Sentinel Values, Pool Modes, and Other Clues to Runtime Architecture
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.binfrom the sample bundle, and run the examples frommore_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, 0xdeadThe 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 USEDThe 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: 0xDEAD0001Following 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 scanThree 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 tableAt 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" -> 0x800051F015 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" -> 0x80004E44Eight 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 startThe 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 registerThe 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_CHANGEIn 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->argThe 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 listThe 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:
-
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. -
Offset collection (Post 2): Memory access offsets from
lw/swinstructions reveal struct layouts.container_ofsubtractions reveal intrusive list node positions. Bitfield masks reveal packed fields. -
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.
-
MMIO pattern recognition (Part 4): Address construction provides candidates; loads, stores, and surrounding control flow show how each address is used.
-
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.