This is the final post in our series. We have used constant tables, memory offsets, cross-references, and memory-mapped I/O (MMIO) accesses to put names to pieces of the binary. Now we can ask what happens when those pieces meet during boot.

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 boot complete marker at face value.

Follow along: Use the same firmware.bin from the sample bundle, and run the examples from more_complex_firmware/. Instruction addresses refer to that binary. The boot outcome below follows from its code and configuration; it is not a captured device boot.


The Pool Allocator: Sentinel Values and Three Modes

Finding the Sentinels

Some of the most revealing values in firmware aren’t in .rodata tables – they’re magic constants embedded directly in code. Search the bundled binary’s executable ranges for lui instructions that load 0xDEAD:

from capstone import Cs, CS_ARCH_MIPS, CS_MODE_MIPS32, CS_MODE_LITTLE_ENDIAN

data = open("firmware.bin", "rb").read()
md = Cs(CS_ARCH_MIPS, CS_MODE_MIPS32 | CS_MODE_LITTLE_ENDIAN)
for start, end in [(0x0000, 0x02AC), (0x02B0, 0x7290)]:
    for insn in md.disasm(data[start:end], 0x80000000 + start):
        if insn.mnemonic == "lui" and insn.op_str.endswith(", 0xdead"):
            print(f"{insn.address:08x}:  {insn.mnemonic} {insn.op_str.replace('$', '')}")
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

The upper-half loads alone do not establish the full values. Follow the surrounding instructions to find 0xDEAD0000 and 0xDEAD0002. These are sentinel values – 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.

# 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

The allocator checks if a slot contains 0xDEAD0000 (free), and if so, overwrites it with 0xDEAD0002 (used). In pool_init, a delay-slot addition constructs a third value:

80001b00:  lui   t0, 0xdead
80001b04:  b     0x80001ab4
80001b08:  addiu t0, t0, 1           # delay slot: 0xDEAD0001

Following the initialization stores and allocation checks gives us:

0xDEAD0000 = FREE     (slot available for allocation)
0xDEAD0001 = BOUNDARY (window boundary, skip during scan)
0xDEAD0002 = USED     (slot currently allocated)

These three sentinel values, together with the indexing code, reveal a windowed 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 0xDEAD0001.

The Three-Mode Dispatch

The function at 0x80001B14 (pool_alloc) starts with a mode check:

80001b14:  beqz  a0, 0x80001c98      # NULL check
80001b18:  nop
  ...
80001b24:  lw    t3, 4(a0)           # t3 = pool->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

Three modes, selected by a field at offset +0x04 in the pool struct:

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

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.

Reconstructing the Pool Struct

From the allocator code, we can recover the pool data structure:

typedef struct {
    const char *name;           /* +0x00 */
    uint32_t    mode;           /* +0x04: 0=windowed, 1=linear, 2=copy */
    uint32_t    num_windows;    /* +0x08 */
    uint32_t    total_slots;    /* +0x0C */
    uint32_t    used_count;     /* +0x10 */
    uint32_t    cursor;         /* +0x14: encoded [7:3]=window, [2:0]=slot */
    uint32_t    alloc_fails;    /* +0x18: failed allocations */
    uint32_t    slots[32];      /* +0x1C: sentinel-tagged slot array */
} pool_t;

In windowed mode, the cursor at +0x14 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: 24 allocatable slots, not 32. Linear and copy modes each initialize one four-slot window, so their pools have four allocatable slots each. The 32-element array is storage capacity, not the active size of every pool.

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 POOL_WINDOW_SIZE is four. The compiled initialization and allocation bounds agree on four. Even with source available, a comment can lose an argument with the instructions.


The CLI Dispatch: Two Command Tables

Finding the Tables

Function pointer tables are RE gold – they map directly to the firmware’s command structure. In our firmware, cli_process starts at 0x8000541C and contains the dispatch logic. Its public-command string comparison loop loads the table base as follows:

# Load table base
8000559c:  lui   s0, 0x8000
800055a0:  addiu s0, s0, 0x78c0      # 0x800078C0 = public_cmds table

At address 0x800078C0, we find an array of 12-byte entries:

$ 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('<I', data[offset:offset+4])[0]
    handler  = struct.unpack('<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}\"  -> 0x{handler:08X}')
    offset += 12
"
  [ 0] "help"       -> 0x80004BF4
  [ 1] "version"    -> 0x80004BC8
  [ 2] "uptime"     -> 0x8000538C
  [ 3] "status"     -> 0x8000522C
  [ 4] "portdump"   -> 0x80005370
  [ 5] "routedump"  -> 0x80005354
  [ 6] "ruledump"   -> 0x80005104
  [ 7] "pooldump"   -> 0x80005144
  [ 8] "taskdump"   -> 0x80005338
  [ 9] "eventdump"  -> 0x8000531C
  [10] "dmadump"    -> 0x80005300
  [11] "partdump"   -> 0x800052E4
  [12] "timer"      -> 0x800052C8
  [13] "reset"      -> 0x80004BA0
  [14] "log"        -> 0x800051F0

15 public commands. But the dispatch function searches two tables. After the public table check fails, it searches a second table at 0x80007860:

  [0] "$dbglvl"    -> 0x80005194
  [1] "$peek"      -> 0x80004DC8
  [2] "$poke"      -> 0x80004D34
  [3] "$flashid"   -> 0x80004CFC
  [4] "$crashme"   -> 0x80005160
  [5] "$rulestat"  -> 0x80005120
  [6] "$wdgtest"   -> 0x80004B74
  [7] "$crctest"   -> 0x80004E44

Eight hidden commands prefixed with $. In production firmware, hidden debug commands like these are security-relevant – $peek and $poke provide arbitrary memory read/write, $crashme triggers a fatal assert, and $wdgtest 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.

The entries in these two tables use {string_pointer, help_pointer, handler_pointer}. Once we find one table, checking nearby data for another is worth the effort; the entry layout itself depends on the firmware.


The Boot Chain: Six Phases With Error Codes

The Phase Dispatch Table

The firmware_main function at 0x80000540 walks a six-entry function pointer table at file offset 0x7290 (address 0x80007290). The binary stores each handler address as a little-endian word. We can read those words directly:

import struct
data = open('firmware.bin', 'rb').read()
for i in range(6):
    addr = struct.unpack('<I', data[0x7290+i*4:0x7290+i*4+4])[0]
    print(f"Phase {i}: handler at 0x{addr:08X}")
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

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.

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 INIT_ERR_RESOURCE (0x0D). The firmware records the failure and still runs phase 5. This is a conclusion from the code path, not a captured boot log.

The Boot Status Marker

Phase 5 writes the boot marker regardless of the earlier phase-4 error:

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

The value 0xBEEF signals that execution reached the runtime-start phase. It does not prove that partition binding succeeded. The phase-4 return code and scratch error register are needed to tell those outcomes apart.


The Rule Engine: Validate, Log, Activate

State Machine

The rule_commit function at 0x8000330C 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:

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

In C-like reconstruction:

if (!rule_validate(table, index))
    return -1;
rule_write_hw(table, index);         /* logs; no MMIO write in this sample */
rtos_mutex_lock(&table->commit_lock);
rule_activate(table, index);         /* sets state to COMMITTED */
rtos_mutex_unlock(&table->commit_lock);
event_post(EVT_RULE_CHANGE, (type << 16) | index);

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 rtos_mutex_lock is ignored, although that function can return -1 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: rule_write_hw only logs a message. These bytes show a state transition and notification, not an atomic update of a device table.

Validation Failure

If validation fails, rule_commit logs an error and returns -1 before the simulated write and state change. The entry remains in its previous state. No rollback operation runs.


Scheduler Code Without Task Switching

The Scheduler Algorithm

From the struct recovery in Part 2, we know that rtos_schedule at 0x80000C10 reads a priority-sorted ready list:

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->arg

The field named total_ticks 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.

The Unwired Timer Tick

The rtos_tick function at 0x80000CB8 walks the timer list:

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

The task has two list nodes: timer_list at +0x28 and run_list at +0x20. timer_isr calls rtos_tick, but the exception handler in this sample never dispatches to timer_isr. The function is present; the interrupt path that would run it is not.


Putting It All Together

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

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)
========================================================

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.


The Techniques, Summarized

Over five posts, we’ve used a consistent set of techniques:

  1. Constant scanning (Post 1): Known byte sequences locate candidate tables. AES S-box (63 7C 77 7B), SHA-256 K[] (0x428A2F98), CRC-32 table (0x77073096). Byte searches need no disassembly; checking the full table and its users supports the algorithm identification.

  2. Offset collection (Post 2): Memory access offsets from lw/sw instructions reveal struct layouts. container_of subtractions reveal intrusive list node positions. Bitfield masks reveal packed fields.

  3. Tool selection (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.

  4. MMIO pattern recognition (Part 4): Address construction provides candidates; loads, stores, and surrounding control flow show how each address is used.

  5. Architectural inference (Post 5): Sentinel values reveal allocator design. Function pointer tables reveal dispatch systems. State transitions reveal protocols. Boot sequences reveal system dependencies.

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.


From Static Inference to a Runtime Check

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 0xBEEF. 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.

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.

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.

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.


This is Part 5 (final) of “Crypto Tables, Struct Ghosts, and Other Secrets Hidden in Firmware.” The sample bundle contains the firmware binary, source, and entropy scanner.