<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="4.4.1">Jekyll</generator><link href="https://page-fault.io/feed.xml" rel="self" type="application/atom+xml" /><link href="https://page-fault.io/" rel="alternate" type="text/html" /><updated>2026-10-17T03:04:45+00:00</updated><id>https://page-fault.io/feed.xml</id><title type="html">VMExit [Leaving Sandbox Zone…]</title><subtitle>Just another Blog about Low Level Programming: Operating Systems, Security Platform Firmwares, Binary Analysis, Algorithms and other boring stuff...</subtitle><author><name>Maciej Grochowski</name></author><entry><title type="html">Sentinel Values, Pool Modes, and Other Clues to Runtime Architecture</title><link href="https://page-fault.io/reverse-engineering/firmware/architecture/2026/10/17/sentinel-values-pool-modes-and-other-clues-to-runtime-architecture.html" rel="alternate" type="text/html" title="Sentinel Values, Pool Modes, and Other Clues to Runtime Architecture" /><published>2026-10-17T00:00:05+00:00</published><updated>2026-10-17T00:00:05+00:00</updated><id>https://page-fault.io/reverse-engineering/firmware/architecture/2026/10/17/sentinel-values-pool-modes-and-other-clues-to-runtime-architecture</id><content type="html" xml:base="https://page-fault.io/reverse-engineering/firmware/architecture/2026/10/17/sentinel-values-pool-modes-and-other-clues-to-runtime-architecture.html"><![CDATA[<p>This is the final post in our series. We have used <a href="/reverse-engineering/firmware/crypto/2026/10/08/sboxes-hash-constants-and-other-cryptographic-fingerprints.html">constant tables</a>, <a href="/reverse-engineering/firmware/structs/2026/10/15/offsets-embedded-nodes-and-other-phantoms-of-lost-structs.html">memory offsets</a>, <a href="/reverse-engineering/firmware/tools/2026/10/17/radare2-ghidra-and-other-ways-to-tear-apart-a-binary.html">cross-references</a>, and <a href="/reverse-engineering/firmware/hardware/2026/10/17/register-maps-mmio-blocks-and-other-hardware-buried-in-code.html">memory-mapped I/O (MMIO) accesses</a> to put names to pieces of the binary. Now we can ask what happens when those pieces meet during boot.</p>

<p>The pool allocator, command-line interface (CLI) dispatch tables, rule state changes, scheduler code, and six-phase init sequence are all visible. Reading them together exposes a resource mismatch: the default partition configuration asks for more rule slots than a pool can provide. If partition binding reaches those allocations, the code records an error and continues in degraded mode. That is more useful than accepting a <code class="language-plaintext highlighter-rouge">boot complete</code> marker at face value.</p>

<blockquote>
  <p><strong>Follow along:</strong> Use the same <code class="language-plaintext highlighter-rouge">firmware.bin</code> from the <a href="/assets/firmware-re/advanced-sample.tar.gz">sample bundle</a>, and run the examples from <code class="language-plaintext highlighter-rouge">more_complex_firmware/</code>. Instruction addresses refer to that binary. The boot outcome below follows from its code and configuration; it is not a captured device boot.</p>
</blockquote>

<hr />

<h2 id="the-pool-allocator-sentinel-values-and-three-modes">The Pool Allocator: Sentinel Values and Three Modes</h2>

<h3 id="finding-the-sentinels">Finding the Sentinels</h3>

<p>Some of the most revealing values in firmware aren’t in <code class="language-plaintext highlighter-rouge">.rodata</code> tables – they’re magic constants embedded directly in code. Search the bundled binary’s executable ranges for <code class="language-plaintext highlighter-rouge">lui</code> instructions that load <code class="language-plaintext highlighter-rouge">0xDEAD</code>:</p>

<figure class="highlight"><pre><code class="language-python" data-lang="python"><span class="kn">from</span> <span class="n">capstone</span> <span class="kn">import</span> <span class="n">Cs</span><span class="p">,</span> <span class="n">CS_ARCH_MIPS</span><span class="p">,</span> <span class="n">CS_MODE_MIPS32</span><span class="p">,</span> <span class="n">CS_MODE_LITTLE_ENDIAN</span>

<span class="n">data</span> <span class="o">=</span> <span class="nf">open</span><span class="p">(</span><span class="sh">"</span><span class="s">firmware.bin</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">rb</span><span class="sh">"</span><span class="p">).</span><span class="nf">read</span><span class="p">()</span>
<span class="n">md</span> <span class="o">=</span> <span class="nc">Cs</span><span class="p">(</span><span class="n">CS_ARCH_MIPS</span><span class="p">,</span> <span class="n">CS_MODE_MIPS32</span> <span class="o">|</span> <span class="n">CS_MODE_LITTLE_ENDIAN</span><span class="p">)</span>
<span class="k">for</span> <span class="n">start</span><span class="p">,</span> <span class="n">end</span> <span class="ow">in</span> <span class="p">[(</span><span class="mh">0x0000</span><span class="p">,</span> <span class="mh">0x02AC</span><span class="p">),</span> <span class="p">(</span><span class="mh">0x02B0</span><span class="p">,</span> <span class="mh">0x7290</span><span class="p">)]:</span>
    <span class="k">for</span> <span class="n">insn</span> <span class="ow">in</span> <span class="n">md</span><span class="p">.</span><span class="nf">disasm</span><span class="p">(</span><span class="n">data</span><span class="p">[</span><span class="n">start</span><span class="p">:</span><span class="n">end</span><span class="p">],</span> <span class="mh">0x80000000</span> <span class="o">+</span> <span class="n">start</span><span class="p">):</span>
        <span class="k">if</span> <span class="n">insn</span><span class="p">.</span><span class="n">mnemonic</span> <span class="o">==</span> <span class="sh">"</span><span class="s">lui</span><span class="sh">"</span> <span class="ow">and</span> <span class="n">insn</span><span class="p">.</span><span class="n">op_str</span><span class="p">.</span><span class="nf">endswith</span><span class="p">(</span><span class="sh">"</span><span class="s">, 0xdead</span><span class="sh">"</span><span class="p">):</span>
            <span class="nf">print</span><span class="p">(</span><span class="sa">f</span><span class="sh">"</span><span class="si">{</span><span class="n">insn</span><span class="p">.</span><span class="n">address</span><span class="si">:</span><span class="mi">08</span><span class="n">x</span><span class="si">}</span><span class="s">:  </span><span class="si">{</span><span class="n">insn</span><span class="p">.</span><span class="n">mnemonic</span><span class="si">}</span><span class="s"> </span><span class="si">{</span><span class="n">insn</span><span class="p">.</span><span class="n">op_str</span><span class="p">.</span><span class="nf">replace</span><span class="p">(</span><span class="sh">'</span><span class="s">$</span><span class="sh">'</span><span class="p">,</span> <span class="sh">''</span><span class="p">)</span><span class="si">}</span><span class="sh">"</span><span class="p">)</span></code></pre></figure>

<figure class="highlight"><pre><code class="language-text" data-lang="text">80001990:  lui t0, 0xdead
800019d0:  lui v1, 0xdead
80001a50:  lui t0, 0xdead
80001b00:  lui t0, 0xdead
80001b58:  lui t4, 0xdead
80001b98:  lui v1, 0xdead
80001c58:  lui v0, 0xdead
80001cc8:  lui v0, 0xdead
80001cd8:  lui v0, 0xdead
80002068:  lui v1, 0xdead
80002078:  lui v1, 0xdead</code></pre></figure>

<p>The upper-half loads alone do not establish the full values. Follow the surrounding instructions to find <code class="language-plaintext highlighter-rouge">0xDEAD0000</code> and <code class="language-plaintext highlighter-rouge">0xDEAD0002</code>. These are <strong>sentinel values</strong> – special markers that the allocator writes to memory slots to track their state. The following instruction excerpts retain their binary addresses; gaps omit unrelated instructions.</p>

<figure class="highlight"><pre><code class="language-text" data-lang="text"># In pool_alloc (windowed mode):
80001b58:  lui   t4, 0xdead          # t4 = 0xDEAD0000 (FREE marker)
  ...
80001b74:  lw    t0, 0x1c(a1)        # Load slot marker
80001b78:  beq   t0, t4, 0x80001b98  # if (slot == DEAD0000) → slot is free
  ...
80001b98:  lui   v1, 0xdead
80001b9c:  addiu v1, v1, 2           # v1 = 0xDEAD0002 (USED marker)
80001ba0:  sw    v1, 0x1c(a1)        # Mark slot as USED</code></pre></figure>

<p>The allocator checks if a slot contains <code class="language-plaintext highlighter-rouge">0xDEAD0000</code> (free), and if so, overwrites it with <code class="language-plaintext highlighter-rouge">0xDEAD0002</code> (used). In <code class="language-plaintext highlighter-rouge">pool_init</code>, a delay-slot addition constructs a third value:</p>

<figure class="highlight"><pre><code class="language-text" data-lang="text">80001b00:  lui   t0, 0xdead
80001b04:  b     0x80001ab4
80001b08:  addiu t0, t0, 1           # delay slot: 0xDEAD0001</code></pre></figure>

<p>Following the initialization stores and allocation checks gives us:</p>

<figure class="highlight"><pre><code class="language-text" data-lang="text">0xDEAD0000 = FREE     (slot available for allocation)
0xDEAD0001 = BOUNDARY (window boundary, skip during scan)
0xDEAD0002 = USED     (slot currently allocated)</code></pre></figure>

<p>These three sentinel values, together with the indexing code, reveal a <strong>windowed</strong> structure. Initialization places a boundary marker in the last slot of each window. The compiled allocation loop accepts only FREE markers, so it skips boundaries without a separate comparison against <code class="language-plaintext highlighter-rouge">0xDEAD0001</code>.</p>

<h3 id="the-three-mode-dispatch">The Three-Mode Dispatch</h3>

<p>The function at <code class="language-plaintext highlighter-rouge">0x80001B14</code> (<code class="language-plaintext highlighter-rouge">pool_alloc</code>) starts with a mode check:</p>

<figure class="highlight"><pre><code class="language-text" data-lang="text">80001b14:  beqz  a0, 0x80001c98      # NULL check
80001b18:  nop
  ...
80001b24:  lw    t3, 4(a0)           # t3 = pool-&gt;mode
80001b28:  addiu v0, zero, 1
80001b2c:  beq   t3, v0, 0x80001c2c  # mode 1: linear scan
80001b30:  addiu v0, zero, 2         # delay slot: prepare next comparison
80001b34:  beq   t3, v0, 0x80001c48  # mode 2: prefer slot zero
80001b38:  nop
80001b3c:  bnez  t3, 0x80001ca0      # unknown mode: error
80001b40:  nop
           # Fall through: mode 0 = windowed scan</code></pre></figure>

<p>Three modes, selected by a field at offset <code class="language-plaintext highlighter-rouge">+0x04</code> in the pool struct:</p>

<table>
  <thead>
    <tr>
      <th>Mode</th>
      <th>Value</th>
      <th>Algorithm</th>
      <th>Sentinel Skip</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Windowed</td>
      <td>0</td>
      <td>Scan windows, cursor wraps</td>
      <td>Skip BOUNDARY (0xDEAD0001)</td>
    </tr>
    <tr>
      <td>Linear</td>
      <td>1</td>
      <td>Flat sequential scan</td>
      <td>Skip USED (0xDEAD0002)</td>
    </tr>
    <tr>
      <td>Copy</td>
      <td>2</td>
      <td>Use slot 0 when the cursor is zero and it is free; otherwise scan linearly</td>
      <td>Same as linear on fallback</td>
    </tr>
  </tbody>
</table>

<p>The three system pools select these modes: AR uses windowed, ID uses linear, and AT uses copy. The copy branch prefers slot zero whenever the cursor is zero and that slot is free; otherwise it uses the linear scan. No rule or 64-bit value is copied by this branch.</p>

<h3 id="reconstructing-the-pool-struct">Reconstructing the Pool Struct</h3>

<p>From the allocator code, we can recover the pool data structure:</p>

<figure class="highlight"><pre><code class="language-c" data-lang="c"><span class="k">typedef</span> <span class="k">struct</span> <span class="p">{</span>
    <span class="k">const</span> <span class="kt">char</span> <span class="o">*</span><span class="n">name</span><span class="p">;</span>           <span class="cm">/* +0x00 */</span>
    <span class="kt">uint32_t</span>    <span class="n">mode</span><span class="p">;</span>           <span class="cm">/* +0x04: 0=windowed, 1=linear, 2=copy */</span>
    <span class="kt">uint32_t</span>    <span class="n">num_windows</span><span class="p">;</span>    <span class="cm">/* +0x08 */</span>
    <span class="kt">uint32_t</span>    <span class="n">total_slots</span><span class="p">;</span>    <span class="cm">/* +0x0C */</span>
    <span class="kt">uint32_t</span>    <span class="n">used_count</span><span class="p">;</span>     <span class="cm">/* +0x10 */</span>
    <span class="kt">uint32_t</span>    <span class="n">cursor</span><span class="p">;</span>         <span class="cm">/* +0x14: encoded [7:3]=window, [2:0]=slot */</span>
    <span class="kt">uint32_t</span>    <span class="n">alloc_fails</span><span class="p">;</span>    <span class="cm">/* +0x18: failed allocations */</span>
    <span class="kt">uint32_t</span>    <span class="n">slots</span><span class="p">[</span><span class="mi">32</span><span class="p">];</span>      <span class="cm">/* +0x1C: sentinel-tagged slot array */</span>
<span class="p">}</span> <span class="n">pool_t</span><span class="p">;</span></code></pre></figure>

<p>In windowed mode, the cursor at <code class="language-plaintext highlighter-rouge">+0x14</code> packs the window index into bits [7:3] and the slot index into bits [2:0]. The AR pool spans eight four-slot windows, but the last slot of each window is a boundary marker: <strong>24 allocatable slots</strong>, not 32. Linear and copy modes each initialize one four-slot window, so their pools have <strong>four allocatable slots</strong> each. The 32-element array is storage capacity, not the active size of every pool.</p>

<p>The companion source makes a useful cross-check here. Its ID and AT initialization comments say “32 slots,” but each call initializes one window, and <code class="language-plaintext highlighter-rouge">POOL_WINDOW_SIZE</code> is four. The compiled initialization and allocation bounds agree on four. Even with source available, a comment can lose an argument with the instructions.</p>

<hr />

<h2 id="the-cli-dispatch-two-command-tables">The CLI Dispatch: Two Command Tables</h2>

<h3 id="finding-the-tables">Finding the Tables</h3>

<p>Function pointer tables are RE gold – they map directly to the firmware’s command structure. In our firmware, <code class="language-plaintext highlighter-rouge">cli_process</code> starts at <code class="language-plaintext highlighter-rouge">0x8000541C</code> and contains the dispatch logic. Its public-command string comparison loop loads the table base as follows:</p>

<figure class="highlight"><pre><code class="language-text" data-lang="text"># Load table base
8000559c:  lui   s0, 0x8000
800055a0:  addiu s0, s0, 0x78c0      # 0x800078C0 = public_cmds table</code></pre></figure>

<p>At address <code class="language-plaintext highlighter-rouge">0x800078C0</code>, we find an array of 12-byte entries:</p>

<figure class="highlight"><pre><code class="language-text" data-lang="text">$ python3 -c "
import struct
data = open('firmware.bin', 'rb').read()
BASE = 0x80000000
offset = 0x78C0  # public_cmds
for i in range(15):
    name_ptr = struct.unpack('&lt;I', data[offset:offset+4])[0]
    handler  = struct.unpack('&lt;I', data[offset+8:offset+12])[0]
    # Read string at name_ptr
    soff = name_ptr - BASE
    name = ''
    while data[soff] != 0: name += chr(data[soff]); soff += 1
    print(f'  [{i:2d}] \"{name}\"  -&gt; 0x{handler:08X}')
    offset += 12
"</code></pre></figure>

<figure class="highlight"><pre><code class="language-text" data-lang="text">  [ 0] "help"       -&gt; 0x80004BF4
  [ 1] "version"    -&gt; 0x80004BC8
  [ 2] "uptime"     -&gt; 0x8000538C
  [ 3] "status"     -&gt; 0x8000522C
  [ 4] "portdump"   -&gt; 0x80005370
  [ 5] "routedump"  -&gt; 0x80005354
  [ 6] "ruledump"   -&gt; 0x80005104
  [ 7] "pooldump"   -&gt; 0x80005144
  [ 8] "taskdump"   -&gt; 0x80005338
  [ 9] "eventdump"  -&gt; 0x8000531C
  [10] "dmadump"    -&gt; 0x80005300
  [11] "partdump"   -&gt; 0x800052E4
  [12] "timer"      -&gt; 0x800052C8
  [13] "reset"      -&gt; 0x80004BA0
  [14] "log"        -&gt; 0x800051F0</code></pre></figure>

<p>15 public commands. But the dispatch function searches <em>two</em> tables. After the public table check fails, it searches a second table at <code class="language-plaintext highlighter-rouge">0x80007860</code>:</p>

<figure class="highlight"><pre><code class="language-text" data-lang="text">  [0] "$dbglvl"    -&gt; 0x80005194
  [1] "$peek"      -&gt; 0x80004DC8
  [2] "$poke"      -&gt; 0x80004D34
  [3] "$flashid"   -&gt; 0x80004CFC
  [4] "$crashme"   -&gt; 0x80005160
  [5] "$rulestat"  -&gt; 0x80005120
  [6] "$wdgtest"   -&gt; 0x80004B74
  [7] "$crctest"   -&gt; 0x80004E44</code></pre></figure>

<p><strong>Eight hidden commands</strong> prefixed with <code class="language-plaintext highlighter-rouge">$</code>. In production firmware, hidden debug commands like these are security-relevant – <code class="language-plaintext highlighter-rouge">$peek</code> and <code class="language-plaintext highlighter-rouge">$poke</code> provide arbitrary memory read/write, <code class="language-plaintext highlighter-rouge">$crashme</code> triggers a fatal assert, and <code class="language-plaintext highlighter-rouge">$wdgtest</code> shortens the watchdog timeout. The last command returns to the main loop, which continues kicking the watchdog, so the code alone does not establish a reset.</p>

<p>The entries in these two tables use <code class="language-plaintext highlighter-rouge">{string_pointer, help_pointer, handler_pointer}</code>. Once we find one table, checking nearby data for another is worth the effort; the entry layout itself depends on the firmware.</p>

<hr />

<h2 id="the-boot-chain-six-phases-with-error-codes">The Boot Chain: Six Phases With Error Codes</h2>

<h3 id="the-phase-dispatch-table">The Phase Dispatch Table</h3>

<p>The <code class="language-plaintext highlighter-rouge">firmware_main</code> function at <code class="language-plaintext highlighter-rouge">0x80000540</code> walks a six-entry function pointer table at file offset <code class="language-plaintext highlighter-rouge">0x7290</code> (address <code class="language-plaintext highlighter-rouge">0x80007290</code>). The binary stores each handler address as a little-endian word. We can read those words directly:</p>

<figure class="highlight"><pre><code class="language-python" data-lang="python"><span class="kn">import</span> <span class="n">struct</span>
<span class="n">data</span> <span class="o">=</span> <span class="nf">open</span><span class="p">(</span><span class="sh">'</span><span class="s">firmware.bin</span><span class="sh">'</span><span class="p">,</span> <span class="sh">'</span><span class="s">rb</span><span class="sh">'</span><span class="p">).</span><span class="nf">read</span><span class="p">()</span>
<span class="k">for</span> <span class="n">i</span> <span class="ow">in</span> <span class="nf">range</span><span class="p">(</span><span class="mi">6</span><span class="p">):</span>
    <span class="n">addr</span> <span class="o">=</span> <span class="n">struct</span><span class="p">.</span><span class="nf">unpack</span><span class="p">(</span><span class="sh">'</span><span class="s">&lt;I</span><span class="sh">'</span><span class="p">,</span> <span class="n">data</span><span class="p">[</span><span class="mh">0x7290</span><span class="o">+</span><span class="n">i</span><span class="o">*</span><span class="mi">4</span><span class="p">:</span><span class="mh">0x7290</span><span class="o">+</span><span class="n">i</span><span class="o">*</span><span class="mi">4</span><span class="o">+</span><span class="mi">4</span><span class="p">])[</span><span class="mi">0</span><span class="p">]</span>
    <span class="nf">print</span><span class="p">(</span><span class="sa">f</span><span class="sh">"</span><span class="s">Phase </span><span class="si">{</span><span class="n">i</span><span class="si">}</span><span class="s">: handler at 0x</span><span class="si">{</span><span class="n">addr</span><span class="si">:</span><span class="mi">08</span><span class="n">X</span><span class="si">}</span><span class="sh">"</span><span class="p">)</span></code></pre></figure>

<figure class="highlight"><pre><code class="language-text" data-lang="text">Phase 0: handler at 0x800003BC  # Hardware init
Phase 1: handler at 0x800004BC  # Driver init
Phase 2: handler at 0x8000045C  # Core services
Phase 3: handler at 0x8000042C  # Rule engine
Phase 4: handler at 0x80000324  # Partition binding
Phase 5: handler at 0x800002B0  # Runtime start</code></pre></figure>

<p>The labels come from inspecting each handler; the pointer table itself contains only addresses. Phases 0–2 halt on an error. Phases 3–5 record the error and continue in degraded mode. That difference matters here.</p>

<p>Phase 4 allocates per-partition rule budgets. The pre-check compares requests with rule-table limits: 32 AR, 48 ID, and 24 AT entries. It passes. But the allocator’s active pools are smaller: 24 usable AR slots, four ID slots, and four AT slots. The first partition asks for 12 ID rules. If port binding succeeds, its fifth ID allocation must fail, so phase 4 returns <code class="language-plaintext highlighter-rouge">INIT_ERR_RESOURCE</code> (<code class="language-plaintext highlighter-rouge">0x0D</code>). The firmware records the failure and still runs phase 5. This is a conclusion from the code path, not a captured boot log.</p>

<h3 id="the-boot-status-marker">The Boot Status Marker</h3>

<p>Phase 5 writes the boot marker regardless of the earlier phase-4 error:</p>

<figure class="highlight"><pre><code class="language-text" data-lang="text">800002f8:  lui   v0, 0xb803          # System Control base
800002fc:  ori   v1, zero, 0xbeef    # boot complete marker
80000300:  sw    v1, 0x14(v0)        # Write to BOOT_STATUS register</code></pre></figure>

<p>The value <code class="language-plaintext highlighter-rouge">0xBEEF</code> signals that execution reached the runtime-start phase. It does <strong>not</strong> prove that partition binding succeeded. The phase-4 return code and scratch error register are needed to tell those outcomes apart.</p>

<hr />

<h2 id="the-rule-engine-validate-log-activate">The Rule Engine: Validate, Log, Activate</h2>

<h3 id="state-machine">State Machine</h3>

<p>The <code class="language-plaintext highlighter-rouge">rule_commit</code> function at <code class="language-plaintext highlighter-rouge">0x8000330C</code> has three stages, but calling it an atomic hardware commit would give it credit it has not earned. From the bitfield analysis in Part 2, the state field lives in bits [15:8] of the flags word:</p>

<figure class="highlight"><pre><code class="language-text" data-lang="text">State transitions:
  FREE (0x00) → ALLOCATED (0x01) → PROGRAMMED (0x02) → COMMITTED (0x03)

The commit function performs:
  1. Validate: require PROGRAMMED state, a valid match range, and nonzero action
  2. Log a would-be hardware shadow write
  3. Lock, mark the entry COMMITTED, unlock, and post EVT_RULE_CHANGE</code></pre></figure>

<p>In C-like reconstruction:</p>

<figure class="highlight"><pre><code class="language-c" data-lang="c"><span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="n">rule_validate</span><span class="p">(</span><span class="n">table</span><span class="p">,</span> <span class="n">index</span><span class="p">))</span>
    <span class="k">return</span> <span class="o">-</span><span class="mi">1</span><span class="p">;</span>
<span class="n">rule_write_hw</span><span class="p">(</span><span class="n">table</span><span class="p">,</span> <span class="n">index</span><span class="p">);</span>         <span class="cm">/* logs; no MMIO write in this sample */</span>
<span class="n">rtos_mutex_lock</span><span class="p">(</span><span class="o">&amp;</span><span class="n">table</span><span class="o">-&gt;</span><span class="n">commit_lock</span><span class="p">);</span>
<span class="n">rule_activate</span><span class="p">(</span><span class="n">table</span><span class="p">,</span> <span class="n">index</span><span class="p">);</span>         <span class="cm">/* sets state to COMMITTED */</span>
<span class="n">rtos_mutex_unlock</span><span class="p">(</span><span class="o">&amp;</span><span class="n">table</span><span class="o">-&gt;</span><span class="n">commit_lock</span><span class="p">);</span>
<span class="n">event_post</span><span class="p">(</span><span class="n">EVT_RULE_CHANGE</span><span class="p">,</span> <span class="p">(</span><span class="n">type</span> <span class="o">&lt;&lt;</span> <span class="mi">16</span><span class="p">)</span> <span class="o">|</span> <span class="n">index</span><span class="p">);</span></code></pre></figure>

<p>The lock/unlock calls surround the state change, but the would-be hardware write happens before them and the event is posted afterward. The return from <code class="language-plaintext highlighter-rouge">rtos_mutex_lock</code> is ignored, although that function can return <code class="language-plaintext highlighter-rouge">-1</code> without acquiring the mutex. Even the state change therefore needs a contention check before we claim mutual exclusion. There is no real hardware write in this sample: <code class="language-plaintext highlighter-rouge">rule_write_hw</code> only logs a message. These bytes show a state transition and notification, not an atomic update of a device table.</p>

<h3 id="validation-failure">Validation Failure</h3>

<p>If validation fails, <code class="language-plaintext highlighter-rouge">rule_commit</code> logs an error and returns <code class="language-plaintext highlighter-rouge">-1</code> before the simulated write and state change. The entry remains in its previous state. No rollback operation runs.</p>

<hr />

<h2 id="scheduler-code-without-task-switching">Scheduler Code Without Task Switching</h2>

<h3 id="the-scheduler-algorithm">The Scheduler Algorithm</h3>

<p>From the struct recovery in Part 2, we know that <code class="language-plaintext highlighter-rouge">rtos_schedule</code> at <code class="language-plaintext highlighter-rouge">0x80000C10</code> reads a priority-sorted ready list:</p>

<figure class="highlight"><pre><code class="language-text" data-lang="text">1. Read head of ready list (global at 0x8008008C)
2. If list is empty, return (nothing to schedule)
3. container_of(head, task_t, run_list) → get task pointer
4. If this is already the current task, increment its selection counter
5. Otherwise:
   a. Set old task's state to READY (1)
   b. Set new task's state to RUNNING (2)
   c. Increment new task's run_count
   d. If task has an entry function, call it with task-&gt;arg</code></pre></figure>

<p>The field named <code class="language-plaintext highlighter-rouge">total_ticks</code> counts scheduler selections, including selections that change the current task. It does not measure elapsed timer ticks. There is no CPU context switch or separate task stack. The function calls an entry directly when it selects a new task. In this sample, initialization adds only the idle task; no caller creates a worker task. The data structures and selection code are still useful to recover, but they do not prove a running multi-task RTOS.</p>

<h3 id="the-unwired-timer-tick">The Unwired Timer Tick</h3>

<p>The <code class="language-plaintext highlighter-rouge">rtos_tick</code> function at <code class="language-plaintext highlighter-rouge">0x80000CB8</code> walks the timer list:</p>

<figure class="highlight"><pre><code class="language-text" data-lang="text">1. Increment global tick counter
2. For each task on the timer list:
   a. Decrement ticks_remaining
   b. If zero:
      - Remove from timer list (task pointer = node pointer - 0x28)
      - If state is BLOCKED, remove from the blocked list and set state to READY
      - For that task, insert its run_list node at task pointer + 0x20 into the ready list</code></pre></figure>

<p>The task has two list nodes: <code class="language-plaintext highlighter-rouge">timer_list</code> at <code class="language-plaintext highlighter-rouge">+0x28</code> and <code class="language-plaintext highlighter-rouge">run_list</code> at <code class="language-plaintext highlighter-rouge">+0x20</code>. <code class="language-plaintext highlighter-rouge">timer_isr</code> calls <code class="language-plaintext highlighter-rouge">rtos_tick</code>, but the exception handler in this sample never dispatches to <code class="language-plaintext highlighter-rouge">timer_isr</code>. The function is present; the interrupt path that would run it is not.</p>

<hr />

<h2 id="putting-it-all-together">Putting It All Together</h2>

<p>Here is the architecture the binary exposes, with the configured limits kept separate from usable pool capacity:</p>

<figure class="highlight"><pre><code class="language-text" data-lang="text">Firmware architecture and inferred limits
========================================================

BOOT CHAIN (6 phases)
  Phase 0: Hardware → Version, Watchdog, IntCtrl
  Phase 1: Drivers  → UART, GPIO, Timer, DMA
  Phase 2: Core     → Pools, Events, scheduler structures
  Phase 3: Rules    → Rule entry arrays: AR[32], ID[48], AT[24]
  Phase 4: Partitions → fifth ID allocation would fail (0x0D)
  Phase 5: Runtime  → continues, unmasks UART/timer/DMA, writes BOOT_STATUS=0xBEEF

RUNTIME LOOP
  cli_process()    → UART input → dispatch (15 public + 8 hidden)
  event_dispatch() → FIFO queue → registered handlers
  rtos_schedule()  → ready list contains idle task only
  Watchdog kick    → WDG_KICK = 0x5A5A

MEMORY ARCHITECTURE
  Usable pool slots: AR 24 (windowed), ID 4 (linear), AT 4 (copy)
  Sentinels: 0xDEAD0000=FREE, 0xDEAD0001=BOUNDARY, 0xDEAD0002=USED
  Task structs: 60 bytes, dual list nodes at +0x20 and +0x28
  Rule entries: 24 bytes, packed flags at +0x10
  Port configs: 48 bytes, list node at +0x24

HARDWARE PERIPHERALS (7 blocks @ 0xB80x0000)
  UART, GPIO, Timer, SysCon, DMA, IntCtrl, Watchdog

CRYPTO
  AES-128 ECB (S-box @ 0x7F7C, inv-S-box @ 0x7E7C)
  SHA-256 (K[] @ 0x8080, H0[] inlined in code)
  HMAC-SHA256 (ipad=0x36, opad=0x5C)
  These routines are reached through the hidden CLI self-test
  CRC-32 (table @ 0x7A70), CRC-16 (bit-by-bit, poly 0x8005)
========================================================</code></pre></figure>

<p>The distinction between entry arrays and allocatable slots explains the phase-4 failure. It also shows why a status marker alone is insufficient evidence of a healthy boot.</p>

<hr />

<h2 id="the-techniques-summarized">The Techniques, Summarized</h2>

<p>Over five posts, we’ve used a consistent set of techniques:</p>

<ol>
  <li>
    <p><strong>Constant scanning</strong> (Post 1): Known byte sequences locate candidate tables. AES S-box (<code class="language-plaintext highlighter-rouge">63 7C 77 7B</code>), SHA-256 K[] (<code class="language-plaintext highlighter-rouge">0x428A2F98</code>), CRC-32 table (<code class="language-plaintext highlighter-rouge">0x77073096</code>). Byte searches need no disassembly; checking the full table and its users supports the algorithm identification.</p>
  </li>
  <li>
    <p><strong>Offset collection</strong> (Post 2): Memory access offsets from <code class="language-plaintext highlighter-rouge">lw</code>/<code class="language-plaintext highlighter-rouge">sw</code> instructions reveal struct layouts. <code class="language-plaintext highlighter-rouge">container_of</code> subtractions reveal intrusive list node positions. Bitfield masks reveal packed fields.</p>
  </li>
  <li>
    <p><strong>Tool selection</strong> (Post 3): r2 maintains a queryable map in the terminal, Ghidra reconstructs C expressions, and Capstone supplies instruction decoding for custom scripts. Check discovered functions, references, and inferred types against the bytes.</p>
  </li>
  <li>
    <p><strong>MMIO pattern recognition</strong> (Part 4): Address construction provides candidates; loads, stores, and surrounding control flow show how each address is used.</p>
  </li>
  <li>
    <p><strong>Architectural inference</strong> (Post 5): Sentinel values reveal allocator design. Function pointer tables reveal dispatch systems. State transitions reveal protocols. Boot sequences reveal system dependencies.</p>
  </li>
</ol>

<p>The same searches can be applied to larger binaries. The amount of data grows, but the need to check each inference against code and control flow does not change.</p>

<hr />

<h2 id="from-static-inference-to-a-runtime-check">From Static Inference to a Runtime Check</h2>

<p>The useful result is a chain we can inspect: initialization gives the ID pool four slots, the first partition requests twelve, the fifth allocation fails, and the dispatcher continues to a phase that writes <code class="language-plaintext highlighter-rouge">0xBEEF</code>. Each step has an address, a condition, and a consequence. We can also name the condition still to test: whether execution reaches that allocation path with the expected port state.</p>

<p>The next useful experiment is small. Execute the allocator in isolation, or trace partition initialization with controlled port state, and observe the return values and scratch-register write. Whole-image emulation needs more preparation: a model for the memory map, UART status, and other peripheral behavior. It also needs to account for the missing timer dispatch. Loading the image into an emulator does not supply those devices or repair the interrupt path.</p>

<p>Keep the recovered names, struct definitions, reference sites, and assumptions alongside the binary hash. When another build changes a table or a branch, those notes tell us which conclusions need checking again.</p>

<p>We started with recognizable bytes and ended with an explanation for a misleadingly successful boot marker. The remaining work is specific enough to test. That is a useful place to finish the static analysis.</p>

<hr />

<p><em>This is Part 5 (final) of “Crypto Tables, Struct Ghosts, and Other Secrets Hidden in Firmware.” The <a href="/assets/firmware-re/advanced-sample.tar.gz">sample bundle</a> contains the firmware binary, source, and entropy scanner.</em></p>]]></content><author><name>Maciej Grochowski</name></author><category term="reverse-engineering" /><category term="firmware" /><category term="architecture" /><summary type="html"><![CDATA[A four-slot pool meets a twelve-rule request. Tracing allocation, boot phases, and error handling shows how firmware can report boot complete after partition binding fails.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://page-fault.io/assets/social-preview.png" /><media:content medium="image" url="https://page-fault.io/assets/social-preview.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Register Maps, MMIO Blocks, and Other Hardware Buried in Code</title><link href="https://page-fault.io/reverse-engineering/firmware/hardware/2026/10/17/register-maps-mmio-blocks-and-other-hardware-buried-in-code.html" rel="alternate" type="text/html" title="Register Maps, MMIO Blocks, and Other Hardware Buried in Code" /><published>2026-10-17T00:00:04+00:00</published><updated>2026-10-17T00:00:04+00:00</updated><id>https://page-fault.io/reverse-engineering/firmware/hardware/2026/10/17/register-maps-mmio-blocks-and-other-hardware-buried-in-code</id><content type="html" xml:base="https://page-fault.io/reverse-engineering/firmware/hardware/2026/10/17/register-maps-mmio-blocks-and-other-hardware-buried-in-code.html"><![CDATA[<p>We have identified algorithm implementations from <a href="/reverse-engineering/firmware/crypto/2026/10/08/sboxes-hash-constants-and-other-cryptographic-fingerprints.html">lookup tables</a>, <a href="/reverse-engineering/firmware/structs/2026/10/15/offsets-embedded-nodes-and-other-phantoms-of-lost-structs.html">recovered struct layouts</a> from offset arithmetic, and <a href="/reverse-engineering/firmware/tools/2026/10/17/radare2-ghidra-and-other-ways-to-tear-apart-a-binary.html">compared three analysis tools</a>. Now we face a different challenge: the firmware talks to hardware.</p>

<p>With <strong>memory-mapped I/O</strong> (MMIO), loads and stores access peripheral registers through addresses in the processor’s address space. In this sample, a store to virtual address <code class="language-plaintext highlighter-rouge">0xB8000000</code> writes UART transmit data. A load from <code class="language-plaintext highlighter-rouge">0xB8020000</code> reads the timer counter. The nearby address <code class="language-plaintext highlighter-rouge">0xB8020004</code> holds the timer’s compare value.</p>

<p>The addresses alone do not tell us those meanings. We need to follow the loads, stores, and surrounding control flow.</p>

<blockquote>
  <p><strong>Follow along:</strong> Use <code class="language-plaintext highlighter-rouge">firmware.bin</code> from the <a href="/assets/firmware-re/advanced-sample.tar.gz">sample bundle</a>, and run the examples from <code class="language-plaintext highlighter-rouge">more_complex_firmware/</code>. The assembly excerpts below were disassembled from that binary with Capstone. Comments explain the instructions; gaps between address ranges omit unrelated instructions. The companion source provides a cross-check after analysis.</p>
</blockquote>

<hr />

<h2 id="how-mmio-looks-in-mips-assembly">How MMIO Looks in MIPS Assembly</h2>

<p>In the conventional MIPS32 segment layout used here, <strong>KSEG1</strong> is the uncached, unmapped virtual address range <code class="language-plaintext highlighter-rouge">0xA0000000</code>–<code class="language-plaintext highlighter-rouge">0xBFFFFFFF</code>. “Unmapped” means that accesses bypass the translation lookaside buffer (TLB). The physical address is <code class="language-plaintext highlighter-rouge">virtual_address &amp; 0x1FFFFFFF</code>, so <code class="language-plaintext highlighter-rouge">0xB8000000</code> accesses physical address <code class="language-plaintext highlighter-rouge">0x18000000</code>.</p>

<p>KSEG1 can address RAM as well as peripherals. For example, <code class="language-plaintext highlighter-rouge">0x80080000</code> in KSEG0 and <code class="language-plaintext highlighter-rouge">0xA0080000</code> in KSEG1 alias the same physical RAM at <code class="language-plaintext highlighter-rouge">0x00080000</code>, with different cache attributes. An address in KSEG1 is therefore a clue about access policy, not proof of MMIO. The <a href="https://faculty.kfupm.edu.sa/coe/aimane/coe301/tools_manuals/MIPS32Vol3.PrivilegedArchitecture.pdf">MIPS32 privileged architecture manual, Section 4.6</a>, describes this mapping.</p>

<p>Our sample’s peripheral accesses cluster around <code class="language-plaintext highlighter-rouge">0xB8000000</code>–<code class="language-plaintext highlighter-rouge">0xB8060000</code>. The compiler often constructs a base with <code class="language-plaintext highlighter-rouge">lui</code> and then uses it in <code class="language-plaintext highlighter-rouge">lw</code> or <code class="language-plaintext highlighter-rouge">sw</code>. Here is the actual UART transmit store:</p>

<figure class="highlight"><pre><code class="language-text" data-lang="text">80002140:  lui   v0, 0xb800
80002144:  sw    a0, (v0)           # write character to 0xB8000000</code></pre></figure>

<p>The status read uses a separate register for the base:</p>

<figure class="highlight"><pre><code class="language-text" data-lang="text">80002130:  lui   v1, 0xb800
80002134:  lw    v0, 8(v1)          # read 0xB8000008</code></pre></figure>

<p>A <code class="language-plaintext highlighter-rouge">lui 0xB8xx</code> instruction gives us a candidate address construction. We still need to find the load or store that uses it.</p>

<hr />

<h2 id="step-1-find-candidate-peripheral-bases">Step 1: Find Candidate Peripheral Bases</h2>

<p>Start with the executable ranges of the bundled binary. The companion ELF identifies <code class="language-plaintext highlighter-rouge">.vectors</code> at file offsets <code class="language-plaintext highlighter-rouge">[0x0000, 0x02AC)</code> and <code class="language-plaintext highlighter-rouge">.text</code> at <code class="language-plaintext highlighter-rouge">[0x02B0, 0x7290)</code>. For an unknown image, identifying code ranges is itself part of the analysis; these bounds are specific to this build.</p>

<p>The following scan looks for <code class="language-plaintext highlighter-rouge">lui</code> instructions in those ranges whose upper immediate falls in our candidate peripheral window:</p>

<figure class="highlight"><pre><code class="language-python" data-lang="python"><span class="kn">from</span> <span class="n">collections</span> <span class="kn">import</span> <span class="n">Counter</span>
<span class="kn">import</span> <span class="n">struct</span>

<span class="n">data</span> <span class="o">=</span> <span class="nf">open</span><span class="p">(</span><span class="sh">"</span><span class="s">firmware.bin</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">rb</span><span class="sh">"</span><span class="p">).</span><span class="nf">read</span><span class="p">()</span>
<span class="n">code_ranges</span> <span class="o">=</span> <span class="p">[(</span><span class="mh">0x0000</span><span class="p">,</span> <span class="mh">0x02AC</span><span class="p">),</span> <span class="p">(</span><span class="mh">0x02B0</span><span class="p">,</span> <span class="mh">0x7290</span><span class="p">)]</span>
<span class="n">counts</span> <span class="o">=</span> <span class="nc">Counter</span><span class="p">()</span>

<span class="k">for</span> <span class="n">start</span><span class="p">,</span> <span class="n">end</span> <span class="ow">in</span> <span class="n">code_ranges</span><span class="p">:</span>
    <span class="k">for</span> <span class="n">offset</span> <span class="ow">in</span> <span class="nf">range</span><span class="p">(</span><span class="n">start</span><span class="p">,</span> <span class="n">end</span><span class="p">,</span> <span class="mi">4</span><span class="p">):</span>
        <span class="n">word</span> <span class="o">=</span> <span class="n">struct</span><span class="p">.</span><span class="nf">unpack_from</span><span class="p">(</span><span class="sh">"</span><span class="s">&lt;I</span><span class="sh">"</span><span class="p">,</span> <span class="n">data</span><span class="p">,</span> <span class="n">offset</span><span class="p">)[</span><span class="mi">0</span><span class="p">]</span>
        <span class="c1"># LUI opcode, with its required zero rs field.
</span>        <span class="nf">if </span><span class="p">(</span><span class="n">word</span> <span class="o">&amp;</span> <span class="mh">0xFFE00000</span><span class="p">)</span> <span class="o">!=</span> <span class="mh">0x3C000000</span><span class="p">:</span>
            <span class="k">continue</span>
        <span class="n">upper</span> <span class="o">=</span> <span class="n">word</span> <span class="o">&amp;</span> <span class="mh">0xFFFF</span>
        <span class="k">if</span> <span class="mh">0xB800</span> <span class="o">&lt;=</span> <span class="n">upper</span> <span class="o">&lt;=</span> <span class="mh">0xB8FF</span><span class="p">:</span>
            <span class="n">counts</span><span class="p">[</span><span class="n">upper</span> <span class="o">&lt;&lt;</span> <span class="mi">16</span><span class="p">]</span> <span class="o">+=</span> <span class="mi">1</span>

<span class="k">for</span> <span class="n">base</span><span class="p">,</span> <span class="n">count</span> <span class="ow">in</span> <span class="nf">sorted</span><span class="p">(</span><span class="n">counts</span><span class="p">.</span><span class="nf">items</span><span class="p">()):</span>
    <span class="nf">print</span><span class="p">(</span><span class="sa">f</span><span class="sh">"</span><span class="s">0x</span><span class="si">{</span><span class="n">base</span><span class="si">:</span><span class="mi">08</span><span class="n">X</span><span class="si">}</span><span class="s">  </span><span class="si">{</span><span class="n">count</span><span class="si">:</span><span class="mi">2</span><span class="n">d</span><span class="si">}</span><span class="s"> LUI instructions</span><span class="sh">"</span><span class="p">)</span>
<span class="nf">print</span><span class="p">(</span><span class="sa">f</span><span class="sh">"</span><span class="s">Total: </span><span class="si">{</span><span class="nf">sum</span><span class="p">(</span><span class="n">counts</span><span class="p">.</span><span class="nf">values</span><span class="p">())</span><span class="si">}</span><span class="s"> LUI instructions</span><span class="sh">"</span><span class="p">)</span></code></pre></figure>

<figure class="highlight"><pre><code class="language-text" data-lang="text">0xB8000000   8 LUI instructions
0xB8010000  12 LUI instructions
0xB8020000   6 LUI instructions
0xB8030000   7 LUI instructions
0xB8040000   4 LUI instructions
0xB8050000   3 LUI instructions
0xB8060000   5 LUI instructions
Total: 45 LUI instructions</code></pre></figure>

<p>Seven candidate blocks, with 45 base constructions. This is not a count of register accesses: one base can serve several loads and stores, and a loop can execute one instruction repeatedly.</p>

<p>A broad scan that checks only the LUI opcode and an immediate in <code class="language-plaintext highlighter-rouge">0xA000</code>–<code class="language-plaintext highlighter-rouge">0xBFFF</code> across the entire flat file produces 51 candidates across 12 upper-address groups. Some are numeric constants in code, including SHA-256 initial-state values; others are data bytes mistaken for instructions. Restricting the scan to code removes the data candidates, and checking the required zero <code class="language-plaintext highlighter-rouge">rs</code> field rejects invalid LUI encodings. Following the constructed values into memory accesses distinguishes addresses from constants.</p>

<hr />

<h2 id="step-2-register-access-pattern-analysis">Step 2: Register Access Pattern Analysis</h2>

<p>Different access patterns suggest different register roles:</p>

<table>
  <thead>
    <tr>
      <th>Observed pattern</th>
      <th>Working inference</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Stores of configuration values during initialization</td>
      <td>Control/configuration register</td>
    </tr>
    <tr>
      <td>Repeated loads followed by a bit test and backward branch</td>
      <td>Polled status register</td>
    </tr>
    <tr>
      <td>Load, mask or OR, then store to the same MMIO address</td>
      <td>Register read-modify-write</td>
    </tr>
    <tr>
      <td>Write a descriptor index after filling descriptors</td>
      <td>Submission notification</td>
    </tr>
    <tr>
      <td>Periodic store of the same magic value</td>
      <td>Watchdog kick or similar trigger</td>
    </tr>
  </tbody>
</table>

<p>These are hypotheses to check against callers, diagnostic strings, and other access sites. A write does not establish that a register is write-only, and a store of <code class="language-plaintext highlighter-rouge">1</code> does not prove write-one-to-clear semantics by itself.</p>

<p>The register tables below list <strong>accesses observed in this binary</strong>. Names come from instruction behavior and the diagnostic register-dump strings. The companion source confirms the intended bit meanings; it does not make unused hardware behavior visible in the binary.</p>

<h3 id="block-0-uart-0xb8000000">Block 0: UART (0xB8000000)</h3>

<p>The initialization function at <code class="language-plaintext highlighter-rouge">0x800023FC</code> writes three configuration values:</p>

<figure class="highlight"><pre><code class="language-text" data-lang="text">800023fc:  lui   v0, 0xb800
80002400:  addiu v1, zero, 1
80002404:  sw    v1, 0x10(v0)       # BAUD = 1
80002408:  addiu v1, zero, 3
8000240c:  sw    v1, 0xc(v0)        # CTRL = 3
80002410:  addiu v1, zero, 7
80002414:  sw    v1, 0x14(v0)       # FIFO control = 7</code></pre></figure>

<p>The transmit routine at <code class="language-plaintext highlighter-rouge">0x80002130</code> gives us a stronger behavioral clue:</p>

<figure class="highlight"><pre><code class="language-text" data-lang="text">80002130:  lui   v1, 0xb800
80002134:  lw    v0, 8(v1)          # STATUS
80002138:  andi  v0, v0, 1          # test bit 0
8000213c:  bnez  v0, 0x80002134     # wait while busy
80002140:  lui   v0, 0xb800         # branch delay slot; restore TX base
80002144:  sw    a0, (v0)           # TX data
80002148:  jr    ra
8000214c:  nop</code></pre></figure>

<p>The <code class="language-plaintext highlighter-rouge">lui</code> in the branch delay slot executes on both outcomes. Keeping the base in <code class="language-plaintext highlighter-rouge">v1</code> lets the loop overwrite <code class="language-plaintext highlighter-rouge">v0</code> with status; the delay-slot instruction then prepares <code class="language-plaintext highlighter-rouge">v0</code> for the transmit store.</p>

<table>
  <thead>
    <tr>
      <th>Offset</th>
      <th>Name</th>
      <th>Observed access</th>
      <th>Interpretation</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">+0x00</code></td>
      <td>TX</td>
      <td>W</td>
      <td>Transmit character</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">+0x04</code></td>
      <td>RX</td>
      <td>R</td>
      <td>Receive character</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">+0x08</code></td>
      <td>STATUS</td>
      <td>R</td>
      <td>Bit 0 polled before TX; bit 1 before RX</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">+0x0C</code></td>
      <td>CTRL</td>
      <td>R/W</td>
      <td>Written with <code class="language-plaintext highlighter-rouge">3</code>; source names bits 0–1 TX/RX enable</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">+0x10</code></td>
      <td>BAUD</td>
      <td>R/W</td>
      <td>Divisor/configuration value</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">+0x14</code></td>
      <td>FIFO_CTRL</td>
      <td>R/W</td>
      <td>FIFO configuration</td>
    </tr>
  </tbody>
</table>

<p>The diagnostic command reads CTRL, BAUD, and FIFO_CTRL, so calling them write-only would contradict the firmware. The exact baud rate still depends on a clock the register writes alone do not establish.</p>

<h3 id="block-1-gpio-0xb8010000">Block 1: GPIO (0xB8010000)</h3>

<p>The GPIO initializer at <code class="language-plaintext highlighter-rouge">0x80002420</code> writes direction, pinmux, data, and interrupt registers. These excerpts retain the original addresses:</p>

<figure class="highlight"><pre><code class="language-text" data-lang="text">80002428:  lui   v0, 0xb801
8000242c:  sw    zero, 4(v0)        # DIR = 0

80002438:  sw    zero, 0x1c(v0)     # PINMUX = 0

80002444:  sw    zero, (v0)         # DATA = 0
80002448:  sw    zero, 8(v0)        # INT_EN = 0
8000244c:  addiu v1, zero, -1
80002450:  sw    v1, 0xc(v0)        # INT_STATUS = 0xFFFFFFFF</code></pre></figure>

<p>Direction changes use a <strong>RAM shadow</strong>. On the output branch of <code class="language-plaintext highlighter-rouge">gpio_set_direction</code>, <code class="language-plaintext highlighter-rouge">a0</code> holds the pin number and <code class="language-plaintext highlighter-rouge">v0</code> initially holds <code class="language-plaintext highlighter-rouge">1</code>:</p>

<figure class="highlight"><pre><code class="language-text" data-lang="text">80002484:  sllv  a0, v0, a0         # mask = 1 &lt;&lt; pin
80002488:  lui   v0, 0x8008
8000248c:  lw    v0, 0x6d4(v0)      # read RAM shadow at 0x800806D4
80002490:  or    v0, a0, v0
80002494:  lui   v1, 0x8008
80002498:  sw    v0, 0x6d4(v1)      # update RAM shadow
8000249c:  lui   v1, 0xb801
800024a0:  sw    v0, 4(v1)          # write shadow to GPIO DIR</code></pre></figure>

<p>This is a read-modify-write of the shadow, followed by an MMIO write. The interrupt-enable routines do read-modify-write the hardware register itself. Tracking the address at each access distinguishes the two cases.</p>

<table>
  <thead>
    <tr>
      <th>Offset</th>
      <th>Name</th>
      <th>Observed access</th>
      <th>Interpretation</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">+0x00</code></td>
      <td>DATA</td>
      <td>R/W</td>
      <td>Read pin data; initialize output data</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">+0x04</code></td>
      <td>DIR</td>
      <td>R/W</td>
      <td>Direction; setters write a RAM shadow</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">+0x08</code></td>
      <td>INT_EN</td>
      <td>R/W</td>
      <td>Interrupt-enable mask</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">+0x0C</code></td>
      <td>INT_STATUS</td>
      <td>R/W</td>
      <td>Pending bits; single-bit/all-ones writes suggest W1C</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">+0x10</code></td>
      <td>SET</td>
      <td>W</td>
      <td>Write a pin mask to set output</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">+0x14</code></td>
      <td>CLR</td>
      <td>W</td>
      <td>Write a pin mask to clear output</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">+0x18</code></td>
      <td>TOGGLE</td>
      <td>W</td>
      <td>Write a pin mask to toggle output</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">+0x1C</code></td>
      <td>PINMUX</td>
      <td>R/W</td>
      <td>Two configuration bits per pin</td>
    </tr>
  </tbody>
</table>

<p>The all-ones write during initialization and single-bit writes in <code class="language-plaintext highlighter-rouge">gpio_int_clear</code> support the write-one-to-clear interpretation. The companion source states that intent explicitly.</p>

<h3 id="block-2-timer-0xb8020000">Block 2: Timer (0xB8020000)</h3>

<p>The timer initializer at <code class="language-plaintext highlighter-rouge">0x80002610</code> stops the timer before configuring it:</p>

<figure class="highlight"><pre><code class="language-text" data-lang="text">80002628:  lui   v0, 0xb802
8000262c:  sw    zero, 8(v0)        # stop timer
80002630:  addiu v1, zero, 1
80002634:  sw    v1, 0x10(v0)       # prescaler = 1
80002638:  addiu a0, zero, 0x3e8
8000263c:  sw    a0, 4(v0)          # compare = 1000
80002640:  sw    zero, (v0)         # reset counter
80002644:  sw    v1, 0xc(v0)        # acknowledge pending match
80002648:  addiu v1, zero, 7
8000264c:  sw    v1, 8(v0)          # enable, auto-reload, interrupt enable</code></pre></figure>

<p>The source names the three control bits and intends a 1 ms tick. Establishing the actual period would also require the timer input clock and divider semantics.</p>

<p>The handler at <code class="language-plaintext highlighter-rouge">0x800026E4</code> acknowledges immediately:</p>

<figure class="highlight"><pre><code class="language-text" data-lang="text">800026ec:  lui   v0, 0xb802
800026f0:  addiu v1, zero, 1
800026f4:  sw    v1, 0xc(v0)        # acknowledge without reading STATUS
800026f8:  lui   v0, 0x8008
800026fc:  lw    v1, 0x6e4(v0)      # software tick counter
80002700:  addiu v1, v1, 1
80002704:  sw    v1, 0x6e4(v0)

80002720:  jal   0x80000cb8         # rtos_tick
80002724:  nop</code></pre></figure>

<p>There is no status read before acknowledgment. Also, the sample’s exception handler never dispatches to this ISR. Finding a handler body and finding a working interrupt path are separate steps.</p>

<table>
  <thead>
    <tr>
      <th>Offset</th>
      <th>Name</th>
      <th>Observed access</th>
      <th>Interpretation</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">+0x00</code></td>
      <td>COUNT</td>
      <td>R/W</td>
      <td>Counter, reset to zero during initialization</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">+0x04</code></td>
      <td>COMPARE</td>
      <td>R/W</td>
      <td>Compare value</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">+0x08</code></td>
      <td>CTRL</td>
      <td>R/W</td>
      <td>Source-defined enable, auto-reload, and interrupt bits</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">+0x0C</code></td>
      <td>STATUS</td>
      <td>R/W</td>
      <td>Read by diagnostics; written with <code class="language-plaintext highlighter-rouge">1</code> to acknowledge</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">+0x10</code></td>
      <td>PRESCALER</td>
      <td>R/W</td>
      <td>Divider configuration</td>
    </tr>
  </tbody>
</table>

<h3 id="block-3-system-control-0xb8030000">Block 3: System Control (0xB8030000)</h3>

<p>Seven <code class="language-plaintext highlighter-rouge">lui</code> instructions construct this base. Their uses include startup markers, version reads, reset, error reporting, and diagnostics.</p>

<p>The reset command writes <code class="language-plaintext highlighter-rouge">1</code> and then loops:</p>

<figure class="highlight"><pre><code class="language-text" data-lang="text">80004bb4:  lui   v0, 0xb803
80004bb8:  addiu v1, zero, 1
80004bbc:  sw    v1, (v0)
80004bc0:  b     0x80004bc0
80004bc4:  nop</code></pre></figure>

<p>The runtime-start phase writes a different register:</p>

<figure class="highlight"><pre><code class="language-text" data-lang="text">800002f8:  lui   v0, 0xb803
800002fc:  ori   v1, zero, 0xbeef
80000300:  sw    v1, 0x14(v0)       # BOOT_STATUS</code></pre></figure>

<p>That marker means execution reached runtime start. Part 5 will show why it does not prove that all earlier phases succeeded.</p>

<table>
  <thead>
    <tr>
      <th>Offset</th>
      <th>Diagnostic name</th>
      <th>Observed access</th>
      <th>Interpretation</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">+0x00</code></td>
      <td>RESET</td>
      <td>R/W</td>
      <td>Written with <code class="language-plaintext highlighter-rouge">0</code> during init and <code class="language-plaintext highlighter-rouge">1</code> by reset command</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">+0x04</code></td>
      <td>CLOCK</td>
      <td>R</td>
      <td>Read by register dump; no clock configuration write</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">+0x08</code></td>
      <td>VERSION</td>
      <td>R</td>
      <td>Logged during hardware init</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">+0x0C</code></td>
      <td>SCRATCH0</td>
      <td>R/W</td>
      <td>Startup writes <code class="language-plaintext highlighter-rouge">0xB007</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">+0x10</code></td>
      <td>SCRATCH1</td>
      <td>R/W</td>
      <td>Init failure writes an encoded phase/error</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">+0x14</code></td>
      <td>BOOT_STAT</td>
      <td>R/W</td>
      <td>Runtime-start marker <code class="language-plaintext highlighter-rouge">0xBEEF</code></td>
    </tr>
  </tbody>
</table>

<p>The “configuring system clock” log string does not establish a clock-register write. Following its instructions reveals a version read and reset handling instead.</p>

<h3 id="block-4-dma-controller-0xb8040000">Block 4: DMA Controller (0xB8040000)</h3>

<p>The DMA initializer at <code class="language-plaintext highlighter-rouge">0x80003960</code> prepares a 16-entry descriptor ring. Here <code class="language-plaintext highlighter-rouge">a0</code> already holds the ring address, <code class="language-plaintext highlighter-rouge">0x80081314</code>:</p>

<figure class="highlight"><pre><code class="language-text" data-lang="text">800039bc:  lui   v0, 0xb804
800039c0:  sw    zero, (v0)         # disable during setup
800039c4:  sw    a0, 8(v0)          # descriptor ring address
800039c8:  addiu v1, zero, 0x10
800039cc:  sw    v1, 0xc(v0)        # 16 descriptors
800039d0:  sw    zero, 0x10(v0)     # HEAD_IDX = 0
800039d4:  sw    zero, 0x14(v0)     # TAIL_IDX = 0
800039d8:  addiu v1, zero, -1
800039dc:  sw    v1, 0x18(v0)       # clear interrupt status
800039e0:  addiu v1, zero, 3
800039e4:  sw    v1, (v0)           # enable + interrupt enable</code></pre></figure>

<p>Submission fills a descriptor, advances the producer index, and writes <strong>HEAD_IDX at <code class="language-plaintext highlighter-rouge">+0x10</code></strong>:</p>

<figure class="highlight"><pre><code class="language-text" data-lang="text">80003a34:  sw    a0, (v1)           # descriptor source
80003a38:  sw    a1, 4(v1)          # destination
80003a3c:  sw    a2, 8(v1)          # length
80003a40:  lui   a0, 0x9000
80003a44:  sw    a0, 0xc(v1)        # OWN | IRQ flags
80003a48:  addiu v0, v0, 1
80003a4c:  andi  v0, v0, 0xf        # producer index wraps modulo 16
80003a50:  sw    v0, 0x1310(t1)     # g_dma_head in RAM

80003a60:  lui   v1, 0xb804
80003a64:  sw    v0, 0x10(v1)       # publish new head to DMA controller</code></pre></figure>

<p>Names alone would have led us astray. In this driver, <code class="language-plaintext highlighter-rouge">head</code> is the producer’s next submission slot, and <code class="language-plaintext highlighter-rouge">tail</code> is software’s next completion slot. <code class="language-plaintext highlighter-rouge">dma_poll</code> reads descriptor flags from RAM:</p>

<figure class="highlight"><pre><code class="language-text" data-lang="text">80003b58:  lw    fp, 0x130c(s1)     # g_dma_tail; s1 = 0x80080000
80003b5c:  sll   v0, fp, 4          # 16-byte descriptor stride
80003b60:  addu  v0, s0, v0         # s0 = descriptor ring base
80003b64:  lw    v0, 0xc(v0)        # descriptor flags
80003b68:  bltz  v0, 0x80003ba4     # bit 31: still owned by DMA, stop
80003b6c:  and   v0, v0, s4         # delay slot; s4 = error-bit mask</code></pre></figure>

<p>Once ownership returns, the routine checks descriptor error/completion bits, clears the descriptor, and advances <code class="language-plaintext highlighter-rouge">g_dma_tail</code> in RAM. It does not use <code class="language-plaintext highlighter-rouge">DMA_STATUS</code> or the MMIO TAIL_IDX register to detect completion.</p>

<p>This is the same broad producer/consumer idea used by queue doorbells, but head/tail names depend on the interface. In <a href="https://spdk.io/doc/nvme_spec.html">NVMe</a>, host software advances the submission queue’s <strong>tail</strong> and the completion queue’s <strong>head</strong>. Here the submission producer is called <strong>head</strong>. Our firmware initializes the MMIO tail at <code class="language-plaintext highlighter-rouge">+0x14</code> and reads it in diagnostics; it contains no device implementation from which to establish subsequent hardware tail updates.</p>

<table>
  <thead>
    <tr>
      <th>Offset</th>
      <th>Name</th>
      <th>Observed access</th>
      <th>Interpretation</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">+0x00</code></td>
      <td>CTRL</td>
      <td>R/W</td>
      <td>Disable during setup; then enable + interrupt enable</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">+0x04</code></td>
      <td>STATUS</td>
      <td>R</td>
      <td>Diagnostic read; not used by completion polling</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">+0x08</code></td>
      <td>DESC_BASE</td>
      <td>R/W</td>
      <td>Ring address written as <code class="language-plaintext highlighter-rouge">0x80081314</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">+0x0C</code></td>
      <td>DESC_COUNT</td>
      <td>W</td>
      <td>Set to <code class="language-plaintext highlighter-rouge">16</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">+0x10</code></td>
      <td>HEAD_IDX</td>
      <td>R/W</td>
      <td>Software writes producer head on submission</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">+0x14</code></td>
      <td>TAIL_IDX</td>
      <td>R/W</td>
      <td>Initialized to zero; read by diagnostics</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">+0x18</code></td>
      <td>INT_STATUS</td>
      <td>W</td>
      <td>All-ones acknowledgment writes</td>
    </tr>
  </tbody>
</table>

<p>The ring-address store passes the linked KSEG0 address directly. The sample has no address conversion or cache-maintenance code here, so these instructions do not establish a working physical DMA mapping.</p>

<h3 id="block-5-interrupt-controller-0xb8050000">Block 5: Interrupt Controller (0xB8050000)</h3>

<p>Hardware initialization masks all sources:</p>

<figure class="highlight"><pre><code class="language-text" data-lang="text">80000410:  lui   v0, 0xb805
80000414:  sw    zero, (v0)</code></pre></figure>

<p>Runtime start selects three sources:</p>

<figure class="highlight"><pre><code class="language-text" data-lang="text">800002d0:  lui   v0, 0xb805
800002d4:  addiu v1, zero, 0xb
800002d8:  sw    v1, (v0)           # bits 0, 1, and 3</code></pre></figure>

<p>The source names those bits UART, timer, and DMA. The mask write configures the controller; it does not establish CPU interrupt enablement or dispatch to a peripheral handler.</p>

<table>
  <thead>
    <tr>
      <th>Offset</th>
      <th>Diagnostic name</th>
      <th>Observed access</th>
      <th>Interpretation</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">+0x00</code></td>
      <td>MASK</td>
      <td>R/W</td>
      <td>Written with <code class="language-plaintext highlighter-rouge">0</code> and <code class="language-plaintext highlighter-rouge">0x0B</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">+0x04</code></td>
      <td>PENDING</td>
      <td>R</td>
      <td>Diagnostic read</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">+0x0C</code></td>
      <td>PRIORITY</td>
      <td>R</td>
      <td>Diagnostic read</td>
    </tr>
  </tbody>
</table>

<p>The source header also defines ACK at <code class="language-plaintext highlighter-rouge">+0x08</code>, but the binary never accesses that address. Leave it out of the recovered map: its presence is knowledge from source.</p>

<h3 id="block-6-watchdog-0xb8060000">Block 6: Watchdog (0xB8060000)</h3>

<p>Hardware initialization sets a timeout, enables the watchdog, and kicks it:</p>

<figure class="highlight"><pre><code class="language-text" data-lang="text">800003f4:  lui   v0, 0xb806
800003f8:  ori   v1, zero, 0xffff
800003fc:  sw    v1, 8(v0)          # timeout
80000400:  addiu v1, zero, 1
80000404:  sw    v1, (v0)           # enable
80000408:  addiu v1, zero, 0x5a5a
8000040c:  sw    v1, 0xc(v0)        # kick</code></pre></figure>

<p>Runtime start and the main loop repeat the kick. The hidden <code class="language-plaintext highlighter-rouge">$wdgtest</code> command shortens the timeout:</p>

<figure class="highlight"><pre><code class="language-text" data-lang="text">80004b88:  lui   v0, 0xb806
80004b8c:  addiu v1, zero, 0x10
80004b90:  sw    v1, 8(v0)</code></pre></figure>

<p>That command returns to the main loop, which continues kicking the watchdog. A short timeout alone does not prove that the device will reset.</p>

<table>
  <thead>
    <tr>
      <th>Offset</th>
      <th>Name</th>
      <th>Observed access</th>
      <th>Interpretation</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">+0x00</code></td>
      <td>CTRL</td>
      <td>R/W</td>
      <td>Written with <code class="language-plaintext highlighter-rouge">1</code> to enable</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">+0x04</code></td>
      <td>COUNT</td>
      <td>R</td>
      <td>Diagnostic read</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">+0x08</code></td>
      <td>TIMEOUT</td>
      <td>R/W</td>
      <td>Boot timeout <code class="language-plaintext highlighter-rouge">0xFFFF</code>; test writes <code class="language-plaintext highlighter-rouge">0x10</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">+0x0C</code></td>
      <td>KICK</td>
      <td>W</td>
      <td>Repeated writes of <code class="language-plaintext highlighter-rouge">0x5A5A</code></td>
    </tr>
  </tbody>
</table>

<hr />

<h2 id="step-3-the-observed-peripheral-map">Step 3: The Observed Peripheral Map</h2>

<p>Following the candidate bases into loads and stores gives this map:</p>

<table>
  <thead>
    <tr>
      <th>Virtual base</th>
      <th>Block</th>
      <th>Distinct register addresses observed</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">0xB8000000</code></td>
      <td>UART</td>
      <td>6</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">0xB8010000</code></td>
      <td>GPIO</td>
      <td>8</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">0xB8020000</code></td>
      <td>Timer</td>
      <td>5</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">0xB8030000</code></td>
      <td>System control</td>
      <td>6</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">0xB8040000</code></td>
      <td>DMA</td>
      <td>7</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">0xB8050000</code></td>
      <td>Interrupt controller</td>
      <td>3</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">0xB8060000</code></td>
      <td>Watchdog</td>
      <td>4</td>
    </tr>
    <tr>
      <td><strong>Total</strong></td>
      <td><strong>7 blocks</strong></td>
      <td><strong>39 addresses</strong></td>
    </tr>
  </tbody>
</table>

<p>These are distinct addresses used by instructions, not 39 accesses at runtime or a complete hardware specification. Diagnostic strings provide names for many registers; the read/write patterns show how this firmware uses them. Bit semantics such as write-one-to-clear remain interpretations supported by the code and confirmed by the sample source.</p>

<hr />

<h2 id="automating-the-search">Automating the Search</h2>

<p>The Step 1 script is a first pass. To turn its candidates into a register map, follow each constructed value through register copies and address arithmetic, then record the effective address of each load or store.</p>

<p>For this build, the distinction matters in several places: one UART base feeds repeated status reads; GPIO direction uses a RAM shadow; DMA completion flags live in RAM. A useful scanner reports instruction addresses and what it counted, so we can inspect those cases. A <code class="language-plaintext highlighter-rouge">lui</code> histogram alone cannot recover register ownership or side effects.</p>

<p>When adapting the scan to another firmware, replace the code ranges and candidate address window with evidence from that image. Broadening the search to all KSEG1 immediates is useful for triage, provided the output remains a list of candidates to verify.</p>

<hr />

<h2 id="exercises">Exercises</h2>

<ol>
  <li>
    <p><strong>Trace the timer handler</strong>: Find the function at <code class="language-plaintext highlighter-rouge">0x800026E4</code> that writes <code class="language-plaintext highlighter-rouge">1</code> to <code class="language-plaintext highlighter-rouge">0xB802000C</code> and calls <code class="language-plaintext highlighter-rouge">0x80000CB8</code>. Does the exception handler ever dispatch to it? Distinguish a present ISR body from an active interrupt path.</p>
  </li>
  <li>
    <p><strong>Map the DMA descriptors</strong>: From initialization and submission, recover the ring address, entry count, and descriptor stride. Identify the source, destination, length, and flags fields. Which bit makes <code class="language-plaintext highlighter-rouge">dma_poll</code> stop, and which MMIO index does submission write?</p>
  </li>
  <li>
    <p><strong>Trace boot markers</strong>: Follow writes to system-control offsets <code class="language-plaintext highlighter-rouge">+0x0C</code>, <code class="language-plaintext highlighter-rouge">+0x10</code>, and <code class="language-plaintext highlighter-rouge">+0x14</code>. What do <code class="language-plaintext highlighter-rouge">0xB007</code>, the phase/error encoding, and <code class="language-plaintext highlighter-rouge">0xBEEF</code> tell us about execution?</p>
  </li>
  <li>
    <p><strong>Check the clock claim</strong>: Find every reference to <code class="language-plaintext highlighter-rouge">0xB8030004</code>. Is it read or written? Compare the result with the “configuring system clock” log message. What evidence would be needed to recover actual clock configuration?</p>
  </li>
</ol>

<hr />

<h2 id="whats-next">What’s Next</h2>

<p>We’ve mapped the addresses the firmware uses, identified its peripheral access patterns, and seen where the available evidence stops. In the final post, we’ll combine that map with struct recovery and dispatch tables to examine the pool allocator, scheduler code, and rule state transitions during boot.</p>

<hr />

<p><em>This is Part 4 of “Crypto Tables, Struct Ghosts, and Other Secrets Hidden in Firmware.”</em></p>

<p><em>Next: <a href="/reverse-engineering/firmware/architecture/2026/10/17/sentinel-values-pool-modes-and-other-clues-to-runtime-architecture.html">Part 5 — Sentinel Values, Pool Modes, and Other Clues to Runtime Architecture</a>.</em></p>]]></content><author><name>Maciej Grochowski</name></author><category term="reverse-engineering" /><category term="firmware" /><category term="hardware" /><summary type="html"><![CDATA[Seven peripheral blocks emerge from the MIPS code. Following loads and stores reveals 39 register addresses, and shows why a base address, an interrupt mask, or a boot marker cannot tell the whole story.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://page-fault.io/assets/social-preview.png" /><media:content medium="image" url="https://page-fault.io/assets/social-preview.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Radare2, Ghidra, and Other Ways to Tear Apart a Binary</title><link href="https://page-fault.io/reverse-engineering/firmware/tools/2026/10/17/radare2-ghidra-and-other-ways-to-tear-apart-a-binary.html" rel="alternate" type="text/html" title="Radare2, Ghidra, and Other Ways to Tear Apart a Binary" /><published>2026-10-17T00:00:03+00:00</published><updated>2026-10-17T00:00:03+00:00</updated><id>https://page-fault.io/reverse-engineering/firmware/tools/2026/10/17/radare2-ghidra-and-other-ways-to-tear-apart-a-binary</id><content type="html" xml:base="https://page-fault.io/reverse-engineering/firmware/tools/2026/10/17/radare2-ghidra-and-other-ways-to-tear-apart-a-binary.html"><![CDATA[<p>In <a href="/reverse-engineering/firmware/crypto/2026/10/08/sboxes-hash-constants-and-other-cryptographic-fingerprints.html">Part 1</a>, we found algorithm tables. In <a href="/reverse-engineering/firmware/structs/2026/10/15/offsets-embedded-nodes-and-other-phantoms-of-lost-structs.html">Part 2</a>, we recovered struct layouts. We now have useful names and addresses, but our notes are doing most of the work. We need to query a table’s users, follow their callers, and keep the names between sessions.</p>

<p>A byte search remains easy in a larger binary. Maintaining function boundaries, references, types, and annotations is where an analysis framework earns its place.</p>

<p>This time we will use <strong>radare2</strong> to build that map in a terminal, then inspect the same firmware with <strong>Ghidra</strong> and <strong>Capstone</strong>. The tools agree on several useful facts. Their output also gives us a few things to question.</p>

<blockquote>
  <p><strong>Follow along:</strong> Download the <a href="/assets/firmware-re/advanced-sample.tar.gz">sample bundle</a>, extract it, and work from <code class="language-plaintext highlighter-rouge">more_complex_firmware/</code>. These examples analyze <code class="language-plaintext highlighter-rouge">firmware.bin</code> as a raw image at <code class="language-plaintext highlighter-rouge">0x80000000</code>. The companion ELF and source are used afterward to check the results.</p>
</blockquote>

<p>The captured output uses radare2 <strong>6.2.4</strong>, Ghidra <strong>12.1.4</strong>, Capstone <strong>5.0.7</strong>, and r2pipe <strong>1.9.6</strong>. Ghidra was run headlessly with JDK 25; its native decompiler was built from the release sources for macOS ARM64. Follow the release’s <a href="https://github.com/NationalSecurityAgency/ghidra/blob/Ghidra_12.1.4_build/GhidraDocs/GettingStarted.md">Getting Started instructions</a>, including native components for your platform. The r2ghidra plugin is optional and is not used for the captured results.</p>

<hr />

<h2 id="load-the-raw-image-in-radare2">Load the Raw Image in Radare2</h2>

<p>A flat binary has no header telling r2 its architecture or load address:</p>

<figure class="highlight"><pre><code class="language-bash" data-lang="bash">r2 <span class="nt">-a</span> mips <span class="nt">-b</span> 32 <span class="nt">-e</span> cfg.bigendian<span class="o">=</span><span class="nb">false</span> <span class="nt">-e</span> anal.cc<span class="o">=</span>o32 <span class="se">\</span>
   <span class="nt">-m</span> 0x80000000 firmware.bin</code></pre></figure>

<table>
  <thead>
    <tr>
      <th>Option</th>
      <th>Meaning</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">-a mips</code></td>
      <td>MIPS instruction set</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">-b 32</code></td>
      <td>32-bit mode</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">-e cfg.bigendian=false</code></td>
      <td>Little-endian byte order</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">-e anal.cc=o32</code></td>
      <td>The calling convention used by this sample</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">-m 0x80000000</code></td>
      <td>Map file offset zero at this address</td>
    </tr>
  </tbody>
</table>

<p>The bit width and calling convention are separate settings. Selecting o32 tells analysis how arguments and return values are passed; it does not guarantee that every inferred function signature is correct.</p>

<p>For the plain-text displays below, disable colors and automatic disassembly comments:</p>

<figure class="highlight"><pre><code class="language-text" data-lang="text">[0x80000000]&gt; e scr.color=0
[0x80000000]&gt; e asm.comments=false
[0x80000000]&gt; s 0x80007f7c
[0x80007f7c]&gt; px 64
- offset -  7C7D 7E7F 8081 8283 8485 8687 8889 8A8B  CDEF0123456789AB
0x80007f7c  637c 777b f26b 6fc5 3001 672b fed7 ab76  c|w{.ko.0.g+...v
0x80007f8c  ca82 c97d fa59 47f0 add4 a2af 9ca4 72c0  ...}.YG.......r.
0x80007f9c  b7fd 9326 363f f7cc 34a5 e5f1 71d8 3115  ...&amp;6?..4...q.1.
0x80007fac  04c7 23c3 1896 059a 0712 80e2 eb27 b275  ..#..........'.u</code></pre></figure>

<p>That is the S-box from Part 1. <code class="language-plaintext highlighter-rouge">s</code> changes the current address, and <code class="language-plaintext highlighter-rouge">px</code> prints bytes. The address in the prompt is the current position in the mapped image.</p>

<h2 id="run-analysis-then-check-what-it-found">Run Analysis, Then Check What It Found</h2>

<p>Return to the image base and ask r2 to analyze it:</p>

<figure class="highlight"><pre><code class="language-text" data-lang="text">[0x80007f7c]&gt; s 0x80000000
[0x80000000]&gt; aaa
[0x80000000]&gt; aflc
75</code></pre></figure>

<p><code class="language-plaintext highlighter-rouge">aaa</code> runs analysis passes that discover functions, references, and control flow. <code class="language-plaintext highlighter-rouge">aflc</code> counts the functions in the current analysis database; <code class="language-plaintext highlighter-rouge">afl</code> lists them.</p>

<p>The number 75 is an analysis result, not the number of functions in the firmware. The companion ELF has <strong>155 symbols explicitly typed as functions</strong>, plus seven untyped startup/vector labels. Under the raw-import settings used later, Ghidra discovers <strong>108 functions</strong>. These counts describe different analysis results; neither automatic pass is a complete inventory.</p>

<p>For a concrete omission, Part 2 located <code class="language-plaintext highlighter-rouge">rtos_sem_wait</code> at <code class="language-plaintext highlighter-rouge">0x80000F98</code>. In this fresh r2 session:</p>

<figure class="highlight"><pre><code class="language-text" data-lang="text">[0x80000000]&gt; afij @ 0x80000f98
[]</code></pre></figure>

<p><code class="language-plaintext highlighter-rouge">afij</code> asks for function information as JSON. An empty array means r2 has no function containing that address in this analysis. Inspect the instructions and analyze the known entry explicitly:</p>

<figure class="highlight"><pre><code class="language-text" data-lang="text">[0x80000000]&gt; af @ 0x80000f98
[0x80000000]&gt; afn rtos_sem_wait 0x80000f98</code></pre></figure>

<p>We supply that address from the earlier analysis. The command does not discover the semaphore’s meaning for us.</p>

<h2 id="find-the-tables">Find the Tables</h2>

<p>Hex searches are short:</p>

<figure class="highlight"><pre><code class="language-text" data-lang="text">[0x80000000]&gt; /x 637c777b
0x80007f7c hit1_0 637c777b
[0x80000000]&gt; /x 982f8a42
0x80008080 hit2_0 982f8a42
[0x80000000]&gt; /x 96300777
0x80007a74 hit3_0 96300777</code></pre></figure>

<p>The last hit is at <code class="language-plaintext highlighter-rouge">0x80007A74</code>, the CRC-32 table’s <strong>second entry</strong>. The table starts four bytes earlier at <code class="language-plaintext highlighter-rouge">0x80007A70</code>. Hit names and numbering can change as searches accumulate.</p>

<p>These prefixes locate candidates. As in Part 1, check the rest of a table and follow its code references before deciding which algorithm a function implements.</p>

<p>Python can do the byte search just as directly:</p>

<figure class="highlight"><pre><code class="language-python" data-lang="python"><span class="n">data</span> <span class="o">=</span> <span class="nf">open</span><span class="p">(</span><span class="sh">"</span><span class="s">firmware.bin</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">rb</span><span class="sh">"</span><span class="p">).</span><span class="nf">read</span><span class="p">()</span>
<span class="n">offset</span> <span class="o">=</span> <span class="n">data</span><span class="p">.</span><span class="nf">find</span><span class="p">(</span><span class="nb">bytes</span><span class="p">.</span><span class="nf">fromhex</span><span class="p">(</span><span class="sh">"</span><span class="s">63 7c 77 7b</span><span class="sh">"</span><span class="p">))</span>
<span class="k">if</span> <span class="n">offset</span> <span class="o">&gt;=</span> <span class="mi">0</span><span class="p">:</span>
    <span class="nf">print</span><span class="p">(</span><span class="sa">f</span><span class="sh">"</span><span class="s">S-box candidate at 0x</span><span class="si">{</span><span class="mh">0x80000000</span> <span class="o">+</span> <span class="n">offset</span><span class="si">:</span><span class="mi">08</span><span class="n">X</span><span class="si">}</span><span class="sh">"</span><span class="p">)</span></code></pre></figure>

<figure class="highlight"><pre><code class="language-text" data-lang="text">S-box candidate at 0x80007F7C</code></pre></figure>

<p>This needs no disassembler. The reason to stay in r2 is what we can do with the address next.</p>

<h2 id="follow-references-into-code">Follow References Into Code</h2>

<p><code class="language-plaintext highlighter-rouge">axt</code> lists the discovered references <strong>to</strong> an address:</p>

<figure class="highlight"><pre><code class="language-text" data-lang="text">[0x80000000]&gt; axt 0x80007f7c
fcn.800063ac 0x800063b8 [DATA:r--] addiu a1, a1, 0x7f7c
fcn.80006440 0x80006474 [DATA:r--] addiu t0, t0, 0x7f7c
fcn.80006440 0x80006514 [DATA:r--] lbu v1, (v1)
fcn.80006440 0x80006520 [DATA:r--] lbu t1, (t1)
fcn.80006440 0x8000652c [DATA:r--] lbu a1, (a1)
fcn.80006440 0x80006538 [DATA:r--] lbu v0, (v0)</code></pre></figure>

<p>The output names two containing functions: <code class="language-plaintext highlighter-rouge">fcn.800063ac</code> and <code class="language-plaintext highlighter-rouge">fcn.80006440</code>. It includes both address construction and indexed table loads. Six reference sites do not mean six different functions.</p>

<p>Inspect the two address constructions:</p>

<figure class="highlight"><pre><code class="language-text" data-lang="text">[0x80000000]&gt; pd 2 @ 0x800063b4
│           0x800063b4      0080053c       lui a1, 0x8000
│           0x800063b8      7c7fa524       addiu a1, a1, 0x7f7c
[0x80000000]&gt; pd 2 @ 0x80006470
│           0x80006470      0080083c       lui t0, 0x8000
│           0x80006474      7c7f0825       addiu t0, t0, 0x7f7c</code></pre></figure>

<p>Both pairs produce <code class="language-plaintext highlighter-rouge">0x80007F7C</code>. From the surrounding code, the first function substitutes bytes in a block; the second uses substitutions during key expansion. We can name them <code class="language-plaintext highlighter-rouge">sub_bytes</code> and <code class="language-plaintext highlighter-rouge">aes_key_expand</code>.</p>

<p>Now follow the helper’s callers:</p>

<figure class="highlight"><pre><code class="language-text" data-lang="text">[0x80000000]&gt; axt 0x800063ac
fcn.80006558 0x800065d4 [CALL:--x] jal fcn.800063ac
fcn.80006558 0x80006730 [CALL:--x] jal fcn.800063ac</code></pre></figure>

<p>Both calls come from the function at <code class="language-plaintext highlighter-rouge">0x80006558</code>. Following the inverse S-box at <code class="language-plaintext highlighter-rouge">0x80007E7C</code> leads through <code class="language-plaintext highlighter-rouge">inv_sub_bytes</code> at <code class="language-plaintext highlighter-rouge">0x800063E0</code> to decryption at <code class="language-plaintext highlighter-rouge">0x800067F0</code>. Table users and the public block-operation functions are different points in the call graph.</p>

<p>The SHA-256 round table gives another example:</p>

<figure class="highlight"><pre><code class="language-text" data-lang="text">[0x80000000]&gt; axt 0x80008080
fcn.80006c10 0x80006cf8 [DATA:r--] addiu a2, a2, -0x7f80
fcn.80006c10 0x80006d50 [DATA:r--] lw s4, (a2)</code></pre></figure>

<p>Here the reference at <code class="language-plaintext highlighter-rouge">0x80006CF8</code> completes an address construction. The <code class="language-plaintext highlighter-rouge">lui a2, 0x8001</code> is nine instructions earlier, at <code class="language-plaintext highlighter-rouge">0x80006CD4</code>. The signed addition <code class="language-plaintext highlighter-rouge">-0x7F80</code> produces <code class="language-plaintext highlighter-rouge">0x80008080</code>. A scanner that only accepts adjacent <code class="language-plaintext highlighter-rouge">lui</code>/<code class="language-plaintext highlighter-rouge">addiu</code> pairs would miss it.</p>

<h2 id="keep-the-names">Keep the Names</h2>

<p>Once the behavior supports a name, record it:</p>

<figure class="highlight"><pre><code class="language-text" data-lang="text">[0x80000000]&gt; afn aes_encrypt_block 0x80006558
[0x80000000]&gt; afn aes_decrypt_block 0x800067f0
[0x80000000]&gt; afn sha256_transform 0x80006c10
[0x80000000]&gt; afn rtos_schedule 0x80000c10
[0x80000000]&gt; pd 5 @ rtos_schedule
┌ 168: rtos_schedule ();
│ afv: vars(2:sp[0x10..0x14])
│           0x80000c10      0880033c       lui v1, 0x8008
│           0x80000c14      8c00628c       lw v0, 0x8c(v1)
│           0x80000c18      8c006324       addiu v1, v1, 0x8c
│       ┌─&lt; 0x80000c1c      24004310       beq v0, v1, 0x80000cb0
│       │   0x80000c20      e0ff4424       addiu a0, v0, -0x20</code></pre></figure>

<p>The bytes and branch targets are unchanged. We have attached names to the functions that analysis already found. The <code class="language-plaintext highlighter-rouge">addiu</code> at <code class="language-plaintext highlighter-rouge">0x80000C20</code> is the <code class="language-plaintext highlighter-rouge">container_of</code> subtraction from Part 2; it also occupies the branch delay slot and executes on both outcomes.</p>

<p>To inspect the control-flow graph, seek to the function and enter visual graph mode:</p>

<figure class="highlight"><pre><code class="language-text" data-lang="text">[0x80000000]&gt; s rtos_schedule
[0x80000c10]&gt; VV</code></pre></figure>

<p>For output that can be captured without an interactive terminal, use <code class="language-plaintext highlighter-rouge">agf @ rtos_schedule</code>. Its first block is shown below, with leading padding removed; the rest of the graph is omitted:</p>

<figure class="highlight"><pre><code class="language-text" data-lang="text">┌─────────────────────────────────────────────────┐
│  0x80000c10                                     │
│   ; CALL XREF from fcn.80000540 @ 0x80000724(x) │
│ 168: rtos_schedule ();                          │
│ afv: vars(2:sp[0x10..0x14])                     │
│ lui v1, 0x8008                                  │
│ lw v0, 0x8c(v1)                                 │
│ addiu v1, v1, 0x8c                              │
│ beq v0, v1, 0x80000cb0                          │
│ addiu a0, v0, -0x20                             │
└─────────────────────────────────────────────────┘</code></pre></figure>

<p>Save the analysis and reopen it in another session:</p>

<figure class="highlight"><pre><code class="language-text" data-lang="text">[0x80000c10]&gt; Ps firmware_re
[0x80000c10]&gt; q</code></pre></figure>

<figure class="highlight"><pre><code class="language-bash" data-lang="bash">r2 <span class="nt">-p</span> firmware_re</code></pre></figure>

<p>The four renamed functions survived a save/reopen check with this version. Keep the rename commands too: they are a small, readable record of the conclusions we supplied. A project stores more state, but a script is easier to inspect in a diff.</p>

<h2 id="automate-the-session-with-r2pipe">Automate the Session With r2pipe</h2>

<p>r2pipe sends commands to a running r2 process and returns text or JSON. Install the Python binding in your analysis environment; these examples use version <code class="language-plaintext highlighter-rouge">1.9.6</code>.</p>

<figure class="highlight"><pre><code class="language-python" data-lang="python"><span class="kn">import</span> <span class="n">r2pipe</span>

<span class="n">r2</span> <span class="o">=</span> <span class="n">r2pipe</span><span class="p">.</span><span class="nf">open</span><span class="p">(</span><span class="sh">"</span><span class="s">firmware.bin</span><span class="sh">"</span><span class="p">,</span> <span class="n">flags</span><span class="o">=</span><span class="p">[</span>
    <span class="sh">"</span><span class="s">-a</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">mips</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">-b</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">32</span><span class="sh">"</span><span class="p">,</span>
    <span class="sh">"</span><span class="s">-e</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">cfg.bigendian=false</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">-e</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">anal.cc=o32</span><span class="sh">"</span><span class="p">,</span>
    <span class="sh">"</span><span class="s">-m</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">0x80000000</span><span class="sh">"</span><span class="p">,</span>
<span class="p">])</span>
<span class="k">try</span><span class="p">:</span>
    <span class="n">r2</span><span class="p">.</span><span class="nf">cmd</span><span class="p">(</span><span class="sh">"</span><span class="s">aaa</span><span class="sh">"</span><span class="p">)</span>
    <span class="k">for</span> <span class="n">pattern</span><span class="p">,</span> <span class="n">label</span> <span class="ow">in</span> <span class="p">[</span>
        <span class="p">(</span><span class="sh">"</span><span class="s">637c777b</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">AES S-box candidate</span><span class="sh">"</span><span class="p">),</span>
        <span class="p">(</span><span class="sh">"</span><span class="s">982f8a42</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">SHA-256 K[] candidate</span><span class="sh">"</span><span class="p">),</span>
        <span class="p">(</span><span class="sh">"</span><span class="s">96300777</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">CRC-32 second-entry candidate</span><span class="sh">"</span><span class="p">),</span>
    <span class="p">]:</span>
        <span class="n">hits</span> <span class="o">=</span> <span class="n">r2</span><span class="p">.</span><span class="nf">cmdj</span><span class="p">(</span><span class="sa">f</span><span class="sh">"</span><span class="s">/xj </span><span class="si">{</span><span class="n">pattern</span><span class="si">}</span><span class="sh">"</span><span class="p">)</span>
        <span class="k">for</span> <span class="n">hit</span> <span class="ow">in</span> <span class="n">hits</span><span class="p">:</span>
            <span class="nf">print</span><span class="p">(</span><span class="sa">f</span><span class="sh">"</span><span class="si">{</span><span class="n">label</span><span class="si">}</span><span class="s">: 0x</span><span class="si">{</span><span class="n">hit</span><span class="p">[</span><span class="sh">'</span><span class="s">addr</span><span class="sh">'</span><span class="p">]</span><span class="si">:</span><span class="mi">08</span><span class="n">X</span><span class="si">}</span><span class="sh">"</span><span class="p">)</span>
    <span class="n">functions</span> <span class="o">=</span> <span class="n">r2</span><span class="p">.</span><span class="nf">cmdj</span><span class="p">(</span><span class="sh">"</span><span class="s">aflj</span><span class="sh">"</span><span class="p">)</span>
    <span class="nf">print</span><span class="p">(</span><span class="sa">f</span><span class="sh">"</span><span class="si">{</span><span class="nf">len</span><span class="p">(</span><span class="n">functions</span><span class="p">)</span><span class="si">}</span><span class="s"> functions in this analysis</span><span class="sh">"</span><span class="p">)</span>
<span class="k">finally</span><span class="p">:</span>
    <span class="n">r2</span><span class="p">.</span><span class="nf">quit</span><span class="p">()</span></code></pre></figure>

<figure class="highlight"><pre><code class="language-text" data-lang="text">AES S-box candidate: 0x80007F7C
SHA-256 K[] candidate: 0x80008080
CRC-32 second-entry candidate: 0x80007A74
75 functions in this analysis</code></pre></figure>

<p>The JSON forms (<code class="language-plaintext highlighter-rouge">/xj</code> and <code class="language-plaintext highlighter-rouge">aflj</code>) avoid parsing display columns. The <code class="language-plaintext highlighter-rouge">try</code>/<code class="language-plaintext highlighter-rouge">finally</code> closes the child process if a search or later processing fails.</p>

<hr />

<h2 id="inspect-the-same-image-in-ghidra">Inspect the Same Image in Ghidra</h2>

<p>Import <code class="language-plaintext highlighter-rouge">firmware.bin</code> as <strong>Raw Binary</strong>, choose <strong>MIPS:LE:32:default</strong>, and set the base address to <code class="language-plaintext highlighter-rouge">0x80000000</code>. Run the default analysis. These are the same image bytes and base address used in r2.</p>

<p>To repeat the S-box search in the GUI, use <strong>Search → Memory…</strong>, select hex input, and search for <code class="language-plaintext highlighter-rouge">63 7C 77 7B</code>. At the result, <strong>References → Show References To</strong> opens the discovered reference list. The <a href="https://ghidra.re/ghidra_docs/GhidraClass/Beginner/Introduction_to_Ghidra_Student_Guide.html">Ghidra introductory guide</a> documents these actions; the results here were exported through Ghidra’s headless API.</p>

<p>For the S-box, that API reports:</p>

<figure class="highlight"><pre><code class="language-text" data-lang="text">80006514 DATA FUN_80006440
80006520 DATA FUN_80006440
8000652c DATA FUN_80006440
80006538 DATA FUN_80006440
800063c4 DATA FUN_800063ac</code></pre></figure>

<p>These are the same two containing functions r2 found, but the reference sites differ. Ghidra reports the indexed load in <code class="language-plaintext highlighter-rouge">sub_bytes</code> at <code class="language-plaintext highlighter-rouge">0x800063C4</code>, while r2 also reports the address-building instruction at <code class="language-plaintext highlighter-rouge">0x800063B8</code>. Agreeing on the table’s users matters more than having identical lists of instruction addresses.</p>

<h3 id="read-the-decompiled-function">Read the Decompiled Function</h3>

<p>Navigate to <code class="language-plaintext highlighter-rouge">0x80006C10</code> and inspect the Decompiler window. This is an excerpt of the actual Ghidra output before renaming or applying source types:</p>

<figure class="highlight"><pre><code class="language-c" data-lang="c">  <span class="n">local_20</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">local_120</span><span class="p">;</span>
  <span class="n">local_1c</span> <span class="o">=</span> <span class="n">local_120</span><span class="p">;</span>
  <span class="k">do</span> <span class="p">{</span>
    <span class="o">*</span><span class="n">local_1c</span> <span class="o">=</span> <span class="p">(</span><span class="n">uint</span><span class="p">)</span><span class="o">*</span><span class="n">param_2</span> <span class="o">&lt;&lt;</span> <span class="mh">0x18</span> <span class="o">|</span> <span class="p">(</span><span class="n">uint</span><span class="p">)</span><span class="n">param_2</span><span class="p">[</span><span class="mi">1</span><span class="p">]</span> <span class="o">&lt;&lt;</span> <span class="mh">0x10</span> <span class="o">|</span> <span class="p">(</span><span class="n">uint</span><span class="p">)</span><span class="n">param_2</span><span class="p">[</span><span class="mi">3</span><span class="p">]</span> <span class="o">|</span>
                <span class="p">(</span><span class="n">uint</span><span class="p">)</span><span class="n">param_2</span><span class="p">[</span><span class="mi">2</span><span class="p">]</span> <span class="o">&lt;&lt;</span> <span class="mi">8</span><span class="p">;</span>
    <span class="n">param_2</span> <span class="o">=</span> <span class="n">param_2</span> <span class="o">+</span> <span class="mi">4</span><span class="p">;</span>
    <span class="n">local_1c</span> <span class="o">=</span> <span class="n">local_1c</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">local_1c</span> <span class="o">!=</span> <span class="n">local_e8</span> <span class="o">+</span> <span class="mi">2</span><span class="p">);</span></code></pre></figure>

<p>Each iteration assembles four input bytes into a big-endian word. Ghidra recovers that expression and the loop condition, but it splits the message schedule across several guessed stack arrays: <code class="language-plaintext highlighter-rouge">local_120</code>, <code class="language-plaintext highlighter-rouge">local_fc</code>, and <code class="language-plaintext highlighter-rouge">local_e8</code>. The original source’s single 64-word array has not been recovered cleanly.</p>

<p>Further down, the round loop ends with:</p>

<figure class="highlight"><pre><code class="language-c" data-lang="c">  <span class="err">}</span> <span class="k">while</span> <span class="p">(</span><span class="n">piVar8</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">s_CLI_initialized_80008180</span><span class="p">);</span></code></pre></figure>

<p>The CLI label looks out of place in a SHA-256 loop. The comparison uses address <code class="language-plaintext highlighter-rouge">0x80008180</code>, immediately after the 256-byte K[] table. Ghidra has named that address after the adjacent string. The code is comparing an end pointer; it never reads the string here. Checking the table extent resolves the apparent connection.</p>

<p>This is the useful boundary of decompilation: it can reconstruct expressions and control flow while still giving an address or local variable a misleading name or type.</p>

<h3 id="compare-with-r2s-pseudocode">Compare With r2’s Pseudocode</h3>

<p>Run <code class="language-plaintext highlighter-rouge">pdc @ sha256_transform</code> in the renamed r2 session. The same input-word loop appears as:</p>

<figure class="highlight"><pre><code class="language-text" data-lang="text">    do {
        // CODE XREF from sha256_transform @ 0x80006c70(x)
        v0 = (unsigned) byte [a1 + 0] // arg2
        v0 &lt;&lt;= 0x18
        v1 = (unsigned) byte [a1 + 1] // arg2
        v1 &lt;&lt;= 0x10
        v0 |= v1
        v1 = (unsigned) byte [a1 + 3] // arg2
        v0 |= v1
        v1 = (unsigned) byte [a1 + 2] // arg2
        v1 &lt;&lt;= 8
        v0 |= v1
        v1 = [sp + 0x104]
        [v1 + 0] = v0
        a1 += 4       // arg2
        v0 = v1 + 4
        [sp + 0x104] = v0
    } while (v0 != a2);</code></pre></figure>

<p>The output retains registers, stack stores, and instruction-like operations. It is useful for following the assembly, while Ghidra’s output combines more operations into C expressions. r2 also inferred a third argument in this function, although the companion source has two. A guessed signature needs the same scrutiny as a guessed struct field.</p>

<p>The optional <a href="https://github.com/radareorg/r2ghidra">r2ghidra plugin</a> provides <code class="language-plaintext highlighter-rouge">pdg</code> using Ghidra’s decompiler engine inside r2. It was not installed for this comparison. Sharing a decompiler engine does not make the surrounding function discovery, types, and analysis inputs identical.</p>

<h2 id="rename-functions-in-either-framework">Rename Functions in Either Framework</h2>

<p>A batch rename is straightforward in both tools. In r2, save these commands as <code class="language-plaintext highlighter-rouge">renames.r2</code>:</p>

<figure class="highlight"><pre><code class="language-text" data-lang="text">aaa
af @ 0x80006558
afn aes_encrypt_block 0x80006558
af @ 0x800067f0
afn aes_decrypt_block 0x800067f0
af @ 0x80006c10
afn sha256_transform 0x80006c10
af @ 0x80000c10
afn rtos_schedule 0x80000c10
Ps firmware_re</code></pre></figure>

<p>Run them in a fresh session:</p>

<figure class="highlight"><pre><code class="language-bash" data-lang="bash">r2 <span class="nt">-q</span> <span class="nt">-a</span> mips <span class="nt">-b</span> 32 <span class="nt">-e</span> cfg.bigendian<span class="o">=</span><span class="nb">false</span> <span class="nt">-e</span> anal.cc<span class="o">=</span>o32 <span class="se">\</span>
   <span class="nt">-m</span> 0x80000000 <span class="nt">-i</span> renames.r2 firmware.bin</code></pre></figure>

<p>Ghidra can do the same work from the GUI or headlessly. Save this script as <code class="language-plaintext highlighter-rouge">RenameFirmware.java</code>:</p>

<figure class="highlight"><pre><code class="language-java" data-lang="java"><span class="kn">import</span> <span class="nn">ghidra.app.script.GhidraScript</span><span class="o">;</span>
<span class="kn">import</span> <span class="nn">ghidra.program.model.listing.Function</span><span class="o">;</span>
<span class="kn">import</span> <span class="nn">ghidra.program.model.symbol.SourceType</span><span class="o">;</span>

<span class="kd">public</span> <span class="kd">class</span> <span class="nc">RenameFirmware</span> <span class="kd">extends</span> <span class="nc">GhidraScript</span> <span class="o">{</span>
    <span class="nd">@Override</span>
    <span class="kd">public</span> <span class="kt">void</span> <span class="nf">run</span><span class="o">()</span> <span class="kd">throws</span> <span class="nc">Exception</span> <span class="o">{</span>
        <span class="kt">long</span><span class="o">[]</span> <span class="n">addresses</span> <span class="o">=</span> <span class="o">{</span><span class="mh">0x80006558</span><span class="no">L</span><span class="o">,</span> <span class="mh">0x800067f0</span><span class="no">L</span><span class="o">,</span> <span class="mh">0x80006c10</span><span class="no">L</span><span class="o">,</span> <span class="mh">0x80000c10</span><span class="no">L</span><span class="o">};</span>
        <span class="nc">String</span><span class="o">[]</span> <span class="n">names</span> <span class="o">=</span> <span class="o">{</span><span class="s">"aes_encrypt_block"</span><span class="o">,</span> <span class="s">"aes_decrypt_block"</span><span class="o">,</span>
                          <span class="s">"sha256_transform"</span><span class="o">,</span> <span class="s">"rtos_schedule"</span><span class="o">};</span>
        <span class="k">for</span> <span class="o">(</span><span class="kt">int</span> <span class="n">i</span> <span class="o">=</span> <span class="mi">0</span><span class="o">;</span> <span class="n">i</span> <span class="o">&lt;</span> <span class="n">addresses</span><span class="o">.</span><span class="na">length</span><span class="o">;</span> <span class="n">i</span><span class="o">++)</span> <span class="o">{</span>
            <span class="nc">Function</span> <span class="n">function</span> <span class="o">=</span> <span class="n">getFunctionAt</span><span class="o">(</span><span class="n">toAddr</span><span class="o">(</span><span class="n">addresses</span><span class="o">[</span><span class="n">i</span><span class="o">]));</span>
            <span class="k">if</span> <span class="o">(</span><span class="n">function</span> <span class="o">==</span> <span class="kc">null</span><span class="o">)</span> <span class="o">{</span>
                <span class="k">throw</span> <span class="k">new</span> <span class="nf">IllegalStateException</span><span class="o">(</span><span class="s">"Analyze the function at "</span> <span class="o">+</span> <span class="n">toAddr</span><span class="o">(</span><span class="n">addresses</span><span class="o">[</span><span class="n">i</span><span class="o">]));</span>
            <span class="o">}</span>
            <span class="n">function</span><span class="o">.</span><span class="na">setName</span><span class="o">(</span><span class="n">names</span><span class="o">[</span><span class="n">i</span><span class="o">],</span> <span class="nc">SourceType</span><span class="o">.</span><span class="na">USER_DEFINED</span><span class="o">);</span>
            <span class="n">println</span><span class="o">(</span><span class="n">function</span><span class="o">.</span><span class="na">getEntryPoint</span><span class="o">()</span> <span class="o">+</span> <span class="s">" "</span> <span class="o">+</span> <span class="n">function</span><span class="o">.</span><span class="na">getName</span><span class="o">());</span>
        <span class="o">}</span>
    <span class="o">}</span>
<span class="o">}</span></code></pre></figure>

<p>For a fresh headless import, with <code class="language-plaintext highlighter-rouge">GHIDRA_HOME</code> pointing at the extracted Ghidra installation:</p>

<figure class="highlight"><pre><code class="language-bash" data-lang="bash"><span class="nb">mkdir</span> <span class="nt">-p</span> ghidra-projects
<span class="s2">"</span><span class="nv">$GHIDRA_HOME</span><span class="s2">/support/analyzeHeadless"</span> ghidra-projects firmware_re <span class="se">\</span>
    <span class="nt">-import</span> firmware.bin <span class="nt">-loader</span> BinaryLoader <span class="nt">-loader-baseAddr</span> 0x80000000 <span class="se">\</span>
    <span class="nt">-processor</span> MIPS:LE:32:default <span class="nt">-cspec</span> default <span class="se">\</span>
    <span class="nt">-scriptPath</span> <span class="nb">.</span> <span class="nt">-postScript</span> RenameFirmware.java</code></pre></figure>

<p>The script’s messages, without the headless logger’s prefixes:</p>

<figure class="highlight"><pre><code class="language-text" data-lang="text">80006558 aes_encrypt_block
800067f0 aes_decrypt_block
80006c10 sha256_transform
80000c10 rtos_schedule</code></pre></figure>

<p>Both frameworks can save names and automate edits. The r2 script uses analysis commands directly; the Java script uses Ghidra’s function API. Choose according to the analysis database you intend to maintain.</p>

<h2 id="where-capstone-fits">Where Capstone Fits</h2>

<p>Capstone is the disassembly library we used earlier. It does not maintain a function database or decompile code, but it is useful when we want to control exactly which instructions a script examines.</p>

<p>This scan finds the first SHA-256 initial-state constant load in the code ranges of this build:</p>

<figure class="highlight"><pre><code class="language-python" data-lang="python"><span class="kn">from</span> <span class="n">capstone</span> <span class="kn">import</span> <span class="n">Cs</span><span class="p">,</span> <span class="n">CS_ARCH_MIPS</span><span class="p">,</span> <span class="n">CS_MODE_MIPS32</span><span class="p">,</span> <span class="n">CS_MODE_LITTLE_ENDIAN</span>

<span class="n">data</span> <span class="o">=</span> <span class="nf">open</span><span class="p">(</span><span class="sh">"</span><span class="s">firmware.bin</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">rb</span><span class="sh">"</span><span class="p">).</span><span class="nf">read</span><span class="p">()</span>
<span class="n">md</span> <span class="o">=</span> <span class="nc">Cs</span><span class="p">(</span><span class="n">CS_ARCH_MIPS</span><span class="p">,</span> <span class="n">CS_MODE_MIPS32</span> <span class="o">|</span> <span class="n">CS_MODE_LITTLE_ENDIAN</span><span class="p">)</span>
<span class="k">for</span> <span class="n">start</span><span class="p">,</span> <span class="n">end</span> <span class="ow">in</span> <span class="p">[(</span><span class="mh">0x0000</span><span class="p">,</span> <span class="mh">0x02AC</span><span class="p">),</span> <span class="p">(</span><span class="mh">0x02B0</span><span class="p">,</span> <span class="mh">0x7290</span><span class="p">)]:</span>
    <span class="k">for</span> <span class="n">insn</span> <span class="ow">in</span> <span class="n">md</span><span class="p">.</span><span class="nf">disasm</span><span class="p">(</span><span class="n">data</span><span class="p">[</span><span class="n">start</span><span class="p">:</span><span class="n">end</span><span class="p">],</span> <span class="mh">0x80000000</span> <span class="o">+</span> <span class="n">start</span><span class="p">):</span>
        <span class="k">if</span> <span class="n">insn</span><span class="p">.</span><span class="n">mnemonic</span> <span class="o">==</span> <span class="sh">"</span><span class="s">lui</span><span class="sh">"</span> <span class="ow">and</span> <span class="n">insn</span><span class="p">.</span><span class="n">op_str</span><span class="p">.</span><span class="nf">endswith</span><span class="p">(</span><span class="sh">"</span><span class="s">, 0x6a09</span><span class="sh">"</span><span class="p">):</span>
            <span class="nf">print</span><span class="p">(</span><span class="sa">f</span><span class="sh">"</span><span class="s">Initial-state constant candidate at 0x</span><span class="si">{</span><span class="n">insn</span><span class="p">.</span><span class="n">address</span><span class="si">:</span><span class="mi">08</span><span class="n">X</span><span class="si">}</span><span class="sh">"</span><span class="p">)</span></code></pre></figure>

<figure class="highlight"><pre><code class="language-text" data-lang="text">Initial-state constant candidate at 0x80006E28</code></pre></figure>

<p>The ranges come from the companion ELF and exclude the read-only data. In an unknown image, we would first need evidence for those code bounds. This scan finds a candidate instruction; the following <code class="language-plaintext highlighter-rouge">ori</code> and stores establish the full constant and its use.</p>

<p>Building a custom reference finder with Capstone remains possible. We would need to track register values across instruction sequences, account for branch delay slots, and associate references with function boundaries. That work makes sense when the analysis is specialized or needs to run across many images. For ordinary browsing, r2 and Ghidra already maintain those relationships.</p>

<h2 id="compare-the-work-not-the-number-of-commands">Compare the Work, Not the Number of Commands</h2>

<table>
  <thead>
    <tr>
      <th>Task</th>
      <th>Plain Python / Capstone</th>
      <th>Radare2</th>
      <th>Ghidra</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Byte search</td>
      <td><code class="language-plaintext highlighter-rouge">bytes.find</code>; no Capstone needed</td>
      <td><code class="language-plaintext highlighter-rouge">/x</code></td>
      <td>Search Memory or scripting API</td>
    </tr>
    <tr>
      <td>Table users and callers</td>
      <td>Custom value tracking and function mapping</td>
      <td>Discovered xrefs with <code class="language-plaintext highlighter-rouge">axt</code></td>
      <td>Discovered references and containing functions</td>
    </tr>
    <tr>
      <td>C-like decompilation</td>
      <td>Not provided by Capstone</td>
      <td><code class="language-plaintext highlighter-rouge">pdc</code>; optional plugin for another engine</td>
      <td>Integrated decompiler</td>
    </tr>
    <tr>
      <td>Batch rename</td>
      <td>Keep a separate map; no Capstone project model</td>
      <td><code class="language-plaintext highlighter-rouge">afn</code> commands or r2pipe</td>
      <td>Java/Python scripting APIs</td>
    </tr>
    <tr>
      <td>Saved analysis</td>
      <td>Script-specific output</td>
      <td>r2 project and command scripts</td>
      <td>Ghidra project</td>
    </tr>
    <tr>
      <td>Terminal use</td>
      <td>Python scripts</td>
      <td>Interactive terminal and scripts</td>
      <td>Headless analysis and scripts</td>
    </tr>
  </tbody>
</table>

<p>For this firmware, r2 gives us a compact terminal workflow from a table search to its users, callers, and saved names. Ghidra gives us C expressions and an interface for applying the structs recovered in Part 2. Capstone gives us instruction decoding inside a script we control. The useful distinction is which work each tool maintains for us.</p>

<p>If you prefer a GUI around a terminal-oriented analysis framework, <a href="https://cutter.re/">Cutter</a> is another option. Its current backend is Rizin, so r2 commands and project behavior should not be assumed to match.</p>

<hr />

<h2 id="exercises">Exercises</h2>

<ol>
  <li>
    <p><strong>Compare references</strong>: Collect S-box references in both r2 and Ghidra. Group them by containing function. Which differences are address-construction sites versus actual loads?</p>
  </li>
  <li>
    <p><strong>Question a label</strong>: Find <code class="language-plaintext highlighter-rouge">s_CLI_initialized_80008180</code> in the SHA-256 decompilation. Use the K[] start and size to explain the end-pointer comparison. What annotation would make it clearer?</p>
  </li>
  <li>
    <p><strong>Check persistence</strong>: Rename the four functions, save the project, close the tool, and reopen it. Verify the names at the same addresses. Export a second map as JSON or text and keep it beside your notes.</p>
  </li>
  <li>
    <p><strong>Find a missed function</strong>: Inspect <code class="language-plaintext highlighter-rouge">rtos_sem_wait</code> at <code class="language-plaintext highlighter-rouge">0x80000F98</code> in a fresh r2 analysis. Define it explicitly, then compare its boundaries with the companion ELF. Does adding a known entry change the discovered reference map?</p>
  </li>
</ol>

<hr />

<h2 id="whats-next">What’s Next</h2>

<p>We can now query references, preserve names, and compare decompiled code with the underlying instructions. In Part 4, we will use those capabilities to separate accesses to hardware registers from ordinary memory accesses, then build a map of the seven peripheral blocks used by this firmware.</p>

<h2 id="references">References</h2>

<ul>
  <li><a href="https://book.rada.re/r2fwrev/setup.html">The Official Radare2 Book: firmware setup</a> — loading a raw image with the correct architecture and mapping.</li>
  <li><a href="https://book.rada.re/analysis/code_analysis.html">The Official Radare2 Book: code analysis</a> — function analysis and cross-reference commands.</li>
  <li><a href="https://book.rada.re/projects/usage.html">The Official Radare2 Book: projects</a> — saving and restoring analysis.</li>
  <li><a href="https://ghidra.re/ghidra_docs/GhidraClass/Beginner/Introduction_to_Ghidra_Student_Guide.html">Ghidra introductory guide</a> — searching and navigating references.</li>
  <li><a href="https://www.capstone-engine.org/lang_python.html">Capstone Python tutorial</a> — decoding instructions from Python.</li>
</ul>

<hr />

<p><em>This is Part 3 of “Crypto Tables, Struct Ghosts, and Other Secrets Hidden in Firmware.”</em></p>

<p><em>Next: <a href="/reverse-engineering/firmware/hardware/2026/10/17/register-maps-mmio-blocks-and-other-hardware-buried-in-code.html">Part 4 — Register Maps, MMIO Blocks, and Other Hardware Buried in Code</a>.</em></p>]]></content><author><name>Maciej Grochowski</name></author><category term="reverse-engineering" /><category term="firmware" /><category term="tools" /><summary type="html"><![CDATA[Table searches are easy. Checking cross-references, function boundaries, and decompiler guesses takes more care. One raw MIPS image shows where radare2, Ghidra, and Capstone agree, and where they need help.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://page-fault.io/assets/social-preview.png" /><media:content medium="image" url="https://page-fault.io/assets/social-preview.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">When Randomness Fails</title><link href="https://page-fault.io/bitcoin-entropy/part-4/" rel="alternate" type="text/html" title="When Randomness Fails" /><published>2026-10-16T17:11:20+00:00</published><updated>2026-10-16T17:11:20+00:00</updated><id>https://page-fault.io/bitcoin-entropy/bitcoin-entropy-part-4</id><content type="html" xml:base="https://page-fault.io/bitcoin-entropy/part-4/"><![CDATA[<p>A random-number API can return a plausible-looking value while the program
behind it has almost no secret choices left. The fault may be a packaging
patch, a wrong symbol selected by the linker, an ignored hardware error, or a
seed taken from the clock. Inspecting the output will not tell us which.</p>

<p>Part 3 measured four candidate generators against a funded-address snapshot.
Here I want to connect the small-source problem to documented failures, then
show an experiment whose expected match count we can actually observe. The
benchmark made <strong>150 billion synthetic probes</strong>. Its planted case
counted <strong>174 matches against 186.26 expected</strong>.</p>

<p>The historical cases fall into four useful groups. These identify failure
mechanisms; they do not rank severity or assign a common search cost. The
benchmark’s bit widths are experimental choices, separate from those incidents.</p>

<h2 id="integration-bypassed-the-intended-source">Integration bypassed the intended source</h2>

<p>Debian’s <a href="https://security-tracker.debian.org/tracker/CVE-2008-0166">CVE-2008-0166 record</a>
describes predictable key material after a packaging change removed
entropy-mixing code. “32,768 possible keys” is an imprecise retelling.
Debian’s <a href="https://wiki.debian.org/SSLkeys">weak-key guidance</a> identifies 32,767
process-ID-derived streams <strong>per architecture</strong>; actual key sets also depend
on key type and generation parameters. The important change was a collapse
to a small, reproducible output family.</p>

<p>Android’s 2013 <a href="https://android-developers.googleblog.com/2013/08/some-securerandom-thoughts.html">SecureRandom note</a>
describes improper initialization of the underlying pseudorandom number
generator (PRNG). This could affect key generation and signing. A weak key and
a repeated signing nonce are distinct routes to compromise, even when the
same integration fault can contribute to both.</p>

<p>The 2026 <a href="https://illbloom.org/articles/cryptojs-vulnerability/">Ill Bloom disclosure</a>
traced weak BIP39 entropy to a vulnerable <code class="language-plaintext highlighter-rouge">CryptoJS.lib.WordArray.random()</code>
implementation using a custom Multiply-With-Carry generator. Exposure depended
on downstream wallet software using that output for mnemonic generation;
including CryptoJS as a dependency was insufficient. The researchers’
<a href="https://illbloom.org/articles/chain-analysis-2/">chain analysis</a> estimated a
<strong>$5,690,922 lower bound through July 13, 2026</strong>. This was a longstanding
randomness weakness disclosed in 2026.</p>

<p>Coinkite’s <a href="https://blog.coinkite.com/entropy-technical-backgrounder/">COLDCARD disclosure</a>
is a particularly concrete integration failure. A build and link error routed
seed generation through MicroPython’s software PRNG instead of the intended
hardware random-number generator (RNG). The hardware source was healthy. The
wallet-generation path simply did not use it.</p>

<p>Under its stated attack assumptions, Coinkite estimated about <strong>40 bits</strong> of
effective search for affected Mk2/Mk3 seeds and about <strong>72 bits</strong> for later
affected models with additional secure-element input. Those are the vendor’s
model-specific estimates. A source-code review that finds the correct RNG
implementation still has to establish which symbol the final binary calls.
The linker gets a vote.</p>

<h2 id="startup-and-hardware-interfaces-supplied-too-little-entropy">Startup and hardware interfaces supplied too little entropy</h2>

<p>FreeBSD has two separate examples. A <a href="https://lists.freebsd.org/pipermail/freebsd-announce/2008-November/001216.html">2008 advisory</a>
covered insufficient early-boot entropy for <code class="language-plaintext highlighter-rouge">arc4random</code>. A
<a href="https://lists.freebsd.org/pipermail/freebsd-current/2015-February/054580.html">2015 FreeBSD-CURRENT warning</a>
concerned a modular RNG change that omitted <code class="language-plaintext highlighter-rouge">randomdev_init_reader</code>. The
affected <code class="language-plaintext highlighter-rouge">read_random(9)</code> and <code class="language-plaintext highlighter-rouge">arc4random</code> path could generate predictable keys
from revision r273872 until the fix at r278907. The warning called for key
regeneration; <a href="https://hardenedbsd.org/article/shawn-webb/2015-02-18/freebsd-random-number-generator-vulnerability">HardenedBSD reported downstream exposure</a>.</p>

<p>These were separate faults in specific versions. The 2015 bug did not literally
return 7, either. Its <a href="https://hovav.net/ucsd/dist/freebsd.pdf">technical analysis</a>
describes a predictable fallback sequence, including a recurrence using <code class="language-plaintext highlighter-rouge">7^5</code>.
That is memorable enough without improving the story.</p>

<p>Lenovo’s <a href="https://support.lenovo.com/us/en/solutions/ps500148-lenovo-xclarity-controller-xcc-risk-of-low-entropy">XClarity Controller advisory</a>
reports predictable hardware RNG values for the first 32 draws after startup
or draws less than two microseconds apart. The affected component was a
baseboard management controller (BMC) in servers. This is evidence about that
controller’s entropy interface, with no demonstrated connection to Bitcoin
wallet generation.</p>

<p><a href="https://bishopfox.com/blog/youre-doing-iot-rng">Bishop Fox’s IoT research</a>
shows what happens when callers ignore an RNG error from a hardware abstraction
layer. They can proceed with zeroes, partial data, or uninitialized bytes in
the output buffer. The study identifies a broad class of possible mistakes;
it does not count demonstrated compromises across that entire population.
A functioning physical source cannot help code that accepts a failed read.</p>

<h2 id="the-generator-was-unsuitable-for-secrets">The generator was unsuitable for secrets</h2>

<p><a href="https://milksad.info/disclosure.html">Milk Sad</a> and the
<a href="https://www.ledger.com/blog/funds-of-every-wallet-created-with-the-trust-wallet-browser-extension-could-have-been-stolen">Trust Wallet browser-extension investigation</a>
describe different wallet-generation paths beginning with a <strong>32-bit PRNG
input</strong>. A long mnemonic cannot recover the missing input uncertainty.
Enumerating that input space is the relevant attack model, with costs set by
each wallet’s complete derivation pipeline.</p>

<p>Mersenne Twister also permits a different attack: an observer with <strong>624
consecutive full 32-bit MT19937 outputs</strong> can reconstruct its state. This
<a href="https://www.schutzwerk.com/blog/attacking-a-rng/">technical demonstration</a>
shows the process. Wallet addresses, truncated values, and hashes do not
satisfy that observation condition automatically. Small-seed enumeration and
state cloning are separate attacks.</p>

<p>Dual_EC_DRBG had a conditional prediction route. The
<a href="https://rump2007.cr.yp.to/15-shumow.pdf">2007 Shumow–Ferguson presentation</a>
showed that knowing a secret relationship between its public elliptic-curve
parameters could permit output prediction. <a href="https://www.nist.gov/news-events/news/2014/04/nist-removes-cryptography-algorithm-random-number-generator-recommendations">NIST removed the algorithm</a>
from its recommendations in 2014. The demonstration establishes a possible
trapdoor; it does not prove who held the required secret for the standardized
points. A bounded small-seed search cannot represent this mechanism.</p>

<h2 id="clocks-and-correlations-gave-the-game-away">Clocks and correlations gave the game away</h2>

<p><a href="https://people.eecs.berkeley.edu/~daw/papers/ddj-netscape.html">Goldberg and Wagner’s Netscape analysis</a>
found that the UNIX browser seeded its SSL PRNG with the time of day, process
ID, and parent process ID. An attacker could narrow those inputs. The paper
describes different details on Windows and Mac, so there is no single seed
formula to apply to every Netscape build.</p>

<p>The <a href="https://gwern.net/doc/cs/cryptography/2006-arkin.pdf">PlanetPoker researchers</a>
found a bounded, clock-seeded shuffle and demonstrated a real-time deck
predictor. They reported no use of the weakness to win money. A deck selected
from a narrow seed population cannot provide the uncertainty of all <code class="language-plaintext highlighter-rouge">52!</code>
permutations; the timing window and implementation determine the actual search.</p>

<p><a href="https://pmc.ncbi.nlm.nih.gov/articles/PMC285899/">Marsaglia’s paper, <em>Random Numbers Fall Mainly in the Planes</em></a>,
illustrates a statistical failure of congruential generators. RANDU is a concrete
example: its successive triples fall on <a href="https://stat.ethz.ch/R-manual/R-devel/library/datasets/html/randu.html">only 15 planes</a>.
Those correlations matter for simulations that assume independent triples.
They do not, by themselves, specify a wallet-search cost or establish a
historical Bitcoin compromise.</p>

<h2 id="after-generation-signing-and-disclosure">After generation: signing and disclosure</h2>

<p>Reusing a nonce in the Elliptic Curve Digital Signature Algorithm (ECDSA)
can expose a key. Two signatures with the right algebraic
relationship can expose the signing key without a seed search. Android’s 2013
failure also had a signing consequence. The separate
<a href="https://github.com/mmpg-x86/entropy-lab/blob/main/research/nonce_reuse.py"><code class="language-plaintext highlighter-rouge">nonce_reuse.py</code> example</a> demonstrates that algebra
with synthetic signatures.</p>

<p>The <a href="/bitcoin-entropy/part-0/">reported implant in Part 0</a> copied the secret
after generation. An ideal RNG cannot protect words read from the display.
Both cases belong in the threat model, but neither is a small key-generation
space to enumerate.</p>

<h2 id="the-strong-source-model">The strong-source model</h2>

<p><a href="https://github.com/bitcoin/bips/blob/master/bip-0039.mediawiki">BIP39</a> begins a
12-word mnemonic with 128 bits of entropy and a 24-word mnemonic with 256 bits.
For an independently chosen uniform secret, <code class="language-plaintext highlighter-rouge">q</code> distinct guesses have success
probability at most <code class="language-plaintext highlighter-rouge">q/2^128</code> or <code class="language-plaintext highlighter-rouge">q/2^256</code>. A sound source leaves no small
initial candidate set for the attacker.</p>

<p>Real devices have to collect and condition entropy to approach that model.
<a href="https://support.apple.com/en-euro/guide/security/seca0c73a75b/web">Apple’s platform-security guide</a>
and <a href="https://donjon.ledger.com/threat-model/os-random-number-generation/">Ledger Donjon’s RNG threat model</a>
describe designs. <a href="https://csrc.nist.gov/pubs/sp/800/90/b/final">NIST SP 800-90B</a>
provides entropy-source assessment and health-test guidance. Design documents
and finite tests supply evidence about particular properties. They cannot
establish that an unknown observer has no predictive information.</p>

<h2 id="giving-the-benchmark-something-to-find">Giving the benchmark something to find</h2>

<p>The scanner’s zero matches leave a measurement problem: how do we check a
matching pipeline when its model predicts almost no matches? I used a separate
<a href="https://github.com/mmpg-x86/entropy-lab/blob/main/research/entropy_tier_benchmark.py">synthetic-token benchmark</a>, with a
known target population large enough to produce observable hits. It generates
no Bitcoin keys, mnemonics, addresses, or signatures.</p>

<p>The repository’s <a href="https://github.com/mmpg-x86/entropy-lab/blob/main/docs/rng-models.md">illustrative RNG lab</a> is separate.
Its supplied fixtures are synthetic; it has no historical wallet fixture corpus
or current-holdings matching. Its documentation describes the implemented
mappings and their limits. The historical incidents above have their own
sources and attack models.</p>

<p>The first benchmark, on October 13, used a fictional fixed-output source and
bounded spaces of <code class="language-plaintext highlighter-rouge">2^12</code>, <code class="language-plaintext highlighter-rouge">2^16</code>, <code class="language-plaintext highlighter-rouge">2^20</code>, and <code class="language-plaintext highlighter-rouge">2^23</code> states. Targets were placed
near the 75% rank so that the searches would do substantial work. The 23-bit
case found its target after <strong>6,291,457 probes in 3.811 seconds</strong>, about
<strong>1.65 million tokens/s</strong>. Those attempt counts were fixed by construction;
the rate was measured. With <code class="language-plaintext highlighter-rouge">--random-target</code>, rank is uniform and expected
full-search work is <code class="language-plaintext highlighter-rouge">(2^b + 1)/2</code>, before any budget truncation.</p>

<p>The <a href="https://github.com/mmpg-x86/entropy-lab/blob/main/results/entropy_tier_results_2026-10-13.json">retained baseline JSON</a>
includes the measured timings and configuration. Its idealized 128- and 256-bit controls each
made ten million independent draws with replacement and found zero matches.
Their single-target hit-probability bounds were about <strong>2.94 × 10^-32</strong> and
<strong>8.64 × 10^-71</strong>. These budgets cannot distinguish such tiny rates. Running
longer without changing the model would mostly produce a larger zero.</p>

<p>For the <a href="https://github.com/mmpg-x86/entropy-lab/blob/main/docs/benchmark.md#checkpointed-run">checkpointed benchmark</a>, we added
<strong>2²⁰ distinct planted targets in a 2⁴⁸-state space</strong>. Each candidate is an
independent uniform draw with replacement. The benchmark hashes it, looks up
the token, and checks the full state. The planted target set remains fixed
across chunks and resumes.</p>

<p>For <code class="language-plaintext highlighter-rouge">M</code> targets in <code class="language-plaintext highlighter-rouge">2^b</code> states and <code class="language-plaintext highlighter-rouge">q</code> probes:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>p = M / 2^b
expected matches = q*p
variance = q*p*(1-p)
P(at least one match) = 1 - (1-p)^q
</code></pre></div></div>

<p>Our density gives <code class="language-plaintext highlighter-rouge">p = 2^-28</code>. Repeated probes finding the same target count
separately. This is an invented population chosen for measurement; it assigns
no target count or entropy value to a historical wallet incident.</p>

<p>Before launch, a denser local check used 1,024 targets in <code class="language-plaintext highlighter-rouge">2^16</code> states and
200,000 probes per long case. It observed <strong>3,146 planted matches against
3,125 expected</strong>, 0.38 standard deviations above expectation. The
<a href="https://github.com/mmpg-x86/entropy-lab/blob/main/results/planted_control_validation_2026-10-14.json">validation record</a>
retains its configuration, implementation hashes, and results.</p>

<h2 id="the-completed-benchmark">The completed benchmark</h2>

<p>The <a href="https://github.com/mmpg-x86/entropy-lab/blob/main/docs/benchmark.md#completed-benchmark">synthetic benchmark</a>
used 32 worker processes and reached the configured probe caps after
<strong>7 h 44 min 30 s</strong>, before the eight-hour limit.</p>

<p>The bounded calibration targets were uniformly placed on this run:</p>

<table>
  <thead>
    <tr>
      <th>Synthetic space</th>
      <th style="text-align: right">Probes until match</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Fixed output: one state</td>
      <td style="text-align: right">1</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">2^12</code></td>
      <td style="text-align: right">379</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">2^16</code></td>
      <td style="text-align: right">23,532</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">2^20</code></td>
      <td style="text-align: right">996,405</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">2^23</code></td>
      <td style="text-align: right">7,739,821</td>
    </tr>
  </tbody>
</table>

<p>The three long cases each completed 50 billion probes:</p>

<table>
  <thead>
    <tr>
      <th>Synthetic case</th>
      <th style="text-align: right">Probes</th>
      <th style="text-align: right">Matches</th>
      <th style="text-align: right">Probes/s</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Planted: <code class="language-plaintext highlighter-rouge">2^20</code> targets in <code class="language-plaintext highlighter-rouge">2^48</code> states</td>
      <td style="text-align: right">50,000,000,000</td>
      <td style="text-align: right">174</td>
      <td style="text-align: right">5.34 million</td>
    </tr>
    <tr>
      <td>Uniform 128-bit control</td>
      <td style="text-align: right">50,000,000,000</td>
      <td style="text-align: right">0</td>
      <td style="text-align: right">5.48 million</td>
    </tr>
    <tr>
      <td>Uniform 256-bit control</td>
      <td style="text-align: right">50,000,000,000</td>
      <td style="text-align: right">0</td>
      <td style="text-align: right">5.37 million</td>
    </tr>
  </tbody>
</table>

<p>At that planted budget, the model predicts <strong>186.26 matches</strong>, with standard
deviation <strong>13.65</strong>. The observed 174 is <strong>0.90 standard deviations below</strong>
expectation. That comparison describes the observation against its model;
it is not a pass/fail threshold for a random count.</p>

<p>Each strong-control worker subchunk draws a fresh uniform target. The
single-target per-probe probability still applies, but these are not 50 billion
attempts against one target fixed for the whole run. The probabilities of at
least one match are bounded by <strong>1.47 × 10^-28</strong> and <strong>4.32 × 10^-67</strong>.</p>

<p>The long cases ran in rotation, so their rates cannot be added as simultaneous
throughput. Compared with an earlier interrupted one-worker execution on the
same server, they were <strong>20.3–20.7 times faster</strong>. Chunk sizes, schema, duration,
and runtime conditions differed. This is a descriptive rate comparison, rather
than a controlled CPU-scaling study. The linked guide explains the timing
limitations and points to the original results and their implementation hashes.</p>

<h2 id="what-the-measurements-support">What the measurements support</h2>

<p>The planted count is the useful positive evidence. This implementation detected
rare planted matches at the measured scale, with an aggregate count close to
the model’s expectation. That does not establish correctness for every input
path or reproduce a wallet incident. Small-space calibration and retained
source identity provide separate checks.</p>

<p>The strong controls have much less statistical power. Their zero counts agree
with the ideal models, but cannot establish that a device supplied 128 or 256
bits of entropy. Also, the planted case has a million targets while each
strong-control subchunk has one. <strong>Space size, target count, and probe count
jointly determine the expected count.</strong> Here the density <code class="language-plaintext highlighter-rouge">2^20 / 2^48 = 2^-28</code>
made the positive control observable.</p>

<p>More processors let us collect that evidence sooner. For a fixed workload,
target count, and time budget, a 20-fold rate improvement buys about
<code class="language-plaintext highlighter-rouge">log2(20) = 4.3</code> more bits of searchable space at the same expected match count.
Even an ideal 32-fold gain buys five bits. That helps a bounded experiment;
it does little to close the gap to a uniformly chosen strong secret. These
rates measure synthetic tokens, not Bitcoin address generation.</p>

<p>The series started with a secret reportedly copied from a display. Along the
way, we saw small generation spaces, unsuitable algorithms, integration faults,
and signing failures. Each has a specific failure point. The benchmark gives
us measured search costs for one stated model. The hardware story reminds us
that an attacker who can read the secret has no reason to pay those costs.</p>

<h2 id="a-lottery-with-a-block-reward">A lottery with a block reward</h2>

<p>Calling random key hunting a “Bitcoin lottery” makes the proposed prize sound
rather more innocent than it is. A successful guess against a funded address
would give access to somebody else’s coins. There is already a Bitcoin lottery
that pays the winner for useful work: solo mining.</p>

<p>Take <strong>1 TH/s</strong> of sustained mining and an <strong>$80 hardware budget</strong>.
<a href="https://www.solosatoshi.com/">Solo Satoshi</a> sells small miners in this class;
its <a href="https://www.solosatoshi.com/product/bitaxe-gamma/">Bitaxe Gamma listing</a>
quotes 1.2 TH/s at $61–$72, depending on options. The $80 is a round-number
budget, and I round the rate down to 1 TH/s. Delivery, electricity, and any
solo-pool deductions are separate.</p>

<p><a href="https://developer.bitcoin.org/reference/block_chain.html#block-headers">Bitcoin’s proof of work</a>
asks for a block-header hash below the target. Freeze difficulty at
<strong>132.716 trillion</strong>, the historical value recorded at
<a href="https://mempool.space/block/000000000000000000001e39df127cbab82a824ac43cfcdbf62e59f6a08b9f0b">block 969,696</a>.
At difficulty <code class="language-plaintext highlighter-rouge">D</code>, finding a block takes approximately <code class="language-plaintext highlighter-rouge">D * 2^32</code> hashes on
average. For <code class="language-plaintext highlighter-rouge">h</code> hashes per second and <code class="language-plaintext highlighter-rouge">t</code> seconds:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>P(at least one block) ≈ 1 - exp(-h*t / (D*2^32))
</code></pre></div></div>

<p>For the key search, use Part 3’s <strong>27,637 keys/s</strong>, with both legacy P2PKH forms
checked for every key. Generously count all <strong>21,198,062</strong> legacy-shaped
snapshot entries as valid targets. Under the ideal-hash model:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>P(at least one snapshot match) ≤ 2 * keys_per_second * t * 21,198,062 / 2^160
</code></pre></div></div>

<p>Here are the probabilities over the same time windows, with full uptime and
unchanging difficulty and targets:</p>

<table>
  <thead>
    <tr>
      <th>Process</th>
      <th style="text-align: right">One day</th>
      <th style="text-align: right">One year</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Solo mining at 1 TH/s</td>
      <td style="text-align: right">≈1.52 × 10⁻⁷</td>
      <td style="text-align: right">≈5.54 × 10⁻⁵, or <strong>1 in 18,100</strong></td>
    </tr>
    <tr>
      <td>Random keys at the measured 27,637 keys/s</td>
      <td style="text-align: right">≤6.93 × 10⁻³²</td>
      <td style="text-align: right">≤2.53 × 10⁻²⁹</td>
    </tr>
    <tr>
      <td>Even an imagined trillion random keys/s</td>
      <td style="text-align: right">≤2.51 × 10⁻²⁴</td>
      <td style="text-align: right">≤9.15 × 10⁻²²</td>
    </tr>
  </tbody>
</table>

<p>The miner’s annual chance is only <strong>0.0055%</strong>. Its mean waiting time at that
fixed difficulty is about <strong>18,000 years</strong>; a block does not become due when
the clock reaches that number. Yet its chance is roughly <strong>2.2 × 10²⁴ times</strong>
the measured scanner’s upper bound. Even the invented trillion-key rate leaves
solo mining about <strong>6 × 10¹⁶ times</strong> ahead: sixty quadrillion.</p>

<p>A mining ASIC’s trillion hashes per second cannot be repurposed into a trillion
private-key-and-address derivations. That last row deliberately gives the key
search an enormous allowance. These are probability comparisons, not a claim
that an $80 miner will pay for itself. Mining figures also ignore stale blocks;
the address figures concern that fixed legacy snapshot, not every wallet or
its later balance. The <a href="https://github.com/mmpg-x86/entropy-lab/blob/main/docs/lottery-comparison.md">calculation and inputs</a>
are included with the companion scripts.</p>

<p>The failures in this series came from predictable inputs, broken integration,
unsafe signing, or a secret being copied. Randomly guessing a sound secret
remains a terrible plan. If the attraction is a tiny chance of receiving
Bitcoin, solo mining offers much better odds and a reward the protocol actually
intends you to collect.</p>

<p>For once, the honest lottery has the better odds.</p>

<h2 id="the-question-entropy-leaves-open">The question entropy leaves open</h2>

<p>The lottery comparison concerns guesses. Bitcoin also gives an attacker
mathematical information about the secret: a public key calculated from it.</p>

<p>A private key <code class="language-plaintext highlighter-rouge">d</code> determines a public point <code class="language-plaintext highlighter-rouge">Q</code> through <strong><code class="language-plaintext highlighter-rouge">Q = dG</code></strong>. Here <code class="language-plaintext highlighter-rouge">G</code>
is a fixed, public base point on the elliptic curve called <strong>secp256k1</strong>.
Multiplication means repeated point addition, computed efficiently. The
network uses the public key to verify a transaction signature without being
given the private key. Bitcoin’s ECDSA and
<a href="https://github.com/bitcoin/bips/blob/master/bip-0340.mediawiki">Schnorr signatures</a>
both use this curve.</p>

<p>For the <a href="https://developer.bitcoin.org/devguide/transactions.html#p2pkh-script-validation">legacy P2PKH addresses</a>
checked by our scanner, the diagram separates forward derivation from the two
attack routes:</p>

<style>
.post-content .entropy-curve-diagram { margin: 24px 0; }
.post-content .entropy-curve-diagram img {
  display: block;
  width: 100%;
  height: auto;
  padding: 0;
  margin: 0;
  background: transparent;
  border-radius: 0;
}
.entropy-curve-diagram .entropy-curve-variant { display: none; }
.entropy-curve-diagram .entropy-curve-dark { display: block; }
[data-theme="light"] .entropy-curve-diagram .entropy-curve-dark,
[data-theme="railscasts"] .entropy-curve-diagram .entropy-curve-dark { display: none; }
[data-theme="light"] .entropy-curve-diagram .entropy-curve-light,
[data-theme="railscasts"] .entropy-curve-diagram .entropy-curve-railscasts { display: block; }
.entropy-curve-diagram a:focus-visible {
  outline: 2px solid var(--accent-blue);
  outline-offset: 4px;
  border-radius: 4px;
}
</style>

<figure class="entropy-curve-diagram" aria-describedby="entropy-curve-caption">
  <a class="entropy-curve-variant entropy-curve-dark" href="https://res.cloudinary.com/gotocco/image/upload/v1792195037/bitcoin-key-derivation-attack-routes-dark_eef5ic.svg" title="View the full-size diagram">
    <img src="https://res.cloudinary.com/gotocco/image/upload/v1792195037/bitcoin-key-derivation-attack-routes-dark_eef5ic.svg" width="1200" height="1720" loading="lazy" decoding="async" alt="Bitcoin key derivation and attack routes: entropy and wallet derivation produce private key d; curve multiplication gives public key Q = dG; hashing and encoding produce a legacy P2PKH address. Attack routes compare guessing the secret with recovering d from Q by solving a discrete logarithm. A separate panel compares public-key exposure in P2PKH and Taproot." />
  </a>
  <a class="entropy-curve-variant entropy-curve-light" href="https://res.cloudinary.com/gotocco/image/upload/v1792195034/bitcoin-key-derivation-attack-routes-light_hd38zc.svg" title="View the full-size diagram">
    <img src="https://res.cloudinary.com/gotocco/image/upload/v1792195034/bitcoin-key-derivation-attack-routes-light_hd38zc.svg" width="1200" height="1720" loading="lazy" decoding="async" alt="Bitcoin key derivation and attack routes: entropy and wallet derivation produce private key d; curve multiplication gives public key Q = dG; hashing and encoding produce a legacy P2PKH address. Attack routes compare guessing the secret with recovering d from Q by solving a discrete logarithm. A separate panel compares public-key exposure in P2PKH and Taproot." />
  </a>
  <a class="entropy-curve-variant entropy-curve-railscasts" href="https://res.cloudinary.com/gotocco/image/upload/v1792195042/bitcoin-key-derivation-attack-routes-raycast_nysifi.png" title="View the full-size diagram">
    <img src="https://res.cloudinary.com/gotocco/image/upload/v1792195042/bitcoin-key-derivation-attack-routes-raycast_nysifi.png" width="1200" height="1720" loading="lazy" decoding="async" alt="Bitcoin key derivation and attack routes: entropy and wallet derivation produce private key d; curve multiplication gives public key Q = dG; hashing and encoding produce a legacy P2PKH address. Attack routes compare guessing the secret with recovering d from Q by solving a discrete logarithm. A separate panel compares public-key exposure in P2PKH and Taproot." />
  </a>
  <figcaption id="entropy-curve-caption">Solid arrows show derivation; dashed arrows show attack routes. Select the diagram to view it at full size.</figcaption>
</figure>

<p>Here the address encodes a hash of the public key. Spending a P2PKH output
reveals that public key. Other output types expose different information:
<a href="https://github.com/bitcoin/bips/blob/master/bip-0341.mediawiki">Taproot</a>
includes an output public key from the start. A fresh receiving address does
not automatically mean a hidden public key.</p>

<p>The recent <a href="https://www.coindesk.com/tech/2026/10/08/bitcoin-and-ether-holders-urged-to-prepare-bunker-mode-against-possible-ai-attacks">CoinDesk report on Justin Drake’s warning</a>
concerns this other route. Drake raised the
possibility of an AI-assisted mathematical breakthrough that could make
private-key recovery practical from exposed public keys. The report presents
a worst-case forecast; it supplies no working attack demonstrating that
Bitcoin keys can be recovered this way.</p>

<p>Finding <code class="language-plaintext highlighter-rouge">d</code> from <code class="language-plaintext highlighter-rouge">Q</code> and <code class="language-plaintext highlighter-rouge">G</code> is the <strong>elliptic-curve discrete logarithm
problem</strong>. The security argument in
<a href="https://github.com/bitcoin/bips/blob/master/bip-0340.mediawiki#motivation">BIP340</a>
explicitly assumes that this problem is hard. A practical method for solving
it on secp256k1 would threaten exposed public keys even if their private keys
had been generated with perfect randomness.</p>

<p>We have calculated guessing probabilities under stated assumptions and
measured a deliberately weakened model. A huge keyspace and 150 billion
synthetic probes cannot prove that no mathematical shortcut exists.</p>

<p>Entropy explains the cost of guessing <code class="language-plaintext highlighter-rouge">d</code>. The next question is what it would
take to recover <code class="language-plaintext highlighter-rouge">d</code> from <code class="language-plaintext highlighter-rouge">dG</code>.</p>]]></content><author><name>Maciej Grochowski</name></author><category term="security" /><summary type="html"><![CDATA[Real entropy failures have different causes. A 150-billion-probe synthetic run gives us a measurable search result, provided we keep its model attached to the numbers.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://page-fault.io/assets/social-preview.png" /><media:content medium="image" url="https://page-fault.io/assets/social-preview.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Where Bitcoin Keys Actually Die: A Scanner Experiment</title><link href="https://page-fault.io/bitcoin-entropy/part-3/" rel="alternate" type="text/html" title="Where Bitcoin Keys Actually Die: A Scanner Experiment" /><published>2026-10-16T17:11:19+00:00</published><updated>2026-10-16T17:11:19+00:00</updated><id>https://page-fault.io/bitcoin-entropy/bitcoin-entropy-part-3</id><content type="html" xml:base="https://page-fault.io/bitcoin-entropy/part-3/"><![CDATA[<p>A full-length Bitcoin key can come from a very short list of possibilities.
A generator starting with a uniform 32-bit seed reaches at most 2³² outputs,
even if it prints each as a 256-bit value. Parts 1 and 2 dealt with spaces too
large to search. Here I wanted to measure some smaller ones.</p>

<p>The scanner in <code class="language-plaintext highlighter-rouge">research/</code> uses four candidate rules: secure random draws,
patterned integers, single-word brainwallets, and a synthetic weak pseudorandom
number generator (PRNG). For each candidate, it derives compressed and
uncompressed legacy Pay-to-Public-Key-Hash (P2PKH) addresses and screens them
against a dated funded-address snapshot. It reads public data and never
constructs, signs, or broadcasts a transaction.</p>

<p>These runs found <strong>zero funded-snapshot matches</strong>. Selected known, drained
addresses supplied positive controls. The weak-PRNG rule is a small-seed model;
it does not reproduce a historical wallet bug.</p>

<h2 id="tier-0-random-control">Tier 0: random control</h2>

<p>The baseline generated <strong>1,500,000</strong> cryptographically random keys with
<code class="language-plaintext highlighter-rouge">os.urandom</code> and derived three million candidate addresses. It screened them
against an index of <strong>57,205,029 addresses</strong> from the October 13, 2026 snapshot
and the scanner’s built-in 16-address control set. Explorer lookups run only
after a flag; this control produced none.</p>

<p>Here is the retained output:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>tiers        : random_control
screened against: funded-address index 2026-10-13 (57,205,029 addrs) + built-in control set (16 addrs) + online API verification of hits
online lookups: 0 ok, 0 failed
keys checked : 1,500,000  (10^6.2)
hits (ever used) : 0   [control: 0, dump: 0]
hits (funded now): n/a (no online lookups)
elapsed      : 54.3s   rate 27,637 keys/s
projection   : ~10^11.9 keys/year on this core
               to cover the *narrowed* 12-word space (2^128 = 10^38.5): ~10^26.6 years
               (age of universe ~ 10^10.1 years)
</code></pre></div></div>

<p>The raw-key rate was <strong>27,637 keys/s</strong> with the index loaded. The output’s
12-word projection divides the mnemonic space by that rate. It does not measure
mnemonic search, which also needs PBKDF2 and BIP32 derivation. Adding those
steps to this pipeline adds work.</p>

<p>The run used CPython 3.13 on Linux. <a href="https://docs.python.org/3.13/library/os.html#os.urandom">Python documents</a>
that <code class="language-plaintext highlighter-rouge">os.urandom</code> uses blocking <code class="language-plaintext highlighter-rouge">getrandom()</code> when available and waits for
kernel initialization. This addresses that early-boot handoff; it does not
certify the operating system’s entropy source.</p>

<h2 id="what-the-index-checks">What the index checks</h2>

<p>The compact index stores <strong>64-bit prefixes of address hashes</strong>. At this size,
an absent address has a modeled collision probability of about
3.10 × 10⁻¹² per lookup: 9.30 × 10⁻⁶ expected flags over three million lookups.
A flag therefore needs an exact full-address check against the original dump.
Today’s explorer history cannot prove membership in an earlier snapshot.</p>

<p>There is an open implementation issue here. The scanner retains an index flag
as a dump hit even after a negative explorer response. Future positive flags
need separate exact confirmation. These runs had zero flags; with a correctly
built index, zero flags also means zero exact matches for the tested addresses.
The <a href="https://github.com/mmpg-x86/entropy-lab/blob/main/docs/scanner.md#a-supplied-snapshot">scanner guide</a>
documents the prefix index and this verification limitation.</p>

<p>A streaming inventory found these shapes in the same dump:</p>

<table>
  <thead>
    <tr>
      <th>Address shape</th>
      <th style="text-align: right">Entries</th>
      <th style="text-align: right">Share</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">1…</code> (P2PKH-shaped)</td>
      <td style="text-align: right">21,198,062</td>
      <td style="text-align: right">37.06%</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">3…</code> (P2SH-shaped)</td>
      <td style="text-align: right">6,152,068</td>
      <td style="text-align: right">10.75%</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">bc1q…</code> (42 chars, P2WPKH-shaped)</td>
      <td style="text-align: right">23,492,675</td>
      <td style="text-align: right">41.07%</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">bc1q…</code> (62 chars, P2WSH-shaped)</td>
      <td style="text-align: right">1,184,245</td>
      <td style="text-align: right">2.07%</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">bc1p…</code> (62 chars, P2TR-shaped)</td>
      <td style="text-align: right">5,081,683</td>
      <td style="text-align: right">8.88%</td>
    </tr>
    <tr>
      <td>Other <code class="language-plaintext highlighter-rouge">bc1…</code></td>
      <td style="text-align: right">96,296</td>
      <td style="text-align: right">0.17%</td>
    </tr>
  </tbody>
</table>

<p>These are prefix-and-length classifications, without checksum validation.
The snapshot date comes from the filename. Its gzip SHA-256 is
<code class="language-plaintext highlighter-rouge">7117127c3a0e8276e3c6c4ad8f4fc28fc0498886481f9704edf7877be05a1c02</code>.</p>

<p>Only the <code class="language-plaintext highlighter-rouge">1…</code> records have the form this scanner generates. Even treating all
21,198,062 as valid P2PKH targets, an ideal-hash model gives at most about
<strong>4.35 × 10⁻³⁵</strong> probability of an exact address match across the random
control’s three million candidates. Per key, checking both forms gives at most
about <strong>2.90 × 10⁻⁴¹</strong>.</p>

<p>For comparison, zero flags in 1.5 million independent random-key trials gives
an approximate 95% upper bound of <strong>2 × 10⁻⁶ per key</strong> by the rule of three.
That empirical bound is roughly thirty-five orders of magnitude above the
ideal-model probability. A short run cannot validate such a tiny rate. This
statistical statement applies to the random control; the other tiers enumerate
specified sets.</p>

<p>All results below share the same coverage: two legacy P2PKH forms at the
snapshot date. The scanner does not generate the other output types in the
table or request current balances for offline misses.</p>

<h2 id="tier-1-patterned-integers">Tier 1: patterned integers</h2>

<p>Start with small integers, repeated digits, powers of two and ten, and dates.
These are the obvious guesses on the first pages of a key directory. Separate
explorer checks found the following histories for uncompressed addresses:</p>

<table>
  <thead>
    <tr>
      <th>Guess</th>
      <th>Address</th>
      <th style="text-align: right">Txs</th>
      <th style="text-align: right">Ever received</th>
      <th style="text-align: right">Balance at check</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>key = 1</td>
      <td><a href="https://blockstream.info/address/1EHNa6Q4Jz2uvNExL497mE43ikXhwF6kZm">1EHNa6Q4Jz2uvNExL497mE43ikXhwF6kZm</a></td>
      <td style="text-align: right">1475</td>
      <td style="text-align: right">7.849 BTC</td>
      <td style="text-align: right"><strong>0.000</strong></td>
    </tr>
    <tr>
      <td>key = 2</td>
      <td><a href="https://blockstream.info/address/1LagHJk2FyCV2VzrNHVqg3gYG4TSYwDV4m">1LagHJk2FyCV2VzrNHVqg3gYG4TSYwDV4m</a></td>
      <td style="text-align: right">97</td>
      <td style="text-align: right">0.170 BTC</td>
      <td style="text-align: right"><strong>0.000</strong></td>
    </tr>
    <tr>
      <td>key = 3</td>
      <td><a href="https://blockstream.info/address/1NZUP3JAc9JkmbvmoTv7nVgZGtyJjirKV1">1NZUP3JAc9JkmbvmoTv7nVgZGtyJjirKV1</a></td>
      <td style="text-align: right">80</td>
      <td style="text-align: right">0.317 BTC</td>
      <td style="text-align: right"><strong>0.000</strong></td>
    </tr>
    <tr>
      <td>key = 42</td>
      <td><a href="https://blockstream.info/address/1HGn3jxoSh8twi4mR3iaNmZr6pbHgjFJEg">1HGn3jxoSh8twi4mR3iaNmZr6pbHgjFJEg</a></td>
      <td style="text-align: right">4</td>
      <td style="text-align: right">0.000163 BTC</td>
      <td style="text-align: right"><strong>0.000</strong></td>
    </tr>
    <tr>
      <td>key = 1024</td>
      <td><a href="https://blockstream.info/address/17imJe7o4mpq2MMfZ328evDJQfbt6ShvxA">17imJe7o4mpq2MMfZ328evDJQfbt6ShvxA</a></td>
      <td style="text-align: right">4</td>
      <td style="text-align: right">0.0071 BTC</td>
      <td style="text-align: right"><strong>0.000</strong></td>
    </tr>
  </tbody>
</table>

<p>Confirmed-chain figures from Blockstream, rechecked October 16, 2026.
Received amounts are rounded; the linked explorers show later state.</p>

<p><code class="language-plaintext highlighter-rouge">key = 1</code> had received about <strong>7.85 BTC</strong>. Every address in the table had a
transaction history and a zero balance when checked. These figures show that
the keys are publicly discoverable; they do not identify who moved the funds
or how quickly.</p>

<p>The full patterned run checked <strong>1,007,440 keys</strong>: integers from 1 to 1,000,000,
plus the other patterns and dates from the genesis block through October 13,</p>
<ol>
  <li>It found six control-set addresses, corresponding to keys 1, 2, and 3
in both public-key encodings. The explorer confirmed those were empty.
Funded-snapshot flags: <strong>zero</strong>.</li>
</ol>

<h2 id="tier-2-brainwallet-phrases">Tier 2: brainwallet phrases</h2>

<p>A traditional brainwallet sets <code class="language-plaintext highlighter-rouge">private key = SHA-256(passphrase)</code>. The hash
produces a full-length value. The secret choices still come from the phrase
selection process, and familiar phrases are easy to try:</p>

<table>
  <thead>
    <tr>
      <th>Passphrase</th>
      <th>Address</th>
      <th style="text-align: right">Txs</th>
      <th style="text-align: right">Ever received</th>
      <th style="text-align: right">Balance at check</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">password</code></td>
      <td><a href="https://blockstream.info/address/16ga2uqnF1NqpAuQeeg7sTCAdtDUwDyJav">16ga2uqnF1NqpAuQeeg7sTCAdtDUwDyJav</a></td>
      <td style="text-align: right"><strong>45028</strong></td>
      <td style="text-align: right">0.357 BTC</td>
      <td style="text-align: right"><strong>0.000</strong></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">bitcoin</code></td>
      <td><a href="https://blockstream.info/address/1E984zyYbNmeuumzEdqT8VSL8QGJi3byAD">1E984zyYbNmeuumzEdqT8VSL8QGJi3byAD</a></td>
      <td style="text-align: right">54</td>
      <td style="text-align: right">0.171 BTC</td>
      <td style="text-align: right"><strong>0.000</strong></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">satoshi</code></td>
      <td><a href="https://blockstream.info/address/1ADJqstUMBB5zFquWg19UqZ7Zc6ePCpzLE">1ADJqstUMBB5zFquWg19UqZ7Zc6ePCpzLE</a></td>
      <td style="text-align: right">10</td>
      <td style="text-align: right">0.0039 BTC</td>
      <td style="text-align: right"><strong>0.000</strong></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">123456</code></td>
      <td><a href="https://blockstream.info/address/1565qkBbLcuFP78f7MFKkK8jtHGDwdSgvX">1565qkBbLcuFP78f7MFKkK8jtHGDwdSgvX</a></td>
      <td style="text-align: right">12</td>
      <td style="text-align: right">0.0018 BTC</td>
      <td style="text-align: right"><strong>0.000</strong></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">correct horse battery staple</code></td>
      <td><a href="https://blockstream.info/address/1JwSSubhmg6iPtRjtyqhUYYH7bZg3Lfy1T">1JwSSubhmg6iPtRjtyqhUYYH7bZg3Lfy1T</a></td>
      <td style="text-align: right">4163</td>
      <td style="text-align: right"><strong>15.947 BTC</strong></td>
      <td style="text-align: right"><strong>0.000</strong></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">blockchain</code></td>
      <td><a href="https://blockstream.info/address/1bZ3M7i8zLWpGVzpnLy7ci6VWGky1jGBP">1bZ3M7i8zLWpGVzpnLy7ci6VWGky1jGBP</a></td>
      <td style="text-align: right"><strong>28326</strong></td>
      <td style="text-align: right">0.281 BTC</td>
      <td style="text-align: right"><strong>0.000</strong></td>
    </tr>
  </tbody>
</table>

<p>Confirmed-chain figures from Blockstream, rechecked October 16, 2026.
Received amounts are rounded; the linked explorers show later state.</p>

<p>The xkcd phrase <code class="language-plaintext highlighter-rouge">correct horse battery staple</code> had received nearly <strong>16 BTC</strong>.
<code class="language-plaintext highlighter-rouge">password</code> had <strong>45,028 transactions</strong>. A published example phrase has had a
rather unfortunate career as a secret. The transaction counts do not let us
assign an entropy value to an individual phrase; they show its public history.</p>

<p>The dictionary run hashed all <strong>104,334 words</strong> in the system dictionary and
checked both P2PKH forms. It found six control-set addresses, confirmed empty,
and zero funded-snapshot flags. The later explorer checks above are separate
from the run’s October 13 snapshot check.</p>

<h2 id="tier-3-a-synthetic-small-seed-generator">Tier 3: a synthetic small-seed generator</h2>

<p>The <code class="language-plaintext highlighter-rouge">weak_rng</code> rule seeds Python’s Mersenne Twister, <code class="language-plaintext highlighter-rouge">random.Random</code>, with each
integer in a selected range. It is a deliberately constrained model. It does
not implement a mnemonic, PBKDF2, or BIP32, and cannot reconstruct the Milk Sad
or Trust Wallet candidate sets. Those pipelines have different per-candidate
costs and derivation paths.</p>

<p>We exhausted all <strong>16,777,216 seeds</strong> in a 24-bit space. Checking both address
forms took <strong>675.6 seconds</strong>, about 11.3 minutes, at <strong>24,833 keys/s</strong> on one
core. The run produced zero funded-snapshot flags.</p>

<p>Scaling that indexed workload to all 2³² seeds gives about <strong>48 continuous
hours</strong> on the same core. This is a projection for the synthetic model, rather
than a benchmark of a historical incident. The absence of funded matches does
not make a small seed space safe or establish that a real incident had no
victims.</p>

<h2 id="four-candidate-rules-one-snapshot">Four candidate rules, one snapshot</h2>

<table>
  <thead>
    <tr>
      <th>Tier</th>
      <th style="text-align: right">Candidate keys</th>
      <th style="text-align: right">Funded-snapshot flags</th>
      <th style="text-align: right">Separate control addresses found</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>0: random control</td>
      <td style="text-align: right">1,500,000</td>
      <td style="text-align: right">0</td>
      <td style="text-align: right">0</td>
    </tr>
    <tr>
      <td>1: patterned integers</td>
      <td style="text-align: right">1,007,440</td>
      <td style="text-align: right">0</td>
      <td style="text-align: right">6</td>
    </tr>
    <tr>
      <td>2: single-word brainwallets</td>
      <td style="text-align: right">104,334</td>
      <td style="text-align: right">0</td>
      <td style="text-align: right">6</td>
    </tr>
    <tr>
      <td>3: synthetic weak PRNG</td>
      <td style="text-align: right">16,777,216</td>
      <td style="text-align: right">0</td>
      <td style="text-align: right">0</td>
    </tr>
  </tbody>
</table>

<p>The control addresses show that the scanner recognizes selected known cases.
The snapshot results establish no matches within the coverage defined above.
They do not measure how common weak wallets are or whether other output types
controlled by these keys hold funds.</p>

<p>The useful distinction is the work required to enumerate each candidate set.
A long key can come from a small, searchable source. Good key-generation
randomness addresses that risk, while safe signing and private backups address
later failures.</p>

<p><a href="/bitcoin-entropy/part-4/">Part 4</a> examines the documented incidents and
adds an experiment with a known synthetic target population. That lets us
predict an observable match count and compare it with a completed run.</p>

<h2 id="reproducing-the-inputs">Reproducing the inputs</h2>

<p>The <a href="https://github.com/mmpg-x86/entropy-lab/blob/main/docs/scanner.md">scanner guide</a>
explains the run options and index construction. The dictionary hash below was
captured after the run: the available file has 104,334 lines and predates it,
but the original log did not record its hash. That limits what can be
established about the original input bytes.</p>

<p>Run these commands from the root of the companion
<a href="https://github.com/mmpg-x86/entropy-lab"><code class="language-plaintext highlighter-rouge">entropy-lab</code> repository</a>.
Use a byte-identical wordlist for the same dictionary candidates;
the command rejects a hash mismatch. Random draws, timings, and explorer
responses will differ.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>python <span class="nt">-m</span> pip <span class="nb">install</span> <span class="nt">-r</span> requirements.txt
<span class="c"># Throughput control against the built-in address set; no chain check.</span>
python <span class="nt">-m</span> research.scan <span class="nt">--tier</span> random_control <span class="nt">--count</span> 1500000
<span class="c"># Build and verify the dated snapshot index, then screen both P2PKH forms.</span>
python <span class="nt">-m</span> research.snapshot_profile Bitcoin_addresses_October_13_2026.txt.gz
python <span class="nt">-m</span> research.index build Bitcoin_addresses_October_13_2026.txt.gz funded_2026-10-13.idx
python <span class="nt">-m</span> research.index verify funded_2026-10-13.idx
python <span class="nt">-m</span> research.scan <span class="nt">--tier</span> random_control <span class="nt">--count</span> 1500000 <span class="se">\</span>
    <span class="nt">--index</span> funded_2026-10-13.idx <span class="nt">--online-verify</span>
<span class="c"># The three enumerated candidate sets.</span>
python <span class="nt">-m</span> research.scan <span class="nt">--tier</span> patterned <span class="nt">--limit</span> 1000000 <span class="nt">--end-date</span> 2026-10-13 <span class="se">\</span>
    <span class="nt">--index</span> funded_2026-10-13.idx <span class="nt">--online-verify</span>
python <span class="nt">-m</span> research.scan <span class="nt">--tier</span> brainwallet <span class="nt">--wordlist</span> /usr/share/dict/words <span class="se">\</span>
    <span class="nt">--wordlist-sha256</span> 9e66281f7e51445eab6857488ff6e3d768afffadb7fb1adbef5e4617bee4a53b <span class="se">\</span>
    <span class="nt">--index</span> funded_2026-10-13.idx <span class="nt">--online-verify</span>
python <span class="nt">-m</span> research.scan <span class="nt">--tier</span> weak_rng <span class="nt">--seed-bits</span> 24 <span class="nt">--count</span> 16777216 <span class="se">\</span>
    <span class="nt">--index</span> funded_2026-10-13.idx <span class="nt">--online-verify</span>
<span class="c"># Check address derivation against known vectors.</span>
python <span class="nt">-m</span> research.btckit
</code></pre></div></div>]]></content><author><name>Maciej Grochowski</name></author><category term="security" /><summary type="html"><![CDATA[Four candidate generators, a dated funded-address snapshot, and zero snapshot matches. The useful evidence is in exactly what the scanner tested.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://page-fault.io/assets/social-preview.png" /><media:content medium="image" url="https://page-fault.io/assets/social-preview.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Seed Phrases, Entropy, and the Myth of the Narrowed Search</title><link href="https://page-fault.io/bitcoin-entropy/part-2/" rel="alternate" type="text/html" title="Seed Phrases, Entropy, and the Myth of the Narrowed Search" /><published>2026-10-16T17:11:18+00:00</published><updated>2026-10-16T17:11:18+00:00</updated><id>https://page-fault.io/bitcoin-entropy/bitcoin-entropy-part-2</id><content type="html" xml:base="https://page-fault.io/bitcoin-entropy/part-2/"><![CDATA[<p>A Bitcoin private key has roughly 256 bits of range, but a modern wallet may
ask you to back up only twelve words. Does that narrow the search enough to
make guessing practical? It narrows the mnemonic input space enormously. With
uniform generation, the smaller space is still far beyond practical classical
exhaustive search.</p>

<p>To see what those words protect, we need to follow the derivation. Recovering
one private key and recovering the root of a wallet are different jobs.</p>

<h2 id="from-entropy-to-a-key-tree">From entropy to a key tree</h2>

<p>Bitcoin Improvement Proposal 39 (BIP39) defines a mnemonic that encodes the
initial entropy and a checksum. It then feeds a key-derivation function, whose
output supplies BIP32’s hierarchical deterministic (HD) key tree:</p>

<style>
.post-content .entropy-key-diagram { margin: 24px 0; }
.post-content .entropy-key-diagram img {
  width: 100%;
  height: auto;
  padding: 0;
  margin: 0;
  background: transparent;
  border-radius: 0;
  color-scheme: dark;
}
[data-theme="light"] .post-content .entropy-key-diagram img { color-scheme: light; }
</style>

<figure class="entropy-key-diagram">
  <img src="https://res.cloudinary.com/gotocco/image/upload/v1792174588/bitcoin-entropy-to-keys-adaptive_hrafmu.svg" width="1060" height="1290" loading="lazy" decoding="async" alt="Wallet derivation: 128–256 random bits become BIP39 recovery words with a checksum. PBKDF2-HMAC-SHA512 combines the words and optional passphrase into a 512-bit seed. BIP32 derives a master private key, chain code, and child key tree. The longer seed does not increase the original entropy." />
  <figcaption>From initial entropy to recovery words, a seed, and a key tree. <a href="https://res.cloudinary.com/gotocco/image/upload/v1792174588/bitcoin-entropy-to-keys-adaptive_hrafmu.svg">View the full-size diagram</a>.</figcaption>
</figure>

<p>PBKDF2 is Password-Based Key Derivation Function 2; here it uses HMAC-SHA512.
The <a href="https://github.com/bitcoin/bips/blob/master/bip-0039.mediawiki">BIP39 specification</a>
defines the conversion, including UTF-8 NFKD normalization of the mnemonic and
passphrase before deriving the seed.</p>

<p>A path such as <code class="language-plaintext highlighter-rouge">m/44'/0'/0'/0/0</code> selects one child private key from that tree.
Its public key and receiving address follow from that child, using the
<a href="/bitcoin-entropy/part-1/#a-private-key-is-a-number">derivation in Part 1</a>.</p>

<p>Each word selects one of 2,048 entries and therefore encodes 11 bits. The
English list runs from <code class="language-plaintext highlighter-rouge">abandon</code> to <code class="language-plaintext highlighter-rouge">zoo</code>, with unique first four letters.
The checksum length is <code class="language-plaintext highlighter-rouge">ENT/32</code>, where <code class="language-plaintext highlighter-rouge">ENT</code> is the initial entropy length.
For BIP39’s allowed 128–256 bits, in steps of 32, the combined bit string gives
exactly five supported word counts.</p>

<table>
  <thead>
    <tr>
      <th>Words</th>
      <th style="text-align: right">Initial entropy</th>
      <th style="text-align: right">Checksum</th>
      <th style="text-align: right">Uniform mnemonic inputs</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>12</td>
      <td style="text-align: right">128 bits</td>
      <td style="text-align: right">4 bits</td>
      <td style="text-align: right">2¹²⁸</td>
    </tr>
    <tr>
      <td>15</td>
      <td style="text-align: right">160 bits</td>
      <td style="text-align: right">5 bits</td>
      <td style="text-align: right">2¹⁶⁰</td>
    </tr>
    <tr>
      <td>18</td>
      <td style="text-align: right">192 bits</td>
      <td style="text-align: right">6 bits</td>
      <td style="text-align: right">2¹⁹²</td>
    </tr>
    <tr>
      <td>21</td>
      <td style="text-align: right">224 bits</td>
      <td style="text-align: right">7 bits</td>
      <td style="text-align: right">2²²⁴</td>
    </tr>
    <tr>
      <td>24</td>
      <td style="text-align: right">256 bits</td>
      <td style="text-align: right">8 bits</td>
      <td style="text-align: right">2²⁵⁶</td>
    </tr>
  </tbody>
</table>

<p>The checksum supplies error detection, not entropy. Only one in 16 uniformly
chosen 12-word strings passes it; for 24 words the fraction is one in 256.
An enumerator can start with entropy values and calculate the checksum, so
these bits do not add secret choices to search.</p>

<p>The optional passphrase changes the seed. It is sometimes called the “25th
word,” but can be an arbitrary string. Every passphrase produces a wallet,
including a typo. That makes exact recovery of the passphrase part of the
backup problem.</p>

<h2 id="the-512-bit-output-does-not-add-entropy">The 512-bit output does not add entropy</h2>

<p>Hold the passphrase and derivation path fixed. Twelve uniformly generated words
supply 2¹²⁸ possible mnemonic inputs, and at most that many values for a
particular child key. Twenty-four words supply 2²⁵⁶ inputs. Deterministic
mappings can collide, so those counts do not prove that every scalar is
reachable. Varying the path changes the question again: one mnemonic generates
many child keys.</p>

<p>Both lengths produce a seed in the same 512-bit format. The longer output
does not create more input uncertainty. A separately chosen secret passphrase
can add uncertainty; more output bytes cannot. The same applies to a broken
source: recording 24 words from a generator with only a few predictable inputs
does not repair it.</p>

<h2 id="guessing-twelve-words">Guessing twelve words</h2>

<p>Part 1 used a generous rate of 10¹² raw keys per year per core. If complete
mnemonic candidates could be checked at that rate, exhausting the 12-word
space would take:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>2^128 / 10^12 ≈ 3.4 × 10^26 years
</code></pre></div></div>

<p>This is a scale comparison, not a mnemonic benchmark. The scanner does not
measure PBKDF2 or BIP32, and checking multiple paths adds work. Different
implementations and hardware need their own measurements.</p>

<p>Even a trillion-fold speedup over the assumed one-core rate leaves about
3.4 × 10¹⁴ years, more than twenty thousand times the current age of the
universe. Twelve uniformly generated words leave a large classical search
margin. Twenty-four make root-secret guessing vastly harder still.</p>

<h2 id="recovering-one-child-key">Recovering one child key</h2>

<p>An exposed secp256k1 public key permits a different attack. Generic discrete-log
recovery takes roughly 2¹²⁸ group operations, regardless of mnemonic length.
Typical Pay-to-Public-Key-Hash (P2PKH) and Pay-to-Witness-Public-Key-Hash (P2WPKH)
spends reveal public keys; Taproot exposes an output public key on receipt.
Recovering the corresponding secret can compromise that output. It generally
does not reveal the mnemonic or every other key.</p>

<p>BIP32 has a specific exception. A parent extended public key (<code class="language-plaintext highlighter-rouge">xpub</code>, including
the chain code), together with a descendant private key connected entirely by
non-hardened steps, can reveal the parent extended private key and its subtree.
Hardened derivation blocks that route across its boundary. Compromising one
account therefore does not automatically recover the master seed or other
hardened accounts. <a href="https://github.com/bitcoin/bips/blob/master/bip-0032.mediawiki#implications">BIP32 documents the implications</a>.</p>

<p>The curve’s roughly 128-bit classical security describes the individual
exposed public key. It does not erase the extra resistance that 24 words give
the wallet’s root secret against mnemonic guessing.</p>

<h2 id="backup-length-and-secret-handling">Backup length and secret handling</h2>

<p>Twenty-four uniformly generated words provide more margin against seed
search. Some of that margin may remain after a partial leak, depending on
exactly what became known. Twelve words give a shorter backup with a still
large classical search space. For either length, a backup has to remain
readable, recoverable, and private.</p>

<p>A copied complete mnemonic removes its guessing margin. The reported implant
in Part 0 would not need more computing power to copy 24 words instead of 12.
A separate, uncompromised passphrase changes what the words alone unlock. Its
protection depends on how it was chosen and kept, rather than on calling it
another word.</p>

<h2 id="before-seed-words-walletdat">Before seed words: wallet.dat</h2>

<p>A Satoshi-era wallet did not use twelve BIP39 words. The standards came later:</p>

<table>
  <thead>
    <tr>
      <th>Date</th>
      <th>Wallet behavior or standard</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>2009–2010</td>
      <td>The client stores individually generated random keys in a Berkeley DB <code class="language-plaintext highlighter-rouge">wallet.dat</code>. Recovery depends on backups containing the relevant keys.</td>
    </tr>
    <tr>
      <td>October 9, 2010</td>
      <td>Satoshi <a href="https://satoshi.nakamotoinstitute.org/posts/bitcointalk/490/">announces the key pool</a>, defaulting to 100 pre-generated keys so a backup can cover future use.</td>
    </tr>
    <tr>
      <td>September 23, 2011</td>
      <td><a href="https://bitcoin.org/en/release/v0.4.0">Version 0.4.0</a> adds wallet encryption and upgrades Berkeley DB from 4.7 to 4.8. Encryption protects stored keys with a passphrase.</td>
    </tr>
    <tr>
      <td>2012</td>
      <td><a href="https://github.com/bitcoin/bips/blob/master/bip-0032.mediawiki">BIP32</a> proposes hierarchical deterministic wallets.</td>
    </tr>
    <tr>
      <td>2013</td>
      <td><a href="https://github.com/bitcoin/bips/blob/master/bip-0039.mediawiki">BIP39</a> proposes the mnemonic scheme.</td>
    </tr>
  </tbody>
</table>

<p>A genuine Satoshi-era wallet is a bag of individually generated keys in a
<code class="language-plaintext highlighter-rouge">wallet.dat</code>. Anyone offering “Satoshi’s 12 words” is selling a story.</p>

<p>Both designs depend on unpredictable generation. Neither excuses unsafe
signing or a copied backup. <a href="/bitcoin-entropy/part-3/">Part 3</a> tests what
happens when the candidate generator starts with small or human-chosen spaces.</p>]]></content><author><name>Maciej Grochowski</name></author><category term="security" /><summary type="html"><![CDATA[Twelve words encode less entropy than twenty-four. Following BIP39 and BIP32 shows what that changes for guessing a seed, recovering a key, and backing up a wallet.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://page-fault.io/assets/social-preview.png" /><media:content medium="image" url="https://page-fault.io/assets/social-preview.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">The Database of All Bitcoin Private Keys Is a Magic Trick</title><link href="https://page-fault.io/bitcoin-entropy/part-1/" rel="alternate" type="text/html" title="The Database of All Bitcoin Private Keys Is a Magic Trick" /><published>2026-10-16T17:11:17+00:00</published><updated>2026-10-16T17:11:17+00:00</updated><id>https://page-fault.io/bitcoin-entropy/bitcoin-entropy-part-1</id><content type="html" xml:base="https://page-fault.io/bitcoin-entropy/part-1/"><![CDATA[<p>You come across <a href="https://keys.lol/">keys.lol</a>, a website claiming to contain
every Bitcoin private key. You open a page and find private keys listed beside
their addresses, with links to more pages than you could ever browse. The last
page number has 75 digits. If the site supposedly has the key to your wallet
too, shouldn’t you be worried?</p>

<p>The site calculates each page when you ask for it. Understanding that small
trick explains why displaying a keyspace is easy and searching it is not.</p>

<h2 id="a-private-key-is-a-number">A private key is a number</h2>

<p>A Bitcoin private key is an integer from 1 to:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>115792089237316195423570985008687907852837564279074904382605163141518161494336
</code></pre></div></div>

<p>The upper bound is <code class="language-plaintext highlighter-rouge">n − 1</code>, where <code class="language-plaintext highlighter-rouge">n</code> is the order of the secp256k1 elliptic
curve group. Valid keys satisfy <code class="language-plaintext highlighter-rouge">1 ≤ k &lt; n</code>; the range is just under 2²⁵⁶.
Multiply the curve’s generator by that integer to get a public key. Hash and
encode the public key to obtain a legacy Pay-to-Public-Key-Hash (P2PKH) address.
The forward calculation is cheap. Recovering the secret from a public key or
address is computationally infeasible under the usual classical models.</p>

<p>The directory uses the forward calculation. A page number selects a short run
of integers, and the server derives their addresses. Every key is “already
there” in the same sense that every integer is already on the number line.
The site is a pocket calculator wearing a trench coat.</p>

<p>Here is the public-key derivation and the hash used in a legacy address:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="n">hashlib</span>
<span class="kn">from</span> <span class="n">coincurve</span> <span class="kn">import</span> <span class="n">PrivateKey</span>


<span class="k">def</span> <span class="nf">hash160</span><span class="p">(</span><span class="n">data</span><span class="p">):</span>
    <span class="n">sha256</span> <span class="o">=</span> <span class="n">hashlib</span><span class="p">.</span><span class="nf">sha256</span><span class="p">(</span><span class="n">data</span><span class="p">).</span><span class="nf">digest</span><span class="p">()</span>
    <span class="k">return</span> <span class="n">hashlib</span><span class="p">.</span><span class="nf">new</span><span class="p">(</span><span class="sh">"</span><span class="s">ripemd160</span><span class="sh">"</span><span class="p">,</span> <span class="n">sha256</span><span class="p">).</span><span class="nf">digest</span><span class="p">()</span>


<span class="n">secret</span> <span class="o">=</span> <span class="mi">1</span>  <span class="c1"># Any integer in [1, n).
</span><span class="n">pub</span> <span class="o">=</span> <span class="n">PrivateKey</span><span class="p">.</span><span class="nf">from_int</span><span class="p">(</span><span class="n">secret</span><span class="p">).</span><span class="n">public_key</span><span class="p">.</span><span class="nf">format</span><span class="p">(</span><span class="n">compressed</span><span class="o">=</span><span class="bp">False</span><span class="p">)</span>
<span class="n">pubkey_hash</span> <span class="o">=</span> <span class="nf">hash160</span><span class="p">(</span><span class="n">pub</span><span class="p">)</span>
<span class="c1"># P2PKH address = Base58Check(0x00 || pubkey_hash)
</span></code></pre></div></div>

<p>For <code class="language-plaintext highlighter-rouge">secret = 1</code>, the uncompressed public-key form gives
<code class="language-plaintext highlighter-rouge">1EHNa6Q4Jz2uvNExL497mE43ikXhwF6kZm</code>. Part 3 checks its rather busy transaction
history. The number is a valid key; choosing it as a secret is another matter.</p>

<p>That distinction runs through the series. Uniform selection across the valid
range supplies about 256 bits of source entropy. A fixed algorithm starting
from a 32-bit seed reaches at most <code class="language-plaintext highlighter-rouge">2^32</code> outputs, however long the resulting
key looks. <a href="/bitcoin-entropy/part-0/#randomness-entropy-and-keeping-a-secret">Part 0</a>
introduces that difference between output length and input uncertainty.</p>

<h2 id="storing-the-directory">Storing the directory</h2>

<p>The range contains about:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>2^256 ≈ 1.16 × 10^77 keys
</code></pre></div></div>

<p>One byte per key would already require about 10⁷⁷ bytes. An actual key-and-address
record would need much more. No useful storage plan starts there.</p>

<p>Calculating a requested page avoids the storage problem. It gives no shortcut
to finding a particular secret: the server still has to choose which page to
calculate. That is where the size of the range becomes inconvenient.</p>

<h2 id="searching-instead">Searching instead</h2>

<p>On one laptop core, the <a href="https://github.com/mmpg-x86/entropy-lab/blob/main/research/scan.py">companion scanner</a>
derives both legacy P2PKH address
forms at about <strong>31,000 keys/s</strong>. Loading the funded-address index and screening
both forms brings the measured control rate to about <strong>28,000 keys/s</strong>. Round
up generously to <strong>10¹² keys per year per core</strong>:</p>

<table>
  <thead>
    <tr>
      <th>Search</th>
      <th style="text-align: right">Candidates</th>
      <th style="text-align: right">Exhaustion time at 10¹² candidates/year</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Full private-key range</td>
      <td style="text-align: right">≈2²⁵⁶</td>
      <td style="text-align: right">≈10⁶⁵ years</td>
    </tr>
    <tr>
      <td>All valid 12-word BIP39 mnemonics</td>
      <td style="text-align: right">2¹²⁸</td>
      <td style="text-align: right">≈10²⁶ years</td>
    </tr>
  </tbody>
</table>

<p>The mnemonic row borrows the raw-key rate to show scale. It assumes uniform
generation, a fixed passphrase, and specified derivation paths. A real mnemonic
search also performs Password-Based Key Derivation Function 2 (PBKDF2) and
BIP32 derivation; this project has not measured that pipeline.</p>

<p>The universe is about 1.4 × 10¹⁰ years old. Exhausting even the 12-word space
at this assumed rate takes roughly 10¹⁶ times that long. A million cores and a
million-fold speedup remove twelve orders of magnitude. There are still plenty
left.</p>

<p>We ran a small random search anyway. The control generated <strong>1,500,000 keys</strong>,
derived both P2PKH forms, and screened the resulting three million addresses
against the <strong>57,205,029-address snapshot dated October 13, 2026</strong>. There were
zero index flags, so the run made no live explorer lookups.</p>

<p>Of the snapshot entries, 21,198,062 have the legacy <code class="language-plaintext highlighter-rouge">1…</code> prefix. Treat every one
as a valid P2PKH target and assume ideal address hashes. The probability of any
exact address match is then at most approximately:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>3,000,000 × 21,198,062 / 2^160 ≈ 4.35 × 10^-35
</code></pre></div></div>

<p>Zero is the expected observation. It applies to those two address forms in
that snapshot. Part 3 explains the prefix index and why a miss says nothing
about other output types or a later balance.</p>

<h2 id="which-secret-and-how-many-targets">Which secret, and how many targets?</h2>

<p>Exhausting a range is only one attack model. Two distinctions affect the cost:</p>

<p><strong>An exposed public key permits a discrete-log attack.</strong> A generic attack such
as Pollard’s rho needs roughly √n ≈ 2¹²⁸ group operations on secp256k1. That is
about 128 bits of classical security for an exposed public key. Recovering that
key’s secret generally does not recover its mnemonic or the wallet’s whole key
tree. <a href="https://github.com/bitcoin/bips/blob/master/bip-0032.mediawiki#security">BIP32 treats those properties separately</a>.</p>

<p><strong>Finding any target can be easier than finding one specified target.</strong> Suppose
there were 2²⁶ distinct, uniformly generated 12-word mnemonics, all using the
fixed passphrase and paths under search. Finding any one would take about
2¹²⁸ / 2²⁶ = 2¹⁰² guesses on average, or roughly 10¹⁹ years at our assumed rate.
The address dump does not establish that population: an address count tells us
neither how many distinct mnemonic-backed wallets exist nor their derivation
paths.</p>

<p>For a different example, uniformly generated raw keys matching any of 2²⁶
P2PKH address hashes would take about 2¹⁶⁰ / 2²⁶ = 2¹³⁴ trials. That is an
address-hash search. Its work units differ from the group operations in a
discrete-log attack and the full derivations in a mnemonic search.</p>

<h2 id="the-smaller-spaces-people-actually-chose">The smaller spaces people actually chose</h2>

<p>Real key-guessing attacks exploit secrets drawn from much smaller populations:
short seeds, unsuitable pseudorandom generators, and human-chosen phrases.
Other failures occur later. A repeated signing nonce can expose a strong key
through signature algebra; a copied recovery phrase needs no search at all.</p>

<p><a href="/bitcoin-entropy/part-2/">Part 2</a> follows the normal wallet
generation stack and the 12-versus-24-word question. Then Part 3 measures four
candidate-generation rules against the snapshot, and Part 4 examines documented
randomness failures and a separate synthetic benchmark.</p>

<p>The scanner reads public blockchain data and never constructs or broadcasts a
transaction. The other experiments use synthetic data. A funded match would
be a statistic to report; it would not authorize spending from that address.</p>]]></content><author><name>Maciej Grochowski</name></author><category term="security" /><summary type="html"><![CDATA[A private-key directory calculates its pages on demand. Building the trick shows why displaying a keyspace and searching it are different jobs.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://page-fault.io/assets/social-preview.png" /><media:content medium="image" url="https://page-fault.io/assets/social-preview.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Nobody Broke the Math</title><link href="https://page-fault.io/bitcoin-entropy/part-0/" rel="alternate" type="text/html" title="Nobody Broke the Math" /><published>2026-10-15T12:00:00+00:00</published><updated>2026-10-15T12:00:00+00:00</updated><id>https://page-fault.io/bitcoin-entropy/bitcoin-entropy-part-0</id><content type="html" xml:base="https://page-fault.io/bitcoin-entropy/part-0/"><![CDATA[<p>An attacker does not have to crack a hardware wallet’s security chip if the
device is about to display the secret. In the <a href="https://www.coindesk.com/tech/2026/10/13/ledger-says-spy-chip-inside-cryptobilis-ledger-devices-transmitted-wallet-keys-over-mobile-network">reported Ledger implant case</a>,
an added circuit copied the recovery phrase from the screen during setup and
sent it over a mobile network. Ledger’s security team said the Secure Element
and operating system were intact. The words were shown on a screen, and the
screen had an audience.</p>

<p>For years, we have heard stories about mysteriously drained wallets: the owner
still had the hardware wallet, had not typed the recovery phrase into a website,
and could not explain where the coins had gone. A copied backup or a phishing
page could explain some cases. It was also easy to assume the owner had simply
forgotten, or omitted, a mistake.</p>

<p>That mechanism could explain at least some of those older stories without
anyone guessing a private key or the owner entering the phrase on a computer.
It does not tell us what happened in any particular earlier case. It does show
why “I never typed my seed anywhere” is not the end of the investigation.</p>

<p>After the news, a few colleagues asked me about websites claiming to contain a
“database of all Bitcoin private keys.” If every possible key was already on a
website, were our wallets safe? Was this how the money had disappeared?</p>

<p>Those questions are why I started this series. Before counting private keys,
though, we need to establish what the device checks actually tell us.</p>

<h2 id="what-the-incident-reports-establish">What the incident reports establish</h2>

<p><em>Incident account checked on October 15, 2026; the investigation remains open.</em></p>

<p>The reported losses involved devices bought through CryptoBilis, a Southeast
Asian reseller listed in Ledger’s official directory <a href="https://www.coindesk.com/tech/2026/10/13/ledger-says-spy-chip-inside-cryptobilis-ledger-devices-transmitted-wallet-keys-over-mobile-network">1</a>, <a href="https://shop.ledger.com/pages/resellers">4</a>. Authorized status
had not prevented the reported tampering. Ledger asked the reseller to pause
sales and shipments, and the October 13 report describes the company’s findings
and advice to affected buyers <a href="https://www.coindesk.com/tech/2026/10/13/ledger-says-spy-chip-inside-cryptobilis-ledger-devices-transmitted-wallet-keys-over-mobile-network">1</a>. I have not examined an affected device.</p>

<p><a href="https://www.bitquery.io/investigations/ledger-cryptobilis-hack">Bitquery’s investigation</a>, marked verified October 15, estimates <strong>$102.0
million taken from 324 wallets on six chains</strong>, with $10 million in USDT frozen
by Tether. These are Bitquery’s attribution and valuation of the flows, rather
than a Ledger-confirmed victim count. Transaction traces show where funds went;
they cannot establish where a physical device was altered.</p>

<h2 id="before-you-initialize-the-device">Before you initialize the device</h2>

<p>Ledger’s <a href="https://www.ledger.com/academy/topics/ledgersolutions/best-practices-to-securely-buy-ledger-signer">buying guide</a> gives a practical starting point:</p>

<ul>
  <li><strong>Check the seller and package.</strong> Verify the seller against the manufacturer’s
authorized list. If the package looks opened or altered, stop and contact
support.</li>
  <li><strong>Check the setup state.</strong> A new device should not arrive with a supplied PIN
or recovery phrase. The device should generate the words during your setup;
record them privately.</li>
  <li><strong>Use the official software.</strong> Download the app from the manufacturer’s site
or official app store and run Genuine Check. Never enter the recovery phrase
into an app or website.</li>
</ul>

<p>Genuine Check authenticates the Secure Element. It cannot detect an added
spying implant when that original chip remains intact <a href="https://www.ledger.com/academy/topics/ledgersolutions/best-practices-to-securely-buy-ledger-signer">3</a>. Authorized sourcing,
intact-looking packaging, and a passing check do not amount to a hardware audit.</p>

<p>Establishing whether a particular device is trustworthy requires examining its
hardware, firmware, and handling of secrets. Here we can work through the
wallet derivations and the mathematics of guessing: count the possible inputs,
follow how they become keys, and calculate probabilities under stated
assumptions. Those calculations do not certify an unknown implementation, but
they let us explain precisely what correctly generated secrets protect against.</p>

<h2 id="randomness-entropy-and-keeping-a-secret">Randomness, entropy, and keeping a secret</h2>

<p>For key generation, randomness has to make the choice unpredictable to an
attacker. Appearance is a poor test. A string derived from a clock value can
look just as untidy as one drawn from a secure source.</p>

<p><strong>Entropy measures uncertainty.</strong> Four fair, independent coin flips have 16
equally likely outcomes: four bits of entropy. Another independent fair flip
doubles the possibilities. For <code class="language-plaintext highlighter-rouge">N</code> equally likely outcomes, entropy is
<code class="language-plaintext highlighter-rouge">log2(N)</code> bits:</p>

<table>
  <thead>
    <tr>
      <th>Choice</th>
      <th style="text-align: right">Equally likely possibilities</th>
      <th style="text-align: right">Entropy</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>One fair coin flip</td>
      <td style="text-align: right">2</td>
      <td style="text-align: right">1 bit</td>
    </tr>
    <tr>
      <td>Four independent fair flips</td>
      <td style="text-align: right">16</td>
      <td style="text-align: right">4 bits</td>
    </tr>
    <tr>
      <td>A uniform 32-bit seed</td>
      <td style="text-align: right">4,294,967,296</td>
      <td style="text-align: right">32 bits</td>
    </tr>
  </tbody>
</table>

<p>The qualifications do work here. Biased or dependent flips do not supply one
new bit each. Information already available to an attacker reduces the
uncertainty that remains. Counting possible outputs can therefore overstate
the difficulty of guessing a secret; NIST’s entropy-source guidance uses
measures such as min-entropy to account for this <a href="https://csrc.nist.gov/pubs/sp/800/90/b/final">6</a>.</p>

<p>Suppose a fixed algorithm expands a 32-bit seed into a 256-bit key. It can
produce at most <code class="language-plaintext highlighter-rouge">2^32</code> different keys. Hashing the seed, printing more digits,
or turning it into words cannot add input possibilities. The next question is
how much work the attacker must do to test each possibility.</p>

<p>Deterministic generation is normal in cryptography. A <strong>cryptographically
secure pseudorandom number generator</strong> (CSPRNG) expands secret state into
outputs designed to resist prediction, provided the state has enough entropy
and the implementation is sound <a href="https://csrc.nist.gov/pubs/sp/800/90/a/r1/final">7</a>. Python’s <code class="language-plaintext highlighter-rouge">secrets</code> module uses the
operating system’s secure source; <code class="language-plaintext highlighter-rouge">random</code> serves simulation <a href="https://docs.python.org/3.13/library/secrets.html">8</a>. Neither can
make a small, guessable initial seed impossible to enumerate.</p>

<p>Then there is <strong>confidentiality</strong>. A correctly generated secret can still be
copied from a display, recovery entry, or backup. Once the attacker has it,
there is nothing left to guess. The reported implant crossed that boundary
without needing to change the key-generation algorithm.</p>

<h2 id="five-posts-three-failure-stages">Five posts, three failure stages</h2>

<p>I wrote the scripts accompanying this series to put numbers behind that
question. The companion repository,
<a href="https://github.com/mmpg-x86/entropy-lab"><code class="language-plaintext highlighter-rouge">entropy-lab</code></a>, contains the scanner,
RNG models, and synthetic benchmarks. The series starts by explaining the key
directory, follows the wallet-generation stack, and ends with measured searches:</p>

<table>
  <thead>
    <tr>
      <th>Part</th>
      <th>Question</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><a href="/bitcoin-entropy/part-1/">1 — The database of all private keys</a></td>
      <td>How can a website show every key without storing them?</td>
    </tr>
    <tr>
      <td><a href="/bitcoin-entropy/part-2/">2 — Seed phrases and entropy</a></td>
      <td>What changes between 12 and 24 words, and what does a seed recover?</td>
    </tr>
    <tr>
      <td><a href="/bitcoin-entropy/part-3/">3 — The scanner experiment</a></td>
      <td>What do four bounded candidate searches find in a funded-address snapshot?</td>
    </tr>
    <tr>
      <td><a href="/bitcoin-entropy/part-4/">4 — When randomness fails</a></td>
      <td>Where have real generators failed, and what can a synthetic benchmark establish?</td>
    </tr>
  </tbody>
</table>

<p>Keep three stages separate as we go. Weak generation makes a key guessable.
A signing failure can expose an otherwise strong key. Copying the recovery
secret bypasses both. Calling all three “broken cryptography” hides the part
that needs fixing.</p>

<h2 id="public-keys-on-demand-public-addresses-on-disk">Public keys on demand, public addresses on disk</h2>

<p>The raw ingredients sound alarming. <a href="https://keys.lol/">keys.lol</a> displays
private keys and their addresses. The retired <a href="https://directory.io/">directory.io</a>
keeps its original first page as a demonstration. Public datasets list funded
addresses and historical address use <a href="http://addresses.loyce.club/">5</a>. Put a candidate generator on one side
and a funded-address list on the other, and you have a search pipeline.</p>

<p>The active directory calculates its pages on demand. It has not obtained anyone’s
secret. Part 1 shows the arithmetic; the page count alone needs 75 digits.</p>

<p>For Part 3, the funded-address snapshot contains <strong>57,205,029 entries</strong>, dated
October 13, 2026. The scanner derives compressed and uncompressed legacy
Pay-to-Public-Key-Hash (P2PKH) addresses for each candidate key. Of the snapshot
entries, 21,198,062 start with <code class="language-plaintext highlighter-rouge">1</code>, the legacy P2PKH prefix; this count classifies
prefixes without validating checksums.</p>

<p>The four runs covered 1.5 million random keys, just over a million patterned
integers, 104,334 dictionary brainwallets, and all 16,777,216 seeds in a
synthetic 24-bit generator. None produced a funded-snapshot prefix flag. Known,
drained addresses served as separate positive controls. Part 3 explains the
index, its verification limit, and why this result covers only the tested
address forms at that snapshot date.</p>

<h2 id="protecting-the-secret-after-setup">Protecting the secret after setup</h2>

<p>The reported implant gives us a useful way to read the rest of the series.
Entropy protects against guessing. Every point where a secret appears needs
protection against copying.</p>

<p>Later recovery entries, displays, and backups create further opportunities to
copy the phrase. For CryptoBilis purchases, the October reports relay Ledger’s
advice to leave unused devices uninitialized and move assets from used devices
to a new device with a newly generated phrase <a href="https://www.coindesk.com/tech/2026/10/13/ledger-says-spy-chip-inside-cryptobilis-ledger-devices-transmitted-wallet-keys-over-mobile-network">1</a>. Consult current manufacturer
guidance before acting on this continuing incident.</p>

<p>Adding words does not protect a complete phrase after someone has copied it.
A separately protected BIP39 passphrase changes what those words alone can
unlock; Part 2 follows that distinction through the derivation stack.</p>

<p>There is an observable result at the other end of the series. A
<a href="https://github.com/mmpg-x86/entropy-lab/blob/main/docs/benchmark.md#completed-benchmark">synthetic benchmark</a>
made 50 billion probes against a planted synthetic target set and counted
<strong>174 matches</strong>,
against <strong>186.26 expected</strong>. The idealized 128- and 256-bit controls each made
50 billion probes and counted zero. Part 4 explains why the planted count is
useful evidence, while the zero counts cannot certify a real entropy source.</p>

<p>First, the website that claims to have all the keys.</p>

<h3 id="sources">Sources</h3>

<ol>
  <li>CoinDesk, October 13, 2026 — <a href="https://www.coindesk.com/tech/2026/10/13/ledger-says-spy-chip-inside-cryptobilis-ledger-devices-transmitted-wallet-keys-over-mobile-network">Ledger’s reported findings on the CryptoBilis hardware implant</a>.</li>
  <li>Bitquery — <a href="https://www.bitquery.io/investigations/ledger-cryptobilis-hack">Ledger CryptoBilis on-chain investigation</a>, figures marked verified October 15, 2026.</li>
  <li>Ledger — <a href="https://www.ledger.com/academy/topics/ledgersolutions/best-practices-to-securely-buy-ledger-signer">Buying guidance and the limits of Genuine Check</a>.</li>
  <li>Ledger — <a href="https://shop.ledger.com/pages/resellers">Authorized reseller directory</a>, rechecked October 16, 2026.</li>
  <li>Public address datasets — <a href="http://addresses.loyce.club/">funded addresses</a> (<a href="https://bitcointalk.org/index.php?topic=5254914.0">maintainer’s project thread</a>) and <a href="http://alladdresses.loyce.club/">historical address use</a>.</li>
  <li>NIST — <a href="https://csrc.nist.gov/pubs/sp/800/90/b/final">SP 800-90B: entropy-source assessment</a>.</li>
  <li>NIST — <a href="https://csrc.nist.gov/pubs/sp/800/90/a/r1/final">SP 800-90A Rev. 1: deterministic random-bit generators</a>.</li>
  <li>Python 3.13 — <a href="https://docs.python.org/3.13/library/secrets.html"><code class="language-plaintext highlighter-rouge">secrets</code> documentation</a>.</li>
</ol>]]></content><author><name>Maciej Grochowski</name></author><category term="security" /><summary type="html"><![CDATA[A hardware wallet can generate an unpredictable secret and still give it away. That distinction starts a series on Bitcoin keys, entropy, and failed randomness.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://page-fault.io/assets/social-preview.png" /><media:content medium="image" url="https://page-fault.io/assets/social-preview.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Offsets, Embedded Nodes, and Other Phantoms of Lost Structs</title><link href="https://page-fault.io/reverse-engineering/firmware/structs/2026/10/15/offsets-embedded-nodes-and-other-phantoms-of-lost-structs.html" rel="alternate" type="text/html" title="Offsets, Embedded Nodes, and Other Phantoms of Lost Structs" /><published>2026-10-15T12:00:00+00:00</published><updated>2026-10-15T12:00:00+00:00</updated><id>https://page-fault.io/reverse-engineering/firmware/structs/2026/10/15/offsets-embedded-nodes-and-other-phantoms-of-lost-structs</id><content type="html" xml:base="https://page-fault.io/reverse-engineering/firmware/structs/2026/10/15/offsets-embedded-nodes-and-other-phantoms-of-lost-structs.html"><![CDATA[<p>In <a href="/reverse-engineering/firmware/crypto/2026/10/08/sboxes-hash-constants-and-other-cryptographic-fingerprints.html">Part 1</a>, we identified AES, SHA-256, and CRC-32 from constant tables in <code class="language-plaintext highlighter-rouge">.rodata</code>. CRC-16 had no table, so we inspected its code to identify the algorithm and its parameters.</p>

<p>Now we need to go deeper. The firmware has scheduler code, mutexes with ownership tracking, a rule engine with allocation, programming, and commit states, and a pool allocator with sentinel values. All of these are built around <strong>structs</strong> – carefully designed data structures with fields at specific offsets, linked together with embedded list nodes.</p>

<p>The companion RTOS models scheduler data structures but does not save and restore CPU context. Its task entry functions are called directly. That still gives us the list and field-access patterns we need to recover.</p>

<p>But when the compiler strips everything down to a flat binary, all the type information vanishes. The struct names are gone. The field names are gone. The only evidence that a 60-byte task control block ever existed is a pattern of memory accesses: <code class="language-plaintext highlighter-rouge">lw v0, 0(a0)</code> … <code class="language-plaintext highlighter-rouge">lw v1, 4(a0)</code> … <code class="language-plaintext highlighter-rouge">sw v0, 0x20(a0)</code>.</p>

<p>Recovering those structs from raw offsets is one of the hardest – and most rewarding – skills in firmware reverse engineering. Let’s work through it systematically.</p>

<blockquote>
  <p><strong>Follow along:</strong> Download the <a href="/assets/firmware-re/advanced-sample.tar.gz">sample firmware and source bundle</a>, then run <code class="language-plaintext highlighter-rouge">tar xf advanced-sample.tar.gz</code> and <code class="language-plaintext highlighter-rouge">cd more_complex_firmware</code>. Use <code class="language-plaintext highlighter-rouge">firmware.bin</code> as the analysis target; the companion source and ELF let you check the recovered layouts and instruction addresses afterward.</p>
</blockquote>

<hr />

<h2 id="the-problem-what-decompilation-leaves-unresolved">The Problem: What Decompilation Leaves Unresolved</h2>

<p>Decompilation lifts instructions into C-like code, but it cannot restore the stripped type and field names. Here is a simplified reconstruction of the scheduler in our binary, with anonymous names retained. It is an illustration, not a verbatim Ghidra capture:</p>

<figure class="highlight"><pre><code class="language-c" data-lang="c"><span class="kt">void</span> <span class="nf">FUN_80000c10</span><span class="p">(</span><span class="kt">void</span><span class="p">)</span>
<span class="p">{</span>
    <span class="kt">int</span> <span class="o">*</span><span class="n">piVar1</span> <span class="o">=</span> <span class="o">*</span><span class="p">(</span><span class="kt">int</span> <span class="o">**</span><span class="p">)</span><span class="mh">0x8008008c</span><span class="p">;</span>
    <span class="k">if</span> <span class="p">(</span><span class="n">piVar1</span> <span class="o">==</span> <span class="p">(</span><span class="kt">int</span> <span class="o">*</span><span class="p">)</span><span class="mh">0x8008008c</span><span class="p">)</span>
        <span class="k">return</span><span class="p">;</span>

    <span class="kt">int</span> <span class="o">*</span><span class="n">piVar2</span> <span class="o">=</span> <span class="p">(</span><span class="kt">int</span> <span class="o">*</span><span class="p">)((</span><span class="kt">char</span> <span class="o">*</span><span class="p">)</span><span class="n">piVar1</span> <span class="o">-</span> <span class="mh">0x20</span><span class="p">);</span>
    <span class="k">if</span> <span class="p">(</span><span class="n">piVar2</span> <span class="o">!=</span> <span class="o">*</span><span class="p">(</span><span class="kt">int</span> <span class="o">**</span><span class="p">)</span><span class="mh">0x80080098</span><span class="p">)</span> <span class="p">{</span>
        <span class="cm">/* Other state checks and branches omitted. */</span>
        <span class="o">*</span><span class="p">(</span><span class="kt">int</span> <span class="o">**</span><span class="p">)</span><span class="mh">0x80080098</span> <span class="o">=</span> <span class="n">piVar2</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="kt">char</span> <span class="o">*</span><span class="p">)</span><span class="n">piVar1</span> <span class="o">-</span> <span class="mh">0x1c</span><span class="p">)</span> <span class="o">=</span> <span class="mi">2</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="kt">char</span> <span class="o">*</span><span class="p">)</span><span class="n">piVar1</span> <span class="o">+</span> <span class="mh">0x14</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="kt">char</span> <span class="o">*</span><span class="p">)</span><span class="n">piVar1</span> <span class="o">+</span> <span class="mh">0x18</span><span class="p">);</span>
    <span class="p">}</span>
<span class="p">}</span></code></pre></figure>

<p>That’s the RTOS scheduler. Can you tell? Probably not. The function name and pointer names convey little, and every struct field access looks like <code class="language-plaintext highlighter-rouge">*(ptr + offset)</code>. The meaning is still opaque.</p>

<p>But the <em>offsets</em> are real. Each one corresponds to a field in a data structure that the original developer carefully designed. Our job is to collect those offsets and reconstruct the struct definition. Here’s how.</p>

<hr />

<h2 id="step-1-collect-offsets-from-one-function">Step 1: Collect Offsets From One Function</h2>

<p>Let’s start with the scheduler function at <code class="language-plaintext highlighter-rouge">0x80000C10</code>. From the disassembly:</p>

<figure class="highlight"><pre><code class="language-text" data-lang="text">80000c10:  lui   v1, 0x8008
80000c14:  lw    v0, 140(v1)        # load from global at 0x8008008C
80000c18:  addiu v1, v1, 140        # v1 = &amp;list_head (0x8008008C)
80000c1c:  beq   v0, v1, ...        # if (list.next == &amp;list) → empty
80000c20:  addiu a0, v0, -32        # a0 = list_entry - 0x20 → container!</code></pre></figure>

<p>Stop right there. That <code class="language-plaintext highlighter-rouge">addiu a0, v0, -32</code> is the most important instruction in this function. It’s a <strong><code class="language-plaintext highlighter-rouge">container_of</code> operation</strong> – subtracting <code class="language-plaintext highlighter-rouge">0x20</code> from a list node pointer to get back to the containing structure.</p>

<p>This tells us: some struct has a <code class="language-plaintext highlighter-rouge">list_node</code> field at offset <code class="language-plaintext highlighter-rouge">+0x20</code>. The list node was loaded from a global list head at <code class="language-plaintext highlighter-rouge">0x8008008C</code>, and the function subtracts 32 bytes to get a pointer to the beginning of the struct.</p>

<p>Continuing through the function, we collect more offsets:</p>

<figure class="highlight"><pre><code class="language-text" data-lang="text"># After container_of (a0 points to struct base; v0 still points to its list node)
80000c3c:  lw    a2, 4(v1)          # Read field at +0x04 of current_task
80000c40:  beq   a2, a1, ...        # Compare field +0x04 against 2
80000c54:  sw    v1, -28(v0)        # v0 = struct+0x20, so -28 = +0x04
                                     # Write 2 to field +0x04 (state = RUNNING)
80000c58:  lw    v1, 20(v0)         # v0 = struct+0x20, so 20 = +0x34</code></pre></figure>

<p>This needs careful accounting. The variable <code class="language-plaintext highlighter-rouge">v0</code> still points to the list node at offset <code class="language-plaintext highlighter-rouge">+0x20</code>, not the struct base. So:</p>
<ul>
  <li><code class="language-plaintext highlighter-rouge">sw v1, -28(v0)</code> → writes to <code class="language-plaintext highlighter-rouge">struct + 0x20 - 28</code> = <code class="language-plaintext highlighter-rouge">struct + 0x04</code></li>
  <li><code class="language-plaintext highlighter-rouge">lw v1, 20(v0)</code> → reads from <code class="language-plaintext highlighter-rouge">struct + 0x20 + 20</code> = <code class="language-plaintext highlighter-rouge">struct + 0x34</code></li>
  <li><code class="language-plaintext highlighter-rouge">lw v1, 24(v0)</code> → reads from <code class="language-plaintext highlighter-rouge">struct + 0x20 + 24</code> = <code class="language-plaintext highlighter-rouge">struct + 0x38</code></li>
  <li><code class="language-plaintext highlighter-rouge">lw v1, -20(v0)</code> → reads from <code class="language-plaintext highlighter-rouge">struct + 0x20 - 20</code> = <code class="language-plaintext highlighter-rouge">struct + 0x0C</code></li>
  <li><code class="language-plaintext highlighter-rouge">lw a0, -16(v0)</code> → reads from <code class="language-plaintext highlighter-rouge">struct + 0x20 - 16</code> = <code class="language-plaintext highlighter-rouge">struct + 0x10</code></li>
</ul>

<p>After one function, we have this partial struct map:</p>

<figure class="highlight"><pre><code class="language-text" data-lang="text">Offset  Access   Operation              Likely meaning
------  ------   ---------              --------------
+0x00   ?        (not accessed here)
+0x04   R/W      compared to 2, set to 1/2   state field (enum?)
+0x0C   R        loaded, then jalr'd    function pointer!
+0x10   R        loaded as arg to jalr  argument to function
+0x20   list_node container_of(-0x20)   linked list node
+0x34   R/W      incremented            counter
+0x38   R/W      incremented            counter</code></pre></figure>

<hr />

<h2 id="step-2-type-inference-from-operations">Step 2: Type Inference From Operations</h2>

<p>Each offset isn’t just a number – the operations performed on it tell you the field’s <em>type</em>:</p>

<table>
  <thead>
    <tr>
      <th>Operation</th>
      <th>Inference</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Compared against small constants (0, 1, 2, 3)</td>
      <td><strong>Enum / state field</strong></td>
    </tr>
    <tr>
      <td>AND with bitmask (<code class="language-plaintext highlighter-rouge">andi $v0, $v1, 0x01</code>)</td>
      <td><strong>Flags / bitfield</strong></td>
    </tr>
    <tr>
      <td>OR with bitmask (<code class="language-plaintext highlighter-rouge">ori $v0, $v1, 0x20</code>)</td>
      <td><strong>Setting a flag bit</strong></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">jalr</code> (jump to value)</td>
      <td><strong>Function pointer</strong></td>
    </tr>
    <tr>
      <td>Used as first arg to <code class="language-plaintext highlighter-rouge">jalr</code></td>
      <td><strong>Argument to callback</strong></td>
    </tr>
    <tr>
      <td>Incremented by 1</td>
      <td><strong>Counter</strong></td>
    </tr>
    <tr>
      <td>Subtracted to get struct base</td>
      <td><strong>List node</strong> (container_of)</td>
    </tr>
    <tr>
      <td>Loaded then used as pointer</td>
      <td><strong>Pointer to another struct</strong></td>
    </tr>
    <tr>
      <td>Compared against <code class="language-plaintext highlighter-rouge">lui/addiu</code> constructed address</td>
      <td><strong>Comparison to global</strong></td>
    </tr>
  </tbody>
</table>

<p>Applying these rules to our offsets:</p>

<ul>
  <li><strong>+0x04</strong>: compared to constants 1 and 2, set to 1 or 2 → <strong>state enum</strong> (1=READY, 2=RUNNING)</li>
  <li><strong>+0x0C</strong>: value loaded and used with <code class="language-plaintext highlighter-rouge">jalr</code> → <strong>function pointer</strong> (task entry point)</li>
  <li><strong>+0x10</strong>: loaded as argument to the jalr’d function → <strong>callback argument</strong> (void *arg)</li>
  <li><strong>+0x20</strong>: subtracted via container_of → <strong>list node</strong> (embedded in parent struct)</li>
  <li><strong>+0x34, +0x38</strong>: incremented → <strong>counters</strong> (run_count, total_ticks?)</li>
</ul>

<p>The counter at <code class="language-plaintext highlighter-rouge">+0x34</code> advances when the selected task changes. The one at <code class="language-plaintext highlighter-rouge">+0x38</code> advances whenever the scheduler selects that task, including repeated selections. These are scheduler counts; the accesses do not establish elapsed timer ticks.</p>

<hr />

<h2 id="step-3-cross-function-struct-building">Step 3: Cross-Function Struct Building</h2>

<p>One function rarely touches all fields. To complete the picture, we need to look at every function that operates on the same struct. The <code class="language-plaintext highlighter-rouge">rtos_task_create</code> function at <code class="language-plaintext highlighter-rouge">0x80000900</code> is a goldmine – it initializes all fields:</p>

<figure class="highlight"><pre><code class="language-text" data-lang="text"># After finding free slot, s0 = pointer to struct
80000988:  sw    v0, 0(s0)          # +0x00 = task_id
80000990:  sw    v0, 4(s0)          # +0x04 = state (set to 1 = READY)
80000994:  sw    a3, 8(s0)          # +0x08 = priority (from arg)
80000998:  sw    a1, 12(s0)         # +0x0C = entry function pointer
8000099c:  sw    a2, 16(s0)         # +0x10 = argument
800009a0:  sw    zero, 20(s0)       # +0x14 = 0 (purpose unknown here)
800009a8:  sw    v0, 24(s0)         # +0x18 = fifth argument (purpose unknown here)
800009b4:  sw    zero, 28(s0)       # +0x1C = 0 (timer value)
800009c0:  sw    v1, 32(s0)         # +0x20 = run_list.next (list init)
800009c4:  sw    v1, 36(s0)         # +0x24 = run_list.prev (list init)
800009d0:  sw    v0, 40(s0)         # +0x28 = timer_list.next (list init)
800009d4:  sw    v0, 44(s0)         # +0x2C = timer_list.prev (list init)
800009d8:  sw    a0, 48(s0)         # +0x30 = name (string pointer)
800009dc:  sw    zero, 52(s0)       # +0x34 = run_count (init to 0)
800009e0:  sw    zero, 56(s0)       # +0x38 = total_ticks (init to 0)</code></pre></figure>

<p>The timer-tick path gives us evidence for one more field. Here <code class="language-plaintext highlighter-rouge">a1</code> points to the task’s <code class="language-plaintext highlighter-rouge">timer_list</code> node at <code class="language-plaintext highlighter-rouge">+0x28</code>:</p>

<figure class="highlight"><pre><code class="language-text" data-lang="text">80000d34:  lw    v0, -12(a1)        # +0x28 - 12 = +0x1C
80000d3c:  addiu v0, v0, -1         # decrement timer value
80000d44:  sw    v0, -12(a1)        # write it back</code></pre></figure>

<p>That supports naming <code class="language-plaintext highlighter-rouge">+0x1C</code> <code class="language-plaintext highlighter-rouge">ticks_remaining</code>. We still cannot establish the meaning of <code class="language-plaintext highlighter-rouge">+0x14</code> or <code class="language-plaintext highlighter-rouge">+0x18</code> from these paths.</p>

<p>Now we can reconstruct the layout. The two list nodes each contain a <code class="language-plaintext highlighter-rouge">next</code> and <code class="language-plaintext highlighter-rouge">prev</code> pointer, so we can use this working type:</p>

<figure class="highlight"><pre><code class="language-c" data-lang="c"><span class="k">struct</span> <span class="n">list_node</span> <span class="p">{</span>
    <span class="k">struct</span> <span class="n">list_node</span> <span class="o">*</span><span class="n">next</span><span class="p">;</span>
    <span class="k">struct</span> <span class="n">list_node</span> <span class="o">*</span><span class="n">prev</span><span class="p">;</span>
<span class="p">};</span>

<span class="cm">/* Recovered layout; some field names remain tentative. */</span>
<span class="k">typedef</span> <span class="k">struct</span> <span class="p">{</span>
    <span class="kt">uint32_t</span>    <span class="n">task_id</span><span class="p">;</span>            <span class="cm">/* +0x00 */</span>
    <span class="kt">uint32_t</span>    <span class="n">state</span><span class="p">;</span>              <span class="cm">/* +0x04: 0=idle, 1=ready, 2=running, 3=blocked */</span>
    <span class="kt">uint32_t</span>    <span class="n">priority</span><span class="p">;</span>           <span class="cm">/* +0x08: lower = higher priority */</span>
    <span class="kt">void</span>       <span class="p">(</span><span class="o">*</span><span class="n">entry</span><span class="p">)(</span><span class="kt">void</span> <span class="o">*</span><span class="p">);</span>    <span class="cm">/* +0x0C: task entry function */</span>
    <span class="kt">void</span>       <span class="o">*</span><span class="n">arg</span><span class="p">;</span>                <span class="cm">/* +0x10: argument to entry */</span>
    <span class="kt">uint32_t</span>    <span class="n">field_14</span><span class="p">;</span>           <span class="cm">/* +0x14: initialized to 0 */</span>
    <span class="kt">uint32_t</span>    <span class="n">field_18</span><span class="p">;</span>           <span class="cm">/* +0x18: from fifth argument */</span>
    <span class="kt">uint32_t</span>    <span class="n">ticks_remaining</span><span class="p">;</span>    <span class="cm">/* +0x1C: decremented by timer tick */</span>
    <span class="k">struct</span> <span class="n">list_node</span> <span class="n">run_list</span><span class="p">;</span>      <span class="cm">/* +0x20: ready/blocked queue link */</span>
    <span class="k">struct</span> <span class="n">list_node</span> <span class="n">timer_list</span><span class="p">;</span>    <span class="cm">/* +0x28: timer queue link */</span>
    <span class="k">const</span> <span class="kt">char</span> <span class="o">*</span><span class="n">name</span><span class="p">;</span>               <span class="cm">/* +0x30: debug name string */</span>
    <span class="kt">uint32_t</span>    <span class="n">run_count</span><span class="p">;</span>          <span class="cm">/* +0x34: selections that change current task */</span>
    <span class="kt">uint32_t</span>    <span class="n">total_ticks</span><span class="p">;</span>        <span class="cm">/* +0x38: all selections, not elapsed ticks */</span>
<span class="p">}</span> <span class="n">task_t</span><span class="p">;</span>  <span class="cm">/* 60 bytes (0x3C) */</span></code></pre></figure>

<p>We just recovered a 60-byte task control block from raw memory offsets. No symbol table, debug info, or source code was needed for the layout.</p>

<hr />

<h2 id="step-4-the-intrusive-linked-list-pattern">Step 4: The Intrusive Linked List Pattern</h2>

<p>Notice something about the list initialization at <code class="language-plaintext highlighter-rouge">+0x20</code> and <code class="language-plaintext highlighter-rouge">+0x28</code>:</p>

<figure class="highlight"><pre><code class="language-text" data-lang="text"># Initializing run_list at +0x20:
800009c0:  sw    v1, 32(s0)         # run_list.next = &amp;self.run_list
800009c4:  sw    v1, 36(s0)         # run_list.prev = &amp;self.run_list

# Initializing timer_list at +0x28:
800009d0:  sw    v0, 40(s0)         # timer_list.next = &amp;self.timer_list
800009d4:  sw    v0, 44(s0)         # timer_list.prev = &amp;self.timer_list</code></pre></figure>

<p>Both list nodes are initialized to point to themselves. This is the signature of an <strong>intrusive doubly-linked list</strong> – the same pattern used by the Linux kernel’s <code class="language-plaintext highlighter-rouge">list_head</code>. An empty list node points to itself:</p>

<figure class="highlight"><pre><code class="language-c" data-lang="c"><span class="cm">/* List initialization: node-&gt;next = node-&gt;prev = node */</span>
<span class="kt">void</span> <span class="nf">list_init</span><span class="p">(</span><span class="k">struct</span> <span class="n">list_node</span> <span class="o">*</span><span class="n">node</span><span class="p">)</span> <span class="p">{</span>
    <span class="n">node</span><span class="o">-&gt;</span><span class="n">next</span> <span class="o">=</span> <span class="n">node</span><span class="p">;</span>
    <span class="n">node</span><span class="o">-&gt;</span><span class="n">prev</span> <span class="o">=</span> <span class="n">node</span><span class="p">;</span>
<span class="p">}</span></code></pre></figure>

<p>The key insight is that the <em>same struct</em> has <strong>two different list nodes</strong> at different offsets:</p>
<ul>
  <li><code class="language-plaintext highlighter-rouge">run_list</code> at <code class="language-plaintext highlighter-rouge">+0x20</code> threads the task into the ready queue or blocked queue</li>
  <li><code class="language-plaintext highlighter-rouge">timer_list</code> at <code class="language-plaintext highlighter-rouge">+0x28</code> threads the task into the timer expiry list</li>
</ul>

<p>This is what makes intrusive lists powerful – and what makes them an RE nightmare. The same task can be on two different lists simultaneously, and each list uses a different <code class="language-plaintext highlighter-rouge">container_of</code> offset to get back to the task struct.</p>

<p>When you see <code class="language-plaintext highlighter-rouge">addiu a0, v0, -0x20</code> in the scheduler, it’s doing <code class="language-plaintext highlighter-rouge">container_of(node, task_t, run_list)</code>. When you see <code class="language-plaintext highlighter-rouge">addiu a0, v0, -0x28</code> in the timer tick handler, it’s doing <code class="language-plaintext highlighter-rouge">container_of(node, task_t, timer_list)</code>. Different offset, same struct.</p>

<hr />

<h2 id="step-5-another-struct--the-mutex">Step 5: Another Struct – The Mutex</h2>

<p>The mutex lock function at <code class="language-plaintext highlighter-rouge">0x80000DC8</code> gives us a second struct to reconstruct:</p>

<figure class="highlight"><pre><code class="language-text" data-lang="text">80000dd0:  lw    v0, 0(a0)          # +0x00: locked flag
80000de0:  lw    v0, 152(v0)        # load g_current_task
80000de4:  lw    v1, 4(a0)          # +0x04: owner pointer
80000de8:  bne   v1, v0, ...        # if (owner != current_task)
80000df0:  lw    v0, 8(a0)          # +0x08: nest_count
80000df4:  addiu v0, v0, 1          # nest_count++
80000df8:  sw    v0, 8(a0)          # +0x08: write back
  ...
80000e44:  sw    a1, 16(a0)         # +0x10: wait_list.prev = new_waiter
80000e48:  addiu a0, a0, 12        # +0x0C: &amp;wait_list (list node offset)</code></pre></figure>

<p>The <code class="language-plaintext highlighter-rouge">addiu a0, a0, 12</code> at <code class="language-plaintext highlighter-rouge">0x80000E48</code> tells us the mutex has a <code class="language-plaintext highlighter-rouge">list_node</code> at offset <code class="language-plaintext highlighter-rouge">+0x0C</code>. Collecting all the offsets:</p>

<figure class="highlight"><pre><code class="language-c" data-lang="c"><span class="k">typedef</span> <span class="k">struct</span> <span class="p">{</span>
    <span class="kt">uint32_t</span>    <span class="n">locked</span><span class="p">;</span>             <span class="cm">/* +0x00: 0=free, 1=locked */</span>
    <span class="kt">void</span>       <span class="o">*</span><span class="n">owner</span><span class="p">;</span>              <span class="cm">/* +0x04: owning task pointer */</span>
    <span class="kt">uint32_t</span>    <span class="n">nest_count</span><span class="p">;</span>         <span class="cm">/* +0x08: recursive lock depth */</span>
    <span class="k">struct</span> <span class="n">list_node</span> <span class="n">wait_list</span><span class="p">;</span>     <span class="cm">/* +0x0C: blocked tasks waiting */</span>
    <span class="kt">uint32_t</span>    <span class="n">field_14</span><span class="p">;</span>           <span class="cm">/* +0x14: purpose not shown here */</span>
<span class="p">}</span> <span class="n">mutex_t</span><span class="p">;</span>  <span class="cm">/* 24 bytes */</span></code></pre></figure>

<p>Look at the relationship: the mutex’s <code class="language-plaintext highlighter-rouge">wait_list</code> at <code class="language-plaintext highlighter-rouge">+0x0C</code> links to the <em>task’s</em> <code class="language-plaintext highlighter-rouge">run_list</code> at <code class="language-plaintext highlighter-rouge">+0x20</code>. When a task blocks on a mutex, its <code class="language-plaintext highlighter-rouge">run_list</code> node is moved from the ready queue to the mutex’s wait list. The <code class="language-plaintext highlighter-rouge">container_of</code> offset is still <code class="language-plaintext highlighter-rouge">-0x20</code> because the list node in the task struct is always at <code class="language-plaintext highlighter-rouge">+0x20</code>, regardless of which list it’s on.</p>

<hr />

<h2 id="step-6-packed-bitfields--the-rule-engine">Step 6: Packed Bitfields – The Rule Engine</h2>

<p>Not all struct fields are simple integers. The rule engine uses <strong>packed bitfields</strong> – multiple values squeezed into a single 32-bit word. Look at this pattern from the rule commit function at <code class="language-plaintext highlighter-rouge">0x8000330C</code>:</p>

<figure class="highlight"><pre><code class="language-text" data-lang="text"># Load field +0x10 of rule entry
80003388:  lw    v1, 16(v0)
# Mask off upper byte: v1 &amp; 0xFF00
8000338c:  andi  v1, v1, 0xFF00
# Compare against 0x0200
80003390:  li    a0, 512
80003394:  bne   v1, a0, error</code></pre></figure>

<p>The code loads a 32-bit word from offset <code class="language-plaintext highlighter-rouge">+0x10</code>, masks it with <code class="language-plaintext highlighter-rouge">0xFF00</code>, and compares the result to <code class="language-plaintext highlighter-rouge">0x0200</code>. This means:</p>
<ul>
  <li>Bits <code class="language-plaintext highlighter-rouge">[15:8]</code> of the word at <code class="language-plaintext highlighter-rouge">+0x10</code> encode a <strong>state</strong> field</li>
  <li>State <code class="language-plaintext highlighter-rouge">0x02</code> means “PROGRAMMED” (the required state before committing)</li>
</ul>

<p>Later in the same function:</p>

<figure class="highlight"><pre><code class="language-text" data-lang="text"># Clear bits [15:8] and set new state 0x03
800033f8:  ins   v0, zero, 0x8, 0x8   # Clear bits 15:8
800033fc:  ori   v0, v0, 0x300         # Set bits 15:8 = 0x03
80003400:  sw    v0, 16(v1)            # Write back</code></pre></figure>

<p>The MIPS <code class="language-plaintext highlighter-rouge">ins</code> (insert) instruction zeroes bits <code class="language-plaintext highlighter-rouge">[15:8]</code>, then <code class="language-plaintext highlighter-rouge">ori</code> sets them to <code class="language-plaintext highlighter-rouge">0x03</code> (COMMITTED state). This is a classic <strong>read-modify-write</strong> on a packed bitfield.</p>

<p>From multiple functions, we can reconstruct the full packing:</p>

<figure class="highlight"><pre><code class="language-text" data-lang="text">Rule flags word at +0x10:
  Bits [2:0]   = partition index (0-7)    mask 0x0007
  Bits [7:3]   = priority (0-31)          mask 0x00F8
  Bits [15:8]  = state                    mask 0xFF00
                  0x00 = FREE
                  0x01 = ALLOCATED
                  0x02 = PROGRAMMED
                  0x03 = COMMITTED</code></pre></figure>

<p>Which gives us this rule entry struct:</p>

<figure class="highlight"><pre><code class="language-c" data-lang="c"><span class="k">typedef</span> <span class="k">struct</span> <span class="p">{</span>
    <span class="kt">uint32_t</span>    <span class="n">type</span><span class="p">;</span>               <span class="cm">/* +0x00: AR=0, ID=1, AT=2 */</span>
    <span class="kt">uint32_t</span>    <span class="n">match_lo</span><span class="p">;</span>           <span class="cm">/* +0x04: match range low */</span>
    <span class="kt">uint32_t</span>    <span class="n">match_hi</span><span class="p">;</span>           <span class="cm">/* +0x08: match range high */</span>
    <span class="kt">uint32_t</span>    <span class="n">action</span><span class="p">;</span>             <span class="cm">/* +0x0C: forwarding action */</span>
    <span class="kt">uint32_t</span>    <span class="n">flags</span><span class="p">;</span>              <span class="cm">/* +0x10: packed [2:0]=part, [7:3]=pri, [15:8]=state */</span>
    <span class="kt">uint32_t</span>    <span class="n">reserved</span><span class="p">;</span>           <span class="cm">/* +0x14 */</span>
<span class="p">}</span> <span class="n">rule_entry_t</span><span class="p">;</span>  <span class="cm">/* 24 bytes (0x18) */</span></code></pre></figure>

<hr />

<h2 id="the-method-a-systematic-approach">The Method: A Systematic Approach</h2>

<p>Let me distill this into a repeatable method:</p>

<h3 id="1-start-with-one-function">1. Start with one function</h3>
<p>Pick a function that accesses many fields – initializers and dump/debug functions are ideal because they touch every field sequentially.</p>

<h3 id="2-collect-every-offset">2. Collect every offset</h3>
<p>For each <code class="language-plaintext highlighter-rouge">lw</code>/<code class="language-plaintext highlighter-rouge">sw</code> instruction, record the base register and offset. Track which register points to the struct base vs. an interior pointer (like a list node). Watch out for compiler tricks like using <code class="language-plaintext highlighter-rouge">v0 = struct + 0x20</code> and then accessing fields relative to that interior pointer.</p>

<h3 id="3-classify-each-field-by-operation">3. Classify each field by operation</h3>

<table>
  <thead>
    <tr>
      <th>Pattern</th>
      <th>Likely type</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Compared to small constants</td>
      <td>Enum / state</td>
    </tr>
    <tr>
      <td>AND/OR with bitmasks</td>
      <td>Flags / bitfield</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">jalr</code> target</td>
      <td>Function pointer</td>
    </tr>
    <tr>
      <td>Loaded then used as address</td>
      <td>Pointer to struct</td>
    </tr>
    <tr>
      <td>Self-referential init (next=prev=self)</td>
      <td>List node</td>
    </tr>
    <tr>
      <td>Incremented / decremented</td>
      <td>Counter</td>
    </tr>
    <tr>
      <td>Loaded and passed to <code class="language-plaintext highlighter-rouge">uart_puts</code></td>
      <td>String pointer</td>
    </tr>
    <tr>
      <td>Loaded and passed to other function</td>
      <td>Usually pointer or ID</td>
    </tr>
  </tbody>
</table>

<h3 id="4-cross-reference-across-functions">4. Cross-reference across functions</h3>
<p>Find every function that accesses the same global array or passes the same struct pointer. Each function reveals different fields. The initializer shows the full layout; the scheduler shows the state transitions; the debug dump shows the display names.</p>

<h3 id="5-look-for-embedded-list-nodes">5. Look for embedded list nodes</h3>
<p>The <code class="language-plaintext highlighter-rouge">container_of</code> subtraction (<code class="language-plaintext highlighter-rouge">addiu reg, reg, -N</code>) is the telltale sign. The offset <code class="language-plaintext highlighter-rouge">N</code> tells you where the list node sits in the containing struct. If you see two different subtraction values for the same struct, it has two list nodes.</p>

<h3 id="6-verify-struct-size">6. Verify struct size</h3>
<p>Look for array access patterns. If the code does <code class="language-plaintext highlighter-rouge">ptr + index * 60</code>, the struct is 60 bytes. Multiplication by a non-power-of-2 constant is often visible as a shift-add sequence:</p>

<figure class="highlight"><pre><code class="language-text" data-lang="text"># Multiply index by 60 (= 4 * 15 = 4 * (16-1))
sll   v0, a0, 4        # v0 = index * 16
subu  v0, v0, a0       # v0 = index * 15
sll   v0, v0, 2        # v0 = index * 60</code></pre></figure>

<p>Or the compiler might use the <code class="language-plaintext highlighter-rouge">mul</code> instruction directly if available:</p>

<figure class="highlight"><pre><code class="language-text" data-lang="text">li    v0, 60
mul   v0, a0, v0       # v0 = index * 60</code></pre></figure>

<p>Either way, the struct size is the multiplication constant.</p>

<hr />

<h2 id="recovered-structs-summary">Recovered Structs: Summary</h2>

<p>After analyzing all the RTOS, rule engine, and driver functions in our firmware, here’s what we recover:</p>

<figure class="highlight"><pre><code class="language-c" data-lang="c"><span class="cm">/* 60 bytes - RTOS task control block */</span>
<span class="k">typedef</span> <span class="k">struct</span> <span class="p">{</span>
    <span class="kt">uint32_t</span>    <span class="n">task_id</span><span class="p">;</span>            <span class="cm">/* +0x00 */</span>
    <span class="kt">uint32_t</span>    <span class="n">state</span><span class="p">;</span>              <span class="cm">/* +0x04 */</span>
    <span class="kt">uint32_t</span>    <span class="n">priority</span><span class="p">;</span>           <span class="cm">/* +0x08 */</span>
    <span class="kt">void</span>       <span class="p">(</span><span class="o">*</span><span class="n">entry</span><span class="p">)(</span><span class="kt">void</span><span class="o">*</span><span class="p">);</span>     <span class="cm">/* +0x0C */</span>
    <span class="kt">void</span>       <span class="o">*</span><span class="n">arg</span><span class="p">;</span>                <span class="cm">/* +0x10 */</span>
    <span class="kt">uint32_t</span>    <span class="n">field_14</span><span class="p">;</span>           <span class="cm">/* +0x14: meaning not established */</span>
    <span class="kt">uint32_t</span>    <span class="n">field_18</span><span class="p">;</span>           <span class="cm">/* +0x18: receives fifth argument */</span>
    <span class="kt">uint32_t</span>    <span class="n">ticks_remaining</span><span class="p">;</span>    <span class="cm">/* +0x1C */</span>
    <span class="k">struct</span> <span class="n">list_node</span> <span class="n">run_list</span><span class="p">;</span>      <span class="cm">/* +0x20 */</span>
    <span class="k">struct</span> <span class="n">list_node</span> <span class="n">timer_list</span><span class="p">;</span>    <span class="cm">/* +0x28 */</span>
    <span class="k">const</span> <span class="kt">char</span> <span class="o">*</span><span class="n">name</span><span class="p">;</span>               <span class="cm">/* +0x30 */</span>
    <span class="kt">uint32_t</span>    <span class="n">run_count</span><span class="p">;</span>          <span class="cm">/* +0x34 */</span>
    <span class="kt">uint32_t</span>    <span class="n">total_ticks</span><span class="p">;</span>        <span class="cm">/* +0x38 */</span>
<span class="p">}</span> <span class="n">task_t</span><span class="p">;</span>

<span class="cm">/* 24 bytes - Mutex with ownership */</span>
<span class="k">typedef</span> <span class="k">struct</span> <span class="p">{</span>
    <span class="kt">uint32_t</span>    <span class="n">locked</span><span class="p">;</span>             <span class="cm">/* +0x00 */</span>
    <span class="n">task_t</span>     <span class="o">*</span><span class="n">owner</span><span class="p">;</span>              <span class="cm">/* +0x04 */</span>
    <span class="kt">uint32_t</span>    <span class="n">nest_count</span><span class="p">;</span>         <span class="cm">/* +0x08 */</span>
    <span class="k">struct</span> <span class="n">list_node</span> <span class="n">wait_list</span><span class="p">;</span>     <span class="cm">/* +0x0C */</span>
    <span class="kt">uint32_t</span>    <span class="n">field_14</span><span class="p">;</span>           <span class="cm">/* +0x14: meaning not established */</span>
<span class="p">}</span> <span class="n">mutex_t</span><span class="p">;</span>

<span class="cm">/* 24 bytes - Rule entry with packed flags */</span>
<span class="k">typedef</span> <span class="k">struct</span> <span class="p">{</span>
    <span class="kt">uint32_t</span>    <span class="n">type</span><span class="p">;</span>               <span class="cm">/* +0x00 */</span>
    <span class="kt">uint32_t</span>    <span class="n">match_lo</span><span class="p">;</span>           <span class="cm">/* +0x04 */</span>
    <span class="kt">uint32_t</span>    <span class="n">match_hi</span><span class="p">;</span>           <span class="cm">/* +0x08 */</span>
    <span class="kt">uint32_t</span>    <span class="n">action</span><span class="p">;</span>             <span class="cm">/* +0x0C */</span>
    <span class="kt">uint32_t</span>    <span class="n">flags</span><span class="p">;</span>              <span class="cm">/* +0x10: packed bitfield */</span>
    <span class="kt">uint32_t</span>    <span class="n">reserved</span><span class="p">;</span>           <span class="cm">/* +0x14 */</span>
<span class="p">}</span> <span class="n">rule_entry_t</span><span class="p">;</span></code></pre></figure>

<p>The layouts follow from memory accesses, pointer arithmetic, and how values are used across functions. Field names remain hypotheses until callers, strings, or observed behavior support them.</p>

<hr />

<h2 id="exercises">Exercises</h2>

<ol>
  <li>
    <p><strong>Find the semaphore struct</strong>: The firmware has <code class="language-plaintext highlighter-rouge">rtos_sem_wait</code> at <code class="language-plaintext highlighter-rouge">0x80000F98</code> and <code class="language-plaintext highlighter-rouge">rtos_sem_post</code> at <code class="language-plaintext highlighter-rouge">0x80001034</code>. Disassemble these functions and reconstruct the semaphore struct. (Hint: it’s similar to mutex_t but with a count field instead of an owner.)</p>
  </li>
  <li>
    <p><strong>Recover port_config_t</strong>: The port initialization at <code class="language-plaintext highlighter-rouge">0x80002830</code> sets up an array of 8 port structures with 48-byte stride (see the <code class="language-plaintext highlighter-rouge">addiu v0, v0, 48</code> loop increment). Map all the fields.</p>
  </li>
  <li>
    <p><strong>Map the route entry</strong>: The routing module uses structs with a list node at <code class="language-plaintext highlighter-rouge">+0x14</code>. Find the <code class="language-plaintext highlighter-rouge">container_of</code> subtraction that reveals this offset and reconstruct the full routing entry.</p>
  </li>
  <li>
    <p><strong>Automate offset collection</strong>: Write a Python script using Capstone to disassemble a function and collect its <code class="language-plaintext highlighter-rouge">lw</code>/<code class="language-plaintext highlighter-rouge">sw</code> offsets. Track the struct pointer through copies from <code class="language-plaintext highlighter-rouge">$a0</code>-<code class="language-plaintext highlighter-rouge">$a3</code> into other registers and through interior pointers such as <code class="language-plaintext highlighter-rouge">struct + 0x20</code>; normalize each access back to the struct base. Report an offset as unknown when register provenance is lost.</p>
  </li>
</ol>

<hr />

<h2 id="whats-next">What’s Next</h2>

<p>We’ve been doing all of this manually – reading disassembly, tracking registers, collecting offsets. It works, but it’s slow. For a 38 KB firmware, manual analysis is feasible. For a 1.5 MB production firmware with 4,000+ functions, you need better tools.</p>

<p>In the next post, we will use <strong>radare2</strong> to query table users and callers, save recovered names, and inspect its function boundaries. We will compare its output with Ghidra and Capstone on the same binary, including the functions and types that automatic analysis gets wrong.</p>

<hr />

<p><em>This is Part 2 of “Crypto Tables, Struct Ghosts, and Other Secrets Hidden in Firmware.”</em></p>

<p><em>Next: <a href="/reverse-engineering/firmware/tools/2026/10/17/radare2-ghidra-and-other-ways-to-tear-apart-a-binary.html">Part 3 — Radare2, Ghidra, and Other Ways to Tear Apart a Binary</a>.</em></p>]]></content><author><name>Maciej Grochowski</name></author><category term="reverse-engineering" /><category term="firmware" /><category term="structs" /><summary type="html"><![CDATA[In Part 1, we identified AES, SHA-256, and CRC-32 from constant tables in .rodata. CRC-16 had no table, so we inspected its code to identify the algorithm and its parameters.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://page-fault.io/assets/social-preview.png" /><media:content medium="image" url="https://page-fault.io/assets/social-preview.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">S-Boxes, Hash Constants, and Other Cryptographic Fingerprints</title><link href="https://page-fault.io/reverse-engineering/firmware/crypto/2026/10/08/sboxes-hash-constants-and-other-cryptographic-fingerprints.html" rel="alternate" type="text/html" title="S-Boxes, Hash Constants, and Other Cryptographic Fingerprints" /><published>2026-10-08T12:00:00+00:00</published><updated>2026-10-08T12:00:00+00:00</updated><id>https://page-fault.io/reverse-engineering/firmware/crypto/2026/10/08/sboxes-hash-constants-and-other-cryptographic-fingerprints</id><content type="html" xml:base="https://page-fault.io/reverse-engineering/firmware/crypto/2026/10/08/sboxes-hash-constants-and-other-cryptographic-fingerprints.html"><![CDATA[<p>The <a href="/reverse-engineering/firmware/mips/2026/07/04/versions-callgraphs-and-other-ways-to-compare-what-changed.html">first series</a> ended with an 8 KB MIPS32 firmware and a set of ways to read it. This time we have a 38,616-byte flat binary. Before following its code, we need to answer a narrower question: which algorithms are in it?</p>

<p>The new sample contains AES-128, SHA-256, two CRC routines, pool allocation, rule handling, memory-mapped I/O, and task scheduler code. We will examine the runtime pieces later. First, we can use the bytes in its constant tables to find the algorithm implementations.</p>

<blockquote>
  <p><strong>Follow along:</strong> Download the <a href="/assets/firmware-re/advanced-sample.tar.gz">sample firmware and source bundle</a>, then run <code class="language-plaintext highlighter-rouge">tar xf advanced-sample.tar.gz</code> and <code class="language-plaintext highlighter-rouge">cd more_complex_firmware</code>. The bundle includes <code class="language-plaintext highlighter-rouge">firmware.bin</code> and the <code class="language-plaintext highlighter-rouge">tools/entropy_scan.py</code> script used below. The binary is the artifact we analyze; the source is there to check our conclusions afterward.</p>
</blockquote>

<hr />

<h2 id="tables-as-search-clues">Tables as Search Clues</h2>

<p>Some algorithm implementations leave recognizable tables in the binary. This AES implementation stores a 256-byte S-box, SHA-256 stores 64 round constants, and the CRC-32 routine uses a 1024-byte lookup table. Those bytes remain visible after the linker removes symbols and section headers from the distributed image.</p>

<p>A complete table match gives us a strong lead. We still need code references to establish which functions use it, and a matching table alone does not tell us every parameter of a checksum. Other implementations may calculate the same values at runtime or use hardware instead of storing a table.</p>

<hr />

<h2 id="step-1-scan-entropy">Step 1: Scan Entropy</h2>

<p>Before searching for a known pattern, we can rank parts of the binary by Shannon entropy. Repeated padding has low entropy; tables, compressed data, and encrypted data can be high. Code and strings vary enough that fixed ranges would be misleading. Entropy gives us places to inspect, not labels for those places.</p>

<p>A complete AES S-box contains every byte value exactly once, so its entropy is <strong>8.0 bits per byte</strong>. A 256-byte window that crosses a table boundary measures a mixture and gives a different result.</p>

<p>The bundled <code class="language-plaintext highlighter-rouge">tools/entropy_scan.py</code> computes entropy over non-overlapping 256-byte windows:</p>

<figure class="highlight"><pre><code class="language-python" data-lang="python"><span class="kn">import</span> <span class="n">math</span>
<span class="kn">import</span> <span class="n">sys</span>

<span class="k">def</span> <span class="nf">entropy</span><span class="p">(</span><span class="n">data</span><span class="p">):</span>
    <span class="sh">"""</span><span class="s">Shannon entropy in bits per byte.</span><span class="sh">"""</span>
    <span class="n">freq</span> <span class="o">=</span> <span class="p">[</span><span class="mi">0</span><span class="p">]</span> <span class="o">*</span> <span class="mi">256</span>
    <span class="k">for</span> <span class="n">b</span> <span class="ow">in</span> <span class="n">data</span><span class="p">:</span>
        <span class="n">freq</span><span class="p">[</span><span class="n">b</span><span class="p">]</span> <span class="o">+=</span> <span class="mi">1</span>
    <span class="n">ent</span> <span class="o">=</span> <span class="mf">0.0</span>
    <span class="k">for</span> <span class="n">f</span> <span class="ow">in</span> <span class="n">freq</span><span class="p">:</span>
        <span class="k">if</span> <span class="n">f</span> <span class="o">&gt;</span> <span class="mi">0</span><span class="p">:</span>
            <span class="n">p</span> <span class="o">=</span> <span class="n">f</span> <span class="o">/</span> <span class="nf">len</span><span class="p">(</span><span class="n">data</span><span class="p">)</span>
            <span class="n">ent</span> <span class="o">-=</span> <span class="n">p</span> <span class="o">*</span> <span class="n">math</span><span class="p">.</span><span class="nf">log2</span><span class="p">(</span><span class="n">p</span><span class="p">)</span>
    <span class="k">return</span> <span class="n">ent</span>

<span class="n">data</span> <span class="o">=</span> <span class="nf">open</span><span class="p">(</span><span class="n">sys</span><span class="p">.</span><span class="n">argv</span><span class="p">[</span><span class="mi">1</span><span class="p">],</span> <span class="sh">'</span><span class="s">rb</span><span class="sh">'</span><span class="p">).</span><span class="nf">read</span><span class="p">()</span>
<span class="n">window</span> <span class="o">=</span> <span class="mi">256</span>

<span class="n">results</span> <span class="o">=</span> <span class="p">[]</span>
<span class="k">for</span> <span class="n">offset</span> <span class="ow">in</span> <span class="nf">range</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="nf">len</span><span class="p">(</span><span class="n">data</span><span class="p">)</span> <span class="o">-</span> <span class="n">window</span> <span class="o">+</span> <span class="mi">1</span><span class="p">,</span> <span class="n">window</span><span class="p">):</span>
    <span class="n">chunk</span> <span class="o">=</span> <span class="n">data</span><span class="p">[</span><span class="n">offset</span><span class="p">:</span><span class="n">offset</span> <span class="o">+</span> <span class="n">window</span><span class="p">]</span>
    <span class="n">results</span><span class="p">.</span><span class="nf">append</span><span class="p">((</span><span class="n">offset</span><span class="p">,</span> <span class="nf">entropy</span><span class="p">(</span><span class="n">chunk</span><span class="p">)))</span>

<span class="n">results</span><span class="p">.</span><span class="nf">sort</span><span class="p">(</span><span class="n">key</span><span class="o">=</span><span class="k">lambda</span> <span class="n">x</span><span class="p">:</span> <span class="o">-</span><span class="n">x</span><span class="p">[</span><span class="mi">1</span><span class="p">])</span>

<span class="nf">print</span><span class="p">(</span><span class="sa">f</span><span class="sh">"</span><span class="si">{</span><span class="sh">'</span><span class="s">Offset</span><span class="sh">'</span><span class="si">:</span><span class="o">&gt;</span><span class="mi">10</span><span class="n">s</span><span class="si">}</span><span class="s">  </span><span class="si">{</span><span class="sh">'</span><span class="s">Address</span><span class="sh">'</span><span class="si">:</span><span class="o">&gt;</span><span class="mi">12</span><span class="n">s</span><span class="si">}</span><span class="s">  </span><span class="si">{</span><span class="sh">'</span><span class="s">Entropy</span><span class="sh">'</span><span class="si">:</span><span class="o">&gt;</span><span class="mi">8</span><span class="n">s</span><span class="si">}</span><span class="sh">"</span><span class="p">)</span>
<span class="nf">print</span><span class="p">(</span><span class="sh">"</span><span class="s">-</span><span class="sh">"</span> <span class="o">*</span> <span class="mi">36</span><span class="p">)</span>
<span class="k">for</span> <span class="n">offset</span><span class="p">,</span> <span class="n">ent</span> <span class="ow">in</span> <span class="n">results</span><span class="p">[:</span><span class="mi">15</span><span class="p">]:</span>
    <span class="n">addr</span> <span class="o">=</span> <span class="mh">0x80000000</span> <span class="o">+</span> <span class="n">offset</span>
    <span class="nf">print</span><span class="p">(</span><span class="sa">f</span><span class="sh">"</span><span class="s">  0x</span><span class="si">{</span><span class="n">offset</span><span class="si">:</span><span class="mi">06</span><span class="n">X</span><span class="si">}</span><span class="s">    0x</span><span class="si">{</span><span class="n">addr</span><span class="si">:</span><span class="mi">08</span><span class="n">X</span><span class="si">}</span><span class="s">    </span><span class="si">{</span><span class="n">ent</span><span class="si">:</span><span class="p">.</span><span class="mi">4</span><span class="n">f</span><span class="si">}</span><span class="sh">"</span><span class="p">)</span></code></pre></figure>

<p>Running this on our 38 KB firmware:</p>

<figure class="highlight"><pre><code class="language-text" data-lang="text">$ python3 tools/entropy_scan.py firmware.bin

    Offset       Address   Entropy
------------------------------------
  0x007F00    0x80007F00    7.4609
  0x008000    0x80008000    7.3113
  0x007C00    0x80007C00    7.2820
  0x007B00    0x80007B00    7.1560
  0x007A00    0x80007A00    6.3438
  0x008100    0x80008100    6.1394
  0x008E00    0x80008E00    5.6130
  0x006E00    0x80006E00    5.5393
  0x007D00    0x80007D00    5.4905
  0x001800    0x80001800    5.3587
  0x004200    0x80004200    5.3427
  0x008C00    0x80008C00    5.3393
  0x002200    0x80002200    5.3293
  0x001600    0x80001600    5.3251
  0x003300    0x80003300    5.2704</code></pre></figure>

<p>The four highest windows fall between <code class="language-plaintext highlighter-rouge">0x7B00</code> and <code class="language-plaintext highlighter-rouge">0x80FF</code>. That is a useful place to look for tables. The S-box begins at <code class="language-plaintext highlighter-rouge">0x7F7C</code>, so the window starting at <code class="language-plaintext highlighter-rouge">0x7F00</code> includes other data; its measured entropy is <code class="language-plaintext highlighter-rouge">7.4609</code>, even though the aligned 256-byte S-box reaches <code class="language-plaintext highlighter-rouge">8.0</code>.</p>

<p>Start with the best-known pattern in that range.</p>

<hr />

<h2 id="step-2-match-the-aes-s-box">Step 2: Match the AES S-Box</h2>

<p>The Rijndael S-box begins with four recognizable bytes:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>63 7C 77 7B
</code></pre></div></div>

<p>These are the substitution values for inputs <code class="language-plaintext highlighter-rouge">0x00</code> through <code class="language-plaintext highlighter-rouge">0x03</code>. They give us a search pattern, not proof on their own. We will check the whole table after finding a candidate.</p>

<p>Let’s search:</p>

<figure class="highlight"><pre><code class="language-bash" data-lang="bash"><span class="nv">$ </span>xxd firmware.bin | <span class="nb">grep</span> <span class="s2">"637c 777b"</span>
00007f70: ba77 d626 e169 1463 5521 0c7d 637c 777b  .w.&amp;.i.cU!.<span class="o">}</span>c|w<span class="o">{</span></code></pre></figure>

<p>One hit at offset <code class="language-plaintext highlighter-rouge">0x7F7C</code>. Let’s look at the full 256-byte table:</p>

<figure class="highlight"><pre><code class="language-bash" data-lang="bash"><span class="nv">$ </span>xxd <span class="nt">-s</span> 0x7F7C <span class="nt">-l</span> 256 firmware.bin
00007f7c: 637c 777b f26b 6fc5 3001 672b fed7 ab76  c|w<span class="o">{</span>.ko.0.g+...v
00007f8c: ca82 c97d fa59 47f0 add4 a2af 9ca4 72c0  ...<span class="o">}</span>.YG.......r.
00007f9c: b7fd 9326 363f f7cc 34a5 e5f1 71d8 3115  ...&amp;6?..4...q.1.
00007fac: 04c7 23c3 1896 059a 0712 80e2 eb27 b275  ..#..........<span class="s1">'.u
00007fbc: 0983 2c1a 1b6e 5aa0 523b d6b3 29e3 2f84  ..,..nZ.R;..)./.
00007fcc: 53d1 00ed 20fc b15b 6acb be39 4a4c 58cf  S... ..[j..9JLX.
00007fdc: d0ef aafb 434d 3385 45f9 027f 503c 9fa8  ....CM3.E...P&lt;..
00007fec: 51a3 408f 929d 38f5 bcb6 da21 10ff f3d2  Q.@...8....!....
00007ffc: cd0c 13ec 5f97 4417 c4a7 7e3d 645d 1973  ...._.D...~=d].s
0000800c: 6081 4fdc 222a 9088 46ee b814 de5e 0bdb  `.O."*..F....^..
0000801c: e032 3a0a 4906 245c c2d3 ac62 9195 e479  .2:.I.$\...b...y
0000802c: e7c8 376d 8dd5 4ea9 6c56 f4ea 657a ae08  ..7m..N.lV..ez..
0000803c: ba78 252e 1ca6 b4c6 e8dd 741f 4bbd 8b8a  .x%.......t.K...
0000804c: 703e b566 4803 f60e 6135 57b9 86c1 1d9e  p&gt;.fH...a5W.....
0000805c: e1f8 9811 69d9 8e94 9b1e 87e9 ce55 28df  ....i........U(.
0000806c: 8ca1 890d bfe6 4268 4199 2d0f b054 bb16  ......BhA.-..T..</span></code></pre></figure>

<p>The 256 bytes starting at firmware address <code class="language-plaintext highlighter-rouge">0x80007F7C</code> match the Rijndael S-box. The first row is <code class="language-plaintext highlighter-rouge">63 7C 77 7B F2 6B 6F C5 30 01 67 2B FE D7 AB 76</code>, as specified in <a href="https://nvlpubs.nist.gov/nistpubs/FIPS/NIST.FIPS.197-upd1.pdf">FIPS 197</a>. Checking all 256 bytes matters more than finding the four-byte prefix.</p>

<p>This implementation also stores an <strong>inverse S-box</strong>, which can be used for decryption. Its first four bytes are <code class="language-plaintext highlighter-rouge">52 09 6A D5</code>:</p>

<figure class="highlight"><pre><code class="language-bash" data-lang="bash"><span class="nv">$ </span>xxd firmware.bin | <span class="nb">grep</span> <span class="s2">"5209 6ad5"</span>
00007e7c: 5209 6ad5 3036 a538 bf40 a39e 81f3 d7fb  R.j.06.8.@......</code></pre></figure>

<p>It starts at <code class="language-plaintext highlighter-rouge">0x7E7C</code>, exactly 256 bytes before the forward S-box. This build placed the tables back-to-back.</p>

<p>We can check the relationship without trusting either four-byte prefix:</p>

<figure class="highlight"><pre><code class="language-python" data-lang="python"><span class="n">data</span> <span class="o">=</span> <span class="nf">open</span><span class="p">(</span><span class="sh">'</span><span class="s">firmware.bin</span><span class="sh">'</span><span class="p">,</span> <span class="sh">'</span><span class="s">rb</span><span class="sh">'</span><span class="p">).</span><span class="nf">read</span><span class="p">()</span>
<span class="n">inverse</span> <span class="o">=</span> <span class="n">data</span><span class="p">[</span><span class="mh">0x7E7C</span><span class="p">:</span><span class="mh">0x7F7C</span><span class="p">]</span>
<span class="n">forward</span> <span class="o">=</span> <span class="n">data</span><span class="p">[</span><span class="mh">0x7F7C</span><span class="p">:</span><span class="mh">0x807C</span><span class="p">]</span>
<span class="k">assert</span> <span class="nf">len</span><span class="p">(</span><span class="nf">set</span><span class="p">(</span><span class="n">forward</span><span class="p">))</span> <span class="o">==</span> <span class="mi">256</span>
<span class="k">assert</span> <span class="nf">all</span><span class="p">(</span><span class="n">inverse</span><span class="p">[</span><span class="n">forward</span><span class="p">[</span><span class="n">value</span><span class="p">]]</span> <span class="o">==</span> <span class="n">value</span> <span class="k">for</span> <span class="n">value</span> <span class="ow">in</span> <span class="nf">range</span><span class="p">(</span><span class="mi">256</span><span class="p">))</span></code></pre></figure>

<p><strong>Without reading a MIPS instruction</strong>, we have two full AES table matches at firmware addresses <code class="language-plaintext highlighter-rouge">0x80007E7C</code> (inverse) and <code class="language-plaintext highlighter-rouge">0x80007F7C</code> (forward). That points to AES code with data for both directions. Later we can follow references to confirm how the firmware uses those tables.</p>

<hr />

<h2 id="step-3-locate-sha-256-constants">Step 3: Locate SHA-256 Constants</h2>

<p>SHA-256 uses 64 round constants, each derived from the fractional parts of the cube roots of the first 64 prime numbers. The first is <code class="language-plaintext highlighter-rouge">0x428A2F98</code>. We will check the following values too; one 32-bit word is too short to identify the table reliably.</p>

<p>In a little-endian MIPS binary, <code class="language-plaintext highlighter-rouge">0x428A2F98</code> is stored as bytes <code class="language-plaintext highlighter-rouge">98 2F 8A 42</code>. Let’s search:</p>

<figure class="highlight"><pre><code class="language-bash" data-lang="bash"><span class="nv">$ </span>xxd firmware.bin | <span class="nb">grep</span> <span class="s2">"982f 8a42"</span>
00008080: 982f 8a42 9144 3771 cffb c0b5 a5db b5e9  ./.B.D7q........</code></pre></figure>

<p>Offset <code class="language-plaintext highlighter-rouge">0x8080</code>. Let’s verify by checking the first four constants:</p>

<figure class="highlight"><pre><code class="language-python" data-lang="python"><span class="kn">import</span> <span class="n">struct</span>

<span class="n">data</span> <span class="o">=</span> <span class="nf">open</span><span class="p">(</span><span class="sh">'</span><span class="s">firmware.bin</span><span class="sh">'</span><span class="p">,</span> <span class="sh">'</span><span class="s">rb</span><span class="sh">'</span><span class="p">).</span><span class="nf">read</span><span class="p">()</span>
<span class="n">offset</span> <span class="o">=</span> <span class="mh">0x8080</span>

<span class="c1"># Read first 4 SHA-256 round constants
</span><span class="k">for</span> <span class="n">i</span> <span class="ow">in</span> <span class="nf">range</span><span class="p">(</span><span class="mi">4</span><span class="p">):</span>
    <span class="n">val</span> <span class="o">=</span> <span class="n">struct</span><span class="p">.</span><span class="nf">unpack</span><span class="p">(</span><span class="sh">'</span><span class="s">&lt;I</span><span class="sh">'</span><span class="p">,</span> <span class="n">data</span><span class="p">[</span><span class="n">offset</span> <span class="o">+</span> <span class="n">i</span><span class="o">*</span><span class="mi">4</span> <span class="p">:</span> <span class="n">offset</span> <span class="o">+</span> <span class="n">i</span><span class="o">*</span><span class="mi">4</span> <span class="o">+</span> <span class="mi">4</span><span class="p">])[</span><span class="mi">0</span><span class="p">]</span>
    <span class="nf">print</span><span class="p">(</span><span class="sa">f</span><span class="sh">"</span><span class="s">K[</span><span class="si">{</span><span class="n">i</span><span class="si">}</span><span class="s">] = 0x</span><span class="si">{</span><span class="n">val</span><span class="si">:</span><span class="mi">08</span><span class="n">X</span><span class="si">}</span><span class="sh">"</span><span class="p">)</span></code></pre></figure>

<figure class="highlight"><pre><code class="language-text" data-lang="text">K[0] = 0x428A2F98
K[1] = 0x71374491
K[2] = 0xB5C0FBCF
K[3] = 0xE9B5DBA5</code></pre></figure>

<p>These first four values match the SHA-256 specification. The candidate table occupies 256 bytes (64 constants × 4 bytes each), from <code class="language-plaintext highlighter-rouge">0x8080</code> to <code class="language-plaintext highlighter-rouge">0x8180</code>. Following references to it will confirm which function uses the values.</p>

<p>SHA-256 also needs <strong>initial hash values</strong> – eight constants derived from the square roots of the first eight primes. The first is <code class="language-plaintext highlighter-rouge">0x6A09E667</code>. You might expect these to sit right after the K[] table, but let’s search:</p>

<figure class="highlight"><pre><code class="language-python" data-lang="python"><span class="n">target</span> <span class="o">=</span> <span class="n">struct</span><span class="p">.</span><span class="nf">pack</span><span class="p">(</span><span class="sh">'</span><span class="s">&lt;II</span><span class="sh">'</span><span class="p">,</span> <span class="mh">0x6A09E667</span><span class="p">,</span> <span class="mh">0xBB67AE85</span><span class="p">)</span>
<span class="k">if</span> <span class="n">data</span><span class="p">.</span><span class="nf">find</span><span class="p">(</span><span class="n">target</span><span class="p">)</span> <span class="o">==</span> <span class="o">-</span><span class="mi">1</span><span class="p">:</span>
    <span class="nf">print</span><span class="p">(</span><span class="sh">"</span><span class="s">H0[] not found as contiguous table!</span><span class="sh">"</span><span class="p">)</span></code></pre></figure>

<figure class="highlight"><pre><code class="language-text" data-lang="text">H0[] not found as contiguous table!</code></pre></figure>

<p>Where did the initial hash values go? The source declares an eight-word array, but this build loads its values as immediate instructions. The larger K[] array remains in read-only data. The result depends on the compiler and optimization settings; the absence of a contiguous table does not mean SHA-256 lacks an initial state.</p>

<p>If we disassemble the <code class="language-plaintext highlighter-rouge">sha256_init</code> function (at <code class="language-plaintext highlighter-rouge">0x80006E28</code>), we can see why:</p>

<figure class="highlight"><pre><code class="language-text" data-lang="text">80006e28:  lui   v0, 0x6A09        # H0[0] = 0x6A09E667
80006e2c:  ori   v0, v0, 0xE667
80006e30:  sw    v0, 0(a0)
80006e34:  lui   v0, 0xBB67        # H0[1] = 0xBB67AE85
80006e38:  ori   v0, v0, 0xAE85
80006e3c:  sw    v0, 4(a0)
80006e40:  lui   v0, 0x3C6E        # H0[2] = 0x3C6EF372
80006e44:  ori   v0, v0, 0xF372
80006e48:  sw    v0, 8(a0)
  ...and so on for all 8 values</code></pre></figure>

<p>Here the first three constants use <code class="language-plaintext highlighter-rouge">lui</code>/<code class="language-plaintext highlighter-rouge">ori</code> pairs: load the upper 16 bits, then OR in the lower 16. There is no table reference for these values. They are embedded in the code, not in <code class="language-plaintext highlighter-rouge">.rodata</code>.</p>

<p>Table scanning misses constants embedded in instructions. The <code class="language-plaintext highlighter-rouge">lui 0x6A09</code> and <code class="language-plaintext highlighter-rouge">ori 0xE667</code> pair is still searchable, but now we have to read code.</p>

<p>Our crypto map:</p>

<table>
  <thead>
    <tr>
      <th>Algorithm</th>
      <th>Table</th>
      <th>Offset</th>
      <th>Size</th>
      <th>Fingerprint</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>AES</td>
      <td>Inverse S-box</td>
      <td><code class="language-plaintext highlighter-rouge">0x7E7C</code></td>
      <td>256 bytes</td>
      <td><code class="language-plaintext highlighter-rouge">52 09 6A D5</code></td>
    </tr>
    <tr>
      <td>AES</td>
      <td>S-box</td>
      <td><code class="language-plaintext highlighter-rouge">0x7F7C</code></td>
      <td>256 bytes</td>
      <td><code class="language-plaintext highlighter-rouge">63 7C 77 7B</code></td>
    </tr>
    <tr>
      <td>SHA-256</td>
      <td>Round constants K[]</td>
      <td><code class="language-plaintext highlighter-rouge">0x8080</code></td>
      <td>256 bytes</td>
      <td><code class="language-plaintext highlighter-rouge">0x428A2F98</code> (LE: <code class="language-plaintext highlighter-rouge">98 2F 8A 42</code>)</td>
    </tr>
    <tr>
      <td>SHA-256</td>
      <td>Initial hash H0[]</td>
      <td><em>(inlined)</em></td>
      <td>n/a</td>
      <td><code class="language-plaintext highlighter-rouge">lui 0x6A09</code> / <code class="language-plaintext highlighter-rouge">ori 0xE667</code> in code</td>
    </tr>
  </tbody>
</table>

<hr />

<h2 id="step-4-identify-the-crc-32-table">Step 4: Identify the CRC-32 Table</h2>

<p>CRC-32 is common in firmware for integrity checks. This implementation stores a 1024-byte table for the reflected polynomial <code class="language-plaintext highlighter-rouge">0xEDB88320</code>; its second entry is <code class="language-plaintext highlighter-rouge">0x77073096</code>.</p>

<figure class="highlight"><pre><code class="language-bash" data-lang="bash"><span class="nv">$ </span>xxd firmware.bin | <span class="nb">grep</span> <span class="s2">"9630 0777"</span>
00007a70: 0000 0000 9630 0777 2c61 0eee ba51 0999  .....0.w,a...Q..</code></pre></figure>

<p>Offset <code class="language-plaintext highlighter-rouge">0x7A70</code>. The first four entries confirm it:</p>

<figure class="highlight"><pre><code class="language-text" data-lang="text">[  0] 0x00000000    &lt;- CRC of 0x00
[  1] 0x77073096    &lt;- CRC of 0x01
[  2] 0xEE0E612C    &lt;- CRC of 0x02
[  3] 0x990951BA    &lt;- CRC of 0x03</code></pre></figure>

<p>You can verify the polynomial by computing entry [1] yourself:</p>

<figure class="highlight"><pre><code class="language-python" data-lang="python"><span class="k">def</span> <span class="nf">crc32_single</span><span class="p">(</span><span class="n">byte</span><span class="p">,</span> <span class="n">poly</span><span class="o">=</span><span class="mh">0xEDB88320</span><span class="p">):</span>
    <span class="sh">"""</span><span class="s">Compute one CRC-32 table entry.</span><span class="sh">"""</span>
    <span class="n">crc</span> <span class="o">=</span> <span class="n">byte</span>
    <span class="k">for</span> <span class="n">_</span> <span class="ow">in</span> <span class="nf">range</span><span class="p">(</span><span class="mi">8</span><span class="p">):</span>
        <span class="k">if</span> <span class="n">crc</span> <span class="o">&amp;</span> <span class="mi">1</span><span class="p">:</span>
            <span class="n">crc</span> <span class="o">=</span> <span class="p">(</span><span class="n">crc</span> <span class="o">&gt;&gt;</span> <span class="mi">1</span><span class="p">)</span> <span class="o">^</span> <span class="n">poly</span>
        <span class="k">else</span><span class="p">:</span>
            <span class="n">crc</span> <span class="o">&gt;&gt;=</span> <span class="mi">1</span>
    <span class="k">return</span> <span class="n">crc</span>

<span class="k">assert</span> <span class="nf">crc32_single</span><span class="p">(</span><span class="mi">0</span><span class="p">)</span> <span class="o">==</span> <span class="mh">0x00000000</span>
<span class="k">assert</span> <span class="nf">crc32_single</span><span class="p">(</span><span class="mi">1</span><span class="p">)</span> <span class="o">==</span> <span class="mh">0x77073096</span>
<span class="k">assert</span> <span class="nf">crc32_single</span><span class="p">(</span><span class="mi">2</span><span class="p">)</span> <span class="o">==</span> <span class="mh">0xEE0E612C</span></code></pre></figure>

<p>The table identifies the reflected polynomial. It does <strong>not</strong> identify the initial value or final XOR. In this firmware, inspecting <code class="language-plaintext highlighter-rouge">crc32_calc</code> shows <code class="language-plaintext highlighter-rouge">0xFFFFFFFF</code> for both, which matches the <a href="https://reveng.sourceforge.io/crc-catalogue/17plus.htm#crc.cat.crc-32-iso-hdlc">CRC-32/ISO-HDLC parameter set</a> used by Ethernet, ZIP, and PNG. The table was the lead; the function settles the variant.</p>

<hr />

<h2 id="step-5-find-a-crc-without-a-table">Step 5: Find a CRC Without a Table</h2>

<p>Not every algorithm leaves a table. The other CRC routine computes its result bit by bit:</p>

<figure class="highlight"><pre><code class="language-c" data-lang="c"><span class="cm">/* C-like reconstruction of the function at 0x80006280 */</span>
<span class="kt">uint16_t</span> <span class="nf">func_80006280</span><span class="p">(</span><span class="k">const</span> <span class="kt">void</span> <span class="o">*</span><span class="n">data</span><span class="p">,</span> <span class="kt">uint32_t</span> <span class="n">length</span><span class="p">)</span>
<span class="p">{</span>
    <span class="k">const</span> <span class="kt">uint8_t</span> <span class="o">*</span><span class="n">p</span> <span class="o">=</span> <span class="p">(</span><span class="k">const</span> <span class="kt">uint8_t</span> <span class="o">*</span><span class="p">)</span><span class="n">data</span><span class="p">;</span>
    <span class="kt">uint16_t</span> <span class="n">crc</span> <span class="o">=</span> <span class="mh">0xFFFF</span><span class="p">;</span>
    <span class="kt">uint32_t</span> <span class="n">i</span><span class="p">;</span>
    <span class="kt">int</span> <span class="n">bit</span><span class="p">;</span>

    <span class="k">for</span> <span class="p">(</span><span class="n">i</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span> <span class="n">i</span> <span class="o">&lt;</span> <span class="n">length</span><span class="p">;</span> <span class="n">i</span><span class="o">++</span><span class="p">)</span> <span class="p">{</span>
        <span class="n">crc</span> <span class="o">^=</span> <span class="p">((</span><span class="kt">uint16_t</span><span class="p">)</span><span class="n">p</span><span class="p">[</span><span class="n">i</span><span class="p">])</span> <span class="o">&lt;&lt;</span> <span class="mi">8</span><span class="p">;</span>
        <span class="k">for</span> <span class="p">(</span><span class="n">bit</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span> <span class="n">bit</span> <span class="o">&lt;</span> <span class="mi">8</span><span class="p">;</span> <span class="n">bit</span><span class="o">++</span><span class="p">)</span> <span class="p">{</span>
            <span class="k">if</span> <span class="p">(</span><span class="n">crc</span> <span class="o">&amp;</span> <span class="mh">0x8000</span><span class="p">)</span> <span class="p">{</span>
                <span class="n">crc</span> <span class="o">=</span> <span class="p">(</span><span class="n">crc</span> <span class="o">&lt;&lt;</span> <span class="mi">1</span><span class="p">)</span> <span class="o">^</span> <span class="mh">0x8005</span><span class="p">;</span>
            <span class="p">}</span> <span class="k">else</span> <span class="p">{</span>
                <span class="n">crc</span> <span class="o">&lt;&lt;=</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">return</span> <span class="n">crc</span><span class="p">;</span>
<span class="p">}</span></code></pre></figure>

<p>No table – you’d need to actually disassemble and decompile to find this one. The XOR constant <code class="language-plaintext highlighter-rouge">0x8005</code> identifies the polynomial, but not the CRC variant by itself. This function starts with <code class="language-plaintext highlighter-rouge">0xFFFF</code>, processes each byte most-significant bit first, and returns without a final XOR. Those parameters identify <strong>CRC-16/CMS</strong>: it returns <code class="language-plaintext highlighter-rouge">0xAEE7</code> for the check string <code class="language-plaintext highlighter-rouge">123456789</code>.</p>

<p>CRC-16/USB uses the same polynomial but reflects input and output and applies a final XOR of <code class="language-plaintext highlighter-rouge">0xFFFF</code>; its check value is <code class="language-plaintext highlighter-rouge">0xB4C8</code>. You can identify the bit-by-bit CRC loop by looking for:</p>
<ol>
  <li>A loop of 8 iterations (bit-by-bit processing)</li>
  <li>A test of the MSB (<code class="language-plaintext highlighter-rouge">&amp; 0x8000</code>)</li>
  <li>An XOR with a constant after the shift</li>
</ol>

<p>The constant tells you the polynomial. The loop structure tells you it’s CRC. The initial value, bit order, and final XOR distinguish this variant from others that share the polynomial.</p>

<p>When you find a bit-by-bit CRC without a table, match the full parameter set against <a href="https://reveng.sourceforge.io/crc-catalogue/16.htm">Greg Cook’s catalogue of CRC algorithms</a>, rather than matching the polynomial alone.</p>

<hr />

<h2 id="step-6-map-the-tables">Step 6: Map the Tables</h2>

<p>Here is the map of the algorithm tables we found in the firmware’s read-only data:</p>

<figure class="highlight"><pre><code class="language-text" data-lang="text">Firmware address map: algorithm tables in .rodata
============================================================
0x80007A70 - 0x80007E70  CRC-32 lookup table     1024 bytes
0x80007E70 - 0x80007E7B  AES Rcon table             11 bytes
          (gap: 1 padding byte)
0x80007E7C - 0x80007F7C  AES inverse S-box        256 bytes
0x80007F7C - 0x8000807C  AES forward S-box         256 bytes
          (gap: 4 bytes)
0x80008080 - 0x80008180  SHA-256 K[] constants     256 bytes
============================================================
          Total mapped tables:        1,803 bytes
          That's 4.7% of our 38 KB firmware

          SHA-256 H0[]:  inlined as lui/ori immediates in code
============================================================</code></pre></figure>

<p>These tables happen to occupy the <code class="language-plaintext highlighter-rouge">0x7A70</code>-<code class="language-plaintext highlighter-rouge">0x8180</code> range in this build. Their placement is a linker result, not a rule about firmware. Once we find one table, nearby bytes are still worth checking.</p>

<p>The SHA-256 initial values are absent from the map because this build embeds them in instructions. We should inspect both data and code before concluding a constant is missing.</p>

<hr />

<h2 id="step-7-follow-table-references">Step 7: Follow Table References</h2>

<p>Finding the tables is step one. Step two is finding which <em>functions</em> use them. In this binary, the AES S-box reference uses a <code class="language-plaintext highlighter-rouge">lui</code>/<code class="language-plaintext highlighter-rouge">addiu</code> instruction pair to load the table address:</p>

<figure class="highlight"><pre><code class="language-text" data-lang="text">80006470:  lui   $t0, 0x8000          # $t0 = 0x80000000
80006474:  addiu $t0, $t0, 0x7F7C   # $t0 = 0x80007F7C</code></pre></figure>

<p>These instructions load the AES S-box address <code class="language-plaintext highlighter-rouge">0x80007F7C</code>. A function that references this address is a candidate for AES processing; the surrounding instructions tell us how it uses the table. Following callers in this sample leads to the hidden <code class="language-plaintext highlighter-rouge">$crctest</code> CLI command, which also tests SHA-256. The binary contains both implementations, but it does not show either one protecting management traffic.</p>

<p>We’ll save the full cross-referencing analysis for the next posts, where we’ll use Ghidra’s decompiler and radare2’s <code class="language-plaintext highlighter-rouge">axt</code> command. For now, Python can calculate the immediate values to look for:</p>

<figure class="highlight"><pre><code class="language-python" data-lang="python"><span class="c1"># AES S-box address we want to find references to
</span><span class="n">target_addr</span> <span class="o">=</span> <span class="mh">0x80007F7C</span>
<span class="n">target_hi</span> <span class="o">=</span> <span class="p">(</span><span class="n">target_addr</span> <span class="o">&gt;&gt;</span> <span class="mi">16</span><span class="p">)</span> <span class="o">&amp;</span> <span class="mh">0xFFFF</span>  <span class="c1"># 0x8000
</span><span class="n">target_lo</span> <span class="o">=</span> <span class="n">target_addr</span> <span class="o">&amp;</span> <span class="mh">0xFFFF</span>          <span class="c1"># 0x7F7C (positive)
</span>
<span class="c1"># In MIPS, if the low 16 bits &gt;= 0x8000, lui loads (hi+1)
# and addiu uses a negative offset
</span><span class="k">if</span> <span class="n">target_lo</span> <span class="o">&gt;=</span> <span class="mh">0x8000</span><span class="p">:</span>
    <span class="n">lui_val</span> <span class="o">=</span> <span class="p">(</span><span class="n">target_hi</span> <span class="o">+</span> <span class="mi">1</span><span class="p">)</span> <span class="o">&amp;</span> <span class="mh">0xFFFF</span>
    <span class="n">addiu_val</span> <span class="o">=</span> <span class="n">target_lo</span> <span class="o">-</span> <span class="mh">0x10000</span>  <span class="c1"># Negative
</span><span class="k">else</span><span class="p">:</span>
    <span class="n">lui_val</span> <span class="o">=</span> <span class="n">target_hi</span>
    <span class="n">addiu_val</span> <span class="o">=</span> <span class="n">target_lo</span>

<span class="nf">print</span><span class="p">(</span><span class="sa">f</span><span class="sh">"</span><span class="s">Looking for: lui $reg, 0x</span><span class="si">{</span><span class="n">lui_val</span><span class="si">:</span><span class="mi">04</span><span class="n">X</span><span class="si">}</span><span class="sh">"</span><span class="p">)</span>
<span class="nf">print</span><span class="p">(</span><span class="sa">f</span><span class="sh">"</span><span class="s">             addiu $reg, $reg, </span><span class="si">{</span><span class="n">addiu_val</span><span class="si">}</span><span class="s"> (0x</span><span class="si">{</span><span class="n">addiu_val</span> <span class="o">&amp;</span> <span class="mh">0xFFFF</span><span class="si">:</span><span class="mi">04</span><span class="n">X</span><span class="si">}</span><span class="s">)</span><span class="sh">"</span><span class="p">)</span>
<span class="nf">print</span><span class="p">(</span><span class="sa">f</span><span class="sh">"</span><span class="s">Target address: 0x</span><span class="si">{</span><span class="n">target_addr</span><span class="si">:</span><span class="mi">08</span><span class="n">X</span><span class="si">}</span><span class="sh">"</span><span class="p">)</span></code></pre></figure>

<figure class="highlight"><pre><code class="language-text" data-lang="text">Looking for: lui $reg, 0x8000
             addiu $reg, $reg, 32636 (0x7F7C)
Target address: 0x80007F7C</code></pre></figure>

<hr />

<h2 id="quick-reference-algorithm-table-signatures">Quick Reference: Algorithm Table Signatures</h2>

<p>Here are signatures for several algorithms you may encounter. The CRC entries are checksums, not cryptography. A short prefix is a search term; verify more bytes and find code references before assigning a function name.</p>

<table>
  <thead>
    <tr>
      <th>Algorithm</th>
      <th>Table Size</th>
      <th>First Bytes (hex)</th>
      <th>Description</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>AES S-box</strong></td>
      <td>256</td>
      <td><code class="language-plaintext highlighter-rouge">63 7C 77 7B</code></td>
      <td>Rijndael substitution</td>
    </tr>
    <tr>
      <td><strong>AES inv S-box</strong></td>
      <td>256</td>
      <td><code class="language-plaintext highlighter-rouge">52 09 6A D5</code></td>
      <td>Rijndael inverse</td>
    </tr>
    <tr>
      <td><strong>AES Rcon</strong></td>
      <td>11 in this firmware</td>
      <td><code class="language-plaintext highlighter-rouge">00 01 02 04 08 10 20 40 80 1B 36</code></td>
      <td>Byte-sized round constants</td>
    </tr>
    <tr>
      <td><strong>SHA-256 K[]</strong></td>
      <td>256</td>
      <td>LE: <code class="language-plaintext highlighter-rouge">98 2F 8A 42</code></td>
      <td>Round constants (64 x 32-bit)</td>
    </tr>
    <tr>
      <td><strong>SHA-256 H0[]</strong></td>
      <td>32</td>
      <td>LE: <code class="language-plaintext highlighter-rouge">67 E6 09 6A</code></td>
      <td>Initial hash state</td>
    </tr>
    <tr>
      <td><strong>SHA-1 K[]</strong></td>
      <td>16</td>
      <td>LE: <code class="language-plaintext highlighter-rouge">99 79 82 5A</code></td>
      <td><code class="language-plaintext highlighter-rouge">0x5A827999</code> first</td>
    </tr>
    <tr>
      <td><strong>CRC-32</strong></td>
      <td>1024</td>
      <td>LE: <code class="language-plaintext highlighter-rouge">00 00 00 00 96 30 07 77</code></td>
      <td>Reflected poly 0xEDB88320</td>
    </tr>
    <tr>
      <td><strong>MD5 T[]</strong></td>
      <td>256</td>
      <td>LE: <code class="language-plaintext highlighter-rouge">78 A4 6A D7</code></td>
      <td><code class="language-plaintext highlighter-rouge">0xD76AA478</code> first</td>
    </tr>
    <tr>
      <td><strong>DES S-box 1</strong></td>
      <td>64</td>
      <td><code class="language-plaintext highlighter-rouge">0E 00 04 0F</code></td>
      <td>First DES substitution</td>
    </tr>
    <tr>
      <td><strong>Blowfish P[]</strong></td>
      <td>72</td>
      <td>LE: <code class="language-plaintext highlighter-rouge">88 6A 3F 24</code></td>
      <td><code class="language-plaintext highlighter-rouge">0x243F6A88</code> first</td>
    </tr>
    <tr>
      <td><strong>ChaCha20</strong></td>
      <td>16</td>
      <td><code class="language-plaintext highlighter-rouge">65 78 70 61</code></td>
      <td>“expand 32-byte k”</td>
    </tr>
  </tbody>
</table>

<p>For big-endian targets (MIPS big-endian, PowerPC, SPARC), the byte order reverses. Our MIPS32 LE firmware stores multi-byte constants in little-endian, so <code class="language-plaintext highlighter-rouge">0x428A2F98</code> becomes <code class="language-plaintext highlighter-rouge">98 2F 8A 42</code>.</p>

<hr />

<h2 id="exercises">Exercises</h2>

<ol>
  <li>
    <p><strong>Verify the S-box</strong>: Write a Python script that computes the AES S-box from the mathematical definition (multiplicative inverse in GF(2^8) + affine transform) and compare it against the 256 bytes at offset <code class="language-plaintext highlighter-rouge">0x7F7C</code> in the firmware.</p>
  </li>
  <li>
    <p><strong>Find the Rcon</strong>: This build stores the AES key-expansion constants as bytes, with a leading <code class="language-plaintext highlighter-rouge">00</code>. Search for <code class="language-plaintext highlighter-rouge">00 01 02 04 08 10 20 40 80 1B 36</code> from the firmware directory:</p>
  </li>
</ol>

<figure class="highlight"><pre><code class="language-bash" data-lang="bash">   python3 <span class="nt">-c</span> <span class="s1">'from pathlib import Path; data = Path("firmware.bin").read_bytes(); print(hex(data.find(bytes.fromhex("00 01 02 04 08 10 20 40 80 1b 36"))))'</span>
   </code></pre></figure>

<p>Confirm that it prints <code class="language-plaintext highlighter-rouge">0x7e70</code>. One padding byte separates the 11-byte match from the inverse S-box.</p>

<ol>
  <li>
    <p><strong>Entropy histogram</strong>: Modify the entropy script to produce a histogram (e.g., using matplotlib) that shows entropy across the entire firmware. The crypto neighborhood should appear as a clear spike.</p>
  </li>
  <li>
    <p><strong>Identify the CRC variant</strong>: Check the CRC-16 polynomial, initial value, bit order, and final XOR against the catalogue. Why does this implementation match CRC-16/CMS rather than CRC-16/USB, despite their shared polynomial?</p>
  </li>
</ol>

<hr />

<h2 id="whats-next">What’s Next</h2>

<p>The tables led us to AES, SHA-256, and CRC-32. CRC-16 required a look at code. That is the useful boundary of the method: recognizable data narrows the search, then instructions tell us what the firmware does with it.</p>

<p>In the next post, Ghidra shows <code class="language-plaintext highlighter-rouge">*(param_1 + 0x20)</code> where the source had a task control block and a linked-list node. We will use repeated offsets and cross-references to recover that structure from the binary.</p>

<hr />

<p><em>This is Part 1 of “Crypto Tables, Struct Ghosts, and Other Secrets Hidden in Firmware.” The <a href="/assets/firmware-re/advanced-sample.tar.gz">sample bundle</a> contains the exact binary, source, and entropy scanner used here.</em></p>

<p><em>Next: <a href="/reverse-engineering/firmware/structs/2026/10/15/offsets-embedded-nodes-and-other-phantoms-of-lost-structs.html">Part 2 — Offsets, Embedded Nodes, and Other Phantoms of Lost Structs</a>.</em></p>]]></content><author><name>Maciej Grochowski</name></author><category term="reverse-engineering" /><category term="firmware" /><category term="crypto" /><summary type="html"><![CDATA[The first series ended with an 8 KB MIPS32 firmware and a set of ways to read it. This time we have a 38,616-byte flat binary. Before following its code, we need to answer a narrower question: which algorithms are in it?]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://page-fault.io/assets/social-preview.png" /><media:content medium="image" url="https://page-fault.io/assets/social-preview.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry></feed>