Conversation
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.
This was referenced Sep 20, 2026
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.
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 -csilently skippedThe downloaded image's entire content binding is
sha256sum -c sha256sum.txtplus its exit status. Reproduced against coreutils 9.4:b.txtis never checked and the command succeeds.validate_image_manifest_pathsusessplit_whitespace(), so it accepts the single-space line, andprune_unlisted_image_filesalso usessplit_whitespace().nth(1), so it treatsb.txtas 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> *bzImagemakes GNU checkbzImage(binary-mode marker) while the whitespace split sees*bzImage, so the file that is checked is the one that gets pruned;<hash> name junkand\<hash> namediverge; CRLF and uppercase hex are accepted by GNU whilestr::lines()strips the\r.And a third grammar over the same file:
dstack_types::sha256sum_entry_hashmatched the second whitespace token and ignored the rest, so trailing tokens were dropped and near-duplicates evaded its duplicate-entry check.Unified into one grammar rather than just adding
--strict.dstack_types::parse_sha256sum_manifestaccepts exactly whatsha256sum <files>emits — 64 hex digits, two spaces, a flat name, no duplicates, no\,*,/, whitespace or control characters — andsha256sum_entry_hashgoes 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.shis the only producer and runs plainsha256sum.2. The
--debugRTMR diff has never produced a single entryAll three call sites passed
&[]foractual_indices, and both loops insidecollect_rtmr_mismatchiterate it — soeventsis always empty andmissing_expected_digestsalways holds the entire expected sequence. The wholeRtmrEventEntry/RtmrEventStatus::{Match,Mismatch,Extra,Missing}machinery is unreachable, andtc-ver-cli-cert-o-004("Result schema completeness and diagnostics") passes anyway.Fixed with
rtmr_event_indices(event_log, rtmr), filtering onTdxEvent::imrin log order.3.
--verify-certreports"is_valid": truewhatever happeneddecode_app_info(false).ok()swallows a decode failure and"is_valid"is a literaltrue, so a certificate whose app-info decoding fails still prints a green result withapp_info: null.Now emits
is_validplusreasonin the same shape as/verify, writes the result file, and exits non-zero.4. The measurement cache key is attacker-steerable
vm_config_cache_keyhashes the whole serializedVmConfig. Unknown JSON fields are dropped before hashing, butimage(an arbitrary string) and the three*_measurementdocuments (caller-sizedchecksum_fileplus CBOR) are serialized and none of them is read bycompute_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/.Fixed by clearing those four fields before hashing — clearing rather than allowlisting, so a new
VmConfigfield 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 fncall sites invoked the hashing synchronously, with nospawn_blockingand no concurrency limit.tpm-qvl/src/collateral.rsalready usesspawn_blockingwith a comment about exactly this.The six identical positional arguments are collapsed into an owned
MeasurementInputsandmeasure()runs viaspawn_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-qvlprecedent.Not covered by #1205, which adds single-flight downloads and truncation retry only. I stayed out of its hunks.
6.
event_log_verifiedoverclaimed — documentation fixed, not the codeThe 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_exsucceeds, which reads the runtime event list replayed against RTMR3.TdxQuote.event_logreaches only the (dead, per defect 2)--debugdiff, three ACPI names on the lite path, andvalidate_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.rsdoc andverifier/README.mdstep 2 now say so.One claim refuted: the
xloadflagsbit is already fixed onnextThe audit reported
kernel.rstestingxlf & 0x40(XLF_5LEVEL_ENABLED) where QEMU tests bit 1. QEMU's side checks out —qemu-8.2.2+ds/hw/i386/x86.c:928testsXLF_CAN_BE_LOADED_ABOVE_4G, andbootparam.hgives1<<1and1<<6— butorigin/nextalready reads(xlf & 0x02)with an explanatory comment, fixed byb419782089(#1229, merged), along with a dedicatedonly_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-mranddstack-types. Golden vectors unchanged:cargo test --release -p dstack-mr --test tdvf_parse -- --ignoredpasses, and both*_does_not_drifttests are unchanged.cargo clippyclean under the CI invocation.