RCTF 2022 Reverse Writeup

Preface

2023 年 XCTF 联赛第一站,虽然是在家打的比赛,氛围不如在战队实验室,但和队友远程协作也使得逆向的过程没有那么孤独。48 小时的比赛,逆向部分 7 道题目,有些强队在距离比赛结束还有 15 小时前就 AK 了,而我在比赛结束前 3 小时才终于提交了最后一道 rdefender。

反思了一下,除了 M1 Mac 上调试 x86_64 小有不便之外,根本原因还是对 lua 以及 rust 相关逆向不太熟悉,导致比较多的时间都消耗在了试错上。除了语言特性的因素,如果在逆向过程中可以快速识别出关键结构体,也会大大加快解题的速度,而以上两个问题都需要有相关的经验做基础,需要在平时多积累、多分析。

此外,huowang 这道题目中包含 unicorn 模拟执行的部分,虽然在实际解题过程中并没有涉及,但在以后的学习中有必要专门研究一下,以免出现将来在比赛过程中现学的窘境。

CheckYourKey

和今年祥云杯 GetTheCorrectKey 使用了相同的 trick,即数据在 .init_array 中做了预处理解密,主要考察的是 native so 加载的过程

需要注意的是,native 函数 ooxx 在实际调用过程中并非调用 Java_com_ctf_CheckYourKey_MainActivity_ooxx ,而是调用的 sub_8965 ,后者是在 JNI_OnLoad 时通过 Register_Natives 函数指定的

加密算法的识别部分并不困难,根据特征值及比对字符串格式不难最终确认出加密为 AES-ECB + base58 + 换表 base64

recover string

from ida_bytes import get_byte

# addr, length, val
modify = [(0x41080, 0x04, 0x17),
          (0x41088, 0x09, 0xad),
          (0x41094, 0x0f, 0xc9),
          (0x410A4, 0x05, 0x5a),
          (0x410b0, 0x1A, 0xC9),
          (0x410cc, 0x04, 0x4A),
          (0x410d4, 0x05, 0x75),
          (0x410dc, 0x01, 0xd9),
          (0x410e0, 0x02, 0xb4),
          (0x410f0, 0x10, 0x2d),
          (0x41104, 0x09, 0x8c),
          (0x41110, 0x0e, 0xe2),
          (0x41120, 0x1e, 0x46),
          (0x41140, 0x10, 0xff),
          (0x41160, 0x20, 0x65),
          (0x41190, 0x21, 0x5b),
          (0x411b4, 0x04, 0xec),
          (0x411c0, 0x15, 0xce),
          (0x411d8, 0x0f, 0x3a),
          (0x411f0, 0x19, 0x1f),
          (0x4120c, 0x0b, 0xc3),
          (0x41218, 0x05, 0x6d),
          (0x41220, 0x0d, 0x41),
          (0x41230, 0x09, 0xf4),
          (0x4123c, 0x0c, 0x7b),
          (0x41250, 0x43, 0xa3),
          (0x41010, 0x3a, 0xaf)]

for item in modify:
    temp = ''
    for i in range(item[1]):
        temp += chr(get_byte(item[0] + i) ^ item[2])
    print(hex(item[0]), temp)

table = ''
for addr in range(0x33f0f, 0x33f4f):
    table += chr(get_byte(addr))
print('base64 table: ' + table)

decrypt

checkyourkey_decrypt

web_run

一道简单的 webassembly 逆向,由于看主逻辑的 wat 没有很复杂,便没有借助反编译工具进行分析,而是选择直接看 wat + Chrome 动态调试,分析后程序逻辑如下:

  • 程序一共接收两次输入,第一次要求输出对应的时间戳,第二次要求输入 ca_value
  • func10 接收第一个输入,并在转换后与硬编码 202211110054 进行比较,如果相等则输出 This is one of the best && happy moments for me. I won't let you pass! 随后退出
  • func9 根据转换后的时间戳生成一个 uuid 格式的字符串存入内存中,该字符串即为后续比对的 ca_value
  • 之后程序读入第二个输入即 ca_value,与 func9 生成的 uuid 格式字符串比对,正确则输出 right value,But the time is not the time I want to hide\ngo ahead!!

由于输入正确的时间戳反而程序会退出,无法生成该时间戳对应的 ca_value,因此思路是 patch wasm 对应逻辑,使得输入正确时间戳 2022/11/11 00:54 后可以正常继续运行,并在调用 func9 后从内存中 dump 出对应的 ca_value

var mem = new Uint8Array(wasmMemory.buffer);
var uuid = mem.slice(0x5010c0, 0x5010c0 + 0x24);

var dump = "";
for (var i = 0; i < 0x24; i++) {
	dump += String.fromCharCode(uuid[i]);
}
console.log(dump);
// RCTF{40959ea7-26e0-4c9d-8f4a-62faf14ff392}

huowang

程序有两个校验,第一个是 unicorn 模拟执行相关的,第二个可以明显看出是迷宫

由于输入即为迷宫路径,可以直接 dfs 出迷宫的所有可行路径输入回程序进行爆破

solve maze

from ida_bytes import get_byte

addr = 0x18D7180

i = 0
maze = []
while len(maze) < 23 * 23:
    if get_byte(addr + i) != 0:
        maze.append(get_byte(addr + i))
    i += 1

def dfs(maze, x, y, step, path = '', record = [0 for _ in range(23 * 23)], rounds = 0):
    nxt = [(-1, 0), (1, 0), (0, -1), (0, 1)]
    if x == 22 and y == 21:
        print(path)
    for i in range(4):
        nx = x + nxt[i][0]
        ny = y + nxt[i][1]
        if 0 <= nx < 23 and 0 <= ny < 23 and record[nx * 23 + ny] == 0 and maze[nx * 23 + ny] == 0x20:
            record[nx * 23 + ny] = 1
            path += step[i]
            dfs(maze, nx, ny, step, path, record, rounds + 1)
            path = path[:-1]
            record[nx * 23 + ny] = 0
    return
 
   
dfs(maze, 0, 1, 'wsad')

bruteforce

from pwn import *
from hashlib import md5

with open('path', 'r') as f:
	path = f.readlines()

ans = []
for i in range(len(path)):
	io = process(['qemu-x86_64', '-L', '/usr/x86_64-linux-gnu/', './HuoWang'])
	temp = path[i].replace('\n', '').encode()
	io.sendline(temp)
	result = io.recvline(timeout=10)
	if b'Father' in result:
		ans.append(md5(temp).hexdigest())
	io.close()

print(ans)
# ['79081c1b246fc9a59f684cb4e454b8a3', 'e1f9e3d166dcec5ecff3a2c5fbdeab3b']
# RCTF{e1f9e3d166dcec5ecff3a2c5fbdeab3b}

和出题人反馈了有一个多解后,加了最短路径的 hint,问题不大

picStore

lua 5.3.3 的逆向,加载 .bin 文件字节码执行

unluac 恢复 lua 源码失败,观察 .bin 文件头部发现与正常并不相同,逆向分析可执行文件并与 lua 源码进行对比可以发现程序改动了源码中对应 lundump.c 的部分,将 LoadByte [sub_48180], LoadInt [sub_481D0], LoadNumber [sub_48110], LoadInteger [sub_480A0] 四个函数中增加了取反操作,于是可以写脚本还原,队友 immortal 在这里是用 hook 做的,我觉得相当优雅。之后就可以正常通过 unluac 等其他工具还原出 lua 源码了,版本对应即可

lua 的源码并不难懂,主要的逻辑就是将输入字节序列逐个运算后,作为 index 从硬编码数组中取值,随后传入 a_AHy3JniQH4 函数进行验证,而该函数是在可执行文件中通过 lua_setgloballua_pushcclosure 提前注册的,实际调用的是 check_result_23_impl ,z3 求解即可

其实逆向部分的工作就到此为止,但是在比赛过程中,我也耗费了不小的经历在逆向程序的交互逻辑上,即 bmp 的 upload、download 等操作,对于本题来说也许是多此一举,但对比赛中同名的 pwn 题也许能有所帮助,仅供参考

from pwn import *

debug = False

if debug:
	context.log_level = 'debug'
	io = process(['qemu-x86_64', '-g', '12345', '-L', '/usr/x86_64-linux-gnu/', './picStore'])
else:
	io = remote('49.0.203.6', 3498) # rev ip:port

def binary(n):
	res = b''
	for i in range(8):
		if n & 1 == 0:
			res += b'\x00'
		else:
			res += b'\x01'
		n >>= 1
	return res

def upload(data):
	global io
	io.recvuntil(b'choice>> ')
	io.sendline(b'1')
	io.recvuntil(b'img data: ')
	io.send(data)

def download(idx):
	global io
	io.recvuntil(b'choice>> ')
	io.sendline(b'2')
	io.recvuntil(b'link: ')
	io.sendline(str(idx))
	# server output img data then

def delete(idx):
	global io
	io.recvuntil(b'choice>> ')
	io.sendline(b'2')
	io.recvuntil(b'link: ')
	io.sendline(str(idx))

def list():
	global io
	io.recvuntil(b'choice>> ')
	io.sendline(b'4')
	# server output list result then

def check():
	global io
	io.recvuntil(b'choice>> ')
	io.sendline(b'5')
	# server output check result then

def exit():
	global io
	io.recvuntil(b'choice>> ')
	io.sendline(b'6')
	# server bye

header = bytes.fromhex('424d')		# magic
header += bytes.fromhex('36120000')	# file size
header += bytes.fromhex('00120000')	# image size
header += bytes.fromhex('36000000')	# total header size: 0xe + 0x28
header += bytes.fromhex('28000000')	# info header size: 0x28
header += bytes.fromhex('40000000')	# width
header += bytes.fromhex('18000000')	# height
header += bytes.fromhex('0100')		# color planes
header += bytes.fromhex('1800')		# bits per pixel
header += bytes.fromhex('00000000')
header += bytes.fromhex('00000000')
header += bytes.fromhex('00000000')
header += bytes.fromhex('00000000')
header += bytes.fromhex('00000000')
header += bytes.fromhex('00000000')

assert(len(header) == 0x36)

# ans is solved by z3
ans = [33, 146, 208, 207, 51, 52, 230, 190, 199, 211, 110, 51, 207, 190, 46, 51, 79, 183, 73, 103, 42, 103, 197, 83, 221, 29, 209, 240, 194, 26]
table = [105, 244, 63, 10, 24, 169, 248, 107, 129, 138, 25, 182, 96, 176, 14, 89, 56, 229, 206, 19, 23, 21, 22, 198, 179, 167, 152, 66, 28, 201, 213, 80, 162, 151, 102, 36, 91, 37, 50, 17, 170, 41, 3, 84, 85, 226, 131, 38, 71, 32, 18, 142, 70, 39, 112, 220, 16, 219, 159, 222, 11, 119, 99, 203, 47, 148, 185, 55, 93, 48, 153, 113, 1, 237, 35, 75, 67, 155, 161, 74, 108, 76, 181, 233, 186, 44, 125, 232, 88, 8, 95, 163, 200, 249, 120, 243, 174, 212, 252, 234, 58, 101, 228, 86, 109, 144, 104, 121, 117, 87, 15, 132, 12, 20, 165, 115, 136, 135, 118, 69, 68, 2, 82, 123, 250, 251, 53, 0, 51, 221, 211, 195, 145, 140, 254, 0, 116, 43, 29, 217, 197, 183, 168, 188, 34, 218, 146, 147, 98, 149, 246, 180, 103, 33, 40, 207, 208, 192, 143, 26, 154, 225, 100, 141, 175, 124, 230, 62, 177, 205, 110, 202, 253, 173, 46, 52, 114, 164, 166, 137, 158, 122, 13, 83, 178, 133, 189, 187, 7, 184, 77, 245, 216, 190, 194, 72, 157, 172, 171, 199, 160, 45, 49, 27, 204, 81, 6, 92, 59, 209, 239, 130, 97, 61, 214, 215, 73, 90, 126, 42, 30, 240, 79, 224, 78, 223, 111, 60, 4, 5, 196, 231, 106, 64, 139, 235, 150, 227, 238, 191, 127, 31, 156, 54, 241, 242, 134, 247, 128, 65, 94, 57, 210, 236, 9, 193]
assert len(table) == 256

data = []
for i in range(len(ans)):
	idx = table.index(ans[i])
	data.append(binary(idx ^ i ^ 0xff))

for i in range(29):
	# upload size maximum is 0x1200
	upload(header + data[i] + data[i+1] + b'\x00'*(0x1200-0x10))

check()
io.interactive()
# flag{U_90t_th3_p1c5t0re_fl49!}

checkserver

程序本身是一个运行在本地 8080 端口的 http 服务,通过提交 POST 请求传参给 authcookie 进行验证

通过交叉引用字符串 [SUCCESS] Auth Success 可以定位到函数 sub_404530 为 check 逻辑所在,分析了一下这里有异常处理的反调试,不过并不影响静态分析。进一步可以定位到 sub_402040 为加密函数,且本质为异或加密,因此可以考虑在 402355 处下断点 dump 出异或的数据

由于处理请求的时候会 fork 子进程,比赛的时候还不知道子进程要怎么调试,卡了一会,但后来队友 immortal 说可以直接 set rip 进行调试,于是得解

data = [0xF8159B80, 0x69789a7e, 0xd68f4c8f, 0x6C4BA891, 0x56139417, 0x6FA391CB, 0x924380a8, 0x57eb72e9, 0x175185c2, 0x91B77F80, 0x3CC7BA6E, 0x50573938, 0x84FB73DC, 0x8FF0A08C, 0xD48F6C7B, 0x42DB7570]
xor = [0xE6, 0xF7, 0x74, 0x9F, 0x05, 0xAB, 0x1A, 0x50, 0xBF, 0x28, 0xB6, 0xE6, 0xA4, 0x9E, 0x7F, 0x0D, 0x22, 0xAC, 0x76, 0x60, 0xFD, 0xA6, 0x90, 0x5E, 0x91, 0xB4, 0x76, 0xA3, 0x8D, 0x43, 0x88, 0x35, 0xF4, 0xE0, 0x37, 0x6A]

data = list(b''.join(map(lambda x: x.to_bytes(4,"little", signed=False),data)))

flag = [data[i] ^ xor[i] for i in range(36)]

print(''.join(map(chr, flag)))
# flag{1b90d90564a58e667319451d1cb6ef}

RTTT

rust 逆向,无符号表,硬看的话会有不小阻碍,于是考虑编写一个 demo 结合 bindiff 恢复一下部分符号表

恢复后 IDA 分析,源 rust 代码的 main 函数对应 std::rt::lang_start_internal 函数的第一个参数,即 sub_EBF0 。分析该函数首先可以注意到的就是函数内有明显的数组逐字节硬编码赋值,随后跟着一个 while(1) 的循环,循环体前面做的实际上是 rust 对于迭代器的边界检查与切片取值,关键的运算就是最后一句语句的异或操作,两段类似操作分别可以得到字符串 Welc0me to RCTF 2O22Congratulations

rttt_xor_in_while

逆向 sub_E310 可以根据算法结构识别出来是 RC4 加密,结合动态调试可知加密使用的 key 即为前面异或得到的两个字符串中的前者,同时也可以发现,传入 RC4 加密的明文是输入经过某种置换 shuffle 后得到的,并在加密后与同样是硬编码赋值的数组进行比对

通过 strings 里可以看出 rust 源程序使用了 rand_core-0.6.4 这个库,结合静态分析定位到 sub_14FB0 函数是一个 LCG 线性同余生成,根据特征值识别出是伪随机生成相关算法,因此推测 sub_FFB0 为伪随机数生成函数,seed 为 0xDEADBEEF

于是程序的逻辑也明朗了起来,首先将输入根据生成的伪随机数序列进行 shuffle,随后 RC4 加密后与硬编码数组比对,正确则输出 Congratulations

由于 seed 为定值,在调用 RC4 函数处下断点比对原始输入与此时参数即可得知 shuffle 的映射关系,进一步可解密得到 flag

from Crypto.Cipher import ARC4

key = b'Welc0me to RCTF 2O22'
before_shuffle = '0123456789abcdefghijklmnopqrstuvwxyzABCDEF'
after_shuffle = 'ozpBaxmtn0sqjdiF8fcyr57b93w14EgAD2uCk6lveh'

enc = [0x34, 0xC2, 0x65, 0x2D, 0xDA, 0xC6, 0xB1, 0xAD, 0x47, 0xBA, 6, 0xA9, 0x3B, 0xC1, 0xCC, 0xD7, 0xF1, 0x29, 0x24, 0x39, 0x2A, 0xC0, 0x15, 2, 0x7E, 0x10, 0x66, 0x7B, 0x5E, 0xEA, 0x5E, 0xD0, 0x59, 0x46, 0xE1, 0xD6, 0x6E, 0x5E, 0xB2, 0x46, 0x6B, 0x31]

mapping = []
for i in range(len(enc)):
	mapping.append(after_shuffle.index(before_shuffle[i]))

cipher = ARC4.new(key)
plaintext = cipher.decrypt(bytes(enc))

flag = ''
for idx in mapping:
	flag += chr(plaintext[idx])
print(flag)
# RCTF{03C3E9B2-E37F-2FD6-CD7E-57C91D77DD61}

rdefender

同样是 rust 逆向,不过给了符号表,是很有意思的一道题目,直接说逆向后的结论

  • 有两个关键的结构体,程序限制二者最多有 16 个,其中一个在这里称作 string_struct ,大小为 0x30,另外一个称作 check_struct ,大小为 0x20,二者结构如下
string_struct:
     |--------------------|--------------------|
0x00 |      ptr_name      |     name_length    |
     |--------------------|--------------------|
0x10 |     name_length    |      ptr_string    |
     |--------------------|--------------------|
0x20 |    string_length   |    string_length   |
     |--------------------|--------------------|
check_struct:
     |--------------------|--------------------|
0x00 |      ptr_data      |     data_length    |
     |--------------------|--------------------|
0x10 |     data_length    |      check_mode    |
     |--------------------|--------------------|
  • 关于交互,每次 while 循环开始首先读入 8 字节,输入的第一字节是模式,0 为读入一个 string_struct,2 为读入一个 check_struct,1 为进入 check 流程
  • 关于数据的读入,程序封装了一个 get_data 函数,首先读入 4 字节 dword 作为数据长度,随后读入对应长度的数据
  • 关于读入 string_struct 流程,首先读入的 8 字节中,第 1 字节为 \x00 ,随后 7 个字节通过 from_utf8_lossy 转换为字符串作为结构体的 name,在此之后调用 get_data 读取结构体的 string 并将构造好的结构体 push 到 string_struct 的 verctor 中
  • 关于读入 check_struct 流程,首先读入的 8 字节中,第 1 字节为 \x02 ,随后 1 字节为 check_mode,最后 6 字节为固定的 \xd1\x66\x9c\x89\xf3\x5b ,在此之后调用 get_data 读取结构体的 data 并将构造好的结构体 push 到 check_struct 的 verctor 中
  • 关于 check 流程,读入的 8 字节中,第 1 字节为 \x01 ,随后 2 字节,分别为 string_struct 与 check_struct 的下标,该流程也有相应的 3 种模式的 check_mode,check_mode 是读入 check_struct 时根据输入指定的
    • check_mode 0: 将 string_struct 中 string 做以 0x83 为基的进制转换,结果与 check_struct 中 data (4 byte) 对应的 dword 比对
    • check_mode 1: 检查 check_struct 中 data (1 byte) 是否出现在 string_struct 的 string 中
    • check_mode 2: 将 check_struct 中 data 作为 vm 指令,与 string_struct 中 string 一同作为参数传入 check 函数执行
  • 关于 check 函数中的虚拟机,是一个较为简单的基于栈与寄存器的 vm 实现,逆向起来并不困难,不再在这里赘述
  • 关于程序输出,主要关注 check 流程中的输出即可,有 \x01\x00\x00\x00\x00\x00\x00\x00\x02\x00\x00\x00\x00\x00\x00\x00 两种输出,对应不同的 check 结果

flag 是在函数开始从文件中读入的,并存入了下标为 0 的 string_struct 中,其 name 为 flag,因此,基于上面逆向分析的结果,可以根据 check 返回值侧信道探测出 flag 字符串内容

import struct
import string
from pwn import *

context.log_level = 'debug'

table = ''
flag = ''

def check_exist(ch):
	global io, table
	# first 8 bytes
	payload = b'\x02' # read a check_struct
	payload += b'\x01' # check_mode = 1
	payload += b'\xd1\x66\x9c\x89\xf3\x5b' # fixed bytes
	# get_data routine
	payload += struct.pack('<I', 1) # length
	payload += ch.encode() # data

	io.send(payload)
	io.recv(8) # should be '\x00' * 8

	# first 8 bytes
	payload = b'\x01' # check routine
	payload += b'\x00' # string_struct vector index
	payload += b'\x00' # check_struct vector index
	payload += b'\xff' * 5 # padding
	
	io.send(payload)
	if io.recv(8) == struct.pack('<Q', 2):
		table += ch

def check_index(ch, idx):
	global io, flag
	# first 8 bytes
	payload = b'\x02' # read a check_struct
	payload += b'\x02' # check_mode = 2
	payload += b'\xd1\x66\x9c\x89\xf3\x5b' # fixed bytes
	# vm instructions
	instr = b'\x00' + ch.encode() # push ch
	instr += b'\x01' + struct.pack('<B', idx) # push string[idx]
	instr += b'\x03\x06' # stack[sp] = stack[sp-1] ^ stack[sp-2]
	instr += b'\x05' # return stack[sp] != 0
	# get_data routine
	payload += struct.pack('<I', len(instr)) # length
	payload += instr

	io.send(payload)
	io.recv(8) # should be '\x00' * 8

	# first 8 bytes
	payload = b'\x01' # check routine
	payload += b'\x00' # string_struct vector index
	payload += b'\x00' # check_struct vector index
	payload += b'\xff' * 5 # padding

	io.send(payload)
	if io.recv(8) == struct.pack('<Q', 1):
		flag += ch

for ch in string.printable:
	io = remote('94.74.84.207', 7892)
	check_exist(ch)
	io.close()

while True:
	l = len(flag)
	for ch in table:
		io = remote('94.74.84.207', 7892)
		check_index(ch, l)
		io.close()
		if len(flag) > l:
			break
	if flag.endswith('}'):
		break

print(flag)
# RCTF{7919f5f8286dd645da24b2045abb85fc}