Skip to content

fix(verifier): the image manifest check accepts lines sha256sum silently skipped, and the --debug RTMR diff is dead code - #1251

Open
kvinwang wants to merge 7 commits into
nextfrom
fix/verifier-measurement-fidelity
Open

kvinwang wants to merge 7 commits into
nextfrom
fix/verifier-measurement-fidelity

Conversation

@kvinwang

Copy link
Copy Markdown
Collaborator

Five defects in the verifier and one documentation correction, each verified against the source and — except where noted — with a test that fails against next.

1. The image manifest check accepts lines sha256sum -c silently skipped

The downloaded image's entire content binding is sha256sum -c sha256sum.txt plus its exit status. Reproduced against coreutils 9.4:

ca978112...  a.txt
0000000000000000000000000000000000000000000000000000000000000000 b.txt   <- one space
$ sha256sum -c sha256sum.txt
a.txt: OK
sha256sum: WARNING: 1 line is improperly formatted
exit=0

b.txt is never checked and the command succeeds. validate_image_manifest_paths uses split_whitespace(), so it accepts the single-space line, and prune_unlisted_image_files also uses split_whitespace().nth(1), so it treats b.txt as listed and keeps it. The file is then measured — bzImage, ovmf.fd, initrd — with no content check at all.

More divergences between the two grammars, all reproduced: <hash> *bzImage makes GNU check bzImage (binary-mode marker) while the whitespace split sees *bzImage, so the file that is checked is the one that gets pruned; <hash> name junk and \<hash> name diverge; CRLF and uppercase hex are accepted by GNU while str::lines() strips the \r.

And a third grammar over the same file: dstack_types::sha256sum_entry_hash matched the second whitespace token and ignored the rest, so trailing tokens were dropped and near-duplicates evaded its duplicate-entry check.

accepted "239f59ed...  measurement.tdx.cbor  junk\n"            (dstack-types)
accepted "44136fa3...  metadata.json\n0000...0000 bzImage\n"     (verifier)

Unified into one grammar rather than just adding --strict. dstack_types::parse_sha256sum_manifest accepts exactly what sha256sum <files> emits — 64 hex digits, two spaces, a flat name, no duplicates, no \, *, /, whitespace or control characters — and sha256sum_entry_hash goes through it. The verifier now hashes each listed file in-process instead of shelling out, which removes the subprocess and the parse differential. os/image/assemble.sh is the only producer and runs plain sha256sum.

Overlaps with #1248, which fixes sha256sum_entry_hash more narrowly (take the whole remainder of the line as the filename). This supersedes it; take one or the other, not both.

2. The --debug RTMR diff has never produced a single entry

All three call sites passed &[] for actual_indices, and both loops inside collect_rtmr_mismatch iterate it — so events is always empty and missing_expected_digests always holds the entire expected sequence. The whole RtmrEventEntry / RtmrEventStatus::{Match,Mismatch,Extra,Missing} machinery is unreachable, and tc-ver-cli-cert-o-004 ("Result schema completeness and diagnostics") passes anyway.

assertion `left == right` failed
  left: []
 right: [(1, "kernel", Match), (2, "kernel-cmdline", Mismatch)]

Fixed with rtmr_event_indices(event_log, rtmr), filtering on TdxEvent::imr in log order.

3. --verify-cert reports "is_valid": true whatever happened

decode_app_info(false).ok() swallows a decode failure and "is_valid" is a literal true, so a certificate whose app-info decoding fails still prints a green result with app_info: null.

left: Bool(true), right: Bool(false)

Now emits is_valid plus reason in the same shape as /verify, writes the result file, and exits non-zero.

4. The measurement cache key is attacker-steerable

vm_config_cache_key hashes the whole serialized VmConfig. Unknown JSON fields are dropped before hashing, but image (an arbitrary string) and the three *_measurement documents (caller-sized checksum_file plus CBOR) are serialized and none of them is read by compute_measurement_details. Varying one byte per request forces a cache miss, a full firmware and kernel hash, ACPI generation, and a new file under <cache>/measurements/.

image moved the cache key

Fixed by clearing those four fields before hashing — clearing rather than allowlisting, so a new VmConfig field stays in the key by default: an extra miss is cheap, a stale measurement is not.

Not addressed: cache eviction. The directory is still unbounded, just no longer attacker-steerable. A bounded or TTL'd cache is worth a separate change.

5. CPU-bound measurement ran on a Rocket worker

Three async fn call sites invoked the hashing synchronously, with no spawn_blocking and no concurrency limit. tpm-qvl/src/collateral.rs already uses spawn_blocking with a comment about exactly this.

The six identical positional arguments are collapsed into an owned MeasurementInputs and measure() runs via spawn_blocking; the cache load and store stay async, since they are small file operations.

Honesty note: this one has no red/green test. Observing the difference needs a real multi-hundred-megabyte image measurement to time, and a synthetic input fails the firmware parse too fast to block anything measurable. It rests on the house rule (#740, #750) and the tpm-qvl precedent.

Not covered by #1205, which adds single-flight downloads and truncation retry only. I stayed out of its hunks.

6. event_log_verified overclaimed — documentation fixed, not the code

The doc comment said "For RTMR 0-2 … only the digests are verified through replay comparison with the quote." Grepping every consumer: the flag is set only after decode_app_info_ex succeeds, which reads the runtime event list replayed against RTMR3. TdxQuote.event_log reaches only the (dead, per defect 2) --debug diff, three ACPI names on the lite path, and validate_v2_preimages, which checks preimage→digest and no register at all.

I argued for fixing the comment rather than the code: RTMR 0-2 are already verified by comparing the quoted values against measurements recomputed from the image, which does not trust the host's event log at all. That is strictly stronger than replaying it. The types.rs doc and verifier/README.md step 2 now say so.

One claim refuted: the xloadflags bit is already fixed on next

The audit reported kernel.rs testing xlf & 0x40 (XLF_5LEVEL_ENABLED) where QEMU tests bit 1. QEMU's side checks out — qemu-8.2.2+ds/hw/i386/x86.c:928 tests XLF_CAN_BE_LOADED_ABOVE_4G, and bootparam.h gives 1<<1 and 1<<6 — but origin/next already reads (xlf & 0x02) with an explanatory comment, fixed by b419782089 (#1229, merged), along with a dedicated only_xlf_bit_1_raises_the_initrd_ceiling. Per #1229's own analysis it would have been fail-closed (initrd ~1.1 GiB off → wrong RTMR1 → legitimate CVM rejected) and no released image changed, because every kernel dstack has shipped sets both bits. No change made here.

Verification

128 tests green across dstack-verifier, dstack-mr and dstack-types. Golden vectors unchanged: cargo test --release -p dstack-mr --test tdvf_parse -- --ignored passes, and both *_does_not_drift tests are unchanged. cargo clippy clean under the CI invocation.

sha256sum.txt is what os_image_hash commits to, and it was read by two
different grammars. sha256sum_entry_hash split on whitespace and matched the
second token, so `<hash>  name  junk` matched a name GNU sha256sum resolves as
`name  junk`, while `<hash> *name` -- valid binary-mode syntax naming `name` --
matched nothing and made the entry look missing.

Accept exactly the line shape `sha256sum <files>` emits, which is what
os/image/assemble.sh runs: 64 hex digits, two spaces, a flat file name, no
duplicates. Everything else is an error instead of a silently different name.
…g sha256sum -c

The whole content binding for a downloaded OS image was `sha256sum -c
sha256sum.txt` plus its exit status. GNU coreutils skips an improperly
formatted line with a warning and still exits 0:

    5891b5...  a.txt
    0000...0000 b.txt        <- one space
    $ sha256sum -c sha256sum.txt
    a.txt: OK
    sha256sum: WARNING: 1 line is improperly formatted
    exit=0

validate_image_manifest_paths split on whitespace, so it accepted that line,
and prune_unlisted_image_files did too, so b.txt survived the prune. bzImage,
ovmf.fd and the initrd were then measured with no content check at all.
`<hash> *bzImage` went wrong the other way: coreutils checks `bzImage`, the
whitespace split sees `*bzImage`, and the file that was checked is the one
that gets pruned.

Parse the manifest once with dstack_types::parse_sha256sum_manifest and hash
each listed file here. One parser, one grammar, and a missing or mismatched
file is an error rather than a warning on someone else's stderr.
collect_rtmr_mismatch takes the indices of the event-log entries that extended
the register, and all three call sites passed `&[]`. Both loops inside iterate
that slice, so `events` came back empty and `missing_expected_digests` held the
entire expected sequence -- for every mismatch, on every RTMR. The whole
RtmrEventEntry / RtmrEventStatus machinery was unreachable, and `--debug`
answered a question the caller had already been told: the two register values.

Pass the indices of the log entries whose `imr` is the register being diffed,
in log order, which is what the function's zip against the expected sequence
expects.
…fo does not decode

`is_valid` was the literal `true` for any certificate whose RA-TLS attestation
verified, and `decode_app_info(false).ok()` threw the decode error away. A
certificate carrying an attestation that verifies but whose app identity does
not decode printed a valid result with `app_info: null` -- no app_id, no
compose_hash, no os_image_hash -- and exited 0.

Report it the way `/verify` does: `is_valid` plus a `reason`, and a non-zero
exit. The result file is still written first, so a caller inspecting it sees
which check failed.
The field documented itself as verifying RTMR 0-2 digests "through replay
comparison with the quote". Nothing in dstack-verifier or dstack-attest replays
a boot-time event log: the flag is set once the runtime event log replays to
its register and the app identity decodes out of its payloads. A TDX quote's
RTMR 0-2 entries reach only the `--debug` diff and the three named ACPI digests
the lite path cross-checks.

Correct the comment rather than the code. Replaying RTMR 0-2 would add nothing:
those registers are already checked against measurements recomputed from the OS
image, which does not trust the host's event log at all, and is the stronger of
the two checks.
vm_config_cache_key hashed the whole VmConfig, including fields the full-image
measurement never reads: an arbitrary `image` string, and the
`tdx_measurement`/`gcp_measurement`/`aws_measurement` documents, each carrying
a caller-sized checksum_file and CBOR blob. A cache miss costs a full firmware
and kernel hash plus ACPI generation and leaves a file under
<cache>/measurements/ that nothing evicts, so one captured quote replayed with
a different filler byte per request misses every time and grows the cache
without bound.

Clear those four before hashing. Clearing rather than listing the fields that
matter keeps a VmConfig field added later in the key by default, which is the
safe direction: an extra miss, never a stale measurement.
compute_measurement_details hashes the firmware and the kernel and generates
the ACPI tables -- pure CPU over a multi-hundred-megabyte image -- and was
called synchronously from three `async fn`s, so it parked a Rocket worker for
the whole computation. tpm-qvl already fetches collateral through
spawn_blocking for the same reason.

Collect the six arguments the three call sites passed identically into an owned
MeasurementInputs and run `measure` through spawn_blocking. The cache lookup
and store stay on the async side: they are small file operations, and keeping
them there avoids cloning the verifier into the task.
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.

1 participant