大小差一错误(off-by-one)介绍
▼
大小差一错误是一类常见的程序设计错误。这方面有一个经典的例子OpenSSH。去 Google 搜索关键词“
OpenSSH off-by-one
”可以了解相关状况。具体来说,
1. if(id channels_alloc)...
2. if(id =channels_alloc)...
第二句应该是正确的写法。举个更通俗的例子:
int a[5],i;
for(i = 1;i
a[i]=0;
上述代码定义了长度为 5 的数组 a,循环的目的是给数组元素初始化,赋值为 0。但是,循环下标从 1 开始到 5,出现了 a[5]=0,这样的不存在的数组元素。这就是典型的“差一错误”(off-by-one)。
其实,貌似说栅栏柱错误(fencepost error)大家更熟悉。我问过身边的朋友,很多人知道这个问题。
如果你要建造一个100米长的栅栏,其栅栏柱间隔为10米,那么你需要多少根栅栏柱呢?
11 根或 9 根都是正确答案,这取决于是否要在栅栏的两端树立栅栏柱,但是 10 根却是错误的。我想起来了我高中是数学老师告诉我们的一个很容易犯错的数学题目。从周一到周五一共有几天?也许你立即反应5-1=4,但是,下意识你也会说五天。实际上应该是5-1+1=5。转换到数学,数字 1 到数字 5 一共有几个数字?这里有一个公式:
从 M 到 N,一共有 M-N+1 项。
这个问题写出来后很简单,只不过在写代码的时候,往往比较容易忽略。尤其在涉及到数组操作两端界限的时候,如果不是从 0 开始计数,就要稍微考虑一下咯。
前 言
▼
我认为我决定写关于本漏洞的理由是因为当我把它发在推特上时,我收到了一些私信说这个内核路径不是爆破点(找不到 bug 在哪)或者不可利用。另一个的理由就是我想在实际中尝试 userfaultfd() 的系统调用。我需要一个真实的 UAF 来试试。
首先,我不知道这个漏洞是否影响到在主要发行版本的任何 upstream 的内核。我只检查了 ubuntu 支线和 Yakkety 支线没有影响。但是,补丁包常常会出问题。Bug 在
https://git.kernel.org/cgit/linux/kernel/git/torvalds/linux.git/commit/?id=bb646cdb12e75d82258c2f2e7746d5952d3e321a
中有介绍,在
https://git.kernel.org/cgit/linux/kernel/git/torvalds/linux.git/commit/?id=30a46a4647fd1df9cf52e43bf467f0d9265096ca
中提交和修复。
因为我不能找到一个拥有爆破点的 ubuntu 内核版本,我在 ubuntu16.04(x86_64)上编译了4.5.1内核。虽然值得一提的是爆破点仅仅影响(像 ubuntu)默认使用AppArmor的发行版本。
Vulnerability(爆破点)
▼
写入 /proc/self/attr/current触发proc_pid_attr_write() 函数。接下来介绍的代码是爆破点前面的:
staticssize_t proc_pid_attr_write(struct file * file, const char __user * buf,
size_tcount, loff_t *ppos)
{
struct inode * inode =file_inode(file);
char *page;
ssize_t length;
struct task_struct *task =get_proc_task(inode);
length = -ESRCH;
if (!task)
goto out_no_task;
if (count > PAGE_SIZE) [1]
count = PAGE_SIZE;
/* No partial writes. */
length = -EINVAL;
if (*ppos != 0)
goto out;
length = -ENOMEM;
page =(char*)__get_free_page(GFP_TEMPORARY); [2]
if (!page)
goto out;
length = -EFAULT;
if (copy_from_user(page, buf,count)) [3]
goto out_free;
/* Guard against adverse ptraceinteraction */
length =mutex_lock_interruptible(&task-;>signal->cred_guard_mutex);
if (length
goto out_free;
length = security_setprocattr(task,
(char*)file->f_path.dentry->d_name.name,
(void*)page, count);
...
buf 参数代表用户提供的缓冲区(和长度 count)被写入到 /proc/self/attr/current。在[1] 处,check 被执行以确保缓冲区会适应一个单页(默认 4096 个字节)。在 [2] 和[3],一个单页被分配了并且用户空间缓冲区被复制到新分配的页。这个页传递给代表 LSM 钩子(AppArmour,SELinux,Smack)的 security_setprocattr。假如是 ubuntu,这个钩子触发了apparmor_setprocattr() 函数,代码显示如下:
在 [4] 处,如果用户提供的缓冲区最后的字节不为空并且缓冲区大小不等于页的大小时,缓冲区末端为空。在另一面,如果用户提供的缓冲区超过(或者等于)单页的大小(在 [2] 处分配的),路径被终止并且 -EINVAL 被返回。
接下来显示的代码改为在爆破点之后的(在 [3] 处的) proc_pid_attr_write():
static ssize_t proc_pid_attr_write(struct file * file, const char __user * buf,
size_t count, loff_t *ppos)
{
struct inode * inode = file_inode(file);
void *page;
ssize_t length;
struct task_struct *task = get_proc_task(inode);
length = -ESRCH;
if (!task)
goto out_no_task;
if (count > PAGE_SIZE)
count = PAGE_SIZE;
/* No partial writes. */
length = -EINVAL;
if (*ppos != 0)
goto out;
page = memdup_user(buf, count); [5]
if (IS_ERR(page)) {
length = PTR_ERR(page);
goto out;
}
/* Guard against adverse ptrace interaction */
length = mutex_lock_interruptible(&task-;>signal->cred_guard_mutex);
if (length
goto out_free;
length = security_setprocattr(task,
(char*)file->f_path.dentry->d_name.name,
page, count);
...
不像 __get_free_page(),memdup_user() 分配了一块被 count 参数指定内存并且复制用户提供的数据到其中。因此,对象被分配的大小不再是严格的 4096 个字节(即使它仍然是最大缓冲区大小)。让我们假设用户提供的数据是 128 个字节大小并且缓冲区的最后字节不为空。当 apparmor_setprocattr() 被触发,args[128] 将被设置为0,因为检查的仍然为 PAGE_SIZE,并不是真实缓冲区大小:
if (args[size - 1] != '\0') {
if (size == PAGE_SIZE)
return -EINVAL;
args[size] = '\0';
}
因为对象在堆中被动态分配,下一个对象的第一位会被重写为 null。标准技术在不会起作用的爆破点对象之后放置了一个目标对象(包含了一个函数指针作为第一个成员)。一个主意是在一些对象上(因为像爆破点对象同样大小的)重写一个引用计数器并且然后触发一个 UAF(谢谢 Nicolas Tripar 的建议)。
对象引用计数器(atomic_t type = signed int 所代表)通常是结构的第一个成员。因为计数器的值通常对于大多数对象都是在 255 以下的,重写像一个对象最低有效的字节会清除计数器并且导致一个标准的 UAF。然而为了利用这个漏洞,我决定用一个不同的方法:重写SLUB freelist 指针。
Exploitation(爆破)
▼
关于这个漏洞的一件好事是我们能控制目标对象的大小。为了触发这个漏洞,对象大小应该被设置为缓存大小中的一个(如9,16,32,64,96,等等)。我们不会探究SLUB分配器如何工作(linux默认内核内存分配器)。我们所需要知道的是同样大小的不同对象为多用途和特殊用途分配积累成相同的缓存。slab是在缓存上包含同样大小对象主要的页。自由对象在偏移地址0(默认)处有一个“next free”的指针指向slab的下一个自由对象。
这个主意是在同样的一个slabshang 放置我们脆弱的对象(A)邻近一个自由对象(B),然后清除对象B的“next free”指针的least-significant字节。当两个新对象在同样的slab上被分配,最后的对象将会被分配在靠着“next free”指针的对象A和/或者对象B。
上文情境(重叠 A 和 B 对象)是仅仅可能结局之一。目标对象“变化”的值是一个字节(0-255)并且最终目标对象的位置会依靠在原始的“next free”指针的值和对象大小。
假设目标对象会与对象 A 和 B 重叠,我们想要控制这些对象的内容。
在一个高等级,漏洞利用程序如下:
1. 在同样的slab上在邻近对象B处放置脆弱对象 A
2. 重写 B中的“next free”指针的 least-significant 字节
3. 在同样的 slab 分配两个新对象:第一个对象将会被放在 B 处,并且第二个对象将会替代我的目标对象 C
4. 如果我们控制对象A和B的内容,我们可以强制对象 C 被分配在用户空间
5. 假设对象 C 有一个能从其他地方触发函数指针,在用户空间或者可能的一个 ROP 链(绕过 SMEP)中设置这个指针为我们的权利提升的 payload。
为了执行步骤 1-3,连续的对象分配能够取得使用一个标准的堆耗尽技术。
接下来,我们需要选择正确的对象的大小。对象比 128 字节更大(例如,申请缓存256,512,1024 个字节,等等)的话,就不会在这里起作用。让我们假设起始的 slab 地址是 0x1000(标记slab的起始地址是与页大小一致的并且连续对象分配是相连的)。接下来的 C 程序列出了被给定对象大小的一个单页的分配:
//page_align.c
#include
int main(intargc, char **argv) {
int i;
void *page_begin = 0x1000;
for (i = 0; i
printf("%p\n",page_begin + i);
}
因为这些对象是 256 个字节(或>128并
vnik@ubuntu:~$./align 256
0x1000
0x1100
0x1200
0x1300
0x1400
0x1500
0x1600
0x1700
0x1800
...
在 slab 所有配置的最低有效位为0并且重写邻近的自由对象的“nextfree”指针为 null 会没有效果:
因为 128 字节缓存,这里有两种可能选项:
vnik@ubuntu:~$./align 128
0x1000
0x1080
0x1100
0x1180
0x1200
0x1280
0x1300
0x1380
0x1400
...
第一个选项相似于之上的 256 字节例子(“nextfree”指针的最低有效位字节已经为0)。第二个选项很有趣,因为重写“next free”指针的最低有效位字节会指向自由对象本身。分配一些8个字节大小的对象(A)到一些(确定的)用户空间内存地址,其次是目标对象(B)的分配会在用户空间用户控制的内存地址放置对象B。这可能是在可靠性和易于利用两者间中的最好的选项。
1. 这有一个50/50成功机会。如果它是第一选项,没有崩溃,我们可以再试一次
2. 找到一个有一些用户空间地址的对象(首8个字节)将被放置在kmalloc-128缓存并不难。
尽管这是最好的方法,我决定将所有96字节对象和用msgsnd()堆耗尽/喷涂粘合起来。主要(仅有)的理由是因为已经发现了一个我想要使用的96字节的目标对象了。感谢Thomas Pollet帮助找到合适的堆对象并且在运行时用gdb/python自动化处理这个乏味的过程!
然而,显然使用 96 字节对象有下降趋势;一个主要的原因是利用的可靠性。一个耗尽slab(如填充满slab部分)的主意就是 48 字节对象的标准 msgget() 技术(其他48字节被用来作为消息头)。这也将用作一个堆喷涂因为我们控制了 msg 对象的一半(48字节)。我们也控制脆弱对象的内容(数据从用户空间被写到 /proc/self/attr/current)。如果目标对象分配以便首8个字节被我们的数据所覆盖,然后漏洞利用将会成功。在另一方面,如果这8个字节用 msg 头(我们没有控制的)来覆盖,这会导致一个页面错误但是内核可能会被它自身恢复。基于我的分析,这里有两个例子,在这“next free”指针会用先前分配的随机 msg 头覆盖。
这里是有一些技巧来提高漏洞利用的可靠性。
目 标 对 象
▼
因为目标对象,我使用了 struct subprocess_info 结构,正是96字节大小。为了触发这个对象的分配,下面的套接字操作可以使用一个随机的协议家族:
socket(22,AF_INET, 0);
套接字族22不存在但是模块自动加载会触发到内核中下面的函数:
intcall_usermodehelper(char *path, char **argv, char **envp, int wait)
{
struct subprocess_info *info;
gfp_t gfp_mask = (wait == UMH_NO_WAIT)? GFP_ATOMIC : GFP_KERNEL;
info = call_usermodehelper_setup(path,argv, envp, gfp_mask, [6]
NULL,NULL, NULL);
if (info == NULL)
return -ENOMEM;
return call_usermodehelper_exec(info,wait); [7]
}
call_usermodehelper_setup [6] 然后会分配对象和初始化它的字段:
structsubprocess_info *call_usermodehelper_setup(char *path, char **argv,
char **envp, gfp_t gfp_mask,
int (*init)(structsubprocess_info *info, struct cred *new),
void (*cleanup)(structsubprocess_info *info),
void *data)
{
struct subprocess_info *sub_info;
sub_info = kzalloc(sizeof(structsubprocess_info), gfp_mask);
if (!sub_info)
goto out;
INIT_WORK(⊂_info->work,call_usermodehelper_exec_work);
sub_info->path = path;
sub_info->argv = argv;
sub_info->envp = envp;
sub_info->cleanup = cleanup;
sub_info->init = init;
sub_info->data = data;
out:
return sub_info;
}
一旦对象被初始化,这将绕过 call_usermodehelper_exec in [7]:
intcall_usermodehelper_exec(struct subprocess_info *sub_info, int wait)
{
DECLARE_COMPLETION_ONSTACK(done);
int retval = 0;
if (!sub_info->path) { [8]
call_usermodehelper_freeinfo(sub_info);
return -EINVAL;
}
...
}
如果路径变量为 null[8],然后 cleanup 函数被执行并且对象被释放:
static voidcall_usermodehelper_freeinfo(struct subprocess_info *info)
{
if (info->cleanup)
(*info->cleanup)(info);
kfree(info);
}
如果我们覆盖了 cleanup 函数指针(记住对象现在在用户空间被分配),然后我们随着 CPL=0 就有了任意代码执行。仅有的一个问题是 subprocess_info 对象分配和释放在同样的路径。在 info->cleanup)(info) 被调用并且设置函数指针到我们的权限提升payload之前修改对象函数指针的一个方法是以某种方法停止执行。我本可以找到其他同样大小的因为分配和函数触发的两种“分开”路径,但是我需要一个理由去尝试userfaultfd() 和这个页面分裂的想法。