Skip to content

jeremydk/crossboot

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

16 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

crossboot

Custom second-stage bootloader for the Xteink X3/X4 e-readers (ESP32-C3). Provides SD-card recovery as a standalone path that does not depend on any app, useful for devices whose vendor has burned DIS_USB_SERIAL_JTAG.

Hold the configured page button + POWER for 500 ms at boot: the bootloader reads UPDATE.BIN from the FAT/exFAT root of an inserted SD card, validates it, and flashes it to the inactive OTA slot. The strap button is build-time configurable via CONFIG_CP_STRAP_BUTTON_PREV / _NEXT (default: NEXT). Without the strap, behavior is identical to the IDF stock bootloader.

If a PARTS.BIN is also present at the FAT/exFAT root, recovery installs it to 0x8000 first (when it differs from the live PT). Pair it with a matching UPDATE.BIN to recover devices whose third-party firmware overwrote the partition layout.

X3 dry-run QR rendered on e-paper X4 dry-run QR rendered on e-paper

X3 (left) and X4 (right) at the end of a dry-run recovery. The QR encodes CROSSBOOT/DRY-RUN — phones scan it as plain text.
▶ Watch a full recovery + boot

By the numbers

  • Hardware-validated end-to-end on X3 + X4 (2026-05)
  • 32 KB bootloader budget · 4 KB headroom · 240 B max stack frame
  • CBMC bounded model check proves no UB on cp_part_validate's bounds / uint32-wrap / range logic, cp_image_validate's entry guard, and cp_fat_mount's parse path
  • libFuzzer (ASAN+UBSAN+MSAN) on the same three parsers; zero crashes
  • 4 static analyzers + 6 unit-test suites running clean (including real-SHA finale, fault-injection atomicity)

Build / flash

ESP-IDF 5.5.2. Recipes in Justfile.

just backup       # save vendor bootloader (do this once, before anything)
just build-debug  # debug: console + log lines, recovery defaults to dry-run
just build        # production: silent, real flash writes
just flash-debug  # flash debug
just flash-prod   # flash production
just monitor      # USJ monitor; `just watch` for aggressive reconnect

Building from source, running host-side tests, static analysis: see DEV_SETUP.md.

Layout

bootloader/bootloader_components/main/   our component
  bootloader_start.c                     entry, strap gate, recovery dispatch
  cp_strap.{c,h}                         POWER + ADC ladder strap detection
  cp_recovery.{c,h}                      SD -> validate -> flash -> otadata
  cp_image.{c,h}                         streaming ESP image validator
  cp_sd.{c,h}                            register-level SD-SPI
  cp_fat.{c,h}                           minimal read-only FAT16/32/exFAT
  cp_ota.{c,h}                           inactive-slot pick + otadata commit
  cp_part.{c,h}                          optional PARTS.BIN install at 0x8000
  cp_epd.{c,h}                           SSD1677 (X4) + UC8xxx-class (X3)
  cp_spi.{c,h}                           SPI2 primitives, shared by sd/epd
  cp_i2c.{c,h}                           bit-bang I2C probe (X3 detection)
  ld/bootloader_main_relocate.ld         relocates libmain.a to iram_loader_seg
tests/                                   host-side fuzz, unit tests, CBMC, lint
shared/partitions.csv                    matches stock pio + crosspoint-reader; sourced from crosspoint

Pitfalls

Things not obvious from reading the code.

CP_DEBUG_RECOVERY_DRY_RUN defaults to y in Kconfig. Production sdkconfig.defaults has to explicitly disable it. Without that override, fresh sdkconfig regeneration leaks the dry-run default into prod and recovery never writes flash.

cp_recovery_run is noreturn. Every exit hits bootloader_reset(). By the time recovery runs we've reconfigured SPI2 (cp_sd_init and cp_epd_init both reset the peripheral); falling through to bootloader_utility_load_boot_image from that state hangs. Side effect: a strap-held boot with no UPDATE.BIN reset-loops until the user releases POWER.

EPD polarity is per-device. X4 byte 0xFF = white pixel. X3 byte 0x00 = white pixel (SDK inverts before sending). cp_epd exposes logical WHITE/BLACK and translates.

Prod isn't fully silent during a recovery. cp_recovery.c's [cp] recovery: ... lines are unconditional esp_rom_printf (not behind any CP_DEBUG_*), so they fire in every build — but only on a recovery pass, which is strap-gated, so a normal boot stays silent. Deliberate: recovery is the one flash-mutating op and should leave a trace if a serial cable is attached. The [cp] gate: / [cp] strap: lines, by contrast, are CP_DEBUG_STRAP-gated (debug builds only).

RVC compressed-jump errors are non-monotone. Don't try to shrink your code to make R_RISCV_RVC_JUMP truncated to fit go away. Two link-time fixes pin it open: -Wl,--no-relax in CMakeLists and ld/bootloader_main_relocate.ld (INSERT BEFORE .iram.text) routing libmain.a:cp_*.* into iram_loader_seg (28 KB) instead of the 10 KB iram_seg. Both must stay.

I2C probe pins are dual-use on X4. GPIO20 is U0RXD on X4; GPIO0 is the battery voltage divider. The probe drives them LOW briefly during start/stop and releases via internal pullups; nothing ACKs on X4 so we fall through cleanly.

Strap intent re-check matters for deep-sleep wake. POWER is read once before bootloader_init and again ~few hundred ms later. A brief wake tap is released by the second read; a deliberate recovery hold is still down. Without the re-check, every wake would burn ~1 sec on strap detection.

Bootloader budget is 32 KB. Region is fixed at 0x0000..0x8000. ~4 KB headroom on each build today.

X3 status

Both X4 and X3 are hardware-validated end-to-end (X3 as of 2026-05): the full recovery path runs on real X3 hardware — board detect (cp_board_detect I2C probe), the GPIO13 SD power gate, cp_epd_init_x3 + cp_epd_refresh_x3 (the progress bar and the failure/QR screens both render correctly), cp_image_validate over a ~5.8 MB image, a real OTA-slot erase + write + cp_ota_commit, then a clean reboot into the freshly-written slot.

The X3-specific code is just the display path plus board detect / the GPIO13 SD-power gate — all three exercised above. Everything else (cp_sd, cp_fat, cp_ota, cp_part, cp_image, cp_recovery's orchestration) is device-agnostic and validated on X4. So cp_part_install (the PARTS.BIN path) and the otadata round-trip aren't X3 risks — cp_part_install just wasn't triggered in the X3 run (no PARTS.BIN on the card; it's the same bootloader_flash_write code, to 0x8000 instead of an OTA slot), and the otadata toggle has gone slot 0 → 1 once but not round-tripped. Both are worth a confirming pass but aren't X3-specific risks.

The X3 EPD controller is a UC8xxx-class part, not the SSD1677 the X4 uses. What that means for cp_epd:

  • BUSY is active-LOW (pin reads 0 while busy), the opposite of X4 — cp_epd_wait_busy keys off cp_epd_device.
  • No sub-region addressing in the update path: every refresh streams the whole 528×792 frame (~760 ms, ~84 ms of SPI for both planes), so cp_epd accumulates a list of pending fill rects (≤512, enough for a v1 QR's 441 modules) and synthesizes the frame from white + those rects at refresh time. RED-RAM writes are no-ops since we always full-sync.

Init sequence, LUTs, and pixel polarity (byte 0x00 = white on X3) were transcribed from the open-x4-sdk's EInkDisplay.cpp X3 branch.

Threat model

crossboot is a recovery bootloader for devices the vendor has locked down via DIS_USB_SERIAL_JTAG. It assumes the user is the operator — your device, your firmware, your SD card. The job is "don't brick the device because of a mistake in the package or a power cut mid-flash," not "defend against an adversary who chose what's on the SD card."

Catches:

  • A malformed PARTS.BIN that would route the install at the bootloader region (0x0..0x8000), the PT region (0x8000..0x9000), or past flash. cp_part_validate rejects offsets outside [0x9000, flash_size), uint32-wrapping offset + size, and any partition pair that overlaps.
  • A live PT that didn't come through cp_part_validate (stock vendor table, or one flashed outside crossboot). cp_ota_pick_inactive applies the same range check to the slot it returns so a bad live PT can't redirect the UPDATE.BIN write at the bootloader.
  • A malformed UPDATE.BIN: bad magic, truncated, segment past EOF, wrong declared size, wrong XOR checksum, wrong SHA. cp_image_validate streams the whole file once for validate, again for write; a bad image never reaches flash.
  • Power loss anywhere in the recovery pass — within cp_part_install, between PT install and OTA write, mid-OTA-write, or during cp_ota_commit. Auto-recovery on next boot if the PT was the casualty. See Safety for the ordering invariants.
  • A slow or hostile SD card. Every blocking op has a timeout; WDT is fed every 4 KB during write and every 32 KB during validate.
  • Corrupt otadata. Both entries invalid → recovery triggers again on next boot rather than booting garbage.

Does not catch, by design:

  • Rollback to older firmware. No version tracking, no anti-rollback eFuse use.
  • Flash encryption / secure boot. Deliberately declined — both have permanent eFuse implications.
  • An adversary with sustained physical access. ESP32-C3 has no hardware glitch detection; voltage-glitch attacks against the strap gate are out of scope for software.
  • A malicious bootloader replacement via the app's bootloader-update path. Once the app can write 0x0..0x8000, it's part of the trusted base; the app is responsible for validating any candidate bootloader before writing.
  • A physically failed flash chip. Take the backup before the first flash; just restore is the way back.

Safety

One-shot bootloader: replaces the vendor's on first install, then every boot runs our code.

  • cp_ota_commit only writes the non-active otadata sector. Power loss during commit leaves the device booting the old slot.
  • cp_part_install (PARTS.BIN path) erases the new otadata sectors before publishing the new PT, so power loss never leaves the new layout reading stale entries from the previous tenant. A power loss during the PT-erase or PT-write step itself leaves the PT sector all-0xFF; the bootloader detects this on next boot and auto-routes to recovery without the strap (the device is already non-bootable, so installing a fresh PT is non-destructive vs the status quo). PT repair needs PARTS.BIN on the SD; without it, the operator sees a NO-PARTS QR. The strap stays the gate for UPDATE.BIN writes. PT install + UPDATE.BIN write run in the same recovery pass; if the pass is interrupted between them, next boot's recovery picks up cleanly from the new (empty) otadata.
  • Every loop in cp_*.c is bounded; every blocking SD command has a timeout; cp_recovery_run is noreturn so no exit falls through into a hang. WDT fed every 32 KB during validate, every 4 KB during write, every 250 ms during EPD waits.
  • cp_image_validate, cp_fat, and cp_part_validate are libFuzzer- fuzzed (ASAN+UBSAN via just fuzz, MSAN on Linux x86_64). CBMC bounded-model-check (just verify) proves no UB (bounds, pointer, signed/unsigned overflow, undefined shift) on three scopes: the bounds / wrap / range logic of cp_part_validate (the function the threat model leans on for malicious PARTS.BIN rejection), cp_image_validate's entry guard, and cp_fat_mount's parse path. The parser bodies past those CBMC scopes are covered by libFuzzer (line coverage cp_image 93% / cp_part 55% / cp_fat 98%; cp_part_install drags cp_part's file figure, the parser itself is at 100%) and by directed unit tests (just test): cp_ota_pick_inactive's slot/seq math, pinned-case boundaries for the four parsers, cp_image_validate's SHA-256 finale against a real SHA reference (catches stub-masked bugs), and the cp_part_install ordering / cp_ota_commit isolation invariants via a fault-injecting flash model. Recovery's pass-2 write re-computes SHA-256 over the bytes actually flashed and aborts before cp_ota_commit on mismatch with pass 1 — closes the SD-card TOCTOU window. cp_image_validate has a 16 MB hard cap and overflow-safe bounds.
  • cp_part_validate rejects partitions outside [0x9000, flash_size) (catches uint32 wrap too) and tables where any two partitions overlap. cp_ota_pick_inactive applies the same range check to the slot it returns from the live PT — the live PT may not have come through cp_part_validate (stock vendor table, manual flash). A PARTS.BIN that flunks either would otherwise point cp_part_install's erase or the UPDATE.BIN write at our own bootloader image (0x0..0x8000), or alias one slot over another.
  • -Werror -Wshadow -Wcast-align -Wpointer-arith -Wstrict-prototypes on cp_*.c. cppcheck and clang --analyze clean.

Integration with crosspoint-reader

This bootloader is built and flashed independently. The crosspoint-reader app side needs two changes to coexist with it:

  1. Make pio run flash crossboot, not the IDF stock bootloader. PlatformIO writes whatever bootloader its toolchain built at offset 0 every upload. To swap that for crossboot: drop crossboot.bin (a copy of bootloader/build/bootloader/ bootloader.bin, or a CI-fetched artifact) at the project root, then add a post: SCons hook that overwrites $BUILD_DIR/bootloader.bin with it before upload:

    # scripts/use_crossboot.py
    Import("env")
    import shutil
    from os.path import isfile, join
    
    CROSSBOOT = join(env.subst("$PROJECT_DIR"), "crossboot.bin")
    TARGET    = join(env.subst("$BUILD_DIR"), "bootloader.bin")
    
    def _swap_in_crossboot(source, target, env):
        if not isfile(CROSSBOOT):
            raise FileNotFoundError(f"crossboot.bin missing at {CROSSBOOT}")
        shutil.copyfile(CROSSBOOT, TARGET)
    
    env.AddPostAction(TARGET, _swap_in_crossboot)

    Wire it in platformio.ini with extra_scripts = post:scripts/use_crossboot.py (must be post: so the framework's bootloader Command is registered when the hook attaches). Without this, every pio run reverts the device to the stock bootloader and crossboot is gone until you just flash-prod again.

  2. Add a "flash bootloader from file" path in the app. The app needs to take a file (downloaded over network, copied from SD, etc.) containing a crossboot bootloader binary and write it to offset 0x0..0x8000 at runtime. This is the upgrade path for crossboot itself once installed; without it, the only way to update crossboot is to physically reflash via USJ. Validate the candidate (header magic, size <= 32 KB, sane shape) before erasing the existing bootloader; the write window is the only brick-able operation in normal app flow.

Recovery if you brick

just restore flashes back the vendor backup if USJ enumerates. If the device has DIS_USB_SERIAL_JTAG fused and the bootloader bricks itself, there is no software recovery. Don't ship without end-to-end testing.

About

No description, website, or topics provided.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors