β¬’ DragonFlyBSD Kernel Audit
← triage Β· dashboard
DF-2697

vaccess() grants group file-access permissions from the SAVED gid (cr_svgid): kernel-enforced group privileges survive setegid()/setgid() drops (POSIX mqueue access control fail-open)

Field Value
ID DF-2697
Status new
Severity Medium
CVSS 3.1 CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:L/I:L/A:N
CWE CWE-284 Improper Access Control
File sys/kern/vfs_subr.c
Lines 1752-1753 (call site sys_mqueue.c:513)
Area kern
Confidence certain
Discovered 2026-08-30
Pass 2 (GLM 5.3 second pass)
Bucket privesc
Reported pending
Known CVE none
CVE match novel

Summary

The group branch of the kernel's generic permission check accepts the credential's SAVED gid in addition to the effective gid and supplementary groups. Because setegid() and unprivileged setgid() drops never clear cr_svgid (the reset runs only for privileged callers or gid==egid), any process that ever held egid=G (setgid binary exec seeds svgid) silently keeps group-G read/write access after dropping the effective gid. Only in-kernel caller: POSIX mqueue open (sys_mqueue.c:558); the filesystem path (vop_helper_access) is unaffected. Same confusion at the call site: sys_mqueue.c:513 stores the queue gid from the creator's cr_svgid.

Threat model & preconditions

Local unprivileged user in a setgid context (or any privilege-separated daemon that drops a group) retains read/write access to group-protected POSIX message queues the process believed it had relinquished. Cannot mint a group the process never held, so Medium rather than High.

Proof of concept

VERIFIED on the guest (findings/poc/DF-2697/): root creates mqueue /svgidtest mode 0660 root:wheel and installs a setgid-wheel binary; unpriv user execs it (egid=svgid=0), setegid(getgid()) drops egid to 1001, then mq_open(O_WRONLY) SUCCEEDS β€” vaccess matched cr_svgid==0. Control without the setgid bit: EACCES. Fix kernel: dropped case denies in both copies, egid=0 case still opens via groupmember.

--- a/sys/kern/vfs_subr.c
+++ b/sys/kern/vfs_subr.c
@@ -1749,8 +1749,7 @@ vaccess(enum vtype type, mode_t file_mode, uid_t uid, gid_t gid,
    }

    /* Otherwise, check the groups. */
-   ismember = groupmember(gid, cred);
-   if (cred->cr_svgid == gid || ismember) {
+   if (groupmember(gid, cred)) {
        if (acc_mode & VEXEC)

(and remove the now-unused int ismember;). Validated in-guest.

Timeline

  • 2026-08-30 Discovered during pass-2 audit of vfs_subr.c (GLM 5.3); reproduced + fix validated same run.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2697 Β· 11 files
FileTypeDescriptionSize
gdrop.c β€” 1.9 KB view raw
mqcreate.c β€” 767 B view raw
setup.sh β€” 374 B view raw
run.sh β€” 625 B view raw
build.sh β€” 172 B view raw
run.log β€” 1.0 KB view raw
fix.log β€” 499 B view raw
env.txt β€” 750 B view raw
fix.diff β€” 691 B view raw
fix.both.diff β€” 1.0 KB view raw
VERDICT.md β€” 3.5 KB ↓ raw
VERDICT.md
↓ download raw

DF-2697 β€” vaccess() honors the saved gid (cr_svgid) in group permission checks

Reproduced? YES (reproduced, impact = access-control bypass, not memory corruption)

Guest: DragonFly 6.5-DEVELOPMENT #0 (X86_64_GENERIC, INVARIANTS), uid 1001 (maxx).

Root cause

sys/kern/vfs_subr.c:1752-1753:

    ismember = groupmember(gid, cred);
    if (cred->cr_svgid == gid || ismember) {

groupmember() (sys/kern/kern_prot.c:949-960) already scans cr_groups[0 .. cr_ngroups), i.e. it covers the effective gid (#define cr_gid cr_groups[0], sys/sys/ucred.h:81) and the supplementary groups β€” exactly the POSIX group access set. The additional cred->cr_svgid == gid clause extends the group branch to the saved gid, which is not part of any access decision set (POSIX 1003.1; FreeBSD's vaccess checks only cr_groups[0] == gid || groupmember(...)).

cr_svgid survives every legitimate privilege drop:

  • setegid() (sys/kern/kern_prot.c:649-653) sets only cr_groups[0]; cr_svgid is untouched.
  • unprivileged setgid(X) (kern_prot.c:572-625): the cr_svgid = gid reset at :611 happens only when the caller is privileged or gid == egid, so an unprivileged drop leaves svgid at the old egid.
  • svgid is seeded from a setgid binary at exec (kern/kern_exec.c:532-535).

So: exec setgid-G binary β†’ egid=G, svgid=G; setegid(rgid) (allowed, rgid) β†’ egid=rgid but svgid stays G β†’ vaccess(..., gid=G, ...) still takes the group branch and grants group-mode read/write.

Note the same confusion appears at the call site: POSIX mqueue creation stores the queue's gid from the creator's svgid (sys/kern/sys_mqueue.c:513 mq_new->mq_egid = td->td_ucred->cr_svgid;) instead of cr_gid. The vaccess() clause is what turns the odd stored value into a grantable credential.

Exploit chain (demonstrated)

  1. root: create mqueue /svgidtest mode 0660 (mq_euid=0, mq_egid=0=wheel).
  2. root: install gdrop setgid-wheel (2755) and gdrop_plain (755).
  3. unpriv user (uid/gid 1001, groups={1001}) runs /gdrop drop /svgidtest: * exec sets egid=svgid=0 (wheel); * setegid(getgid()) β†’ egid=1001, svgid remains 0; * mq_open(O_WRONLY) β†’ vaccess: owner? uid 1001 != 0. Group? groupmember(0, cred)=false (groups={1001}) but cr_svgid==0==gid β†’ group branch β†’ S_IWGRP set in 0660 β†’ granted. * Control run (/gdrop_plain, svgid=1001) β†’ EACCES (correct).

Result: kernel-enforced write access to a wheel-group object after the process dropped its effective wheel gid. Impact ceiling: silently defeats group-privilege drops for POSIX mqueue access (e.g. sandboxed/privilege- separated processes keep mqueue read/write they already dropped). It does not grant a group the process never held, and it does not touch the main filesystem path (vop_helper_access, sys/kern/vfs_helper.c:141-159, uses cr_gid + supplementary only β€” no svgid).

Fix validation

Combined one-line fix kernel (fix.both.diff: vaccess drops the svgid clause; hammer result fix), built with make nativekernel KERNCONF=X86_64_GENERIC in the guest:

  • baseline (stock kernel): setgid copy opens the queue after the drop β€” see run.log
  • patched kernel: mq_open β†’ EACCES after setegid in ALL cases, and the setgid "keep" case still opens (egid=0 via groupmember) β€” see fix.log

Verdict

Real, reproducible kernel access-control fail-open confined to vaccess() (only caller: POSIX mqueue open). Severity Medium: defeats privilege drops but cannot mint a group the process never had.

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

Single-fix kernel (fix.both.diff carried this one-line change + the DF-2698 one-liner) built with make nativekernel KERNCONF=X86_64_GENERIC and booted. Baseline stock kernel: setgid-wheel gdrop opens the 0660 queue after setegid drop (SUCCESS). Patched kernel: EACCES in all dropped cases; egid=0 case still succeeds via groupmember. Bug gone, no regression.

fix.log / fix_run.log (before/after in same pack); fix_build.log tail of the nativekernel build; uname -v #2 boot identity.
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT DragonFly 6.5-DEVELOPMENT #2: Mon Aug 31 00:53:26 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

Confirmed kernel references

Detail

Exploit chain

root setup: mqueue /svgidtest created 0660 root:wheel (umask 0) + gdrop installed 2755 root:wheel; unpriv user (uid/gid 1001, groups={1001}) execs gdrop: egid=svgid=0; gdrop calls setegid(1001) (allowed, rgid) -> egid=1001, svgid stays 0; mq_open(O_WRONLY) -> vaccess group branch matches cr_svgid==0==queue gid -> write access to the wheel-group queue after the drop was 'completed'.

Evidence (decisive lines)

['run.log: ==SETGID-wheel-copy== mq_open(/svgidtest, O_WRONLY) = 3 -- SUCCESS after setegid drop, while ==CONTROL-no-setgid== gets EACCES', 'fix.log (patched kernel): identical run -> Permission denied in both cases; keep-egid case still opens (groupmember semantics preserved)', 'VERDICT.md: full path:line trace']

PoC changes

mqcreate.c initially did not reset an existing queue and inherited root's umask 022, making the queue 0640 (no group write) so every run was denied; recreated with umask 000. gdrop.c unchanged from the sketch.

Verified recommended fix

Drop the cr_svgid clause in vaccess(): replace 'ismember = groupmember(gid, cred); if (cred->cr_svgid == gid || ismember)' with 'if (groupmember(gid, cred))'.

Verdict

vaccess() at sys/kern/vfs_subr.c:1752-1753 grants the group branch of the POSIX permission check when cred->cr_svgid == gid. groupmember() already covers the effective gid (cr_groups[0]) and supplementary groups, so the svgid clause only ever ADDS the saved gid to the access set. setegid() (kern_prot.c:649-653) and unprivileged setgid() drops never clear cr_svgid, so after exec of a setgid-G binary and an egid drop the kernel keeps granting group-G read/write. Only in-kernel caller is POSIX mqueue open (sys_mqueue.c:558). Reproduced on the stock guest: a setgid-wheel binary that calls setegid(getgid()) then mq_open(O_WRONLY) on a 0660 root:wheel queue SUCCEEDS (control binary without the setgid bit: EACCES). One-line fix (drop the svgid clause) validated with an in-guest nativekernel build: after the drop mq_open fails with EACCES while the legitimate egid=0 case still succeeds.