Frida演示之某阅读APP解密分析

Published: 2021年11月07日

In Reverse.

之前想看一本书只有XXX上有,于是开了个会员准备把它解出来,但是后面沉迷其他事耽误了,今天突然想起一看会员要白开了赶紧分析,最后在调试这里被坑了很久最终没弄完,先记下来以后有时间再看看....

image.png

注:分析只是为了方便把书籍放Pad上阅读,为了保护知识产权本文只分享思路,并且会使用XXX表示APP名称,不会提供任何代码或解密后书籍,请勿联系索取...

分析

分析数据库和被保护数据

当然还是从数据库和文件入手,经分析它的数据库和缓存文件如下:

dream2lte:/data/data/com.XXX.player # ls
app_bugly        app_ebook_base_font  app_packages  app_process_lock  app_tbs       app_webview  code_cache  files  lib-main
app_crashrecord  app_flutter          app_patrons   app_sslcache      app_textures  cache        databases   lib    shared_prefs

dream2lte:/sdcard/.XXX # ls
__MACOSX  apk  audio  cache  chatVoice  ddjk.ttf  ebook  frame.html  igetgetBook  pic

从数据库看它的信息比较简单,token时jwt应该有点东西,chapters是json格式,从里边看它是分章节存储的epub文件: image.png 打开epub文件,看到它是被加密了: image.png 而且看着像是epub规范规定的加密,搜索可知确实像: image.png 但是如果真像这样就好咯,可以直接解密,然鹅这里的encryption.xml格式不对劲:

  <enc:EncryptionKey Id="EK">
    <enc:EncryptionMethod Algorithm="http://www.w3.org/2001/04/xmlenc#rsa-1_5" Version="1.0"/>
    <ds:KeyInfo>
      <ds:KeyName>XXX.Inc</ds:KeyName>
    </ds:KeyInfo>
    <enc:CipherData>
      <enc:CipherValue>DRM</enc:CipherValue>
    </enc:CipherData>
  </enc:EncryptionKey>

  <enc:EncryptedData Id="DATA0">
    <enc:EncryptionMethod Algorithm="http://www.w3.org/2001/04/xmlenc#kw-aes128"/>
    <ds:KeyInfo>
      <ds:RetrievalMethod Type="http://www.w3.org/2001/04/xmlenc#EncryptionKey" URI="#EK"/>
    </ds:KeyInfo>
    <enc:CipherData>
      <enc:CipherReference URI="OEBPS/Images/image-150.jpg"/>
    </enc:CipherData>
    <enc:EncryptionProperties>
      <enc:EncryptionProperty xmlns:ns="http://www.idpf.org/2016/encryption#compression">
        <ns:Compression Method="8" OriginalLength="21075"/>
      </enc:EncryptionProperty>
    </enc:EncryptionProperties>
  </enc:EncryptedData>

可以看到它的Key格式根本不对,应该是经过修改的,于是只能先看看apk了,通过搜索openbook,token之类的关键词找到这个点很可疑: image.png

定位关键点

通过搜索上面的加密关键字也可以定位到libebk-engine.so库,先看看它注册的Native函数,搜索发现它使用的RegsiterNative方式注册的,指定类型后如下: image.png 这里直接跟着getToken.../openBook...等也能分析出解密过程,但是想找捷径,先直接用findcrypt去找找AES表,这里只有个逆盒还没交叉引用: image.png 那就先上土办法直接搜0xA56363C6: image.png 定位到AES操作的位置:

image.png

这里是静态编译进去的,不像上一个应用那么明显,一般也不用分析算法,再向上翻,通过交叉引用到如下函数,图里我分析时已经加了符号,本来是无符号的: image.png 可以看到这里面已经有很多运算了,而且一些点基本能猜出来,继续分析其他参数的作用,如通过memcpy或赋值,通过类型等可猜测如下:

int __fastcall dec_70A9384(char *ibuf, _DWORD *obuf, char *key, int key_size, int total_size, int *out_size, int curpos, int typ)

现在可以开始验证猜测,以MD5为例,此时可用Frida Dump出数据,启动方式见下一节,此处直接上代码:

let ebookModName = 'libebk-engine.so';
let ebookkBase = Module.getBaseAddress(ebookModName);

function decInerTrace() {
    let dec_70A9384 = ebookkBase.add(0x10A9384);
    Interceptor.attach(dec_70A9384, {
        onEnter: function (args) {
            this.ibuf = dumpmem(args[0], min(64, args[4].toUInt32()));  // 函数进入时,备份密文,否则在解密时可能会被破坏
            this.l = min(64, args[4].toUInt32());  // 密文长度
            this.obufAddr = args[1];  // 备份明文的地址,在输出时才能读到明文
            this.key = dumpmem(args[2], args[3].toUInt32());  // key的长度
            this.curpos = args[6].toUInt32();  // 这个是后来分析的,先忽略
        },
        onLeave: function (retval) {
          // 如下,在函数结束时,输出整个函数调用的结果
            console.log(`dec(
 ibuf: ${this.ibuf}
 key: ${this.key}
 curpos: ${this.curpos}
 obuf:${dumpmem(this.obufAddr, this.l)}
)`)
        }
    });
}

通过这种方式能获取到此函数的输入与输出:

dec(
 ibuf: 9710de00  f6 01 7f fa 6d 3f fe 7b b7 30 fb fc 11 b8 76 19  ....m?.{.0....v.
             9710de10  d9 9d 9d 69 af 61 e3 42 be f4 fb 26 96 c2 60 36  ...i.a.B...&..`6
             9710de20  ea c7 3e d5 86 6b 35 f0 60 e4 83 f7 e4 80 76 ea  ..>..k5.`.....v.
             9710de30  82 84 a6 e9 5d 0c a4 a9 1a 52 8b 1a 0f 72 c7 6a  ....]....R...r.j
 key:  7cfac0a0  5a 80 25 78 b3 68 88 98 3f 0b 2d 60 2f ea 8c 32  Z.%x.h..?.-`/..2
 curpos: 278528
 obuf: 9710de00  8d 91 49 5f fe 39 a4 47 eb 93 b6 dd 1d 2c 0e 10  ..I_.9.G.....,..
           9710de10  d0 88 9a a3 1e 35 89 8b 18 ff 00 49 93 1f 66 8c  .....5.....I..f.
             9710de20  b9 ef ab 4f 45 8d 17 70 aa 86 2b 02 87 2c 24 80  ...OE..p..+..,$.
             9710de30  18 00 16 96 1e 6f 8b ab c4 4b ef a6 47 2d df 2e  .....o...K..G-..
)

首先验证下MD5的猜测是否正确,此时再对md5final做插桩:

function md5Trace() {
    let md5Final = ebookkBase.add(0x10AD3F8);
    Interceptor.attach(md5Final, {
        onEnter: function (args) {
            this.outBuf = args[0];
        },
        onLeave: function (retval) {
            console.log(`md5Final(${dumpmem(this.outBuf, 16)})`)
        }
    })
}

输出如下:

md5Final(a8921518  0f b4 08 49 4d af fe e5 b8 72 04 db ad 26 69 d1  ...IM....r...&i.)

于是写Python代码验证下:

In [1]: import hashlib

In [2]: hashlib.md5(bytes.fromhex('5a 80 25 78 b3 68 88 98 3f 0b 2d 60 2f ea 8c 32')).hexdigest()
Out[2]: '0fb408494daffee5b87204dbad2669d1'

可见猜测一致,继续用这种方法trace aes解密函数:

function aesDecTrace() {
    let aesDec = ebookkBase.add(0x10A97D8);
    Interceptor.attach(aesDec, {
        onEnter: function (args) {
            this.arg1 = dumpmem(args[0], args[2].toUInt32());
            this.arg2 = args[1];
            this.arg3 = args[2].toUInt32();
            this.iv = dumpmem(args[3], 16);
            this.key = dumpmem(args[5], 16);
        },
        onLeave: function (retval) {
            console.log(`aes dec (ret=${retval})=> (
        key:${this.key}
        iv:${this.iv}
        in:${this.arg1}
        in_size:${this.arg3}
        out:${dumpmem(this.arg2, this.arg3)})`);
        }
    });
}

由于它每次解密16字节,因此会有大量的输出,但是这里面有些并不是16字节,如:

aes dec (ret=0x0)=> (
        key:a892144c  5a 80 25 78 b3 68 88 98 3f 0b 2d 60 2f ea 8c 32  Z.%x.h..?.-`/..2
        iv:a8921500  aa 11 ad ec da 00 00 00 e8 0a 5b 40 1d d7 a1 7e  ..........[@...~
        in:9af4d3a0  3c b1 ad 77 10 3a 5f 1f f1 8a 61 0e d9 89        <..w.:_...a...
        in_size: 14
        out:a8921520  c9 69 b7 fa 3f a0 69 37 fd 7f d5 bd ff 00        .i..?.i7......)
aes dec (ret=0x0)=> (
        key:a8920a8c  5a 80 25 78 b3 68 88 98 3f 0b 2d 60 2f ea 8c 32  Z.%x.h..?.-`/..2
        iv:a8920b40  aa 11 ad ec 80 5d 00 00 e8 0a 5b 40 1d d7 a1 7e  .....]....[@...~
        in:a5b97200  d2 2a a5                                         .*.
        in_size: 3
        out:a8920b60  7f ff d9                                         ...)

可见它的输入并不一定是AES加密的填充长度,此时要么用XTS,要么自己实现,要么就是用CFB或OFB模式: image.png 这两种模式区别还是很明显,看看输入的IV和Key是怎么和密文交互就知道了。不过这里还是直接猜测,由于密钥和IV是正常长度前者可以排除,因此尝试CFB和OFB发现OFB模式满足要求:

In [3]: from Crypto.Cipher import AES

In [4]: iv = bytes.fromhex('aa 11 ad ec da 00 00 00 e8 0a 5b 40 1d d7 a1 7e')

In [5]: key = bytes.fromhex('5a 80 25 78 b3 68 88 98 3f 0b 2d 60 2f ea 8c 32')

In [6]: cipher_text = bytes.fromhex('3c b1 ad 77 10 3a 5f 1f f1 8a 61 0e d9 89')

In [7]: cipher = AES.new(key, mode=AES.MODE_OFB, IV=iv)

In [8]: cipher.decrypt(cipher_text).hex()
Out[8]: 'c969b7fa3fa06937fd7fd5bdff00'

其他猜测全可使用这种方式验证,之后不再演示。仔细观察上文的输出会发现IV一直在变化:

image.png

于是可写出如下代码:

import struct
from hashlib import md5

from Crypto.Cipher import AES


pack32 = lambda num: struct.pack('<I', num)
unpack32 = lambda data_bytes: struct.unpack('<I', data_bytes)[0]

AES_BLOCK_SIZE = AES.block_size


def aes_dec(aes_key: bytes, cur_pos: int, cipher_text: bytes):
    buf_arr = []
    tmp_iv = md5(aes_key).digest()
    tmp_iv = bytes(map(lambda ch: ch ^ 0xa5, tmp_iv))
    iv = tmp_iv[0:4] + pack32(cur_pos // AES_BLOCK_SIZE) + tmp_iv[4:0xc]
    for i in range(0, len(cipher_text), AES_BLOCK_SIZE):
        aes = AES.new(aes_key, mode=AES.MODE_OFB, IV=iv)
        buf_arr.append(aes.decrypt(cipher_text[i:i + AES_BLOCK_SIZE]))
        iv = iv[0:4] + pack32(unpack32(iv[4:8]) + 1) + iv[8:]
    return b''.join(buf_arr)

继续向上分析,发现它被如下函数调用: image.png 通过交叉引用可知是类future_core::EncryptedInputStream虚函数,它的父类是future_core::InputStream,看名字一般都是抽象类,我们需要找到一个简易的实现类去分析它的虚表,经查找有future_core::FileInputStream类,它是对libc调用的简单封装,因此很容易通过它分析出虚函数的作用: image.png 因为虚函数的特性,对照过来就是到了EncryptedInputStream的虚函数作用了,接下来就是继续分析key的来源。 由于是CPP的虚函数调用没有交叉引用了,因此最好使用动态分析,本来想通过Stalker绘制个调用图,然鹅目前Frida对arm32的Stalker支持还有BUG,QDBI也不支持ARM了:

function traceCall() {
    // 只关心ebook,先排除掉其他范围
    let ebookModuleMap = new ModuleMap(function (mod) {
        return mod.path.indexOf(ebookModName) != -1;
    });
    Process.enumerateRanges('--x').forEach(function (range) {
        if (!ebookModuleMap.has(range.base)) {
            Stalker.exclude(range);
        }
    });
    // 追踪所有线程的调用
    Process.enumerateThreads().forEach(function (thread) {
            Stalker.follow(thread.id, {
                events: {
                    call: true, // CALL instructions: yes please
                },
                onReceive(events) {
                    console.log(Stalker.parse(events, {
                        annotate: true,
                        stringify: true
                    }));
                },
            })
        }
    )
}

因此就打算用调试,结果遇到了很多问题,最终没弄完,现在有两条路,硬着分析或者看看为什么调试会失败,是不是有反调手段,简单跟了几个点没发现问题:

function fopenTrace() {
    Interceptor.attach(Module.getExportByName(null, 'fopen'), {
        onEnter: function (args) {
            this.path = args[0].readCString();
        },
        onLeave: function (retval) {
            console.log(`fopen(${this.path})=>fd=${retval}`)
        }
    });
    Interceptor.attach(Module.getExportByName(null, 'open'), {
        onEnter: function (args) {
            this.path = args[0].readCString();
        },
        onLeave: function (retval) {
            console.log(`open(${this.path})=>fd=${retval}`)
        }
    });
    Interceptor.attach(Module.getExportByName(null, 'openat'), {
        onEnter: function (args) {
            this.dirFd = args[0].toUInt32();
            this.path = args[1].readCString();
        },
        onLeave: function (retval) {
            console.log(`openat(${this.dirFd}, ${this.path})=>fd=${retval}`)
        }
    })
}

function signalTrace() {
    Interceptor.attach(Module.getExportByName(null, 'gsignal'), {
        onEnter: function (args) {
            console.log(`gsignal(${args[0].toUInt32()})`);
        }
    });
    Interceptor.attach(Module.getExportByName(null, 'raise'), {
        onEnter: function (args) {
            console.log(`raise(${args[0].toUInt32()})`);
        }
    });
  //....
}

还能用console.log('Return : ' + this.returnAddress);去获取函数的返回地址,通过笨办法也能绘制出调用图,硬着分析就是正着来,工作量也不大,暂时不想搞了。。。待续

又有需求想看本书发现就DD上有,重新拿起分析,由于期间也没有再看frida,这次直接静态分析,根据继承情况可对类进行定义,首先看EncryptedInputStream的open,分析如下:

image-20220817200512504

再看其他类,可知加密输入流对象是被嵌入压缩流对象的:

image-20220817201008002

所以其实就是通常的DRM保护,用frida验证一下确实能得到明文,回想之前之所以失败,猜测可能没有设对zlib的解压参数,最终通过这种方法可正确解密数据:

image-20220817203622952

调试

Frida插桩

使用adb启动frida-server,我使用的是BlueStack的混合模式,因此server需要为x86架构的,否则它会显示找不到libc(其实也可以解决但是没必要):

betamao@DESKTOP # adb shell
dream2lte:/ $ su
dream2lte:/ # /data/frida-server-15.1.2-android-x86 -l 0.0.0.0:55555
/data/frida-server-15.1.2-android-x86 -l 0.0.0.0:55555 ...

此处可以直接使用USB连接,不过我喜欢端口转发,这里加一步:

betamao@DESKTOP # adb forward tcp:55555 tcp:55555

这样就可以插桩了,由于要插桩的应用是arm架构的,因此需要加上--realm=emulated参数,如下:

betamao@DESKTOP # frida -l .\xxx.js -n XXX -H 127.0.0.1:55555 --realm=emulated

使用ida

frida-gadget注入成功后不再占用调试接口,因此可使用ida进行调试:

betamao@DESKTOP # adb shell
dream2lte:/ $ su
dream2lte:/ # cd /data
dream2lte:/data # ./ads -p55556 -v
IDA Android 32-bit remote debug server(ST) v7.5.26. Hex-Rays (c) 2004-2020
Listening on 0.0.0.0:55556..

betamao@DESKTOP # adb forward tcp:55556 tcp:55556

但是即使未打任何断点,只要attach就会崩溃: image.png 看崩溃位置,查看文件类型发现是x86架构的:

dream2lte:/ # file /system/bin/app_process32_original
/system/bin/app_process32_original: ELF shared object, 32-bit LSB 386, dynamic (/system/bin/linker), for Android 25, BuildID=f79ee9d7df3ceb7c81692f303514779a, stripped

使用gdb

怀疑是模拟器原因,因此直接上真机,由于真机USB+IDA速度异常的慢,此处使用gdb调试,在安装ndk时会自动下载gdbserver,也可以直接去网上下,运行服务:

dream2lte:/ # ./gdbserver --attach :55557 ${ps -ef | grep luoji | grep -v 'grep'|cut -d' ' -f7}

之后使用gdb远程调试,注意需要使用arm版或multiarch版:

betamao@DESKTOP:~/$ apt install gdb-multiarch
betamao@DESKTOP:~/$ gdb-multiarch
pwndbg> set arch arm
pwndbg> target remote 127.0.0.1:55557

目标存在大量共享库,为了提高加载速度可先将这些库下载到本地,再使用set sysroot /some/sysroot命令指定为本地路径。