<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Rigalis Blog</title><description>Binary notes, reverse engineering, and design explorations.</description><link>https://rigalis.me</link><item><title>Shaders, a Signing Block, and a Self-Modifying VM [z0d1ak CTF]</title><link>https://rigalis.me/blog/z0d1ak-hadopelagic-vmception</link><guid isPermaLink="true">https://rigalis.me/blog/z0d1ak-hadopelagic-vmception</guid><description>Finding a 32-byte bearing hidden behind shader math and debugger traps, then cracking a self-modifying license VM by hand.</description><pubDate>Mon, 14 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Got into z0d1ak CTF with THEM?! and idktheflag, really fun rev, even better theme, here are my solves for Hadopelagic &amp;amp; VMception.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Part 1: Hadopelagic&lt;/h2&gt;
&lt;blockquote&gt;
&lt;p&gt;&amp;quot;Two currents share one trench. One charts the reef; the other listens below the light.&amp;quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;This was one of the more interesting rev challenges.&lt;/p&gt;
&lt;p&gt;End state, for anyone who wants the punchline before the plot:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;bearing: ce3b4a41ecc4aec9e2216145af5630072da19dfbe3b0488cfbfc20d816f3016c
flag:    zdk{bELOw_the_THerMOClIn3_two_SHADER_CURrENts_ConVERgE}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;First look&lt;/h3&gt;
&lt;p&gt;This was a personalized challenge, everyone got their own APK, and the job was to find your own 32-byte &amp;quot;bearing.&amp;quot; Unzip it and you get:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;AndroidManifest.xml
resources.arsc
classes.dex
assets/reef.glsl
assets/trench.spv
lib/x86_64/libhadopelagic.so
META-INF/HADAL.SF
META-INF/HADAL.RSA
META-INF/MANIFEST.MF
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The flavor text lines up nicely with what&amp;#39;s actually in there: two &amp;quot;currents,&amp;quot; a GLSL vertex shader and a SPIR-V Vulkan compute shader, held together by a native lib, with a signing block that turns out to matter a lot more than usual.&lt;/p&gt;
&lt;p&gt;Cracked open the DEX with jadx and found the entry point:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;// ctf.hadopelagic.MainActivity
private static native byte[] nativeProbe(
  String sourceDir, String bearingHex,
  byte[] reefGlsl, byte[] trenchSpv,
  byte[] e16, byte[] certSha256);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;So: 64-hex-char bearing, APK path, both shaders, a mystery 16-byte array, and the signing cert&amp;#39;s SHA-256 in; 115 bytes out. Java slices that into &lt;code&gt;key[32] || nonce[12] || ciphertext[71]&lt;/code&gt; and tries a ChaCha20-Poly1305 decrypt. Get the bearing wrong and the Poly1305 tag just fails, no hints, no partial credit. The whole challenge is finding the one bearing that makes the key come out right.&lt;/p&gt;
&lt;p&gt;The static array turned out to be:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;e = {-111,-25,106,47,-60,3,-67,88,125,18,-24,-87,69,54,-53,112}
// = 91 e7 6a 2f c4 03 bd 58 7d 12 e8 a9 45 36 cb 70
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;The two currents, up close&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;reef.glsl&lt;/code&gt; sets a flat &lt;code&gt;uvec4&lt;/code&gt; output per vertex ID, 16 vertices deep:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-glsl&quot;&gt;#version 300 es
precision highp int;
flat out uvec4 reefWords;
void main() {
    if (gl_VertexID == 0) reefWords = uvec4(0x54acade1u, 0x6807f876u, ...);
    ...
    if (gl_VertexID == 15) reefWords = uvec4(0x36bc20b8u, ...);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;16 × uvec4 comes out to 64 words, 256 bytes of lookup table. Called this &lt;code&gt;R[64]&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;trench.spv&lt;/code&gt; disassembles, via &lt;code&gt;spirv-dis&lt;/code&gt;, into something that chews on a 64-word uniform buffer across 8 rounds and rewrites the first 8 words:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;w0&amp;#39; = rotl((w0^s41) + (w11^0x6D2B79F5),5) ^ rotl(w13+s43,7)
w1&amp;#39; = rotl((w1^s42) + (w18^0xDA56F3EA),9) ^ rotl(w26+s44,11)
...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Initially I assumed the SpecId numbers in the decoration list were basically array indices into some spec constant table, wired that up, ran it, and got values that didn&amp;#39;t match anything. After checking ARX math, I reread how SPIR-V spec constants work. They use result ID, not SpecId, as index. Each one is tied to a &lt;em&gt;result ID&lt;/em&gt;, and the SpecId is attached separately through its own decoration instruction:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;OpDecorate %34 SpecId 41
OpDecorate %60 SpecId 42
OpDecorate %49 SpecId 43
OpDecorate %75 SpecId 44
OpDecorate %100 SpecId 45
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Once I parsed the decorations first and built a proper result ID to spec value map, everything clicked into place. I had actually pasted the SPIR-V disassembly into an AI chat and asked why my SpecId lookup was off - it pointed out that SpecId is a decoration on the result ID, not an array index, which saved me another hour of staring at the spec. Wasted an evening on that, but it&amp;#39;s the kind of mistake you only make once.&lt;/p&gt;
&lt;h3&gt;Trying to just read the native lib, and giving up on that plan&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;libhadopelagic.so&lt;/code&gt; is stripped x86_64, built with NDK r27b, linking against &lt;code&gt;libandroid.so&lt;/code&gt;, &lt;code&gt;libEGL.so&lt;/code&gt;, &lt;code&gt;libGLESv3.so&lt;/code&gt;, &lt;code&gt;libvulkan.so&lt;/code&gt;, plus the usual libc/libm/libdl. I started hand-decompiling &lt;code&gt;nativeProbe&lt;/code&gt; in a disassembler. After ~1KB of ~3KB of stripped code, with the GLES and Vulkan setup still untouched, I gave up on reading it.&lt;/p&gt;
&lt;p&gt;Switched to dynamic execution: run it natively and observe.&lt;/p&gt;
&lt;p&gt;Blocking issues:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Android&amp;#39;s libc exports symbol versions (&lt;code&gt;LIBC&lt;/code&gt;, &lt;code&gt;LIBC_Q&lt;/code&gt;) that glibc has never heard of. First attempt to just &lt;code&gt;dlopen&lt;/code&gt; the library failed immediately on a missing version node.&lt;/li&gt;
&lt;li&gt;No EGL, no GLESv3, no Vulkan, no Android runtime anywhere on my system.&lt;/li&gt;
&lt;li&gt;No JVM, so no JNI environment for a function that expects to be called from one.&lt;/li&gt;
&lt;li&gt;There&amp;#39;s no actual GPU work being verified here anyway, the shaders are just being used as opaque math boxes.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Fixed iteratively:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Patched the ELF&amp;#39;s &lt;code&gt;.gnu.version&lt;/code&gt; section to mark every symbol as global and zeroed &lt;code&gt;DT_VERNEEDNUM&lt;/code&gt;, so the dynamic linker stops looking for version tags that don&amp;#39;t exist on my system. First attempt only patched part of the version table and the linker still choked on one symbol, so I redid it exhaustively.&lt;/li&gt;
&lt;li&gt;Wrote a stub &lt;code&gt;libc.so&lt;/code&gt; exporting just the handful of missing symbols the binary actually calls (&lt;code&gt;__errno&lt;/code&gt;, &lt;code&gt;__sF&lt;/code&gt;, &lt;code&gt;__assert2&lt;/code&gt;, &lt;code&gt;android_set_abort_message&lt;/code&gt;, &lt;code&gt;android_get_device_api_level&lt;/code&gt;). Missed &lt;code&gt;android_get_device_api_level&lt;/code&gt; on the first pass and got an instant crash at load time.&lt;/li&gt;
&lt;li&gt;Wrote stub EGL/GLES that parses the &lt;code&gt;uvec4(...)&lt;/code&gt; lines straight out of the shader source and evaluates them on CPU. No GPU required, none wanted. Drafted the initial stub with AI - prompted it to generate a minimal EGL/GLES shim that just parses the shader source, then hand-edited the parsing regex to handle the uvec4 formatting.&lt;/li&gt;
&lt;li&gt;Wrote a small SPIR-V interpreter, not general purpose, just the opcodes this one shader actually uses.&lt;/li&gt;
&lt;li&gt;Wrote a C harness with a fake 300-entry JNIEnv table and fake jstring/jbyteArray objects, then jumped straight to &lt;code&gt;nativeProbe&lt;/code&gt; from &lt;code&gt;JNI_OnLoad+0xb0&lt;/code&gt;. First version of the JNIEnv table had a few function slots missing, which segfaulted the moment &lt;code&gt;nativeProbe&lt;/code&gt; tried to call something like &lt;code&gt;NewByteArray&lt;/code&gt;. Filled in the rest of the table and it started running clean.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Once running, all buffers and constants were visible in logs.&lt;/p&gt;
&lt;h3&gt;What the harness told me&lt;/h3&gt;
&lt;p&gt;For any bearing, &lt;code&gt;nativeProbe&lt;/code&gt; hands back &lt;code&gt;key[32] || nonce[12] || ct[71]&lt;/code&gt;. A few differential runs, same everything except the bearing, made the shape of the problem obvious:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;nonce&lt;/code&gt; and &lt;code&gt;ct&lt;/code&gt; never change. They&amp;#39;re sitting in the APK&amp;#39;s signing block at offset &lt;code&gt;0x49044&lt;/code&gt;, static across bearings.&lt;/li&gt;
&lt;li&gt;Only &lt;code&gt;key&lt;/code&gt; moves when the bearing moves.&lt;/li&gt;
&lt;li&gt;The spec constants are also bearing-independent:&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;s41=026c2a46 s42=ef568048 s43=1b9bebf3 s44=c4c2e6e4 s45=c3fa5451
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;The Vulkan buffer starts out pre-loaded with the 64 reef words.&lt;/li&gt;
&lt;li&gt;After the compute dispatch, exactly 8 words change. Called this the &amp;quot;wake,&amp;quot; &lt;code&gt;T&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Hand-verified round 1 to make sure my interpreter wasn&amp;#39;t lying to me, and got:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;T = 67a79866 3d335082 f5a9f31f b78cc296
    96369a34 06a7651d f2c433ee 535e127f
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Matched.&lt;/p&gt;
&lt;h3&gt;The actual key derivation&lt;/h3&gt;
&lt;p&gt;Digging into the tail end of &lt;code&gt;nativeProbe&lt;/code&gt; turned up two layers stacked together: a custom 96-round ARX mixer (named &lt;code&gt;cdfc0&lt;/code&gt; after its address, naming things properly takes too long mid-CTF) that transforms the bearing &lt;code&gt;B&lt;/code&gt; into &lt;code&gt;X = F(B)&lt;/code&gt;, and then a hash over everything:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;&amp;quot;hadopelagic/key/v3\0&amp;quot; || B || X || T || SP || cert || e16 || status
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;SHA-256 didn&amp;#39;t match. Checked constants and found the IV word &lt;code&gt;0x6b08e647&lt;/code&gt;, which is distinctly BLAKE2s. I pasted the constants into AI - it suggested BLAKE2s. Sigma table and rotations (16/12/8/7) confirmed it.&lt;/p&gt;
&lt;p&gt;The mixer itself, per round:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;for i in 0..95:
    ebp = golden*(i+1) ^ wake[m1] ^ SP[(3i)&amp;amp;7] ^ e16[i&amp;amp;3]
    ebp = rol(ebp, amt1)
    ebp = (ebp + W[i&amp;amp;7]) ^ W[(5i+3)&amp;amp;7]
    ebp = rol(ebp, amt2)
    W[i&amp;amp;7] = ebp
    ebp = (ebp + W[(i+2)&amp;amp;7])
    ebp = rol(ebp, amt3)
    W[(i&amp;amp;7)^4] ^= ebp
    if (i&amp;amp;7)==7: mix_all_8_words()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &amp;quot;mix all 8 words&amp;quot; step at the end of every 8th round is GF(2) linear with byte doubling (&lt;code&gt;xtime&lt;/code&gt;) folded in, basically a cousin of AES MixColumns. Confirmed it byte by byte against live gdb dumps before trusting it for anything downstream.&lt;/p&gt;
&lt;h3&gt;The anti-debug trap, and why gdb kept lying to me&lt;/h3&gt;
&lt;p&gt;Somewhere in &lt;code&gt;nativeProbe&lt;/code&gt; there&amp;#39;s a check that:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;reads &lt;code&gt;/proc/self/status&lt;/code&gt; looking for a nonzero &lt;code&gt;TracerPid&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;greps &lt;code&gt;/proc/self/maps&lt;/code&gt; for &lt;code&gt;frida&lt;/code&gt;, &lt;code&gt;gum-js&lt;/code&gt;, &lt;code&gt;xposed&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;compares the signing cert hash against an embedded value&lt;/li&gt;
&lt;li&gt;checks that the reported API level is 36&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Each failure XORs a different constant into a running status dword (tracer: &lt;code&gt;0x13579BDF&lt;/code&gt;, cert: &lt;code&gt;0x6D2B79F5&lt;/code&gt;, API level: &lt;code&gt;0x94D049BB&lt;/code&gt;). That status dword then feeds straight into the BLAKE2s input.&lt;/p&gt;
&lt;p&gt;Offline reproduction failed with the same bearing. The difference was TracerPid (debugger attached vs clean). &lt;code&gt;TracerPid&lt;/code&gt; was nonzero under gdb and zero without it, which meant every single dump I&amp;#39;d been trusting as &amp;quot;ground truth&amp;quot; was actually a poisoned run. Model + poisoned status (0x13579BDF, TracerPid≠0 under gdb) reproduced the gdb-era dump (698a980c...); clean runs need status=0. One naming trap that cost me a second round of confusion: cert here is the 32-byte cert SHA-256 passed to nativeProbe (certSha256 in the JNI signature - sha256 of the DER cert, 4e3b9f67...), not the DER file itself. Feeding the DER gives a plausible-looking but wrong key (36ce11c3...); with the hash it reproduces exactly:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import subprocess, hashlib
pem = subprocess.check_output([&amp;#39;openssl&amp;#39;, &amp;#39;pkcs7&amp;#39;, &amp;#39;-inform&amp;#39;, &amp;#39;DER&amp;#39;,
                               &amp;#39;-in&amp;#39;, &amp;#39;META-INF/HADAL.RSA&amp;#39;,
                               &amp;#39;-print_certs&amp;#39;, &amp;#39;-out&amp;#39;, &amp;#39;/dev/stdout&amp;#39;])
der = subprocess.check_output([&amp;#39;openssl&amp;#39;, &amp;#39;x509&amp;#39;, &amp;#39;-outform&amp;#39;, &amp;#39;DER&amp;#39;], input=pem)
certHash = hashlib.sha256(der).digest()  # 4e3b9f67... (32 bytes)
key = hashlib.blake2s(prefix + B + F(B) + T_le + SP_le + certHash + e16 + b&amp;#39;\x00&amp;#39; * 4, digest_size=32).digest()
# T_le/SP_le = LE packs of the printed T/SP words; B/X = LE packs of the bearing/F(B) words; prefix = the 19 bytes hadopelagic/key/v3\x00 from the .so; nonce/ct = APK offsets 0x49044/0x49054.
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Cracking the signing block, then inverting F&lt;/h3&gt;
&lt;p&gt;The APK Signing Block v2 area had a custom attribute sitting right there in the open:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;SP[8] || X_target[32] || nonce[12] || len || ct[71]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;X_target&lt;/code&gt; is &lt;code&gt;F(B*)&lt;/code&gt;, the output of the transform applied to the correct bearing:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;X_target = 124e7fb9 c779c176 9f158e23 8ec85140 818cc0bf 0a22b7b0 37e76daa 1c7d3300  # BE word order; bytes at 0x49024 LE: b97f4e12 76c179c7 238e159f 4051c88e fc08c81b 0b7220aa a6de737 00337d1c - hash uses LE
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;So the challenge just hands you the answer to &lt;code&gt;F(B*)&lt;/code&gt; and dares you to invert &lt;code&gt;F&lt;/code&gt;. Brute force over 32 bytes is infeasible.&lt;/p&gt;
&lt;p&gt;The break: every rotation amount in cdfc0 depends only on round index and fixed tables, never on data, so the whole thing inverts cleanly:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;y = rol((C + x) ^ k, r)&lt;/code&gt; inverts to &lt;code&gt;x = (rotr(y, r) ^ k) - C&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;the linear mix step inverts via a 32x32 matrix inverse over GF(2)&lt;/li&gt;
&lt;li&gt;run the 96 rounds backwards, undoing each step in reverse order&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Wrote Finv, verified it over 200 random roundtrips (after fixing one flipped sub-step order), ran it on X_target:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def build_params():
    P = []
    for i in range(96):
        m1 = (SP[i &amp;amp; 7] + i) &amp;amp; 7
        ebp = (0x9E3779B9 * (i + 1)) &amp;amp; M
        ebp ^= WK[m1]; ebp ^= SP[(3*i) &amp;amp; 7]; ebp ^= E16W[i &amp;amp; 3]
        ebp = rol(ebp, 13*i + h31(13*i) + 1)
        u = ((ebp &amp;gt;&amp;gt; 27) + i) &amp;amp; 0xFF
        a2 = h8(u) &amp;amp; 31
        bval = ((ebp &amp;gt;&amp;gt; 19) + 3*i) &amp;amp; M
        a3 = (h31(bval) + bval + 1) &amp;amp; 31
        P.append((ebp, i &amp;amp; 7, (5*i+3) &amp;amp; 7, (i+2) &amp;amp; 7, (i &amp;amp; 7) ^ 4, a2, a3))
    return P

def _fword(v):
    b0, b1, b2, b3 = v &amp;amp; 0xFF, (v &amp;gt;&amp;gt; 8) &amp;amp; 0xFF, (v &amp;gt;&amp;gt; 16) &amp;amp; 0xFF, (v &amp;gt;&amp;gt; 24) &amp;amp; 0xFF
    d0, d1, d2, d3 = xt(b0), xt(b1), xt(b2), xt(b3)
    return ((b3^d0^d1^b1^b2) | ((b3^b0^b2^d1^d2) &amp;lt;&amp;lt; 8)
            | ((b3^d3^b1^b0^d2) &amp;lt;&amp;lt; 16) | ((d3^d0^b0^b1^b2) &amp;lt;&amp;lt; 24))

def _inv_table():
    cols = [_fword(1 &amp;lt;&amp;lt; k) for k in range(32)]
    basis = {}
    for k in range(32):
        i2, t2 = cols[k], 1 &amp;lt;&amp;lt; k
        while i2:
            hb = i2.bit_length() - 1
            if hb in basis:
                bi, bt = basis[hb]; i2 ^= bi; t2 ^= bt
            else:
                basis[hb] = (i2, t2); break
    inv = []
    for j in range(32):
        i2, t2 = 1 &amp;lt;&amp;lt; j, 0
        while i2:
            hb = i2.bit_length() - 1
            bi, bt = basis[hb]; i2 ^= bi; t2 ^= bt
        inv.append(t2)
    return inv

_INVW = _inv_table()

def inv_mix(W):
    rec = [0]*8
    for j in range(8):
        v, r = W[j], 0
        for b in range(32):
            if (v &amp;gt;&amp;gt; b) &amp;amp; 1:
                r ^= _INVW[b]
        rec[j] = r
    for j in range(8):
        W[IDX[j]] = rec[j]

def Finv(out_words):
    P = build_params()
    W = list(out_words)
    for i in reversed(range(96)):
        if (i &amp;amp; 7) == 7:
            inv_mix(W)
        ebp2, r11, xs, aslot, fx, a2, a3 = P[i]
        W[fx] ^= rol((W[r11] + W[aslot]) &amp;amp; M, a3)
        # NOTE ORDER: rotr(y, a2) ^ W[xs] - not rotr(y ^ W[xs], a2)
        W[r11] = ((ror(W[r11], a2) ^ W[xs]) - ebp2) &amp;amp; M
    return W

B_star = Finv(X_target)
# = 414a3bce c9aec4ec 456121e2 073056af
#   fb9da12d 8c48b0e3 d820fcfb 6c01f316
bearing_hex = b&amp;#39;&amp;#39;.join(struct.pack(&amp;#39;&amp;lt;I&amp;#39;, w) for w in B_star).hex()
# = ce3b4a41ecc4aec9e2216145af5630072da19dfbe3b0488cfbfc20d816f3016c  # LE bytes of BE words
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Landing the plane&lt;/h3&gt;
&lt;p&gt;Fed that bearing through the clean (untraced) harness:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;bearing: ce3b4a41ecc4aec9e2216145af5630072da19dfbe3b0488cfbfc20d816f3016c
key:     27fd0b6ec11195b4bf27f654b4d3ee3a509b22b5155b244b90f1840e96104e1e
flag:    zdk{bELOw_the_THerMOClIn3_two_SHADER_CURrENts_ConVERgE}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Poly1305 tag checks out.&lt;/p&gt;
&lt;h3&gt;The scripts that actually mattered&lt;/h3&gt;
&lt;p&gt;SpecId parsing, since it bit me once:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# parse SpecId decorations first!
rid2spec = {}
# ... for each OpDecorate with decoration==1: rid2spec[result_id] = spec_id
# then OpSpecConstant result gets spec value by rid2spec[rid]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The helper functions and tables (rol, h31, SP, WK, ...) are defined in full in the scripts section below; the snippets above assume them.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;F&lt;/code&gt; model, &lt;code&gt;cdfc0&lt;/code&gt;, core loop (helpers defined, then pseudocode - exact constants precomputed):&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;M = 0xFFFFFFFF
def rol(v, n):
    n &amp;amp;= 31
    return ((v &amp;lt;&amp;lt; n) &amp;amp; M) | (v &amp;gt;&amp;gt; (32 - n)) if n else v &amp;amp; M
def ror(v, n):
    return rol(v, 32 - n)
def h31(x):
    v = x &amp;amp; 0xFFFF
    q = (v * 0x843) &amp;gt;&amp;gt; 16
    t = (x - q) &amp;amp; 0xFFFF
    return ((t &amp;gt;&amp;gt; 1) + q) &amp;gt;&amp;gt; 4
def h8(u):
    q = (u * 9) &amp;gt;&amp;gt; 8
    c = (u - q) &amp;amp; 0xFF; c &amp;gt;&amp;gt;= 1
    c = (c + q) &amp;amp; 0xFF; c &amp;gt;&amp;gt;= 4
    c = (c + u) &amp;amp; 0xFF
    return (c + 1) &amp;amp; 0xFF
def xt(x):
    return ((x &amp;lt;&amp;lt; 1) ^ (0x1B if x &amp;amp; 0x80 else 0)) &amp;amp; 0xFF
IDX = [3, 0, 6, 1, 7, 4, 2, 5]
def mix(W):
    src = list(W)
    for j in range(8):
        v = src[IDX[j]]
        b0, b1, b2, b3 = v &amp;amp; 0xFF, (v &amp;gt;&amp;gt; 8) &amp;amp; 0xFF, (v &amp;gt;&amp;gt; 16) &amp;amp; 0xFF, (v &amp;gt;&amp;gt; 24) &amp;amp; 0xFF
        d0, d1, d2, d3 = xt(b0), xt(b1), xt(b2), xt(b3)
        W[j] = ((b3^d0^d1^b1^b2) | ((b3^b0^b2^d1^d2) &amp;lt;&amp;lt; 8)
                | ((b3^d3^b1^b0^d2) &amp;lt;&amp;lt; 16) | ((d3^d0^b0^b1^b2) &amp;lt;&amp;lt; 24))
SP = [0x026c2a46, 0xef568048, 0x1b9bebf3, 0xc4c2e6e4,
      0xc3fa5451, 0xc877b4fd, 0x128288ac, 0xff275cf3]
E16W = [0x2f6ae791, 0x58bd03c4, 0xa9e8127d, 0x70cb3645]  # e16 as LE words
WK = T_WORDS + R_WORDS[8:]  # 64-word wake buffer; T as printed above (67a79866...), R parsed from reef.glsl uvec4 lines; rounds only read [0..7]

def cdfc0(in_words):
    W = list(in_words)
    for i in range(96):
        m1 = (SP[i &amp;amp; 7] + i) &amp;amp; 7
        ebp = (0x9E3779B9 * (i + 1)) &amp;amp; M
        ebp ^= WK[m1]; ebp ^= SP[(3*i) &amp;amp; 7]; ebp ^= E16W[i &amp;amp; 3]
        ebp = rol(ebp, 13*i + h31(13*i) + 1)
        u = ((ebp &amp;gt;&amp;gt; 27) + i) &amp;amp; 0xFF
        bval = ((ebp &amp;gt;&amp;gt; 19) + 3*i) &amp;amp; M
        ebp = (ebp + W[i &amp;amp; 7]) &amp;amp; M
        ebp ^= W[(5*i + 3) &amp;amp; 7]
        ebp = rol(ebp, h8(u))
        W[i &amp;amp; 7] = ebp
        ebp = (ebp + W[(i+2) &amp;amp; 7]) &amp;amp; M
        ebp = rol(ebp, h31(bval) + bval + 1)
        W[(i &amp;amp; 7) ^ 4] ^= ebp
        if (i &amp;amp; 7) == 7:
            mix(W)
    return W
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And the finish line, invert, rebuild, decrypt:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import struct, hashlib, subprocess
from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305
apk = open(&amp;#39;hadopelagic.apk&amp;#39;, &amp;#39;rb&amp;#39;).read()
lib = open(&amp;#39;libhadopelagic.so&amp;#39;, &amp;#39;rb&amp;#39;).read()
F = cdfc0  # the mixer from the previous section
SPb = apk[0x49004:0x49004 + 32]                       # 8 spec words, LE
X_apk = apk[0x49024:0x49024 + 32]                     # X_target bytes (LE order)
X_target_words = list(struct.unpack(&amp;#39;&amp;lt;8I&amp;#39;, X_apk))
T_words = [0x67a79866, 0x3d335082, 0xf5a9f31f, 0xb78cc296,
           0x96369a34, 0x06a7651d, 0xf2c433ee, 0x535e127f]
T_le = b&amp;#39;&amp;#39;.join(struct.pack(&amp;#39;&amp;lt;I&amp;#39;, w) for w in T_words)
SP_le = SPb
prefix = lib[lib.find(b&amp;#39;hadopelagic/key/v3&amp;#39;):lib.find(b&amp;#39;hadopelagic/key/v3&amp;#39;) + 19]
nonce, ct = apk[0x49044:0x49044 + 12], apk[0x49054:0x49054 + 71]
B_star = Finv(X_target_words)
X = F(B_star)                          # == X_target
B_bytes = b&amp;#39;&amp;#39;.join(struct.pack(&amp;#39;&amp;lt;I&amp;#39;, w) for w in B_star)
X_bytes = b&amp;#39;&amp;#39;.join(struct.pack(&amp;#39;&amp;lt;I&amp;#39;, w) for w in X)
e16 = bytes([0x91, 0xE7, 0x6A, 0x2F, 0xC4, 0x03, 0xBD, 0x58,
             0x7D, 0x12, 0xE8, 0xA9, 0x45, 0x36, 0xCB, 0x70])
key = hashlib.blake2s(prefix + B_bytes + X_bytes + T_le + SP_le + certHash + e16 + b&amp;#39;\x00&amp;#39; * 4, digest_size=32).digest()
flag = ChaCha20Poly1305(key).decrypt(nonce, ct, None)   # zdk{bELOw_the_THerMOClIn3_two_SHADER_CURrENts_ConVERgE}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The harness and shim sources (&lt;code&gt;harness.c&lt;/code&gt;, &lt;code&gt;vk_shims.c&lt;/code&gt;, &lt;code&gt;gl_shims.c&lt;/code&gt;, &lt;code&gt;libc_stub.c&lt;/code&gt;) are longer than they are interesting. The Python model above plus &lt;code&gt;finv.py&lt;/code&gt; is really the whole story.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Part 2: VMception&lt;/h2&gt;
&lt;blockquote&gt;
&lt;p&gt;&amp;quot;Our license checker was designed by someone who thought assembly wasn&amp;#39;t low-level enough.&amp;quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;End state:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;license: N3BULA-7A91-X0R5-VM42
flag:    zdk{V1r7u4L_macHLnes_ARE_FUn}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;What we&amp;#39;re dealing with&lt;/h3&gt;
&lt;p&gt;One stripped x86-64 Linux binary. Run it and:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;=== ZDK License Verification System ===
License key: &amp;lt;input&amp;gt;
[-] Invalid license   (exit 1)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Not a lot to go on from the outside. Poking at &lt;code&gt;main&lt;/code&gt; shows it demands exactly 21 characters, then:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;code&gt;malloc&lt;/code&gt;s a 0x2a8-byte heap chunk and runs an init routine that decrypts a program into it.&lt;/li&gt;
&lt;li&gt;A loader transforms your 21-byte license into some internal VM memory layout.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;run()&lt;/code&gt; executes the decrypted bytecode against that memory.&lt;/li&gt;
&lt;li&gt;If everything checks out, it prints success and derives a 29-byte flag from register state.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Translation: this isn&amp;#39;t a license check, it&amp;#39;s a small CPU, and your license key is the input tape.&lt;/p&gt;
&lt;h3&gt;Two separate memory spaces, mixed up at first&lt;/h3&gt;
&lt;p&gt;Initially assumed single VM state blob - addresses didn&amp;#39;t match.&lt;/p&gt;
&lt;p&gt;Turned out there are actually three distinct regions:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A &lt;strong&gt;VM struct&lt;/strong&gt; on the stack: 8 qword registers at &lt;code&gt;+0x00&lt;/code&gt;, a small stack at &lt;code&gt;+0x40&lt;/code&gt;, a data region at &lt;code&gt;+0x140&lt;/code&gt;, the transformed license at &lt;code&gt;+0x180&lt;/code&gt;, a running hash at &lt;code&gt;+0x240&lt;/code&gt;, plus pc/flags/status bytes.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;heap buffer&lt;/strong&gt; (0x2a8 bytes): the actual bytecode program, sitting encrypted until init decrypts it.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;12-byte array on the stack&lt;/strong&gt; that I initially read as more program bytes. It isn&amp;#39;t. It&amp;#39;s a &lt;em&gt;patch table&lt;/em&gt;, instructions for modifying the heap program at runtime rather than instructions to run.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The 12-byte patch table is not code - disassembling it gives nonsense.&lt;/p&gt;
&lt;h3&gt;Fifteen opcodes, one dispatch table&lt;/h3&gt;
&lt;p&gt;Found the dispatch table at file offset &lt;code&gt;0x2080&lt;/code&gt;, mapping a raw opcode byte to one of 15 handlers (the remaining tables sit at fixed file offsets alongside it: TBL_MAP at 0x2180, TBL_ENC at 0x21a0, TBL_A at 0x2450, TBL_B at 0x2460):&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;1  LDI   [op][reg][imm]     regs[r] = imm
2  LDM   [op][reg][addr]    regs[r] = mem8[0x140+addr]
3  STM   [op][addr][reg]    mem8[0x140+addr] = regs[r] &amp;amp; 0xff
4  XOR   [op][ra][rb]
5  ADD   [op][ra][rb]
6  SUB   [op][ra][rb]
7  ROL   [op][reg][imm]     (byte rotate)
8  ROR   [op][reg][imm]
9  CMP   [op][ra][rb]       flags = (eq); on success patch heap[off] ^= k
10 JNZ   [op][lo][hi]       pc += sign16 if flags != 0
11 JZ    [op][lo][hi]       pc += sign16 if flags == 0
12 PUSH  [op][reg]
13 POP   [op][reg]
14 MIX   [op][ra][rb]       regs[ra] = rol8((regs[ra]^regs[rb]) + 0x37, regs[rb])
15 END   [op][flag]         halt; success = (flag == 1)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Straightforward once extracted. Dumped dispatch table at 0x2080, used AI to generate 15 handler skeletons, verified manually.&lt;/p&gt;
&lt;p&gt;Heap decryption, once reversed, turned out simple:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;heap[0] = 0x23
ecx = 0x54
for i in range(1, 0x2a8):
    b = TBL_ENC[i] ^ (ecx &amp;amp; 0xFF) ^ TBL_B[i &amp;amp; 15] ^ TBL_A[i &amp;amp; 15]
    ecx += 0x3D
    heap[i] = b
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And the license loader, which turns your 21 input bytes into the VM&amp;#39;s working memory while also accumulating a running hash as it goes:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;h = 0x1337C0DE5EEDBEEF  # helpers rol8/rol64 defined in solver below
for i, byte in enumerate(license_bytes):
    prod = i * 0xCCCCCCCCCCCCCCCD
    hi = prod &amp;gt;&amp;gt; 64
    q = ((hi &amp;amp; ~3) + (hi &amp;gt;&amp;gt; 2)) &amp;amp; ((1 &amp;lt;&amp;lt; 64) - 1)
    al = rol8(byte ^ 0x5A, (i - q) &amp;amp; 7)
    al = (al + TBL_MAP[i]) &amp;amp; 0xFF
    mem[0x180 + i] = al
    h = rol64(h ^ al, 7) * 0x9E3779B185EBCA87
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;divstuff&lt;/code&gt; is a divide-by-5 disguised as a magic-constant multiply (&lt;code&gt;0xCCCCCCCCCCCCCCCD&lt;/code&gt;), a fairly standard compiler trick for turning division into multiplication and a reasonably standard thing for a challenge author to lean on for extra confusion. I emulated it exactly rather than trying to simplify it away, mostly because my first attempt at &amp;quot;simplifying&amp;quot; it introduced a rounding error that threw every downstream byte off by one on certain inputs.&lt;/p&gt;
&lt;h3&gt;Self-modifying, and confusing because of it&lt;/h3&gt;
&lt;p&gt;Every successful &lt;code&gt;CMP&lt;/code&gt; fires off a patch from the 12-byte table:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;entry: u16 heap_offset | xor_byte | unused
heap[offset] ^= xor_byte   # only if offset &amp;lt; current pc
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The starting patches were &lt;code&gt;heap[0x0c]^=0xa5&lt;/code&gt;, &lt;code&gt;heap[0x1b]^=0x5a&lt;/code&gt;, &lt;code&gt;heap[0x2a]^=0xc3&lt;/code&gt;, &lt;code&gt;heap[0x3f]^=0x3c&lt;/code&gt;. Before that clicked, I kept disassembling the heap once, statically, but a correct early comparison rewrites later bytes, so the snapshot I was reading was never the code that would run. Once I accepted that the program mutates itself as it runs and started re-dumping the heap after each patch point, the control flow finally made sense. Jumps route around the various failure stubs, and any wrong byte halts things early.&lt;/p&gt;
&lt;h3&gt;Turning the VM into a constraint system&lt;/h3&gt;
&lt;p&gt;Once the program was fully mapped out (license index equals &lt;code&gt;addr - 0x40&lt;/code&gt;, since the transformed license lives at that stack offset), it reduced to a system of byte constraints:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;t[6]==0xA6, t[11]==0x12, t[16]==0x75
rol(t[5],3)==0x64, rol(t[8],2)==0xEE
t[0]^t[1]==0x5E, t[1]^t[2]==0xDB, ... (full XOR chain 0..19)
MIX(t[2],t[9])==0x56, t[3]+t[14]==0x09, t[4]-t[17]==0x42,
t[7]^t[18]==0x3C, t[10]+t[20]==0xFF, MIX(t[12],t[15])==0xE6,
t[13]-t[19]==0x8B, t[0]^t[20]==0xDD, t[1]+t[12]==0x42,
MIX(t[16],t[17])==0xAB
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Plus the running hash needing to land exactly on &lt;code&gt;0xCD0320F4ACAE3BD6&lt;/code&gt; (the &lt;code&gt;MIX(t[0],t[3])&lt;/code&gt; result is overwritten by &lt;code&gt;LDI r7,0xA5; END&lt;/code&gt; and &lt;code&gt;r7==0xA5&lt;/code&gt; is a success signal, not a license constraint).&lt;/p&gt;
&lt;p&gt;First move: throw it at Z3. It returned a solution fast; the binary rejected it. The bug: Z3 treated SUB constraints as signed by default; the VM is all unsigned bytes. Fixed that, got a second candidate, also rejected, this time from an underconstrained model missing the hash and anchors. With the full chain + combos + hash the solution is unique, so I dropped the solver and propagated by hand:&lt;/p&gt;
&lt;p&gt;Manual propagation instead: XOR chain outward from the anchors, brute-force the leftovers, verify against the hash:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;t = 27 79 a2 51 8d 8c a6 ba bb 8c 05 12 c9 c0 b8 5f 75 4b 86 35 fa
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then just ran the loader transform backwards:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def rot_amount(i):
    prod = i * 0xCCCCCCCCCCCCCCCD
    hi = prod &amp;gt;&amp;gt; 64
    q = ((hi &amp;amp; ~3) + (hi &amp;gt;&amp;gt; 2)) &amp;amp; ((1 &amp;lt;&amp;lt; 64) - 1)
    return (i - q) &amp;amp; 7

lic = bytes((ror8((t[i] - TBL_MAP[i]) &amp;amp; 0xFF, rot_amount(i)) ^ 0x5A) for i in range(21))
# lic == b&amp;#39;N3BULA-7A91-X0R5-VM42&amp;#39;
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Flag&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;echo &amp;quot;N3BULA-7A91-X0R5-VM42&amp;quot; | ./vmception
# === ZDK License Verification System ===
# License key: [+] License accepted
# zdk{V1r7u4L_macHLnes_ARE_FUn}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;The solver, in shape&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def solve_t():
    t = [None]*21
    def setc(i, v):
        t[i] = v
    def rol8(x, n):
        n &amp;amp;= 7
        return ((x &amp;lt;&amp;lt; n) &amp;amp; 0xFF) | (x &amp;gt;&amp;gt; (8-n)) if n else x &amp;amp; 0xFF
    def ror8(x, n):
        return rol8(x, 8-n)
    def mix(a, b):
        return rol8((a ^ b) + 0x37, b &amp;amp; 7)

    # anchored bytes from VM heap
    setc(6, 0xA6)
    setc(11, 0x12)
    setc(16, 0x75)
    setc(5, ror8(0x64, 3))
    setc(8, ror8(0xEE, 2))

    # full XOR chain 0..19 (extracted from heap program 0x140+addr)
    xor_chain = [
        (0, 1, 0x5E), (1, 2, 0xDB), (2, 3, 0xF3), (3, 4, 0xDC), (4, 5, 0x01),
        (5, 6, 0x2A), (6, 7, 0x1C), (7, 8, 0x01), (8, 9, 0x37), (9, 10, 0x89),
        (10, 11, 0x17), (11, 12, 0xDB), (12, 13, 0x09), (13, 14, 0x78), (14, 15, 0xE7),
        (15, 16, 0x2A), (16, 17, 0x3E), (17, 18, 0xCD), (18, 19, 0xB3), (19, 20, 0xCF),
    ]
    # propagate XOR chain until stable
    changed = True
    while changed:
        changed = False
        for a, b, c in xor_chain:
            if t[a] is not None and t[b] is None:
                t[b] = t[a] ^ c; changed = True
            elif t[b] is not None and t[a] is None:
                t[a] = t[b] ^ c; changed = True

    # single-byte constraints - brute force remaining unknowns
    # MIX / ADD / SUB / XOR as per heap program
    def rol64(v, s):
        s &amp;amp;= 63
        return ((v &amp;lt;&amp;lt; s) | (v &amp;gt;&amp;gt; (64 - s))) &amp;amp; ((1 &amp;lt;&amp;lt; 64) - 1) if s else v
    def check():
        if mix(t[2], t[9]) != 0x56: return False
        if (t[3] + t[14]) &amp;amp; 0xFF != 0x09: return False
        if (t[4] - t[17]) &amp;amp; 0xFF != 0x42: return False
        if (t[7] ^ t[18]) != 0x3C: return False
        if (t[10] + t[20]) &amp;amp; 0xFF != 0xFF: return False
        if mix(t[12], t[15]) != 0xE6: return False
        if (t[13] - t[19]) &amp;amp; 0xFF != 0x8B: return False
        if (t[0] ^ t[20]) != 0xDD: return False
        if (t[1] + t[12]) &amp;amp; 0xFF != 0x42: return False
        if mix(t[16], t[17]) != 0xAB: return False
        # t[7]==0xA5 is success signal from LDI r7,0xA5 before END, not a license constraint - checked as program post-condition
        # hash check
        h = 0x1337C0DE5EEDBEEF
        for i in range(21):
            h = rol64(h ^ t[i], 7) * 0x9E3779B185EBCA87 &amp;amp; ((1&amp;lt;&amp;lt;64)-1)
        if h != 0xCD0320F4ACAE3BD6: return False
        return True

    # brute force remaining None bytes
    import itertools
    unknowns = [i for i, v in enumerate(t) if v is None]
    for vals in itertools.product(range(256), repeat=len(unknowns)):
        for idx, val in zip(unknowns, vals):
            t[idx] = val
        if check():
            return t
    return None
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;Takeaways&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Run hostile code natively, even when the setup hurts.&lt;/strong&gt; The Hadopelagic harness replaced weeks of hand-decompiling with watching logs scroll by. Nearly every shim broke on its first run and needed a second pass, but patching ELF version tags and faking a GPU was still less work than reading one stripped function by hand.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Ground truth each model before stacking the next one on it.&lt;/strong&gt; The mix step earned trust byte by byte against live dumps. Every skip cost real time: SpecId-as-index, the loader-math shortcut, Z3&amp;#39;s signed SUBs. (And the dumps I trusted most turned out to be poisoned runs, see below.)&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Debuggers change the thing you&amp;#39;re debugging.&lt;/strong&gt; TracerPid meant gdb and clean runs disagreed, and I burned time trusting the wrong ground truth before catching it.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;A solver is only as good as its constraints.&lt;/strong&gt; Z3 gave two wrong answers and got dropped: unsigned bytes modeled as signed, then an underconstrained model missing the hash and anchors. Manual propagation was slower to set up and easier to trust. Same pattern in Hadopelagic: exact inversion, ARX rounds backward plus a GF(2) matrix inverse, beats guessing, once invertibility is confirmed first.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;References&lt;/h2&gt;
&lt;p&gt;Material referenced or relied on during these solves:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;SPIR-V specification&lt;/strong&gt; (result IDs vs SpecId decorations, OpSpecConstant): &lt;a href=&quot;https://registry.khronos.org/SPIR-V/specs/unified1/SPIRV.html&quot;&gt;Khronos SPIR-V Registry&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;SPIRV-Tools&lt;/strong&gt; (&lt;code&gt;spirv-dis&lt;/code&gt;, used to disassemble &lt;code&gt;trench.spv&lt;/code&gt;): &lt;a href=&quot;https://github.com/KhronosGroup/SPIRV-Tools&quot;&gt;KhronosGroup/SPIRV-Tools&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;BLAKE2&lt;/strong&gt; (identified by IV word &lt;code&gt;0x6b08e647&lt;/code&gt;, sigma table, 16/12/8/7 rotations): &lt;a href=&quot;https://www.rfc-editor.org/rfc/rfc7693.html&quot;&gt;RFC 7693&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;ChaCha20-Poly1305&lt;/strong&gt; (the final decrypt, key/nonce/ciphertext split): &lt;a href=&quot;https://www.rfc-editor.org/rfc/rfc8439.html&quot;&gt;RFC 8439&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;APK Signature Scheme v2&lt;/strong&gt; (the signing block at &lt;code&gt;0x49004&lt;/code&gt; holding SP, X_target, nonce, ct): &lt;a href=&quot;https://source.android.com/docs/security/features/apksigning/v2&quot;&gt;Android source docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Z3 theorem prover&lt;/strong&gt; (VMception constraint attempts, signed vs unsigned pitfall): &lt;a href=&quot;https://github.com/Z3Prover/z3&quot;&gt;Z3Prover/z3&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;jadx&lt;/strong&gt; (DEX entry point, &lt;code&gt;nativeProbe&lt;/code&gt; JNI signature): &lt;a href=&quot;https://github.com/skylot/jadx&quot;&gt;skylot/jadx&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;AES MixColumns&lt;/strong&gt; (the cousin of the GF(2) &lt;code&gt;xtime&lt;/code&gt; mix step in &lt;code&gt;cdfc0&lt;/code&gt;): &lt;a href=&quot;https://csrc.nist.gov/pubs/fips/197/final&quot;&gt;FIPS 197&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;TracerPid&lt;/strong&gt; (the anti-debug check via &lt;code&gt;/proc/self/status&lt;/code&gt;): &lt;a href=&quot;https://man7.org/linux/man-pages/man5/proc_pid_status.5.html&quot;&gt;proc_pid_status man page&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Division by magic multiplication&lt;/strong&gt; (the divide-by-5 behind &lt;code&gt;divstuff&lt;/code&gt;, &lt;code&gt;0xCCCCCCCCCCCCCCCD&lt;/code&gt;): &lt;a href=&quot;http://www.hackersdelight.org/&quot;&gt;Hacker&amp;#39;s Delight&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</content:encoded></item><item><title>Deep Dive: Hunting Heap Overflows in ELF Binaries</title><link>https://rigalis.me/blog/heap-overflow-elf-hunting</link><guid isPermaLink="true">https://rigalis.me/blog/heap-overflow-elf-hunting</guid><description>An in-depth analysis of glibc allocator dynamics, heap chunk structures, and exploiting classic double-free &amp; heap corruption - demo showcases a left progress index, right marginalia, callouts, figures, tables, footnotes.</description><pubDate>Mon, 18 May 2026 00:00:00 GMT</pubDate></item></channel></rss>