Skip to content

fix(fuzz): point the OTA harness at a function that exists, and build it - #110

Merged
srpatcha merged 8 commits into
embeddedos-org:masterfrom
Kartikey1306:fix/ota-fuzz-harness
Sep 1, 2026
Merged

srpatcha merged 8 commits into
embeddedos-org:masterfrom
Kartikey1306:fix/ota-fuzz-harness

Conversation

@Kartikey1306

@Kartikey1306 Kartikey1306 commented Sep 1, 2026 •

Copy link
Copy Markdown
Contributor

tests/fuzz/fuzz_ota_header.c declared and called:

extern int eos_ota_parse_header(const void *data, size_t len);

There is no such function anywhere in the tree. The harness was also commented out of tests/fuzz/CMakeLists.txt, so it was dead twice over — and could not have compiled if anyone had enabled it.

Same shape as the fuzz_devicetree harness fixed in #50, which likewise declared an eos_dtb_parse() that did not exist. Worth noting as a pattern: a fuzz target that is commented out never fails to build, so nothing ever tells you it is aimed at nothing.

What it fuzzes now

The part that actually takes attacker-controlled bytes. eos_ota_source_t carries a declared size and an expected digest that travel with the update, and eos_ota_write_chunk() streams the payload — so the harness treats the first 36 bytes of fuzz input as that control block and the rest as payload, fed in fuzz-chosen slice widths so chunk boundaries vary rather than always landing on the same offsets.

It finishes with verify / apply / rollback so the digest comparison and the authenticity gate are exercised, with an authenticator that refuses everything — apply() must never install a fuzz-chosen image, and wiring one in means the run cannot silently stop at the gate added in #75.

What I ran, and what I did not

Not run here: libFuzzer itself. AppleClang ships no libclang_rt.fuzzer_osx.a, so -fsanitize=fuzzer does not link on this machine:

ld: library '.../libclang_rt.fuzzer_osx.a' not found

Instead I built LLVMFuzzerTestOneInput against a small driver under -fsanitize=address,undefined -fno-sanitize-recover=all and fed it random inputs:

5 seeds × 40,000 inputs = 200,000 — no sanitizer reports

That proves the harness body runs and the ingest path survives arbitrary input. It is not coverage-guided, and CI with a real libFuzzer toolchain is where that happens.

Verification

-- Fuzz targets: fuzz_sha256, fuzz_aes, fuzz_devicetree, fuzz_ota_header
cmake -DEOS_ENABLE_FUZZING=ON target registered and configures
ctest --no-tests=error 26/28

The two Not Run need services/compat, which does not build on macOS — #96's subject.

Stacked on #102, which repairs the macOS HAL link.

Kartikey1306 and others added 8 commits August 31, 2026 22:43
…ckend

Which backend source CMake compiles and which registrar eos_hal_init() calls
are the same decision made in two places, with two different conditions.

CMakeLists.txt:

    if(EOS_PLATFORM STREQUAL "linux" OR NOT CMAKE_CROSSCOMPILING)
        target_sources(eos_hal PRIVATE hal/src/hal_linux.c)
    else()
        target_sources(eos_hal PRIVATE hal/src/hal_rtos.c)

hal_common.c:

    #ifdef __linux__
        eos_hal_linux_register();
    #else
        eos_hal_rtos_register();

They agree on Linux and on a cross build, and disagree on every other host. A
macOS host build compiles hal_linux.c, then references a symbol from the file
it did not compile:

    Undefined symbols for architecture arm64:
      "_eos_hal_rtos_register", referenced from: _eos_hal_init

CMake now sets EOS_HAL_BACKEND_HOST or EOS_HAL_BACKEND_RTOS next to the
target_sources() call that makes the choice, and hal_common.c keys off that.
__linux__ stays as a fallback for anyone compiling these files without CMake.

Two things had to move with it:

- hal.h declared the registrars under the same #ifdef, so hal_common.c could
  not even name the one CMake had compiled. Both are declared unconditionally
  now; exactly one is still defined per build, and an unreferenced declaration
  costs nothing. The header's own comment already argued for this -- "an
  undeclared registrar is one nobody can find".
- hal_linux.c wraps its whole body in #ifdef __linux__, so on macOS it compiled
  to an empty translation unit and the build had no backend at all. It is the
  host backend rather than the Linux one: the only platform calls in it are
  clock_gettime() and nanosleep(), both POSIX. Guarded on the CMake define,
  with __linux__ as the fallback. hal_rtos.c takes the mirror condition.

Linux behaviour is unchanged: EOS_HAL_BACKEND_HOST and __linux__ are both
defined there, and the same registrar is selected as before. Cross builds get
EOS_HAL_BACKEND_RTOS and are likewise unchanged. eos master CI is green on
Linux and stays that way.

Verified on macOS: the eos_hal link error is gone and ctest goes from 0/28 to
19/28.

This does NOT make macOS a supported host on its own. The remaining 9 failures
are services/compat/src/os_adapter_posix.c calling pthread_mutex_timedlock()
and sem_timedwait(), neither of which macOS provides. That is a separate piece
of work in a different subsystem and I have not attempted it here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
With -DEBLDR_BOARD=qemu_arm64 actually taking effect (it was -DBOARD= before
and silently ignored), eBoot's configure now runs the board path and refuses:

    EBLDR_VERIFY_STAGE1 is ON but board qemu_arm64 has no stage-1 linker
    script, so there is no stage-1 image to hash. Configure with
    -DEBLDR_VERIFY_STAGE1=OFF to build stage-0 without the integrity check.

That refusal is correct -- the board ships only board_qemu_arm64.c/.h, no
linker scripts, so there is no stage-1 image for stage-0 to verify -- and the
message names the flag. Passed here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The QEMU step ran for ~20ms, left an empty serial log, and reported nothing --
because its stderr went to /dev/null and its exit code into `|| true`. The one
message that says why the guest did not boot was the message being thrown away.

Keep the run non-fatal only for exit 124 (timeout(1) killing a still-running
guest, which is how a bare-metal image "finishes"); any other nonzero exit now
fails the step loudly with QEMU's own stderr printed. Also list the artifact
files before booting, so a missing or truncated eos_qemu.bin is visible.

The TC validation gate below is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
With stderr finally visible, the reason the guest never booted is one line:

    qemu-system-aarch64: failed to find romfile "efi-virtio.rom"

The workflow installs qemu-system-arm with --no-install-recommends, which
skips ipxe-qemu -- the package that ships efi-virtio.rom. The virt machine
creates a default virtio-net device, that device wants its boot ROM, and QEMU
exits during machine init. This has been the failure since the job first ran;
it was just invisible behind 2>/dev/null.

The simulation uses no networking, so -nic none removes the device rather
than installing a ROM for a NIC nothing talks to.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Build & Test (Linux x86_64) job hangs in "Run C unit tests" and holds the
workflow open until GitHub's 6-hour limit. Which test hangs is currently
unknowable from the outside: ctest runs with no per-test timeout, so the job
never reaches the summary that would name it.

--timeout 120 turns the invisible hang into a named failure. 120s is generous
-- the slowest test in this suite takes 1.4s.

Second, related: the concurrency group I added cancels in-progress runs on
every push. Right for PR branches; wrong for master, where each merge
cancelled the previous merge's run -- which is why this job has not been
allowed to COMPLETE on master since 08-30 and the hang went unnoticed.
cancel-in-progress is now conditional on the ref not being master.

Verified locally (macOS): ctest 25/28 pass with the timeout flag; the 3
remaining are Not Run because test_os_adapter/test_os_services/test_ota do not
build here (the known pthread_mutex_timedlock/sem_timedwait gap). Those three
are Linux-only -- exactly the ones this timeout will name if one of them is
the hang.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The test named by the new ctest timeout is test_net, and the hang is not a
networking bug. CI builds Release; Release defines NDEBUG; NDEBUG deletes
assert() together with the expression inside it. Half of test_net's checks
wrap the call under test:

    assert(eos_net_connect(c, &addr) == 0);
    assert(eos_net_send(c, "ping", 4) == 4);

With those gone, no client ever connects, the echo thread blocks in accept()
forever, and pthread_join() hangs the suite -- for exactly as long as nothing
bounds the test, which is what the previous commit fixed.

Reproduced locally before fixing: a Release build of the unmodified test hangs
on macOS too (this was never Linux-specific; my earlier local runs simply were
not Release). Replaced every assert() with a CHECK() macro that always
evaluates and exits nonzero on failure.

After: Release test_net passes 19/19 in under a second; full local Release
ctest 27/28 (the 1 is test_os_adapter, Not Run on macOS as known).

Same latent class elsewhere: other C tests also use assert() and silently
check nothing in Release builds -- they pass today because their expressions
have no needed side effects that block, but they are weaker than they look.
Worth a sweep in its own PR rather than burying it here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The ARM Cortex-M4 job caught a regression in my backend-selection change:

    hal/src/hal_linux.c:30:10: fatal error: sys/ioctl.h: No such file or directory

That cross build also compiles hal_linux.c -- EOS_PLATFORM defaults to
"linux", so CMake's host branch is taken even for arm-none-eabi -- and it only
ever built because `#ifdef __linux__` reduced the file to an empty translation
unit there. Widening the guard to EOS_HAL_BACKEND_HOST un-emptied it on a
toolchain with no POSIX headers.

The guard now asks the right question -- "is this a hosted POSIX build?" --
once, in hal.h:

    #if defined(__linux__) || \
        (defined(EOS_HAL_BACKEND_HOST) && (defined(__unix__) || defined(__APPLE__)))
    #define EOS_HAL_HOSTED 1

and hal_linux.c, hal_rtos.c and hal_common.c all key off EOS_HAL_HOSTED
instead of each doing their own arithmetic. arm-none-eabi defines none of
those macros, so the empty-TU behavior the ARM job depends on is restored;
aarch64-linux-gnu defines __linux__, so the QEMU simulation lane is unchanged;
macOS defines __APPLE__, so the original fix stands.

Verified locally by preprocessing hal_linux.c both ways (clang -U__APPLE__
-U__unix__ -U__linux__ simulating bare metal: body absent; hosted: present)
and a full Release build + ctest: 26/28, the 2 remaining being the documented
macOS Not-Runs (pthread_mutex_timedlock/sem_timedwait).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
tests/fuzz/fuzz_ota_header.c declared and called

    extern int eos_ota_parse_header(const void *data, size_t len);

There is no such function anywhere in the tree. The harness was also commented
out of tests/fuzz/CMakeLists.txt, so it was dead twice over -- and could not
have compiled if anyone had enabled it. Same shape as the fuzz_devicetree
harness fixed in embeddedos-org#50, which declared an eos_dtb_parse() that likewise did not
exist.

Rewritten against the real API, and aimed at the part that actually takes
attacker-controlled bytes. eos_ota_source_t carries a declared size and an
expected digest that travel WITH the update, and eos_ota_write_chunk() streams
the payload, so the harness treats the first 36 bytes of fuzz input as that
control block and the rest as payload -- fed in fuzz-chosen slice widths so
chunk boundaries vary rather than always landing on the same offsets.

It finishes with verify/apply/rollback so the digest comparison and the
authenticity gate are exercised, with an authenticator that refuses
everything: apply() must never install a fuzz-chosen image, and wiring one in
means the run cannot silently stop at the gate added in embeddedos-org#75.

Not run here: libFuzzer itself. AppleClang ships no libclang_rt.fuzzer_osx.a,
so `-fsanitize=fuzzer` does not link on this machine. What I did instead was
build LLVMFuzzerTestOneInput against a small driver under
-fsanitize=address,undefined -fno-sanitize-recover=all and feed it random
inputs:

    5 seeds x 40,000 inputs = 200,000 -- no sanitizer reports

That proves the harness body runs and the ingest path survives arbitrary
input; it is not coverage-guided, and CI with a real libFuzzer toolchain is
where that happens.

CMake now reports:
    -- Fuzz targets: fuzz_sha256, fuzz_aes, fuzz_devicetree, fuzz_ota_header

Verified: ctest 26/28, the two Not-Run being targets that need services/compat,
which does not build on macOS (embeddedos-org#96's subject).

Stacked on embeddedos-org#102, which repairs the macOS HAL link.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@srpatcha
srpatcha merged commit 8a2d835 into embeddedos-org:master Sep 1, 2026
28 checks passed
Kartikey1306 added a commit to Kartikey1306/eos that referenced this pull request Sep 3, 2026


bsan/ and bsan2/ are CMake build directories. They reached master in
8a2d835 (embeddedos-org#110) — 1441 files, 12.2 MB, four compiled object files, and
two CMakeCache.txt holding absolute paths from the machine that ran the
sanitizer build. Nothing in the repository refers to either directory.

They are mine, and they are the same mistake as the .coverage database
that embeddedos-org#75 carried to master and embeddedos-org#79 had to remove: `git add -A` in a tree
with a build directory whose name no ignore pattern matched. .gitignore
already lists build/, _build/, build-*/, build_coverage/,
build_qemu_arm64/ and build_sim/ — every one of them added after being
committed once. Widened to cover bsan*/ so this name cannot come back.

Removed with `git rm -r --cached`, so anyone with a local sanitizer
build keeps it; it is simply no longer tracked.

Contents only — no source, build or CI file is touched, and the tree
builds and tests exactly as before:
  -DEOS_BUILD_TESTS=ON -DEOS_PRODUCT=vbox_test + ctest -> 34/34 passed
Kartikey1306 added a commit to Kartikey1306/eos that referenced this pull request Sep 3, 2026


bsan/ and bsan2/ are CMake build directories. They reached master in
8a2d835 (embeddedos-org#110) — 1441 files, 12.2 MB, four compiled object files, and
two CMakeCache.txt holding absolute paths from the machine that ran the
sanitizer build. Nothing in the repository refers to either directory.

They are mine, and they are the same mistake as the .coverage database
that embeddedos-org#75 carried to master and embeddedos-org#79 had to remove: `git add -A` in a tree
with a build directory whose name no ignore pattern matched. .gitignore
already lists build/, _build/, build-*/, build_coverage/,
build_qemu_arm64/ and build_sim/ — every one of them added after being
committed once. Widened to cover bsan*/ so this name cannot come back.

Removed with `git rm -r --cached`, so anyone with a local sanitizer
build keeps it; it is simply no longer tracked.

Contents only — no source, build or CI file is touched, and the tree
builds and tests exactly as before:
  -DEOS_BUILD_TESTS=ON -DEOS_PRODUCT=vbox_test + ctest -> 34/34 passed
srpatcha pushed a commit that referenced this pull request Sep 8, 2026
* fix(build): restore the low-order Ed25519 suite that #93 removed

`CI — eos` and `Python Unit Tests` are red on master (769191a), and both
fail for one reason:

    these suites exist in tests/ but no add_executable() in
    tests/CMakeLists.txt builds them, so they never run:
    ['test_crypto_ed25519_loworder.c']

3aa8644 (#93, "remove duplicate test targets from tests/CMakeLists.txt")
removed all four lines that build and register
test_crypto_ed25519_loworder. It was not a duplicate — there was exactly
one registration before that commit, and it took it.

What stopped running is the suite that guards #101: Ed25519 rejecting
low-order public keys. A low-order key makes every term of the
verification equation collapse to the identity regardless of the
message, so a signature of all zeros verifies against any content at
all. The fix is still in services/crypto/src/ed25519_verify.c and still
correct; nothing has been checking it since #93 merged, and nothing
would have noticed if a later change had removed it too.

Restored verbatim from 3aa8644^, in its original position before
test_crypto_sha512. No other file changes.

The guard that caught this is test_cmake_test_registration.py, added
recently. It did exactly its job — this is the first thing it found.

Verified:
  test_cmake_test_registration.py    -> 5 passed (1 failed on master)
  pytest tests/                      -> 15 passed (14 passed 1 failed on master)
  test_crypto_ed25519_loworder       -> 5/5 tests passed
  ctest                              -> 39/39 passed (master builds 38)

* chore: untrack the sanitizer build trees committed in #110

bsan/ and bsan2/ are CMake build directories. They reached master in
8a2d835 (#110) — 1441 files, 12.2 MB, four compiled object files, and
two CMakeCache.txt holding absolute paths from the machine that ran the
sanitizer build. Nothing in the repository refers to either directory.

They are mine, and they are the same mistake as the .coverage database
that #75 carried to master and #79 had to remove: `git add -A` in a tree
with a build directory whose name no ignore pattern matched. .gitignore
already lists build/, _build/, build-*/, build_coverage/,
build_qemu_arm64/ and build_sim/ — every one of them added after being
committed once. Widened to cover bsan*/ so this name cannot come back.

Removed with `git rm -r --cached`, so anyone with a local sanitizer
build keeps it; it is simply no longer tracked.

Contents only — no source, build or CI file is touched, and the tree
builds and tests exactly as before:
  -DEOS_BUILD_TESTS=ON -DEOS_PRODUCT=vbox_test + ctest -> 34/34 passed

* chore: collapse the redundant bsan ignore patterns, and say what untracking does not do

Answers the review on #117.

Finding 1 (Low) -- bsan*/ already matches bsan/ and bsan2/, so those two lines
were dead the moment the third was written. This file's stated problem is that
it accumulates one redundant entry per incident; adding two more while fixing
that is the wrong direction. Verified the single pattern still covers all
three shapes: bsan/, bsan2/ and bsan99/ are each ignored with only bsan*/
present.

Finding 2 (Low) -- the body read as though the trees were gone. They are only
untracked going forward: the 12.2 MB and the absolute paths stay in history at
8a2d835 permanently, and every clone still downloads them. Said so in the PR
body rather than leaving the next reader to work it out. No history rewrite is
proposed -- the reviewer scanned all 1441 blobs for credential-shaped content
and found none, the only disclosure being a public GitHub handle, so a rewrite
would cost every consumer a re-clone for no security benefit.

Verified:
  pytest tests/                                 15 passed
  bsan/, bsan2/, bsan99/ with only bsan*/       all three ignored
  git status --short                            clean

Refs #117

* ci: pin the eBoot and ebuild checkouts instead of floating on master

Every open eos pull request is currently red on `EoS Full-Stack Simulation`,
and none of them caused it. The kernel job checks out embeddedos-org/eBoot at
`ref: master`, and eBoot master does not compile: include/eos_image.h:135 and
:142 static-assert offsetof(eos_image_header_t, reserved) for a member that
became tlv_len + tlv_hash, and core/ed25519_verify.c redefines
point_is_identity -- two pairs of PRs that each merged clean and broke the
build together. eBoot #94 repairs it and waits on review; until it lands, no
change to any eos PR can make this job pass, and after it lands the next
broken eBoot master does the same thing again.

A floating `ref: master` makes every eos PR's CI depend on the moment-to-
moment state of another repository's default branch. Pinning the two
compiled-against checkouts makes the job a function of this repository plus
two recorded SHAs -- red means the PR broke something, green means it did not,
and a cross-repo bump is a reviewable one-line diff with CI on it.

  EBOOT_COMMIT  a172a6d6e1e6  the newest eBoot master ancestor that compiles;
                found by walking master back and building each candidate.
                Its child 8a015b2 already carries the ed25519 redefinition.
  EBUILD_COMMIT e5d8052f3e2c  ebuild's current master, frozen as-is. Both of
                its checkouts feed steps that are commented out (eFab does
                not exist), so this pin only removes the float.

The middleware matrix (10 repos) still floats; it runs those repos' own
tests rather than compiling eos against them, and 10 more pins deserve their
own decision. When eBoot #94 merges, bump EBOOT_COMMIT to the merge SHA.

Verified before pushing, master vs pin under the job's own configuration
(-DEBLDR_BOARD=qemu_arm64 -DEBLDR_VERIFY_STAGE1=OFF -DCMAKE_BUILD_TYPE=Release,
cross-compile mode): eBoot@master fails at the eos_image.h asserts, the exact
error in the job log; eBoot@a172a6d builds every target, rc=0. Both pinned
SHAs verified reachable from their repos' master. Workflow parses; the three
`ref:` edits and the env block are the whole diff. This PR's own simulation
run is the live test: it uses this branch's workflow and should go green
while every master-based PR stays red.

Carried onto this branch from #135 (001128d) so this PR's own simulation
job runs against a buildable eBoot: each pull request executes its own copy
of the workflow, so the fix greens a PR only once its branch contains it.
Byte-identical to #135's commit -- git drops it as already-upstream the
moment #135 merges. The pin itself is #135's to review.
srpatcha added a commit that referenced this pull request Sep 8, 2026
…140)

Revert "chore: untrack the sanitizer build trees committed in #110 (#117)"

This reverts commit 85a493b.
srpatcha added a commit that referenced this pull request Sep 8, 2026
…110"" (#141)

Revert "Revert "chore: untrack the sanitizer build trees committed in #110" (…"

This reverts commit 1988d85.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants