2022-游戏安全竞赛-安卓客户端方向WP
2022-04-29 11:47:33

APP启动外挂过程

首先打开apk,被BlackObfuscator混淆过

image-20220423111317272

这里可以提取代码执行一下获得switch路径,代码不是很多,结合CFG图直接动态分析了

系统开启全局debuggable magisk resetprop ro.debuggable 1 stop;start;

使用 am start -D -n "com.tencent.esp/.MainActivity" 让app等待调试器

ddms中开启method trace

使用jdb -connect com.sun.jdi.SocketAttach:hostname=127.0.0.1,port=8700恢复代码执行

MainActivity cinit

首先在主类的static块中加载了Putri的lib并初始化了一些值(这个库没用到?)

image-20220423135251256

MainActivity.onCreate

根据调用顺序分析代码

image-20220423135156667

736:

读取配置文件,判断是否存在配置,不存在则写入

image-20220423140209844

797:

通过以下代码申请权限

1
this.requestPermissions(new String[]{"android.permission.ACCESS_COARSE_LOCATION", "android.permission.ACCESS_FINE_LOCATION", "android.permission.READ_EXTERNAL_STORAGE", "android.permission.WRITE_EXTERNAL_STORAGE"}, 102);

962:

image-20220423140524569

获取root权限,获取文件路径

1696:

image-20220423140728929

检测APP能否绘制悬浮窗

2001:

image-20220423141203686

检查写入权限

MainActivity.onClick

image-20220423142112757

image-20220423143329010

sock1复制出来

image-20220423143408334

并给予777权限

调用 a 函数

image-20220423143042125

向 /Android/data/.tyb 写入 DO NOT DELETE

并将assert内几个文件复制出来(到app目录)

对sock1文件进行解密

image-20220423152304668

这两个函数不存在于这个类的源码中,是后加的

看一下两个函数

image-20220423152359744

image-20220423152409083

256字节为一块进行解密,看得出来是RC4,

h使用gamesec作为密匙初始化Sbox

m进行加解密运算

对sock1进行RC4运算获得一个elf文件

然后

image-20220423153146657

启动su向管道内写入命令启动刚才解密出的文件并启动服务

这里的f被修改过

image-20220423214540113

su运行的命令为/data/data/com.tencent.esp/files/sock1 [x] [y]

APP绘制过程

Overlay.onCreate

image-20220423153910855

该服务创建了一个悬浮窗并显示,同时创建了一个ESPView

EspView cinit

初始化Paint用于绘制

启动一个线程更新悬浮窗

image-20220423154150425

线程内通过image-20220423154611626

在指定间隔时间(或者超时后时间)调用postInvalidate()通知窗口重绘

通过学习AndroidView更新流程知晓窗口重绘会调用onDraw方法

ESPView onDraw

通过AS单步分析onDraw方法

在onDraw内判断窗口是否可见然后通过

1
p0.drawColor(i, PorterDuff$Mode.CLEAR);

清空内容

读取/sdcard/1A.txt

内容

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
"cube": [{
"name": "ThirdPersonCharacter",
"x": 471.47,
"y": 773.20
}, {
"name": "ThirdPersonCharacter2",
"x": 471.47,
"y": 773.20
}, {
"name": "ThirdPersonCharacter3",
"x": 1486.88,
"y": 667.73
}]
}

将name绘制在 (x,y上)

image-20220423155908961

此处 a 为绘制文本方法

DrawOn为Native方法(好像DrawOn没什么用)

二进制程序

脱壳

IDA打开发现存在壳,发现UE4!标记,怀疑是UPX,修复魔数image-20220423161314552

image-20220423161301519

同时发现结尾多余的hack字符,删掉

此时upx -d 提示

CantUnpackException: ElfXX_Ehdr corrupted

补齐ELF魔术头,从结尾找到EC F9 04 00修复上面的长度

image-20220423180228333

还是旧提示

换用upx devel分支修改代码编译执行

image-20220423180510295

直接通过了。。

这里有两个问题,一个是要用新版UPX 一个是要补好ELF魔术头跟被修改的长度数据

保护分析

脱壳后可以执行,详细分析

image-20220423191023582

此处进入主函数

image-20220423191313857

OLLVM 寄

通过OBPO还原控制流(OBPO的原理是修改ida微码,运行程序会改回微码)

可以看到还是有文本xor加密存在,这里写了个脚本反混淆,将IDA生成的伪代码传入即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def deObf(str):
find = re.findall('byte_(\w+) \^= (\w+)u;',str)
for group in find:
ea = int(group[0],16)
ch = ida_bytes.get_byte(ea)
ch = ch ^ int(group[1],16)
if ch != 0:
if(ch == '/'):
idc.set_name(ea,"b_" + hex(ea) + "_xiegang")
elif(ch == '-'):
idc.set_name(ea,"b_" + hex(ea) + "_henggang")
else:
idc.set_name(ea,"b_" + hex(ea) + "_" + chr(ch))
else:
idc.set_name(ea,"b_" + hex(ea) + "_blank")

效果

image-20220423200247795

main中申请了0xc8字节的空间存放动态获取的函数地址

image-20220423212742009

使用刚才的脚本恢复一下名称,然后用以下脚本给结构体命名

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import re
def readAt(addr):
name = ''
while True:
find = re.findall('b_0x.+_(.+)',ida_name.get_name(addr))
if find == []:
break;
#print(find[0])
if find[0] == 'blank':
break;
else:
name += find[0][0];
addr += 1
return name

def de(str):
struid = ida_struct.get_struc_id("struct_func_pointers")
find = re.findall('v(.+) = .+.b_(\w+)_.+\n.+\n.+dword(.+) = v.+;',str)
for group in find:
ea = int(group[1],16)
off = int(group[2],16)
set_member_name(struid,off,readAt(ea))
print(readAt(ea),hex(ea),hex(off))

效果如下

image-20220423212914079

有几个没识别到,字符串解密的伪代码不同,这里下个断点依据文本直接补上(这里有个更简单的办法,不去静态解密,直接下断然后用idapyhton去读内存中解密好的值并且更加通用,绕路了)

api偏移

image-20220423213947270

初始化指针表之后读取x y参数,在loop(0x26378)函数内使用pthread_create创建线程,失败则退出

image-20220424023840989

image-20220424024005055

线程所执行的函数为一个死循环,调用检测函数后休眠

image-20220424024055944

检测函数被高强度控制流混淆以及代码膨胀,OBPO无法进行重建函数

这里使用frida跟踪函数调用来辅助分析(脚本在附件,hook了指针表内所有函数)

首先将创建线程替换成直接运行便于分析

image-20220424024346486

检测点1(0xe568)

解密文本后执行 sub_27420

image-20220424025303473

其中调用 popen函数执行ls /proc/self/fd -all r读取文件信息后pclose

使用strstr检测返回文本其中是否存在 frida injector字样

存在则获取自己pid并kill

image-20220424025042636

若kill后代码还在执行(防止直接hook kill?)则exit(1)

检测点2 (0x15820)

opendir 打开 /data/local/tmp目录(调用函数)

readdir 获取目录下内容

strcmp 与 re.frida.server进行比较 (也就是检测是否存在frida临时文件)

存在则获取自己pid并kill

若kill后代码还在执行(防止直接hook kill?)则exit(1)

检测点3 (0xd83c)

这是一个信号反调试

这个函数注册了SIGTRAP的信号处理程序,并申请了一块内存放入代码执行

image-20220424034708991

代码只有8字节

image-20220424033153660

BKPT指令产生一个软中断,程序收到SIGTRAP

信号处理函数将501bc处数据解密并写入502ec

image-20220424033607070

写入数据为

image-20220424044658347

也就是把BKPT指令替换成了mov r0 ,#1

此时返回值为1

当返回值不为1时置检测不通过

绕过:修改状态为已解密并将数据替换成信号处理函数内写入的数据

image-20220424051832479

检测点4 (0x16fcc)

读取/proc/self/task,其中tid文件大于2个就获取自己pid并kill

若kill后代码还在执行则exit(1)

image-20220424040015743

绕过:readdir返回0

image-20220424051740887

检测点5 (0x183c0)

这个函数的代码膨胀量巨大,可以考虑trace排除掉无用部分,实际上这个函数没有什么逻辑,只有部分有用代码,配合frida分析可知

该函数执行popen:

1
2
cat /proc/net/tcp |grep :5D8A
cat /proc/net/tcp |grep :69A2

检测IDA,Frida默认端口号,并计算结果数目发现存在这两个端口则获取自己pid并kill

若kill后代码还在执行则exit(1)

绕过:修改程序内保存的Frida端口,IDA则用-p切换端口

image-20220424055948547

至此主程序的5个检测函数分析完成

内部程序加载

内存加载sock流程

首先申请0x1c000大小的内存并给予可执行权限

image-20220424090547897

随后将申请到的空间及文件句柄传给 0x7cf0函数image-20220424090945244

该函数中调用了0x67dc解密sock002文件

image-20220424091125906

过程为将文件读入内存使用RC4进行解密

image-20220424091220172

密匙为TencentGameSecurity

程序将几个部分解密后放在内存中并解析结构

image-20220424092513495

判断解析的符号名是否为 main1

解析到main1后调用该函数

image-20220424092642196

传入x,y及指针表作为参数

至此外挂程序正式被调用

修复外挂ELF

解密几个文件后发现可能是将sock001某些区段抽取后进行加密的

根据对比正常ELF文件可知sock002为ProgramHeader

从正常ELF文件中复制一份文件头并将002放在文件头后面

image-20220424091625853

该头可以识别

004+003长度符合001中一段空位,参照其他ELF将两个bin合并贴回ELF中

005同样有空位,对比正常文件内容合理,贴之

随后修复ELF头

根据shstrtab找到节区所在位置0x1a48c

并修改e_shoffimage-20220424092129326

image-20220424092053989

根据文件长度减去e_shoff后除以节头大小40 得到节数量并写入ELF Header

image-20220424092258588

至此elf可以被正常解析

外挂本体分析
获取游戏PID

main1将a3参数保存到全局变量后调用sub_8490函数(传入 x y)

sub_8490中调用sub_6BF0获取游戏进程ID

该函数内有两种获取方法,第一种是使用popen运行

pidof com.YourCompany.ThirdPerson

若运行失败则打开 /proc 目录遍历子目录读取cmdline文件

image-20220424100517617

寻找游戏进程,最终通过atoi返回数字pid

读取游戏libUE4.so基址

调用sub_69c0函数打开游戏进程/proc/pid/maps文件,fgets逐行读取,strstr查找libUE4.so

匹配到后使用strtoull取出基址

image-20220424101524837

读取内存方式

image-20220424104430787

读取内存的函数有三种模式,通过rand生成随机数选择

rand % 3 = 0 时调用process_vm_readv

rand % 3 = 1 时调用syscall 提供系统调用号调用 (376)vm_readv系统调用

rand % 3 = 2 时调用 sub_75F0()函数

​ 其中申请内存给予可执行权限并将base64编码的shellcode写入

DcCg4fAALekAcKDhAQCg4QIQoOEDIKDheACc6AAAAO/wAL3oHv8v4Q==

​ 然后将指向该shellcode的指针赋值给pointer表中的第13个

image-20220424104911812

Shellcode Disassembly

1
2
3
4
5
6
7
8
9
10
0x0000000000000000:  0D C0 A0 E1    mov  ip, sp
0x0000000000000004: F0 00 2D E9 push {r4, r5, r6, r7}
0x0000000000000008: 00 70 A0 E1 mov r7, r0
0x000000000000000c: 01 00 A0 E1 mov r0, r1
0x0000000000000010: 02 10 A0 E1 mov r1, r2
0x0000000000000014: 03 20 A0 E1 mov r2, r3
0x0000000000000018: 78 00 9C E8 ldm ip, {r3, r4, r5, r6}
0x000000000000001c: 00 00 00 EF svc #0
0x0000000000000020: F0 00 BD E8 pop {r4, r5, r6, r7}
0x0000000000000024: 1E FF 2F E1 bx lr

可见这是使用svc指令产生0号中断,而零号中断代表系统调用

此处通过直接中断调用vm_readv系统调用

image-20220424105153173

解码shellcode函数返回后调用shellcode执行

arm下的系统调用号image-20220424113544243

透视实现

偏移总览:部分来源于SDK,部分为分析libUE4二进制文件及阅读源码获得

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
GWorld =  [libUE4.so + 0x491e6f0]
GNames = [libUE4.so + 0x48711B4]
Level(PersistentLevel) = [GWorld + 32]
Actors = [Level + 112]
ActorsCount= Level + 116
AcotorID = [Actors[i] + 16]
FName = GNames[AcotorID/4000][(AcotorID%4000)*4]
name = FName + 8
RootComponent = [Actors[i] + 288]
RelativeLocation = RootComponent + 256
Player = [libue4Base + 0x4907EA0]
PlayerController = [Player + 32]
PlayerCameraManager = [PlayerController + 780]
MinimalViewInfo = PlayerCameraManager + 800
| Vector Location + 800
| Rotator Rotation + 812
| float FOV + 824

该程序只有一个重要的函数sub_8490,该函数是一个死循环,大可分为两部分,写出数据文件以及从游戏中读取数据更新缓存

image-20220424131240215

第一次进入此函数时会将几个基址读取出来

透视函数的第一部分:写出文件

image-20220424132814755

内容比较简单,从v62所申请的内存区域读出角色名,屏幕上的x,y坐标并拼装成json写入文件,代码也没什么混淆不多介绍

写出过程Frida记录

image-20220424132954225

透视函数的第二部分:读取数据计算屏幕坐标

比较重要的是第二部分

image-20220424133128915

遍历Actor对象的ID,使用ActorID从GNames中读取到对应的FName,在FName + 8 的地址读出字符串(具体偏移在偏移总览中)

判断名字是否符合一些条件,不符合则跳过

image-20220424133652983

这里判断了是否为ThirdPersonCharacter4

测试可知ThirdPersonCharacter4是玩家操控的角色

这里判断有两个作用,一是不去绘制自己的名称,二十通过读取自己的组件获取了坐标(12字节向量但是好像没用?)

之后判断都不是目标Character则跳过

image-20220424135304784

如果是目标 Character

首先读取相机的矩阵(个人理解是矩阵,实际上是一个)MinimalViewInfo 其中包含了相机的坐标,角度 FOV

之后读取Actor组件,从组件读取 Character绝对位置

将取得数据

1
2
3
4
	目标绝对坐标  Vector 
​ 相机 Location
​ 相机 Rotation (都在MinimalViewInfo中)
​ 屏幕宽,高

交给函数 sub_7ed0 处理

image-20220424135757839

该函数中进行了一些坐标的转换以及图形学矩阵操作,最终算出目标在相机平面内的二维坐标

(即已知相机坐标,摄像头角度,其他人坐标就可算出其他角色在2D屏幕上的显示坐标,由于游戏内存在遮挡关系, 自己获得坐标直接画上去就实现了透视效果)

image-20220424140002279

将结果写在a1中

image-20220424141056484

再次读取目标的绝对坐标,将数据写入进第一部分读取的内存区域,其中格式为

名字字符串指针 4字节

玩家绝对位置指针 4字节(没用到)

float x 4字节

float y 4字节

16字节为一组

image-20220424142315190

遍历完成后跳转至该函数第一部分,

该函数第一部分读取此处写入的N组数据后写入文件,由APP读取文件并通过悬浮窗服务在屏幕的x,y位置上绘制名字文本。

Prev
2022-04-29 11:47:33
Next