Scapy Uitbreiden voor Hardware Reverse Engineering

Overzicht

Wat veel gebruikers (mijzelf inbegrepen) vaak vergeten, is dat Scapy ook een algemeen framework is voor binaire protocollen. Het gedetailleerde systeem voor het structureren, maken en ontleden van pakketten is onafhankelijk van de bron van de bytes, of deze nu via Ethernet, CAN of een ander netwerkprotocol binnenkomen. In dit artikel demonstreren we hoe Scapy kan worden gebruikt om SPI-verkeer van een logic analyzer te ontleden en een firmware-image te reconstrueren op basis van deze captures.

Achtergrondinformatie

Onlangs kregen we de opdracht om een embedded apparaat te analyseren waarvan we slechts één exemplaar hadden. In dit vakgebied is dat een uitdaging; met slechts één apparaat wil je het risico niet lopen het te beschadigen door IC's te verwijderen, of erger nog, een vorm van tamper-protectie te activeren. Hoewel tamper-protectie geen geïdentificeerd probleem was op dit apparaat, gebruikten we dit als aanleiding om nieuwe tooling te ontwikkelen.

Vanwege de aard van de opdracht kunnen we geen teardown van het apparaat delen, maar we kunnen wel het type SPI-flash vermelden dat werd gebruikt:

OnderdeelType / RolDatasheet
1Winbond W25Q128JVSPI NOR flash, 16 MiB[Datasheet]

Zoals gebruikelijk bij embedded assessments, bleken de eerste pogingen om UART- en debug-interfaces te identificeren vruchteloos. De ontwikkelaars hadden diagnostische output effectief uitgeschakeld en debug-interfaces verwijderd. Pogingen om de flash in-circuit uit te lezen, liepen tegen klassieke problemen aan.

Dit was een black-box analyse en er was geen firmware geleverd voor dit doelwit. Hoewel we in het verleden vaker eenmalige scripts hebben geschreven voor het reconstrueren van firmware uit SPI-captures, wilden we nu herbruikbare tooling ontwikkelen die we in de toekomst kunnen delen en verbeteren.

Voordat we verder gaan, volgt een korte opfrisser over hoe SPI werkt als protocol en hoe dit wordt gebruikt om met SPI-flash-apparaten te communiceren.

SPI: Een Korte Opfrisser

SPI is een full-duplex bus met vier draden die overal in embedded systemen voorkomt. Voor een flash-chip zijn de volgende vier kernsignalen van belang:

SignaalRol
CLKClock, aangestuurd door de host (controller)
CS / CS#Chip select, actief laag - bakent een enkele transactie af
MOSI (DI)Controller → flash (commando's, adressen, schrijfdata)
MISO (DO)Flash → controller (leesdata, status)

Een belangrijk punt is dat de chip select (CS#) een transactie afbakent. Elke keer dat CS# laag gaat, begint een nieuw commando; wanneer het weer hoog gaat, is het commando voltooid. Binnen dat venster klokt de host een opcode uit, optioneel een adres, optioneel enkele dummy cycles, en verzendt of ontvangt vervolgens data. Omdat SPI full-duplex is, worden MOSI en MISO gelijktijdig geklokt. Tijdens een leesoperatie verzendt de host dus "don't-care" bytes op MOSI, terwijl de flash echte data verzendt op MISO.

SPI wordt niet alleen voor flash gebruikt; dezelfde bus transporteert ook TPM-verkeer, sensordata, display-commando's en diverse propriëtaire protocollen. Door Scapy te gebruiken voor de analyse van dit verkeer, is het eenvoudig om applicatielaag-parsing in Python toe te voegen.

SPI-verkeer in de Praktijk

Het onderdeel waar we ons op richten gebruikt een SOIC8-footprint. Met een SOIC8-clip en de Saleae Logic-software kunnen we de signalen die naar en van deze chip gaan tijdens het opstarten vastleggen.

Let op: Dit werkt niet altijd; het toevoegen van een clip en extra jumperdraden kan bij hoge snelheden leiden tot problemen met de signaalintegriteit.

Wanneer we inzoomen op de signalen, kunnen we het volgende vaststellen:

  1. De lijn verbonden met D1 is periodiek met een frequentie van 16 MHz. Dit is onze clock-lijn (CLK).
  2. Alle activiteit begint wanneer D2 laag gaat. Dit is de CS-lijn.
  3. D3 en D4 worden kort daarna actief. Al het verkeer stopt zodra D2 weer hoog gaat.

In de capture zien we dat D3 als eerste iets verzendt, gevolgd door data op D4. Er is enige ruis zichtbaar op D3 wanneer D4 actief wordt, maar bij nader inzien zijn dit pulsen die smaller zijn dan één periode van onze clock. Dit betekent dat ze niet gesampled kunnen worden bij de huidige clocksnelheid en dus als ruis kunnen worden beschouwd.

Door de Saleae SPI-analyzer te gebruiken, zien we dat het verkeer is gedecodeerd. Om dit te interpreteren, kijken we naar de commando's in de datasheet van het SPI-apparaat:

OpcodeNaam
0x9FJEDEC_ID (Read ID)
0x03READ
0x0BFAST_READ
0x6BFASTREADQUAD_OUT
0x05RDSR1 (Read Status Register-1)
0x06 / 0x02WREN / PAGE_PROGRAM
0x20SECTOR_ERASE (4 KiB)
0xD8BLOCK_ERASE (64 KiB)

De eerste byte op D3 is 0x03, wat wijst op een leescommando. Een READ-transactie bestaat uit het commando, het adres en vervolgens de data. In onze capture zien we een leesactie op adres 0, waarbij de respons-bytes 03 00 00 08 zijn. De lengte van de leesactie wordt bepaald door het moment waarop de CS-lijn weer hoog gaat.

We kunnen de gedecodeerde bytes uit de capture exporteren naar een CSV-bestand. De exporter maakt onderscheid tussen enable (CS# gaat laag), result (één byte is geklokt) en disable (CS# gaat hoog), wat essentieel is om individuele SPI-transacties te scheiden.

Een Korte Introductie tot Scapy

Scapy beschrijft een protocol als een Packet-subklasse met een fieldsdesc lijst. Elke entry is een veld: een getypeerd, benoemd stukje bytes dat weet hoe het zichzelf moet ontleden uit een buffer en weer moet opbouwen. Pakketten kunnen in lagen worden gestapeld met de / operator, en bindlayers() vertelt Scapy hoe de volgende laag automatisch moet worden gekozen tijdens het ontleden.

Voor de analyse van SPI-verkeer gebruiken we de volgende velden:

  • ByteEnumField: een byte-veld met een dictionary voor naamgeving (bijv. 0x03 wordt READ).
  • ThreeBytesField: een 24-bit integer voor het SPI-flash-adres.
  • ConditionalField: omsluit een ander veld en voegt dit alleen toe wanneer aan een bepaalde voorwaarde is voldaan.
  • FieldLenField / StrLenField: een lengteveld gekoppeld aan een veld met variabele lengte.
  • FlagsField: een bitveld met benoemde bits, ideaal voor statusregisters.

SPI-Flash Modelleren in Scapy

We definiëren drie lagen:

  1. Een basis SPI-laag die registreert welke chip-select lijn bij een transactie hoort.
  2. Een SPIFlashCmd-laag voor het verzoek (opcode, optioneel adres, optioneel dummy byte).
  3. Respons-lagen voor de terugkomende data: platte leesdata en een gedecodeerd statusregister.
from scapy.packet import Packet, bind_layers
from scapy.fields import (
    ByteEnumField, ByteField, ThreeBytesField,
    ConditionalField, FieldLenField, StrLenField, FlagsField,
)

class SPI(Packet):
    """Basislaag — aan welke chip-select lijn behoort deze transactie?"""
    name = "SPI"
    fields_desc = [
        ByteEnumField("cs", 0, {0: "flash"}),
    ]

class SPIFlashCmd(Packet):
    """Een enkel SPI NOR flash commando (de MOSI-zijde van een transactie)."""
    name = "SPIFlashCmd"
    COMMANDS = {
        0x03: "READ",
        0x0B: "FAST_READ",
        0x6B: "FAST_READ_QUAD_OUT",
        0x02: "PAGE_PROGRAM",
        0x05: "RDSR1",
        0x35: "RDSR2",
        0x06: "WREN",
        0x04: "WRDI",
        0x20: "SECTOR_ERASE",
        0xD8: "BLOCK_ERASE",
        0x9F: "JEDEC_ID",
    }
    CMD_READ      = 0x03
    CMD_FAST_READ = 0x0B
    CMD_RDSR1     = 0x05
    CMD_HAS_ADDR  = {0x03, 0x0B, 0x6B, 0x02, 0x20, 0xD8}
    CMD_HAS_DUMMY = {0x0B, 0x6B}

    fields_desc = [
        ByteEnumField("cmd", CMD_READ, COMMANDS),
        ConditionalField(
            ThreeBytesField("addr", 0),
            lambda p: p.cmd in p.CMD_HAS_ADDR,
        ),
        ConditionalField(
            ByteField("dummy", 0),
            lambda p: p.cmd in p.CMD_HAS_DUMMY,
        ),
    ]

bind_layers(SPI, SPIFlashCmd, cs=0)

Voor de data die terugkomt, is een leesrespons simpelweg een blob met een lengteprefix:

class SPIFlashReadResp(Packet):
    name = "SPIFlashReadResp"
    fields_desc = [
        FieldLenField("dlen", None, length_of="data", fmt="I"),
        StrLenField("data", b"", length_from=lambda p: p.dlen),
    ]

Daarnaast kunnen we het statusregister (RDSR1, 0x05) analyseren met een FlagsField:

class SPIFlashStatusResp(Packet):
    name = "SPIFlashStatusResp"
    fields_desc = [
        FlagsField("sr", 0, 8, {
            0x01: "BUSY",   # erase/write in progress
            0x02: "WEL",    # write enable latch
            0x04: "BP0",
            0x08: "BP1",
            0x10: "BP2",
            0x20: "TB",     # top/bottom protect
            0x40: "SEC",    # sector protect
            0x80: "SRP0",   # status register protect 0
        })
    ]

Hierdoor wordt een statusbyte van 0x02 getoond als <SPIFlashStatusResp sr=WEL> in plaats van een magisch getal.

Van Capture naar Bytes

Een transactie is alles tussen een enable en de volgende disable in de CSV. We kunnen deze transacties als volgt parsen:

import csv

def parse_transactions(path):
    """Yield {'mosi': bytes, 'miso': bytes} voor elke CS-afgebakende transactie."""
    current = None
    with open(path, newline="") as f:
        for row in csv.DictReader(f):
            ttype = (row["type"] or "").strip('"')
            if ttype == "enable":
                current = {"mosi": bytearray(), "miso": bytearray()}
            elif ttype == "result" and current is not None:
                mosi = row["mosi"] or ""
                miso = row["miso"] or ""
                current["mosi"].append(int(mosi, 16) if mosi.startswith("0x") else 0)
                current["miso"].append(int(miso, 16) if miso.startswith("0x") else 0)
            elif ttype == "disable" and current is not None:
                if current["mosi"]:
                    yield {"mosi": bytes(current["mosi"]),
                           "miso": bytes(current["miso"])}
                current = None

We hoeven de MOSI-bytes niet handmatig te analyseren; we geven ze direct aan SPIFlashCmd en laten de ConditionalFields bepalen of er een adres of dummy byte aanwezig is.

import struct

def transaction_to_packets(tx):
    mosi, miso = tx["mosi"], tx["miso"]
    try:
        req = SPIFlashCmd(mosi)
    except struct.error:
        return None, None

    hdr_len = len(req.self_build())
    pkt = SPI(cs=0) / req
    resp = None

    if req.cmd in (SPIFlashCmd.CMD_READ, SPIFlashCmd.CMD_FAST_READ):
        data = miso[hdr_len:]
        if data:
            resp = SPIFlashReadResp(data=data)
    elif req.cmd == SPIFlashCmd.CMD_RDSR1 and len(miso) > hdr_len:
        resp = SPIFlashStatusResp(sr=miso[hdr_len])

    return pkt, resp

Het apparaat naar flash schrijven detecteren

Tijdens het analyseren van de capture merkten we dat er niet alleen leesacties waren, maar ook meer dan 2.000 PAGEPROGRAM commando's en een BLOCKERASE. We kunnen een helper schrijven om deze transacties te beschrijven:

from itertools import groupby

def describe(pkt, resp):
    cmd = pkt[SPIFlashCmd]
    line = cmd.sprintf("%cmd%")
    if cmd.cmd in SPIFlashCmd.CMD_HAS_ADDR:
        line += f" addr=0x{cmd.addr:06x}"
    if cmd.cmd == 0x02: # PAGE_PROGRAM
        line += f" ({len(cmd.payload)} bytes)"
    if isinstance(resp, SPIFlashStatusResp):
        line += " sr=" + resp.sprintf("%sr%")
    return line

def trace(txs):
    lines = (describe(*transaction_to_packets(tx)) for tx in txs)
    for line, group in groupby(lines):
        n = sum(1 for _ in group)
        print(line if n == 1 else f"{line}  (x{n:,})")

Een trace van een erase- en program-sequentie ziet er als volgt uit:

  • RDSR1 sr=BP0+TB (Chip is idle)
  • WREN (Write Enable Latch wordt gezet)
  • RDSR1 sr=WEL+BP0+TB (WEL is nu actief)
  • BLOCK_ERASE addr=0x730000 (64 KiB block wordt gewist)
  • RDSR1 sr=BUSY+WEL+BP0+TB (x210,836) (Controller poll't tot BUSY verdwijnt)
  • RDSR1 sr=BP0+TB (Erase voltooid)
  • WREN
  • PAGE_PROGRAM addr=0x730000 (32 bytes)

Het apparaat herhaalt dit proces 2.048 keer, 32 bytes per keer, om het volledige 64 KiB block vanaf 0x730000 te herschrijven.

Het Flash-image reconstrueren

Elke READ-transactie vertelt ons welke bytes op welk adres stonden. We kunnen een image reconstrueren door een bytearray te initialiseren met 0xFF en deze in te vullen met de gecapturede data.

def reconstruct(path, flash_size=0x1000000, fill=0xFF):
    image = bytearray([fill]) * flash_size
    coverage = bytearray(flash_size)
    for tx in parse_transactions(path):
        pkt, resp = transaction_to_packets(tx)
        if not isinstance(resp, SPIFlashReadResp):
            continue
        addr = pkt[SPIFlashCmd].addr
        data = resp.data
        end = min(addr + len(data), flash_size)
        image[addr:end] = data[:end - addr]
        coverage[addr:end] = b"\x01" * (end - addr)

    covered = sum(coverage)
    pct = 100 * covered / flash_size
    print(f"reconstructed {covered:,} / {flash_size:,} bytes ({pct:.1f}%)")
    return bytes(image), coverage

Reconstructie in de Praktijk

Na het uitvoeren van dit script konden we de bootloader en kernel-image reconstrueren via binwalk, maar het root-bestandssysteem ontbrak. Dit kan twee oorzaken hebben: we hebben niet de volledige boot-sequentie gecaptureerd, of de SPI-flash wordt later in QSPI-modus aangesproken.

QSPI-Analyse

Quad-SPI (QSPI) verhoogt de leessnelheid door data parallel over vier lijnen (IO0-IO3) uit te sturen. In single-lane modus zijn alleen DI (IO0) en DO (IO1) actief; in quad-modus worden alle vier de pinnen bidirectionele datalijnen.

Een duidelijk teken van QSPI is wanneer de voormalige /WP en /HOLD pinnen plotseling verkeer gaan dragen en de clockfrequentie stijgt (in dit geval van ~16 MHz naar ~50 MHz). De kernel herconfigureert de flash-controller om de doorvoer met ongeveer 12x te verhogen.

De gebruikte leescommando is 0x6B (Fast Read Quad Output), een zogenaamd "1-1-4" commando: de opcode en het adres gaan over IO0, gevolgd door 8 dummy cycles, waarna de data over alle vier de lanes wordt verzonden.

Omdat de standaard SPI-analyzer van Saleae geen QSPI ondersteunt, gebruikten we de QSPI-Analyzer plugin. De export van deze analyzer heeft een andere structuur, maar omdat ons Scapy-model de logische transactie modelleert en niet het aantal draden, konden we dezelfde reconstructie-routine gebruiken.

Door de single-lane capture en de quad capture samen te voegen (met dd of de --merge functie in onze tool), kregen we een volledig beeld:

DECIMAL                            HEXADECIMAL                        DESCRIPTION
---------------------------------------------------------------------------------------------------
39192                              0x9918                             CRC32 polynomial table
40288                              0x9D60                             gzip compressed data (kernel)
337944                             0x52818                            LZMA compressed data
2949120                            0x2D0000                           SquashFS file system
14548992                           0xDE0000                           JFFS2 filesystem

Uiteindelijk konden we de SquashFS-image extraheren en de volledige mappenstructuur (bin, etc, home, root, usr, var, web) herstellen, enkel op basis van SPI-captures.

Conclusie

We hebben gedemonstreerd hoe een firmware-image kan worden gereconstrueerd uit een SPI-traffic capture. Deze methode ondersteunt zowel standaard als Quad SPI en maakt het mogelijk om flash-inhoud te reconstrueren zonder de chip fysiek te verwijderen. Hoewel we alleen kunnen reconstrueren wat het apparaat daadwerkelijk leest tijdens de capture, is dit een uitstekend startpunt voor initiële reverse engineering en triage.