Reddish
Summary
The attacker found a Node-RED administrative interface on TCP 1880 with no authentication whatsoever.
Querying the Node-RED REST API exposed an active flow ID, and a follow-up POST deployed a malicious flow containing an exec node wired to an HTTP endpoint, granting immediate root-level command execution inside the Node-RED Docker container.
From that foothold, TCP probes across the Docker bridge subnets revealed two additional internal-only peers: an unauthenticated Redis 4.0.9 instance and an Apache/PHP container that shared a data volume with Redis.
Redis's CONFIG SET and SAVE primitives were used over a raw TCP tunnel to write a PHP webshell directly into the shared web-container document root, yielding a second foothold as www-data.
A root-owned backup cron running rsync over a www-data-writable staging directory was then identified; staging filenames that began with '-e sh' caused rsync's wildcard expansion to treat those filenames as command-line options, executing an attacker-controlled script as root inside the web container.
Finally, an rsync daemon module on the backup host exposed the underlying host's filesystem — including /etc/cron.d — to the container network with write access.
Pushing a malicious root cron entry via that module caused the host to copy root.txt to a web-readable path, completing full host compromise.
Attack path — how the box was taken
Port scan revealed a single exposed service: Node-RED on TCP 1880, then Discovered unauthenticated Node-RED Admin API and retrieved the active flow ID, then Deployed a malicious Node-RED flow for persistent root-in-container command execution, then Enumerated internal Docker networks and discovered Redis and Apache/PHP peers, then Abused unauthenticated Redis to write a PHP webshell into the web container's document root, then Exploited rsync wildcard option injection in a root backup cron to execute code as root, then Wrote a root cron entry to the host via an exposed rsync daemon module, capturing root.txt.
Exact commands 2
nmap -sV -sC -p- --min-rate 5000 -oA reddish_full $TARGETcurl -sS http://$TARGET:1880/Exact commands 2
curl -sS -X POST http://$TARGET:1880/ -H 'Content-Type: application/json' -d '{}'curl -sS http://$TARGET:1880/flowsAttack patterns used
The transferable techniques behind the compromise.
Unauthenticated API enumeration / information disclosureEnumerationT1592
What it is
Node-RED's Admin HTTP API was exposed with no credentials required. A POST to the root endpoint and a GET to /flows both returned flow data immediately, yielding the active flow ID [REDACTED: sensitive value]. This ID gates all further API calls to deploy or modify flows.
Why it works
Enable the built-in adminAuth block in settings.js (type: 'credentials', users: [{...hashed password...}]) or place a reverse proxy enforcing HTTP Basic or Bearer token authentication in front of port 1880. Never run the Node-RED runtime as root; use a dedicated low-privilege service account. In production, disable the editor UI entirely and expose only the runtime HTTP nodes the application requires. If internet exposure is not needed, bind Node-RED to 127.0.0.1 and access it only via a management VPN.
Unauthenticated Redis CONFIG SET / SAVE arbitrary file writeLateral MovementT1505.003
What it is
Redis 4.0.9 at <retired-internal-ip>:6379 required no password. Redis's CONFIG SET commands allow changing the RDB save directory and filename at runtime. Because the Redis container and the Apache/PHP container shared a data volume mounted at /var/www/html/[REDACTED: sensitive value]/, redirecting the RDB save path there and saving a key whose value was a PHP webshell caused Redis's dump file to contain executable PHP. Requesting the file via Apache returned command execution as www-data.
Why it works
Set a strong requirepass in redis.conf (Redis 6+ preferably with ACL users). Disable dangerous administrative commands that are not operationally needed by renaming them to empty strings in redis.conf: CONFIG, SAVE, DEBUG, SLAVEOF, REPLICAOF. Never mount a Redis data directory and an Apache document root to the same volume. Bind Redis to a dedicated backend Docker network that does not include the web container, and enforce network-level isolation in docker-compose or Docker firewall rules.
Rsync wildcard argument injection via root cronPrivilege EscalationT1053.003
What it is
From the www-data foothold on the Apache/PHP container, cron inspection revealed a root-owned rsync backup job that read all files from the www-data-writable staging directory /var/www/html/[REDACTED: sensitive value]/ using a shell glob (*). Because rsync expands wildcards before parsing arguments, creating a file literally named '-e sh proof.rdb' alongside an executable script 'proof.rdb' caused rsync — running as root — to interpret the filename as options: '-e sh' (use sh as the remote-shell executor) applied to 'proof.rdb', executing that script as root. Proof of root execution was confirmed by finding /var/tmp/rsync_wildcard_proof written after one cron interval.
Why it works
Replace the shell glob with an explicit, hardcoded source path or a manifest file that web-facing accounts cannot modify. Pass '--' before the source path in the rsync command to prevent option injection (rsync [options] -- /exact/source/ /dest/). Run the backup cron as a dedicated non-root backup service account and ensure the rsync binary is not SUID. Apply AppArmor or SELinux confinement to the rsync process so that even if arguments are injected, the resulting shell cannot write to sensitive paths.
Rsync daemon module unauthenticated write → host filesystem arbitrary file writePrivilege EscalationT1105
What it is
The same backup infrastructure exposed an rsync daemon module named 'backup' reachable at <retired-internal-ip> from the web container. This module mapped the host's filesystem including /etc/cron.d, /root/, and /backup/. With unauthenticated write access, a malicious cron entry was pushed to the host's /etc/cron.d/ via rsync. When the host's cron daemon processed it, root copied /root/root.txt into a www-data-readable path inside the web container, from which the flag was retrieved.
Why it works
Disable the rsync daemon entirely if it is not operationally required. If it must run: restrict each module with 'hosts allow' to specific, trusted management IPs only; set 'read-only mode' for all modules that do not require inbound writes; add explicit 'exclude = /etc/cron* /root /home' entries to every module definition; run rsyncd as a dedicated unprivileged uid (not root). Use Docker network segmentation (separate bridge networks, explicit deny rules) so that backup containers cannot reach production web or database containers.
Findings
Exposed services
| External surface | A full TCP port scan of <retired-instance-ip> returned exactly one open port: 1880/tcp, running a Node.js/Express application identified as Node-RED. No SSH, HTTP on 80/443, or any other service was reachable from the internet, focusing the entire attack surface on the Node-RED admin interface. |