In Part 2, we mapped the major functions, string references, and 16 CLI commands using nothing but strings, pattern matching, and a Python script. That left us with a suspicious route-engine string:

WARNING: no routes programmed for stack

Part 2 could show that the string exists and that it belongs to the route subsystem. It could not prove when that path runs, or whether it is the exact runtime symptom. For that we need structure. In this post, we will decompile the firmware, trace the execution path through the route engine, find why two boot routes miss the forwarding log branch, patch the binary, and recalculate the firmware image’s CRC. The sample logs the would-be forwarding action; it does not write a hardware table or forward packets.


From Disassembly to C: What Decompilers Give You

Reading MIPS assembly works for short functions, but once you are staring at a 60-instruction function with nested loops and multiple branches, you want C. Decompilers like Ghidra take machine code and produce readable (if imperfect) C.

Here is a C-like reconstruction of the unannotated iterate_active_routes function. It illustrates the control flow and anonymous names; it is not a verbatim Ghidra capture:

void FUN_80000efc(int param_1)
{
    int *piVar1;
    int iVar2;
    int iVar3;
    int iVar4;

    iVar3 = 0;
    iVar4 = 0;
    piVar1 = (int *)0x80040008;
    do {
        iVar2 = *piVar1;
        if (iVar2 != 0) {
            if ((iVar2 & 4) != 0) {
                FUN_80000b74(4, s_route_bridge_flag_set_skipping_80001f70);
                iVar3 = iVar3 + 1;
            }
            else if ((iVar2 & 1) != 0) {
                FUN_80000b74(4, s_route_programming_forwarding_entry_80001f94);
                iVar4 = iVar4 + 1;
            }
        }
        piVar1 = piVar1 + 4;
    } while (piVar1 != (int *)0x80040208);
    if (iVar4 == 0) {
        FUN_80000b74(4, s_WARNING_no_routes_programmed_80001fb8);
    }
    if (0 < iVar3) {
        FUN_80000b74(4, s_bridge_routes_deferred_80001fe0);
    }
    return;
}

The structure shows the loop, flag checks, and log messages. The anonymous names convey little; the string references help identify what each branch logs.

This is what decompilation gives you: structure without semantics. You get the control flow and data flow right, but understanding what it means requires annotation.


Annotation: From piVar1 to entry->flags

Let us annotate the reconstruction. Using the strings, the function name we discovered in Part 2, and the route table structure, we can give the fields working names:

/* iterate_active_routes() at 0x80000EFC
 * Walks the route table and logs active routes as forwarding candidates.
 * Bridge-flagged routes take a separate logging path.
 */
void iterate_active_routes(int stack_idx)
{
    route_entry_t *entry;
    int flags;
    int skipped = 0;       /* bridge-deferred routes */
    int programmed = 0;    /* routes reaching forwarding log branch */

    entry = &g_route_table_entries[0];   /* Ghidra's raw piVar1 points at entry[0].flags */
    do {
        flags = entry->flags;
        if (flags != 0) {                    /* skip empty slots */
            if ((flags & 0x04) != 0) {       /* ROUTE_FLAG_BRIDGE = 0x04 */
                log_msg(MOD_ROUTE, "route: bridge flag set, skipping");
                skipped++;
            }
            else if ((flags & 0x01) != 0) {  /* ROUTE_FLAG_ACTIVE = 0x01 */
                log_msg(MOD_ROUTE, "route: programming forwarding entry");
                programmed++;
            }
        }
        entry++;
    } while (entry != &g_route_table_entries[32]);

    if (programmed == 0) {
        log_msg(MOD_ROUTE, "WARNING: no routes programmed for stack");
    }
    if (skipped > 0) {
        log_msg(MOD_ROUTE, "bridge routes deferred to bridge handler");
    }
}

Now it reads like source code. And now the bug jumps out.


The Bug: Priority Inversion

Look at the two if-statements in the loop:

if ((flags & 0x04) != 0) {       /* check BRIDGE flag first */
    skipped++;
    continue;
}
else if ((flags & 0x01) != 0) {  /* check ACTIVE flag second */
    programmed++;
}

The bridge flag (0x04) is checked before the active flag (0x01). This creates an if/else where a route that has both flags set hits the bridge check first and gets diverted – the active check never runs.

Let us trace what happens during boot. The firmware adds four routes:

route_add(0x0A01, 0x01, ACTIVE | STATIC);           /* 10.1.x.x → port 0 */
route_add(0x0A02, 0x02, ACTIVE | STATIC);           /* 10.2.x.x → port 1 */
route_add(0xC0A8, 0x04, ACTIVE | BRIDGE);           /* 192.168.x → port 2 */
route_add(0xAC10, 0x08, ACTIVE | STATIC | BRIDGE);  /* 172.16.x → port 3 */

Routes 1 and 2 have flags 0x03 (ACTIVE + STATIC). The BRIDGE check fails (0x03 & 0x04 = 0), so they fall through to the ACTIVE check (0x03 & 0x01 = 1) and reach the forwarding log branch.

Routes 3 and 4 have flags 0x05 and 0x07 respectively – both include BRIDGE (0x04). The bridge check succeeds first: 0x05 & 0x04 = 4, which is non-zero, so the bridge path wins. The route’s ACTIVE bit exists, but the forwarding log path never gets to use it.

The result? Only 2 of 4 routes reach the forwarding log branch in the final scan. The other two take the bridge log branch. This sample has no hardware forwarding-table write, so these logs do not establish what happens to packets. The final boot scan produces:

[MOD:4] route: programming forwarding entry    ← route 1 (10.1.x)
[MOD:4] route: programming forwarding entry    ← route 2 (10.2.x)
[MOD:4] route: bridge flag set, skipping       ← route 3 (192.168.x) SKIPPED!
[MOD:4] route: bridge flag set, skipping       ← route 4 (172.16.x) SKIPPED!
[MOD:4] bridge routes deferred to bridge handler

iterate_active_routes is called by route_add after every insertion, so the scan runs four times during boot – not once at the end. Calls 1 and 2 only see ACTIVE+STATIC entries and log them through the forwarding branch. Call 3 hits route 3’s BRIDGE flag for the first time. Call 4 does the same for route 4.

Routes 1 and 2 reach that branch on every rescan, so the local programmed counter is never zero – the WARNING: no routes programmed guard never fires for this route set. It detects a scan with no routes reaching the forwarding log branch, not an empty hardware table. That is the correction decompilation gives us over the string-only view from Part 2: the warning string was a clue to inspect this function, while the actual branch-order problem is the quieter “bridge flag set, skipping” path.

This is a flag-priority problem – the bridge flag check runs before the active flag check, so a route with both flags never reaches the active branch. If the intended behavior is to handle both flags independently, the checks need separate paths. The sample does not implement a bridge handler or hardware forwarding write.

In the real vendor firmware project, we found exactly this pattern: a flag check at instruction 0xC012936C tested a P2P flag before the active-capability flag, causing an entire subsystem to silently fail whenever a specific hardware configuration was present. The symptom was identical – “no rules programmed” – and it took weeks of trace analysis to find those four bytes.


The Disassembly: Four Instructions

Let us look at the exact machine code. The bug lives in four instructions at offsets 0x0F6C-0x0F78:

80000f64:   8e020000    lw      v0, 0(s0)       # v0 = entry->flags
80000f68:   1040fffb    beqz    v0, next_entry  # if flags == 0, skip

80000f6c:   30430004    andi    v1, v0, 0x4     # v1 = flags & BRIDGE  ← THE BUG
80000f70:   1460fff5    bnez    v1, bridge_skip # if BRIDGE set → branch to bridge_skip
80000f74:   30420001    andi    v0, v0, 0x1     # delay slot: executes even when branch is taken
80000f78:   1040fff7    beqz    v0, next_entry  # ← never reached when branch taken

The instruction at 0x80000F70 is the problem: bnez v1, bridge_skip. When the BRIDGE bit is set this branch fires – and because 0x80000F74 is the branch delay slot, the andi there executes either way. What never executes is 0x80000F78: the branch that tests the ACTIVE result before the forwarding log path. Routes with both BRIDGE and ACTIVE flags follow only the bridge log path.


Binary Patching: The Four-Byte Fix

The simplest way to make ACTIVE + BRIDGE routes reach the forwarding log branch is to NOP out the branch at 0x0F70. Replace bnez v1, bridge_skip with nop, so the bridge flag check still runs but never diverts execution. All non-empty routes reach the ACTIVE check.

import struct

with open('firmware.bin', 'rb') as f:
    data = bytearray(f.read())

# The bug: bnez instruction at offset 0x0F70
bug_offset = 0x0F70
original = struct.unpack_from('<I', data, bug_offset)[0]
print(f"Original: 0x{original:08X}  (bnez v1, bridge_skip)")

# The fix: NOP (0x00000000)
struct.pack_into('<I', data, bug_offset, 0x00000000)
print(f"Patched:  0x00000000  (nop)")

with open('firmware_patched.bin', 'wb') as f:
    f.write(data)

Four bytes change this branch decision. The bridge flag still gets tested by the andi instruction, but the result is never acted on – all non-empty routes proceed to the ACTIVE check.

After patching:
80000f6c:   30430004    andi    v1, v0, 0x4     # v1 = flags & BRIDGE (still runs)
80000f70:   00000000    nop                      # ← PATCHED (was: bnez v1, bridge_skip)
80000f74:   30420001    andi    v0, v0, 0x1     # former delay slot; already executed before patch
80000f78:   1040fff7    beqz    v0, next_entry  # ← now always reached

Now routes with ACTIVE | BRIDGE (flags 0x05 or 0x07) hit the NOP, the former delay-slot andi runs as before, but execution falls through to 0x80000F78 – the beqz that was previously unreachable. The ACTIVE flag is set, the branch is not taken, and all four boot routes reach the forwarding log branch. The patch does not add a hardware-table write.


CRC Recalculation: You Break It, You Fix It

Remember the firmware image from Part 1? Each partition has a CRC-32 checksum, and the image header has a global checksum. Patching even a single byte invalidates both:

Original firmware.bin CRC-32:  0x9F25E0DC
Patched firmware.bin CRC-32:   0x156FE139   ← completely different!

If we packed the patched binary without updating its CRC, the bundled fwpack.py --info command would report a mismatch. We need to repack to keep the image’s integrity fields consistent:

$ python3 fwpack.py firmware_patched.bin firmware_patched.img

Packed firmware image: firmware_patched.img
  Total size:    9152 bytes
  Image CRC-32:  0xC5E88C64
  Partitions:    3

  [bootloader]
    Offset:  0x0120
    Size:    32 bytes
    CRC-32:  0x4F7A6ACA
  [main_fw]
    Offset:  0x0160
    Size:    8496 bytes
    CRC-32:  0x156FE139    ← updated automatically
  [config]
    Offset:  0x22B0
    Size:    256 bytes
    CRC-32:  0x2683AC5B

The packager recalculates all checksums from the payload data. In practice, reverse engineering the vendor’s CRC algorithm (or identifying it as standard CRC-32) is a necessary step before you can deploy any binary patch. Some vendors use non-standard polynomials, or CRC the payload with a salt, or layer RSA signatures on top. Each additional layer makes patching harder – but the fundamental workflow is the same: patch the code, recalculate the integrity checks, repack.


What We Changed (And What We Didn’t)

Let us be precise about what this patch does:

  Before patch After patch
Routes with ACTIVE only Forwarding branch logged Forwarding branch logged
Routes with BRIDGE only Skipped (deferred log) Skipped (silent)
Routes with ACTIVE + BRIDGE Bridge branch logged Forwarding branch logged
Routes with no flags Skipped (empty) Skipped (empty)

The ACTIVE + BRIDGE path now reaches the forwarding log. The ACTIVE-only path is unchanged. But the BRIDGE-only path changes: before the patch, a BRIDGE-only route incremented skipped and triggered the “bridge routes deferred” log. After the NOP, the branch that drove that path is gone – the route falls through to the ACTIVE check, which fails (flags & 0x01 == 0), and exits via next_entry with no counter and no log.

The “bridge handler” is a log message; there is no real deferred queue behind it. If there were, NOPping the branch would trade one bug for another. In a real project you would test BRIDGE and ACTIVE independently and preserve both behaviors. The NOP is a minimal demonstration of changing the branch outcome, not a complete forwarding fix.


The Bigger Picture

We went from suspicious route strings to a root cause and a binary patch in one post. Let us trace back the full path:

  1. Part 1: Built the flat binary – 8,496 bytes, no symbols, no sections
  2. Part 2: strings found suspicious route messages; xref scanning pointed us at the route subsystem
  3. Part 3: Decompilation revealed the flag-priority problem in iterate_active_routes at 0x80000EFC
  4. Binary patch: NOP at offset 0x0F70 (4 bytes)
  5. CRC update: Repack firmware image with new checksums

That is the real-world firmware RE workflow. Find a clue in the strings. Trace it to a function. Decompile the function. Understand the logic. Patch the binary. Update the checksums.

In the real project, this same workflow – from a warning message in a UART log to a binary patch at a specific address – took weeks. Most of that time was spent building the tools and understanding the firmware’s architecture. The actual bug was four bytes, just like this one.

But here is the thing: we patched four bytes. What if the fix was more complex – restructuring a function, adding new code, changing data structures? You cannot do that with a hex editor. You would need to recompile from source.

And that raises a question: if a decompiler can reconstruct C-like control flow and strings give us names to investigate, can’t we just… compile the result?

In Part 4: “Symbols, Scripts, and other linking nightmares”, we will try. And we will discover why that simple question has a very complicated answer.


References