前言
一开始以为和去年一样就是和湘大联合组织的校内选拔赛,并未规划太多内容。然后大约四月中旬老师说可以扩大规模邀请别的学校来打,这个消息让我兴奋之余又倍感压力,遂加班加点出了不少题目。由于本人水平有限,题目设计难免存在缺陷或细节瑕疵,望各位师傅多多包容。
考虑到是新生赛,我将以通俗易懂的语言,结合清晰的逻辑拆解,帮助大家梳理解题思路,希望能让每一位刚入门的二进制宝宝都有所收获~
每个方向的题目讲解顺序是由易到难,有不懂的地方可以在下方评论或者私信我哦~
Reverse · 逆向
mathematics
题目描述根据朋友的真实经历改编
IDA打开发现程序对输入的字符数组进行了一堆加减乘运算并和一些值进行比较,同时可知flag长度为28
我们按Y将Str类型改为char Str[28]
,然后处理一下伪代码提取出方程式组(好多同学这一步应该交给AI处理了吧,想当初我刚打CTF哪有这好日子啊,给AI的数据它处理时经常出错会改掉一些值,只能自己写py脚本提取)
最后就交给z3处理就好了
解题脚本
#!/usr/bin/python3
#-*- coding=utf-8 -*-
from z3 import *
import time
solver = Solver()
flag = [BitVec("%d" % i, 8) for i in range(28)]
for i in range(28):
solver.add(flag[i] < 127)
solver.add(flag[i] >= 32)
solver.add((flag[0] - 188) + (flag[3] * 23) + (flag[4] + 188) + (flag[8] * 166) + (flag[10] + 214) + (flag[13] * 33) + (flag[15] - 73) + (flag[16] * 95) + (flag[18] * 149) + (flag[20] * 69) + (flag[21] - 143) + (flag[22] + 110) + (flag[23] * 95) + (flag[27] - 201) == 66851)
solver.add((flag[0] * 247) + (flag[2] - 87) + (flag[4] * 236) + (flag[5] + 92) + (flag[7] * 146) + (flag[11] * 232) + (flag[12] - 83) + (flag[15] - 222) + (flag[16] + 34) + (flag[17] - 32) + (flag[21] + 107) + (flag[24] * 167) + (flag[25] + 130) + (flag[27] - 21) == 99270)
solver.add((flag[0] * 247) + (flag[2] + 237) + (flag[4] - 13) + (flag[6] + 154) + (flag[11] - 249) + (flag[12] * 235) + (flag[14] - 214) + (flag[15] + 85) + (flag[18] * 188) + (flag[19] + 66) + (flag[20] * 84) + (flag[22] - 123) + (flag[25] + 74) + (flag[27] + 206) == 71948)
solver.add((flag[0] + 170) + (flag[2] - 43) + (flag[3] + 155) + (flag[5] - 40) + (flag[6] * 115) + (flag[8] * 13) + (flag[9] - 93) + (flag[10] + 21) + (flag[11] - 142) + (flag[13] + 190) + (flag[14] + 6) + (flag[17] - 36) + (flag[20] * 121) + (flag[23] - 56) == 27343)
solver.add((flag[3] - 166) + (flag[4] - 13) + (flag[5] + 64) + (flag[6] * 51) + (flag[7] - 56) + (flag[8] * 148) + (flag[9] + 183) + (flag[10] + 66) + (flag[14] + 118) + (flag[16] * 188) + (flag[20] - 244) + (flag[21] + 23) + (flag[24] + 224) + (flag[27] * 93) == 49143)
solver.add((flag[1] * 223) + (flag[5] + 249) + (flag[7] + 107) + (flag[8] * 129) + (flag[10] - 112) + (flag[11] + 52) + (flag[13] - 169) + (flag[15] * 94) + (flag[16] - 87) + (flag[17] - 45) + (flag[20] - 193) + (flag[22] * 116) + (flag[23] - 149) + (flag[27] + 218) == 51279)
solver.add((flag[1] * 251) + (flag[4] + 123) + (flag[5] - 196) + (flag[8] - 203) + (flag[9] * 114) + (flag[11] - 110) + (flag[15] * 208) + (flag[16] + 83) + (flag[20] * 31) + (flag[22] - 9) + (flag[24] + 7) + (flag[25] + 176) + (flag[26] - 230) + (flag[27] - 66) == 63264)
solver.add((flag[0] + 6) + (flag[1] + 123) + (flag[2] * 163) + (flag[6] - 15) + (flag[7] * 223) + (flag[8] + 214) + (flag[13] - 246) + (flag[15] + 1) + (flag[16] - 100) + (flag[19] * 225) + (flag[22] * 206) + (flag[23] - 36) + (flag[24] * 160) + (flag[27] - 124) == 79369)
solver.add((flag[1] + 163) + (flag[4] * 190) + (flag[5] + 144) + (flag[6] * 157) + (flag[7] * 8) + (flag[10] * 47) + (flag[13] - 162) + (flag[14] - 85) + (flag[15] - 119) + (flag[18] * 73) + (flag[20] + 213) + (flag[21] + 81) + (flag[24] + 23) + (flag[27] + 34) == 50602)
solver.add((flag[1] - 85) + (flag[6] - 53) + (flag[9] * 85) + (flag[11] * 14) + (flag[12] - 251) + (flag[13] - 212) + (flag[14] - 240) + (flag[19] + 194) + (flag[22] * 125) + (flag[23] * 101) + (flag[24] * 136) + (flag[25] - 161) + (flag[26] * 210) + (flag[27] - 156) == 61274)
solver.add((flag[0] - 67) + (flag[4] * 249) + (flag[5] * 98) + (flag[9] + 138) + (flag[12] + 124) + (flag[13] + 189) + (flag[14] - 153) + (flag[17] - 155) + (flag[20] * 29) + (flag[21] * 83) + (flag[22] * 118) + (flag[23] - 184) + (flag[24] - 63) + (flag[27] + 151) == 50614)
solver.add((flag[1] * 109) + (flag[3] + 26) + (flag[4] + 5) + (flag[5] * 242) + (flag[6] * 188) + (flag[7] + 90) + (flag[8] + 245) + (flag[11] * 104) + (flag[13] + 179) + (flag[14] - 33) + (flag[20] * 115) + (flag[22] - 87) + (flag[23] - 10) + (flag[24] + 137) == 67326)
solver.add((flag[1] + 57) + (flag[3] * 26) + (flag[4] * 107) + (flag[5] * 207) + (flag[6] + 211) + (flag[9] - 136) + (flag[10] + 65) + (flag[13] + 132) + (flag[19] - 252) + (flag[20] * 33) + (flag[22] + 65) + (flag[23] * 110) + (flag[25] - 108) + (flag[27] + 18) == 42278)
solver.add((flag[2] + 224) + (flag[4] + 128) + (flag[6] - 146) + (flag[9] * 167) + (flag[10] * 145) + (flag[13] * 121) + (flag[14] * 119) + (flag[15] * 249) + (flag[17] - 251) + (flag[19] - 117) + (flag[21] * 92) + (flag[23] + 181) + (flag[25] - 115) + (flag[26] * 30) == 94332)
solver.add((flag[0] * 252) + (flag[1] + 214) + (flag[3] - 176) + (flag[6] - 5) + (flag[7] - 162) + (flag[11] - 49) + (flag[13] - 221) + (flag[16] * 17) + (flag[17] * 124) + (flag[18] + 161) + (flag[21] - 193) + (flag[23] + 220) + (flag[25] + 247) + (flag[26] - 84) == 34135)
solver.add((flag[0] - 230) + (flag[2] * 102) + (flag[4] - 113) + (flag[7] - 205) + (flag[11] - 4) + (flag[12] * 80) + (flag[13] * 40) + (flag[14] * 75) + (flag[16] - 197) + (flag[17] + 222) + (flag[19] + 160) + (flag[21] - 136) + (flag[24] * 96) + (flag[25] * 57) == 39532)
solver.add((flag[2] * 28) + (flag[7] * 154) + (flag[8] * 140) + (flag[10] - 250) + (flag[12] * 224) + (flag[13] + 43) + (flag[14] - 94) + (flag[15] - 210) + (flag[17] + 158) + (flag[18] - 39) + (flag[20] * 227) + (flag[21] + 107) + (flag[22] + 236) + (flag[24] + 160) == 77098)
solver.add((flag[1] + 214) + (flag[2] - 250) + (flag[3] - 13) + (flag[4] + 160) + (flag[7] + 126) + (flag[8] - 251) + (flag[15] + 152) + (flag[16] - 64) + (flag[17] - 99) + (flag[20] * 17) + (flag[21] - 13) + (flag[22] - 180) + (flag[24] * 6) + (flag[27] - 2) == 3478)
solver.add((flag[0] - 160) + (flag[2] + 188) + (flag[3] - 250) + (flag[4] + 86) + (flag[6] * 41) + (flag[7] * 198) + (flag[9] + 16) + (flag[11] - 84) + (flag[16] - 105) + (flag[19] - 245) + (flag[20] + 22) + (flag[21] + 206) + (flag[23] - 244) + (flag[24] * 49) == 25911)
solver.add((flag[0] * 152) + (flag[1] - 214) + (flag[2] + 36) + (flag[3] + 117) + (flag[4] * 207) + (flag[6] + 32) + (flag[8] + 241) + (flag[9] * 214) + (flag[12] + 230) + (flag[13] + 131) + (flag[14] - 40) + (flag[17] + 206) + (flag[18] * 42) + (flag[23] * 184) == 81744)
solver.add((flag[0] + 112) + (flag[1] - 64) + (flag[3] - 40) + (flag[5] * 19) + (flag[6] * 163) + (flag[10] - 73) + (flag[11] + 95) + (flag[12] - 48) + (flag[13] - 117) + (flag[16] - 248) + (flag[17] - 166) + (flag[18] - 198) + (flag[21] - 64) + (flag[24] + 68) == 16931)
solver.add((flag[1] + 179) + (flag[6] - 35) + (flag[7] - 237) + (flag[9] * 193) + (flag[10] + 44) + (flag[14] + 232) + (flag[15] * 244) + (flag[16] + 44) + (flag[17] + 130) + (flag[19] + 70) + (flag[20] - 215) + (flag[21] + 78) + (flag[22] - 208) + (flag[23] * 38) == 52715)
solver.add((flag[0] * 108) + (flag[2] - 39) + (flag[3] - 185) + (flag[4] * 125) + (flag[5] + 89) + (flag[6] - 177) + (flag[10] + 238) + (flag[14] + 103) + (flag[15] + 205) + (flag[17] * 32) + (flag[19] + 86) + (flag[20] * 145) + (flag[23] + 122) + (flag[25] - 32) == 42787)
solver.add((flag[1] - 53) + (flag[3] * 129) + (flag[5] * 136) + (flag[7] * 67) + (flag[8] + 3) + (flag[13] * 99) + (flag[14] + 77) + (flag[15] * 193) + (flag[18] - 86) + (flag[20] + 191) + (flag[21] - 41) + (flag[24] - 138) + (flag[25] * 242) + (flag[26] + 166) == 89131)
solver.add((flag[1] + 173) + (flag[2] * 103) + (flag[3] - 244) + (flag[6] + 221) + (flag[7] - 20) + (flag[8] + 15) + (flag[9] + 225) + (flag[10] * 30) + (flag[11] - 228) + (flag[14] + 66) + (flag[18] - 7) + (flag[24] - 222) + (flag[25] * 183) + (flag[27] * 248) == 64044)
solver.add((flag[1] - 66) + (flag[2] * 200) + (flag[4] - 142) + (flag[6] + 27) + (flag[7] - 246) + (flag[9] - 244) + (flag[12] + 43) + (flag[13] - 50) + (flag[14] - 66) + (flag[15] * 144) + (flag[17] * 208) + (flag[19] * 224) + (flag[26] - 108) + (flag[27] - 0) == 75123)
solver.add((flag[0] - 75) + (flag[1] - 135) + (flag[3] - 205) + (flag[6] - 145) + (flag[8] - 168) + (flag[9] + 54) + (flag[14] - 60) + (flag[15] + 81) + (flag[16] - 149) + (flag[18] + 64) + (flag[19] * 148) + (flag[22] - 154) + (flag[25] * 202) + (flag[27] - 179) == 38130)
solver.add((flag[2] * 32) + (flag[4] - 132) + (flag[6] + 106) + (flag[7] - 83) + (flag[10] * 227) + (flag[11] + 163) + (flag[14] - 173) + (flag[18] + 135) + (flag[19] + 198) + (flag[20] * 30) + (flag[21] + 48) + (flag[22] + 152) + (flag[24] * 207) + (flag[26] * 183) == 71113)
start_time = time.perf_counter()
if solver.check() == sat:
end_time = time.perf_counter()
model = solver.model()
elapsed = end_time - start_time
ans = ''.join([chr(model[c].as_long()) for c in flag])
print(f"[*] flag:{ans}")
print(f"[*] time: {round(elapsed, 4)} s")
else:
print("no ans!")
大约跑个一分钟就出flag了
等脚本跑出来之前和大伙说个题外话吧,我那个朋友是通信专业的,女主和他是一个专业的,两个人打电赛的时候天天待一起学习,女生数学不是很好他还经常给女生补课,互相聊得也很来,一来二去的男生对她产生了一些特殊的情感。
然后有次他们一起出去吃饭,我朋友觉得是时候冲了,表白的话说完后问女生对他什么意思,女生显得有些犹豫说她拿不准主意,想过段时间再给他答复。
然后呢?然后题目flag跑出来了
她说 I_Lik3_U_but_n0t_in_th4t_way
shash
题目名称shash的全称其实是 short hash,也就是短哈希
静态编译的程序,但是程序逻辑并不复杂,定位到main函数
跟进变量unk_49D02C
发现是字符串"%s",那么这个很显然是scanf函数。OK下面的函数sub_401170
处理我们输入的字符串然后判断返回值是不是43,推测大概率是strlen
函数,那么可以得到flag长度为43
接下来看那个for循环,循环22次,每次sub_401905
函数处理两个字节,然后将返回值和DWORD
数组aR比较
跟进函数sub_401905
发现是个简单的哈希处理
该哈希算法其实是FNV-1a
算法,一种快速且低冲突的哈希算法,适用于大量数据的快速哈希处理,尤其适合于哈希几乎相同的字符串
我们都知道哈希算法不可逆,但如果哈希算法处理的是少量数据,那么爆破也许是个不错的方法
像这个题目,每次处理两个字节,那么复杂度也就是2^16=65536,但同时我们又知道flag肯定是可见字符串,在32~127之间。那么复杂度就降到了96*96=9216了,最多只需要爆破不到22w次就能拿到弗拉格
这时候就有同学要问了,“老师老师,22w次还不多吗?”
废话,当然不多啦!22w次在计算机里其实是个很小的单位了,特别是在哈希函数不复杂且处理数据量小的情况下,嗖~的一下就跑完了
不过你要是口算的话…可以参考把md5算法的C语言代码喂给DS大模型,然后让他根据算法计算一个短字符的md5值,再把这个过程重复22w次,你猜要跑多久呢?
好了不多说,将aR
数组导出然后开爆
解题脚本
#include <stdio.h>
#include <string.h>
#include <stdint.h>
uint32_t hashes[] = {2028691325, 1792671826, 1305679590, 2266829395, 2279835011, 434841374, 2346945487, 432869898, 401286136, 2150371800, 2346945487, 2263057392,
197983232, 401139041, 15253804, 2333792776, 2313390249, 2364708844, 2431672225, 2431672225, 451471898, 592863352};
uint32_t hash_function(const unsigned char *data, size_t len) {
uint32_t hash = 0x811C9DC5;
const uint32_t fnv_prime = 0x01000193;
for (size_t i = 0; i < len; i++) {
hash ^= data[i];
hash *= fnv_prime;
}
return hash;
}
void main()
{
for(int i = 0;i < 21;i++)
{
for(int hbyte = 0x20;hbyte < 127;hbyte++)
{
for(int lbyte = 0x20;lbyte < 127;lbyte++)
{
short tmp = (hbyte << 8) | lbyte;
if(hash_function((const unsigned char*)&tmp, 2) == hashes[i])
{
printf("%c%c", lbyte, hbyte);
break;
}
}
}
}
}
编译运行得到flag,长度为42,缺了个’}‘是为什么呢?因为最后那一块数据是b’}\x00’,而\x00不在我们的爆破表里,最后那一块相当于是没有找到匹配的解所以没有输出
但是也无伤大雅了,我们知道最后那个字符肯定是’}’
C++++
一道简单的C#逆向(雾
因为这道题用到了代码注入等技术,很有可能被杀软误杀,大家可以信任后再动调
说会题目,属实没想到校内只能有一解,校外刚开始两天的解题情况也不乐观
或许.net真的是个小众的语言吧😭
用dnspy
等IL反编译工具打开,点击进入
阅读代码,根据代码推测应该是从资源文件中读取了什么脚本然后执行
在资源下找到这两个脚本
-
分析encrypt脚本
了解过游戏逆向的同学应该不陌生,这是Cheat Engine
的自动汇编脚本
现在程序进入汇编分析阶段,其实这个脚本就是实现了一个简单的加密函数
- 首先函数开头保存参数1 rdi到栈上
- 然后进入
loop_init
,将参数1赋值给rax,从rax指针取字节movzx零填充赋值给eax寄存器,若当前字节不为0则跳转到循环体loop_body
- 循环体执行了一些位运算,我们假设操作的字节为k,那么执行的操作用C语言表达就是
((((((k-1)&0xf) << 4) | (((k-1) >> 4) & 0xf)) ^ 0xb2) + 7)
- 将运算结果写回内存然后将参数1 指针+1
- 最后继续循环执行
叽里呱啦说了一堆恐怕有些同学快要睡着了,如果自身的汇编水平比较低,那么也可以把这个汇编脚本扔给AI,让它快速分析出对应的C语言代码
这里要注意的是data
用db
指令声明了一段数据 -
分析main脚本
这里很简单看汇编也能一眼懂,将我们的输入的字符串作为指针赋值给rdi然后调用encrypt
,接下来就是密文比较了
源src和目标dst分别是input
和data
,比较五个块然后将结果写到data+0x200
的地址
两个脚本分析完毕就可以把data
数据导出然后写解密脚本啦~
解题脚本
# 加密过程是(((((k-1)&0xf) << 4) | (((k-1) >> 4) & 0xf)) ^ 0xb2) + 7
# 那么解密其实就是将这个过程反过来
def decrypt(enc):
decrypted = ""
for byte in enc:
k = (byte - 7) & 0xff
j = k ^ 0xb2
high = (j & 0xf0) >> 4
low = (j & 0x0f) << 4
m = high | low
decrypted += chr((m + 1) & 0xff)
return decrypted
enc = "cdce9d8eed1c676b988c5eacfbec98acf8fb58489c9cfb7babb83c5eacfbecfbac9cfbb77c"
print(decrypt(bytes.fromhex(enc)))
得到flag
后话
所以这个题目其实是套了一层C#壳的汇编逆向!
time’sUP
这道题首先一个问题是,它值不值600分?
如果放在校外赛道,显然是不值的,一上来就被秒了。但是在校内赛道显然又值600分了,因为一直到比赛结束也只有1解。
其实这道题本身是很简单的,有安卓逆向经验的同学应该是随便秒了,但为什么我会给600分呢?因为是新生赛,大部分人的难点不在于题目而在于环境。通常来说搭建一个Android frida调试环境有以下步骤
- 下载一个安卓模拟器并安装
- 配置好adb环境,熟悉基本的adb命令
- 去github上找frida server程序并通过adb push到安卓模拟器
- 安装和frida server对应版本的python包
- 愉快的进行frida hook~
听起来挺简单的但我第一次搭安卓调试环境花了半天时间,可能萌新自带触发「奇奇怪怪的报错
」debuff吧🥺
如果你现在已经把frida
环境搭好了,那么现在我们一起来看看题目吧
用jadx
打开,找到MainActivity,显然这就是程序的主逻辑
就算是没学过Java的同学看代码也能知道程序是获取key和iv然后进行AES-CBC加密并对加密结果base64编码最后和密文比较
只不过有个反调试罢了,其实这道题有很多种做法,不一定要用到frida,我们用jadx调试也是可以的
在这里下个断点,也就是调用完判断是否在调试然后比较返回值的地方
找到下方的调试窗口,将v0修改为0
然后我们继续在aesCbcEncrypt
函数那里下个断点,接着运行
在调试窗口就能直接看到key和iv了,复制一下数据即可
不过既然提到了frida,这里也还是说一下frida的解法吧
Java.perform(function() {
// 查找MainActivity类
var MainActivity = Java.use('com.example.appre.MainActivity');
// Hook aesCbcEncrypt
MainActivity.aesCbcEncrypt.overload('[B', '[B', '[B').implementation = function(bytes, key, iv) {
// 将key和iv转换为十六进制字符串
var keyHex = bytesToHex(key);
var ivHex = bytesToHex(iv);
// 输出key和iv
console.log("Key (Hex): " + keyHex);
console.log("IV (Hex): " + ivHex);
var result = this.aesCbcEncrypt(bytes, key, iv);
return result;
};
function bytesToHex(bytes) {
var hex = [];
for (var i = 0; i < bytes.length; i++) {
var b = bytes[i] & 0xFF;
var h = b.toString(16);
if (h.length === 1) {
hex.push('0');
}
hex.push(h);
}
return hex.join('');
}
});
小脚本一写,小命令frida -U -n appre -l script.js
一输,再按下check按钮,key和iv就来了
但是要注意!!!
key的生成和UNIX时间相关,获取到的时间戳会 & 1048576(0x100000),也就是说随机数种子其实也就两种状态:要么是0x100000,要么是0。并且这个大概是12天变化一次。
题目描述说本地check在大约在5-11 9:40后失效,出题的时候种子是0x100000,那么可以得出此时seed已经变成了0所以本地check过不了了。
如果想获得正确的key,可以hook random的setSeed
方法,使参数固定为0x100000。这里就不上frida脚本了,同学们复现的时候可以自己动手操作一下~
拿到key和iv,赛博厨子一把梭就好了
C++++_revenge
为什么会出这个revenge呢?因为我把C++++那道题发给D0wnBe@t验题的时候,他直接秒了并且问这和C#有什么关系呢?
我恍然大悟——该死,我的AutoAssembler根本没派上用场啊!
遂默默在汇编脚本解释器代码里加了个小trick,然后将它称为revenge!
大家感兴趣的话可以在github上看一下原项目
https://github.com/S1nyer/AutoAssembler
有了C++++题目的经验,这次我们直奔资源文件
发现和C++++相比就多了一个rdx异或,异或值是0x1234567890abcdef
然后密文值data这次不在脚本里声明而是通过memoryAPI来写入
如果你现在将密文数组导出然后按上面的思路来解会发现:唉我去?怎么是乱码!
因为trick在C#代码而非汇编脚本中,如果进行代码对比就会发现,在汇编解释器的AutoAssemble(string[] Codes, ref List<AutoAssembler.AllocedMemory> alloceds)
函数下多出了这一行代码
这里是什么呢?其实就是汇编解释器已经将汇编脚本里的汇编指令都编译完成且写入内存后,处理一些宏指令,比如:createthread命令,它会创建一个线程并将给出的内存地址作为代码执行。
这里玩的小trick就是在执行线程之前,将代码里的异或key替换了。异或key=0x6d6974737568615f其实是mitsuha_,也就是三叶的英音译,当然小端序下是倒过来的
解题脚本
from struct import pack, unpack
def decrypt(enc):
decrypted = ""
for byte in enc:
k = (byte - 7) & 0xff
j = k ^ 0xb2
high = (j & 0xf0) >> 4
low = (j & 0x0f) << 4
m = high | low
decrypted += chr((m + 1) & 0xff)
return decrypted
enc = "92aff5fb9e68a42a833f942b7e4f72f501ea33f9188fe535623fe4be481f723364ed36cdef2a2236839ac49e8f7fde11"
xorkey=b"_ahustim"
flag = bytearray()
for (i,k) in enumerate(bytes.fromhex(enc)):
flag.append(k ^ xorkey[i % len(xorkey)])
flag = decrypt(flag)
print(flag)
后话
这里意外的是Britney师傅的非预期解,这小子直接改我资源文件里的脚本将xor的值写到input内存,然后再改我的C#代码直接读取xorkey (???Britney不削能玩?
EzBinary
这道题也是非常朴实无华的Android native逆向,一眼就能知道考的是native逆向,不像PYCC喜欢和你玩点花花肠子。
jadx打开看主函数啊
没有弯弯绕绕直接就将用户输入传进checkFlag函数检查是否正确。
我们跟进会发现是native函数
OK,用winrar把lib文件拖出来。CPU架构任君选哈,这里我选的是arm64爱妃,主要是ida自带Android_arm的类型库,方便符号恢复
然后将里面的
libbinnative.so
用IDA打开,查看checkFlag函数的实现
按Y把a1的数据类型改成JNIEnv*
这里是把java里的String
类型转成C的char*
类型,然后给sub_B10
函数处理,最后是返回sub_BE8
的返回值
那么sub_BE8
大概率就是和密文判断相关的函数了,而sub_B10
则是加密函数
跟进这两个函数看具体实现
算法学的不错的同学应该可以看出,左边是一个二叉树构建函数,根据用户输入的字符串构建一颗二叉树
右边则是后序遍历比较两颗二叉树结构与内容是否相同的函数
它们的C语言实现如下
// 左函数
TreeNode* buildTree(const char* s, int start, int end) {
if (start > end) return NULL;
if (start == end) return createNode(s[start]);
// 计算分割点
int len = end - start + 1;
int mid = len / 2;
// 递归构建子树
TreeNode* left = buildTree(s, start, start + mid - 1);
TreeNode* right = buildTree(s, start + mid, end - 1);
// 创建当前节点(根节点取最后一个字符)
TreeNode* root = createNode(s[end]);
root->left = left;
root->right = right;
return root;
}
// 右函数
bool Compare(TreeNode* root1, TreeNode* root2) {
if (root1 == NULL && root2 == NULL) {
return true;
}
if (root1 == NULL || root2 == NULL) {
return false;
}
// 递归比较左子树
bool leftMatch = Compare(root1->left, root2->left);
if (!leftMatch) {
return false;
}
// 递归比较右子树
bool rightMatch = Compare(root1->right, root2->right);
if (!rightMatch) {
return false;
}
// 比较当前节点的值
return root1->data == root2->data;
}
那么很明显unk_3188
变量就是目标二叉树的根节点,二叉树比较函数那里提醒了我们程序用的是后序遍历
那么现在其实有两种做法:
- frida hook Native函数,获取根节点地址然后后序遍历目标二叉树
- 查内存结构,手搓二叉树!
当然我的预期解是frida hook,上一个安卓题是Java层Hook,这一道题相当于上一个的进阶版:Native Hook
注意注意!!! 虽然我们用IDA打开的是arm64
架构的so库,但是APP运行时实际载入的so库是你模拟器的CPU架构决定的!
而我的模拟器实际上载入的是x86_64
的so库
所以在写hook脚本的时候,记得替换成正确的函数偏移,这里我们要hook的函数是Native里的二叉树比较函数,x86_64
下的函数偏移是0xC20
还要注意因为比较函数是递归实现的,所以我们只在第一次进入函数的时候遍历
Java.perform(function() {
// 查找目标模块和函数
var moduleName = "libbinnative.so";
var compareOffset = 0x0000000000000C20;
// 查找模块基址
var module = Module.findBaseAddress(moduleName);
if (!module) {
console.error("无法找到模块:", moduleName);
return;
}
console.log("模块 " + moduleName + " 基址:", module);
// 计算Compare函数的绝对地址
var compareAddr = module.add(compareOffset);
console.log("Compare函数地址:", compareAddr);
var hasTraversed = false;
// Hook Compare函数
Interceptor.attach(compareAddr, {
onEnter: function(args) {
// 只在第一次调用时遍历root树
if (!hasTraversed) {
hasTraversed = true;
var rootPtr = args[0];
console.log("Root指针地址:", rootPtr);
console.log("内置树后序遍历结果:");
postOrderTraversal(rootPtr);
}
}
});
var flag = ""
// 后序遍历函数实现
function postOrderTraversal(nodePtr) {
if (nodePtr.isNull()) return;
// 用ptr()转换成NativePointer类型
var node = ptr(nodePtr);
var leftPtr = Memory.readPointer(node.add(8));
var rightPtr = Memory.readPointer(node.add(16));
postOrderTraversal(leftPtr);
postOrderTraversal(rightPtr);
var data = Memory.readU8(node);
flag += String.fromCharCode(data);
console.log("flag -> ", flag);
}
});
写好脚本之后,要在APP里先随便输入一点内容然后点击确认按钮,这是为了触发native函数,使APP载入native库,否则会找不到模块
最后输入frida -U -n binnative -l script.js
启动frida,按APP确认按钮即可
结果如图
你喜欢数据结构吗?喵~
后话
其实我写代码的时候,那些二叉树节点都是只有字符data
但没有被连接的,连接二叉树我特意写了个initTree函数来实现
并且将它作为so库的init函数进行调用
但有可能因为是开的-O3
优化,编译器直接把树结构写死在内存了哈哈哈哈哈
oh~牛批,还有这种优化
这里要提一嘴的是D0wnBe@t是直接手搓画图解的
果然技巧只会耽误手撕的时间哈哈哈哈哈哈
ez_turing
虚拟机逆向没什么好说的,总结就是:耐心!耐心!还是他妈的耐心!
其实比赛有个小bug不知道有没有人发现,为了降低PWN题baby_vm的逆向难度,程序其实是保留符号编译的,并且指令集以及虚拟机的CPU结构和逆向完全一样,只不过VM的构造函数不同罢了。所以如果你用ida打开baby_vm
然后再去做逆向题ez_turing
,相当于把指令集白给你了
ez_turing(左)
baby_vm(右)
有很多种解法,最简单粗暴的做法首先是还原大致的虚拟机CPU结构(其实CPU结构并不复杂)
typedef struct _CPU
{
uint64_t regs[16];
byte* ip;
uint64_t* sp;
uint64_t* bp;
bool power;
}_CPU;
然后在指令翻译函数那里下断点然后动调,分析每一步虚拟机干了什么,最后还原出虚拟机的加密过程,写出解密脚本,这是我对新生的预期解 但校内赛是0解,大失败😭
这里放出VM构造函数的代码,这样大家就更加容易理解程序的操作
VM::VM(byte* code)
{
char buf[512];
memset(&this->cpu, 0 , sizeof(_CPU));
std::cout << "input your flag:";
std::cout.flush();
std::cin >> buf;
cpu.power = true;
cpu.ip = code;
cpu.bp = (uint64_t*)malloc(1024*sizeof(uint64_t));
cpu.sp = cpu.bp;
int status = 0;
// 将用户输入的数据压虚拟机栈
uint64_t *dat = (uint64_t*)buf;
int count = strlen(buf) / 8 + (strlen(buf) % 8 == 0 ? 0 : 1);
cpu.regs[13] = 1;
while (count)
{
status = interpret();
if (status)
{
printf("Runtime Error!%s at ip:%d", errors[status], cpu.ip-code);
return;
}
if(cpu.regs[14] == 0x80) //syscall
{
cpu.regs[0] = *dat++;
cpu.regs[14] = 0;
count--;
}
}
cpu.regs[13] = 0;
// 执行加密逻辑
while(cpu.power){
status = interpret();
if (status)
{
printf("Runtime Error!%s at ip:%x", errors[status], cpu.ip-code);
return;
}
}
if(cpu.regs[7] == 0x9a55)
{
printf("Congratulations!Your flag is right!\n");
}else if(cpu.regs[7] == 0xdead)
{
printf("Sorry!Your flag is wrong!\n");
}else
{
printf("WTF?Your VM code maybe modified!\n");
}
//printRegs();
//dumpStack(4);
return;
}
最开始那个循环其实是处理用户输入的,当r14==0x80时,就相当于VM执行了syscall,需要外部处理。
这里处理的是什么呢?当然是用户输入的数据啦,将它压入r0寄存器然后清除r14标志位,继续循环
后面的那个循环则是处理VM的加密逻辑了
有同学说动态调试太累了,有没有稍微省力一点的解法呢?当然有,当我们还原出指令集后,就可以写个python脚本来还原VM字节码的操作了
作为出题人为了方便出题,我其实写了一个VMBuilder
类,然后让AI改改写了个VMDisassembler
类来反汇编VM字节码
但是代码比较长,这里我就把VMBuilder
类以及出题代码放上来,同学们可以参考一下,都有注释的
(如果需要VMDisassembler
的代码可以私聊我)
from struct import pack
class VMBuilder:
def __init__(self):
self.buf = bytearray()
def _validate_registers(self, *regs):
for r in regs:
if not 0 <= r <= 15:
raise ValueError(f"Invalid register R{r} (0-15 only)")
def add(self, r1, r2):
"""ADD r1, r2"""
self._validate_registers(r1, r2)
self.buf += pack("<BBB", 1, r1, r2)
def add_imm(self, rx, imm):
"""ADD rX, imm"""
self._validate_registers(rx)
self.buf += pack("<BBQ", 2, rx, imm)
def sub(self, r1, r2):
self._validate_registers(r1, r2)
self.buf += pack("<BBB", 3, r1, r2)
def sub_imm(self, rx, imm):
self._validate_registers(rx)
self.buf += pack("<BBQ", 4, rx, imm)
def mul(self, r1, r2):
self._validate_registers(r1, r2)
self.buf += pack("<BBB", 5, r1, r2)
def mul_imm(self, rx, imm):
self._validate_registers(rx)
self.buf += pack("<BBQ", 6, rx, imm)
def mov(self, dst, src):
self._validate_registers(dst, src)
self.buf += pack("<BBB", 7, dst, src)
def ixor(self, r1, r2):
self._validate_registers(r1, r2)
self.buf += pack("<BBB", 8, r1, r2)
def push(self, rx):
self._validate_registers(rx)
self.buf += pack("<BB", 9, rx)
def pop(self, rx):
self._validate_registers(rx)
self.buf += pack("<BB", 10, rx)
def cmp(self, r1, r2):
self._validate_registers(r1, r2)
self.buf += pack("<BBB", 11, r1, r2)
def jmp(self, offset):
self.buf += pack("<Bh", 12, offset)
def jz(self, offset):
self.buf += pack("<Bh", 13, offset)
def jnz(self, offset):
self.buf += pack("<Bh", 14, offset)
def shiftL(self, rx, shift):
self._validate_registers(rx)
if not 0 <= shift <= 64:
raise ValueError("Shift amount must be 0-64")
self.buf += pack("<BBB", 15, rx, shift)
def shiftR(self, rx, shift):
self._validate_registers(rx)
if not 0 <= shift <= 64:
raise ValueError("Shift amount must be 0-64")
self.buf += pack("<BBB", 16, rx, shift)
def load(self, rx, offset):
self._validate_registers(rx)
self.buf += pack("<BBH", 17, rx, offset)
def store(self, rx, offset):
self._validate_registers(rx)
self.buf += pack("<BBH", 18, rx, offset)
def load2(self, r1, r2):
self._validate_registers(r1, r2)
self.buf += pack("<BBB", 19, r1, r2)
def store2(self, r1, r2):
self._validate_registers(r1, r2)
self.buf += pack("<BBB", 20, r1, r2)
def halt(self):
self.buf += pack("<B", 21)
def dump(self):
"""返回完整字节码副本"""
return bytes(self.buf)
def save(self, filename):
"""保存字节码到文件"""
with open(filename, 'wb') as f:
f.write(self.dump())
if __name__ == "__main__":
vm = VMBuilder()
key = [
0x9ce6a58be681a6e8,
0xe68885e585bfe589,
0xbb8ee5b1a4e58287,
0x8fe5a58ee68e80e6
]
# r13 = 1,表示数据未读取完毕,当r13=0时说明数据已读取完毕
# r0 = 当前数据块,每次从用户输入的数据中分割一个8字节大小的块然后存到虚拟机栈上
# r1 储存读入的数据块数量
# 当r14 = 0x80时说明需要外部设置r0的数据(类似于syscall)
load_input = 0
vm.add_imm(14, 0x80)
vm.push(0) # push r0
vm.add_imm(1, 1) # r1 += 1
vm.cmp(13, 15) # cmp r13,r15
vm.jnz(load_input - len(vm.buf) - 3)
# 初始化密钥
vm.add_imm(7, key[0]) # r7 = k0
vm.add_imm(8, key[1]) # r8 = k1
vm.add_imm(9, key[2]) # r9 = k2
vm.add_imm(10, key[3]) # r10 = k3
# 初始化加密参数
delta = 0xcafebabe0d000721
vm.mov(4, 15) # r4 = 0 (块对计数器)
vm.mov(5, 1) # r5 = r1 (总块数)
vm.shiftR(5, 1) # r5 = 总块对数
# 外层循环:遍历所有块对
block_loop = len(vm.buf)
vm.cmp(4, 5) # 比较当前处理块对数
vm.jz(0) # 全部处理完则跳出循环
jz_pos = len(vm.buf) # 记录jz指令的位置
# 计算当前块对内存偏移
vm.mov(6, 4) # r6 = 块对索引
vm.shiftL(6, 1) # 转换为单元偏移(乘以2)
# 加载当前明文块对
vm.load2(1, 6) # r1 = bp[r6]
vm.add_imm(6, 1)
vm.load2(2, 6) # r2 = bp[r6+1]
vm.sub_imm(6, 1)
# 初始化加密变量
vm.ixor(3, 3) # sum = 0
vm.add_imm(11, 24) # 内层循环计数器
# 内层循环:24轮加密
encrypt_loop = len(vm.buf)
# sum += delta
vm.add_imm(3, delta)
# v0 更新计算(使用永久寄存器r7-r10)
vm.mov(12, 2)
vm.shiftL(12, 4) # v1 << 4
vm.add(12, 7) # +k0
vm.mov(13, 2)
vm.add(13, 3) # v1 + sum
vm.mov(14, 2)
vm.shiftR(14, 5) # v1 >> 5
vm.add(14, 8) # +k1
vm.ixor(12, 13) # 异或操作
vm.ixor(12, 14)
vm.add(1, 12) # 更新v0
# v1 更新计算
vm.mov(12, 1)
vm.shiftL(12, 4) # v0 << 4
vm.add(12, 9) # +k2
vm.mov(13, 1)
vm.add(13, 3) # v0 + sum
vm.mov(14, 1)
vm.shiftR(14, 5) # v0 >> 5
vm.add(14, 10) # +k3
vm.ixor(12, 13)
vm.ixor(12, 14)
vm.add(2, 12) # 更新v1
# 循环控制
vm.sub_imm(11, 1)
vm.cmp(11, 15)
vm.jnz(encrypt_loop - len(vm.buf) - 3)
# 覆盖存储加密结果
vm.store2(6, 1) # bp[r6] = r1
vm.add_imm(6, 1)
vm.store2(6, 2) # bp[r6+1] = r2
# 递增块对计数器
vm.add_imm(4, 1)
vm.jmp(block_loop - len(vm.buf) - 3)
encrypted = [0x9225C2295691ED58, 0xF58044F637F80C26,0xF9F30D9F992BC3B9, 0xE5D8537D9674CCA4,0x2D977B86002702D9, 0x6DE2B4196F76B787]
# 动态跳转修正
offset = len(vm.buf) - jz_pos
vm.buf[jz_pos-2:jz_pos] = pack("<h", offset)
# 最后是密文比对验证的虚拟机代码
# 将密文读入寄存器r1~r6
for i in range(1,7):
vm.mov(i, 15)
vm.add_imm(i, encrypted[i-1])
pos = []
# 与栈上的加密数据对比
for i in range(6):
vm.load(0, i)
vm.ixor(0, i+1)
vm.jnz(0)
pos.append(len(vm.buf))
vm.ixor(7, 7)
vm.add_imm(7, 0x9a55)
vm.halt()
# 对比失败,首先把之前比较失败的jnz跳转全部处理
for p in pos:
offset = len(vm.buf) - p
vm.buf[p-2:p] = pack("<h", offset)
vm.ixor(7, 7)
vm.add_imm(7, 0xdead)
vm.halt()
vm.save("vmcode.bin")
print("Bytecode generated:", len(vm.buf), "bytes")
后话
这个VM被D0wnBe@t手撕了(下面是他的分析截图),我愿称博✌️为香袋手撕王