2020网鼎杯玄武组reverse babyvm题解

题目质量还是不错的,在此记录一下解题过程。

逆向分析程序

拿到程序后先随便跑跑,然后在IDA中搜索字符串很容易定位到用户代码。而几乎所有函数中开头都会有for循环给整个栈赋值-858993460以及call sub_75a41,结尾都会有call sub_7878c,这些都是编译器自动加上的代码。相信大多数人都会有程序打印出-858993460的经历,其实这个值就是0xcccccccc,debug模式下编译器会将当前函数的栈全部初始化为这个值。

2020网鼎杯玄武组reverse babyvm题解_第1张图片

有一些函数可以通过参数值或者其他信息推出函数名,修改了一些函数名之后,代码就容易看了不少。另外在动态调试时,程序跑到call 0x7584d时直接退出了,应该是开了一些反调试机制,而且这个函数似乎对结果没什么影响,因此patch掉所有的call 0x7584d,所以这里看不到调用了sub_7584d。

继续分析代码,可以看到,这里规定了输入是形如flag{xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx}的格式,每一个x代表16进制的一位。输入结束之后,会把其中的十六进制数提取出来然后合并成一个大的十六进制数放到一个数组中。后面似乎只要经过一个处理函数处理这个大数,然后再对结果进行判断就结束了,好像不算太难嘛^^(误)。

接着分析函数sub_7fa80

2020网鼎杯玄武组reverse babyvm题解_第2张图片

逻辑比较清晰,就是将十六进制数转成字符,类似于python上的long_to_bytes。动态调试也印证了这一结果。

只剩下函数sub_75a78了,继续在IDA上分析这个函数,点进去之后发现又有好几个函数……

2020网鼎杯玄武组reverse babyvm题解_第3张图片

分析函数sub_789b7,点进去一看,有很多外部调用函数,又是crypt,又是hash啥的,直接在动态调试step over看能不能从返回值看到点什么。断到这个函数然后点下step over…… 嗯,程序又退出了,看来又开了反调试,但是这个函数将中间结果拿去当参数了,里面又有一些加密的操作,所以这个函数不能直接patch掉,先看一下函数内部有没有一些用户函数是进行反调试的吧。

动态调试定位到程序是在这里退出的,其中sub_7528a调用了IsDebuggerPresent,sub_75fa5最终调用TerminateProcess,所以直接patch掉call 0x75fa5即可。继续动态调试,可以发现第三个参数所指向的内存发生了改变。

嗯……输出结果是128位,而后面100byte输入得到的输出结果也是128位,至少能确定它是一个哈希函数,而128位输出的哈希函数应该是md5了吧,计算一下输入的md5验证一下结果,在动态调试中我输入的flag为 "flag{31313131-3131-3131-3131-313131313131}" (可能每次输入的不太一样,用到会特别说明),那么它应该生成的是字符串'1111111111111111'的哈希值,计算一下'1111111111111111'的md5值验证一下题目用的是不是md5算法。

2020网鼎杯玄武组reverse babyvm题解_第4张图片

输出结果完全一致,很显然sub_789b7就是个md5算法了,输出结果会放到第三个参数中。

接着分析函数sub_77fc1

2020网鼎杯玄武组reverse babyvm题解_第5张图片

用IDA静态分析基本上就可以分析出是什么算法了,它是把输入所有的byte相加,得到的和再对100取模,然后用这个取模后的值作为lfsr的初始状态。然后用一个数组存下lfsr的每轮迭代的状态,lfsr经过100轮迭代后得到一个状态序列。即得到一个长度为100的byte数组,如果看不懂这是个lfsr算法并没有关系。只需要知道一个初始状态(图中的对应的v8或v6)对应一个输出序列就可以了。

回到caller函数sub_75a78

2020网鼎杯玄武组reverse babyvm题解_第6张图片

似乎分析完这个循环就结束了,那就继续吧。首先是sub_7573f,函数比较短,具体内容是取j到j+4这四个byte,然后将这4个byte转成little_endian的整数并返回,比如说这四个byte是"1234",返回值就是0x34333231。

然后分析函数sub_75159。点进去又有一堆函数调用,真的是没完没了啊= =,前面sub_765cc和sub_77918都是类似于malloc和memset的操作,不需要分析。

接下来的 sub_7552d是vm的一些初始化操作。

2020网鼎杯玄武组reverse babyvm题解_第7张图片

可以根据这个初始化操作先建立好结构体,再根据后续操作修改成员变量名。而且从这段代码可以推测出第二个参数(unk_1a2000)就是vm程序的机器码。那么后面只要分析出每个opcode的作用就可以解析这一段vm了。

接下来看函数sub_78890。嗯……很长的switch-case,题目提到的vm在这里终于出现了。

2020网鼎杯玄武组reverse babyvm题解_第8张图片

根据if (v35 == 255) 这类的语句可以猜测出v34和v35应该是opcode,而v37应该是一个cpu的结构体,结构体offset为0的地方应该是cs,offset为20的地方应该是ip,类似的可以推导出结构体其他位置对应什么寄存器。这段代码里面没有涉及太复杂的操作,都是一些cpu的指令的模拟,就不细说分析opcode的过程了。

以下是我推出来的结构体。

2020网鼎杯玄武组reverse babyvm题解_第9张图片

vm机器码执行完之后,函数会返回一个值,就是寄存器r1的值。

分析到这里,先画个流程图表示整个程序的流程,因为我自己都被自己说糊涂了……

2020网鼎杯玄武组reverse babyvm题解_第10张图片

2020网鼎杯玄武组reverse babyvm题解_第11张图片

然后解析vm指令(流程图中的f函数),借助大佬的脚本,将vm的指令翻译成了类似汇编语言的指令,其中有很多push和pop组合成的赋值操作,所以稍微精简一下代码,然后从头开始解析(注:根据cpu初始化的函数可以知道,虚拟数据段ds的内存布局是[input[j], hash1[j], hash2[j]],即ds:[0] = input[j], ds:[1] = hash1[j], ds:[2] = hash2[j]):

	mov r1, ds:[0]				; input
	mov r4, r1
	mov r1, 16
	mov r3, 0
_loop:
	sub r1, 1
	mov r5, r1
	mod r5, 8
	ror r4, r5
	xor r4, 0x9e3779b9
	push r4
	call rol6
	xor r4, r1
	pop r4
	cmp r1, r3
	jne _loop

	mov r3, ds:[1]				; hash1
	mov r2, ds:[2]				; hash2
	mov r5, r4
	or r5, r2
	or r5, r3
	push r5
	mov r5, r4
	and r5, r2
	and r5, r3
	push r5
	mov r5, r4
	and r5, r2
	push r5
	mov r5, r4
	and r5, r3
	push r5
	mov r5, r2
	and r5, r3
	pop r6
	xor r5, r6
	pop r6
	xor r5, r6
	pop r6
	xor r5, r6
	pop r6
	xor r5, r6
	mov r1, r5
	not r1
	exit

rol6:
	mov r1, ss:[sp - 8]
	add r1, 3
	mov ss:[sp - 8]
	mov r2, ss:[sp - 9]
	rol r2, 6
	mov ss:[sp - 9], r2
	ret

一眼就可以看到0x9e3779b9这个特别的数,然而这题似乎和tea算法没有什么关系……接着分析,其实这样已经可以比较轻松的知道vm在执行什么算法了,但是在call rol6这里还有一些小细节要注意,首先跟着流程走看一下执行call内存布局的变化,在call之前,栈内存布局只有一个r4,而call的过程经过了8次push,所以内存布局应该是这样的:

2020网鼎杯玄武组reverse babyvm题解_第12张图片

然而mov r1, ss:[sp - 8]; add r1, 3; mov ss:[sp - 8]这几条指令会修改栈上的ret_addr,使得ip会增加3个字节,这有点像pwn的栈溢出修改返回地址的操作。改了ret_addr之后,ret直接跳转pop r4,而xor r4, r1这条指令将不会被执行。

_loop这一段的操作可以认为是对明文的一段加密操作,加密算法和相应的解密算法的python代码如下,其中rol(src, i)和ror(src, 32 - i)的操作是等价的:

def encrypt(plain):
	cipher = plain
	i = 16
	while i > 0:
		i -= 1
		cipher = rol32(cipher, 32 - (i % 8))
		cipher ^= 0x9e3779b9
		cipher = rol32(cipher, 6)
	return cipher

def decrypt(cipher):
	plain = cipher
	for i in range(16):
		plain = rol32(plain, 32 - 6)
		plain ^= 0x9e3779b9
		plain = rol32(plain, i % 8)
	return plain

循环结束后部分的代码是:有三个变量分别为cipher, hash1, hash2,cipher是前面对明文(即处理过后的flag)加密运算,hash1和hash2则分别是明文的md5值和lfsr状态序列的md5值。然后计算出以下五部分的值:

(1) cipher | hash1 | hash2
(2) cipher & hash1 & hash2
(3) cipher & hash1
(4) cipher & hash2
(5) hash1 & hash2。

然后将这五部分进行异或运算,最后取反。

还是用图表达来得直接,画个图来表示vm的流程吧= =(以vm[0] = f(input[0], hash1[0], hash2[0])为例):

2020网鼎杯玄武组reverse babyvm题解_第13张图片

根据算法反求flag

呼,总算是把程序部分逆出来了,剩下就是反求flag的工作了,首先是vm中的这一段计算:

2020网鼎杯玄武组reverse babyvm题解_第14张图片

出于本能,看到这么复杂的式子总想化简试下,但是在套用了几次运算律之后就不想再套下去了,还不如写出真值表来得直接,于是乎就写出了这段求真值表的代码:

#!/usr/bin/env python
l = [
	[0, 0, 0],
	[0, 0, 1],
	[0, 1, 0],
	[0, 1, 1],
	[1, 0, 0],
	[1, 0, 1],
	[1, 1, 0],
	[1, 1, 1]
]

for k in l:
	a = k[0] | k[1] | k[2]
	b = k[0] & k[1] & k[2]
	c = k[0] & k[1]
	d = k[0] & k[2]
	e = k[1] & k[2]
	print(k, a ^ b ^ c ^ d ^ e)

运行结果:

[0, 0, 0] 0
[0, 0, 1] 1
[0, 1, 0] 1
[0, 1, 1] 0
[1, 0, 0] 1
[1, 0, 1] 0
[1, 1, 0] 0
[1, 1, 1] 1

学过数理逻辑的同学应该对这个真值表的结果不陌生,这个其实就是x ^ y ^ z的真值表,也就是说a ^ b ^ c ^ d ^ e等价于x ^ y ^ z,最后再取反就是vm的输出了。

这样问题就转化成了

2020网鼎杯玄武组reverse babyvm题解_第15张图片

由于hash1[i]和vm[i]已知,那么可以计算出:

然后注意到sum(input)的值域是[0, 100),那么lfsr就会相应产生100个不同的状态序列,hash2也只有100个可能的值,只要把这100个hash2的值都试一遍,然后用下式计算出相应的input的值。最后再对input进行md5运算,如果和题目所给的hash1对应得上,那么就取得flag了。

完整exp如下

#!/usr/bin/env python
import hashlib
from Crypto.Util.number import bytes_to_long, long_to_bytes

hash1 = [0xc5d83690, 0x978162ea, 0x1932a96c, 0x4222669]
vm = [0x1f7902cc, 0x2fae3d15, 0xceebfe91, 0xaff6af42]

# 0 <= s < 100
def lfsr(s):
	res = 0
	for k in range(100):
		bits = 0
		s ^= 0xc3
		res <<= 8
		res |= s
		for l in range(8):
			bits ^= (s >> l) & 1
		s = (s << 1) | bits
		s &= 0xff
	return long_to_bytes(res)

def rol32(n, bits):
	return (n << bits) & 0xffffffff | (n >> (32 - bits))

def decrypt(cipher):
	plain = cipher
	for i in range(16):
		plain = rol32(plain, 32 - 6)
		plain ^= 0x9e3779b9
		plain = rol32(plain, i % 8)
	return plain

def encrypt(plain):
	cipher = plain
	i = 16
	while i > 0:
		i -= 1
		cipher = rol32(cipher, 32 - (i % 8))
		cipher ^= 0x9e3779b9
		cipher = rol32(cipher, 6)
	return cipher

def switch_endian(src):
	dest = 0
	while src > 0:
		dest <<= 8
		dest |= src & 0xff
		src >>= 8
	return dest

md5_1 = switch_endian(hash1[0]) << 96 | switch_endian(hash1[1]) << 64 | switch_endian(hash1[2]) << 32 | switch_endian(hash1[3]) 
if __name__ == '__main__':
	t = [0] * 4
	h2 = [0] * 4
	p = [0] * 4
	t[0] = 0xffffffff ^ vm[0] ^ hash1[0]
	t[1] = 0xffffffff ^ vm[1] ^ hash1[1]
	t[2] = 0xffffffff ^ vm[2] ^ hash1[2]
	t[3] = 0xffffffff ^ vm[3] ^ hash1[3]
	for i in range(100):
		l1 = lfsr(i)
		hl = hashlib.md5()
		hl.update(l1)
		hash2 = int(hl.hexdigest(), 16)
		# convert to little
		h2[0] = switch_endian(hash2 >> 96)
		h2[1] = switch_endian((hash2 >> 64) & 0xffffffff)
		h2[2] = switch_endian((hash2 >> 32) & 0xffffffff)
		h2[3] = switch_endian(hash2 & 0xffffffff)
		# convert to big
		p[0] = switch_endian(decrypt(t[0] ^ h2[0]))
		p[1] = switch_endian(decrypt(t[1] ^ h2[1]))
		p[2] = switch_endian(decrypt(t[2] ^ h2[2]))
		p[3] = switch_endian(decrypt(t[3] ^ h2[3]))
		# print(p)
		_p = (p[0] << 96) | (p[1] << 64) | (p[2] << 32) | p[3]
		hl = hashlib.md5()
		hl.update(long_to_bytes(_p))
		rr = int(hl.hexdigest(), 16)
		if rr == md5_1:
			print([hex(plain) for plain in p])

运行结果:

['0x9e573902', '0xe314837', '0xa33732a4', '0x75ca007c']

整理一下就可以得到flag值:

flag{9e573902-0e31-4837-a337-32a475ca007c}

你可能感兴趣的:(2020网鼎杯玄武组reverse babyvm题解)