2021 腾讯游戏安全技术复赛 android 客户端安全 wp
点开发现仍然是一个unity 工程,版本号2018.3.1f,后端il2cpp,global-metadata.dat 被加密,libunity.so 和 libil2cpp.so 内有壳最首先是libunity.so、libil2cpp.so、libsec2021.so 的 ELF 文件头被恶意破坏了(但是libmain.so 的没有),IDA 无法分析。结合 readelf 给的信息(或者对比libunity.so、libmain.so,或者对比unity 2018.3.1f官方 libunity.so 与这个 libunity.so的文件头),可直接修复。
然后是分析反调试策略的绕过。Libsec2021 中没有保护好 ida 的调试端口 23946,直接搜代码就能搜到:
$ objdump -D libsec2021.so | grep \#23946
1bf30: 8a 1d 05 e3 movw r1, #23946
这样就直接定位到了 ida 反调试的地方,根据静态分析交叉引用,结合动态调试,ida 不难发现出现问题的地方 1B5F0
经过一定时间分析,不难发现其中有一个疑似与反调试和检测高度相关的变量 debug_related 。只要经过了他所在的那一句,程序就会崩溃退出。
此外,在摸索过程中,我发现一个有趣的现象。利用他可以快速的暴露当前是哪一地方检测没通过。具体而言,当libsec2021.so 被 patch 之后,下面两句的 BLX 就会出现野指针,然后跑飞出现异常。如果观察崩溃时系统的 dump 记录,很快就能知道是哪里出问题了。这个看起来是这个壳设计上的失误。例如,下面是程序崩溃时系统的 dump:
当程序跑飞时的瞬间,返回值 BL 就是跑飞的地方。
当前libsec2021的基是c1aed000,lr = c1b11604,一减就是出问题地方的RVA
>>> hex(0xc1b11604 - 0xc1aed000)
'0x24604'
此外,除了 patch 程序,修改 debug_related 的值,也会造成上述的崩溃且被系统记录,通过查看日志很快就可以绕过。
在试图绕过的时候,我发现不能直接 patch 程序,因为在最开始的时候程序用他的代码算了一些东西(通过打硬件断点不难发现),例如下面是其中一处:
当程序运行到此处内容被 patch 导致计算结果变化时。后面的解壳代码会出错
基于上述考虑,我使用了一种动态改内存的方法绕过反调。我写了一个命令行程序,解壳完毕游戏启动之后就启动。一上来先把 debug_related (004B230) 修改了。然后根据日志把其他具备反调试策略的代码的修改也一一加入,最后知道 ida/gdb attach 之后不会报错位置。写内存是通过读写 /proc/pid/mem 文件实现的,主要代码如下
void ipatch(int mem_fd, unsigned long long addr, unsigned char old, unsigned char new, int dir)
{
unsigned char buf1[] = {0, 0};
unsigned char buf2[] = {0, 0};
unsigned char buf3[] = {dir ? old : new, 0};
lseek64(mem_fd, addr, SEEK_SET);
read(mem_fd, buf1, 1);
lseek64(mem_fd, addr, SEEK_SET);
write(mem_fd, buf3, 1);
lseek64(mem_fd, addr, SEEK_SET);
read(mem_fd, buf2, 1);
printf("%s%08llx: %02x -> %02x\n", dir ? "old" : "new", addr, buf1, buf2);
}
int main(int argc, char *argv[])
{
//…
sprintf(mem_file_name, "/proc/%s/mem", argv);
mem_fd = open(mem_file_name, O_RDWR);
long long addr = findaddr(argv, "sec2021", "00000000");
if (addr != -1)
{
printf("start patch ...\n");
ipatch(mem_fd, addr + 0x01B541 - 1, 0x2A, 0x00, 0);
ipatch(mem_fd, addr + 0x01B543 - 1, 0x00, 0xA0, 0);
// 省略若干,都可以根据崩溃日志分析得到
ipatch(mem_fd, addr + 0x00024602, 0x2F, 0xA0, 0);
ipatch(mem_fd, addr + 0x00024606, 0xE0, 0x00, 0);
ipatch(mem_fd, addr + 0x0004B230, 0x9f, 0x12, 0);
ipatch(mem_fd, addr + 0x0004B231, 0x13, 0x34, 0);
ipatch(mem_fd, addr + 0x0004B232, 0x79, 0x56, 0);
ipatch(mem_fd, addr + 0x0004B233, 0x66, 0x78, 0);
printf("patch done %d ...\n", 0);
}
return 0;
}
具体代码详见 bypass.c,编译命令为
armv7a-linux-androideabi18-clang /tmp/bypass.c -llog -o /tmp/bypass
以root 权限运行bypass 程序(程序接受一个命令行参数,为游戏进程 pid)后效果如下:
可见确实起了反反调试的作用,strace 不再会导致程序退出了。IDA 和 gdb 也是一样的。至此反调试部分介绍完毕。
接下来分析游戏逻辑。由于使用了 il2cpp,且libil2cpp.so 有壳,global-metadata.dat 也被加密,因此接下来常见的思路是恢复 global-metadata.dat,脱壳 libil2cpp.so,然后上 il2cppDump 获取 cs 头文件,从而知道后面该看什么地方。但是针对这个问题,我发现一个更简单的思路。
Libil2cpp.so 脱壳还是照常,结合 /proc/pid/maps 和 /proc/pid/mem 很容易搞定。见 dump.c。dump 完了之后还要再修一下文件头。
接下来是逻辑的分析。我在尝试的时候发现了一种基于string 的方法。具体而言,在 il2cpp plt 表的 strlen 函数上下断点:
(gdb) x/5i (0xbea2e000 + 0x8FD78)
0xbeabdd78 <strlen@plt>: add r12, pc, #6291456 ; 0x600000
0xbeabdd7c <strlen@plt+4>: add r12, r12, #811008 ; 0xc6000
0xbeabdd80 <strlen@plt+8>: ldr pc, ! ; 0x5e0
0xbeabdd84 <_Znwj@plt>: add r12, pc, #6291456 ; 0x600000
0xbeabdd88 <_Znwj@plt+4>: add r12, r12, #811008 ; 0xc6000
(gdb) hb *(0xbea2e000 + 0x8FD78)
Hardware assisted breakpoint 1 at 0xbeabdd78
(gdb) command
Type commands for breakpoint(s) 1, one per line.
End with a line saying just "end".
>x/s $r0
>c
>end
(gdb) c
Continuing.
Thread 14 "UnityMain" hit Breakpoint 1, 0xbeabdd78 in strlen@plt () from target:/data/app/com.personal.flappybird-5yocNuCl7GDoMAhFUkMXKg==/lib/arm/libil2cpp.so
0xbec2ed84: "UnityEngine.Object::Destroy(UnityEngine.Object,System.Single)"
Thread 14 "UnityMain" hit Breakpoint 1, 0xbeabdd78 in strlen@plt () from target:/data/app/com.personal.flappybird-5yocNuCl7GDoMAhFUkMXKg==/lib/arm/libil2cpp.so
0xbec2ed84: "UnityEngine.Object::Destroy(UnityEngine.Object,System.Single)"
// 省略
Thread 14 "UnityMain" hit Breakpoint 1, 0xbeabdd78 in strlen@plt () from target:/data/app/com.personal.flappybird-5yocNuCl7GDoMAhFUkMXKg==/lib/arm/libil2cpp.so
0xbe3329a4: "EndGame"
Thread 14 "UnityMain" hit Breakpoint 1, 0xbeabdd78 in strlen@plt () from target:/data/app/com.personal.flappybird-5yocNuCl7GDoMAhFUkMXKg==/lib/arm/libil2cpp.so
0xbe3329ac: "GameOver"
Thread 14 "UnityMain" hit Breakpoint 1, 0xbeabdd78 in strlen@plt () from target:/data/app/com.personal.flappybird-5yocNuCl7GDoMAhFUkMXKg==/lib/arm/libil2cpp.so
0xbe3329b5: "Replay"
// 省略
可以看到很多字符串都出现了。其中 GameOver 意味着判定上了,游戏失败,是赛题中需要处理的地方。因此在 "GameOver" 下硬件断点
^C
Thread 1 "onal.flappybird" received signal SIGINT, Interrupt.
0xea621394 in __epoll_pwait () from target:/apex/com.android.runtime/lib/bionic/libc.so
(gdb) dis
(gdb) awatch *(0xbe3329ac)
Hardware access (read/write) watchpoint 2: *(0xbe3329ac)
(gdb) c
Continuing.
// 操作游戏,当撞上的时候,立马就出现下面的一句
Thread 14 "UnityMain" hit Hardware access (read/write) watchpoint 2: *(0xbe3329ac)
Value = 1701667143
0xea5e6568 in strcmp_a15 () from target:/apex/com.android.runtime/lib/bionic/libc.so
(gdb)
硬件断点表明:读 "GameOver" 的时候很有就是判定失败时的逻辑,因为每次撞上(不管是柱子还是地板)都会执行这一句。
不妨根据调用栈
(gdb) bt
#00xea5e6568 in strcmp_a15 () from target:/apex/com.android.runtime/lib/bionic/libc.so
#10xc0dbe48c in ?? () from target:/data/app/com.personal.flappybird-5yocNuCl7GDoMAhFUkMXKg==/lib/arm/libunity.so
#20xc0db0884 in ?? () from target:/data/app/com.personal.flappybird-5yocNuCl7GDoMAhFUkMXKg==/lib/arm/libunity.so
#30xc0da80d8 in ?? () from target:/data/app/com.personal.flappybird-5yocNuCl7GDoMAhFUkMXKg==/lib/arm/libunity.so
#40xc0da8084 in ?? () from target:/data/app/com.personal.flappybird-5yocNuCl7GDoMAhFUkMXKg==/lib/arm/libunity.so
#50xc0d354bc in ?? () from target:/data/app/com.personal.flappybird-5yocNuCl7GDoMAhFUkMXKg==/lib/arm/libunity.so
#60xbef6d0ec in ?? () from target:/data/app/com.personal.flappybird-5yocNuCl7GDoMAhFUkMXKg==/lib/arm/libil2cpp.so
#70xbef6f2a0 in ?? () from target:/data/app/com.personal.flappybird-5yocNuCl7GDoMAhFUkMXKg==/lib/arm/libil2cpp.so
#80xbef6f1f0 in ?? () from target:/data/app/com.personal.flappybird-5yocNuCl7GDoMAhFUkMXKg==/lib/arm/libil2cpp.so
Backtrace stopped: previous frame identical to this frame (corrupt stack?)
在上述标红的 libil2cpp 相关的地方前后、栈上栈下都下断点,看看故意撞上/没撞上柱子时,断点有没有段下。由于脱壳后代码没有跳转混淆,因此大段大段的代码:要么是无论撞没撞上都会断下来;要么是前半段无论撞没撞上都会断下来但后半段是只有撞上才断下来;要么是全都只有撞上才断下来。使用二分搜索的策略,可以很快的定位到上一句无论撞不撞都断,但下一句不断(这一句就是个跳转)。这个跳转就是赛题任务的关键了,劫持它就能实现任务。
经过一段时间的分析,不难定位到,有两个关键的跳转(对应撞地板和撞柱子),分别位于
LOAD:00540DA8 BNE loc_540E24
LOAD:005413E0 BNE loc_541458
在之前 bypass 反调试的基础上,增加几句 il2cpp的 patch 就能实现赛题的要求。
int main(int argc, char *argv[])
{
//…
for (;;)
{
addr = findaddr(argv, "il2cpp", "00090000");
if (addr > 0)
{
printf("il2cpp OK\n");
}
else
{
printf("wait il2cpp\n");
sleep(1);
continue;
}
ipatch(mem_fd, 0x540DA8 + addr - 0x90000, 0x00, 0x00, 0);
ipatch(mem_fd, 0x540DA9 + addr - 0x90000, 0x00, 0x00, 0);
ipatch(mem_fd, 0x540DAA + addr - 0x90000, 0x00, 0xA0, 0);
ipatch(mem_fd, 0x540DAB + addr - 0x90000, 0x00, 0xE1, 0);
ipatch(mem_fd, 0x5413E3 + addr - 0x90000, 0x1A, 0xEA, 0);
break;
}
return 0;
}
详见 bypass-patch.c。只要在 root 的手机上运行该程序,就能够实现题目要求的功能。至此游戏逻辑分析部分介绍完毕。
最后是封包的部分。一个直观的想法是修复脱壳后的程序使得能够直接patch文件就能运行,而不是上面那样 hot-patch。这里由于比赛时间有限,我采取了一个变通方案——通过静态注入的方式注入一个共享库,然后在共享库里放 hot-patch 的代码(以及绕过包重签名的)
注入利用 libmain,方式如下:将原来的 libmian.so 重命名为 libmain2.so,注入用的新代码写在 libmain.so 里,并 libmian.so 拉 libmain2.so,而且libmain.so 要将 libmain2.so 导出的 JNI_OnLoad 向调用者传过去。libmian.so 的入口是 my_init()
void __attribute__ ((constructor)) my_init(void);
typedef int (*PJNI_ONLOAD)(void *, void *);
PJNI_ONLOAD JNI_OnLoad_forward = NULL;
int JNI_OnLoad(void *a, void *b)
{
return JNI_OnLoad_forward(a, b);
}
void my_init()
{
LOGI("hello!!");
void *p = dlopen("libmain2.so", RTLD_NOW);
JNI_OnLoad1 = dlsym(p, "JNI_OnLoad");
LOGI("libmain2.so = %p, JNI_OnLoad = %p", p, JNI_OnLoad1);
patchlibsec2021();
pthread_create(&tid, NULL, main_thread, NULL);
}
剩下的就是跟前面一样的bypass 与patch 。
unsigned long long findaddr(const char* s, const char* o)
{
unsigned long long addr = -1;
char buf;
char buf2;
int pid = getpid();
sprintf(buf2, "/proc/%d/maps", pid);
FILE *fp = fopen(buf2, "r");
while (fgets(buf, sizeof buf, fp) != NULL) {
if (strstr(buf, s) && strstr(buf, o))
{
LOGI("found in maps: %s\n", buf);
sscanf(buf, "%llx", &addr);
}
}
LOGI("addr = %llx\n", addr);
fclose(fp);
return addr;
}
void patchlibsec2021()
{
usleep(4000 + (rand() % 100));
long long libsec2021addr = -1;
for (;;)
{
libsec2021addr = findaddr("libsec2021.so", "00000000");
if (libsec2021addr != -1) break;
usleep(5 + (rand() % 5));
LOGI("wait for libsec2021 ...\n");
}
mprotect((void *)libsec2021addr, 0x40000, PROT_READ | PROT_WRITE | PROT_EXEC);
LOGI("start patch libsec2021 ...\n");
*(unsigned char *)(libsec2021addr + 0x01B541 - 1) = 0x00;
*(unsigned char *)(libsec2021addr + 0x01B543 - 1) = 0xA0;
*(unsigned char *)(libsec2021addr + 0x01B544 - 1) = 0xE1;
*(unsigned char *)(libsec2021addr + 0x01B551 - 1) = 0x00;
*(unsigned char *)(libsec2021addr + 0x01B553 - 1) = 0xA0;
*(unsigned char *)(libsec2021addr + 0x01B554 - 1) = 0xE1;
*(unsigned char *)(libsec2021addr + 0x01B5F0 - 1) = 0xEA;
*(unsigned char *)(libsec2021addr + 0x032A61 - 1) = 0x00;
*(unsigned char *)(libsec2021addr + 0x032EA9 - 1) = 0x00;
*(unsigned char *)(libsec2021addr + 0x032EAB - 1) = 0x50;
*(unsigned char *)(libsec2021addr + 0x037DEF - 1) = 0x50;
*(unsigned char *)(libsec2021addr + 0x037DF0 - 1) = 0xE1;
*(unsigned char *)(libsec2021addr + 0x037F94 - 1) = 0xE1;
*(unsigned char *)(libsec2021addr + 0x038457) = 0xE1;
*(unsigned char *)(libsec2021addr + 0x001DB8F) = 0xEA;
*(unsigned char *)(libsec2021addr + 0x001DC23) = 0xEA;
*(unsigned char *)(libsec2021addr + 0x0001E37C) = 0x00;
*(unsigned char *)(libsec2021addr + 0x0001E37D) = 0x00;
*(unsigned char *)(libsec2021addr + 0x0001E37E) = 0xA0;
*(unsigned char *)(libsec2021addr + 0x0001E382) = 0x00;
*(unsigned char *)(libsec2021addr + 0x000261A8) = 0x00;
*(unsigned char *)(libsec2021addr + 0x000261A9) = 0x00;
*(unsigned char *)(libsec2021addr + 0x000261AA) = 0xA0;
*(unsigned char *)(libsec2021addr + 0x000261AE) = 0x00;
*(unsigned char *)(libsec2021addr + 0x00024600) = 0x00;
*(unsigned char *)(libsec2021addr + 0x00024601) = 0x00;
*(unsigned char *)(libsec2021addr + 0x00024602) = 0xA0;
*(unsigned char *)(libsec2021addr + 0x00024606) = 0x00;
*(unsigned char *)(libsec2021addr + 0x0004B230) = 0x12;
*(unsigned char *)(libsec2021addr + 0x0004B231) = 0x34;
*(unsigned char *)(libsec2021addr + 0x0004B232) = 0x56;
*(unsigned char *)(libsec2021addr + 0x0004B233) = 0x78;
LOGI("patch libsec2021 done...\n");
}
void patchil2cpp()
{
long long libil2cppaddr = -1;
for (;;)
{
libil2cppaddr = findaddr("libil2cpp.so", "00090000");
if (libil2cppaddr != -1) break;
sleep(1);
LOGI("wait for il2cpp");
}
sleep(2);
mprotect((void *)libil2cppaddr, 0x600000, PROT_READ | PROT_WRITE | PROT_EXEC);
LOGI("start patch il2cpp ...\n");
*(unsigned char *)(libil2cppaddr + 0x540DA8 - 0x90000) = 0x00;
*(unsigned char *)(libil2cppaddr + 0x540DA9 - 0x90000) = 0x00;
*(unsigned char *)(libil2cppaddr + 0x540DAA - 0x90000) = 0xA0;
*(unsigned char *)(libil2cppaddr + 0x540DAB - 0x90000) = 0xE1;
*(unsigned char *)(libil2cppaddr + 0x5413E3 - 0x90000) = 0xEA;
LOGI("patch il2cpp done...\n");
}
详细代码见 inject.c,编译命令为
armv7a-linux-androideabi18-clang /tmp/inject.c --shared -fPIC -llog -o /tmp/inject.so
打包命令为
java -jar apktool_2.5.0.jar d -srf FlappyBird.apk
mv FlappyBird/lib/armeabi-v7a/libmain.so FlappyBird/lib/armeabi-v7a/libmain2.so
cp /tmp/inject.so FlappyBird/lib/armeabi-v7a/libmain.so
java -jar apktool_2.5.0.jar b FlappyBird
jarsigner -keystore ??? -storepass ??? FlappyBird/dist/FlappyBird.apk -signedjar hack.apk test
至此所有解题步骤的介绍完毕。
每每看到您这样的大佬发纯技术帖子,我也总想着哪天能够发发{:301_972:} 感谢楼主的分享 楼主好专业 感想大佬的分享!!谢谢,666 太专业了 这太专业了 看不懂,支持一个666!:Dweeqw 好专业的亚子
看不懂(我只学过vb,c#... 膜拜大佬 很久没有看到这么专业的文章了点个赞