首页
社区
课程
招聘
[原创] 腾讯游戏安全技术竞赛2023 安卓客户端初赛WriteUp
2023-4-17 18:58 44123

[原创] 腾讯游戏安全技术竞赛2023 安卓客户端初赛WriteUp

2023-4-17 18:58
44123

解题情况

没有去专门绕过壳的反调试,完全使用frida和IDA静态分析,完成了第一个任务成功获取flag。
加密算法共4种,第二个任务注册机,缺一个算法的解密算法,其他三个算法均已写好C实现的解密算法。

Flag截图

图片描述

大概解题思路

frida默认会被检测,经过修改源码中的几个特征(端口号、临时目录、maps中相关字符串等),自行编译后获得一个可以hook除了libsec2023.so之外的所有so的frida。
随后hook系统函数dlopen,当加载libil2cpp.so之后从内存dump下来解密后的so文件,通过比较apk中的so来修复一些结构体信息,此时得到了一个完整的可被IDA正常解析的libil2cpp.so,因此可以用IL2CPPDumper恢复IDA中符号和结构体。
找到CollectCoin函数,使用frida去patch其中金币增加的逻辑,改为每次增加1000,这样我们就可以一次性达到获取flag的分数,从而轻易获取flag。
之后发现有两个被混淆了函数名称的C#类,经过足够的分析后,可以判断其中一个是VM,包含一些字段用来模拟栈、操作码等,总共有21个方法,除了构造方法外均为VM的Handler。随后在xxx函数通过frida分析找到XTEA加密,然后用frida在内存中找到并提取了密钥(第一处加密)。
(PS:这里的加密多少处是指我找的顺序,不是逻辑上的顺序)
Hook SmallKeyboard__iI1Ii_4610736函数后发现函数在开头跳转到了g_sec2023_p_array的函数中,随后在libsec2023.so分析该函数,发现被混淆。模式基本上都是将常规的分支、直接跳转改为用寄存器寻址跳转,寄存器中的跳转地址由一个常数+一个偏移来得到,常数和取偏移的基址在一个函数中是固定的,在跳转前,使用条件选择指令等来获取相应的偏移,从而间接实现分支跳转,使用Unicorn模拟执行+IDAPython修改汇编+遇到特殊情况手动改的方式,我们可以轻易地去除这个混淆。
去除混淆后可以找到两个加密算法(第三、四处加密),其中第三处可以直接分析,第四处加密通过frida hook得知是调用了Sec2023.Encrypt方法,该类由壳加载,JADX / JEB等工具无法直接分析,需要使用frida-dump工具在运行时dump下来,然后分析。随后可以发现一个加了BlackObfuscator的dex中就找到了该方法,由于没有去除混淆的方案,这里选择使用复制所有真实指令,结合第三处加密,通过frida观察加密结果来分析加密算法实现的是否正确了,得到了想要的结果之后就可以写这两处加密的解密函数。
最后回头去分析用VM实现的加密算法(第二处加密),我的解决方案是在frida中hook所有handler,通过输出所有被执行过的指令为C代码,将栈在输出的源码中处理成寄存器变量(register关键字),所有通过opcode来取出的值处理为常量,这样重新编译后就可以得到较为精简的加密算法,该过程需要结合frida多次观察和分析。
分析完这四处算法,逆向编写出这4处加密算法的解密算法,就可以成功实现注册机。

具体解题过程

*由于实际解题过程有点复杂和混乱,如果有地方没说清楚请谅解,因为我也忘了我都干了些啥=_=||

整一个假的Frida

下载frida源码,将其中的/server/server.vala中的re.frida_server改为其他字符串,再把src/linux/linux-host-session.vala中的so文件特征字符串更改,然后用一个27042之外的端口启动修改好的frida,随后启动游戏,发现启动成功,并不会被检测。
图片描述

Dump && Recover IL2CPP

虽然用修改后的frida去hook libsec2023.so仍然会被检测,但是hook其他库没有出现问题。而so在被dlopen加载之后有可能就解密好了,因此这里选择用frida去尝试hook dlopen去dump libil2cpp.so。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
function WriteMemToFile(addr, size, file_path) {
  Java.perform(function() {
    var prefix = '/data/data/com.com.sec2023.rocketmouse.mouse/files/'
 
    var mkdir = Module.findExportByName('libc.so', 'mkdir');
    var chmod = Module.findExportByName('libc.so', 'chmod');
    var fopen = Module.findExportByName('libc.so', 'fopen');
    var fwrite = Module.findExportByName('libc.so', 'fwrite');
    var fclose = Module.findExportByName('libc.so', 'fclose');
 
    var call_mkdir = new NativeFunction(mkdir, 'int', ['pointer', 'int']);
    var call_chmod = new NativeFunction(chmod, 'int', ['pointer', 'int']);
    var call_fopen =
        new NativeFunction(fopen, 'pointer', ['pointer', 'pointer']);
    var call_fwrite =
        new NativeFunction(fwrite, 'int', ['pointer', 'int', 'int', 'pointer']);
    var call_fclose = new NativeFunction(fclose, 'int', ['pointer']);
 
    call_mkdir(Memory.allocUtf8String(prefix), 0x1FF);
    call_chmod(Memory.allocUtf8String(prefix), 0x1FF);
    var fp = call_fopen(
        Memory.allocUtf8String(prefix + file_path),
        Memory.allocUtf8String('wb'));
    if (call_fwrite(addr, 1, size, fp)) {
      console.log('[+] Write file success, file path: ' + prefix + file_path);
    } else {
      console.log('[x] Write file failed');
    }
 
    call_fclose(fp);
  });
}
 
function HookLibWithCallback(name, callback) {
  var dlopen = Module.findExportByName('libdl.so', 'dlopen');
  var detach_listener = Interceptor.attach(dlopen, {
    onEnter: function(args) {
      var cur = args[0].readCString();
      console.log('[+] dlopen called, name: ' + cur);
      if (cur.indexOf(name) != -1) {
        this.hook = true;
      }
    },
    onLeave: function() {
      if (this.hook) {
        console.log('[+] Hook Lib success, name:', name);
        callback();
        detach_listener.detach();
      }
    }
  });
}
 
function DumpIL2CPP() {
  var libil2cpp = TraverseModules('single', {name: 'libil2cpp.so'});
  WriteMemToFile(libil2cpp.base, libil2cpp.size, 'libil2cpp.so');
}
 
function main() {
  HookLibWithCallback('libil2cpp.so', DumpIL2CPP);
}
 
main();

从手机里把pull出来so,发现函数已经解密,IDA能正常解析,但是还有部分函数会爆红。因此用010editor对apk中的so和dump出来的so进行比对,补上尾部的重定位表,重新IDA打开,此时已经可以正常解析。
图片描述
此时的libil2cpp.so非常完整,尝试使用IL2CPPDumper恢复符号,发现可以成功Dump
图片描述

Patch游戏逻辑 && 获得Flag

在dump.cs中搜索金币相关方法以及字段,发现了CollectCoin这个函数
图片描述
IDA不断跟进,可以找到一处自增逻辑,那么我们可以利用frida去修改这里的汇编来使我们的金币获取更快
图片描述

1
2
3
4
5
6
7
function PatchIncrease() {
  var libil2cpp = TraverseModules('single', {name: 'libil2cpp.so'});
  var insn = libil2cpp.base.add(0x465674);
  console.log('[+] Patching..');
  Memory.protect(insn, 4, 'rwx');
  insn.writeByteArray([0x01, 0xA0, 0x0F, 0x11]);
}

重新启动游戏,随便吃几粒金币,即可获取flag
图片描述

分析IL2CPP

题目要求实现外挂的注册机,在dnspy中找到SmallKeyboard类,可以分析到输入的小键盘一些相关字段和方法
图片描述
在IDA中跟进get_input_il1li函数,可以发现是一个判断按键类型进入不同函数的逻辑
图片描述
结合frida辅助分析可以知道,当KeyType<2时为数字输入,当KeyType=2时为确认,触发关键逻辑

分析SmallKeyboard__iI1Ii_4610736函数

其中SmallKeyboard__iI1Ii_4610736为关键函数,将我们输入的key转为UInt64类型传入,跟进可以发现有一处跳转会跳到g_sec2023_p_array中的一个函数,此处我已经patch(为了IDA能正常反编译该函数)
图片描述
使用frida hook首部的跳转指令(此处已经是patch成NOP)后一条指令,可以发现我们输入的key发生了变化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function HookSmallKeyboard$$parse_input_iI1Ii_4610736() {
  var libil2cpp = TraverseModules('single', {name: 'libil2cpp.so'});
  var HookSmallKeyboard$$parse_input_iI1Ii_4610736 =
      libil2cpp.base.add(0x465AB4);
  Interceptor.attach(HookSmallKeyboard$$parse_input_iI1Ii_4610736, {
    onEnter: function(args) {
      PrintNativeBackTraceFuzzy(this.context, TraverseModules('all', {}));
      console.log('[+] HookSmallKeyboard$$parse_input_iI1Ii_4610736 called');
      console.log('input_key: ', args[1]);
      Dump_g_sec2023_o_array();
    }
  });
}
// 假设输入了1111,则key会变成0x7d8174410d817fa
// [+] HookSmallKeyboard$$parse_input_iI1Ii_4610736 called
// input_key:  0x7d8174410d817fa

该函数中,我们可以找到一个XTEA加密算法,通过frida hook我们可以获取到key
图片描述

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function get_reg_code() {
  var libil2cpp = TraverseModules('single', {name: 'libil2cpp.so'});
  var ctor = libil2cpp.base.add(0x4660E8);
  Interceptor.attach(ctor, {
    onEnter: function(args) {
      console.log('[+] vm ctor called');
      console.log('ipt_enc_high: ', this.context.x22);
    },
    onLeave: function(retval) {
      console.log('[+] vm ctor returned');
    }
  });
 
  var loc_465D34 = libil2cpp.base.add(0x465D34);
  Interceptor.attach(loc_465D34, {
    onEnter: function(args) {
      console.log('[+] get_reg_code called');
      console.log('key_array: ', parseSystemArrayObjectU32(this.context.x20));
      console.log('ipt_enc_high: ', this.context.x22);
      console.log('ipt_enc_low: ', this.context.x21);
      console.log('reg_code_i32: ', this.context.x0);
    }
  });
}

下面是对该加密算法的实现,经过frida对输入输出进行验证,是正确的。

1
2
3
4
5
6
7
8
9
10
11
12
uint32_t key[4] = {0x7B777C63, 0xC56F6BF2, 0x2B670130, 0x76ABD7FE};
void EncryptXTEA(uint32_t *msg, uint32_t *key, ssize_t round) {
  uint32_t v0 = msg[0], v1 = msg[1];
  uint32_t delta = DELTA, sum = 0xBEEFBEEF;
  for (ssize_t idx = 0; idx < round; idx++) {
    v0 += (((v1 << 7) ^ (v1 >> 8)) + v1) ^ (sum - key[sum & 3]);
    sum += delta;
    v1 += (((v0 << 8) ^ (v0 >> 7)) - v0) ^ (sum + key[(sum >> 13) & 3]);
  }
  msg[0] = v0;
  msg[1] = v1;
}

分析libsec2023.so中g_sec2023_p_array的函数

上面的情况说明跳转到的那个地方有对input进行加密的函数,打开libsec2023.so跟进那个函数分析,其中v5函数指针调用的是一个打log的函数,不用理会。主要是sub_3B8CC
图片描述
跟进可以发现函数被混淆了,因为分支跳转指令利用寄存器寻址
图片描述

分析混淆机制以及使用Unicorn去混淆

分析可以发现,是将常规的分支、直接跳转改为用寄存器寻址跳转,寄存器中的跳转地址由一个常数+一个偏移来得到,常数和取偏移的基址在一个函数中是固定的,在跳转前,使用条件选择指令等来获取相应的偏移,从而间接实现分支跳转。
图片描述
现在的情况是,我没有去干掉壳的反调试,因此没办法调试libsec2023.so,而这个壳frida也hook不上。但我又不想费力去手动patch掉所有混淆的汇编指令,此时的最佳选择显然是利用模拟执行相关的工具去帮我们完成。

去混淆脚本

通过脚本+人工半自动分析,可以很快的去除掉所有需要去除的花指令

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
from emu_utils import *
from unicorn import *
from unicorn.arm64_const import *
# from ida_bytes import *
 
 
def trace_back_insn_with_target(insn_queue, target_reg):
    for insn in insn_queue:
        if (target_reg in insn.op_str):
            if (insn.mnemonic == 'add'):
                print(insn.mnemonic + '\t' + insn.op_str)
        if (insn.mnemonic == 'ldr'):
            print(insn.mnemonic + '\t' + insn.op_str)
        if (insn.mnemonic == 'csel'):
            print(insn.mnemonic + '\t' + insn.op_str)
 
 
def log_hook(emu, addr, size, user_data):
    disasm = get_disasm(emu, addr, size)
 
    print(hex(addr) + '\t' + disasm.mnemonic + '\t' + disasm.op_str)
 
 
def step_over_hook(emu, addr, size, none):
    disasm = get_disasm(emu, addr, size)
 
    if (disasm.mnemonic == 'bl' or disasm.mnemonic == 'blr'):
        emu.reg_write(UC_ARM64_REG_PC, addr + size)
 
    if (disasm.mnemonic == 'ret'):
        print('function returned')
        emu.emu_stop()
 
    if (addr == 0x3ac68):
        emu.reg_write(UC_ARM64_REG_W10, 0xEECF7326)
 
 
def normal_hook(emu, addr, size, insn_queue):
    global const_value, offset_value, cond, cond_value, uncond_value
    disasm = get_disasm(emu, addr, size)
    reg_maps = get_reg_maps()
 
    insn_queue.insert(0, disasm)
    if (len(insn_queue) > 8):
        insn_queue.pop()
 
    if (disasm.mnemonic == 'csel'):
        cond_value = emu.reg_read(reg_maps[disasm.op_str.split(', ')[1]])
        uncond_value = emu.reg_read(reg_maps[disasm.op_str.split(', ')[2]])
        cond = disasm.op_str.split(', ')[3]
 
    if (disasm.mnemonic == 'cset'):
        cond_value = 1
        uncond_value = 0
        cond = disasm.op_str.split(', ')[1]
 
    if (disasm.mnemonic == 'ldr'):
        if (len(disasm.op_str.split(', ')) == 3):
            offset_value = emu.reg_read(
                reg_maps[disasm.op_str.split(', ')[1].split('[')[1]])
        elif (len(disasm.op_str.split(', ')) == 4):
            offset_value = emu.reg_read(
                reg_maps[disasm.op_str.split(', ')[1].split('[')[1]])
            cond_value *= 8
 
    if (disasm.mnemonic == 'add' and '#' not in disasm.op_str and 'w' not in disasm.op_str):
        const_value = emu.reg_read(reg_maps[disasm.op_str.split(', ')[2]])
 
    if (disasm.mnemonic == 'br'):
        print('on br insn')
 
        target_reg = disasm.op_str
        trace_back_insn_with_target(insn_queue, target_reg)
 
        print(hex(const_value), hex(offset_value),
              cond, cond_value, uncond_value)
 
        cond_addr = emu.mem_read(offset_value + cond_value, 4)
        cond_addr = (int.from_bytes(
            cond_addr, byteorder='little') + const_value) & 0xffffffff
        uncond_addr = emu.mem_read(offset_value + uncond_value, 4)
        uncond_addr = (int.from_bytes(
            uncond_addr, byteorder='little') + const_value) & 0xffffffff
 
        patch_asm = b''
        patch_asm += get_asm('b' + cond + ' ' + hex(cond_addr), addr - 4)
        patch_asm += get_asm('b ' + hex(uncond_addr), addr)
        # patch_bytes(addr - 4, patch_asm)
 
        emu.reg_write(UC_ARM64_REG_PC, addr + size)
 
 
def emulate_execution(filename, start_addr, hook_func, user_data):
    emu = Uc(UC_ARCH_ARM64, UC_MODE_LITTLE_ENDIAN)
 
    textSec = get_section(filename, '.text')
    dataSec = get_section(filename, '.data')
 
    textSec_entry = textSec.header['sh_addr']
    textSec_size = textSec.header['sh_size']
    textSec_raw = textSec.header['sh_offset']
 
    TEXT_BASE = textSec_entry >> 12 << 12
    TEXT_SIZE = (textSec_size + 0x1000) >> 12 << 12
    TEXT_RBASE = textSec_raw >> 12 << 12
 
    dataSec_entry = dataSec.header['sh_addr']
    dataSec_size = dataSec.header['sh_size']
    dataSec_raw = dataSec.header['sh_offset']
 
    DATA_BASE = dataSec_entry >> 12 << 12
    DATA_SIZE = (dataSec_size + 0x1000) >> 12 << 12
    DATA_RBASE = dataSec_raw >> 12 << 12
 
    VOID_1_BASE = 0x00000000
    VOID_1_SIZE = TEXT_BASE
 
    VOID_2_BASE = TEXT_BASE + TEXT_SIZE
    VOID_2_SIZE = DATA_BASE - VOID_2_BASE
 
    STACK_BASE = DATA_BASE + DATA_SIZE
    STACK_SIZE = 0xFFFFFFFF - STACK_BASE >> 12 << 12
 
    emu.mem_map(VOID_1_BASE, VOID_1_SIZE)
    emu.mem_map(TEXT_BASE, TEXT_SIZE)
    emu.mem_map(DATA_BASE, DATA_SIZE)
    emu.mem_map(VOID_2_BASE, VOID_2_SIZE)
    emu.mem_map(STACK_BASE, STACK_SIZE)
 
    emu.mem_write(TEXT_BASE, read(filename)[TEXT_RBASE:TEXT_RBASE+TEXT_SIZE])
    emu.mem_write(DATA_BASE, read(filename)[DATA_RBASE:DATA_RBASE+DATA_SIZE])
    emu.reg_write(UC_ARM64_REG_FP, STACK_BASE + 0x1000)
    emu.reg_write(UC_ARM64_REG_SP, STACK_BASE + STACK_SIZE // 2)
 
    emu.hook_add(UC_HOOK_CODE, log_hook)
    emu.hook_add(UC_HOOK_CODE, step_over_hook, user_data)
    emu.hook_add(UC_HOOK_CODE, hook_func, user_data)
 
    emu.emu_start(start_addr, 0x0)
 
 
if __name__ == '__main__':
    filename = './libsec2023.so'
    start_addr = 0x3ac68
 
    insn_queue = []
    emulate_execution(filename, start_addr, normal_hook, insn_queue)

分析加密函数

去混淆后即可分析函数,经过分析,可以知道是对input以4字节为一组分别进行加密。
图片描述
其中enc_1逻辑较为明显,显然是可逆的
图片描述
下面这个函数实现了上面的加密算法,经过frida验证输入输出是正确的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
void EncryptObfusedRoundI(uint32_t ipt[]) {
  for (ssize_t idx = 0; idx < 2; ++idx) {
    uint8_t buf[4] = {0}, tmp[4] = {0};
 
    for (ssize_t i = 0; i < 4; i++) {
      buf[i] = ((uint8_t *)&ipt[idx])[i] ^ i;
    }
 
    buf[3] ^= 0x86;
    buf[2] -= 0x5E;
    buf[1] ^= 0xD3;
    buf[0] -= 0x1C;
 
    for (ssize_t i = 0; i < 4; i++) {
      tmp[i] = buf[i] - i * 8;
    }
 
    ipt[idx] = *(uint32_t *)&tmp;
  }
}

而enc_2中调用了一个类中的方法,我们只知道这个方法叫什么,但是并不知道是哪个类下的方法,因此我们需要hook GetStaticMethodID这个JNI函数。
[圖片]
通过hook JNI函数找到调用解密函数的类
通过hook,我们可以知道调用的是Sec2023.Encrypt方法,但是JADX中并没有找到,不能直接分析。说明这个类是由壳动态加载的,因此我们需要找办法dump下来dex。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function HookGetStaticMethodID() {
  var libart = TraverseModules('single', {name: 'libart.so'});
  var GetStaticMethodID = libart.base.add(0x3A87B4);
  Interceptor.attach(GetStaticMethodID, {
    onEnter: function(args) {
      var clazz = args[1];
      var name = args[2].readUtf8String();
      var sig = args[3].readUtf8String();
 
      // get clazz name
      var clazz_name = Java.vm.getEnv().getClassName(clazz);
 
      console.log(`[*] GetStaticMethodID called: ${clazz_name} ${name} ${sig}`);
    }
  });
}

使用frida-dexdump获取壳加载的Dex && 分析Sec2023.Encrypt方法

通过这个工具我们可以很轻松地在内存里找到相关的dex,dump下来就可以接着分析了。
图片描述
打开分析,可以发现加了BlackObfuscator,而我并没有现成的解决方案可以参考和使用,那就只能推理和猜猜看了。
图片描述

分析被BlackObfuscator混淆的算法

这里我用一种很简单粗暴的办法,就是把每个case中的真实指令直接复制出来,然后可以发现变量之间的运算有明显的逻辑关系,经过推理和结合frida分析验证(上面的frida脚本log了加密完的key),可以推理出正确的加密算法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
uint32_t EncryptObfusedRoundII(uint32_t ipt) {
  uint32_t n1 = 0, n2 = 0;
  uint8_t result[4] = {0}, *buf = (uint8_t *)&ipt;
  uint8_t key[8] = {0x32, 0xCD, 0xFF, 0x98, 0x19, 0xB2, 0x7C, 0x9A};
 
  n1 = __builtin_bswap32(*(uint32_t *)buf);
  n2 = (n1 >> 7) | (n1 << 25);
 
  *(uint32_t *)result = __builtin_bswap32(n2);
 
  for (int idx = 0; idx < 8; idx++) {
    result[idx] = (result[idx] ^ key[idx % 8]);
    result[idx] = (result[idx] + idx);
  }
 
  return *(uint32_t *)result;
}

使用frida分析VM && 使用frida解释VM

分析完上面提到的算法之后,我们可以在此处找到一个函数的调用,它改变了input的值,所以应为加密算法
图片描述
对这两个类进行分析,可以知道上面那个类中的都是opcode dict的key,下面那个类是VM的Handler和一些相关的字段。
图片描述
VM直接分析不好分析,我们需要侧面去分析。两种常用的方法:一是把指令数组和handler自己实现在程序外部自行分析,二是直接hook来分析。这里选择直接hook通过打log来还原VM逻辑。
首先需要解析VM类的this指针

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function ParseVMClazz(thiz) {
  var fields = thiz.add(0x10);
  var opcode = fields.add(0x00);
  var input = fields.add(0x08);
  var buf = fields.add(0x10);
  var x = fields.add(0x18);
  var cnt = fields.add(0x1C)
  var maybe_idx = fields.add(0x20);
  var num = fields.add(0x24);
  var opdict = fields.add(0x28);
  var vm_context = {
    opcode: opcode.readPointer(),
    input: input.readPointer(),
    buf: buf.readPointer(),
    x: x.readS32(),
    cnt: cnt.readS32(),
    maybe_idx: maybe_idx.readS32(),
    num: num.readS32(),
    opdict: opdict.readPointer(),
  };
  return vm_context;
}

然后通过手动hook一个个handler来实现VM解释器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
function VMparser() {
  var libil2cpp = TraverseModules('single', {name: 'libil2cpp.so'});
 
  var array_op_1_O00O0000O0o = libil2cpp.base.add(0x46B480);
  Interceptor.attach(array_op_1_O00O0000O0o, {
    onEnter: function(args) {
      var x = ParseVMClazz(args[0]).x;
      // console.log('[*] array_op_1_O00O0000O0o called');
      console.log(`    input[buf${x}] = buf${x - 1};`);
    }
  });
 
  var array_op_2_O00O00000oo = libil2cpp.base.add(0x46B4E8);
  Interceptor.attach(array_op_2_O00O00000oo, {
    onEnter: function(args) {
      var x = ParseVMClazz(args[0]).x;
      var opcode = ParseVMClazz(args[0]).opcode.add(32);
      var cnt = ParseVMClazz(args[0]).cnt;
      // console.log('[*] array_op_2_O00O00000oo called');
      console.log(`    input[${opcode.add(cnt * 2).readU16()}] = buf${x};`);
    }
  });
 
  var array_op_3_O00O0000OO = libil2cpp.base.add(0x46B3F8);
  Interceptor.attach(array_op_3_O00O0000OO, {
    onEnter: function(args) {
      var x = ParseVMClazz(args[0]).x;
      var opcode = ParseVMClazz(args[0]).opcode.add(32);
      var cnt = ParseVMClazz(args[0]).cnt;
      // console.log('[*] array_op_3_O00O0000OO called');
      console.log(`    buf${x + 1} = input[${opcode.add(cnt * 2).readU16()}];`);
    }
  });
 
  var array_op_4_O0O0000Ooo = libil2cpp.base.add(0x46B28C);
  Interceptor.attach(array_op_4_O0O0000Ooo, {
    onEnter: function(args) {
      var opcode = ParseVMClazz(args[0]).opcode.add(32);
      var cnt = ParseVMClazz(args[0]).cnt;
      // console.log('[*] array_op_4_O0O0000Ooo called');
      console.log(`    cnt = ${opcode.add(cnt * 2).readU16()};`);
    }
  });
  .....
  ...
}

随便输入一个字符串,点击确认按钮,我们就可以得到一个输出的形式为C代码的VM逻辑。
输出如下,因为后面我放到直接把他作为加密函数来验证VM逻辑是否dump正确,所以有一些改动

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
register uint32_t buf0, buf1, buf2, buf3, buf4, buf5, cnt;
uint32_t input[8] = {0};
input[0] = ipt[0];
input[1] = ipt[1];
 
buf0 = 24;
input[2] = buf0;
buf0 = input[0];
buf1 = input[2];
buf0 = ((~(buf0 + 0x100) | 0xFFFFFEFF) + 2 * buf0 +
        (~(buf0 + 0x100) & 0xFFFFFEFF) + 0x202) >>
       buf1;
buf1 = 255;
buf0 = (buf1 + (buf1 ^ ~buf0) + (~buf0 & ~buf1) + (buf0 & ~buf1) + 1) & buf0;
buf1 = input[2];
buf2 = 8;
buf1 = buf1 - buf2 + (buf1 ^ buf2) + (buf1 & buf2) - (buf1 | buf2);
input[2] = buf1;
buf1 = input[2];
buf2 = 0;
buf1 = buf1 < buf2;
if (buf1) {
  cnt = 26;
} else {
  cnt = 4;
}
.....
...

由于我们上面其他加密算法都已经找到了,再加上这个,我们可以对输入输出做完整的验证。经过frida hook输入输出可以知道dump下来的VM逻辑没问题。至此,最后一个问题就是如何去得到这个算法的逆算法。

重编译VM指令,恢复算法逻辑

对于这种VM,我们可以直接重新开优化编译,并把其中的一些变量用register关键字定义为寄存器变量,这样IDA的反编译效果会有极大的提升。
重新编译后,IDA反编译出的算法逻辑如下,总共一百多行,不多也不少。难就难在有指令替换。打算直接动调猜每一条指令的作用(因为变量不会被重复赋值,指令也并没有像原本VM这种难以接受的分析,所以我觉得这是可行的)
图片描述

梳理加密过程,开整注册机

题目要求对于任意TOKEN,我们的注册机都能生成一个KEY来注册外挂。那就意味着随机生成的TOKEN经过解密之后就是KEY。那注册机其实就是上述所有算法的解密算法实现。我们知道,首先执行的是壳内两个算法加密,然后是VM内的算法加密,最后是XTEA算法加密,那么注册机就是逆过来实现就完事了。

XTEA解密算法

1
2
3
4
5
6
7
8
9
10
11
void DecryptXTEA(uint32_t *msg, uint32_t *key, ssize_t round) {
  uint32_t v0 = msg[0], v1 = msg[1];
  uint32_t delta = DELTA, sum = 0xBEEFBEEF + delta * round;
  for (ssize_t idx = 0; idx < round; idx++) {
    v1 -= (((v0 << 8) ^ (v0 >> 7)) - v0) ^ (sum + key[(sum >> 13) & 3]);
    sum -= delta;
    v0 -= (((v1 << 7) ^ (v1 >> 8)) + v1) ^ (sum - key[sum & 3]);
  }
  msg[0] = v0;
  msg[1] = v1;
}

VM解密算法

暂未实现

壳内第二个算法的解密算法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
uint32_t DecryptObfusedRoundII(uint32_t enc) {
  uint32_t n1 = 0, n2 = 0;
  uint8_t result[4] = {0}, *buf = (uint8_t *)&enc;
  uint8_t key[8] = {0x32, 0xCD, 0xFF, 0x98, 0x19, 0xB2, 0x7C, 0x9A};
 
  for (int idx = 0; idx < 8; idx++) {
    result[idx] = (buf[idx] - idx);
    result[idx] = (result[idx] ^ key[idx % 8]);
  }
 
  n2 = __builtin_bswap32(*(uint32_t *)result);
  n1 = (n2 << 7) | (n2 >> 25);
 
  return __builtin_bswap32(n1);
}

壳内第一个算法的解密算法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
void DecryptObfusedRoundI(uint32_t enc[]) {
  for (ssize_t idx = 0; idx < 2; ++idx) {
    uint8_t buf[4] = {0}, tmp[4] = {0};
 
    *(uint32_t *)&tmp = enc[idx];
 
    for (ssize_t i = 0; i < 4; i++) {
      buf[i] = tmp[i] + i * 8;
    }
 
    buf[3] ^= 0x86;
    buf[2] += 0x5E;
    buf[1] ^= 0xD3;
    buf[0] += 0x1C;
 
    for (ssize_t i = 0; i < 4; i++) {
      tmp[i] = buf[i] ^ i;
    }
 
    enc[idx] = *(uint32_t *)&tmp;
  }
}

[培训]二进制漏洞攻防(第3期);满10人开班;模糊测试与工具使用二次开发;网络协议漏洞挖掘;Linux内核漏洞挖掘与利用;AOSP漏洞挖掘与利用;代码审计。

最后于 2023-4-17 19:07 被fallw1nd编辑 ,原因:
收藏
点赞11
打赏
分享
最新回复 (12)
雪    币: 1160
活跃值: (1123)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
HUAJIEN 2023-4-17 19:32
2
0
fallw1nd!
雪    币: 1744
活跃值: (8728)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
你瞒我瞒 2023-4-18 09:15
3
0
6666666
雪    币: 19584
活跃值: (60093)
能力值: (RANK:125 )
在线值:
发帖
回帖
粉丝
Editor 2023-4-18 11:15
4
0


感谢分享,为了防止时间长了链接失效,题目本地存档一份。

https://gslab.qq.com/html/competition/2023/race-pre.htm

上传的附件:
雪    币: 27
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
0xE4s0n 2023-4-18 11:15
5
0
样本地址:https://gslab.qq.com/html/competition/2023/doc/%E5%AE%89%E5%8D%93%E5%AE%A2%E6%88%B7%E7%AB%AF%E5%AE%89%E5%85%A8-%E5%86%B3%E8%B5%9B%E9%A2%98%E7%9B%AE.zip
雪    币: 219
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
Skyart 2023-4-18 14:38
6
0
66666
雪    币: 2
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
zx_958536 2023-4-20 18:48
7
0
tql
雪    币: 62
活跃值: (561)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
万里星河 2023-4-20 20:07
8
0
疯狂打call
雪    币: 5601
活跃值: (3872)
能力值: ( LV5,RANK:70 )
在线值:
发帖
回帖
粉丝
简单的简单 2023-4-21 14:33
9
0
太强了
雪    币: 19431
活跃值: (29092)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
秋狝 2023-4-21 16:56
10
1
感谢分享,tal
雪    币: 3266
活跃值: (4355)
能力值: ( LV9,RANK:160 )
在线值:
发帖
回帖
粉丝
乐子人 3 2023-5-1 02:10
11
0
去混淆脚本很6,学习了
雪    币:
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
NOOB@ToT 2023-5-7 00:57
12
0

雪    币: 5601
活跃值: (3872)
能力值: ( LV5,RANK:70 )
在线值:
发帖
回帖
粉丝
简单的简单 2023-11-19 23:55
13
0
大佬,去混淆的脚本里是不是缺少 from emu_utils import * 这个模块,可以贴下代码么
游客
登录 | 注册 方可回帖
返回