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:
| Onderdeel | Type / Rol | Datasheet | |
|---|---|---|---|
| 1 | Winbond W25Q128JV | SPI 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:
| Signaal | Rol |
|---|---|
| CLK | Clock, 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:
- De lijn verbonden met D1 is periodiek met een frequentie van 16 MHz. Dit is onze clock-lijn (CLK).
- Alle activiteit begint wanneer D2 laag gaat. Dit is de CS-lijn.
- 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:
| Opcode | Naam |
|---|---|
| 0x9F | JEDEC_ID (Read ID) |
| 0x03 | READ |
| 0x0B | FAST_READ |
| 0x6B | FASTREADQUAD_OUT |
| 0x05 | RDSR1 (Read Status Register-1) |
| 0x06 / 0x02 | WREN / PAGE_PROGRAM |
| 0x20 | SECTOR_ERASE (4 KiB) |
| 0xD8 | BLOCK_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.0x03wordtREAD).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:
- Een basis
SPI-laag die registreert welke chip-select lijn bij een transactie hoort. - Een
SPIFlashCmd-laag voor het verzoek (opcode, optioneel adres, optioneel dummy byte). - 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)WRENPAGE_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.
Groetjes,