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.

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 structs – carefully designed data structures with fields at specific offsets, linked together with embedded list nodes.

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.

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: lw v0, 0(a0) … lw v1, 4(a0) … sw v0, 0x20(a0).

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.

Follow along: Download the sample firmware and source bundle, then run tar xf advanced-sample.tar.gz and cd more_complex_firmware. Use firmware.bin as the analysis target; the companion source and ELF let you check the recovered layouts and instruction addresses afterward.


The Problem: What Decompilation Leaves Unresolved

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:

void FUN_80000c10(void)
{
    int *piVar1 = *(int **)0x8008008c;
    if (piVar1 == (int *)0x8008008c)
        return;

    int *piVar2 = (int *)((char *)piVar1 - 0x20);
    if (piVar2 != *(int **)0x80080098) {
        /* Other state checks and branches omitted. */
        *(int **)0x80080098 = piVar2;
        *(int *)((char *)piVar1 - 0x1c) = 2;
        ++*(int *)((char *)piVar1 + 0x14);
        ++*(int *)((char *)piVar1 + 0x18);
    }
}

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 *(ptr + offset). The meaning is still opaque.

But the offsets 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.


Step 1: Collect Offsets From One Function

Let’s start with the scheduler function at 0x80000C10. From the disassembly:

80000c10:  lui   v1, 0x8008
80000c14:  lw    v0, 140(v1)        # load from global at 0x8008008C
80000c18:  addiu v1, v1, 140        # v1 = &list_head (0x8008008C)
80000c1c:  beq   v0, v1, ...        # if (list.next == &list) → empty
80000c20:  addiu a0, v0, -32        # a0 = list_entry - 0x20 → container!

Stop right there. That addiu a0, v0, -32 is the most important instruction in this function. It’s a container_of operation – subtracting 0x20 from a list node pointer to get back to the containing structure.

This tells us: some struct has a list_node field at offset +0x20. The list node was loaded from a global list head at 0x8008008C, and the function subtracts 32 bytes to get a pointer to the beginning of the struct.

Continuing through the function, we collect more offsets:

# 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

This needs careful accounting. The variable v0 still points to the list node at offset +0x20, not the struct base. So:

  • sw v1, -28(v0) → writes to struct + 0x20 - 28 = struct + 0x04
  • lw v1, 20(v0) → reads from struct + 0x20 + 20 = struct + 0x34
  • lw v1, 24(v0) → reads from struct + 0x20 + 24 = struct + 0x38
  • lw v1, -20(v0) → reads from struct + 0x20 - 20 = struct + 0x0C
  • lw a0, -16(v0) → reads from struct + 0x20 - 16 = struct + 0x10

After one function, we have this partial struct map:

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

Step 2: Type Inference From Operations

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

Operation Inference
Compared against small constants (0, 1, 2, 3) Enum / state field
AND with bitmask (andi $v0, $v1, 0x01) Flags / bitfield
OR with bitmask (ori $v0, $v1, 0x20) Setting a flag bit
jalr (jump to value) Function pointer
Used as first arg to jalr Argument to callback
Incremented by 1 Counter
Subtracted to get struct base List node (container_of)
Loaded then used as pointer Pointer to another struct
Compared against lui/addiu constructed address Comparison to global

Applying these rules to our offsets:

  • +0x04: compared to constants 1 and 2, set to 1 or 2 → state enum (1=READY, 2=RUNNING)
  • +0x0C: value loaded and used with jalr → function pointer (task entry point)
  • +0x10: loaded as argument to the jalr’d function → callback argument (void *arg)
  • +0x20: subtracted via container_of → list node (embedded in parent struct)
  • +0x34, +0x38: incremented → counters (run_count, total_ticks?)

The counter at +0x34 advances when the selected task changes. The one at +0x38 advances whenever the scheduler selects that task, including repeated selections. These are scheduler counts; the accesses do not establish elapsed timer ticks.


Step 3: Cross-Function Struct Building

One function rarely touches all fields. To complete the picture, we need to look at every function that operates on the same struct. The rtos_task_create function at 0x80000900 is a goldmine – it initializes all fields:

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

The timer-tick path gives us evidence for one more field. Here a1 points to the task’s timer_list node at +0x28:

80000d34:  lw    v0, -12(a1)        # +0x28 - 12 = +0x1C
80000d3c:  addiu v0, v0, -1         # decrement timer value
80000d44:  sw    v0, -12(a1)        # write it back

That supports naming +0x1C ticks_remaining. We still cannot establish the meaning of +0x14 or +0x18 from these paths.

Now we can reconstruct the layout. The two list nodes each contain a next and prev pointer, so we can use this working type:

struct list_node {
    struct list_node *next;
    struct list_node *prev;
};

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

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.


Step 4: The Intrusive Linked List Pattern

Notice something about the list initialization at +0x20 and +0x28:

# Initializing run_list at +0x20:
800009c0:  sw    v1, 32(s0)         # run_list.next = &self.run_list
800009c4:  sw    v1, 36(s0)         # run_list.prev = &self.run_list

# Initializing timer_list at +0x28:
800009d0:  sw    v0, 40(s0)         # timer_list.next = &self.timer_list
800009d4:  sw    v0, 44(s0)         # timer_list.prev = &self.timer_list

Both list nodes are initialized to point to themselves. This is the signature of an intrusive doubly-linked list – the same pattern used by the Linux kernel’s list_head. An empty list node points to itself:

/* List initialization: node->next = node->prev = node */
void list_init(struct list_node *node) {
    node->next = node;
    node->prev = node;
}

The key insight is that the same struct has two different list nodes at different offsets:

  • run_list at +0x20 threads the task into the ready queue or blocked queue
  • timer_list at +0x28 threads the task into the timer expiry list

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 container_of offset to get back to the task struct.

When you see addiu a0, v0, -0x20 in the scheduler, it’s doing container_of(node, task_t, run_list). When you see addiu a0, v0, -0x28 in the timer tick handler, it’s doing container_of(node, task_t, timer_list). Different offset, same struct.


Step 5: Another Struct – The Mutex

The mutex lock function at 0x80000DC8 gives us a second struct to reconstruct:

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: &wait_list (list node offset)

The addiu a0, a0, 12 at 0x80000E48 tells us the mutex has a list_node at offset +0x0C. Collecting all the offsets:

typedef struct {
    uint32_t    locked;             /* +0x00: 0=free, 1=locked */
    void       *owner;              /* +0x04: owning task pointer */
    uint32_t    nest_count;         /* +0x08: recursive lock depth */
    struct list_node wait_list;     /* +0x0C: blocked tasks waiting */
    uint32_t    field_14;           /* +0x14: purpose not shown here */
} mutex_t;  /* 24 bytes */

Look at the relationship: the mutex’s wait_list at +0x0C links to the task’s run_list at +0x20. When a task blocks on a mutex, its run_list node is moved from the ready queue to the mutex’s wait list. The container_of offset is still -0x20 because the list node in the task struct is always at +0x20, regardless of which list it’s on.


Step 6: Packed Bitfields – The Rule Engine

Not all struct fields are simple integers. The rule engine uses packed bitfields – multiple values squeezed into a single 32-bit word. Look at this pattern from the rule commit function at 0x8000330C:

# Load field +0x10 of rule entry
80003388:  lw    v1, 16(v0)
# Mask off upper byte: v1 & 0xFF00
8000338c:  andi  v1, v1, 0xFF00
# Compare against 0x0200
80003390:  li    a0, 512
80003394:  bne   v1, a0, error

The code loads a 32-bit word from offset +0x10, masks it with 0xFF00, and compares the result to 0x0200. This means:

  • Bits [15:8] of the word at +0x10 encode a state field
  • State 0x02 means “PROGRAMMED” (the required state before committing)

Later in the same function:

# 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

The MIPS ins (insert) instruction zeroes bits [15:8], then ori sets them to 0x03 (COMMITTED state). This is a classic read-modify-write on a packed bitfield.

From multiple functions, we can reconstruct the full packing:

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

Which gives us this rule entry struct:

typedef struct {
    uint32_t    type;               /* +0x00: AR=0, ID=1, AT=2 */
    uint32_t    match_lo;           /* +0x04: match range low */
    uint32_t    match_hi;           /* +0x08: match range high */
    uint32_t    action;             /* +0x0C: forwarding action */
    uint32_t    flags;              /* +0x10: packed [2:0]=part, [7:3]=pri, [15:8]=state */
    uint32_t    reserved;           /* +0x14 */
} rule_entry_t;  /* 24 bytes (0x18) */

The Method: A Systematic Approach

Let me distill this into a repeatable method:

1. Start with one function

Pick a function that accesses many fields – initializers and dump/debug functions are ideal because they touch every field sequentially.

2. Collect every offset

For each lw/sw 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 v0 = struct + 0x20 and then accessing fields relative to that interior pointer.

3. Classify each field by operation

Pattern Likely type
Compared to small constants Enum / state
AND/OR with bitmasks Flags / bitfield
jalr target Function pointer
Loaded then used as address Pointer to struct
Self-referential init (next=prev=self) List node
Incremented / decremented Counter
Loaded and passed to uart_puts String pointer
Loaded and passed to other function Usually pointer or ID

4. Cross-reference across functions

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.

5. Look for embedded list nodes

The container_of subtraction (addiu reg, reg, -N) is the telltale sign. The offset N 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.

6. Verify struct size

Look for array access patterns. If the code does ptr + index * 60, the struct is 60 bytes. Multiplication by a non-power-of-2 constant is often visible as a shift-add sequence:

# 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

Or the compiler might use the mul instruction directly if available:

li    v0, 60
mul   v0, a0, v0       # v0 = index * 60

Either way, the struct size is the multiplication constant.


Recovered Structs: Summary

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

/* 60 bytes - RTOS task control block */
typedef struct {
    uint32_t    task_id;            /* +0x00 */
    uint32_t    state;              /* +0x04 */
    uint32_t    priority;           /* +0x08 */
    void       (*entry)(void*);     /* +0x0C */
    void       *arg;                /* +0x10 */
    uint32_t    field_14;           /* +0x14: meaning not established */
    uint32_t    field_18;           /* +0x18: receives fifth argument */
    uint32_t    ticks_remaining;    /* +0x1C */
    struct list_node run_list;      /* +0x20 */
    struct list_node timer_list;    /* +0x28 */
    const char *name;               /* +0x30 */
    uint32_t    run_count;          /* +0x34 */
    uint32_t    total_ticks;        /* +0x38 */
} task_t;

/* 24 bytes - Mutex with ownership */
typedef struct {
    uint32_t    locked;             /* +0x00 */
    task_t     *owner;              /* +0x04 */
    uint32_t    nest_count;         /* +0x08 */
    struct list_node wait_list;     /* +0x0C */
    uint32_t    field_14;           /* +0x14: meaning not established */
} mutex_t;

/* 24 bytes - Rule entry with packed flags */
typedef struct {
    uint32_t    type;               /* +0x00 */
    uint32_t    match_lo;           /* +0x04 */
    uint32_t    match_hi;           /* +0x08 */
    uint32_t    action;             /* +0x0C */
    uint32_t    flags;              /* +0x10: packed bitfield */
    uint32_t    reserved;           /* +0x14 */
} rule_entry_t;

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.


Exercises

  1. Find the semaphore struct: The firmware has rtos_sem_wait at 0x80000F98 and rtos_sem_post at 0x80001034. Disassemble these functions and reconstruct the semaphore struct. (Hint: it’s similar to mutex_t but with a count field instead of an owner.)

  2. Recover port_config_t: The port initialization at 0x80002830 sets up an array of 8 port structures with 48-byte stride (see the addiu v0, v0, 48 loop increment). Map all the fields.

  3. Map the route entry: The routing module uses structs with a list node at +0x14. Find the container_of subtraction that reveals this offset and reconstruct the full routing entry.

  4. Automate offset collection: Write a Python script using Capstone to disassemble a function and collect its lw/sw offsets. Track the struct pointer through copies from $a0-$a3 into other registers and through interior pointers such as struct + 0x20; normalize each access back to the struct base. Report an offset as unknown when register provenance is lost.


What’s Next

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.

In the next post, we will use radare2 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.


This is Part 2 of “Crypto Tables, Struct Ghosts, and Other Secrets Hidden in Firmware.”

Next: Part 3 — Radare2, Ghidra, and Other Ways to Tear Apart a Binary.