啊嘞……

我觉得可以……的吧……?

(我其实不知道我为什么要写这些……?)

正片……?

挣脱

这是一个平平无奇的参数 (但是没了它登录不了) ,如下所示:

1
X-Random-Challenge: 99fkG54BAAC/1sAr/ZD2GhaqPorsBaGyRsgNpeeU1sK72U2FSgB+9u99Cf43ohO+bhKaNgpqk6E=

解码之后,我们得到了以下内容:

1
F7D7E41B9E010000BFD6C02BFD90F61A16AA3E8AEC05A1B246C80DA5E794D6C2BBD94D854A007EF6EF7D09FE37A213BE6E129A360A6A93A1

这是一个固定56字节的内容,注意到前8字节,是一个UNIX时间戳,而后面还有48字节,我们需要继续揭露……

遮蔽

我尝试了一些可能性,例如这是不是什么SHA256?或者Blowfish?又或者是TEA之类的……后来均以失败告终……没办法,大抵只能硬着头皮逆

在简单的静态分析之后,我找到了这个函数的地址……这是一个非常大的函数,足足有0x161AC长,内部包含了严重的函数内联和循环展平,导致难以分析,所以,我开始用frida试图dump出一些有用的东西

我使用了一个惯用的方法,也就是hook void memcpy(void *dest, const void *src, size_t n),然后dump出src的值,再加上一点调用栈,以此试图找到这段内容发生的地方……

消散

在dump中,我发现了一些有意思的内容,例如:

1
2
3
4
5
6
             0  1  2  3  4  5  6  7  8  9  A  B  C  D  E  F  0123456789ABCDEF
--------b0 bc 8f 85 05 9e 01 00 00 2f 73 74 65 65 70 74 65 ......../steepte
--------c0 6e 6e 69 73 2f 34 30 2f 67 61 6d 65 2f 63 6f 6e nnis/40/game/con
--------d0 74 65 6e 74 5f 62 75 6e 64 6c 65 31 5c 18 c9 32 tent_bundle1\..2
--------e0 0e 38 60 ee 04 f0 14 38 39 e7 49 88 17 9c 5d 2a .8`....89.I...]*
--------f0 6f 3e 0d 91 ba a1 5a a9 57 ba b5 o>....Z.W..

前8字节很明显就是时间戳,后面跟上了一个Path,和一个32字节的何意味数据

找到base64发生的地方也比我想象中简单 (不是哥们你怎么把base64生成也内联进去了……) ,第一段内容出现在target.so!0x86dff4(说的更确切一点,是在target.so!fun+0x157f8附近),这样的话,我就可以稍微减少一下工作量……

另外这个函数是真的不当人,它内部采用了比较狗屎的结构,数据流有点难分析(用那啥一点的话来说,就是有一堆不透明谓词),我用stalker打印出了函数运行的路径,并丢到IDA里上色,过滤掉了一堆碍眼的大坨玩意

遏止

经过许多天的打桩 摸鱼 ,我大概是定位到了两段关键 (又长得几乎一模一样) 的汇编 (你说这不是编译器inline我都不信(x)) (以下汇编从IDA导出),已经做好了部分注释:

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
loc_86C068
ADD X8, X22, X19
ADD X0, X8, #0xA8 ; dest
LDR W8, [SP,#0x5C0+var_544]
ADRL X9, unk_19592D4 ; 填充为空46字节内容
ADD X1, X9, W10,UXTW ; src
SUB W2, W8, W10 ; n
BL .memcpy
LDR W8, [SP,#0x5C0+var_150]
LDR W9, [SP,#0x5C0+var_150+4]
UBFX X12, X8, #3, #7
CMN W8, #0x51
ADD W10, W8, #0x50
CINC W8, W9, HI
CMP W12, #0x76
STR W10, [SP,#0x5C0+var_150]
STR W8, [SP,#0x5C0+var_150+4]
B.CC loc_86DBB4
ADD X19, SP, #0x5C0+var_150
SUB W9, W21, W12
ADD X8, X19, X12
ADD X0, X8, #0xA8 ; dest
MOV W2, W9 ; n
ADD X1, SP, #0x5C0+var_240 ; src=SP+0x3029
STR W9, [SP,#0x5C0+var_454]
BL .memcpy ; 拷贝了29 30 58 02 00 00 00 00 00 00
; 这10字节到46字节的缓冲区
ADD X8, X19, #0x128
loc_86C0D4
LDR W9, [X20] ; X20就是新开辟的缓冲区,储存了原始的Timestamp+path+hash
STUR W9, [X20,#-0x80] ; 把这块内存复制到高位内存
ADD X20, X20, #4
CMP X20, X8
B.CC loc_86C0D4 ; X20就是新开辟的缓冲区,储存了原始的Timestamp+path+hash
LDR W13, [SP,#0x484]
LDR W12, [SP,#0x488]
LDR W14, [SP,#0x48C]
LDR W6, [SP,#0x478]
LDR W11, [SP,#0x47C]
AND W10, W12, W13
AND W8, W14, W12
AND W9, W13, W6
EOR W8, W8, W9
EOR W9, W14, W6
EOR W10, W14, W10
MOV W17, W14
STR W14, [SP,#0x154]
LDR W14, [SP,#0x490]
LDR W16, [SP,#0x480]
...省略无数汇编

下面是冗长而无序的汇编,总的来看,它把栈上内存的值读到寄存器之后几乎只做几个操作:MOVANDEOREXTR(用来循环移位的),然后写到特定的栈空间里

这段汇编的特别之处在于,在loc_86C0D4代表的这一大坨逻辑里,它完全没有对堆空间进行任何操作,所有的操作完完全全都在栈上进行,于是我进而查看了它对栈空间的访问情况……

首先我dump出了它的栈空间(顺便一提这玩意的栈空间有0x5C0长我的天哪),由于函数和栈空间太大了,我又写了一段简单的Kt来稍微过滤一下它到底访问了哪些栈空间……(不要问咱为什么不用Py,因为咱不会哈哈)

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
import java.io.File

fun main() {
val file = File("sample.s")
val ldMemory = mutableSetOf<String>()
val stMemory = mutableSetOf<String>()
val dynamicLdMemory = mutableSetOf<String>()
val dynamicStMemory = mutableSetOf<String>()
for (line in file.readLines()) {
if (line.isEmpty()) {
continue
}
val regex = "(?<=SP,#).*?(?=])".toRegex();
if (line.startsWith("ST")) {
val find = regex.findAll(line).firstOrNull()?.value ?: throw Exception("nope: $line")
dynamicStMemory.add(find)
stMemory.add(find)
} else if (line.startsWith("LD")) {
val find = regex.findAll(line).firstOrNull()?.value ?: throw Exception("nope $line")
if (!dynamicStMemory.contains(find)) {
dynamicLdMemory.add(find)
}
ldMemory.add(find)
}
}
val pureLdMemory = ldMemory - stMemory
val pureStMemory = stMemory - ldMemory
println("Ld: ${ldMemory.hexSorted()}")
println("St: ${stMemory.hexSorted()}")
println("pureLd(${pureLdMemory.size}): ${pureLdMemory.hexSorted()}")
println("dynLd(${dynamicLdMemory.size}): ${dynamicLdMemory.hexSorted()}")
println("pureSt(${pureStMemory.size}): ${pureStMemory.hexSorted()}")
}

fun Set<String>.hexSorted(): List<String> {
return toList().map { it.substring(2).toInt(16) }.sorted().map { it.toString(16) };
}

注意
这么做显然不太合适,因为它直接忽略了寄存器的宽度,所以这段小脚本取到的只是偏移地址,而非每一个被读写的字节;不过在这里,对于大多数操作的寄存器都是W寄存器来看,它已经够用了

最后出来的结果如下:

1
2
3
4
5
Ld: [70, 7c, 80, 84, 88, 8c, 90, 94, 98, 9c, a0, b0, c0, d0, d8, e0, e8, f0, f8, 108, 110, 118, 124, 128, 12c, 130, 13c, 140, 144, 148, 14c, 150, 154, 158, 15c, 160, 16c, 478, 47c, 480, 484, 488, 48c, 490, 494, 498, 49c, 4a0, 4a4, 4a8, 4ac, 4b0, 4b4, 4b8, 4bc, 4c0, 4c4, 4c8, 4cc, 4d0, 4d4, 4d8, 4dc, 4e0, 4e4, 4e8, 4ec, 4f0, 4f4, 4f8, 4fc, 500, 504, 508, 50c, 510, 514]
St: [70, 7c, 80, 84, 88, 8c, 94, 98, 9c, a0, b0, c0, d0, d8, e0, e8, f0, f8, 108, 110, 118, 124, 128, 12c, 130, 13c, 140, 144, 148, 14c, 150, 154, 158, 15c, 160, 398, 399, 39a, 39b, 39c, 39d, 39e, 39f, 3a0, 3a1, 3a2, 3a3, 3a4, 3a5, 3a6, 3a7, 3a8, 3a9, 3aa, 3ab, 3ac, 3ad, 3ae, 3af, 470, 478, 47c, 480, 484, 488, 48c, 490, 494]
pureLd(34): [90, 16c, 498, 49c, 4a0, 4a4, 4a8, 4ac, 4b0, 4b4, 4b8, 4bc, 4c0, 4c4, 4c8, 4cc, 4d0, 4d4, 4d8, 4dc, 4e0, 4e4, 4e8, 4ec, 4f0, 4f4, 4f8, 4fc, 500, 504, 508, 50c, 510, 514]
dynLd(42): [90, 16c, 478, 47c, 480, 484, 488, 48c, 490, 494, 498, 49c, 4a0, 4a4, 4a8, 4ac, 4b0, 4b4, 4b8, 4bc, 4c0, 4c4, 4c8, 4cc, 4d0, 4d4, 4d8, 4dc, 4e0, 4e4, 4e8, 4ec, 4f0, 4f4, 4f8, 4fc, 500, 504, 508, 50c, 510, 514]
pureSt(25): [398, 399, 39a, 39b, 39c, 39d, 39e, 39f, 3a0, 3a1, 3a2, 3a3, 3a4, 3a5, 3a6, 3a7, 3a8, 3a9, 3aa, 3ab, 3ac, 3ad, 3ae, 3af, 470]

我们可以发现,除开一堆自己先写再读的临时变量以外,其余大部分都是十分连续的读取,通过读取这些地址的值,结合原有的汇编,我们有以下发现:

  1. 在LD中,有两个不连续的偏移,90这个值目前看来永远是0x00000000;而16c这个值只关系到一个根本不会复制内容的memcpy,所以理论上也不重要,这还挺奇怪的,不过更有可能是我没有测试一些特殊情况
  2. 这些地址可以分为四部分,“只读不写”、“只写不读”、“读了又改”,“写了再读”这四种,其中“写了再读”的应该是临时变量(寄存器不够用了应该是),直接忽略;“只读不写”(498-514)的我觉得是纯输入;“只写不读”(398-3af)的是纯输出;“读了又改”(478-494)的比较棘手,但是根据静态分析,这一段32字节的内容其实是一个存在so文件中的常量,地址是target.so!0x4BE004,具体值是88 6A 3F 24 D3 08 A3 85 2E 8A 19 13 44 73 70 03 22 38 09 A4 D0 31 9F 29 98 FA 2E 08 89 6C 4E EC,这段常量的意义不明
  3. loc_86C068这一段内,它向代表连续输入的栈空间的末尾写入了一个常量:29 30 58 02 00 00 00 00 00 00,这个常量的意义不明
  4. loc_86C0D4这一段内容其实没有依赖任何通用寄存器的初始值,在这一段内容内使用的所有的通用寄存器在使用前都被覆盖上了初值,而这些初值要么是立即数,要么是从栈空间读出来的,所以,我们可以有一个很大胆的想法

……我们就完全可以在unicorn里模拟这段逻辑而无需理解它:

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
use std::{fs, iter};

use unicorn::{Arch, MemHookType, Mode, Protection, Register, RegisterARM64, Unicorn};

fn main() {
let runnable_bin = fs::read("dump.bin").unwrap();
let input_bin = fs::read("input+0x478.bin").unwrap();
let mut uc = Unicorn::new(Arch::ARM64, Mode::LITTLE_ENDIAN).unwrap();
let base_addr = 0x1000_0000;
let base_size = 0x10000;
let stack_addr = 0x4000_0000;
let stack_size = 0x40000;
uc.mem_map(base_addr, base_size, Protection::ALL).unwrap();
uc.mem_map(stack_addr, stack_size, Protection::ALL).unwrap();
uc.mem_write(base_addr, &runnable_bin).unwrap();
uc.reg_write(RegisterARM64::SP as i32, stack_addr).unwrap();
uc.add_mem_hook(
MemHookType::MEM_READ_UNMAPPED,
0,
!0,
|uc, mem_type, address, size, value| {
let pos = uc.reg_read(RegisterARM64::PC as i32).unwrap();
println!("oops: {:016X}:{:#08X} at {:#016X}", address, size, pos);
uc.emu_stop().unwrap();
false
},
)
.unwrap();
// uc.mem_write(stack_addr + 0x16c, &[0x0A]).unwrap();
// 也就是这个时候,我发现0x16c似乎不会影响结果
uc.mem_write(stack_addr + 0x478, &input_bin).unwrap();
dump(&uc, stack_addr, 0x600);
uc.emu_start(base_addr, base_addr + runnable_bin.len() as u64, 0, 0)
.unwrap();
dump(&uc, stack_addr, 0x600);
}

fn dump(uc: &Unicorn, start: u64, len: u64) {
let mut mem = vec![0u8; len as usize];
uc.mem_read(start, &mut mem).unwrap();
dump_print(&mem, start, len);
}

fn dump_print(mem: &[u8], start: u64, len: u64) {
println!(" 0 1 2 3 4 5 6 7 8 9 A B C D E F 0123456789ABCDEF");
let mut cursor = 0;
while cursor < len {
let base_str = format!("{:016X}", start + cursor);
let hexes: Vec<_> = mem
.iter()
.skip(cursor as usize)
.take(16)
.map(Option::from)
.collect();
let len = hexes.len();
let hexes: Vec<_> = hexes
.into_iter()
.chain(iter::repeat_n(Option::None, 16 - len))
.collect();
print!("{} ", base_str);
for it in hexes {
match it {
Some(u) => print!("{:02X}", *u),
None => print!("--"),
}
print!(" ");
}
println!();
cursor += 16;
}
}

通过将预定值入栈的特定部位,以上模拟成功地还原产出了那开头48字节的后半段24字节的内容,至于前半段,我需要一些时间来进一步复现

悲怆

那么,代价是什么呢?

很明显,虽然我们复现出了算法,但是有很多细节,我们并没有理解,例如神秘的29 30 58 02 00 00 00 00 00 0088 6A 3F 24 D3 08 A3 85 2E 8A 19 13 44 73 70 03 22 38 09 A4 D0 31 9F 29 98 FA 2E 08 89 6C 4E EC,那个块内的,似乎取自圆周率的立即数,还有将一些数据向高位-0x80栈空间复制输入的操作,一切都太蹊跷了

并且,我初步推断,这是一个白盒加密……虽然效果可能并不强

下一步,我会分析一下先前版本的算法——有可能很类似,只是改了一个常数,也有可能是改了一堆常数……

我也可能或许会先将这个程序测试一下——当然,很有可能会在下一个版本爆炸掉

碎碎念……

上面的文字看起来很短,流程应该还算清晰,但是其实我在插桩&缩小分析范围&反复调脚本&在很奇怪的网络环境下连接frida(?)&很多杂七杂八的事情上还是花掉了很多时间……毕竟不是专业干这个的……呜喵……

勤工俭学的工资还没发……

体育课的定向跑好好玩但是好累……

上次种的玉米和花生不知道长得怎么样了……

傻逼GIANT一个自行车水壶支架居然要75块钱……

鱼鱼,Temmie,easy,废,滢,嘿嘿……

明天早上还要上课……

不过事到如今,也只能睡觉了ww