Safety in Zig: SafeAllocator
A while back I talked about Zig’s DebugAllocator and how it works.
Well, the DebugAllocator has grown. It’s now renamed to SafeAllocator, it’s thread-safe,
and has acquired a few new safety capabilities on top of the ones it already had.
For one, SafeAllocator changes the way you’d normally initialize it. While using the DebugAllocator, initializing would look something like this:
var gpa = std.heap.DebugAllocator(.{}){};
Now, initializing means passing in explicitly a backing allocator. You know, everything explicit as per Zig’s philosophy.
var safe = std.heap.SafeAllocator.init(std.heap.page_allocator, .{});
The other slight change is what happens when deinit() is called.
In Zig 0.16.0 (the current tagged release as of this writing), deinit would return
std.heap.Check.leak if there were any leaks or std.heap.Check.ok if there weren’t.
Now, in the master branch 0.17.0-dev.1786+75044cb04 (future 0.17.0), deinit returns a usize which is the
number of leaks. This is a pretty simple thing but a big improvement. Consider the
following example:
const std = @import("std");
pub fn main() !void {
var sa = std.heap.SafeAllocator.init(std.heap.page_allocator, .{});
defer {
const leaks = sa.deinit();
std.debug.print("leaks: {d}\n", .{leaks});
}
var allocator = sa.allocator();
const ptr = try allocator.create(i32);
const ptr2 = try allocator.create(i32);
ptr.* = 42;
ptr2.* = 67;
}
We knowingly leak memory twice and use the new deinit to return the number of leaks.
m3lk0r@parrot:~ zig build run
error(SafeAllocator): leaked [addr: 73070a0be0a0, len: 4 (0x4) align: 4] allocated at:
/home/m3lk0r/main.zig:11:38: 0x11e4dc9 in main (main.zig)
const ptr2 = try allocator.create(i32);
^
/home/m3lk0r/zig-x86_64-linux-0.17.0-dev.1786+75044cb04/lib/std/start.zig:758:64: 0x11e5576 in callMain (std.zig)
if (fn_info.param_types.len == 0) return wrapMain(root.main());
^
/home/m3lk0r/zig-x86_64-linux-0.17.0-dev.1786+75044cb04/lib/std/start.zig:218:5: 0x11e4c01 in _start (std.zig)
asm volatile (switch (native_arch) {
^
error(SafeAllocator): leaked [addr: 73070a0be010, len: 4 (0x4) align: 4] allocated at:
/home/m3lk0r/main.zig:10:37: 0x11e4d00 in main (main.zig)
const ptr = try allocator.create(i32);
^
/home/m3lk0r/zig-x86_64-linux-0.17.0-dev.1786+75044cb04/lib/std/start.zig:758:64: 0x11e5576 in callMain (std.zig)
if (fn_info.param_types.len == 0) return wrapMain(root.main());
^
/home/m3lk0r/zig-x86_64-linux-0.17.0-dev.1786+75044cb04/lib/std/start.zig:218:5: 0x11e4c01 in _start (std.zig)
asm volatile (switch (native_arch) {
^
leaks: 2
We still get the stack trace for the leaks but also we can see the number of leaks.
One of the new capabilities added to this allocator is the ability to detect write-after-free. Write-after-free is essentially a uaf where a program writes to a deallocated memory. This is known to cause UB 1 (IB in Zig). This bug can be quite insidious2 3.
The following is a simple example of a write-after-free detection. Two threads are used here, the second thread being the one that will write to the freed memory.
const std = @import("std");
const Thread = std.Thread;
const Atomic = std.atomic.Value;
pub fn main(init: std.process.Init) !void {
var sa = std.heap.SafeAllocator.init(init.gpa, .{});
defer _ = sa.deinit();
const allocator = sa.allocator();
const ptr = try allocator.create(i32);
ptr.* = 0;
var freed = Atomic(bool).init(false);
const t1 = try Thread.spawn(.{}, thread1, .{ allocator, ptr, &freed });
const t2 = try Thread.spawn(.{}, thread2, .{ ptr, &freed });
t1.join();
t2.join();
}
fn thread1(allocator: std.mem.Allocator, ptr: *i32, freed: *Atomic(bool)) void {
ptr.* = 42;
std.debug.print("Thread 1: wrote 42, now freeing\n", .{});
allocator.destroy(ptr);
freed.store(true, .release);
}
fn thread2(ptr: *i32, freed: *Atomic(bool)) void {
while (!freed.load(.acquire)) {
std.Thread.yield() catch {};
}
std.debug.print("Thread 2: writing to freed pointer\n", .{});
ptr.* = 67;// ← should be detected as a write-after-free
}
That throws the following panic upon running:
m3lk0r@parrot:~ zig build run
Thread 1: wrote 42, now freeing
Thread 2: writing to freed pointer
thread 6618 panic: write after free at *7d92d7f9c010
original alloc of [addr: 7d92d7f9c010, len: 4 (0x4) align: 4]:
/home/m3lk0r/concurrency.zig:11:37: 0x11e4d76 in main (concurrency.zig)
const ptr = try allocator.create(i32);
^
/home/m3lk0r/zig-x86_64-linux-0.17.0-dev.1786+75044cb04/lib/std/start.zig:797:30: 0x11e5b08 in callMain (std.zig)
return wrapMain(root.main(.{
^
/home/m3lk0r/zig-x86_64-linux-0.17.0-dev.1786+75044cb04/lib/std/start.zig:218:5: 0x11e4c01 in _start (std.zig)
asm volatile (switch (native_arch) {
^
...
I won’t explain how the SafeAllocator works under the hood but the explanation is here.
I know the above exampe may be a bit contrived but an even simpler one exists here.
https://cmu-sei.github.io/secure-coding-standards/sei-cert-c-coding-standard/rules/memory-management-mem/mem30-c/#noncompliant-code-example-1 ↩︎
https://web.archive.org/web/20190418204028/https://news.sophos.com/en-us/2019/04/18/protected-cve-2018-18500-heap-write-after-free-in-firefox-analysis-and-exploitation/ ↩︎