Hardening: Supplementary groups are never dropped in privilege-drop (`as_user.c`)
Nobody has claimed this yet.
- Dominant language
- Go
- Stars
- 974
- Forks
- 296
- Avg merge
- 6d 14h
- Merged PRs (30d)
- 9
Description
Summary
The privilege-drop function set_eids() in
internal/as_user/as_user.c changes the effective UID and GID to a
target user, but never calls setgroups() (or initgroups()) to
drop the process's supplementary group list. This means the child
thread retains root's supplementary groups after the credential
switch, which deviates from the POSIX privilege-drop best practice
documented in CERT POS36-C.
Practical impact — low
This is a correctness / defense-in-depth issue, not a practically
exploitable vulnerability in ignition's context:
- Ignition runs once, at first boot, provisioning the system from
scratch. There are generally no pre-existing group-restricted files
whose access would be meaningfully changed by retained supplementary
groups. - Ignition already runs as root — the
as_usermechanism is
defense-in-depth, not a security boundary. The retained groups don't
grant access beyond what ignition already has. - An attacker who controls the Ignition config already controls the
entire system setup (users, files, systemd units), so the
supplementary groups leak does not provide additional leverage.
That said, the fix is trivial (one line) and brings the code in line
with POSIX best practices, which is worthwhile for correctness and
for any future reuse of this code in a context where the privilege
drop is a security boundary.
Affected code
internal/as_user/as_user.c, set_eids(), lines 57–74:
static int set_eids(au_ids_t *ids) {
uid_t cu;
gid_t cg;
umask(077);
cu = geteuid();
cg = getegid();
if(cg != ids->gid && setregid(-1, ids->gid) == -1)
return -1;
if(cu != ids->uid && setreuid(-1, ids->uid) == -1)
return -1;
return 0; /* ← returns success, but supplementary groups untouched */
}
There is no setgroups(0, NULL) or initgroups(user, gid) call
anywhere in as_user.c, as_user.h, or anywhere else in the
ignition codebase.
Root cause
The correct POSIX privilege-drop sequence, as documented in CERT
POS36-C ("Observe correct revocation order while relinquishing
privileges"), is:
setgroups(0, NULL)— clear supplementary groupssetgid(target_gid)— drop primary groupsetuid(target_uid)— drop user (last, since it may revoke the
ability to change groups)
Step 1 is entirely missing. Steps 2 and 3 use setre*id(-1, ...)
(effective-only, not setres*id), but that is a separate observation
(AU-2). The supplementary groups issue (AU-1) is the more impactful
defect because it grants concrete unauthorized filesystem access.
Reproducer — C program demonstrating retained supplementary groups
This standalone program reproduces the exact clone + set_eids
pattern from as_user.c and prints the supplementary group list
before and after the credential drop, proving the groups are retained:
/*
* repro_au1.c — demonstrates that set_eids() retains supplementary groups.
*
* Must be run as root (to have supplementary groups to retain).
*
* Compile: gcc -o repro_au1 repro_au1.c
* Run: sudo ./repro_au1
* Expected: The child thread shows the SAME supplementary groups as root,
* despite having effective uid/gid changed to nobody (65534).
*/
#define _GNU_SOURCE
#include <errno.h>
#include <grp.h>
#include <sched.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#define STACK_SIZE (64 * 1024)
#define TARGET_UID 65534 /* nobody */
#define TARGET_GID 65534 /* nogroup */
static void print_groups(const char *label) {
gid_t groups[128];
int n = getgroups(128, groups);
printf(" %s: euid=%u egid=%u supplementary_groups(%d)=[",
label, geteuid(), getegid(), n);
for (int i = 0; i < n; i++)
printf("%s%u", i ? "," : "", groups[i]);
printf("]\n");
}
/* Exact reproduction of set_eids from as_user.c */
static int set_eids(uid_t uid, gid_t gid) {
uid_t cu;
gid_t cg;
umask(077);
cu = geteuid();
cg = getegid();
if (cg != gid && setregid(-1, gid) == -1)
return -1;
if (cu != uid && setreuid(-1, uid) == -1)
return -1;
/* BUG: no setgroups(0, NULL) — supplementary groups retained */
return 0;
}
struct child_args {
uid_t uid;
gid_t gid;
int result; /* 0 = groups retained (bug confirmed), 1 = groups dropped */
};
static int child_fn(void *arg) {
struct child_args *ca = (struct child_args *)arg;
printf("\n[Child thread — BEFORE set_eids]\n");
print_groups("before");
if (set_eids(ca->uid, ca->gid) == -1) {
perror("set_eids failed");
ca->result = -1;
return 0;
}
printf("\n[Child thread — AFTER set_eids (euid=%u, egid=%u)]\n",
geteuid(), getegid());
print_groups("after");
/* Check: are supplementary groups still present? */
gid_t groups[128];
int n = getgroups(128, groups);
if (n > 0) {
printf("\n *** BUG CONFIRMED: %d supplementary group(s) RETAINED "
"after privilege drop ***\n", n);
ca->result = 0;
} else {
printf("\n Groups were dropped (no bug).\n");
ca->result = 1;
}
return 0;
}
int main(void) {
if (geteuid() != 0) {
fprintf(stderr, "Error: must be run as root (sudo ./repro_au1)\n");
return 1;
}
printf("[Parent — root]\n");
print_groups("root");
struct child_args ca = { .uid = TARGET_UID, .gid = TARGET_GID, .result = -1 };
char *stack = malloc(STACK_SIZE);
if (!stack) { perror("malloc"); return 1; }
sigset_t allsigs, orig;
sigemptyset(&orig);
sigfillset(&allsigs);
sigprocmask(SIG_BLOCK, &allsigs, &orig);
int pid = clone(child_fn, stack + STACK_SIZE,
CLONE_FILES | CLONE_VM, &ca);
sigprocmask(SIG_SETMASK, &orig, NULL);
if (pid == -1) { perror("clone"); free(stack); return 1; }
if (waitpid(pid, NULL, __WCLONE) == -1 && errno != ECHILD)
perror("waitpid");
printf("\n[Result]\n");
if (ca.result == 0)
printf(" BUG: supplementary groups were NOT dropped by set_eids().\n"
" The target user retains root's supplementary groups.\n");
else if (ca.result == 1)
printf(" OK: supplementary groups were dropped.\n");
else
printf(" ERROR: set_eids() failed.\n");
free(stack);
return (ca.result == 0) ? 0 : 1;
}
Expected output (when run as root)
[Parent — root]
root: euid=0 egid=0 supplementary_groups(N)=[0,...]
[Child thread — BEFORE set_eids]
before: euid=0 egid=0 supplementary_groups(N)=[0,...]
[Child thread — AFTER set_eids (euid=65534, egid=65534)]
after: euid=65534 egid=65534 supplementary_groups(N)=[0,...]
*** BUG CONFIRMED: N supplementary group(s) RETAINED after privilege drop ***
[Result]
BUG: supplementary groups were NOT dropped by set_eids().
The target user retains root's supplementary groups.
Note: euid and egid correctly change to 65534 (nobody/nogroup),
but the supplementary group list is identical before and after —
root's groups (including root(0)) are retained. The exact set of
supplementary groups depends on the system; on a typical desktop
install, groups like adm(4), disk(6), sudo(27) will also
appear.
Consequence
The privilege drop is incomplete per CERT POS36-C: the child
thread's supplementary group list is identical to root's after
set_eids() returns. The reproducer above confirms this concretely.
In ignition's actual usage (one-shot first-boot provisioning running
as root), this does not grant access beyond what ignition already
has, so practical impact is low. However, the code's stated
intent is to "run as the target user," and that contract is violated.
Scope
The set_eids() function is called for every as_user operation:
au_open()— open files as target userau_mkdir_all()— recursive mkdir as target userau_rename()— rename files as target user
These are invoked from ignition's Go code during first-boot
provisioning, processing Ignition configs that specify file ownership.
Fix
Add setgroups(0, NULL) before the gid/uid changes in set_eids():
--- a/internal/as_user/as_user.c
+++ b/internal/as_user/as_user.c
@@ -57,6 +57,10 @@
static int set_eids(au_ids_t *ids) {
uid_t cu;
gid_t cg;
umask(077);
+ if(setgroups(0, NULL) == -1)
+ return -1;
+
cu = geteuid();
cg = getegid();
Or, for a more thorough fix, use initgroups(username, gid) to set
the target user's actual supplementary groups, and use
setresgid/setresuid to also drop the real and saved ids (see
observation AU-2).
How this was found
Found by applying the Squeeze Loop strategy ("The Squeeze Loop
Strategy: Catching Coherent-and-Wrong Artifacts with an
Author-Independent Executable Oracle," Zenodo
10.5281/zenodo.20787816,
2026) on its C terrain. The Frama-C/WP analysis proved that the
return values of setregid/setreuid are correctly checked
(23/23 goals valid) and that the file operation only executes after
successful credential drop (2/2 gate assertions valid). However, the
analysis also revealed what the code does not do: there is no
setgroups call anywhere in the codebase, and the WP-proved
credential postcondition covers only euid/egid, not supplementary
groups. The Squeeze Loop's soft upper bound — CERT POS36-C's
documented correct privilege-drop sequence, which mandates
setgroups — pinpoints the gap that the executable lower bound
(WP proof) cannot cover.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start in internal/as_user/as_user.c at set_eids(), where the issue identifies the incomplete credential drop. Use the supplied root-run reproducer to observe supplementary groups before and after the change, and review the au_open(), au_mkdir_all(), and au_rename() callers. Done means the target process has no retained supplementary groups after a successful privilege drop, while existing operations still work.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- c
- Domain
- security
- Issue type
- Bug
- Difficulty
- 2/5
- Estimated time
- 1-3 hours
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 76/100