记一次逆向cocos2dx游戏
怒极摸鱼产物。

0x00 前言

逆向课上闲来无事,想起之前丢下的一个坑,花了一天时间做完了。

算是第一次完整的逆向实践,记录一下。

目标:某国服PPT游戏,未加壳,解密加密图片和lua脚本

工具:

  • IDA Pro 7.5
  • Frida

0x01 简单分析

直接打开APK文件查看结构,确定该游戏客户端使用cocos2dx作为引擎。

尝试采用cocos2d通用的xxtea算法解密,看了几个文件之后发现文件头对不上,也没找到sign和key,应该不是。

加密的文件特征为前两个字节是0x55 0x46,即字母的UF

  Offset: 00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F 	
00000000: 55 46 00 01 05 21 20 3E 55 56 0E 0E 08 11 71 1A    UF...!.>UV....q.

用JEB简单地看了一下Java类,没找到类似加密的方法。推测在封装的.so库中。

由于该客户端只引用了3个so库,很简单地锁定libcocos2dlua.so,丢到IDA里面分析。

这里算是走了点弯路,因为开始还在想是不是改了xxtea(同样是固定字节),所以搜了xxtea,其实直接定位luaLoadBuffer会更快。

int __fastcall cocos2d::LuaStack::luaLoadBuffer(int a1, int a2, CCCrypto *this, unsigned __int8 *a4, int a5)
{
  //...
  else
  {
    if ( (int)a4 > 3 && *(_BYTE *)this == 85 && *((_BYTE *)this + 1) == 70 )
    {
      v22 = 0;
      v23 = 0;
      v24[0] = 0;
      CCCrypto::decryptUF(this, a4, (int)&v22, v24, &v23, v13);
      v10 = a2;
      v11 = this;
      v12 = (unsigned __int8 *)v23;
    }
    else
    {
      v11 = this;
      v12 = a4;
      v10 = a2;
    }
    result = j_luaL_loadbuffer(v10, (int)v11, (int)v12, a5);
  }
  return result;
}

很显然加密用的就是这个CCCrypto::decryptUF了,点进去看看:

int __fastcall CCCrypto::decryptUF(CCCrypto *pInBuff, unsigned __int8 *inlen, int a2, int *a3, int *a4, int *a5)
{
  //...

  if ( (int)inlen <= 3 )
  {
    v26 = 1;
    return -v26;
  }
  if ( *(_BYTE *)pInBuff != 0x55 || *((_BYTE *)pInBuff + 1) != 0x46 )
  {
    v26 = 2;
    return -v26;
  }
  //...
}

没错,就是你!

搜索decryptUF可以定位另一个用于加密的函数decryptUFImage,其实只要根据反汇编抄代码就行了,但是看的有点头大所以决定先跑HOOK。

0x02 Hook尝试

没做过Android Hook,先查资料。

锁定Firda,不需要编译,直接远程跑脚本就行。

服务端直接用Magisk Frida,少一步手动启用Frida。

写js脚本:

setTimeout(function(){
    Java.performNow(function () {
        var rawbyte;
        var rawlen;
        var luaL_loadbuffer = Module.findExportByName("libcocos2dlua.so", "_ZN8CCCrypto14decryptUFImageEPhi");
        Interceptor.attach(luaL_loadbuffer, {
            onEnter: function(args) {
                rawbyte = args[0];
                rawlen = args[2];
                //console.log(args[1].readByteArray(20))
            },
            onLeave: function (retval) {
                send('isbyte',rawbyte.readByteArray(rawlen.toInt32()));
            }
        } );
    });
}, 100)

这个是抓的图片,lua脚本也类似。

对应的python脚本:

# -*- coding: utf-8 -*-
# adb forward tcp:27043 tcp:27043
# adb forward tcp:27042 tcp:27042
import frida
import sys
import os
import random

def generate_random_str(randomlength=16):
  """
  无法hook文件路径,暂时用随机串生成文件名
  """
  random_str = ''
  base_str = 'ABCDEFGHIGKLMNOPQRSTUVWXYZabcdefghigklmnopqrstuvwxyz0123456789'
  length = len(base_str) - 1
  for i in range(randomlength):
    random_str += base_str[random.randint(0, length)]
  return random_str

def write(path, content):
    print('write:', path)
    folder = os.path.dirname(path)
    if not os.path.exists(folder):
        os.makedirs(folder)
    open(path, 'wb').write(content)

def on_message(message, data):
    if message['payload']=='isbyte':
        name = "test"+generate_random_str()+".png"
        name = "./output/"+ name
        content = data
        dirName = os.path.dirname(name)
        if not os.path.exists(dirName):
            os.makedirs(os.path.dirname(name))
        write(name, content)
    else:
        print(message)
        
device = frida.get_usb_device(timeout=100)
 
pid = device.spawn(["com.xxxxxxx"])
session = device.attach(pid)
device.resume(pid)
 
jscode = open('test.js', 'r',encoding='utf-8').read() 
script = session.create_script(jscode)
script.on("message", on_message)
script.load()
sys.stdin.read()

直接执行,自动拉起App,抓取数据。

0x03 重写解密

Hook嘛,内容不一定完整。于是还是回到阅读解密函数。

先从简单的图片解密看起:

//generated by The Interactive Disassembler (IDA)
unsigned __int8 *__fastcall CCCrypto::decryptUFImage(CCCrypto *this, unsigned __int8 *a2, int a3)
{
  unsigned __int8 *result; // r0
  CCCrypto *v5; // r2
  char v6; // r6
  char v7; // r5
  char v8; // r4
  char *i; // r2
  unsigned __int8 *v10; // r1
  char v11; // [sp+4h] [bp-1Ch]

  result = a2;
  if ( (int)a2 > 0x19 && *(_BYTE *)this == 0x55 && *((_BYTE *)this + 1) == 0x46 )
  {
    v11 = *((_BYTE *)this + 5);
    v5 = this;
    v6 = *((_BYTE *)this + 2);
    v7 = *((_BYTE *)this + 3);
    v8 = *((_BYTE *)this + 4);
    do
    {
      *(_BYTE *)v5 = *((_BYTE *)v5 + 6);
      v5 = (CCCrypto *)((char *)v5 + 1);
    }
    while ( v5 != (CCCrypto *)((char *)this + 20) );
    *((_BYTE *)this + 21) = v7;
    *((_BYTE *)this + 20) = v6;
    *((_BYTE *)this + 23) = v11;
    *((_BYTE *)this + 22) = v8;
    for ( i = (char *)this + 24; ; ++i )
    {
      result = a2 - 2;
      if ( i - (char *)this >= (int)(a2 - 2) )
        break;
      *i = i[2];
    }
    v10 = &a2[(_DWORD)this];
    *(v10 - 2) = 0;
    *(v10 - 1) = 0;
  }
  return result;
}

看得出来只是移位并添加了几个开头的字节,结合hook到的解密后图片,写出解密代码:

def decryptUFImg(img: bytes):
    img=list(img)
    if (int(len(img)) < 0x1A or img[0] != 0x55 or img[1] != 0x46):
        return (0,)
    shift = img[2:6]
    img[0:21]=img[6:27]
    img[20:24] = shift
    img[24:-2]=img[26:]
    img[-2:]=[0,0]
    return bytes(img)

def replaceImg(imgpath):
    imgbyte = ()
    with open(imgpath, "rb") as r:
        imgbyte = r.read()
    decode = decryptUFImg(imgbyte)
    if (decode[0] != 0x00):
        with open(imgpath, "wb") as w:
            w.write(decode)
            print(f"decode img {imgpath}")

简单到不配叫加密。

decryptUF:

//generated by The Interactive Disassembler (IDA)
int __fastcall CCCrypto::decryptUF(CCCrypto *pInBuff, unsigned __int8 *inlen, int a2, int *a3, int *a4, int *a5)
{
  int v6; // r4
  int v7; // r3
  char *v8; // r7
  int v10; // r0
  int v11; // r2
  char *v12; // r6
  unsigned __int8 *v13; // r4
  int v14; // r5
  int v15; // r1
  unsigned int v16; // r7
  unsigned int v17; // r4
  unsigned __int8 *v18; // r6
  int v19; // r1
  int v20; // r3
  int v21; // r1
  int v22; // r2
  CCCrypto *v23; // r3
  unsigned __int8 *v24; // r1
  int v26; // r0
  int v27; // [sp+0h] [bp-20h]
  int v28; // [sp+0h] [bp-20h]
  unsigned int v29; // [sp+4h] [bp-1Ch]

  if ( (int)inlen <= 3 )
  {
    v26 = 1;
    return -v26;
  }
  if ( *(_BYTE *)pInBuff != 0x55 || *((_BYTE *)pInBuff + 1) != 0x46 )
  {
    v26 = 2;
    return -v26;
  }
  if ( *((_BYTE *)pInBuff + 2) == 0x4F )
  {
    *a3 = *((unsigned __int8 *)pInBuff + 3);
    v6 = 1;
    v7 = 4;
  }
  else
  {
    v6 = 0;
    v7 = 2;
  }
  v8 = (char *)pInBuff + v7; 
  *(_DWORD *)a2 = *((unsigned __int8 *)pInBuff + v7);
  v10 = *((unsigned __int8 *)pInBuff + v7 + 1); 
  v11 = v7 + 2;
  if ( v10 == 1 )
  {
    v12 = v8;
    v27 = -v7;
    v13 = &inlen[-v7 - 3];
    v14 = *((unsigned __int8 *)pInBuff + v11) - (_DWORD)pInBuff - v7;
    while ( v12 - v8 < (int)v13 )
    {
      j_j___aeabi_idivmod((int)&v12[v14], 0x21);
      v12[v27] = v12[3] ^ byte_B82264[v15];
      ++v12;
    }
    *a4 = (int)v13;
  }
  else if ( v10 == 2 )
  {
    v16 = 0;
    v17 = v7 + 3;
    v18 = &inlen[-v7 - 3];
    v28 = *((unsigned __int8 *)pInBuff + v11);
    while ( v16 < v17 )
    {
      j_j___aeabi_idivmod(v16 + v28, 33);
      *((_BYTE *)pInBuff + v16) = v18[(_DWORD)pInBuff + v16] ^ byte_B82264[v19 + 36];
      ++v16;
    }
    v20 = (int)&v18[-v17];
    if ( (int)&v18[-v17] > 95 )
      v20 = 95;
    v29 = v20 + v17;
    while ( v17 < v29 )
    {
      j_j___aeabi_idivmod(v17 + v28, 33);
      *((_BYTE *)pInBuff + v17++) ^= byte_B82264[v21 + 36];
    }
    *a4 = (int)v18;
  }
  else
  {
    v22 = 7;
    if ( !v6 )
      v22 = 5;
    v23 = pInBuff;
    v24 = &inlen[-v22];
    while ( v23 - pInBuff < (int)v24 )
    {
      *(_BYTE *)v23 = *((_BYTE *)v23 + v22);
      v23 = (CCCrypto *)((char *)v23 + 1);
    }
    *a4 = (int)v24;
  }
  return 0;
}

这个比较麻烦,变量太多看起来头大。

结合csdn大佬提供的之前版本的decryptUF,写出解密函数如下:

import os

RKey=[0x11, 0x2B, 0x65, 0x78, 0x17, 0xC, 0xD, 0x13, 0x15,
0x35, 0x62, 0x6F, 0x7B, 0x62, 0x15, 0x7F, 0x11, 0x2C,
0x63, 0x17, 0x16, 0x57, 0xC, 0x59, 0xB, 0x20, 0x65,
0x21, 0x20, 0x63, 0xC, 0x7F, 8, 0, 0, 0, 0x11, 0x2B,
0x65, 0x78, 0x17, 0xC, 0xD, 0x13, 0x15, 0x35, 0x62,
0x6F, 0x7B, 0x62, 0x15, 0x7F, 0x11, 0x2C, 0x63, 0x17,
0x16, 0x57, 0xC, 0x59, 0xB, 0x20, 0x65, 0x21, 0x20,
0x63, 0xC, 0x7F, 8, 0, 0, 0]

def decryptUFLua(buf):
    buf = list(buf)
    buflen = len(buf)
    if (buflen < 3 or buf[0] != 0x55 or buf[1] != 0x46):
        return (0,)
    
    delen = buflen - 5
    a = 0
    b = 2
    if (buf[2] == 0x4f):
        a = 1
        b = 4
    cbit = buf[b + 1]

    if (cbit == 0x01):
        i = b
        sbit = buf[b + 2] - 2
        while (i - b < delen):
            buf[i - b] = buf[i + 3] ^ RKey[(i + sbit) % 33]
            i = i + 1
    
    elif (cbit == 0x02):
        print("cbit==2 detected")
        i = 0
        j = b + 3
        sbit = buf[b + 2]
        while (i < j):
            buf[i] = buf[i + delen] ^ RKey[(i + sbit) % 33 + 36]
            i = i + 1
        sbit2 = 95 if (delen - j > 95) else (delen - j)
        while (j < sbit2 + j):
            buf[j] = buf[j] ^ RKey[(j + sbit) % 33 + 36]
            j += 1
    
    else:
        print("cbit==else detected")
        sbit = 7 if (a) else 5
        i = 0
        v24 = buflen - sbit
        while (i < v24):
            buf[i] = buf[i + sbit]
            i = i + 1
    return bytes(buf[:-5])

def replaceLua(path):
    bufbyte = ()
    with open(path, "rb") as r:
        bufbyte = r.read()
    decode = decryptUFLua(bufbyte)
    if (decode[0] != 0x00):
        with open(path[:-1], "wb") as w:
            w.write(decode)
            print(f"decode luac {path}")
            os.remove(path)

值得一提的是这个算法虽然看上去很长,但是实际上解密的时候根本没跑到过下面两个分支,也就是说这个代码可以精简为:

def decryptUFLua(buf):
    buf = list(buf)
    buflen = len(buf)
    if (buflen < 3 or buf[0] != 0x55 or buf[1] != 0x46 or buf[3] != 0x01):
        return (0,)
    sbit = buf[4] - 2
    for i in range(2,buflen-3):
        buf[i - 2] = buf[i + 3] ^ RKey[(i + sbit) % 33]
    return bytes(buf[:-5])

写个文件夹遍历,完成一次性解密:

def DFT(rootpath):
    stack = [rootpath]
    while stack != []:
        path = stack.pop()
        if os.path.isdir(path):
            for a_path in os.listdir(path):
                stack.append(path + '/' + a_path)

        if (path.endswith(".png") or path.endswith(".jpg")):
            replaceImg(path)
        elif (path.endswith(".luac")):
            replaceLua(path)

上次修改於 2021-03-27