Absolution-based fuzzing for the Ledger Ethereum app. The canonical path stays
fuzz-only: fuzz_harness_entry() restores the prefix, picks one fuzz command,
rewraps it as a real APDU buffer, runs apdu_parser(), and then dispatches
through a fuzz-side APDU mirror that is checked against production main.c at
configure time. Supplemental non-APDU coverage surfaces (swap helpers, plugin
utils) live in a separate fuzz-only extension module instead of inside the main
APDU dispatcher.
BOLOS_SDKset to a checkout of the Ledger Secure SDK that contains the fuzzing framework.- Clang ≥ 14 with
llvm-profdataandllvm-covfor coverage reports. - The SDK's
ledger_fuzz_setup()step fetches Absolution automatically on the first configure. SetLEDGER_FUZZ_ABSOLUTION_LOCAL_DIRif you want to point the build at a local Absolution install instead.
From the workspace root:
BOLOS_SDK=$(pwd)/ledger-secure-sdk \
"$BOLOS_SDK"/fuzzing/scripts/app-campaign.sh \
--app-dir "$(pwd)/app-ethereum" ethereum-smokeethereum-smokeis the optional campaign name. Omit it to let the SDK script pick a UTC timestamp under.fuzz-artifacts/.- The command builds the app, syncs the invariant, updates
mock/scenario_layout.h, generates seeds, runs fuzzing, and writes coverage. - The promoted base corpus at
fuzzing/base-corpus/is folded into the bootstrap corpus automatically when it exists and its.compat-keymatches the current build.
| Variable / flag | Default | Meaning |
|---|---|---|
WARMUP_SEC |
30 |
Warmup seconds per worker |
MAIN_SEC |
60 |
Main phase seconds per worker |
WORKERS |
min(2, nproc) |
Parallel LibFuzzer workers |
EXTRA_CORPUS |
unset | Colon-separated extra corpus dirs merged into bootstrap |
BASE_CORPUS_DIR |
fuzzing/base-corpus if present |
Promoted seeds; BASE_CORPUS_DIR= skips |
BUILD_JOBS |
CPU-based | Parallel compile jobs |
OVERWRITE=1 |
unset | Replace an existing .fuzz-artifacts/<name>/ |
--clean |
off | Wipe build dirs before configure |
The fuzz integration is intentionally split so app-ethereum/src/ stays
untouched:
harness/fuzz_dispatcher.cis the tiny boundary file required by the SDK.harness/fuzz_command_registry.*is the single source of truth for command indices, canonical APDU vs extension grouping, and TLV grammar selection.harness/fuzz_apdu_adapter.cre-encodes the selected fuzz command as a real APDU buffer and runsapdu_parser()before local dispatch.harness/fuzz_apdu_dispatch.cmirrors the production APDU switch for the canonical app entry surface only.harness/fuzz_non_apdu.ccontains the fuzz-only swap/plugin helper shims.harness/fuzz_tlv_config.ccontains the TLV grammar-aware mutator setup.mock/app_runtime.cowns the globals that normally come from excludedsrc/main.cand performs the per-iteration runtime reset.
scripts/check_dispatch_conformance.py runs during CMake configure and fails
the fuzz build if the canonical fuzz APDU inventory drifts from
src/main.c, src/apdu_constants.h, or fuzz-manifest.toml.
| Path | Purpose |
|---|---|
fuzz-manifest.toml |
App-local config: CLA/INS list, coverage files, dictionary, seed strategy |
CMakeLists.txt |
App sources, explicit fuzz support sources, and the build-time conformance check |
harness/fuzz_dispatcher.c |
Thin SDK boundary: mutator hook, reset hook, dispatch hook, fuzz_entry() |
harness/fuzz_command_registry.* |
Canonical fuzz command list shared by C harness code and Python seed tooling |
harness/fuzz_apdu_adapter.c |
APDU re-encoding plus apdu_parser() fidelity step |
harness/fuzz_apdu_dispatch.c |
Canonical APDU-only fuzz dispatch mirror |
harness/fuzz_non_apdu.c |
Supplemental swap/plugin helper coverage surfaces |
harness/fuzz_tlv_config.c |
TLV tag grammars and custom mutator wiring |
mock/mocks.h / mock/mocks.c |
Engine globals, exit jump buffer, and BSS-zero no-op |
mock/app_runtime.c |
Fuzz-owned replacements for globals that normally live in excluded src/main.c |
mock/scenario_layout.h |
Prefix offsets used by the harness and seed generators |
invariants/fuzz_globals.zon |
Synced Absolution invariant |
invariants/zero-symbols.txt |
App globals removed from the prefix |
invariants/domain-overrides.txt |
Enum and state constraints that improve convergence |
base-corpus/ |
Promoted corpus snapshot checked into the app |
macros/exclude_macros.txt |
SDK macro exclusions needed by the fuzz build |
scripts/generate_seed_corpus.py |
Custom seed corpus generator driven by the shared command registry |
scripts/check_dispatch_conformance.py |
Production/fuzz parity check for canonical APDU commands |
The fuzz build uses link-time mock overrides instead of compile-time
#ifdef gates. Source files that need mocking are excluded from the build
in CMakeLists.txt and replaced by mock implementations in mock/mocks.c:
-
check_signature_with_pubkey(ledger_pki.cexcluded): always returnstrue. ECDSA verification is a pure cryptographic gate that fuzzing cannot solve (2^-128 probability). Without this mock, every signature-gated TLV handler would bail at verification, leaving all downstream parsers at 0% coverage. The sacrificed code has no memory-sensitive operations and is better served by unit tests with known key-pairs. -
ui_display_safe_account: no-op. The production function triggers an NBGL review flow that blocks on user interaction. Mocking it allows the fuzzer to reach the post-validation success path incmd_safe_account.c. -
parseBip32: uses the real production implementation (fromsrc/main.c), not a simplified stub. This ensures the fuzzer exercises the same BIP32 validation andbip32_path_readcall that runs on-device.
The eth_ustream.c no-progress guard is active under
FUZZING_BUILD_MODE_UNSAFE_FOR_PRODUCTION (set automatically by libFuzzer).
This prevents infinite loops from Absolution-injected txContext state where
normal progress invariants may be violated.
Deep code (GTP, EIP-712, safe accounts) requires multi-APDU prerequisite
state that cannot be produced in a single-command-per-iteration harness.
The dispatcher (fuzz_apdu_adapter.c) conditionally bootstraps this state
before dispatching the target command:
-
GTP: when
appState == SIGNING_TXand the command isINS_GTP_FIELDorINS_GTP_TRANSACTION_INFO, the bootstrap creates atx_ctxwith parked calldata (selector 0xAABBCCDD), finds the matching context, and attaches a synthetictx_infowith fields_hash=0xFF... The field-table init runs as part oftx_ctx_init. This allows GTP FIELD seeds to exercise deep TLV parsing, data-path traversal, and format dispatch. -
EIP-712: when
appState == SIGNING_EIP712and the command isINS_EIP712_STRUCT_IMPL,INS_EIP712_FILTERING, orINS_SIGN_EIP_712_MESSAGE, the bootstrap allocates the context, registers a minimal type schema (EIP712Domain with name/chainId/verifyingContract, and Mail with value/to), sets both root types viapath_set_root, and enablesEIP712_FILTERING_FULLmode. This unlocks the deep EIP-712 code:filtering.c,encode_field.c,type_hash.c,format_hash_field_type.c,field_hash.c,schema_hash.c, and the full typed-data path navigation. -
Safe account: when the command is
INS_PROVIDE_SAFE_ACCOUNTwithP2 == SIGNER_DESCRIPTOR, the bootstrap allocates aSAFE_DESCwith threshold=1, signers_count=1, role=SIGNER. This allows signer descriptor seeds to exercise the signer TLV parsing and address verification logic. -
Enum values: the GTP bootstrap also seeds a 4×4 grid of synthetic enum entries (id 0..3, value 0..3) keyed to the bootstrapped contract address and selector. These entries are registered through the production
handle_enum_value_tlv_payload+verify_enum_value_structpath (with the signature check mocked) soformat_param_enum'sget_matching_enum()can find matches and exercise the success tail.
Trade-off: these bootstraps skip the APDU-level parsing that normally builds the prerequisite structures (INS_SIGN for tx_ctx, INS_EIP712_STRUCT_DEF for eip712 types, INS_PROVIDE_SAFE_ACCOUNT P2=SAFE_DESCRIPTOR for SAFE_DESC, INS_PROVIDE_ENUM_VALUE for enum entries). That parsing is already fuzzed by seeds that target those INS directly. The bootstrap only provides the minimum prerequisite state so dependent handlers can exercise their own deep parsing and memory logic.