Skip to content

[Bug]: SIGSEGV on startup — sysinfo get_group_name() reads uninitialized struct group when getgrgid_r finds no group #3863

Description

@ericnoam

Bug Description

forge segfaults on macOS arm64 (SIGSEGV, EXC_BAD_ACCESS, KERN_INVALID_ADDRESS at a tiny non-zero address, on a tokio-rt-worker thread) whenever the machine has a user account whose primary GID has no corresponding group entry.

I traced this from the crash report through the stripped binary's disassembly to the exact upstream source. The root cause is in the sysinfo crate's get_group_name(), which misuses the POSIX getgrgid_r contract.

getgrgid_r returns 0 for "group not found." It signals the miss by setting the result out-param to NULL, and in that case it leaves the caller's struct group completely untouched. Checking only the return value is not sufficient.

sysinfo/src/unix/users.rs:

pub(crate) unsafe fn get_group_name(id: libc::gid_t, buffer: &mut Vec<libc::c_char>) -> Option<String> {
    let mut g = std::mem::MaybeUninit::<libc::group>::uninit();
    let mut tmp_ptr = std::ptr::null_mut();
    ...
            if retry_eintr!(set_to_0 => last_errno => getgrgid_r(
                id as _, g.as_mut_ptr() as _, buffer.as_mut_ptr(),
                buffer.capacity() as _, &mut tmp_ptr as _
            )) != 0
            { ... return None; }
            break;
        }
        let g = g.assume_init();                    // <-- UB: never written on a miss
        super::utils::cstr_to_rust(g.gr_name)       // <-- dereferences stack garbage
    }
}

tmp_ptr is declared and passed, but its value is never read. On a "not found" result the function calls assume_init() on memory getgrgid_r never wrote, then walks g.gr_name as a NUL-terminated C string. Whatever stale value happens to sit in that stack slot is dereferenced.

Verification

12 crash reports on this machine over ~1 week, all with byte-identical top frames:

signal:   SIGSEGV / EXC_BAD_ACCESS
subtype:  KERN_INVALID_ADDRESS at 0x0000000000000001
thread:   "tokio-rt-worker"
lifetime: 168 ms - 12 s

Disassembling the fault site in the shipped (stripped) binary shows the missing check directly. The result out-param is zeroed at sp+0x58 before the call and never loaded again:

1013c7cec: str  xzr, [sp, #0x58]     ; tmp_ptr = NULL
1013c7d14: bl   getgrgid_r           ; x1=&group (sp+0x38), x4=&tmp_ptr (sp+0x58)
1013c7d3c: cbz  w0, 0x1013c7d74      ; ret == 0 -> assumed success
...
1013c7d74: ldr  x8, [sp, #0x38]      ; g.gr_name  (never written by libc)
1013c7d84: cbz  x8, ...              ; only a NULL check
1013c7d88: ldrb w23, [x8]            ; <-- SIGSEGV, x8 = 0x1

Confirming the libc semantics on macOS 26.6.2:

struct group g, *res = NULL; char buf[2048];
g.gr_name = (char *)0x1;                       // stale stack value
int r = getgrgid_r(4242424, &g, buf, sizeof buf, &res);
// r = 0, errno = 0, res = NULL, g.gr_name = 0x1  (left untouched)
gid=20       ret=0  result=0x16b569918  gr_name=0x16b569940
gid=4242424  ret=0  result=0x0          gr_name=0x1   <-- returns 0 but NOT FOUND

The trigger on my machine is a service account installed by an endpoint-management agent (Automox): _automoxserviceaccount has PrimaryGroupID: 1000, and no group with GID 1000 exists. It is the only such account out of 268 passwd entries — but one is enough, since forge enumerates all of them at startup.

$ dscl . -read /Users/_automoxserviceaccount PrimaryGroupID
PrimaryGroupID: 1000
$ dscl . -search /Groups PrimaryGroupID 1000
(no results)

This also explains why the crash is intermittent for some people: it only faults when the stale stack slot happens to be non-zero. When it happens to be 0, the cbz NULL check catches it and forge survives.

Steps to Reproduce

  1. On macOS, have any user account whose primary GID has no matching group. Any machine with Automox installed will have this; otherwise it can be created for testing.
  2. Run any forge command, e.g. forge -p "hello".
  3. zsh: segmentation fault forge -p "hello", exit status 139.

Maintainers can reproduce the faulty branch directly without such an account by calling sysinfo's group lookup with any GID that has no group (e.g. 4242424) and observing that getgrgid_r returns 0 while leaving struct group uninitialized.

Expected Behavior

forge starts normally. A GID that does not resolve to a group should yield None (or be skipped), not a dereference of uninitialized memory.

Actual Behavior

Immediate SIGSEGV before the session gets anywhere. Exit status 139. Reproduces on every invocation.

Suggested Fix

Check the result out-param, which is the documented way to distinguish "found" from "not found":

            break;
        }
        if tmp_ptr.is_null() {
            return None;                        // no group with this gid
        }
        let g = g.assume_init();
        super::utils::cstr_to_rust(g.gr_name)

This is an upstream sysinfo bug and is worth reporting there as well, but it reaches users through forge, and forge can pick it up via a dependency bump once fixed. Note also that Cargo.lock pins sysinfo 0.29.11 while Cargo.toml requests 0.38.3, which may be worth a look independently.

Possibly related

#2645 reported the same signature — KERN_INVALID_ADDRESS at 0x0000000000000002, faulting thread tokio-rt-worker, "Data Abort — byte read Translation fault". It was closed as fixed by #2794, but that PR replaced an unrelated transmute_copy in forge_select. The byte-read-at-a-tiny-address crash on a tokio worker is still present in 2.13.21, and I believe this is its actual cause.

Forge Version

2.13.21

Operating System & Version

macOS 26.6.2 (build 25G83), Apple Silicon arm64 (Mac16,7)

AI Provider

Other / OpenAI-compatible

Model

qwen/qwen3.8-27b (irrelevant — the crash happens before any model call completes)

Installation Method

curl -fsSL https://forgecode.dev/cli | sh (pre-built binary)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions