reviews/current/astra-20260909-round2/native-entropy-and-memory.md
On this page

Independent native, entropy, and memory review

Reviewed 2026-09-09. Frozen runtime: a10323dede4413fbf295916b8ad12e3dbad7514e.

Result and evidence boundary

I did not demonstrate disclosure of a victim's request/response or early recovery of an epoch key by an in-scope external attacker. This is a bounded review, not a certification of the native implementation or kernel. The known aesDummy race is real at source level. I also demonstrated a concrete native erasure gap: after a RandomX VM's destructor runs, its retained register-file bytes still reconstruct the final hash, which is a segment wrapping secret in production. That experiment does not provide an external memory-read primitive.

I read the current threat model as a claim to test. I did not read other reviewers' reports. I made no production calls or runtime source changes. Only this report and .local/astra-native-round2/ artifacts were written.

git diff --exit-code confirms the working tree's crates/, vendor/, Cargo.toml, Cargo.lock, config/, Rust toolchain, both Dockerfiles, EIF build scripts, and library collector are identical to the frozen commit. HEAD is 85c61e96590b52ded80a995ef5d1e379b2287761; new CI tooling exists after the frozen commit and was not confused with frozen runtime code. Evidence: .local/astra-native-round2/source-evidence.json and empty frozen-runtime.diff.

Production path actually examined

The Graviton Dockerfile targets aarch64-unknown-linux-gnu. The timelock build checks vendored file hashes, then builds RandomX in Release mode. The CMake ARM branch adds jit_compiler_a64.cpp and jit_compiler_a64_static.S and enables -march=armv8-a+crypto. Linux AES detection uses getauxval(AT_HWCAP) (cpu.cpp:82). The Rust binding combines detected JIT/hard-AES flags with V2 and SECURE, and adds FULL_MEM for production (randomx.rs:49-69).

The reached path is cache allocation and Argon2 initialization, AArch64 superscalar JIT generation, dataset initialization, then seven workers independently constructing CompiledVmHardAesSecure VMs. Each hash uses Blake2b, hardware AES scratchpad/program generation, AArch64 program emission, the assembly VM loop, and final AES/Blake2b extraction. I examined the relevant allocator, virtual-memory, dataset, program/register structures, C API, compiled VM, AArch64 emitter and assembly, and Rust ownership/composition. This was not an exhaustive line-by-line audit of every vendored file or of AWS-LC.

The external attacker does not supply RandomX programs or dataset keys to the enclave. Production creates them from direct NSM randomness. HTTP input goes through the relay's parsers, not into a native hash API. Thus a native bug triggered by ordinary pseudorandom programs could still affect production, but an arbitrary handcrafted JIT program is not a demonstrated attacker-controlled input. Offline solvers have a different input boundary.

Findings

N1 — Segment outputs remain reconstructible in destroyed native VMs

Classification: demonstrated secret remanence / defense-in-depth gap; no demonstrated external disclosure. Conditional impact is high if combined with an enclave memory disclosure.

Production's seven worker loops finish with x = vm.hash(...) and return their final x values for segment wrapping (crates/timelock/src/lib.rs:327-348). Rust zeroizes its x, seed, and result owners on destruction. Native VM destruction merely releases the scratchpad; the base destructor is empty (vendor/randomx/src/virtual_machine.cpp:39,101). It does not clear the program, configuration, or register file. getFinalResult computes the final output as Blake2b of the 256-byte register file (virtual_machine.cpp:120-122).

The independent local probe explicitly runs the virtual destructor while retaining allocated storage, copies the register-file bytes at the allocator boundary, and hashes those bytes. It reconstructs the last hash exactly. This avoids accessing freed memory: it demonstrates what is passed to deallocation, not how long a particular allocator preserves freed bytes. Both light and full-memory AArch64 runs report last_hash_recoverable_after_native_destructor=true.

Concrete conditional attack: an attacker who later gains a read of a retained final register file can calculate a segment output with one Blake2b operation, derive that segment's wrapping key, and decrypt its successor seed. The final segment's register file would directly enable epoch-key unwrapping once the manifest is available. This could bypass the intended work even after the Rust result owners have been erased. The missing step is an in-scope, reachable arbitrary-memory disclosure; this review found none. Nitro isolation alone does not supply that read capability.

The native stack's tempHash is also not explicitly wiped (randomx.cpp:392-403), and the scratchpad is released without clearing (allocator.cpp:freeMemory). Native program/JIT state contains additional secret-derived information, although I did not demonstrate equivalent key recovery from those remnants alone. These are concrete reasons to avoid describing Rust Zeroizing owners as complete erasure.

Suggested fix: add an explicit native secret-state cleanup API/destructor path covering register file, scratchpad, program/configuration, and relevant stack temporaries, and make the Rust binding invoke it. Verify at the deallocation boundary. Preserve the distinction between best-effort erasure and resistance to live-memory attacks.

N2 — The known AES probe race has no demonstrated secret-dependent effect

Classification: confirmed source-level C++ data race; no demonstrated key disclosure or work reduction.

virtual_machine.cpp:98-113 defines one static non-atomic aesDummy and performs a load, AES operation, and store in every hard-AES VM allocation. lib.rs:327-348 concurrently constructs seven VMs, so read/write and write/write overlap is reachable during every generation. volatile does not establish synchronization.

The observed source accesses only this probe's fixed global storage. Its value starts from zero and is transformed independently of epoch keys, private seeds, HTTP contents, or VM scratchpads; the result is not used to initialize the VM. I found no source-level path from this race to an attacker-selected pointer, buffer length, skipped RandomX rounds, or secret output. C++ undefined behavior prevents a formal safety guarantee, but treating undefined behavior alone as demonstrated arbitrary code execution or epoch disclosure would overstate the evidence. I did not rerun the earlier reviewer's TSan reproducer or rely on its report.

Fix the probe with local per-construction storage or a synchronized one-time check. Serializing all VM construction in the binding would mitigate this occurrence but would not remove the upstream defect for other callers.

N3 — JIT permission changes silently ignore failures

Classification: confirmed error-handling weakness; no demonstrated confidentiality breach.

vendor/randomx/src/virtual_memory.c:159-203 has pageProtect return mprotect errors, but setPagesRW, setPagesRX, and setPagesRWX discard them. Secure VMs therefore do not validate that each W-to-X or X-to-W transition succeeded. The reached secure path allocates pages RW, writes, then requests RX; later generations switch back to RW.

In that sequence a failed RX transition ordinarily leaves non-executable RW memory and faults on execution; a failed RW transition leaves RX memory and faults on writing. I found no demonstrated path that converts this into a silently executable writable page or a key leak. Parent-triggered resource pressure could make unchecked transitions an availability problem, but no such failure was induced here. Return and propagate these errors rather than relying on a later fault. This is a lower-priority correctness/hardening finding than a confidentiality exploit.

Entropy and kernel integration

attest.rs:215-243 builds the same 64-bit NSM ioctl ABI as the locally installed pinned aws-nitro-enclaves-nsm-api 0.5.2: two iovecs, a nine-byte CBOR GetRandom request, and a 0x3000-byte response allocation. It validates returned length before slicing, borrows the CBOR byte string, bounds it to 1–256 bytes, and keeps the raw buffer and copied random chunk in zeroizing storage. Failure has no production OS fallback. The destination is cleared first; partially collected bytes remain only in a zeroizing temporary and are not committed on error. The 16-chunk budget bounds short-response behavior. The ioctl device is local; malformed NSM bytes are not directly selectable by the parent under the stated trust assumptions.

The TLS provider, TLS identity, and signing seed are initialized after seed_os_rng() returns (v2_main.rs:149-172). The signing seed, epoch key, seven seeds, dataset key, and wrap nonces use direct fallible NSM fills. Tokio startup can consume OS randomness before this gate for runtime internals, but I found no production TLS/signing/epoch secret generation before it. The native RandomX implementation itself does not substitute a random source for the private inputs.

For Linux reseeding, the code credits 2048 NSM entropy bits, checks at least 256 bits remain, forces CRNG reseeding, waits 20 ms, and repeats. I fetched the official kernel config at the pinned Nitro CLI source commit: SHA-256 424e97785438d01e1436e738df0192142d81657a7b11d008f2cb9e5637b6354b, matching the repository's tooling lock. It has HZ=250 and NUMA enabled. In the upstream Linux 4.14.256 RNG implementation, RNDRESEEDCRNG updates the global invalidation timestamp to jiffies - 1, while secondary CRNGs reseed when it is strictly newer than their initialization time. A second reseed after several ticks addresses the same-tick secondary-state issue. This is source-comparison support, not a source-to-binary proof of the Amazon kernel or a dynamic Nitro entropy test.

The entropy count check and reseed ioctl are not one transaction, but I found no attacker-controlled concurrent guest entropy drainer at boot. Kernel entropy operations can leave additional internal copies (for example write_pool's stack buffer); user-space wiping does not erase kernel copies. No externally reachable read of those copies was demonstrated.

FFI, parsers, diagnostics, and side channels

The binding's Dataset: Sync permits shared immutable access after initialization. Each worker creates and mutably owns its VM; the VM borrows the dataset through PhantomData, preventing dataset destruction first. There is no Send/Sync implementation allowing one VM to be shared by workers. Full dataset initialization uses the library's entire item count, and hash output storage is exactly 32 bytes. Apart from aesDummy, I found no shared writable cache/dataset state reached during full-memory hashing. Constructor allocation failures are generally converted to null pointers; exceptions in other C API operations are not uniformly caught and could abort across the Rust boundary under resource exhaustion, but I did not demonstrate disclosure through this.

AArch64 JIT instruction register selectors are reduced modulo eight before emission. Scratchpad reads/writes are masked to their configured regions; dataset reads combine an aligned base-range mask with a bounded extra-item offset. The assembly reserves RANDOMX_PROGRAM_MAX_SIZE * 12 instruction slots and separate literal space. The boundary probe below is useful negative evidence, not a proof of all code/literal bounds; generated assembly is not ASan-instrumented.

The v2 relay catches upstream and dispatch errors and returns fixed strings. Its diagnostics queue accepts fixed static strings and main substitutes a fixed terminal error. I did not find a request URL, response body, or native state interpolated into the parent diagnostics channel. Rust's default panic hook still exists, but a panic message containing private data is not enough without a parent-visible sink; I did not establish such a leak in the measured nondebug setup.

There are ordinary unwiped Rust copies of request/response content: Exchange, base64 strings, the JSON record, the intermediate CBOR value, and response Bytes (v2_proxy.rs:14-29,105-113,190-216). Only the final encoded plaintext owner is wrapped in Zeroizing. Their persistence broadens the consequences of a future heap disclosure, but ordinary allocator reuse through safe initialized buffers does not itself reveal them to another client.

RandomX is deliberately data-dependent. Here its inputs include secret seed/chain state; the JIT code, branches, and memory accesses therefore depend on secrets. A malicious operator could attempt shared-cache contention measurements, and anonymous clients can measure their own request latency while generation runs. However, I have not established the necessary Graviton/Nitro cache observability, trace resolution, or a reconstruction method for a 256-bit chain state. This remains a concrete investigation target with a missing attack demonstration, not a claimed delay bypass. Hard-AES removes a software AES lookup path from the production hash path but does not make RandomX constant-time.

Local validation and limits

Artifacts are in .local/astra-native-round2/:

The probe is built with -fsanitize=address,undefined and -march=armv8-a+crypto; native sources were not edited. It ran on local macOS ARM64, not production Linux/Graviton. Thus it exercises AArch64 code generation and execution, including the full dataset initializer, but uses a different allocator, OS permission implementation, compiler, and CPU. Synthetic emitter cases are intentionally more controllable than enclave inputs and do not constitute exploitation. No continuous fuzzing, binary audit of the EIF/kernel, hardware side-channel experiment, or exhaustive dependency audit was performed.

The current threat model already limits erasure and side-channel claims. N1 makes that limitation concrete and actionable. It should remain explicitly classified as a memory-remanence weakness unless a reachable disclosure primitive is demonstrated. The evidence here does not justify claiming either that the confidentiality objective is broken or that it has been proved.