Re: [PATCH] apparmor: fix cred UAF caused by begin_current_label_crit_section()
Jann Horn <[email protected]> Tue, 14 Jul 2026 17:49:33 +0200
| Newsgroups | org.kernel.vger.linux-security-module,org.kernel.vger.linux-kernel,org.kernel.vger.stable |
|---|---|
| Message-ID | <CAG48ez2hcn2L2uC8bu_3wqKuX35cpQhmVxuNNdpmdJ_tQvShPQ@mail.gmail.com> |
On Tue, Jul 14, 2026 at 5:39 PM Jann Horn <[email protected]> wrote: > I have a test case where I run aa-disable on a profile while a process > using that profile is blocked on splice() from a FUSE passthrough file into > a full pipe; after the profile update, the pipe becomes empty, splice() > resumes, the credentials go out of sync, and a subsequent getuid() syscall > results in a KASAN UAF splat. To test this, you should run a kernel with KASAN. CONFIG_RCU_STRICT_GRACE_PERIOD=y might also be necessary to trigger a KASAN warning. Open two terminals A and B. In terminal A, write the following policy into /etc/apparmor.d/credbug-test: ``` abi <abi/4.0>, include <tunables/global> profile credbug /tmp/credbug { /** rwm, mount, umount, capability sys_admin setuid, } ``` and enable it: ``` user@vm:~$ sudo aa-enforce /tmp/credbug Setting /tmp/credbug to enforce mode. Warning: profile credbug represents multiple programs user@vm:~$ ``` In terminal B, build the reproducer and launch it with root privileges: ``` user@vm:~/apparmor-replace-label$ cat > credbug.c #define _GNU_SOURCE #include <pthread.h> #include <assert.h> #include <err.h> #include <errno.h> #include <fcntl.h> #include <stdio.h> #include <unistd.h> #include <sys/fsuid.h> #include <sys/mount.h> #include <sys/prctl.h> #include <linux/fuse.h> #define SYSCHK(x) ({ \ typeof(x) __res = (x); \ if (__res == (typeof(x))-1) \ err(1, "SYSCHK(" #x ")"); \ __res; \ }) static int p[2]; static int fuse_fd; static volatile int fuse_ready = 0; static volatile int backing_id = -1; static int last_credref_fd = -1; static int drop_last_credref = 0; static void *splice_thread_fn(void *dummy) { int backing_fd = SYSCHK(open("/tmp/credbug", O_RDONLY)); // unshare creds setfsuid(1); setfsuid(0); last_credref_fd = SYSCHK(open("/", O_PATH)); while (!fuse_ready) /*spin*/; struct fuse_backing_map backing_arg = { .fd = backing_fd }; backing_id = SYSCHK(ioctl(fuse_fd, FUSE_DEV_IOC_BACKING_OPEN, &backing_arg)); int passthrough_fd = SYSCHK(open("/tmp/mntfile", O_RDONLY)); off_t off0 = 0; // creds are changed in the middle of this splice(passthrough_fd, &off0, p[1], NULL, 1, 0); close(passthrough_fd); SYSCHK(ioctl(fuse_fd, FUSE_DEV_IOC_BACKING_CLOSE, &backing_id)); drop_last_credref = 1; while (drop_last_credref) /*spin*/; sleep(1); getuid(); return NULL; } #define READ(_obj) if (read(fuse_fd, &(_obj), sizeof(_obj)) != sizeof(_obj)) err(1, "failed to read " #_obj) #define WRITE(_obj) if (write(fuse_fd, &(_obj), (_obj).h.len) != (_obj).h.len) err(1, "failed to write " #_obj) static void *fuse_thread_fn(void *dummy) { while (1) { struct { struct fuse_in_header inh; union { struct fuse_init_in init_in; struct fuse_open_in open_in; struct fuse_read_in read_in; char pad[10000]; }; } buf; ssize_t read_res = read(fuse_fd, &buf, sizeof(buf)); if (read_res == -1) { if (errno == ENODEV) return NULL; } assert(read_res >= sizeof(buf.inh)); if (buf.inh.opcode == FUSE_INIT) { printf("fuse: init\n"); struct { struct fuse_out_header h; struct fuse_init_out b; } reply = { .h = { .len = sizeof(reply), .error = 0, .unique = buf.inh.unique }, .b = { .major = buf.init_in.major, .minor = buf.init_in.minor, .max_stack_depth=1, .flags=FUSE_INIT_EXT, .flags2=FUSE_PASSTHROUGH>>32 } }; WRITE(reply); fuse_ready = 1; } else if (buf.inh.opcode == FUSE_GETATTR) { printf("fuse: getattr\n"); struct { struct fuse_out_header h; struct fuse_attr_out b; } reply = { .h = { .len = sizeof(reply), .error = 0, .unique = buf.inh.unique }, .b = { .attr_valid = FATTR_SIZE | FATTR_MODE, .attr = { .size = 0x1, .mode = 0100777 } } }; WRITE(reply); } else if (buf.inh.opcode == FUSE_OPEN) { printf("fuse: open node 0x%lu\n", (unsigned long)buf.inh.nodeid); while (backing_id == -1) /*spin*/; struct { struct fuse_out_header h; struct fuse_open_out b; } reply = { .h = { .len = sizeof(reply), .error = 0, .unique = buf.inh.unique }, .b = { .open_flags = FOPEN_PASSTHROUGH, .backing_id = backing_id } }; WRITE(reply); } else { printf("FUSE_<%d> unhandled\n", buf.inh.opcode); struct { struct fuse_out_header h; } reply = { .h = { .len = sizeof(reply), .error = -ENOSYS, .unique = buf.inh.unique }, }; WRITE(reply); } } } int main(void) { // create a FUSE mount, and handle requests in a thread SYSCHK(close(SYSCHK(open("/tmp/mntfile", O_RDONLY|O_CREAT, 0666)))); fuse_fd = SYSCHK(open("/dev/fuse", O_RDWR)); char mount_data[4096]; sprintf(mount_data, "fd=%d,rootmode=0100777,user_id=%d,group_id=%d", fuse_fd, getuid(), getgid()); SYSCHK(mount("blah", "/tmp/mntfile", "fuse", MS_NODEV|MS_NOSUID, mount_data)); pthread_t fuse_thread; pthread_create(&fuse_thread, NULL, fuse_thread_fn, NULL); SYSCHK(pipe(p)); SYSCHK(fcntl(p[1], F_SETPIPE_SZ, 0x1000)); char buf[0x1000] = {}; SYSCHK(write(p[1], buf, 0x1000)); pthread_t splice_thread; pthread_create(&splice_thread, NULL, splice_thread_fn, NULL); getchar(); read(p[0], buf, 0x1000); while (!drop_last_credref) /*spin*/; close(last_credref_fd); drop_last_credref = 0; pthread_join(splice_thread, NULL); SYSCHK(umount2("/tmp/mntfile", MNT_DETACH)); pthread_join(fuse_thread, NULL); } user@vm:~/apparmor-replace-label$ gcc -o /tmp/credbug credbug.c user@vm:~/apparmor-replace-label$ sudo /tmp/credbug fuse: init fuse: open node 0x1 ``` While the reproducer is blocked, use terminal A to disable its profile: ``` user@vm:~$ sudo aa-disable /tmp/credbug Disabling /tmp/credbug. user@vm:~$ ``` Then hit enter in terminal B to let the reproducer continue: ``` FUSE_<25> unhandled FUSE_<18> unhandled user@vm:~/apparmor-replace-label$ ``` Now you should see a KASAN UAF splat in dmesg, when getuid() tries accessing the creds: ``` ================================================================== BUG: KASAN: slab-use-after-free in __ia32_sys_getuid+0x3d/0x80 Read of size 8 at addr ffff8881269a8950 by task credbug/696 CPU: 2 UID: 0 PID: 696 Comm: credbug Not tainted 7.2.0-rc3-00038-g3b029c035b34 #55 PREEMPT Hardware name: QEMU Standard PC (i440FX + PIIX, 1996), BIOS 1.17.0-debian-1.17.0-1 04/01/2014 Call Trace: <TASK> __dump_stack+0x21/0x30 dump_stack_lvl+0x76/0xa0 print_address_description+0x7b/0x1f0 print_report+0x5b/0x70 kasan_report+0x16d/0x1a0 [...] __asan_load8+0x98/0xa0 __ia32_sys_getuid+0x3d/0x80 x64_sys_call+0x1cc1/0x3030 do_syscall_64+0xd8/0x380 [...] entry_SYSCALL_64_after_hwframe+0x76/0x7e [...] </TASK> Allocated by task 696: kasan_save_track+0x3a/0x70 kasan_save_alloc_info+0x3c/0x50 __kasan_slab_alloc+0x4e/0x60 kmem_cache_alloc_noprof+0x25f/0x570 prepare_creds+0x2b/0x4d0 __sys_setfsuid+0x8b/0x190 __x64_sys_setfsuid+0x1e/0x30 x64_sys_call+0x955/0x3030 do_syscall_64+0xd8/0x380 entry_SYSCALL_64_after_hwframe+0x76/0x7e Freed by task 70: kasan_save_track+0x3a/0x70 kasan_save_free_info+0x46/0x60 __kasan_slab_free+0x43/0x70 kmem_cache_free+0x182/0x500 put_cred_rcu+0x19e/0x210 rcu_core+0x877/0xfd0 rcu_core_si+0x9/0x10 handle_softirqs+0x19f/0x550 __irq_exit_rcu+0xab/0x180 irq_exit_rcu+0x9/0x20 sysvec_call_function+0x73/0x80 asm_sysvec_call_function+0x1b/0x20 Last potentially related work creation: kasan_save_stack+0x3a/0x60 kasan_record_aux_stack+0x99/0xb0 call_rcu+0x51/0x5c0 __put_cred+0x9a/0xc0 __fput+0x425/0x540 fput_close_sync+0x8a/0x140 __x64_sys_close+0x55/0xe0 x64_sys_call+0x26ce/0x3030 do_syscall_64+0xd8/0x380 entry_SYSCALL_64_after_hwframe+0x76/0x7e The buggy address belongs to the object at ffff8881269a88c0 which belongs to the cache cred of size 184 The buggy address is located 144 bytes inside of freed 184-byte region [ffff8881269a88c0, ffff8881269a8978) [...] ================================================================== ```