From bdd6ecba9b03228947972c656bae0db759bdb2c5 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Mon, 27 Apr 2026 00:57:39 +0000 Subject: [PATCH 1/2] std: add AddressSanitizer poisoning to allocators and ArrayList Adds std.debug.Asan with inline wrappers around the ASAN runtime's manual-poison API (__asan_poison_memory_region / unpoison / __sanitizer_annotate_contiguous_container). All calls are no-ops when builtin.sanitize_address is false. Instrumented: - ArrayList / ArrayListUnmanaged: annotate [len..capacity] as poisoned via the contiguous-container API, mirroring libc++ vector. Catches out-of-bounds reads/writes into reserved capacity. - FixedBufferAllocator: poison freed regions and the previously-used range on reset(). Adds deinit() to unpoison the backing buffer. Does not eagerly poison on init() since Zig does not yet emit per-frame shadow cleanup, which would leave stale poison on stack buffers. - ArenaAllocator: poison the unused tail of each buffer node and freed/reset regions; unpoison before returning memory to the child allocator. - MemoryPool: poison destroyed items past the free-list link; unpoison on create(). Catches use-after-destroy. - DebugAllocator: poison freed bucket slots and the unallocated tail of fresh pages; unpoison before returning pages to the backing allocator. - StackFallbackAllocator: poison on free from the fixed buffer. Detection tests in std/debug/asan_test.zig verify each via Asan.isPoisoned(); they skip when ASAN is disabled. --- lib/std/array_list.zig | 122 ++++++++++++++++++++++-- lib/std/debug.zig | 2 + lib/std/debug/Asan.zig | 81 ++++++++++++++++ lib/std/debug/asan_test.zig | 129 ++++++++++++++++++++++++++ lib/std/heap.zig | 37 +++++++- lib/std/heap/FixedBufferAllocator.zig | 27 +++++- lib/std/heap/arena_allocator.zig | 32 ++++++- lib/std/heap/debug_allocator.zig | 17 ++++ lib/std/heap/memory_pool.zig | 25 +++++ 9 files changed, 456 insertions(+), 16 deletions(-) create mode 100644 lib/std/debug/Asan.zig create mode 100644 lib/std/debug/asan_test.zig diff --git a/lib/std/array_list.zig b/lib/std/array_list.zig index f15e59a42402..aa558809bbb8 100644 --- a/lib/std/array_list.zig +++ b/lib/std/array_list.zig @@ -6,6 +6,7 @@ const mem = std.mem; const math = std.math; const Allocator = mem.Allocator; const ArrayList = std.ArrayList; +const Asan = std.debug.Asan; /// Deprecated. pub fn Managed(comptime T: type) type { @@ -40,6 +41,28 @@ pub fn AlignedManaged(comptime T: type, comptime alignment: ?mem.Alignment) type return if (alignment) |a| ([:s]align(a.toByteUnits()) T) else [:s]T; } + /// ASAN: re-annotate the backing buffer so that `[0..items.len)` is + /// addressable and `[items.len..capacity)` is poisoned. `old_len` must + /// be the previous `items.len` (or `capacity` for a freshly-unpoisoned + /// buffer). No-op when ASAN is disabled or `T` is zero-sized. + inline fn asanAnnotate(self: Self, old_len: usize) void { + if (!Asan.enabled or @sizeOf(T) == 0 or self.capacity == 0) return; + Asan.annotateContiguousContainer( + @ptrCast(self.items.ptr), + self.capacity * @sizeOf(T), + old_len * @sizeOf(T), + self.items.len * @sizeOf(T), + ); + } + + /// ASAN: unpoison the entire allocated buffer. Called before handing + /// memory back to an allocator (free/remap) or to external code so that + /// non-ASAN-aware consumers do not fault on the spare-capacity bytes. + inline fn asanUnpoisonAll(self: Self) void { + if (!Asan.enabled or @sizeOf(T) == 0 or self.capacity == 0) return; + Asan.unpoison(@ptrCast(self.items.ptr), self.capacity * @sizeOf(T)); + } + /// Deinitialize with `deinit` or use `toOwnedSlice`. pub fn init(gpa: Allocator) Self { return Self{ @@ -61,6 +84,7 @@ pub fn AlignedManaged(comptime T: type, comptime alignment: ?mem.Alignment) type /// Release all allocated memory. pub fn deinit(self: Self) void { if (@sizeOf(T) > 0) { + self.asanUnpoisonAll(); self.allocator.free(self.allocatedSlice()); } } @@ -102,6 +126,7 @@ pub fn AlignedManaged(comptime T: type, comptime alignment: ?mem.Alignment) type const allocator = self.allocator; const old_memory = self.allocatedSlice(); + self.asanUnpoisonAll(); if (allocator.remap(old_memory, self.items.len)) |new_items| { self.* = init(allocator); return new_items; @@ -148,6 +173,7 @@ pub fn AlignedManaged(comptime T: type, comptime alignment: ?mem.Alignment) type pub fn insertAssumeCapacity(self: *Self, i: usize, item: T) void { assert(self.items.len < self.capacity); self.items.len += 1; + self.asanAnnotate(self.items.len - 1); @memmove(self.items[i + 1 .. self.items.len], self.items[i .. self.items.len - 1]); self.items[i] = item; @@ -174,9 +200,11 @@ pub fn AlignedManaged(comptime T: type, comptime alignment: ?mem.Alignment) type // extra capacity. const new_capacity = Aligned(T, alignment).growCapacity(self.capacity, new_len); const old_memory = self.allocatedSlice(); + self.asanUnpoisonAll(); if (self.allocator.remap(old_memory, new_capacity)) |new_memory| { self.items.ptr = new_memory.ptr; self.capacity = new_memory.len; + self.asanAnnotate(self.capacity); return addManyAtAssumeCapacity(self, index, count); } @@ -189,6 +217,7 @@ pub fn AlignedManaged(comptime T: type, comptime alignment: ?mem.Alignment) type self.allocator.free(old_memory); self.items = new_memory[0..new_len]; self.capacity = new_memory.len; + self.asanAnnotate(self.capacity); // The inserted elements at `new_memory[index..][0..count]` have // already been set to `undefined` by memory allocation. return new_memory[index..][0..count]; @@ -207,6 +236,7 @@ pub fn AlignedManaged(comptime T: type, comptime alignment: ?mem.Alignment) type assert(self.capacity >= new_len); const to_move = self.items[index..]; self.items.len = new_len; + self.asanAnnotate(new_len - count); @memmove(self.items[index + count ..][0..to_move.len], to_move); const result = self.items[index..][0..count]; @memset(result, undefined); @@ -303,6 +333,7 @@ pub fn AlignedManaged(comptime T: type, comptime alignment: ?mem.Alignment) type const new_len = old_len + items.len; assert(new_len <= self.capacity); self.items.len = new_len; + self.asanAnnotate(old_len); @memcpy(self.items[old_len..][0..items.len], items); } @@ -326,6 +357,7 @@ pub fn AlignedManaged(comptime T: type, comptime alignment: ?mem.Alignment) type const new_len = old_len + items.len; assert(new_len <= self.capacity); self.items.len = new_len; + self.asanAnnotate(old_len); @memcpy(self.items[old_len..][0..items.len], items); } @@ -386,18 +418,22 @@ pub fn AlignedManaged(comptime T: type, comptime alignment: ?mem.Alignment) type /// have a more optimal memset codegen in case it has a repeated byte pattern. /// Asserts that the list can hold the additional items. pub inline fn appendNTimesAssumeCapacity(self: *Self, value: T, n: usize) void { - const new_len = self.items.len + n; + const old_len = self.items.len; + const new_len = old_len + n; assert(new_len <= self.capacity); - @memset(self.items.ptr[self.items.len..new_len], value); self.items.len = new_len; + self.asanAnnotate(old_len); + @memset(self.items.ptr[old_len..new_len], value); } /// Adjust the list length to `new_len`. /// Additional elements contain the value `undefined`. /// Invalidates element pointers if additional memory is needed. pub fn resize(self: *Self, new_len: usize) Allocator.Error!void { + const old_len = self.items.len; try self.ensureTotalCapacity(new_len); self.items.len = new_len; + self.asanAnnotate(old_len); } /// Reduce allocated capacity to `new_len`. @@ -414,16 +450,21 @@ pub fn AlignedManaged(comptime T: type, comptime alignment: ?mem.Alignment) type /// Asserts that the new length is less than or equal to the previous length. pub fn shrinkRetainingCapacity(self: *Self, new_len: usize) void { assert(new_len <= self.items.len); + const old_len = self.items.len; self.items.len = new_len; + self.asanAnnotate(old_len); } /// Invalidates all element pointers. pub fn clearRetainingCapacity(self: *Self) void { + const old_len = self.items.len; self.items.len = 0; + self.asanAnnotate(old_len); } /// Invalidates all element pointers. pub fn clearAndFree(self: *Self) void { + self.asanUnpoisonAll(); self.allocator.free(self.allocatedSlice()); self.items.len = 0; self.capacity = 0; @@ -461,6 +502,7 @@ pub fn AlignedManaged(comptime T: type, comptime alignment: ?mem.Alignment) type // the allocator implementation would pointlessly copy our // extra capacity. const old_memory = self.allocatedSlice(); + self.asanUnpoisonAll(); if (self.allocator.remap(old_memory, new_capacity)) |new_memory| { self.items.ptr = new_memory.ptr; self.capacity = new_memory.len; @@ -471,6 +513,7 @@ pub fn AlignedManaged(comptime T: type, comptime alignment: ?mem.Alignment) type self.items.ptr = new_memory.ptr; self.capacity = new_memory.len; } + self.asanAnnotate(self.capacity); } /// Modify the array so that it can hold at least `additional_count` **more** items. @@ -483,7 +526,9 @@ pub fn AlignedManaged(comptime T: type, comptime alignment: ?mem.Alignment) type /// The new elements have `undefined` values. /// Never invalidates element pointers. pub fn expandToCapacity(self: *Self) void { + const old_len = self.items.len; self.items.len = self.capacity; + self.asanAnnotate(old_len); } /// Increase length by 1, returning pointer to the new item. @@ -502,6 +547,7 @@ pub fn AlignedManaged(comptime T: type, comptime alignment: ?mem.Alignment) type pub fn addOneAssumeCapacity(self: *Self) *T { assert(self.items.len < self.capacity); self.items.len += 1; + self.asanAnnotate(self.items.len - 1); return &self.items[self.items.len - 1]; } @@ -524,6 +570,7 @@ pub fn AlignedManaged(comptime T: type, comptime alignment: ?mem.Alignment) type assert(self.items.len + n <= self.capacity); const prev_len = self.items.len; self.items.len += n; + self.asanAnnotate(prev_len); return self.items[prev_len..][0..n]; } @@ -546,6 +593,7 @@ pub fn AlignedManaged(comptime T: type, comptime alignment: ?mem.Alignment) type assert(self.items.len + n <= self.capacity); const prev_len = self.items.len; self.items.len += n; + self.asanAnnotate(prev_len); return self.items[prev_len..][0..n]; } @@ -555,6 +603,7 @@ pub fn AlignedManaged(comptime T: type, comptime alignment: ?mem.Alignment) type if (self.items.len == 0) return null; const val = self.items[self.items.len - 1]; self.items.len -= 1; + self.asanAnnotate(self.items.len + 1); return val; } @@ -570,6 +619,10 @@ pub fn AlignedManaged(comptime T: type, comptime alignment: ?mem.Alignment) type /// Note that such an operation must be followed up with a direct /// modification of `self.items.len`. pub fn unusedCapacitySlice(self: Self) []T { + // Callers are expected to write directly into this region; unpoison + // it so ASAN does not flag those writes. The next length-changing + // operation will re-establish the poison boundary. + self.asanUnpoisonAll(); return self.allocatedSlice()[self.items.len..]; } @@ -629,6 +682,28 @@ pub fn Aligned(comptime T: type, comptime alignment: ?mem.Alignment) type { return if (alignment) |a| ([:s]align(a.toByteUnits()) T) else [:s]T; } + /// ASAN: re-annotate the backing buffer so that `[0..items.len)` is + /// addressable and `[items.len..capacity)` is poisoned. `old_len` must + /// be the previous `items.len` (or `capacity` for a freshly-unpoisoned + /// buffer). No-op when ASAN is disabled or `T` is zero-sized. + inline fn asanAnnotate(self: Self, old_len: usize) void { + if (!Asan.enabled or @sizeOf(T) == 0 or self.capacity == 0) return; + Asan.annotateContiguousContainer( + @ptrCast(self.items.ptr), + self.capacity * @sizeOf(T), + old_len * @sizeOf(T), + self.items.len * @sizeOf(T), + ); + } + + /// ASAN: unpoison the entire allocated buffer. Called before handing + /// memory back to an allocator (free/remap) or to external code so that + /// non-ASAN-aware consumers do not fault on the spare-capacity bytes. + inline fn asanUnpoisonAll(self: Self) void { + if (!Asan.enabled or @sizeOf(T) == 0 or self.capacity == 0) return; + Asan.unpoison(@ptrCast(self.items.ptr), self.capacity * @sizeOf(T)); + } + /// Initialize with capacity to hold `num` elements. /// The resulting capacity will equal `num` exactly. /// Deinitialize with `deinit` or use `toOwnedSlice`. @@ -652,6 +727,7 @@ pub fn Aligned(comptime T: type, comptime alignment: ?mem.Alignment) type { /// Release all allocated memory. pub fn deinit(self: *Self, gpa: Allocator) void { + self.asanUnpoisonAll(); gpa.free(self.allocatedSlice()); self.* = undefined; } @@ -684,6 +760,7 @@ pub fn Aligned(comptime T: type, comptime alignment: ?mem.Alignment) type { /// Its capacity is cleared, making deinit() safe but unnecessary to call. pub fn toOwnedSlice(self: *Self, gpa: Allocator) Allocator.Error!Slice { const old_memory = self.allocatedSlice(); + self.asanUnpoisonAll(); if (gpa.remap(old_memory, self.items.len)) |new_items| { self.* = .empty; return new_items; @@ -733,6 +810,7 @@ pub fn Aligned(comptime T: type, comptime alignment: ?mem.Alignment) type { pub fn insertAssumeCapacity(self: *Self, i: usize, item: T) void { assert(self.items.len < self.capacity); self.items.len += 1; + self.asanAnnotate(self.items.len - 1); @memmove(self.items[i + 1 .. self.items.len], self.items[i .. self.items.len - 1]); self.items[i] = item; @@ -785,6 +863,7 @@ pub fn Aligned(comptime T: type, comptime alignment: ?mem.Alignment) type { assert(self.capacity >= new_len); const to_move = self.items[index..]; self.items.len = new_len; + self.asanAnnotate(new_len - count); @memmove(self.items[index + count ..][0..to_move.len], to_move); const result = self.items[index..][0..count]; @memset(result, undefined); @@ -874,6 +953,7 @@ pub fn Aligned(comptime T: type, comptime alignment: ?mem.Alignment) type { @memmove(self.items[after_range - extra ..][0..src.len], src); @memset(self.items[self.items.len - extra ..], undefined); self.items.len -= extra; + self.asanAnnotate(self.items.len + extra); } } @@ -952,6 +1032,7 @@ pub fn Aligned(comptime T: type, comptime alignment: ?mem.Alignment) type { const len = end - start; // safety checks final `sorted_indexes` are in range @memmove(self.items[start - shift ..][0..len], self.items[start..][0..len]); self.items.len = end - shift; + self.asanAnnotate(end); } /// Removes the element at the specified index and returns it. @@ -984,6 +1065,7 @@ pub fn Aligned(comptime T: type, comptime alignment: ?mem.Alignment) type { const new_len = old_len + items.len; assert(new_len <= self.capacity); self.items.len = new_len; + self.asanAnnotate(old_len); @memcpy(self.items[old_len..][0..items.len], items); } @@ -1015,6 +1097,7 @@ pub fn Aligned(comptime T: type, comptime alignment: ?mem.Alignment) type { const new_len = old_len + items.len; assert(new_len <= self.capacity); self.items.len = new_len; + self.asanAnnotate(old_len); @memcpy(self.items[old_len..][0..items.len], items); } @@ -1033,6 +1116,9 @@ pub fn Aligned(comptime T: type, comptime alignment: ?mem.Alignment) type { pub fn print(self: *Self, gpa: Allocator, comptime fmt: []const u8, args: anytype) error{OutOfMemory}!void { comptime assert(T == u8); try self.ensureUnusedCapacity(gpa, fmt.len); + // The Allocating writer manages our buffer directly; unpoison the + // spare capacity so it can write without ASAN false positives. + self.asanUnpoisonAll(); var aw: std.Io.Writer.Allocating = .fromArrayList(gpa, self); defer self.* = aw.toArrayList(); return aw.writer.print(fmt, args) catch |err| switch (err) { @@ -1116,10 +1202,12 @@ pub fn Aligned(comptime T: type, comptime alignment: ?mem.Alignment) type { /// /// Asserts that the list can hold the additional items. pub inline fn appendNTimesAssumeCapacity(self: *Self, value: T, n: usize) void { - const new_len = self.items.len + n; + const old_len = self.items.len; + const new_len = old_len + n; assert(new_len <= self.capacity); - @memset(self.items.ptr[self.items.len..new_len], value); self.items.len = new_len; + self.asanAnnotate(old_len); + @memset(self.items.ptr[old_len..new_len], value); } /// Append a value to the list `n` times. @@ -1132,18 +1220,22 @@ pub fn Aligned(comptime T: type, comptime alignment: ?mem.Alignment) type { /// If the list lacks unused capacity for the additional items, returns /// `error.OutOfMemory`. pub inline fn appendNTimesBounded(self: *Self, value: T, n: usize) error{OutOfMemory}!void { - const new_len = self.items.len + n; + const old_len = self.items.len; + const new_len = old_len + n; if (self.capacity < new_len) return error.OutOfMemory; - @memset(self.items.ptr[self.items.len..new_len], value); self.items.len = new_len; + self.asanAnnotate(old_len); + @memset(self.items.ptr[old_len..new_len], value); } /// Adjust the list length to `new_len`. /// Additional elements contain the value `undefined`. /// Invalidates element pointers if additional memory is needed. pub fn resize(self: *Self, gpa: Allocator, new_len: usize) Allocator.Error!void { + const old_len = self.items.len; try self.ensureTotalCapacity(gpa, new_len); self.items.len = new_len; + self.asanAnnotate(old_len); } /// Reduce allocated capacity to `new_len`. @@ -1158,6 +1250,7 @@ pub fn Aligned(comptime T: type, comptime alignment: ?mem.Alignment) type { } const old_memory = self.allocatedSlice(); + self.asanUnpoisonAll(); if (gpa.remap(old_memory, new_len)) |new_items| { self.capacity = new_items.len; self.items = new_items; @@ -1184,16 +1277,21 @@ pub fn Aligned(comptime T: type, comptime alignment: ?mem.Alignment) type { /// Asserts that the new length is less than or equal to the previous length. pub fn shrinkRetainingCapacity(self: *Self, new_len: usize) void { assert(new_len <= self.items.len); + const old_len = self.items.len; self.items.len = new_len; + self.asanAnnotate(old_len); } /// Invalidates all element pointers. pub fn clearRetainingCapacity(self: *Self) void { + const old_len = self.items.len; self.items.len = 0; + self.asanAnnotate(old_len); } /// Invalidates all element pointers. pub fn clearAndFree(self: *Self, gpa: Allocator) void { + self.asanUnpoisonAll(); gpa.free(self.allocatedSlice()); self.items.len = 0; self.capacity = 0; @@ -1224,6 +1322,7 @@ pub fn Aligned(comptime T: type, comptime alignment: ?mem.Alignment) type { // the allocator implementation would pointlessly copy our // extra capacity. const old_memory = self.allocatedSlice(); + self.asanUnpoisonAll(); if (gpa.remap(old_memory, new_capacity)) |new_memory| { self.items.ptr = new_memory.ptr; self.capacity = new_memory.len; @@ -1234,6 +1333,7 @@ pub fn Aligned(comptime T: type, comptime alignment: ?mem.Alignment) type { self.items.ptr = new_memory.ptr; self.capacity = new_memory.len; } + self.asanAnnotate(self.capacity); } /// Modify the array so that it can hold at least `additional_count` **more** items. @@ -1250,7 +1350,9 @@ pub fn Aligned(comptime T: type, comptime alignment: ?mem.Alignment) type { /// The new elements have `undefined` values. /// Never invalidates element pointers. pub fn expandToCapacity(self: *Self) void { + const old_len = self.items.len; self.items.len = self.capacity; + self.asanAnnotate(old_len); } /// Increase length by 1, returning pointer to the new item. @@ -1273,6 +1375,7 @@ pub fn Aligned(comptime T: type, comptime alignment: ?mem.Alignment) type { assert(self.items.len < self.capacity); self.items.len += 1; + self.asanAnnotate(self.items.len - 1); return &self.items[self.items.len - 1]; } @@ -1310,6 +1413,7 @@ pub fn Aligned(comptime T: type, comptime alignment: ?mem.Alignment) type { assert(self.items.len + n <= self.capacity); const prev_len = self.items.len; self.items.len += n; + self.asanAnnotate(prev_len); return self.items[prev_len..][0..n]; } @@ -1349,6 +1453,7 @@ pub fn Aligned(comptime T: type, comptime alignment: ?mem.Alignment) type { assert(self.items.len + n <= self.capacity); const prev_len = self.items.len; self.items.len += n; + self.asanAnnotate(prev_len); return self.items[prev_len..][0..n]; } @@ -1372,6 +1477,7 @@ pub fn Aligned(comptime T: type, comptime alignment: ?mem.Alignment) type { if (self.items.len == 0) return null; const val = self.items[self.items.len - 1]; self.items.len -= 1; + self.asanAnnotate(self.items.len + 1); return val; } @@ -1386,6 +1492,10 @@ pub fn Aligned(comptime T: type, comptime alignment: ?mem.Alignment) type { /// Note that such an operation must be followed up with a direct /// modification of `self.items.len`. pub fn unusedCapacitySlice(self: Self) []T { + // Callers are expected to write directly into this region; unpoison + // it so ASAN does not flag those writes. The next length-changing + // operation will re-establish the poison boundary. + self.asanUnpoisonAll(); return self.allocatedSlice()[self.items.len..]; } diff --git a/lib/std/debug.zig b/lib/std/debug.zig index 10e51b382734..bf7436d0e608 100644 --- a/lib/std/debug.zig +++ b/lib/std/debug.zig @@ -21,6 +21,7 @@ pub const Pdb = @import("debug/Pdb.zig"); pub const SelfInfo = @import("debug/SelfInfo.zig"); pub const Info = @import("debug/Info.zig"); pub const Coverage = @import("debug/Coverage.zig"); +pub const Asan = @import("debug/Asan.zig"); pub const simple_panic = @import("debug/simple_panic.zig"); pub const no_panic = @import("debug/no_panic.zig"); @@ -1780,4 +1781,5 @@ test { _ = &Pdb; _ = &SelfInfo; _ = &dumpHex; + _ = @import("debug/asan_test.zig"); } diff --git a/lib/std/debug/Asan.zig b/lib/std/debug/Asan.zig new file mode 100644 index 000000000000..a95b18c02deb --- /dev/null +++ b/lib/std/debug/Asan.zig @@ -0,0 +1,81 @@ +//! AddressSanitizer manual-poisoning helpers. +//! +//! These wrap the ASAN runtime's manual-poison API so that allocators and +//! containers can mark freed/unused regions as inaccessible. All functions +//! compile to no-ops when `builtin.sanitize_address` is false, so call sites +//! need no `if` guard. +//! +//! Poisoning is shadow-byte granular (8 bytes on most targets). A region +//! whose start is not 8-aligned may leave the first partial qword unpoisoned; +//! this is a limitation of the ASAN shadow encoding, not of these wrappers. + +const builtin = @import("builtin"); + +/// Whether the current compilation has AddressSanitizer instrumentation. +pub const enabled = builtin.sanitize_address; + +/// Mark `[ptr, ptr+len)` as inaccessible. Any subsequent load or store in +/// that range triggers an ASAN `use-after-poison` report. +pub inline fn poison(ptr: [*]const u8, len: usize) void { + if (!enabled) return; + __asan_poison_memory_region(ptr, len); +} + +/// Mark `[ptr, ptr+len)` as accessible again. +pub inline fn unpoison(ptr: [*]const u8, len: usize) void { + if (!enabled) return; + __asan_unpoison_memory_region(ptr, len); +} + +/// Convenience overload taking any slice. +pub inline fn poisonSlice(slice: anytype) void { + if (!enabled) return; + if (slice.len == 0) return; + const bytes = @import("std").mem.sliceAsBytes(slice); + __asan_poison_memory_region(bytes.ptr, bytes.len); +} + +/// Convenience overload taking any slice. +pub inline fn unpoisonSlice(slice: anytype) void { + if (!enabled) return; + if (slice.len == 0) return; + const bytes = @import("std").mem.sliceAsBytes(slice); + __asan_unpoison_memory_region(bytes.ptr, bytes.len); +} + +/// Tell ASAN about a contiguous container's `[storage, storage+capacity)` +/// where only `[storage, storage+new_len)` is valid. Equivalent to libc++'s +/// vector annotation. `old_len` must be the value passed as `new_len` on the +/// previous call (or `capacity` on first call). All lengths are in bytes. +pub inline fn annotateContiguousContainer( + storage: [*]const u8, + capacity: usize, + old_len: usize, + new_len: usize, +) void { + if (!enabled) return; + if (capacity == 0) return; + __sanitizer_annotate_contiguous_container( + storage, + storage + capacity, + storage + old_len, + storage + new_len, + ); +} + +/// Returns true if the byte at `ptr` is currently poisoned. Intended for +/// assertions in tests. +pub inline fn isPoisoned(ptr: *const anyopaque) bool { + if (!enabled) return false; + return __asan_address_is_poisoned(ptr) != 0; +} + +extern fn __asan_poison_memory_region(addr: [*]const u8, size: usize) void; +extern fn __asan_unpoison_memory_region(addr: [*]const u8, size: usize) void; +extern fn __asan_address_is_poisoned(addr: *const anyopaque) c_int; +extern fn __sanitizer_annotate_contiguous_container( + beg: [*]const u8, + end: [*]const u8, + old_mid: [*]const u8, + new_mid: [*]const u8, +) void; diff --git a/lib/std/debug/asan_test.zig b/lib/std/debug/asan_test.zig new file mode 100644 index 000000000000..c26734fb6add --- /dev/null +++ b/lib/std/debug/asan_test.zig @@ -0,0 +1,129 @@ +//! Tests that the manual ASAN poisoning applied by std allocators and +//! containers actually marks the right bytes. These tests query the ASAN +//! shadow via `Asan.isPoisoned` rather than dereferencing poisoned memory, +//! so they pass cleanly under `-fsanitize-address` instead of crashing. +//! +//! Every test is gated on `Asan.enabled` and skipped otherwise so that the +//! file can be built into the regular (non-sanitized) test binary without +//! producing false failures. +//! +//! Run directly with: +//! zig test lib/std/debug/asan_test.zig --zig-lib-dir lib -fsanitize-address -lc + +const std = @import("std"); +const testing = std.testing; +const Asan = std.debug.Asan; + +test "asan: ArrayList spare capacity is poisoned" { + if (!Asan.enabled) return error.SkipZigTest; + + const gpa = testing.allocator; + var list: std.ArrayList(u64) = try .initCapacity(gpa, 8); + defer list.deinit(gpa); + + try testing.expectEqual(@as(usize, 8), list.capacity); + + try list.append(gpa, 1); + try list.append(gpa, 2); + try list.append(gpa, 3); + + // Base of the backing buffer (items.ptr is a [*]u64; treat as bytes for + // shadow queries so we are explicit about granularity). + const base: [*]const u8 = @ptrCast(list.items.ptr); + + // items[0..3] live, items[3..8] poisoned spare capacity. + try testing.expect(!Asan.isPoisoned(&base[0 * @sizeOf(u64)])); + try testing.expect(!Asan.isPoisoned(&base[2 * @sizeOf(u64)])); + try testing.expect(Asan.isPoisoned(&base[3 * @sizeOf(u64)])); + try testing.expect(Asan.isPoisoned(&base[7 * @sizeOf(u64)])); + + // Popping shrinks the live region; the just-vacated slot becomes poison. + _ = list.pop(); + try testing.expect(!Asan.isPoisoned(&base[1 * @sizeOf(u64)])); + try testing.expect(Asan.isPoisoned(&base[2 * @sizeOf(u64)])); +} + +test "asan: FixedBufferAllocator poisons freed regions" { + if (!Asan.enabled) return error.SkipZigTest; + + // 8-byte alignment so the ASAN shadow boundaries line up exactly with + // the buffer; otherwise the first/last partial qword may be left + // unpoisoned by the runtime. + var buf: [256]u8 align(8) = undefined; + var fba = std.heap.FixedBufferAllocator.init(&buf); + defer fba.deinit(); + const a = fba.allocator(); + + // init() does not poison eagerly (Zig has no per-frame shadow cleanup yet). + try testing.expect(!Asan.isPoisoned(&buf[0])); + try testing.expect(!Asan.isPoisoned(&buf[100])); + + const slab = try a.alloc(u8, 64); + try testing.expect(!Asan.isPoisoned(&slab[0])); + try testing.expect(!Asan.isPoisoned(&slab[32])); + + // free() poisons the returned region so use-after-free is caught. + a.free(slab); + try testing.expect(Asan.isPoisoned(&buf[0])); + try testing.expect(Asan.isPoisoned(&buf[32])); + + // A fresh allocation past the high-water mark is still addressable. + const slab2 = try a.alloc(u8, 64); + try testing.expect(!Asan.isPoisoned(&slab2[0])); + + // reset() poisons everything that was handed out so far. + fba.reset(); + try testing.expect(Asan.isPoisoned(&slab2[0])); +} + +test "asan: ArenaAllocator reset(.retain_capacity) re-poisons" { + if (!Asan.enabled) return error.SkipZigTest; + + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer arena.deinit(); + const a = arena.allocator(); + + const slab = try a.alloc(u64, 16); + const base: [*]const u8 = @ptrCast(slab.ptr); + try testing.expect(!Asan.isPoisoned(&base[0])); + try testing.expect(!Asan.isPoisoned(&base[15 * @sizeOf(u64)])); + + // Keep the buffer but rewind end_index to 0; the previously handed-out + // bytes must now be inaccessible. + _ = arena.reset(.retain_capacity); + try testing.expect(Asan.isPoisoned(&base[0])); + try testing.expect(Asan.isPoisoned(&base[8 * @sizeOf(u64)])); +} + +test "asan: MemoryPool destroy poisons item tail" { + if (!Asan.enabled) return error.SkipZigTest; + + // Item large enough that there is a poisonable tail past the free-list + // `next` pointer (one usize). + const Item = struct { data: [32]u8 }; + + var pool = std.heap.MemoryPool(Item).init(testing.allocator); + defer pool.deinit(); + + const item = try pool.create(); + const bytes: [*]const u8 = @ptrCast(item); + + // Freshly created: whole item is addressable. + try testing.expect(!Asan.isPoisoned(&bytes[0])); + try testing.expect(!Asan.isPoisoned(&bytes[@sizeOf(Item) - 1])); + + pool.destroy(item); + + // First @sizeOf(usize) bytes hold the free-list link and stay unpoisoned; + // everything after is poison. + const link_end = @sizeOf(usize); + try testing.expect(!Asan.isPoisoned(&bytes[0])); + try testing.expect(Asan.isPoisoned(&bytes[link_end])); + try testing.expect(Asan.isPoisoned(&bytes[@sizeOf(Item) - 1])); + + // Re-create pulls from the free list and unpoisons the full item again. + const item2 = try pool.create(); + try testing.expectEqual(@intFromPtr(item), @intFromPtr(item2)); + try testing.expect(!Asan.isPoisoned(&bytes[link_end])); + try testing.expect(!Asan.isPoisoned(&bytes[@sizeOf(Item) - 1])); +} diff --git a/lib/std/heap.zig b/lib/std/heap.zig index 3bb4fef6a094..09c7ffa233bd 100644 --- a/lib/std/heap.zig +++ b/lib/std/heap.zig @@ -8,6 +8,7 @@ const c = std.c; const Allocator = std.mem.Allocator; const windows = std.os.windows; const Alignment = std.mem.Alignment; +const Asan = std.debug.Asan; pub const ArenaAllocator = @import("heap/arena_allocator.zig").ArenaAllocator; pub const SmpAllocator = @import("heap/SmpAllocator.zig"); @@ -406,6 +407,10 @@ pub fn StackFallbackAllocator(comptime size: usize) type { self.get_called = true; } self.fixed_buffer_allocator = FixedBufferAllocator.init(self.buffer[0..]); + // The unallocated tail is intentionally not poisoned: Zig does not + // yet emit per-frame ASAN shadow cleanup, so eager poisoning would + // leave stale poison after the owning frame returns. Freed regions + // are still poisoned (see free/resize). return .{ .ptr = self, .vtable = &.{ @@ -430,8 +435,11 @@ pub fn StackFallbackAllocator(comptime size: usize) type { ra: usize, ) ?[*]u8 { const self: *Self = @ptrCast(@alignCast(ctx)); - return FixedBufferAllocator.alloc(&self.fixed_buffer_allocator, len, alignment, ra) orelse - return self.fallback_allocator.rawAlloc(len, alignment, ra); + if (FixedBufferAllocator.alloc(&self.fixed_buffer_allocator, len, alignment, ra)) |ptr| { + Asan.unpoison(ptr, len); + return ptr; + } + return self.fallback_allocator.rawAlloc(len, alignment, ra); } fn resize( @@ -443,7 +451,15 @@ pub fn StackFallbackAllocator(comptime size: usize) type { ) bool { const self: *Self = @ptrCast(@alignCast(ctx)); if (self.fixed_buffer_allocator.ownsPtr(buf.ptr)) { - return FixedBufferAllocator.resize(&self.fixed_buffer_allocator, buf, alignment, new_len, ra); + const ok = FixedBufferAllocator.resize(&self.fixed_buffer_allocator, buf, alignment, new_len, ra); + if (ok) { + if (new_len > buf.len) { + Asan.unpoison(buf.ptr + buf.len, new_len - buf.len); + } else if (new_len < buf.len) { + Asan.poison(buf.ptr + new_len, buf.len - new_len); + } + } + return ok; } else { return self.fallback_allocator.rawResize(buf, alignment, new_len, ra); } @@ -458,7 +474,16 @@ pub fn StackFallbackAllocator(comptime size: usize) type { ) ?[*]u8 { const self: *Self = @ptrCast(@alignCast(context)); if (self.fixed_buffer_allocator.ownsPtr(memory.ptr)) { - return FixedBufferAllocator.remap(&self.fixed_buffer_allocator, memory, alignment, new_len, return_address); + const result = FixedBufferAllocator.remap(&self.fixed_buffer_allocator, memory, alignment, new_len, return_address); + if (result) |ptr| { + // FixedBufferAllocator.remap only ever resizes in place. + if (new_len > memory.len) { + Asan.unpoison(ptr + memory.len, new_len - memory.len); + } else if (new_len < memory.len) { + Asan.poison(ptr + new_len, memory.len - new_len); + } + } + return result; } else { return self.fallback_allocator.rawRemap(memory, alignment, new_len, return_address); } @@ -472,7 +497,9 @@ pub fn StackFallbackAllocator(comptime size: usize) type { ) void { const self: *Self = @ptrCast(@alignCast(ctx)); if (self.fixed_buffer_allocator.ownsPtr(buf.ptr)) { - return FixedBufferAllocator.free(&self.fixed_buffer_allocator, buf, alignment, ra); + FixedBufferAllocator.free(&self.fixed_buffer_allocator, buf, alignment, ra); + Asan.poison(buf.ptr, buf.len); + return; } else { return self.fallback_allocator.rawFree(buf, alignment, ra); } diff --git a/lib/std/heap/FixedBufferAllocator.zig b/lib/std/heap/FixedBufferAllocator.zig index 0951dd3bcc88..49c3915260dc 100644 --- a/lib/std/heap/FixedBufferAllocator.zig +++ b/lib/std/heap/FixedBufferAllocator.zig @@ -2,6 +2,7 @@ const std = @import("../std.zig"); const Allocator = std.mem.Allocator; const assert = std.debug.assert; const mem = std.mem; +const Asan = std.debug.Asan; const FixedBufferAllocator = @This(); @@ -9,12 +10,22 @@ end_index: usize, buffer: []u8, pub fn init(buffer: []u8) FixedBufferAllocator { + // The unallocated tail is intentionally not poisoned here: Zig does not + // yet emit per-frame ASAN shadow cleanup, so poisoning a stack-backed + // buffer would leave stale poison after the FBA goes out of scope. + // Freed regions are still poisoned (see free/resize/reset). return .{ .buffer = buffer, .end_index = 0, }; } +/// Unpoisons the backing buffer so it can be reused outside this allocator. +pub fn deinit(self: *FixedBufferAllocator) void { + if (!@inComptime()) Asan.unpoisonSlice(self.buffer); + self.* = undefined; +} + /// Using this at the same time as the interface returned by `threadSafeAllocator` is not thread safe. pub fn allocator(self: *FixedBufferAllocator) Allocator { return .{ @@ -68,7 +79,9 @@ pub fn alloc(ctx: *anyopaque, n: usize, alignment: mem.Alignment, ra: usize) ?[* const new_end_index = adjusted_index + n; if (new_end_index > self.buffer.len) return null; self.end_index = new_end_index; - return self.buffer.ptr + adjusted_index; + const result = self.buffer.ptr + adjusted_index; + if (!@inComptime()) Asan.unpoison(result, n); + return result; } pub fn resize( @@ -85,18 +98,21 @@ pub fn resize( if (!self.isLastAllocation(buf)) { if (new_size > buf.len) return false; + if (!@inComptime()) Asan.poison(buf.ptr + new_size, buf.len - new_size); return true; } if (new_size <= buf.len) { const sub = buf.len - new_size; self.end_index -= sub; + if (!@inComptime()) Asan.poison(buf.ptr + new_size, sub); return true; } const add = new_size - buf.len; if (add + self.end_index > self.buffer.len) return false; + if (!@inComptime()) Asan.unpoison(buf.ptr + buf.len, add); self.end_index += add; return true; } @@ -125,6 +141,7 @@ pub fn free( if (self.isLastAllocation(buf)) { self.end_index -= buf.len; } + if (!@inComptime()) Asan.poisonSlice(buf); } fn threadSafeAlloc(ctx: *anyopaque, n: usize, alignment: mem.Alignment, ra: usize) ?[*]u8 { @@ -137,12 +154,16 @@ fn threadSafeAlloc(ctx: *anyopaque, n: usize, alignment: mem.Alignment, ra: usiz const adjusted_index = end_index + adjust_off; const new_end_index = adjusted_index + n; if (new_end_index > self.buffer.len) return null; - end_index = @cmpxchgWeak(usize, &self.end_index, end_index, new_end_index, .seq_cst, .seq_cst) orelse - return self.buffer[adjusted_index..new_end_index].ptr; + end_index = @cmpxchgWeak(usize, &self.end_index, end_index, new_end_index, .seq_cst, .seq_cst) orelse { + const result = self.buffer.ptr + adjusted_index; + Asan.unpoison(result, n); + return result; + }; } } pub fn reset(self: *FixedBufferAllocator) void { + if (!@inComptime()) Asan.poisonSlice(self.buffer[0..self.end_index]); self.end_index = 0; } diff --git a/lib/std/heap/arena_allocator.zig b/lib/std/heap/arena_allocator.zig index 130eae66f839..2039ef9f968e 100644 --- a/lib/std/heap/arena_allocator.zig +++ b/lib/std/heap/arena_allocator.zig @@ -3,6 +3,7 @@ const assert = std.debug.assert; const mem = std.mem; const Allocator = std.mem.Allocator; const Alignment = std.mem.Alignment; +const Asan = std.debug.Asan; /// This allocator takes an existing allocator, wraps it, and provides an interface where /// you can allocate and then free it all together. Calls to free an individual item only @@ -57,6 +58,8 @@ pub const ArenaAllocator = struct { const next_it = node.next; const buf_node: *BufNode = @fieldParentPtr("node", node); const alloc_buf = @as([*]u8, @ptrCast(buf_node))[0..buf_node.data]; + // Hand back fully unpoisoned memory; the child allocator may not be ASAN-aware. + Asan.unpoisonSlice(alloc_buf); self.child_allocator.rawFree(alloc_buf, BufNode_alignment, @returnAddress()); it = next_it; } @@ -138,6 +141,7 @@ pub const ArenaAllocator = struct { break node; const buf_node: *BufNode = @fieldParentPtr("node", node); const alloc_buf = @as([*]u8, @ptrCast(buf_node))[0..buf_node.data]; + Asan.unpoisonSlice(alloc_buf); self.child_allocator.rawFree(alloc_buf, BufNode_alignment, @returnAddress()); it = next_it; } else null; @@ -148,22 +152,31 @@ pub const ArenaAllocator = struct { self.state.buffer_list.first = first_node; // perfect, no need to invoke the child_allocator const first_buf_node: *BufNode = @fieldParentPtr("node", first_node); - if (first_buf_node.data == total_size) + if (first_buf_node.data == total_size) { + // Retained as-is: poison the data region so use-after-reset is caught. + Asan.poison(@as([*]u8, @ptrCast(first_buf_node)) + @sizeOf(BufNode), total_size - @sizeOf(BufNode)); return true; + } const first_alloc_buf = @as([*]u8, @ptrCast(first_buf_node))[0..first_buf_node.data]; + // Unpoison before letting the child allocator resize/free this region. + Asan.unpoisonSlice(first_alloc_buf); if (self.child_allocator.rawResize(first_alloc_buf, BufNode_alignment, total_size, @returnAddress())) { // successful resize first_buf_node.data = total_size; + Asan.poison(first_alloc_buf.ptr + @sizeOf(BufNode), total_size - @sizeOf(BufNode)); } else { // manual realloc const new_ptr = self.child_allocator.rawAlloc(total_size, BufNode_alignment, @returnAddress()) orelse { // we failed to preheat the arena properly, signal this to the user. + // Re-poison the retained buffer's data region (still owned by us). + Asan.poisonSlice(first_alloc_buf[@sizeOf(BufNode)..]); return false; }; self.child_allocator.rawFree(first_alloc_buf, BufNode_alignment, @returnAddress()); const buf_node: *BufNode = @ptrCast(@alignCast(new_ptr)); buf_node.* = .{ .data = total_size }; self.state.buffer_list.first = &buf_node.node; + Asan.poison(new_ptr + @sizeOf(BufNode), total_size - @sizeOf(BufNode)); } } return true; @@ -179,6 +192,9 @@ pub const ArenaAllocator = struct { buf_node.* = .{ .data = len }; self.state.buffer_list.prepend(&buf_node.node); self.state.end_index = 0; + // Poison the data region (everything past the header). The header itself + // must remain accessible for list traversal and `data` reads. + Asan.poison(ptr + @sizeOf(BufNode), len - @sizeOf(BufNode)); return buf_node; } @@ -202,11 +218,15 @@ pub const ArenaAllocator = struct { if (new_end_index <= cur_buf.len) { const result = cur_buf[adjusted_index..new_end_index]; self.state.end_index = new_end_index; + Asan.unpoisonSlice(result); return result.ptr; } const bigger_buf_size = @sizeOf(BufNode) + new_end_index; if (self.child_allocator.rawResize(cur_alloc_buf, BufNode_alignment, bigger_buf_size, @returnAddress())) { + // Poison the freshly-grown tail; the next loop iteration will + // unpoison exactly the slice handed to the caller. + Asan.poison(cur_alloc_buf.ptr + cur_alloc_buf.len, bigger_buf_size - cur_alloc_buf.len); cur_node.data = bigger_buf_size; } else { // Allocate a new node if that's not possible @@ -226,14 +246,20 @@ pub const ArenaAllocator = struct { if (@intFromPtr(cur_buf.ptr) + self.state.end_index != @intFromPtr(buf.ptr) + buf.len) { // It's not the most recent allocation, so it cannot be expanded, // but it's fine if they want to make it smaller. - return new_len <= buf.len; + if (new_len <= buf.len) { + Asan.poisonSlice(buf[new_len..]); + return true; + } + return false; } if (buf.len >= new_len) { self.state.end_index -= buf.len - new_len; + Asan.poisonSlice(buf[new_len..]); return true; } else if (cur_buf.len - self.state.end_index >= new_len - buf.len) { self.state.end_index += new_len - buf.len; + Asan.unpoison(buf.ptr + buf.len, new_len - buf.len); return true; } else { return false; @@ -263,6 +289,8 @@ pub const ArenaAllocator = struct { if (@intFromPtr(cur_buf.ptr) + self.state.end_index == @intFromPtr(buf.ptr) + buf.len) { self.state.end_index -= buf.len; } + // Catch use-after-free regardless of whether we could rewind end_index. + Asan.poisonSlice(buf); } }; diff --git a/lib/std/heap/debug_allocator.zig b/lib/std/heap/debug_allocator.zig index a4b1de5b47b5..e039b6807536 100644 --- a/lib/std/heap/debug_allocator.zig +++ b/lib/std/heap/debug_allocator.zig @@ -89,6 +89,7 @@ const assert = std.debug.assert; const mem = std.mem; const Allocator = std.mem.Allocator; const StackTrace = std.builtin.StackTrace; +const Asan = std.debug.Asan; const default_page_size: usize = switch (builtin.os.tag) { // Makes `std.heap.PageAllocator` take the happy path. @@ -772,6 +773,9 @@ pub fn DebugAllocator(comptime config: Config) type { if (config.verbose_log) { log.info("small alloc {d} bytes at 0x{x}", .{ len, addr }); } + // The slot was poisoned when the page was created; unpoison + // it now that it is being handed to the caller. + Asan.unpoison(@as([*]u8, @ptrFromInt(addr)), size_class); return @ptrFromInt(addr); } } @@ -806,6 +810,12 @@ pub fn DebugAllocator(comptime config: Config) type { bucket.log2PtrAligns(slot_count)[0] = alignment; } + // Poison every slot except slot 0 (which is being returned now) so + // that overruns and use-before-allocation are caught by ASAN. The + // bucket metadata lives past the slot region and is left untouched. + const size_class = @as(usize, 1) << @as(Log2USize, @intCast(size_class_index)); + Asan.poison(page + size_class, (@as(usize, slot_count) - 1) * size_class); + if (config.verbose_log) { log.info("small alloc {d} bytes at 0x{x}", .{ len, @intFromPtr(page) }); } @@ -939,6 +949,10 @@ pub fn DebugAllocator(comptime config: Config) type { bucket.requestedSizes(slot_count)[slot_index] = 0; } bucket.freed_count += 1; + // Poison the freed slot so use-after-free is caught by ASAN. Slots + // are never reused; the whole page is unpoisoned below before being + // returned to the backing allocator once every slot has been freed. + Asan.poison(old_memory.ptr, size_class); if (bucket.freed_count == bucket.allocated_count) { if (bucket.prev) |prev| { prev.next = bucket.next; @@ -954,6 +968,9 @@ pub fn DebugAllocator(comptime config: Config) type { if (!config.never_unmap) { const page: [*]align(page_size) u8 = @ptrFromInt(page_addr); + // Unpoison the entire page before returning it to the + // backing allocator, which may not be ASAN-aware. + Asan.unpoison(page, page_size); self.backing_allocator.rawFree(page[0..page_size], page_align, @returnAddress()); } } diff --git a/lib/std/heap/memory_pool.zig b/lib/std/heap/memory_pool.zig index 2b201f2b5422..06eeab4d0c13 100644 --- a/lib/std/heap/memory_pool.zig +++ b/lib/std/heap/memory_pool.zig @@ -1,5 +1,6 @@ const std = @import("../std.zig"); const Alignment = std.mem.Alignment; +const Asan = std.debug.Asan; const debug_mode = @import("builtin").mode == .Debug; @@ -76,6 +77,7 @@ pub fn MemoryPoolExtra(comptime Item: type, comptime pool_options: Options) type /// Destroys the memory pool and frees all allocated memory. pub fn deinit(pool: *Pool) void { + pool.unpoisonFreeList(); pool.arena.deinit(); pool.* = undefined; } @@ -92,6 +94,8 @@ pub fn MemoryPoolExtra(comptime Item: type, comptime pool_options: Options) type .next = pool.free_list, }; pool.free_list = free_node; + // Poison the payload past the free-list link so stale references trip ASAN. + Asan.poison(@as([*]u8, @ptrCast(free_node)) + @sizeOf(Node), item_size - @sizeOf(Node)); } } @@ -110,6 +114,7 @@ pub fn MemoryPoolExtra(comptime Item: type, comptime pool_options: Options) type // TODO: Potentially store all allocated objects in a list as well, allowing to // just move them into the free list instead of actually releasing the memory. + pool.unpoisonFreeList(); const reset_successful = pool.arena.reset(mode); pool.free_list = null; @@ -127,6 +132,11 @@ pub fn MemoryPoolExtra(comptime Item: type, comptime pool_options: Options) type else return error.OutOfMemory; + // Unpoison the full item before handing it to the caller. The free-list + // header was kept addressable, but the tail may have been poisoned by + // destroy()/preheat(). + Asan.unpoison(@as([*]u8, @ptrCast(node)), item_size); + const ptr = @as(ItemPtr, @ptrCast(node)); ptr.* = undefined; return ptr; @@ -142,6 +152,21 @@ pub fn MemoryPoolExtra(comptime Item: type, comptime pool_options: Options) type .next = pool.free_list, }; pool.free_list = node; + // Poison everything past the free-list link so use-after-destroy is caught. + // The first @sizeOf(Node) bytes stay addressable so we can walk `next`. + Asan.poison(@as([*]u8, @ptrCast(node)) + @sizeOf(Node), item_size - @sizeOf(Node)); + } + + /// Walk the free list and unpoison every node's payload. Called before + /// returning memory to the backing allocator (deinit/reset) so that an + /// allocator which is not ASAN-aware does not trip on poisoned bytes. + fn unpoisonFreeList(pool: *Pool) void { + if (!Asan.enabled) return; + var it = pool.free_list; + while (it) |node| { + it = node.next; + Asan.unpoison(@as([*]u8, @ptrCast(node)), item_size); + } } fn allocNew(pool: *Pool) MemoryPoolError!*align(item_alignment.toByteUnits()) [item_size]u8 { From 5dd82fede5bd6bc1775f64c2e4a4c0405d06cb40 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Mon, 27 Apr 2026 01:21:10 +0000 Subject: [PATCH 2/2] std.debug.Asan: guard builtin.sanitize_address with @hasDecl for bootstrap --- lib/std/debug/Asan.zig | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/std/debug/Asan.zig b/lib/std/debug/Asan.zig index a95b18c02deb..3c65e25c1c2f 100644 --- a/lib/std/debug/Asan.zig +++ b/lib/std/debug/Asan.zig @@ -12,7 +12,9 @@ const builtin = @import("builtin"); /// Whether the current compilation has AddressSanitizer instrumentation. -pub const enabled = builtin.sanitize_address; +/// Guarded with @hasDecl so the bootstrap stage1 (whose builtin predates +/// sanitize_address) can still compile std. +pub const enabled = @hasDecl(builtin, "sanitize_address") and builtin.sanitize_address; /// Mark `[ptr, ptr+len)` as inaccessible. Any subsequent load or store in /// that range triggers an ASAN `use-after-poison` report.