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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

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

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions