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?

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.

Follow along: Download the sample firmware and source bundle, then run tar xf advanced-sample.tar.gz and cd more_complex_firmware. The bundle includes firmware.bin and the tools/entropy_scan.py script used below. The binary is the artifact we analyze; the source is there to check our conclusions afterward.


Tables as Search Clues

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.

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.


Step 1: Scan Entropy

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.

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

The bundled tools/entropy_scan.py computes entropy over non-overlapping 256-byte windows:

import math
import sys

def entropy(data):
    """Shannon entropy in bits per byte."""
    freq = [0] * 256
    for b in data:
        freq[b] += 1
    ent = 0.0
    for f in freq:
        if f > 0:
            p = f / len(data)
            ent -= p * math.log2(p)
    return ent

data = open(sys.argv[1], 'rb').read()
window = 256

results = []
for offset in range(0, len(data) - window + 1, window):
    chunk = data[offset:offset + window]
    results.append((offset, entropy(chunk)))

results.sort(key=lambda x: -x[1])

print(f"{'Offset':>10s}  {'Address':>12s}  {'Entropy':>8s}")
print("-" * 36)
for offset, ent in results[:15]:
    addr = 0x80000000 + offset
    print(f"  0x{offset:06X}    0x{addr:08X}    {ent:.4f}")

Running this on our 38 KB firmware:

$ 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

The four highest windows fall between 0x7B00 and 0x80FF. That is a useful place to look for tables. The S-box begins at 0x7F7C, so the window starting at 0x7F00 includes other data; its measured entropy is 7.4609, even though the aligned 256-byte S-box reaches 8.0.

Start with the best-known pattern in that range.


Step 2: Match the AES S-Box

The Rijndael S-box begins with four recognizable bytes:

63 7C 77 7B

These are the substitution values for inputs 0x00 through 0x03. They give us a search pattern, not proof on their own. We will check the whole table after finding a candidate.

Let’s search:

$ xxd firmware.bin | grep "637c 777b"
00007f70: ba77 d626 e169 1463 5521 0c7d 637c 777b  .w.&.i.cU!.}c|w{

One hit at offset 0x7F7C. Let’s look at the full 256-byte table:

$ xxd -s 0x7F7C -l 256 firmware.bin
00007f7c: 637c 777b f26b 6fc5 3001 672b fed7 ab76  c|w{.ko.0.g+...v
00007f8c: ca82 c97d fa59 47f0 add4 a2af 9ca4 72c0  ...}.YG.......r.
00007f9c: b7fd 9326 363f f7cc 34a5 e5f1 71d8 3115  ...&6?..4...q.1.
00007fac: 04c7 23c3 1896 059a 0712 80e2 eb27 b275  ..#..........'.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<..
00007fec: 51a3 408f 929d 38f5 bcb6 da21 10ff f3d2  [email protected]....!....
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>.fH...a5W.....
0000805c: e1f8 9811 69d9 8e94 9b1e 87e9 ce55 28df  ....i........U(.
0000806c: 8ca1 890d bfe6 4268 4199 2d0f b054 bb16  ......BhA.-..T..

The 256 bytes starting at firmware address 0x80007F7C match the Rijndael S-box. The first row is 63 7C 77 7B F2 6B 6F C5 30 01 67 2B FE D7 AB 76, as specified in FIPS 197. Checking all 256 bytes matters more than finding the four-byte prefix.

This implementation also stores an inverse S-box, which can be used for decryption. Its first four bytes are 52 09 6A D5:

$ xxd firmware.bin | grep "5209 6ad5"
00007e7c: 5209 6ad5 3036 a538 bf40 a39e 81f3 d7fb  R.j.06.8.@......

It starts at 0x7E7C, exactly 256 bytes before the forward S-box. This build placed the tables back-to-back.

We can check the relationship without trusting either four-byte prefix:

data = open('firmware.bin', 'rb').read()
inverse = data[0x7E7C:0x7F7C]
forward = data[0x7F7C:0x807C]
assert len(set(forward)) == 256
assert all(inverse[forward[value]] == value for value in range(256))

Without reading a MIPS instruction, we have two full AES table matches at firmware addresses 0x80007E7C (inverse) and 0x80007F7C (forward). That points to AES code with data for both directions. Later we can follow references to confirm how the firmware uses those tables.


Step 3: Locate SHA-256 Constants

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 0x428A2F98. We will check the following values too; one 32-bit word is too short to identify the table reliably.

In a little-endian MIPS binary, 0x428A2F98 is stored as bytes 98 2F 8A 42. Let’s search:

$ xxd firmware.bin | grep "982f 8a42"
00008080: 982f 8a42 9144 3771 cffb c0b5 a5db b5e9  ./.B.D7q........

Offset 0x8080. Let’s verify by checking the first four constants:

import struct

data = open('firmware.bin', 'rb').read()
offset = 0x8080

# Read first 4 SHA-256 round constants
for i in range(4):
    val = struct.unpack('<I', data[offset + i*4 : offset + i*4 + 4])[0]
    print(f"K[{i}] = 0x{val:08X}")
K[0] = 0x428A2F98
K[1] = 0x71374491
K[2] = 0xB5C0FBCF
K[3] = 0xE9B5DBA5

These first four values match the SHA-256 specification. The candidate table occupies 256 bytes (64 constants × 4 bytes each), from 0x8080 to 0x8180. Following references to it will confirm which function uses the values.

SHA-256 also needs initial hash values – eight constants derived from the square roots of the first eight primes. The first is 0x6A09E667. You might expect these to sit right after the K[] table, but let’s search:

target = struct.pack('<II', 0x6A09E667, 0xBB67AE85)
if data.find(target) == -1:
    print("H0[] not found as contiguous table!")
H0[] not found as contiguous table!

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.

If we disassemble the sha256_init function (at 0x80006E28), we can see why:

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

Here the first three constants use lui/ori 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 .rodata.

Table scanning misses constants embedded in instructions. The lui 0x6A09 and ori 0xE667 pair is still searchable, but now we have to read code.

Our crypto map:

Algorithm Table Offset Size Fingerprint
AES Inverse S-box 0x7E7C 256 bytes 52 09 6A D5
AES S-box 0x7F7C 256 bytes 63 7C 77 7B
SHA-256 Round constants K[] 0x8080 256 bytes 0x428A2F98 (LE: 98 2F 8A 42)
SHA-256 Initial hash H0[] (inlined) n/a lui 0x6A09 / ori 0xE667 in code

Step 4: Identify the CRC-32 Table

CRC-32 is common in firmware for integrity checks. This implementation stores a 1024-byte table for the reflected polynomial 0xEDB88320; its second entry is 0x77073096.

$ xxd firmware.bin | grep "9630 0777"
00007a70: 0000 0000 9630 0777 2c61 0eee ba51 0999  .....0.w,a...Q..

Offset 0x7A70. The first four entries confirm it:

[  0] 0x00000000    <- CRC of 0x00
[  1] 0x77073096    <- CRC of 0x01
[  2] 0xEE0E612C    <- CRC of 0x02
[  3] 0x990951BA    <- CRC of 0x03

You can verify the polynomial by computing entry [1] yourself:

def crc32_single(byte, poly=0xEDB88320):
    """Compute one CRC-32 table entry."""
    crc = byte
    for _ in range(8):
        if crc & 1:
            crc = (crc >> 1) ^ poly
        else:
            crc >>= 1
    return crc

assert crc32_single(0) == 0x00000000
assert crc32_single(1) == 0x77073096
assert crc32_single(2) == 0xEE0E612C

The table identifies the reflected polynomial. It does not identify the initial value or final XOR. In this firmware, inspecting crc32_calc shows 0xFFFFFFFF for both, which matches the CRC-32/ISO-HDLC parameter set used by Ethernet, ZIP, and PNG. The table was the lead; the function settles the variant.


Step 5: Find a CRC Without a Table

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

/* C-like reconstruction of the function at 0x80006280 */
uint16_t func_80006280(const void *data, uint32_t length)
{
    const uint8_t *p = (const uint8_t *)data;
    uint16_t crc = 0xFFFF;
    uint32_t i;
    int bit;

    for (i = 0; i < length; i++) {
        crc ^= ((uint16_t)p[i]) << 8;
        for (bit = 0; bit < 8; bit++) {
            if (crc & 0x8000) {
                crc = (crc << 1) ^ 0x8005;
            } else {
                crc <<= 1;
            }
        }
    }
    return crc;
}

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

CRC-16/USB uses the same polynomial but reflects input and output and applies a final XOR of 0xFFFF; its check value is 0xB4C8. You can identify the bit-by-bit CRC loop by looking for:

  1. A loop of 8 iterations (bit-by-bit processing)
  2. A test of the MSB (& 0x8000)
  3. An XOR with a constant after the shift

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.

When you find a bit-by-bit CRC without a table, match the full parameter set against Greg Cook’s catalogue of CRC algorithms, rather than matching the polynomial alone.


Step 6: Map the Tables

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

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

These tables happen to occupy the 0x7A70-0x8180 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.

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.


Step 7: Follow Table References

Finding the tables is step one. Step two is finding which functions use them. In this binary, the AES S-box reference uses a lui/addiu instruction pair to load the table address:

80006470:  lui   $t0, 0x8000          # $t0 = 0x80000000
80006474:  addiu $t0, $t0, 0x7F7C   # $t0 = 0x80007F7C

These instructions load the AES S-box address 0x80007F7C. 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 $crctest CLI command, which also tests SHA-256. The binary contains both implementations, but it does not show either one protecting management traffic.

We’ll save the full cross-referencing analysis for the next posts, where we’ll use Ghidra’s decompiler and radare2’s axt command. For now, Python can calculate the immediate values to look for:

# AES S-box address we want to find references to
target_addr = 0x80007F7C
target_hi = (target_addr >> 16) & 0xFFFF  # 0x8000
target_lo = target_addr & 0xFFFF          # 0x7F7C (positive)

# In MIPS, if the low 16 bits >= 0x8000, lui loads (hi+1)
# and addiu uses a negative offset
if target_lo >= 0x8000:
    lui_val = (target_hi + 1) & 0xFFFF
    addiu_val = target_lo - 0x10000  # Negative
else:
    lui_val = target_hi
    addiu_val = target_lo

print(f"Looking for: lui $reg, 0x{lui_val:04X}")
print(f"             addiu $reg, $reg, {addiu_val} (0x{addiu_val & 0xFFFF:04X})")
print(f"Target address: 0x{target_addr:08X}")
Looking for: lui $reg, 0x8000
             addiu $reg, $reg, 32636 (0x7F7C)
Target address: 0x80007F7C

Quick Reference: Algorithm Table Signatures

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.

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

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 0x428A2F98 becomes 98 2F 8A 42.


Exercises

  1. Verify the S-box: 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 0x7F7C in the firmware.

  2. Find the Rcon: This build stores the AES key-expansion constants as bytes, with a leading 00. Search for 00 01 02 04 08 10 20 40 80 1B 36 from the firmware directory:

   python3 -c '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"))))'
   

Confirm that it prints 0x7e70. One padding byte separates the 11-byte match from the inverse S-box.

  1. Entropy histogram: 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.

  2. Identify the CRC variant: 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?


What’s Next

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.

In the next post, Ghidra shows *(param_1 + 0x20) 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.


This is Part 1 of “Crypto Tables, Struct Ghosts, and Other Secrets Hidden in Firmware.” The sample bundle contains the exact binary, source, and entropy scanner used here.

Next: Part 2 — Offsets, Embedded Nodes, and Other Phantoms of Lost Structs.