Skip to content

fix(secure-boot): sign the whole image header, not just the payload hash - #40

Merged
srpatcha merged 3 commits into
embeddedos-org:masterfrom
Kartikey1306:fix/sign-full-image-header
Aug 27, 2026
Merged

fix(secure-boot): sign the whole image header, not just the payload hash#40
srpatcha merged 3 commits into
embeddedos-org:masterfrom
Kartikey1306:fix/sign-full-image-header

Conversation

@Kartikey1306

Copy link
Copy Markdown
Contributor

Stacked on #38. This branch contains #38's commit plus one new commit,
f8a82d6. Review that commit alone, or merge #38 first and this becomes a
single-commit diff. It is independent of #39.

Summary

eos_image_verify_signature() verified the Ed25519 signature over hdr->hash
32 of the header's 156 bytes. Every other field sat outside the
signature: image_size, load_addr, entry_addr, image_version, flags,
sig_type, sig_len.

An attacker holding any legitimately signed image could keep that signature
byte-for-byte and still rewrite the metadata the bootloader acts on.

The bug

int rc = eos_crypto_verify_signature(
    hdr->hash, EOS_HASH_SIZE,      /* <-- 32 bytes. That is the whole message. */
    hdr->signature, hdr->sig_len,
    pub_key, key_len);

Because the payload hash is unchanged by header edits, the signature keeps
verifying. Three concrete consequences:

Edit Effect
entry_addr redirect execution to any offset inside the image
load_addr relocate where the image is copied before it runs
clear EOS_IMG_FLAG_HASH_SHA256 eos_image_verify_integrity() drops from SHA-256 to CRC32

The third is the worst: CRC32 is trivially forgeable, so once integrity
checking is downgraded the payload itself becomes editable. The flag that
selects the algorithm was not covered by the signature that was supposed to
protect it.

Demonstrated with real Ed25519 keys — one image, signed once, then tampered:

  genuine image, v1 verify:            ACCEPTED
  TAMPERED image, v1 verify:           ACCEPTED  <-- vulnerable
    (entry 0x08010100 -> 0x08099999, load -> 0x20000000, SHA-256 flag cleared)
    payload untouched, so hash[] still matches and the signature is intact

  genuine image, v2 verify:            ACCEPTED
  TAMPERED image, v2 verify:           REJECTED  <-- fixed

Approach

Sign the header prefix — everything except signature[] itself:

#define EOS_IMG_SIGNED_LEN  offsetof(eos_image_header_t, signature)   /* 92 */

int rc = eos_crypto_verify_signature(
    (const uint8_t *)hdr, EOS_IMG_SIGNED_LEN,
    hdr->signature, hdr->sig_len,
    pub_key, key_len);

hash[] is inside that prefix, so the payload stays covered transitively — one
signature now binds the metadata and the payload together.

No crypto changes were needed: eos_crypto_verify_signature() already passes
the message straight through to eos_ed25519_verify(), which takes an
arbitrary length.

Ordering detail: sig_type and sig_len are themselves inside the signed
region, so the signer must finalise them before computing the signature. That
works because Ed25519 signatures are always 64 bytes, so sig_len is known up
front.

Breaking change, deliberately

EOS_IMAGE_HDR_VERSION goes to 2. Existing signed images must be
re-signed; their v1 signatures do not verify under v2.

I did not add a v1 compatibility path. Accepting v1 signatures would leave
the entire vulnerability open — an attacker would simply present a v1 image.
A clean break is the only version of this fix that actually fixes it.
eos_image_parse_header() now validates hdr_version (it previously ignored
it), rejecting 0 and anything newer than the build understands. v1 still
parses — it is a real format — but fails at signature verification.

Tooling moved in lockstep

A verifier change without a matching signer just makes every image unbootable.

  • sign_image.py signs the header prefix, sets sig_type/sig_len before
    signing, and stamps hdr_version = 2.
  • --verify is now implemented. It was declared in argparse but main()
    never read it — the flag silently did nothing, while
    docs/security_review_checklist.md already says signing changes should be
    "tested with --verify flag against known-good test vectors". It now
    recomputes the payload hash and checks the signature over the prefix.
  • eos_sign.py had constants that did not match include/eos_types.h:
    SIG_TYPE_ED25519 = 1 — that value is EOS_SIG_CRC32, which
    eos_image_verify_signature() rejects outright — and
    IMG_FLAG_SIGNED = 1 << 2, which is EOS_IMG_FLAG_DEBUG. It also never set
    EOS_IMG_FLAG_HASH_SHA256, so the bootloader read its stored SHA-256 as a
    CRC32. Its images could never have booted.

Layout is now static-asserted

The signing tools address the header by absolute byte offset, so the struct
layout is part of the on-disk format:

EOS_IMG_STATIC_ASSERT(sizeof(eos_image_header_t) == 156, ...);
EOS_IMG_STATIC_ASSERT(offsetof(eos_image_header_t, signature) == 92, ...);

Without these, reordering a field would silently break signing without breaking
the build. The macro degrades gracefully on pre-C11 and works under C++.

Testing

C — 16 tests, was 12. Four new, all passing:

Test What it pins
test_signed_region_covers_all_metadata every field is inside the signed prefix; signature[] is the only thing outside
test_flags_are_inside_the_signed_region the downgrade vector specifically
test_unsigned_signature_types_are_rejected sig_type NONE/CRC32/SHA256 and wrong sig_len never satisfy the check
test_header_version_is_validated v0 and v3 rejected, v1 parses, v2 is current

The coverage test fails if anyone adds a field after signature[] — which is
exactly the mistake that created this bug.

Python — tests/unit/test_sign_image.py, 14 new end-to-end tests. These
pack a real image, sign it, verify it, then tamper and require rejection:

$ pytest tests/unit/test_sign_image.py -q
14 passed in 1.51s

Covered: entry_addr, load_addr, image_size, image_version, sig_type,
hdr_version, the SHA-256 flag, the payload, and verification under the wrong
key. One test checks the signed message independently of the tool that produced
it — it verifies the signature over bytes [0, 92) with cryptography
directly, and asserts it does not verify over hash[] alone.

Writing that suite caught a bug in my own --verify: it reported success for
an image whose sig_type had been downgraded to CRC32, because it fell back to
"integrity only". Passing --key now requires a valid signature, matching
what the bootloader does.

Build:

$ cmake --build build -j              # no new warnings in changed files
$ ./build/tests/test_image_verify
16/16 tests passed
$ ctest --test-dir build
93% tests passed (13/14 — the 14th is the pre-existing test_recovery link
                  failure this branch does not touch; see #39)

With #39 merged in locally, the combined tree is BUILD=0, 14/14.

A finding I am reporting, not fixing

eos_ed25519_verify() is not RFC 8032 Ed25519. It computes
k = SHA-256(R ‖ A ‖ M) where the standard uses SHA-512
(core/ed25519_verify.c:696). The file says so itself:

NOTE: This implementation uses SHA-256 as the internal hash (instead of
SHA-512 as per strict RFC 8032) to avoid adding a separate SHA-512
implementation. The signing tool (sign_image.py) must use the matching
hash algorithm.

sign_image.py uses cryptography's standard Ed25519, which is SHA-512. The
stated requirement is not met
, so no signature produced by the tool can
verify on-device. tests/unit/test_ed25519.c does not catch it because every
one of its assertions is negative — NULL arguments, malformed signatures, zero
keys. Not one test asserts that a valid signature verifies.

This is orthogonal to what is signed, which is what this PR fixes, and
closing it means adding SHA-512 and reworking the reduction — a substantial
change to the most security-critical file in the tree. It deserves its own PR
with RFC 8032 test vectors. I would rather flag it precisely than bolt it on
here. Happy to open the issue, or that PR, if useful.

Note this does not weaken the change: with the header unsigned, a working
verifier would have been worse, because tampered images would have booted.

Limitations and considerations

  • All existing signed images stop verifying and must be re-signed. This is
    the intended, unavoidable consequence — see Breaking change above.
  • eos_sign.py still emits [header][TLV][payload], but the bootloader
    computes the payload address as addr + hdr_size and there is no caller of
    eos_tlv_parse() anywhere in the tree
    . Images from that tool still fail
    integrity checking because the TLV sits where the payload is expected. I
    fixed its constants and signing so it does not drift further, and documented
    the remaining gap at the top of the file — resolving it is a format decision
    (move the TLV after the payload, or teach the boot path to parse it), not a
    tooling fix.
  • EBLDR_REQUIRE_SIGNATURES=OFF reopens the downgrade. With signature
    verification disabled, flags is unauthenticated again by construction. The
    default is ON.
  • docs/quickstart.md documented commands that do not exist
    (--generate-key, --key/--input/--output). Corrected to the real CLI while
    documenting the new --verify step.
  • Line endings preserved. core/image_verify.c and CHANGELOG.md are
    mixed CRLF/LF and sign_image.py is CRLF; edits were applied byte-wise, so
    image_verify.c is +21 lines rather than a whole-file rewrite.

Type of Change

  • fix — Bug fix (security)
  • test — Add or fix tests
  • docs — Documentation
  • Breaking — image header format v1 → v2; signed images must be re-signed

Changes

  • core/image_verify.c — signature covers the header prefix; hdr_version validated.
  • include/eos_image.hEOS_IMG_SIGNED_LEN, version 2, layout static asserts.
  • tools/sign_image.py — sign the prefix; implement --verify.
  • tools/eos_sign.py — correct constants; sign the prefix; document the TLV gap.
  • tests/unit/test_image_verify.c — 4 new cases (16 total).
  • tests/unit/test_sign_image.pynew, 14 end-to-end tamper tests.
  • docs/quickstart.md, docs/secure_boot_chain.md,
    docs/security_review_checklist.md, docs/threat_model.md (new row T-303),
    CHANGELOG.md.

Pre-Submission Checklist

  • Compiles without new warnings
  • C tests 16/16; Python tests 14/14
  • Vulnerability demonstrated before the fix and shown rejected after
  • Signing tools updated in lockstep with the verifier
  • Breaking change called out explicitly
  • Documentation updated

Kartikey1306 and others added 2 commits August 26, 2026 11:44
…ead flash

eos_crc32() returned 0 when eos_hal_flash_read() failed. That value is
indistinguishable from a region that genuinely hashes to 0, so the CRC32
branch of eos_image_verify_integrity() reported EOS_OK for an image whose
payload could not be read at all — provided the stored CRC was 0.

The stored CRC is the first four bytes of hdr->hash, which lives in the image
header. The header is not covered by the Ed25519 signature (that is computed
over hdr->hash only), so setting those bytes to 0 costs nothing.

The SHA-256 branch has always propagated the read error: eos_crypto_verify_image()
returns rc from eos_hal_flash_read(). The two branches now behave the same.

Reproduced against the simulated flash in tests/unit/test_image_verify.c by
placing an image so its payload runs past the end of the device:

  flash read of payload tail -> -6 (EOS_ERR_FLASH)
  eos_image_verify_integrity -> 0  (EOS_OK)   <-- verified an unreadable image

Adds eos_crc32_checked(), which reports read failures through its return value,
and switches the verification path to it. eos_crc32() is kept for API
compatibility, now implemented in terms of the checked variant and documented
as unsuitable for deciding whether an image is intact.

Two smaller fixes in the same function, both the same "do not verify something
you did not read" shape: a zero image_size is rejected rather than CRC'd to a
fixed value, and an addr + hdr_size that wraps uint32_t is rejected rather than
producing a payload address that is not the payload.

Tests: seven cases added to tests/unit/test_image_verify.c (12 total, was 5),
including a matching-CRC image that must verify, a wrong-CRC image that must
not, and the unreadable-payload regression. The CRC32 used to build
expectations is implemented independently in the test so it does not simply
restate image_verify.c.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
eos_image_verify_signature() verified the Ed25519 signature over hdr->hash —
32 of the header's 156 bytes. Every other field sat outside the signature:
image_size, load_addr, entry_addr, image_version, flags, sig_type and sig_len.

An attacker holding any legitimately signed image could therefore keep the
signature intact and still:

  - move entry_addr, redirecting execution within the image;
  - change load_addr, relocating where the image is copied to;
  - clear EOS_IMG_FLAG_HASH_SHA256, which downgrades
    eos_image_verify_integrity() from SHA-256 to CRC32 — and CRC32 is
    trivially forgeable, so the payload then becomes editable too.

The payload hash is unchanged in all three cases, so the signature still
verified. Demonstrated with real Ed25519 keys: a header with entry_addr moved
from 0x08010100 to 0x08099999, load_addr to 0x20000000 and the SHA-256 flag
cleared is ACCEPTED under the old scheme and REJECTED under the new one.

The signature now covers EOS_IMG_SIGNED_LEN bytes — the whole header except
signature[] itself. hash[] is inside that prefix, so the payload stays covered
transitively. This is a format change: EOS_IMAGE_HDR_VERSION goes to 2 and
existing signed images must be re-signed. No v1 compatibility path is offered,
because accepting v1 signatures would leave the downgrade open.

eos_image_parse_header() now validates hdr_version instead of ignoring it.

The signing tools move in lockstep:

  - sign_image.py signs the header prefix, sets sig_type/sig_len before
    signing (they are inside the signed region), and stamps hdr_version = 2.
  - --verify was declared in argparse but never used by main(); it is now
    implemented, checking the payload hash and the signature over the prefix.
    The security review checklist already assumed this flag worked.
  - eos_sign.py had constants that did not match include/eos_types.h:
    SIG_TYPE_ED25519 was 1 (that is EOS_SIG_CRC32, which the bootloader
    rejects outright) and IMG_FLAG_SIGNED was 1 << 2 (EOS_IMG_FLAG_DEBUG). It
    also never set EOS_IMG_FLAG_HASH_SHA256, so the bootloader read its stored
    SHA-256 as a CRC32.

Struct offsets are now static-asserted, because the signing tools address the
header by absolute byte offset and a silent layout change would break signing
without breaking the build.

Tests: 4 C cases pinning that every trusted field lies inside the signed
prefix and that unsigned signature types are rejected (16 total, was 12), plus
tests/unit/test_sign_image.py — 14 end-to-end cases that pack, sign and verify
a real image, then tamper each header field and the payload and require every
one to be rejected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
srpatcha
srpatcha previously approved these changes Aug 27, 2026
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