From 9b4bfe9a75ace16e6170fdd8e7d2c197f44616bf Mon Sep 17 00:00:00 2001 From: Matt Brown Date: Sat, 17 Oct 2026 06:15:47 -0400 Subject: [PATCH] sxverity: identify the SpaceX dm-verity container (#55) moria had no signature for the sxverity container, so on images that use it it named only the inner romfs payload and left the 4 KiB structured header as an unnamed gap (reported neither as a finding nor, by default, as an unidentified region). sxverity is SpaceX's wrapper around the Linux dm-verity feature, used on Starlink User Terminal firmware. The container is a 4 KiB preamble of four 1024-byte records (the ASCII magic "sxverity" starts each one at 0x000 / 0x400 / 0x800 / 0xC00), each carrying a dm-verity root hash, a public key, and an ed25519 signature; the protected payload (a big-endian romfs) follows at 0x1000. Documented by Quarkslab, "Diving into Starlink's User Terminal Firmware". Add an identify-only signature + validator (like verity/luks; the romfs payload is already extractable on its own). The validator confirms the four records and the romfs payload, then sizes the finding to the 4 KiB record table, so the header is named and the romfs surfaces as its own finding. It rejects a stray "sxverity" string that lacks the structure, which matters because the romfs volume name is itself "sxverity". Reaches the consistent tier (magic + full record table + payload cross-check). The 8-byte ASCII magic plus the required four-record-and-payload structure is low false-positive: a sweep over a firmware corpus of 1127 files produced two sxverity findings, both the real Starlink partitions, and nothing else. On the real partitions the summary goes from "romfs" to "sxverity, romfs", the 4 KiB header is a consistent-tier finding and no longer an unnamed gap, and the romfs is still reported and extractable at 0x1000. Note on the format: the record's +0x0c and +0x14 fields are not fixed constants (across the two sample partitions +0x0c is a size that ranges from 2 KiB to ~23 MB and +0x14 varies, likely verity block counts), so the validator keys on the stable structure rather than those fields. The record body carries the root hash / public key / signature, but their slots are not cleanly aligned, so they are not surfaced as byte-level evidence here. Tests: a synthetic container fixture + MANIFEST entry (satisfies the coverage gate), and tests/test_sxverity.py (identify at offset 0 sized to the record table, the romfs payload at 0x1000, embedded-at-offset, false-positive guards for a lone magic / missing payload / single record, and a round-trip against the real Starlink partitions that self-skips when they are absent). Full suite green; ASan/UBSan clean on the real partitions and the fixtures. --- CMakeLists.txt | 2 + signatures/sxverity.toml | 26 +++++++ src/validators/registry.cpp | 2 + src/validators/sxverity.cpp | 51 +++++++++++++ src/validators/sxverity.hpp | 12 +++ tests/gen_samples.py | 14 ++++ tests/run.sh | 2 + tests/test_sxverity.py | 142 ++++++++++++++++++++++++++++++++++++ 8 files changed, 251 insertions(+) create mode 100644 signatures/sxverity.toml create mode 100644 src/validators/sxverity.cpp create mode 100644 src/validators/sxverity.hpp create mode 100644 tests/test_sxverity.py diff --git a/CMakeLists.txt b/CMakeLists.txt index d057434..5ef5547 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -126,6 +126,7 @@ add_executable(moria src/validators/jpeg.cpp src/validators/png.cpp src/validators/gif.cpp + src/validators/sxverity.cpp src/validators/otra.cpp src/validators/rae_rfp.cpp src/validators/wince_rom.cpp @@ -257,6 +258,7 @@ if(Python3_Interpreter_FOUND) test_lzma test_jzlzma test_image_size + test_sxverity test_rae_rfp test_otra test_wince_rom diff --git a/signatures/sxverity.toml b/signatures/sxverity.toml new file mode 100644 index 0000000..82d97d5 --- /dev/null +++ b/signatures/sxverity.toml @@ -0,0 +1,26 @@ +name = "sxverity" +category = "container" + +# SpaceX "sxverity" container: a 4 KiB preamble of four 1024-byte records (the +# magic "sxverity" starts each record, at 0x000 / 0x400 / 0x800 / 0xC00), each +# carrying a dm-verity root hash, an embedded public key, and an ed25519 signature +# over the preceding fields; in the images observed the four records are +# byte-identical. The protected payload (a big-endian romfs) follows at 0x1000. +# It wraps the Linux dm-verity feature on Starlink User Terminal firmware +# (persistent partitions, the sx runtime partition, version_info, update bundles). +# The validator confirms the four records and the romfs payload, so the 8-byte +# ASCII magic reaches the consistent tier. Identify only: the finding owns the +# 4 KiB record table and the romfs payload surfaces as its own finding at 0x1000. +validator = "sxverity" +confidence = "consistent" + +[[magic]] +ascii = "sxverity" +endian = "little" + +[doc] +description = "sxverity container (SpaceX dm-verity wrapper): a 4 KiB signed record table over a romfs payload, used on Starlink User Terminal firmware." +references = [ + { title = "Diving into Starlink's User Terminal Firmware (Quarkslab)", url = "https://blog.quarkslab.com/starlink.html" }, + { title = "dm-verity (kernel docs)", url = "https://www.kernel.org/doc/html/latest/admin-guide/device-mapper/verity.html" }, +] diff --git a/src/validators/registry.cpp b/src/validators/registry.cpp index 1eca51f..972ae33 100644 --- a/src/validators/registry.cpp +++ b/src/validators/registry.cpp @@ -24,6 +24,7 @@ #include "validators/rae_rfp.hpp" #include "validators/spiffs.hpp" #include "validators/squashfs.hpp" +#include "validators/sxverity.hpp" #include "validators/tar.hpp" #include "validators/uboot_env.hpp" #include "validators/ubi.hpp" @@ -56,6 +57,7 @@ Validator find_validator(const std::string& name) { if (name == "tar") return validate_tar; if (name == "zip") return validate_zip; if (name == "jpeg") return validate_jpeg; + if (name == "sxverity") return validate_sxverity; if (name == "png") return validate_png; if (name == "gif") return validate_gif; if (name == "rae_rfp") return validate_rae_rfp; diff --git a/src/validators/sxverity.cpp b/src/validators/sxverity.cpp new file mode 100644 index 0000000..8a8d0c8 --- /dev/null +++ b/src/validators/sxverity.cpp @@ -0,0 +1,51 @@ +// sxverity.cpp — SpaceX "sxverity" dm-verity container (Starlink UT firmware). #55 +// +// On-disk layout (per Quarkslab, "Diving into Starlink's User Terminal Firmware", +// and confirmed against two sample partitions): +// 0x000 "sxverity" magic, record 0 +// 0x400 "sxverity" magic, record 1 } four 1024-byte records, byte-identical +// 0x800 "sxverity" magic, record 2 } in the images observed; each holds a +// 0xC00 "sxverity" magic, record 3 } verity root hash, a public key, and an +// ed25519 signature over the record. +// 0x1000 "-rom1fs-" big-endian romfs payload (volume name also "sxverity"). +// We confirm the four records and the payload, then size the finding to the 4 KiB +// record table so the preamble is named and the romfs is reported on its own. +#include "validators/sxverity.hpp" + +#include +#include + +namespace ft { + +namespace { +constexpr size_t kRecord = 1024; // record stride +constexpr size_t kRecords = 4; // four records = the 4 KiB table +constexpr size_t kPreamble = kRecord * kRecords; // 0x1000, payload starts here +constexpr uint8_t kMagic[8] = {'s', 'x', 'v', 'e', 'r', 'i', 't', 'y'}; +constexpr uint8_t kRomfs[8] = {'-', 'r', 'o', 'm', '1', 'f', 's', '-'}; + +bool magic_at(const Reader& r, size_t off, const uint8_t (&m)[8]) { + auto b = r.bytes(off, 8); + return b && std::memcmp(b->data(), m, 8) == 0; +} +} // namespace + +bool validate_sxverity(ValidatorCtx& ctx) { + const Reader& r = ctx.reader; + const size_t base = ctx.offset; // the matched magic (record 0) + + // Require the full four-record table and the romfs payload right after it. + // This rejects a stray "sxverity" string that lacks the surrounding structure + // (the romfs volume name is itself "sxverity", and the magic recurs at each + // record boundary, so without this a single container yields spurious hits). + for (size_t i = 0; i < kRecords; ++i) + if (!magic_at(r, base + i * kRecord, kMagic)) return false; + if (!magic_at(r, base + kPreamble, kRomfs)) return false; + + ctx.out.size = kPreamble; // own the 4 KiB record table; romfs is its own finding + ctx.out.set_confidence(Confidence::Consistent, + "sxverity: four-record signed table over a romfs payload"); + return true; +} + +} // namespace ft diff --git a/src/validators/sxverity.hpp b/src/validators/sxverity.hpp new file mode 100644 index 0000000..9198c9d --- /dev/null +++ b/src/validators/sxverity.hpp @@ -0,0 +1,12 @@ +#pragma once +#include "signature.hpp" +namespace ft { +// Confirm a SpaceX "sxverity" dm-verity container: four 1024-byte records whose +// magic "sxverity" starts each 0x400 boundary through 0xC00, followed by a +// big-endian romfs payload ("-rom1fs-") at 0x1000. Sizes the finding to the 4 KiB +// record table, so the preamble is named and the romfs payload surfaces as its +// own finding. Reaches `consistent` (magic + full record table + payload +// cross-check); rejects a stray "sxverity" string that lacks the structure (the +// romfs volume name is itself "sxverity"). See issue #55. +bool validate_sxverity(ValidatorCtx& ctx); +} // namespace ft diff --git a/tests/gen_samples.py b/tests/gen_samples.py index 02615c8..975d198 100755 --- a/tests/gen_samples.py +++ b/tests/gen_samples.py @@ -181,6 +181,19 @@ def romfs(): return bytes(b) +def sxverity(): + # SpaceX sxverity container: four 1024-byte records each starting with the + # "sxverity" magic (a size field at +0x0c and pseudo hash/key/sig bytes so the + # record is not all-zero), then the romfs payload at 0x1000. The validator + # requires all four records + the romfs payload. See issue #55. + rec = bytearray(1024) + rec[0:8] = b"sxverity" + struct.pack_into(" a `sxverity` finding at offset 0 covering the 4 KiB + record table (tier consistent), with the romfs surfaced as its own finding + at 0x1000, so the summary names the container. + 2. The container found at a nonzero offset (embedded in a larger flash image). + 3. FP guards: a lone "sxverity" string without the four-record + romfs + structure is not reported (the romfs volume name is itself "sxverity"). + 4. Optional: real sxverity images, if a directory of them is pointed to via + $MORIA_SXVERITY_SAMPLES (self-skips otherwise, so CI stays self-contained + with no sample path baked in). Any file in that dir starting with the magic + is checked, so no specific filename is assumed. + +Run: python3 tests/test_sxverity.py +""" +import json +import os +import struct +import subprocess +import sys +import tempfile + +HERE = os.path.dirname(os.path.abspath(__file__)) +MORIA = sys.argv[1] if len(sys.argv) > 1 else os.path.join(HERE, "..", "build", "moria") + + +def moria_findings(data: bytes): + with tempfile.NamedTemporaryFile(suffix=".bin") as f: + f.write(data) + f.flush() + r = subprocess.run([MORIA, "-j", f.name], capture_output=True, timeout=120) + return json.loads(r.stdout).get("findings", []) + + +def findings_of(data: bytes, typ: str): + return [x for x in moria_findings(data) if x["type"] == typ] + + +def romfs(size: int = 256) -> bytes: + b = bytearray(size) + b[0:8] = b"-rom1fs-" + struct.pack_into(">I", b, 8, size) # full_size, big-endian + b[16:24] = b"sxverity" # volume name (as on the real device) + return bytes(b) + + +def container(payload: bytes = None) -> bytes: + if payload is None: + payload = romfs() + rec = bytearray(1024) + rec[0:8] = b"sxverity" + struct.pack_into("