feat(os): normalize the Linux setup header so new images measure the kernel file - #1189
Merged
Conversation
Contributor
There was a problem hiding this comment.
🔵 Needs a closer look
It changes firmware measurement behavior and core TDX attestation semantics (RTMR[1]) across multiple components, which warrants careful human security review despite the added tests/fixtures.
Pull request overview
This PR makes TDX RTMR[1] (kernel measurement) independent of host QEMU version by normalizing the Linux setup header both in the shipped kernel image and in OVMF right before measurement/loading. It then simplifies dstack-mr to treat RTMR[1] as the plain Authenticode SHA-384 of the shipped bzImage, updates measurement/document versions, and refreshes verifier fixtures accordingly.
Changes:
- Normalize bootloader-owned Linux setup-header fields in the shipped
bzImage(build-time) and again in OVMF (measurement-time) to eliminate QEMU-dependent bytes. - Simplify
dstack-mrkernel measurement logic to hash the kernel “as shipped” (dropping QEMU-header-patching logic and related memory-size constraints). - Update documentation, tests, fixtures, and
tools/vm-runnerbehavior to match the new measurement semantics.
File summaries
| File | Description |
|---|---|
| tools/vm-runner/vm-runner.py | Emits qemu_version into vm_config when detectable; accepts newer rootfs .verity naming. |
| os/yocto/layers/meta-dstack/recipes-core/dstack-ovmf/dstack-ovmf/0007-OvmfPkg-QemuKernelLoaderFsDxe-normalize-setup-header.patch | OVMF-side setup-header normalization before measuring/loading the kernel blob. |
| os/yocto/layers/meta-dstack/recipes-core/dstack-ovmf/dstack-ovmf_git.bb | Wires the new OVMF patch into the Yocto build. |
| os/tests/test-kernel-header-normalization.sh | Cross-checks the normalization field table between the Python script and the OVMF patch; exercises idempotence and invariants. |
| os/mkosi/tests/acceptance.sh | Ensures mkosi build wiring keeps applying the OVMF patch and the assemble hook. |
| os/mkosi/components/ovmf/ovmf.sh | Updates component cache key inputs to include the new OVMF patch. |
| os/mkosi/components/ovmf/ovmf-build.sh | Applies the new OVMF patch in the mkosi OVMF build flow. |
| os/image/README.md | Documents why the shipped kernel differs from raw build output (normalization rationale and invariants). |
| os/image/normalize-kernel-header.py | Build-time normalization implementation for bzImage setup-header fields. |
| os/image/assemble.sh | Hooks normalization into image assembly before measurements/checksums are computed. |
| dstack/verifier/src/verification.rs | Bumps measurement cache version to invalidate stale cached RTMR computations. |
| dstack/verifier/fixtures/tdx-lite.README.md | Updates fixture provenance/assumptions for normalized-kernel measurements. |
| dstack/verifier/fixtures/tdx-lite-getquote.json | Refreshes lite fixture capture content for the new measurement behavior. |
| dstack/verifier/fixtures/tdx-lite-attestation.json | Refreshes lite fixture capture content for the new measurement behavior. |
| dstack/dstack-types/src/lib.rs | Updates TDX measurement document version/semantics documentation (v3 → v4 meaning shift). |
| dstack/dstack-mr/src/tdx.rs | Switches RTMR[1] computation to hash shipped kernel; adjusts memory-split constant naming/usage. |
| dstack/dstack-mr/src/main.rs | Updates CLI help text related to measurement split feature wording. |
| dstack/dstack-mr/src/machine.rs | Updates call sites for the simplified rtmr1_log API. |
| dstack/dstack-mr/src/kernel.rs | Removes QEMU kernel-header patching logic; RTMR[1] now measures the shipped kernel hash. |
| docs/security/security-model.md | Documents the removal of the host-declared QEMU dependency for kernel measurement. |
Review details
- Files reviewed: 20/20 changed files
- Comments generated: 1
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
kvinwang
force-pushed
the
fix/normalize-kernel-setup-header
branch
3 times, most recently
from
September 7, 2026 05:47
4000ea2 to
81ea2ac
Compare
kvinwang
force-pushed
the
fix/normalize-kernel-setup-header
branch
3 times, most recently
from
September 7, 2026 06:58
d86fa02 to
2752622
Compare
…kernel file
QEMU is the boot loader for `-kernel`: it fills in the setup-header fields the
Linux boot protocol expects a boot loader to supply (`type_of_loader`,
`ramdisk_image`/`ramdisk_size`, `heap_end_ptr`, `cmd_line_ptr`) and serves the
result over fw_cfg, where OVMF measures it into RTMR[1].
QEMU commit a7542a38f399 ("x86/loader: Don't update kernel header for CoCo
VMs", first released in 10.2.0) stopped rewriting the header for confidential
guests, so the same kernel measures differently depending on which QEMU the
host runs -- and the host is the one that declares that version.
Remove the dependency for images built from now on. The image build zeroes
those fields in the kernel it ships and records
`"kernel_header_normalized": true` in metadata.json; OVMF zeroes them again
before the kernel blob is measured and loaded. RTMR[1] is then the plain
Authenticode hash of the bzImage in sha256sum.txt, on every QEMU version and at
every guest memory size, which also lifts the `memory_size == 2 GiB ||
>= 0xB0000000` restriction on the no-image-download path.
Existing images are untouched. Their firmware does not normalize, so their
digest still covers QEMU's rewritten copy, and `dstack-mr` keeps measuring them
that way -- byte for byte the same measurement document, so `os_image_hash` and
every on-chain whitelist entry stay valid. Which behavior applies is declared
by the image, never by the host, and both verification paths read it: the
image-download path from metadata.json via `ImageInfo`, the no-image-download
path from the flag mirrored into the measurement document. The flag is omitted
when false, so pre-normalization documents encode exactly as they always did
and the document version does not move.
The normalized field set comes from the boot protocol rather than from QEMU:
every field Documentation/arch/x86/boot.rst types as `write` is one the boot
loader fills in and the kernel supplies no value for. Fields typed `modify`
carry real kernel-supplied values -- code32_start is the protected-mode entry
point -- and are left alone.
In practice this rewrites two bytes. heap_end_ptr is the only `write` field a
built kernel leaves non-zero, and it is read only when the boot loader has set
CAN_USE_HEAP, which the kernel builds clear and this also clears. On the
EFI-stub path the real-mode setup code never runs, and no setup-header field
overlaps the PE headers at 0x40..0x170.
The image build and OVMF each implement the normalization, and they have to agree byte for byte: if they drift, every CVM built from then on fails attestation with an RTMR[1] mismatch and nothing points at why. Parse the field table out of both and compare, then exercise the image-build side against a synthetic bzImage -- including that the `modify` fields it must not touch survive, that it is idempotent, and that a non-bzImage is rejected at build time while OVMF leaves one alone. Run it from the mkosi acceptance suite, which is what `build.sh lint` and the mkosi-build workflow already execute.
The rootfs check only accepted `rootfs.img.verity`, so current images -- which ship `rootfs.img.parted.verity` -- failed with "Unsupported rootfs image format" before QEMU was even started. Both are raw verity disks the initramfs finds by PARTLABEL, so match on the `.verity` suffix. Also emit the detected QEMU version into vm_config. Without it the verifier falls back to 9.1.0, which picks the wrong MEM.PAGE.ADD ordering for a QEMU 8 host and predicts an MRTD the hardware never produced.
…ions
Two captures of the same image from live CVMs, one on QEMU 8.2.2 and one on
10.2.1. 8.2.2 is the load-bearing one: it does rewrite the setup header, so
that capture only passes if the firmware actually undid the rewrite rather than
merely agreeing with itself. 10.2.1 then shows the digest did not move.
MRTD 78cb3ad7... 8ed3f63f... differ -- page-add ordering
RTMR0 68102e7b... 131221a6... differ -- generated ACPI tables
RTMR1 60db95c7... 60db95c7... identical
RTMR2 f873ce9b... f873ce9b... identical
The two existing lite fixtures are untouched and still pass, which is the
regression check for images that predate the normalization.
kvinwang
force-pushed
the
fix/normalize-kernel-setup-header
branch
from
September 7, 2026 07:10
2752622 to
3ff6a57
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Makes RTMR[1] independent of the host's QEMU version, for images built from now
on. Existing images keep their current measurement byte for byte.
Fixes the same root cause as #1183, but by removing the QEMU-version dependency
rather than modelling it — see "Relationship to #1183".
Problem
TDX CVMs on QEMU 10.2 fail verification with an RTMR[1] mismatch:
QEMU is the boot loader for
-kernel: it fills in the setup-header fields theLinux boot protocol expects a boot loader to supply —
type_of_loader,ramdisk_image/ramdisk_size,heap_end_ptr,cmd_line_ptr— and serves theresult over fw_cfg. OVMF measures those bytes into RTMR[1].
Upstream commit
a7542a38f399("x86/loader: Don't update kernel header for CoCo VMs", first in 10.2.0) widened
the pre-existing SEV-only skip to every confidential guest:
Measured on a real image, the rewrite touches 4 regions, 11 bytes:
The real problem is not the arithmetic — it is that what to compute depends on
a version the host declares. A knob the verifier must consult is a knob the
host can lie about.
Fix: normalize on both sides, for new images
os/image/normalize-kernel-header.pyzeroes theboot-loader-written fields in the shipped
bzImage, wired intoos/image/assemble.shso both the Yocto and mkosi backends get it, andrecords
"kernel_header_normalized": trueinmetadata.json.0007-OvmfPkg-QemuKernelLoaderFsDxe-normalize-setup-header.patchzeroes them again in the
kernelblob, before it is measured and loaded.kernel file, on every QEMU version and at every guest memory size. That
lifts the
memory_size == 2 GiB || >= 0xB0000000restriction on theno-image-download path.
Normalization is idempotent, so it lands on the same bytes whether or not QEMU
rewrote anything.
The field set comes from the boot protocol, not from QEMU
That decouples the rule from QEMU's behavior, so it does not go stale when QEMU
changes, and it rules out the obvious trap:
modifyfields carry realkernel-supplied values, and
code32_start(0x214 =0x100000) is theprotected-mode entry point.
It changes two bytes of the kernel
Every
writefield is already zero in a freshly built bzImage exceptheap_end_ptr(0x224 =0x50a0). Zeroing it is safe on every boot path:boot.rsttypes itwrite (obligatory): the boot loader writes it, thekernel supplies no value.
init_heap()reads it only when the boot loader has setCAN_USE_HEAP,which the kernel builds clear and this also clears.
0x40..0x170; no setup-header field overlaps them.Secure Boot is not planned, so there is no signature to invalidate.
Existing images are untouched
dstack-mris embedded in KMS (CvmVerifier), so it must keep verifying everyCVM that is already deployed. Those images ship an OVMF that does not normalize,
and on QEMU ≤ 10.1 — which is all of production today — their RTMR[1] covers
QEMU's rewritten copy. That path is unchanged, including
patch_kernel()andthe memory-size restriction that goes with it.
Regenerating the measurement for a currently shipped image reproduces the
document it already carries byte for byte, so
os_image_hashand everyon-chain whitelist entry stay valid:
Which behavior applies is declared by the image, never by the host:
kernel_header_normalizedinmetadata.jsonImageInfo→Machine::normalized_setup_headerTdxOsImageMeasurementThe flag is omitted from the CBOR when false, so pre-normalization documents
encode exactly as they always did — the document version does not move.
An old image on QEMU ≥ 10.2 stays broken, exactly as it is today; the remedy is
to re-emit it, which is the direction this PR exists to enable.
Verification
Real TDX hardware, both QEMU generations. The same image was booted as a
live CVM on a QEMU 8.2.2 host and on a QEMU 10.2.1 host:
78cb3ad7…8ed3f63f…68102e7b…131221a6…60db95c7…8f79d20060db95c7…8f79d200f873ce9b…f873ce9b…8.2.2 is the load-bearing case: it does rewrite the setup header, so passing
there proves the firmware undid a real rewrite rather than merely agreeing with
itself. 10.2.1 then shows the digest did not move. On both hosts the first
RTMR[1] event is
880962f4…eccaf30— the Authenticode SHA-384 of the shippedbzImage, cross-checked with an independent implementation — and each CVM's ownCCEL event log replays to exactly the RTMRs in its quote.
dstack-verifieraccepts both, over the no-image-download path with anunreachable download URL:
Full
mkosiimage build (./os/mkosi/build.sh image) passes end to end:the OVMF patch applies and the firmware rebuilds,
assemble.shinvokes thenormalization, and the resulting image's kernel, measurement document and
sha256sum.txtare self-consistent.Offline,
normalize(QEMU's rewrite at 1 GiB / 2 GiB / 2.5 GiB / 0xB0000000 / 768 GiB) == normalize(original)for all five, and normalization is idempotent.That is what lets the memory-size restriction go for normalized images.
New tests:
os/tests/test-kernel-header-normalization.shparses the field table out ofboth the image-build script and the OVMF patch and compares them. If they
ever drift, every CVM fails on RTMR[1] with nothing pointing at why. It also
checks the
modifyfields survive, that normalization is idempotent, andthat a non-bzImage or a truncated one is rejected at build time while OVMF
leaves a non-bzImage alone. It runs from the mkosi acceptance suite, i.e.
from
build.sh lintand the existing mkosi-build workflow.dstack-mr: the flag actually reaches the digest — the two branches selectdifferent kernel bytes, and only the pre-normalization one moves with guest
RAM.
dstack-types:ImageInfoparses the flag out ofmetadata.json, andtreats its absence as the pre-normalization behavior. That is the field the
image-download path reads; losing it there is a silent attestation failure.
dstack-types: the flag round-trips, and a pre-normalization document doesnot drift — the flag stays absent and the bytes are unchanged.
tdx-lite-normalized-attestation.jsonandtdx-lite-normalized-qemu-10-2-attestation.jsonpin both captures above, sothe version-independence property is checked in CI rather than asserted.
The two existing lite fixtures are unchanged and still pass, which is the
regression check for deployed images.
os/mkosi/tests/acceptance.shasserts the patch stays applied and theassemble hook stays wired.
Relationship to #1183
#1183 fixes the same bug by recording both digests in the measurement document
and picking one from the host-declared
vm_config.qemu_version. It is theright shape if old images must keep working on QEMU ≥ 10.2.
This PR instead makes the question disappear for new images, and leaves old
images exactly as they are. The two are compatible: if #1183 lands first, this
builds on it and old images gain 10.2 support too.
Drive-by
tools/vm-runnercould not launch current images at all — its rootfs check onlyaccepted
rootfs.img.verity, sorootfs.img.parted.verityfailed with"Unsupported rootfs image format" before QEMU started. It also omitted
qemu_versionfromvm_config, which makes the verifier fall back to 9.1.0 andpick the wrong MEM.PAGE.ADD ordering on a QEMU 8 host. Both fixed; the hardware
capture above went through it.
Full workspace suite, clippy
-D warningsandprekare clean. Follow-upcleanup tracked in #1185.