Skip to content

fix: an SMBIOS string turns off the guest's own attestation checks, and a host-shared size limit that is only a stat - #1285

Open
kvinwang wants to merge 2 commits into
nextfrom
fix/host-app-boundary
Open

kvinwang wants to merge 2 commits into
nextfrom
fix/host-app-boundary

Conversation

@kvinwang

Copy link
Copy Markdown
Collaborator

Problem

Two places on the host→guest boundary where a check exists but the untrusted host decides whether it applies.

1. An SMBIOS string picks the attestation variant, and one variant measures nothing

detect_tee_variant() (dstack/dstack-attest/src/attestation.rs) resolves the platform from Platform::detect_or_dstack(), which reads /sys/class/dmi/id/product_name and sys_vendor. QEMU takes those strings from host-side configuration — configure_smbios in dstack/vmm/src/app/qemu.rs:1083, fed by [cvm.product] in vmm.toml — and no TDX or SEV-SNP measurement covers SMBIOS. It is a host claim, not evidence.

Platform::NitroEnclave was the one arm that asked for nothing else. Naming it selects TeeVariant::DstackNitroEnclave, and that variant turns off two guest-side checks:

  • TeeVariant::has_tdx() is false and tpm_event_pcr_and_bank() is None, so emit_runtime_event extends no measurement register and still returns Okapp-id, compose-hash, instance-id, init-script-hash, key-provider and the rest are appended to the userspace log and measured nowhere;
  • verify_mr_config_id_for_mode returns Ok(()) for it without reading MR_CONFIG_ID at all (dstack-util/src/system_setup/config_id_verifier.rs:96-99), so the whole compose-hash/app-id/key-provider-id binding is skipped.

Reproduced on origin/next on a machine with no TDX, no SEV-SNP and no /dev/nsm, with only the SMBIOS product name replaced:

$ printf 'Nitro Enclave\n' > /tmp/fake_product_name
$ unshare -Urm sh -c 'mount --bind /tmp/fake_product_name /sys/class/dmi/id/product_name \
    && cargo test -p dstack-attest --lib a_dmi_string_alone -- --nocapture'

running 1 test
product_name = Ok("Nitro Enclave\n")
/dev/nsm exists = false
detect_tee_variant() = Ok(DstackNitroEnclave)

thread '...::a_dmi_string_alone_cannot_select_the_nitro_enclave_variant' panicked at
dstack-attest/src/attestation.rs:3312:9:
DMI alone selected DstackNitroEnclave with no NSM device present
test result: FAILED. 0 passed; 1 failed; ...

The code already knows DMI is not authoritative here: Platform::detect checks /dev/nsm before DMI, with the comment "/dev/nsm is the authoritative Nitro Enclave ABI". The DMI fallback then admits the claim without it.

2. The host-shared size limit is a stat, and the thing it stats need not be a file

HostShared::copy (dstack/dstack-util/src/system_setup.rs) took src_path.metadata()?.len(), compared it to max_size, and then ran an unbounded std::io::copy from a path opened O_NOFOLLOW. The source is the host share; cvm.host_share_mode defaults to 9p (dstack/vmm/vmm.toml:106), so the host serves every read live and decides what kind of file each name is.

A FIFO stats as zero bytes and passes the limit. Opening it without O_NONBLOCK blocks until a writer appears, and once one does, io::copy reads it with no limit into the guest's tmpfs. dstack-prepare.service carries FailureAction=reboot, so either outcome is a reboot loop the host triggers on demand rather than an error message.

Pre-fix, with the origin/next copy body and the new tests:

running 4 tests
test system_setup::host_shared_copy_tests::a_fifo_is_refused_without_blocking_or_reading_it ...
thread '...' panicked at dstack-util/src/system_setup.rs:4347:14:
the copy neither finished nor failed within 10s: it is blocked on the FIFO open,
or reading it with no limit: Timeout
FAILED
test system_setup::host_shared_copy_tests::a_file_over_the_limit_is_refused_rather_than_truncated ... ok
test system_setup::host_shared_copy_tests::a_regular_file_within_the_limit_is_copied ... ok
test system_setup::host_shared_copy_tests::a_symlink_is_still_refused ... ok

test result: FAILED. 3 passed; 1 failed; ...

Fix

resolve_tee_variant(platform, devices) replaces the inline match. The platform hint may only select among variants the hardware corroborates: NitroEnclave now requires /dev/nsm, and is refused outright when a TDX or SEV-SNP device is present, because the variant it selects measures no runtime event. The other arms keep the device requirements they already had (Gcp needs TDX, AwsEc2 needs a TPM), now expressed against one TeeDevices struct so the function is testable without a real platform.

copy_host_shared_file(src, dst, max_size) opens with O_NOFOLLOW | O_NONBLOCK, checks the file type on the open descriptor rather than the path, and applies the limit to the copy via Read::take(max_size + 1) — reading one byte past the limit is what distinguishes "fits" from "was truncated", so an over-long file is refused rather than silently arriving as its first max_size bytes. The destination now gets truncate(true).

Compatibility

Neither change touches a wire format, a derived key, or a measurement. No SDK is affected.

  • resolve_tee_variant: rejects input that previously worked only in the case a guest claims Nitro Enclave without exposing /dev/nsm. A real Nitro Enclave exposes it, and Attestation::quote already fails without it — so every configuration that could previously produce a quote still can; what changes is that the ones that could not now fail at detection with a clear message instead of silently skipping two checks first. dstack-tee-simulator is unaffected: its Nitro Enclave mode creates /dev/nsm in init_done before it notifies systemd READY, and the unit is Type=notify Before=dstack-prepare.service.
  • copy_host_shared_file: rejects a host-shared entry that is not a regular file, and a file larger than the documented per-file limit. Both were already contract violations; the second was already supposed to be rejected and was rejected whenever stat told the truth.

Verification

cd dstack
cargo test -p dstack-util -p dstack-attest    # 170 passed, 0 failed
cargo clippy --workspace --lib --bins -- -D warnings --allow unused_variables   # clean
cargo fmt --all --check                       # clean

No simulator and no DSTACK_SIMULATOR_ENDPOINT are needed for any of the above. The DMI reproduction needs unshare -Urm (unprivileged user namespaces); the committed test passes trivially without it and is documented with the command that makes it bite. Nothing under os/ is touched, so os/tests/acceptance.sh was not run. No guest image was built: the emit_runtime_event-measures-nothing and verify_mr_config_id-returns-Ok consequences are established by reading TeeVariant::has_tdx/tpm_event_pcr_and_bank and verify_mr_config_id_for_mode, not by booting a CVM.

Test-merge against open PRs

PR branch result
#1233 fix/guest-secret-file-modes conflictdstack-util/src/system_setup.rs
#1243 fix/guest-boot-and-env clean
#1254 fix/guest-exposure-surfaces clean
#1257 fix/guest-boot-chain clean
#1263 fix/guest-agent-residuals clean
#1264 fix/guest-setup-residuals clean
#1266 fix/verifier-result-contract clean
#1274 fix/guest-initramfs-hardening clean
#1275 fix/verifier-input-canonicalization clean
#1280 fix/config-parser-bounds clean

#1233 resolution: both branches extract the same function from the same closure, which is why they collide. #1233's signature is the better one — it takes ignore_missing too, so the whole closure body moves out. Take #1233's signature and call site, and this branch's body: keep #1233's exists()/ignore_missing guard and its truncate(true), replace the metadata()-then-io::copy pair with the O_NOFOLLOW | O_NONBLOCK open, the file-type check on the descriptor, and take(max_size + 1). The four tests in host_shared_copy_tests then need ignore_missing: false added at each call. No semantic overlap otherwise.

Not in this PR, because #1254 already does it: mount_host_shared mounts the host share -o ro with no nosuid,nodev,noexec, so on 9p a device node or a setuid bit on the share is live in the guest. #1254 adds exactly HOST_SHARED_MOUNT_OPTIONS = "ro,nosuid,nodev,noexec" on both the disk and 9p paths, with tests. This branch had the same change and it was dropped rather than raced.

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