Did the Payload Actually Run? Host Forensics After a Bad Dependency
Euael Eshete ยท August 10, 2026
Part 2 of a three-part series on investigating and recovering from a software supply-chain compromise. Part 1 covered finding the compromised dependency. This part covers the machine that ran it.
TL;DR
- Finding a malicious package answers what could have happened. It does not answer whether it did.
- No single host check is conclusive. Read the live process check, the persistence check, the connection check, and the file timeline together.
- An empty antivirus result is not a clean bill of health. It means one engine did not flag one technique.
- Fix the dependency by assuming more than one hook exists, then do a full clean reinstall. Editing around the problem leaves the lockfile poisoned.
- Write the finding down in the repository, not in a chat thread. Record what remains owed outside the repository so it does not get lost.
What you need to determine
Part 1 covered how to find a compromised dependency. This part covers the machine that ran it.
Most dependency payloads are downloaders. For one to have mattered, several things had to go the attacker way. The code had to execute, which usually needs nothing more than an import. Any network call it made had to succeed. Whatever it fetched had to run without an error.
If any one of those failed, the payload did nothing beyond an outbound request that went nowhere. Dead command-and-control infrastructure, a firewall rule, or a bug in the attacker code all produce the same result.
Does a matching process run right now
Linux and macOS:
ps -eo pid,ppid,lstart,args | grep -i node
Windows (PowerShell):
Get-CimInstance Win32_Process -Filter "Name='node.exe'" |
Select-Object ProcessId, ParentProcessId, CreationDate, CommandLine |
Format-List
Use Get-CimInstance Win32_Process rather than Get-Process. Get-Process hides the full command line. The command line is what separates a normal tool call from a bare node -e "<inline script>". An inline call has no readable file path, because no file exists to point at.
Expect legitimate noise in both cases. Dev tooling, language servers, and background daemons all appear here. Read every line and look for the one you cannot account for. Inline scripts and anything running out of a temp directory deserve the most attention.
Persistence
A one-shot payload that already ran and exited leaves nothing for a live process check. Persistence entries are the next place to look.
Linux:
crontab -l ; sudo crontab -l
systemctl list-units --type=service --state=running
ls -la ~/.config/autostart /etc/xdg/autostart 2>/dev/null
macOS:
launchctl list | grep -v com.apple
ls -la ~/Library/LaunchAgents /Library/LaunchAgents /Library/LaunchDaemons
Windows:
# Scheduled tasks, minus the built-in OS namespace
Get-ScheduledTask | Where-Object { $_.TaskPath -notlike "\Microsoft\*" } |
Select-Object TaskName, TaskPath, State
# Registry Run keys
Get-ItemProperty 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Run' -ErrorAction SilentlyContinue
Get-ItemProperty 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Run' -ErrorAction SilentlyContinue
# Startup folders, per-user and all-users
Get-ChildItem "$env:APPDATA\Microsoft\Windows\Start Menu\Programs\Startup"
Get-ChildItem "$env:ProgramData\Microsoft\Windows\Start Menu\Programs\Startup"
HKCU persists for one user. HKLM requires elevated access to write at all. An unfamiliar HKLM entry is therefore the stronger signal.
Open outbound connections
This only catches connections that are open right now. A payload that already made its callback and exited will not appear. The check costs seconds, and it occasionally catches something in flight.
Linux and macOS:
lsof -iTCP -sTCP:ESTABLISHED -P | grep -E ":80|:443"
Windows:
Get-NetTCPConnection -State Established |
Where-Object { $_.RemotePort -eq 80 -or $_.RemotePort -eq 443 } |
ForEach-Object {
$p = Get-Process -Id $_.OwningProcess -ErrorAction SilentlyContinue
[PSCustomObject]@{ Remote = "$($_.RemoteAddress):$($_.RemotePort)"; Proc = $p.ProcessName; Path = $p.Path }
} | Sort-Object Proc -Unique
What your endpoint agent already knows
Windows Defender:
Get-MpThreatDetection | Select-Object InitialDetectionTime, ThreatID, Resources
Get-WinEvent -FilterHashtable @{
LogName='Microsoft-Windows-Windows Defender/Operational'
StartTime=(Get-Date "<exposure-window-start>")
} | Where-Object { $_.Id -in 1006,1007,1008,1009,1116,1117 }
An empty result here is not a clean bill of health. It means the heuristics in one engine did not flag anything. Signature and heuristic detection misses narrow, novel techniques. The blockchain command-and-control addressing from Part 1 is one example. Treat this as one input, not the verdict.
Execution history and dropped files
Linux and macOS:
find /tmp /var/tmp "$HOME" -maxdepth 3 -newermt "<window-start>" ! -newermt "<window-end>" -type f 2>/dev/null
Windows:
# Per-executable run history
Get-ChildItem "C:\Windows\Prefetch\NODE.EXE*" | Select-Object Name, LastWriteTime, CreationTime
# New files in common drop locations, scoped to the exposure window
$paths = @("$env:TEMP", "$env:APPDATA", "$env:LOCALAPPDATA\Temp")
foreach ($p in $paths) {
Get-ChildItem $p -File -ErrorAction SilentlyContinue |
Where-Object { $_.CreationTime -gt (Get-Date "<window-start>") -and $_.CreationTime -lt (Get-Date "<window-end>") } |
Select-Object FullName, CreationTime, Length
}
Expect heavy legitimate noise. Browsers and build tools create and delete short-lived temp files constantly. Look for three things: executable extensions, names that echo the payload source, and files in directories they have no reason to occupy.
Reading the results together
No check above is conclusive on its own. A clean sweep across all of them reassures rather than settles the question. A detached process that already exited leaves almost nothing behind. Neither does a second stage that removed its own traces.
One check comes close to conclusive: a current antivirus or EDR scan with full static and behavioral analysis. Treat everything above as triage. It tells you how urgent that scan is. It does not replace the scan.
Fix the dependency properly
The most important habit in this whole series sits here.
Assume more than one hook exists, and go looking. Do not fix the first one and declare victory. Attackers wire a compromised dependency in redundantly on purpose. A resolution override, plus a direct dependency entry, plus a build config that imports it explicitly. Removing any single one leaves the attack intact.
Grep the whole repository, not the file where you first noticed the problem:
grep -rn "<malicious-package-name>" --include=*.json --include=*.ts --include=*.js --include=*.mjs .
Then work in this order:
-
Remove every hook. That means the resolution override, every direct dependency entry in every manifest, and any config file that exists to import the bad package. Some config files exist purely to smuggle in that import. If the feature such a file claims to add was never used, delete the file rather than repair it.
-
Do a full clean reinstall. Delete the installed tree and the lockfile. Do not edit around them:
rm -rf node_modules package-lock.json npm install -
Verify the fix took. Do not assume the edits worked:
npm ls <real-package-name> # normal version, no alias syntax grep -rn "<malicious-package-name>" . # nothing left, anywhere -
Confirm nothing broke. Run the normal build and test path. A security fix that quietly breaks the build creates a second, harder problem.
-
Write down what you found. Put the record in the repository, not in a chat thread. Record the finding, its location, the exposure window, and the checks that set the scope. Record what you still owe outside the repository. That last item is the one that gets lost.
The short version
A fixed dependency and a quiet host answer one question: is this project safe to keep using. They say nothing about what that machine could reach. Other servers, other accounts, other tokens. That is Part 3.
The series
- Part 1: Finding a Compromised Dependency
- Did the Payload Actually Run? (this post)
- Part 3: How Far Did It Reach?