新学期好耶

实则不然,但是早八变少了,这是好的

我不想上作物栽培……

关于scirpophaga

虽然早有预料,但是这比我想象中来的更快了一些,硬字节匹配实在是太太太太不精确了,为了更加精确地定位函数入口(对我说的就是你,Frag2),我们几乎必然需要对软件进行改造

鉴于我对逆向工程浅薄的认知,我自以为是地制定了以下几个可能可行的方案

  1. 函数长度匹配
    这个函数的长度在每个版本中都较为精准,每个版本的偏差不会超过0x10字节,所以匹配长度是一个可行的方案
    我已经知道你要说什么了,那就是“函数”在ELF文件内是一个半不存在的概念,因此如果需要使用这个方法,我们就需要判断出每个“函数”的边界——这相当于把IDA的一小部分重新发明了一遍,对我这种小白来说,这十分地不明智
    不过鉴于aarch64的函数调用(在没有混淆与编译器优化的情况下)十分规范,几乎每个函数开头都会有类似STP X29, X30, [SP,#0x...]这样的标识,又几乎每个函数的末尾也都会有BR X30(或者更经常说RET X30(或者是RET)),我们其实可以大致圈出每个函数的轮廓,不需要太过于精准
    结果:事实证明,那种函数长度匹配简直就没什么大用处,函数边界偏了不是一点半点

  2. 指令组分密度匹配
    在大致锚定了函数边界的前提下,我们可以粗略地,甚至可以在只识别opcode的情况下,对指令的类型进行计数,或者叫做“种群密度分析”(鉴定为学农学学的),在每次编译时,操作的寄存器的变化理应(推测)比操作类型多得多,所以可以通过截取opcode部分,为每个函数建立一个小表,之后根据现有分析结果,来推测出到底哪个最接近这个比例
    这个做法的问题也很明显,例如多个哈希函数内联至多个函数时,大量的异或、移位操作会将其他指令稀释,这对这种分析很不友好
    结果:由于指令组分密度匹配依赖于第一种方法的准确性,所以这个方案也失效了,我曾经尝试过匹配指令的类型,最后以失败与极差的特异程度告终

  3. B剔除
    你知道的,在现有版本的软件中,跳过外部调用的方式简单粗暴,即通过函数起始偏移加上一个硬编码的偏移量和4倍数的长度,然后将其全部填充为0x00000091(即add x0, x0, #0x0, lsl #0)来实现跳过操作,不过这太过于暴力了,虽然我仍然打算使用它进行跳过操作,但是基于函数起始位置和一些仅在当前版本生效的值进行填充实在是太愚蠢了,所以我仍然决定通过匹配指令类型的序列来实现剔除功能
    当然,这个问题在于指令序列本身是有顺序的……我相当于硬编码了另一个东西进去,只不过普适性可能较高而已
    结果:对于第三种方案,我想你也知道了,这么做会误伤其它的BL指令,并且导致结果的失效

  4. 新常量的提取
    要不是对方老是更换端点,我也不想这么做,算了,这个之后说吧

就当刚才的是废话吧

经过对目标应用的分析,刚刚的3种方案其实一个都没有采用,我使用了另外一套方案

从ADRP & ADD讲起

根据对汇编的分析,我发现在这类构造API请求的函数中,都会引用一个未加密的字符串

1
2
adrp x1, #0xfffffffffebb9000
add x1, x1, #0xbe3, lsl #0

而这个字符串就是API Path的后半段,考虑到对方似乎没有对这个字符串加密的打算,我决定匹配这个字符串:

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
match inst.mnemonic() {
// ...
Mnemonic::Adrp if pattern_pos.is_none() => {
let imm = inst.op_immediate(1);
if imm >= input.len().try_into().unwrap() {
continue;
}
adrp_base_op = Some(imm);
}
Mnemonic::Add
if let Some(adrp_base) = adrp_base_op
&& pattern_pos.is_none() =>
{
adrp_base_op.take();
let ImmShiftedMove { imm: add_imm, lsl } = inst.op(2) else {
continue;
};
let addr = (adrp_base + ((add_imm as u64) << lsl)) as usize;
let pattern = "game/content_bundle".as_bytes();
let end = addr + pattern.len();
if end >= input.len() {
continue;
}
if &input[addr..end] == pattern {
pattern_pos = Some(dec.ip());
}
}
_ => {}
}

这个片段的做法很简单粗暴,就是搜索所有的Adrp+Add的组合,并取其目标地址的值,然后匹配对应的值是否为"game/content_bundle",这样,我们就得到了这两条指令的位置,由于对方程序仅在这个“函数”引用了这个字符串,所以我们得到的地址就在函数内

向上搜索

这里的标题有点歧义,实际上程序一直在记录形如STP X29, X30, ...的指令的位置,只不过在我们找到Adrp+Add组合前,我们的位置一直在更新

1
2
3
4
5
6
7
8
9
10
11
12
13
match inst.mnemonic() {
// ...
Mnemonic::Stp if pattern_pos.is_none() => {
let reg1 = inst.op_register(0);
let reg2 = inst.op_register(1);
if (reg1 == Register::X29 && reg2 == Register::X30)
|| (reg2 == Register::X29 && reg1 == Register::X30)
{
function_start_pos = Some(dec.ip());
}
}
/// ...
}

BL的处理

为了给外部调用填充NOP,我们需要定位这些外部调用的位置,观察到在匹配到Adrp+Add前,总是有三处BL,从匹配后到能取到Q1Q2值时,所有的BL均可沉默化

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
 0: bl #0xc69d0 # 第一处
4: adds xzr, x0, #0x10, lsl #0
8: b.cs #0x14970
c: orr x23, xzr, x0, lsl #0
10: subs xzr, x0, #0x17, lsl #0
14: b.cs #0x30
18: add x9, sp, #0x268, lsl #0
1c: ubfm w8, w23, #31, #30
20: orr x24, x9, #0x1
24: strb w8, [sp, #0x268]
28: cbnz x23, #0x54
2c: b #0x64
30: add x8, x23, #0x10, lsl #0
34: and x25, x8, #0xfffffffffffffff0
38: orr x0, xzr, x25, lsl #0
3c: bl #0xc68c0 # 第二处
40: orr x8, x25, #0x1
44: orr x24, xzr, x0, lsl #0
48: str x0, [sp, #0x278]
4c: str x8, [sp, #0x268]
50: str x23, [sp, #0x270]
54: add x1, sp, #0x350, lsl #0
58: orr x0, xzr, x24, lsl #0
5c: orr x2, xzr, x23, lsl #0
60: bl #0xc68d0 # 第三处
64: strb wzr, [x24, x23]
68: strh wzr, [sp, #0x250]
6c: adrp x1, #0xfffffffffebb906c # ADRP和ADD位置
70: add x1, x1, #0xbe3, lsl #0
74: add x8, sp, #0x238, lsl #0
78: add x0, sp, #0x268, lsl #0
7c: bl #0xfffffffffffd000c # 第四处
80: str xzr, [sp, #0x228]
84: str xzr, [sp, #0x220]
88: str xzr, [sp, #0x230]
8c: add x0, sp, #0x220, lsl #0
90: bl #0xffffffffff3a83c8 # 第五处
94: adrp x8, #0x1ee094
98: ldr x8, [x8, #0xf58]
9c: ldr x8, [x8]
a0: ldr x0, [x8, #0x48]
a4: ldr x8, [x0]
a8: ldr x8, [x8, #0x8]
ac: blr x8
b0: ldr x8, [sp, #0x38]
b4: orr x23, xzr, x0, lsl #0
b8: ldp x20, x22, [x8, #0x110]
bc: add x0, sp, #0x1e8, lsl #0
c0: add x1, sp, #0x238, lsl #0
c4: bl #0xc6950 # 第六处
c8: add x0, sp, #0x1d0, lsl #0
cc: add x1, sp, #0x250, lsl #0
d0: bl #0xc6950 # 第七处
d4: ldr q1, [sp, #0xc0]
d8: add x8, x22, x23, lsl #0
dc: movz w15, #0x262, lsl #0
# 直到提取出Q1 Q2,再也没有BL指令

所以我们可以创建一个临时的列表来存放这些地址

1
2
3
4
5
6
7
8
9
10
match inst.mnemonic() {
// ...
Mnemonic::Bl => {
temp_bl.insert(0, dec.ip());
if pattern_pos.is_none() {
temp_bl.truncate(3);
}
}
// ...
}

最后,找到那个MOV

一条十分具有辨识度的MOV指令吸引了我的注意力,虽然不清楚它为什么在这,但它却精准地出现在了我观测过的所有的C2常量相关函数中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
 0: ldr q3, [sp, #0xb0]
4: add x11, sp, #0x390, lsl #0
8: ldp q0, q1, [sp, #0x3f0]
c: movn w8, #0x12, lsl #0 # 没错,就是这条
10: str q3, [sp, #0x420]
14: ldr q3, [sp, #0xa0]
18: str xzr, [sp, #0x3e0]
1c: stur wzr, [x11, #0x57]
20: movz w11, #0x262, lsl #0
24: ldp q4, q5, [x14]
28: subs xzr, x0, #0x0, lsl #0
2c: str w11, [sp, #0x448]
30: madd w11, w0, w8, wzr
34: csel w8, w8, w11, eq
38: movi v2.2d, #0x0
3c: add x15, sp, #0x410, lsl #0

要匹配它也非常简单,它同时也是我们的终止信号:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
match inst.mnemonic() {
// ...
Mnemonic::Mov if pattern_pos.is_some() => {
let imm = inst.op_immediate(1) as i64;
if imm == -19 {
return Ok(FunctionSearchResult {
function_offset: function_start_pos.unwrap(),
adrl_offset: pattern_pos.unwrap() - 0x4,
terminal_offset: dec.ip() + 0x4,
fill_from: temp_bl.last().unwrap() - 0x4,
fill_to: *temp_bl.first().unwrap(),
});
}
}
// ...
}

最后,我们找到了5个位点:

  1. function_offset为函数的起始偏移,由STP X29, X30, ...推算而来
  2. terminal_offset为模拟的终止偏移,由MOVN推算而来
  3. fill_fromfill_to为填充的起始和终止偏移,由多个BL的起止位置覆盖
  4. adrl_offset仅作记录,无实际用途

所以?

这个方案的效果出奇的好,在测试的数个版本中,它无需机械匹配字节序列均可以找到目标函数并实施精准覆盖,没有出现曾经的因版本而过期的现象

具体源码可参阅cuscutaceae/scirpophaga