{
	"id": "4123bd4b-e7f5-45f6-9eb4-4d2d96672da5",
	"created_at": "2026-04-06T00:21:18.539018Z",
	"updated_at": "2026-04-10T03:37:09.145784Z",
	"deleted_at": null,
	"sha1_hash": "786731141f8b591b0d998cb2c77b02b26b325ba2",
	"title": "Scavenger Malware Distributed via eslint-config-prettier NPM Package Supply Chain Compromise",
	"llm_title": "",
	"authors": "",
	"file_creation_date": "0001-01-01T00:00:00Z",
	"file_modification_date": "0001-01-01T00:00:00Z",
	"file_size": 301731,
	"plain_text": "Scavenger Malware Distributed via eslint-config-prettier NPM\r\nPackage Supply Chain Compromise\r\nArchived: 2026-04-05 23:38:34 UTC\r\nOverview\r\nThis blog was written in collaboration with Cedric Brisson. Big thanks to Cedric for staying up throughout the\r\nweekend to complete this analysis with us. Go check out his sister blog here.\r\nOn Friday July 18th, a number of Github users reported a popular NPM package es-lint-config-prettier\r\nhaving releases published despite code changes not being reflected within their Github repository. The maintainer\r\nlater stated that their NPM account had been compromised via a phishing email:\r\nThey then acknowledged that the following NPM packages had been affected:\r\neslint-config-prettier versions: 8.10.1, 9.1.1, 10.1.6, 10.1.7\r\neslint-plugin-prettier versions: 4.2.2, 4.2.3\r\nsnyckit versions: 0.11.9\r\n@pkgr/core versions : 0.2.8\r\nnapi-postinstall versions: 0.3.1\r\nThis blog covers the infection vector used with the compromised package eslint-config-prettier to execute\r\nthe Scavenger Loader on infected systems, an overview of the loader’s functionality and its follow-on Stealer\r\nhttps://invokere.com/posts/2025/07/scavenger-malware-distributed-via-eslint-config-prettier-npm-package-supply-chain-compromise/\r\nPage 1 of 7\n\npayloads.\r\nInfection Vector\r\nThe eslint-config-prettier package shipped a post install script install.js that contains the function\r\nlogDiskSpace() that is executed upon the NPM package’s installation:\r\nThe logDiskSpace function checks if the platform is win32 (Microsoft Windows) and if it is, then it creates a\r\nchild process to execute a shipped DLL node-gyp.dll with rundll32.exe .\r\nScavenger Loader\r\nThe DLL is a loader malware variant written in Microsoft Visual Studio C++ that was compiled on 2025-07-18\r\n08:59:38 (the same day that the malicious package was distributed) and contains the export name loader.dll .\r\nOnce executed with rundll32.exe , the DLL entry point starts a separate thread to execute the core loader\r\nfunctionality. The functionality is largely within a monolithic function that contains a number of anti-analysis\r\ntechniques, including anti-VM detection, antivirus detection, dynamically resolved runtime functions, XOR string\r\ndecryption and hook patching to bypass antivirus and endpoint detection and response (EDR) technologies.\r\nAnti-VM Detection\r\nThe loader attempts to detect if it is within a virtual environment by calling GetSystemFirmwareTable with the\r\nFirmwareTableProviderSignature set to RSMB to retrieve the raw SMBIOS firmware table provider. This\r\nprovider is used to enumerate the SMBIOSTableData for common virtual machine BIOS names, including:\r\nVMware\r\nqemu\r\nQEMU\r\nAnalysis Tool and Antivirus Detection\r\nhttps://invokere.com/posts/2025/07/scavenger-malware-distributed-via-eslint-config-prettier-npm-package-supply-chain-compromise/\r\nPage 2 of 7\n\nThe loader also enumerates its process space for the following DLLs:\r\nsnxhk.dll (Avast’s hook library)\r\nSf2.dll (Avast related)\r\nSxIn.dll (Qihu 360)\r\nSbieDll.dll (Sandboxie)\r\ncmdvrt32.dll (Comodo Antivirus)\r\nwinsdk.dll\r\nwinsrv_x86.dll\r\nHarmony0.dll (likely related to the lib.harmony patching project)\r\nDumper.dll (likely related to memory dumping)\r\nvehdebug-x86_64.dll (CheatEngine related)\r\nIn addition, Scavenger Loader will attempt to identify userland hooks (commonly set in place by Anti-virus and\r\nEDR to track API calls) for the functions IsDebuggerPresent and NtClose by checking that the first byte of\r\neach function for the value 0x4c (the expected value of a non-hooked function).\r\nOther Anti-Analysis Checks\r\nThe number of processors is identified by acquiring the BASIC_SYSTEM_INFORMATION structure from\r\nNtQuerySystemInformation and checking the NumberOfProcessors member to ensure the number of\r\nprocessors is above 3\r\nChecks if it can use WriteConsoleW to determine if it’s being ran in a console by writing “0 bytes” and\r\nchecking the success status\r\nChecks if the %TEMP%\\SCVNGR_VM directory already exists (if the malware is already present on the\r\nmachine)\r\nIf any of these checks succeed, then the loader will purposefully cause a null-pointer exception that will cause the\r\nloader to crash.\r\nFunction Hash Resolution, Hook Identification \u0026 Unhooking\r\nIn addition to the anti-analysis functionality described above, the sample makes use of dynamic function\r\nresolution with CRC32 and a custom value table. This is used to resolve all functions needed for the sample to\r\nexecute at runtime. Interestingly, each function is resolved every time that it is needed, unlike typical malware\r\nfunctionality that will resolve a function table at the beginning of its execution to be used throughout its execution.\r\nScavenger Loader will perform indirect syscalls for the following functions:\r\nNtSetInformationThread : Used to set ThreadHideFromDebugger\r\nNtQuerySystemInformation : Gather information on the number of processors (discussed above)\r\nIndirect syscall resolution is done with the following steps:\r\n1. The targeted function is dynamically resolved in ntdll.dll\r\nhttps://invokere.com/posts/2025/07/scavenger-malware-distributed-via-eslint-config-prettier-npm-package-supply-chain-compromise/\r\nPage 3 of 7\n\n2. A new allocation is made with NtAllocateVirtualMemory . The first offset of the new allocation is set to\r\nmov r10, rcx\r\n3. The original bytes are copied starting at the second offset within the newly allocated buffer up to the\r\nsyscall instruction to acquire its syscall number\r\n4. The function is finalized by writing the syscall and retn instructions to the buffer\r\nThe resulting buffer is then used to call each respective syscall resolved in this manner.\r\nString Decryption\r\nAll strings are protected with the https://github.com/JustasMasiulis/xorstr/tree/master project, that performs\r\ncompile-time XOR-encryption of strings embedded within the malware binary. This results in string constants\r\nbeing replaced with XOR decryption routines where strings are needed throughout the binary. The following\r\nBinary Ninja script can be used to decrypt 64-bit binary strings obfuscated with this project:\r\nimport struct\r\nimport binaryninja\r\nimport sys\r\nimport json\r\n# Let's capture each assignment instruction\r\ndef match_LowLevelIL_18002def6_0(insn):\r\n # rax = 0x17662843e35b915e\r\n if insn.operation != binaryninja.LowLevelILOperation.LLIL_SET_REG:\r\n return False\r\n if insn.dest.name != 'rax':\r\n return False\r\n # 0x17662843e35b915e\r\n if insn.src.operation != binaryninja.LowLevelILOperation.LLIL_CONST:\r\n return False\r\n return True\r\nbinary = sys.argv[1]\r\n# The obfuscator makes these functions huge, so we need to adjust the defaults and only do basic analysis to get\r\nbv = binaryninja.load(binary, options={'analysis.mode': 'basic', 'analysis.limits.maxFunctionSize': 100000000,\r\nbb_start = set()\r\n# Here we capture each assignment from each basic block\r\n# that meets our criteria. Limit each \"stack' to a basic block\r\nfor func in bv.functions:\r\n #print(f\"Function: {hex(func.start)}\")\r\n for bb in func.llil:\r\n for instr in bb:\r\nhttps://invokere.com/posts/2025/07/scavenger-malware-distributed-via-eslint-config-prettier-npm-package-supply-chain-compromise/\r\nPage 4 of 7\n\nif match_LowLevelIL_18002def6_0(instr):\r\n bb_start.add(instr.il_basic_block[0].address)\r\n# We then filter each \"stack\" to use only those with high\r\n# amount of assignments (encrypted strings)\r\nhigh_assigns = []\r\nstack = []\r\nfor start in bb_start:\r\n stack = []\r\n for cbb in bv.get_functions_containing(start)[0].llil:\r\n if cbb[0].address == start:\r\n for instr in cbb:\r\n if match_LowLevelIL_18002def6_0(instr):\r\n stack.append(instr)\r\n if len(stack) \u003e= 4:\r\n high_assigns.append(stack)\r\nresult = {}\r\nfor stack in high_assigns:\r\n # Each captured stack is effectively made up of one half of keys\r\n # and the other half of ciphertext. So we just need to iterate\r\n # over each half respectively to cover each string.\r\n slen = len(stack)//2\r\n rqs = []\r\n cts = stack[:slen]\r\n keys = stack[slen:]\r\n for i, ct in enumerate(cts):\r\n rqs.append(ct.src.constant ^ keys[i].src.constant)\r\n print(f\"Result for: {stack[0].address:2x}: {(struct.pack('Q'*len(rqs), *rqs)).decode('ascii')}\")\r\n result[hex(stack[0].address)] = (struct.pack('Q'*len(rqs), *rqs)).decode('ascii').split(\"\\x00\")[0]\r\nf = open(f'{sys.argv[1]}.json', 'w')\r\nf.write(json.dumps(result))\r\nf.close()\r\nLoader Functionality\r\nOnce all anti-analysis checks have passed, the loader will perform an HTTP GET request to a set of hard-coded\r\nC2 addresses with the C++ libcurl library in the following URL format: https:{C2 Domain}/c/k2/ . If any of the\r\nrequests fail, it will continue to the next C2 domain within its list until it receives a valid response. The response is\r\nexpected to be a Base64-encoded key that is decoded and appended to a hard-coded value N63r2SLz to create a\r\nsession key that is used to encrypt and decrypt command-and-control (C2) communications with the XXTEA\r\nblock cipher.\r\nhttps://invokere.com/posts/2025/07/scavenger-malware-distributed-via-eslint-config-prettier-npm-package-supply-chain-compromise/\r\nPage 5 of 7\n\nThe loader then proceeds to make a second C2 request in the format of https[:]//{C2 Domain}/c/v?v={Pseudo-Random Value} which the C2 provides a response to containing the provided value encrypted with the given\r\nsession key. The value is then decrypted by the loader using XXTEA and checked to match the provided value. If\r\nthey do not match, then the loader performs the same crash mechanism used during the anti-analysis checks.\r\nThe loader then performs a GET request in the format: https[:]/{C2 Domain}/pl?=-\u0026t={Epoch Time Integer}\u0026s=\r\n{XXTEA Encrypted Epoch Time Integer} . The C2 responds with a Base64-encoded XXTEA encrypted blob that,\r\nonce decrypted, provides the following JSON:\r\n[{\"enabled\": true, \"identifier\": \"shiny\", \"drop_name\": \"version.dll\", \"next_to_match\":\r\n\"notification_helper.exe\", \"next_to_extra_nav\": \"\\\\\\\\..\\\\\\\\..\\\\\\\\\", \"next_to_extra_nav_confirmation\":\r\n\"anifest.xml\"}, {\"enabled\": true, \"identifier\": \"electric\", \"drop_name\": \"umpdc.dll\", \"next_to_match\":\r\n\"electrum\\\\\\\\servers.json\", \"next_to_extra_nav\": \"\\\\\\\\..\\\\\\\\..\\\\\\\\\", \"next_to_extra_nav_confirmation\":\r\n\"\"}, {\"enabled\": true, \"identifier\": \"exodus\", \"drop_name\": \"profapi.dll\", \"next_to_match\":\r\n\"\\\\\\\\Exodus.exe\", \"next_to_extra_nav\": \"\\\\\\\\..\", \"next_to_extra_nav_confirmation\":\r\n\"v8_context_snapshot.bin\"}]\r\nThis JSON configuration provides various payload options that are likely selected based on the system\r\nenvironment that the loader has infected. From what we’ve observed thus far, each payload is a stealer module\r\nthat will collect information based on the environment in which it is executing. The loader can then download a\r\nmodule using the following URL format: https://{C2 Domain}/pdl?p={Identifier Name}\u0026t={Epoch Time\r\nInteger}\u0026s={XXTEA Encrypted Epoch Time Integer} . This results in a module encrypted with the XOR key\r\nFuckOff that is written to %TEMP% with a filename generated with GetTempFileNameA . The temporary file is\r\nthen read into memory, decrypted using the hard-coded XOR key FuckOff , and is written to its respective\r\nconfigured location for DLL side loading, or execution by a third-party application.\r\nStealer Functionality\r\nOnce the loader functionality is complete, Scavenger Loader will also read the npmrc file from a user’s system\r\nand send this to the C2 server in a GET request with the format: https:{C2 Domain}/c/a?={npmrc Base64-\r\nEncoded XXTEA Encrypted Data} . These configuration files often contain authentication tokens that can lead to\r\nfurther compromises.\r\nWe were told about related activity (thanks @struppigel) that involved a supply chain attack of a game mod\r\nhttps://lemonyte.com/blog/beamng-malware. This indicates that similar supply chain compromise activity is being\r\nused to distribute Scavenger payloads.\r\nFollow-Up Work\r\nDue to the timeliness of this event and its high impact (this package has 30M+ weekly downloads) we wanted to\r\nget this information to the public as quickly as possible. Due to this, we will be conducting additional analysis of\r\nall of these campaign components, including the delivered stealer modules.\r\nIndicators of Compromise\r\nhttps://invokere.com/posts/2025/07/scavenger-malware-distributed-via-eslint-config-prettier-npm-package-supply-chain-compromise/\r\nPage 6 of 7\n\nAll samples and C2 URLs related to Scavenger Loader and stealer modules can be found here:\r\nhttps://github.com/Invoke-RE/community-malware-research/blob/main/Research/Loaders/Scavenger/IOCs.md\r\nSpecial Thanks\r\nCedric Brisson\r\nMyrtus0x0 for assistance with network analysis\r\nHexamine22 for assistance in identifying the C++ string obfuscator\r\nSource: https://invokere.com/posts/2025/07/scavenger-malware-distributed-via-eslint-config-prettier-npm-package-supply-chain-compromise/\r\nhttps://invokere.com/posts/2025/07/scavenger-malware-distributed-via-eslint-config-prettier-npm-package-supply-chain-compromise/\r\nPage 7 of 7",
	"extraction_quality": 1,
	"language": "EN",
	"sources": [
		"Malpedia"
	],
	"references": [
		"https://invokere.com/posts/2025/07/scavenger-malware-distributed-via-eslint-config-prettier-npm-package-supply-chain-compromise/"
	],
	"report_names": [
		"scavenger-malware-distributed-via-eslint-config-prettier-npm-package-supply-chain-compromise"
	],
	"threat_actors": [
		{
			"id": "8941e146-3e7f-4b4e-9b66-c2da052ee6df",
			"created_at": "2023-01-06T13:46:38.402513Z",
			"updated_at": "2026-04-10T02:00:02.959797Z",
			"deleted_at": null,
			"main_name": "Sandworm",
			"aliases": [
				"IRIDIUM",
				"Blue Echidna",
				"VOODOO BEAR",
				"FROZENBARENTS",
				"UAC-0113",
				"Seashell Blizzard",
				"UAC-0082",
				"APT44",
				"Quedagh",
				"TEMP.Noble",
				"IRON VIKING",
				"G0034",
				"ELECTRUM",
				"TeleBots"
			],
			"source_name": "MISPGALAXY:Sandworm",
			"tools": [],
			"source_id": "MISPGALAXY",
			"reports": null
		},
		{
			"id": "3a0be4ff-9074-4efd-98e4-47c6a62b14ad",
			"created_at": "2022-10-25T16:07:23.590051Z",
			"updated_at": "2026-04-10T02:00:04.679488Z",
			"deleted_at": null,
			"main_name": "Energetic Bear",
			"aliases": [
				"ATK 6",
				"Blue Kraken",
				"Crouching Yeti",
				"Dragonfly",
				"Electrum",
				"Energetic Bear",
				"G0035",
				"Ghost Blizzard",
				"Group 24",
				"ITG15",
				"Iron Liberty",
				"Koala Team",
				"TG-4192"
			],
			"source_name": "ETDA:Energetic Bear",
			"tools": [
				"Backdoor.Oldrea",
				"CRASHOVERRIDE",
				"Commix",
				"CrackMapExec",
				"CrashOverride",
				"Dirsearch",
				"Dorshel",
				"Fertger",
				"Fuerboos",
				"Goodor",
				"Havex",
				"Havex RAT",
				"Hello EK",
				"Heriplor",
				"Impacket",
				"Industroyer",
				"Karagany",
				"Karagny",
				"LightsOut 2.0",
				"LightsOut EK",
				"Listrix",
				"Oldrea",
				"PEACEPIPE",
				"PHPMailer",
				"PsExec",
				"SMBTrap",
				"Subbrute",
				"Sublist3r",
				"Sysmain",
				"Trojan.Karagany",
				"WSO",
				"Webshell by Orb",
				"Win32/Industroyer",
				"Wpscan",
				"nmap",
				"sqlmap",
				"xFrost"
			],
			"source_id": "ETDA",
			"reports": null
		},
		{
			"id": "a66438a8-ebf6-4397-9ad5-ed07f93330aa",
			"created_at": "2022-10-25T16:47:55.919702Z",
			"updated_at": "2026-04-10T02:00:03.618194Z",
			"deleted_at": null,
			"main_name": "IRON VIKING",
			"aliases": [
				"APT44 ",
				"ATK14 ",
				"BlackEnergy Group",
				"Blue Echidna ",
				"CTG-7263 ",
				"ELECTRUM ",
				"FROZENBARENTS ",
				"Hades/OlympicDestroyer ",
				"IRIDIUM ",
				"Qudedagh ",
				"Sandworm Team ",
				"Seashell Blizzard ",
				"TEMP.Noble ",
				"Telebots ",
				"Voodoo Bear "
			],
			"source_name": "Secureworks:IRON VIKING",
			"tools": [
				"BadRabbit",
				"BlackEnergy",
				"GCat",
				"NotPetya",
				"PSCrypt",
				"TeleBot",
				"TeleDoor",
				"xData"
			],
			"source_id": "Secureworks",
			"reports": null
		},
		{
			"id": "b3e954e8-8bbb-46f3-84de-d6f12dc7e1a6",
			"created_at": "2022-10-25T15:50:23.339976Z",
			"updated_at": "2026-04-10T02:00:05.27483Z",
			"deleted_at": null,
			"main_name": "Sandworm Team",
			"aliases": [
				"Sandworm Team",
				"ELECTRUM",
				"Telebots",
				"IRON VIKING",
				"BlackEnergy (Group)",
				"Quedagh",
				"Voodoo Bear",
				"IRIDIUM",
				"Seashell Blizzard",
				"FROZENBARENTS",
				"APT44"
			],
			"source_name": "MITRE:Sandworm Team",
			"tools": [
				"Bad Rabbit",
				"Mimikatz",
				"Exaramel for Linux",
				"Exaramel for Windows",
				"GreyEnergy",
				"PsExec",
				"Prestige",
				"P.A.S. Webshell",
				"AcidPour",
				"VPNFilter",
				"Neo-reGeorg",
				"Cyclops Blink",
				"SDelete",
				"Kapeka",
				"AcidRain",
				"Industroyer",
				"Industroyer2",
				"BlackEnergy",
				"Cobalt Strike",
				"NotPetya",
				"KillDisk",
				"PoshC2",
				"Impacket",
				"Invoke-PSImage",
				"Olympic Destroyer"
			],
			"source_id": "MITRE",
			"reports": null
		}
	],
	"ts_created_at": 1775434878,
	"ts_updated_at": 1775792229,
	"ts_creation_date": 0,
	"ts_modification_date": 0,
	"files": {
		"pdf": "https://archive.orkl.eu/786731141f8b591b0d998cb2c77b02b26b325ba2.pdf",
		"text": "https://archive.orkl.eu/786731141f8b591b0d998cb2c77b02b26b325ba2.txt",
		"img": "https://archive.orkl.eu/786731141f8b591b0d998cb2c77b02b26b325ba2.jpg"
	}
}