CVE-2009-1897 is back, now under every @bitCast
How Zig's @bitCast lowering and LLVM's optimizer can synthesize exploitable use-after-free bugs that no source review or EDR will ever see.
There is no CVE for this. Not yet. The surface is the lowering path between Zig’s @bitCast builtin and LLVM IR, recompiled under the opaque pointer model that became default in LLVM 15. The bug class is compiler-introduced memory unsafety - source that is correct under the language’s value semantics, object code that holds an exploitable use-after-free. No CVSS vector is assigned because there is no single advisory. The closest historical analog carries one: CVE-2009-1897, CVSS v2 7.2, where GCC’s optimizer deleted a NULL-pointer check in the Linux tun driver and handed Brad Spengler a clean local-root primitive. The mechanism that produced that bug is back, generalised, and now sits under every reinterpret-cast that crosses a lifetime or aliasing boundary.
@bitCast reinterprets the bit pattern of a value as another type of the same size. No conversion, no range check - the bytes are re-read under a new type. Zig 0.11 collapsed it to a single-argument builtin with result-type inference; the destination type comes from the result location. For scalars that fit in a register it lowers to an LLVM bitcast or a no-op. For aggregates - structs, arrays, unions, anything with a memory layout - it does not. Result-location semantics force the value through memory. The compiler emits an alloca, stores the source representation, and loads it back under the destination type. That stack slot is the start of the problem.
The precedent is well documented. The Linux kernel compiles with -fno-strict-aliasing and -fno-delete-null-pointer-checks for exactly this reason - kernel developers learned that the optimizer’s standard-conformant assumptions delete checks they wrote on purpose. The signed-overflow undefined-behavior class produced a decade of silently removed a + b < a bounds tests. John Regehr’s group catalogued hundreds of these in production C. CWE-733 names the pattern directly: compiler optimization removal or modification of security-critical code. What changes now is the frontend. A memory-safe language presents value semantics to the developer and lowers to the same C-shaped IR underneath. The safety guarantee is a front-end property. The optimizer operates after the guarantee is discharged.
First failure mode: lifetime. Every aggregate-bitcast alloca is bracketed by llvm.lifetime.start and llvm.lifetime.end intrinsics. Those markers tell LLVM’s stack-coloring pass when the slot is live. Slots with non-overlapping lifetimes share storage - this is how the backend keeps stack frames small. The hazard appears when a pointer derived from the bitcast result escapes the marked lifetime. After inlining, the optimizer sees lifetime.end on the slot, concludes the storage is dead, and reuses it for the next object. The escaped pointer now references reallocated stack. CWE-562 in the abstract; a use-after-scope that stack coloring promotes into a genuine UAF in practice. The free was never in the source. The optimizer synthesised it from a lifetime marker the programmer never saw.
Second failure mode: provenance. Opaque pointers removed the typed-pointer bitcast. bitcast i32* to i8* is gone - ptr is ptr. That simplified the IR and removed a class of redundant casts. It did not remove provenance. LLVM still tracks where a pointer came from to decide what it can alias. Round-trip a pointer through an integer - ptrtoint then inttoptr, which is what a bitcast of a pointer-bearing aggregate degrades into - and provenance becomes ambiguous. The optimizer is permitted to assume the reconstructed pointer aliases nothing the original did. Alias analysis returns noalias. Dead-store elimination then removes a store the program will later read, or load elimination folds a stale value the program already overwrote. The memory is correct. The compiler’s model of the memory is not.
Third failure mode: type-based alias analysis. The optimizer assumes two pointers of incompatible types do not alias. A @bitCast exists precisely to read one type’s bytes through another type’s view. When that view feeds code optimized under TBAA - most often Zig linked against C-emitted IR carrying !tbaa metadata - a store through the reinterpreted pointer looks unreachable. Dead-store elimination deletes it. This is the same pass that has been silently removing security-critical memset calls for fifteen years - CWE-14, the reason explicit_bzero and memset_s exist. A buffer scrubbed before free is a dead store to the optimizer. The secret stays in the freed chunk. The next heap groom reads it back.
None of this is a Zig defect. Zig is the frontend that made the reinterpret-cast ergonomic enough to appear everywhere. The latitude lives in the backend, and every LLVM frontend inherits it. Rust’s mem::transmute lowers through the same passes; transmuting a reference across a lifetime is the identical use-after-scope hazard, held back only by the borrow checker’s front-end discipline, which evaporates at the IR boundary. Clang’s reinterpret casts, Swift’s unsafe bit casts - same backend, same provenance model, same stack coloring. The vulnerable surface is not a language. It is the optimizer’s license to rewrite memory operations under assumptions the source never stated. Modern compilers get more aggressive about that license every release. The bug class scales with the optimizer, not the language.
The attacker does not touch the victim’s compiler. The miscompilation is already baked into the shipped binary. What the attacker controls is the input that reaches the affected code path. The primitive is pre-positioned - a dangling pointer the optimizer created, or a stale read the program trusts. Reaching it is an input-shaping problem, not a memory-corruption problem; the corruption is structural. Once the dangling reference is live, the path to control is the standard one: reclaim the freed allocation with attacker-sized data, reuse the confused pointer, convert the read or write into an arbitrary primitive. None of that is novel. What is novel is the origin. The vulnerable state machine was emitted by the toolchain, not authored by the developer.
Severity tracks reachability. A miscompiled UAF in a parser inside a network-facing service lands in the CVSS 8.1-9.8 band - remote, low complexity, no privileges. The same defect in a local utility is a privilege-escalation primitive, the CVE-2009-1897 shape. MITRE ATT&CK maps the exploitation phase, not the bug’s introduction: T1203 for client-side execution through a malicious document or page, T1068 where the target is a privileged process. The introduction phase has no ATT&CK ID, because no adversary action created it. That is the uncomfortable part. The weakness entered through a trusted build step.
Nothing fires. There is no Sysmon event for a deleted bounds check. No Windows Security event for a reused stack slot. EDR observes a normal process executing its own code, because it is executing its own code - the object the developer shipped. The divergence between intent and instruction lives below the source line and above the endpoint sensor. Source SAST is structurally blind; it parses the language, and the language is correct. The defect exists only after lowering and optimization. ASan and MSan builds catch the UAF at test time if the path is exercised. IR-level differential analysis - comparing -O0 and -O2 emission for eliminated stores and collapsed comparisons - surfaces the divergence. Binary diffing across LLVM versions flags code that changed shape with no source change. Crash telemetry catches it last and most expensively.
For a detection engineer the takeaway is a shift in instrumentation point. The control that catches this is not a SIEM correlation rule - it is a CI gate. A sanitizer build (ASan for spatial and temporal heap errors, MSan for uninitialized reads created by an eliminated store) run against a corpus that exercises the bitcast-heavy paths. A reproducible-build check that pins the LLVM version and fails on toolchain drift. A diffing stage that compares optimized IR against a known-good baseline and alerts on disappeared store and icmp instructions in security-relevant functions. None of these live on the endpoint. The endpoint is where this bug is invisible. Pushing detection left into the build is the only place the signal exists before a crash dump or an in-the-wild exploit produces it for free.
There is no patch boundary, because there is no patch. A bug class does not get a point release. Residual exposure is every aggregate @bitCast, every pointer round-trip, every reinterpret view that survives a recompile under a newer LLVM with more aggressive provenance and lifetime analysis. Code that was safe under LLVM 16 can miscompile under LLVM 18 with no source edit - the optimizer simply got better at exploiting the latitude the IR gave it. The fix lives at the semantics layer: provenance-preserving cast lowering, conservative lifetime emission for escaped slots, sanitizer gates that fail the build on a detected UAF, IR diffing across toolchain bumps. CWE-733 and CWE-758 are the right labels - optimizer removal of security-critical code, reliance on undefined behavior. The actor here is not in the network. It is in the toolchain. Escalate accordingly.
Keep Reading
duckdbDuckDB trusts persisted blocks attackers control
DuckDB runs in-process as a C++ library. Its immutability and checksum assumptions create a quiet memory-corruption surface that host EDR never sees.
use-after-freeCypherpunk frees the key schedule twice
UAF in the Cypherpunk Library's context teardown - CWE-416, heap reuse, sandbox-free RCE path, and why EDR misses the corruption stage.
linux-kernelCVE-2026-31337: Dirty Frag roots every major distro
Technical analysis of CVE-2026-31337 'Dirty Frag': a Linux kernel UAF in IP fragment reassembly giving local root across major distros.
Stay in the loop
New writing delivered when it's ready. No schedule, no spam.