Skip to content

[Audit][High] Use-after-free in AudioSystemManager.deinit β€” reads AudioSystem.allocator after self-freeΒ #932

Description

@MichaelFisher1997

πŸ” Module Scanned

src/game/ (automated audit scan)

πŸ“ Summary

AudioSystemManager.deinit calls self.audio_system.deinit() which internally frees the AudioSystem heap allocation via self.allocator.destroy(self). Control then returns to AudioSystemManager.deinit and dereferences self.audio_system.allocator to free the manager itself, reading freed memory (use-after-free / CWE-416). The bug also causes a double-destroy hazard if the allocator's backing page is reused.

πŸ“ Location

  • File: src/game/audio_system_manager.zig:18-22
  • Function/Scope: AudioSystemManager.deinit

The free of the inner struct happens at:

  • File: modules/engine-audio/src/system.zig:78
  • Function/Scope: AudioSystem.deinit

πŸ”΄ Severity: High

  • Critical: Crashes, data corruption, security vulnerabilities, GPU device loss
  • High: Memory safety bugs (use-after-free), undefined behavior, resource lifecycle violations
  • Medium: Performance degradation, missing error handling, suboptimal patterns
  • Low: Code style, dead code, minor improvements

πŸ’₯ Impact

Whenever the application exits normally (or fails late in init thanks to the errdefer audio_manager.deinit() chain in src/game/app.zig:189), the heap allocator will see reads of an already-freed AudioSystem struct. In a debug/safety allocator this surfaces as a panic ("invalid free" or "use after free"); in release builds it can return a stale allocator pointer that mutates arbitrary memory, silently corrupts the next allocation, or causes a double-free of the manager when the allocator tracks its own state. Because this is on the shutdown path it can manifest as mysterious late-exit crashes that are hard to reproduce and attribute.

πŸ”Ž Evidence

src/game/audio_system_manager.zig:

pub fn deinit(self: *AudioSystemManager) void {
    self.audio_system.deinit();            // (1) AudioSystem.deinit frees 
    const allocator = self.audio_system.allocator;  // (2) UAF: AudioSystem memory was freed
    allocator.destroy(self);
}

modules/engine-audio/src/system.zig:

pub fn deinit(self: *AudioSystem) void {
    self.stopAll();
    if (self.backend_ptr) |ptr| {
        const backend_inst: *sdl_backend.SDLAudioBackend = @ptrCast(@alignCast(ptr));
        backend_inst.destroy();
        self.backend_ptr = null;
    }
    self.manager.deinit();
    self.allocator.destroy(self);   // frees the AudioSystem heap allocation
}

Call site in src/game/app.zig:

errdefer audio_manager.deinit();  // line 189 β€” triggered on any later init failure
...
self.audio_manager.deinit();       // line 284 β€” normal shutdown

The pattern of "inner type destroys itself inside its own deinit" is fragile and has already been a source of UAF bugs in this codebase (cf. the comment block at system.zig:62-69 referencing issue #683 about Mixer/SoundData ordering).

πŸ› οΈ Proposed Fix

Pick one of the following, in order of preference:

  1. Capture the allocator before delegating in AudioSystemManager.deinit:

    pub fn deinit(self: *AudioSystemManager) void {
        const allocator = self.audio_system.allocator; // capture first
        self.audio_system.deinit();                      // AudioSystem still valid here
        allocator.destroy(self);
    }

    Optionally, also stash the allocator inside AudioSystemManager at init time so it doesn't depend on the inner struct at all.

  2. Make AudioSystem.deinit non-destructive on self: split it so AudioSystem no longer calls self.allocator.destroy(self); let the caller (AudioSystemManager) own the destroy. Update all callers (AudioSystem.init already does errdefer allocator.destroy(self) so the error path is covered). This is more invasive but eliminates the "inner self-destroy" footgun for every future caller and matches the convention recommended in the comment block at system.zig:62-69.

A regression test should be added in src/tests.zig that constructs an AudioSystemManager with a debug-tracking allocator (e.g., std.testing.allocator) and calls deinit, asserting no leak/double-free is reported.

βœ… Acceptance Criteria

  • AudioSystemManager.deinit no longer reads self.audio_system after audio_system.deinit has returned
  • Running nix develop --command zig build test passes with no leak or double-free diagnostics when the audio manager is exercised
  • A unit test exists in src/tests.zig that constructs and destroys an AudioSystemManager using std.testing.allocator and confirms clean teardown
  • The fix is applied to the recommended option (1) or (2) above; whichever is chosen, document the ownership contract in the module header so future wrappers don't reintroduce the bug

πŸ“š References

Metadata

Metadata

Assignees

No one assigned

    Labels

    automated-auditIssues found by automated opencode audit scansbugSomething isn't workingdocumentationImprovements or additions to documentationenhancementNew feature or requesthotfix

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions