coreos / coreos/ignition

Hardening: Supplementary groups are never dropped in privilege-drop (`as_user.c`)

Open Beginner friendly
#2,242 0 comments 0 reactions 0 assignees View on GitHub

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_user mechanism 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:

  1. setgroups(0, NULL) — clear supplementary groups
  2. setgid(target_gid) — drop primary group
  3. setuid(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 user
  • au_mkdir_all() — recursive mkdir as target user
  • au_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

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.