# 前言

抖音六神算法,一听这名字就超 cooool 的,正好最近也没有什么别的事情,于是就趁着周末来逆一逆抖音 APP, 在分析六神算法的过程中,它的 java 层的字符串加密算法我感觉很有意思,于是就写了这篇文章来记录一下~

本文所分析的抖音版本为 300600

# ssl pinning 绕过

打开 app 之后用 repable 抓包,抖音直接显示无网络,所以想都不用想,肯定是有 ssl pinning,但是常规的过 ssl pinning 的代码没有用,因为抖音和其他 app 不一样,它的 ssl 校验是在 native 层的

hook apk 打开的 so 库,一般网络库的加载都是在最前面的,在 libsscronet.so 找到 ssl 相关的函数

image-20240801003621820

这一块应该是在 so 中用自定义的函数去进行 ssl 的验证

搜索字符串发现有很多 certificate , 那大概率就是在这个 so 里面进行 ssl pinning

image-20240801004717078

看了一下导入函数,感觉 SSL_CTX_set_custom_verify 有点像校验的样子

SSL_CTX_set_custom_verify 证书校验异步操作,相比 OpenSSL,BoringSSL 单独将认证证书的流程拿出来从而提供更详细的认证控制

image-20240801004045357

再细看一下这个函数

void SSL_CTX_set_custom_verify(
    SSL_CTX *ctx, int mode,
    enum ssl_verify_result_t (*callback)(SSL *ssl, uint8_t *out_alert)) {
  ctx->verify_mode = mode;
  ctx->custom_verify_callback = callback;
}

其中的 mode 用来验证客户端,mode 的值为 1 进行验证,但是如果取 0 就是不验证

// SSL_VERIFY_NONE, on a client, verifies the server certificate but does not
// make errors fatal. The result may be checked with |SSL_get_verify_result|. On
// a server it does not request a client certificate. This is the default.
#define SSL_VERIFY_NONE 0x00
// SSL_VERIFY_PEER, on a client, makes server certificate errors fatal. On a
// server it requests a client certificate and makes errors fatal. However,
// anonymous clients are still allowed. See
// |SSL_VERIFY_FAIL_IF_NO_PEER_CERT|.
#define SSL_VERIFY_PEER 0x01
// SSL_VERIFY_FAIL_IF_NO_PEER_CERT configures a server to reject connections if
// the client declines to send a certificate. This flag must be used together
// with |SSL_VERIFY_PEER|, otherwise it won't work.
#define SSL_VERIFY_FAIL_IF_NO_PEER_CERT 0x02
// SSL_VERIFY_PEER_IF_NO_OBC configures a server to request a client certificate
// if and only if Channel ID is not negotiated.
#define SSL_VERIFY_PEER_IF_NO_OBC 0x04

所以我们可以直接用 frida patch 第二个参数的值为 0

function hook_dlopen(soName = '') {
    Interceptor.attach(Module.findExportByName(null, "android_dlopen_ext"),
        {
            onEnter: function (args) {
                var pathptr = args[0];
                if (pathptr !== undefined && pathptr != null) {
                    var path = ptr(pathptr).readCString();
                    //console.log('load ',path);
                    if (path.indexOf(soName) >= 0) {
                        this.is_can_hook = true;
                    }
                }
            },
            onLeave: function (retval) {
                if (this.is_can_hook) {
                    ssl_bypass();
                    //do your own code
                }
            }
        }
    );
}
function ssl_bypass(){
    let SSL_CTX_set_custom_verify = Module.getExportByName('libsscronet.so', 'SSL_CTX_set_custom_verify');
    console.log('start hook SSL_CTX_set_custom_verify')
    if (SSL_CTX_set_custom_verify != null) {
        Interceptor.attach(SSL_CTX_set_custom_verify, {
            onEnter: function (args) {
                console.log('SSL_CTX_set_custom_verify mode: ',args[1]);
                args[1] = ptr(0);
                console.log('patch mode from 1 to 0 to bypass ssl')
            },
            onLeave: function (retval) {
            }
        });
    }
}
//frida -H 127.0.0.1:1234 -l .\douyin\hook.js -f com.ss.android.ugc.aweme
setImmediate(hook_dlopen, "libsscronet.so")

抓到包啦

image-20240801010106216

在请求头中出现的六个 x- 带头的参数就是六神算法

image-20240818142610556

# 定位参数位置

接下来就是去定位这些参数的位置咯,该怎么定位呢?

其实很简单,按照正向安全开发的经验来说,对于大型的 APP, 这种关键参数所涉及的算法基本上是不可能在 java 层中出现的,他们通常极大概率是把加密算法下沉到 native 层中去调用,所以说,我们就只需要找 so 就可以啦,而这种含有关键算法的 so 一般都有一个共性,就是 so 中不会出现静态注册的 java 方法,并且 so 被混淆的非常滴厉害,各种花指令寄存器跳转啥的通通来一遍让我们一打开 IDA 反编译连伪代码都看不了,找到这类 so,那就离关键算法不远了,而其他无关紧要的 so 基本不会做防护,要是也加上高强度的混淆,可能会出 bug 不说,还会拖慢 APP 启动速度影响用户体验

而小型的 APP, 由于很少会在安全上花功夫,所以说我们在 java 层中搜索参数名称,一般就可以找到参数所在的位置了

而在之前我们已经知道进行 ssl pinning 的 so 库的名称是 libsscronet.so , 那对参数进行加密的 so 库一般就在网络库的附近加载,感觉 libmetasec_ml.so 这个 so 挺像的

image-20240818142932419

一用 IDA 反编译 so 之后引入眼帘的就是让人感到亲切的 BR 寄存器跳转!

image-20240818143106335

接下来看看这个 so 动态注册了什么方法咯

function getModuleInfoByPtr(fnPtr) {
  var modules = Process.enumerateModules();
  var modname = null,
      base = null;
  modules.forEach(function (mod) {
    if (mod.base <= fnPtr && fnPtr.toInt32() <= mod.base.toInt32() + mod.size) {
      modname = mod.name;
      base = mod.base;
      return false;
    }
  });
  return [modname, base];
}
function hook_registNatives() {
  var env = Java.vm.getEnv();
  var handlePointer = env.handle.readPointer();
  console.log("handle: " + handlePointer);
  var nativePointer = handlePointer.add(215 * Process.pointerSize).readPointer();
  console.log("register: " + nativePointer);
  /**
   typedef struct {
      const char* name;
      const char* signature;
      void* fnPtr;
   } JNINativeMethod;
   jint RegisterNatives(JNIEnv* env, jclass clazz, const JNINativeMethod* methods, jint nMethods)
   */
  Interceptor.attach(nativePointer, {
    onEnter: function onEnter(args) {
      var env = Java.vm.getEnv();
      var p_size = Process.pointerSize;
      var methods = args[2];
      var methodcount = args[3].toInt32();
      var name = env.getClassName(args[1]);
      console.log("==== class: " + name + " ====");
      console.log("==== methods: " + methods + " nMethods: " + methodcount + " ====");
      for (var i = 0; i < methodcount; i++) {
        var idx = i * p_size * 3;
        var fnPtr = methods.add(idx + p_size * 2).readPointer();
        var infoArr = getModuleInfoByPtr(fnPtr);
        var modulename = infoArr[0];
        var modulebase = infoArr[1];
        var logstr = "name: " + methods.add(idx).readPointer().readCString() + ", signature: " + methods.add(idx + p_size).readPointer().readCString() + ", fnPtr: " + fnPtr + ", modulename: " + modulename + " -> base: " + modulebase;
        if (null != modulebase) {
          logstr += ", offset: " + fnPtr.sub(modulebase);
        }
        console.log(logstr);
      }
    }
  });
}

这里注册了 ms.bd.c.l.a , 它的方法签名是 (IIJLjava/lang/String;Ljava/lang/Object;)Ljava/lang/Object; , 并且在 so 中的偏移是 0x126e30

image-20240818143547405

直接对偏移 0x126e30 的传入传出参数进行 hook, 发现就是六神算法的生成位置~

image-20240818144352514

而在对 ms.bd.c.l.a 进行交叉引用后发现,这个 native 函数还有一个字符串加密的功能如图所示,通过调用 l.a 函数,并传入 code 为 0x1000001 来实现 java 层的字符串加密,接下来我们来对这个字符串加密算法进行更加深入的研究

image-20240818162150793

# libmetasec_ml.so 反混淆

这个 so 的花指令主要有五类 (其实还有一类是 CSEL-BR 寄存器跳转混淆,但是由于字符串解密算法不涉及 CSEL-BR 混淆,所以此处没有给出相关的示例)

  1. BL-BR 寄存器跳转
    这个寄存器跳转简单读一下汇编就可以很清楚的知道是怎么实现的啦,主要是通过一个函数获取到了当前 BL 指令下一行的内存地址,然后通过加上一个偏移使用 BR 寄存器跳转的形式跳过去,所以我们只要把中间越过的指令直接 NOP 掉就好啦

    image-20240817210246836

  2. B.GT-CBNZ 垃圾指令
    这里出现了一大段 IDA 无法解析的指令

image-20240805011945451

其实对于此处的汇编来说,它执行后最终的值是固定的

MOV             W8, #0x1B5
MOV             W9, #0x16F
STR             W8, [SP,#0x4C]
STR             W9, [SP,#0x48]
LDR             W8, [SP,#0x4C]
LDR             W9, [SP,#0x48]
CMP             W9, #0x4C ; 'L'
B.GT          
MOV             W9, #1
MADD            W8, W8, W8, W9
MOV             W9, #7
UDIV            W9, W8, W9
SUB             W9, W9, W9,LSL#3
ADD             W8, W8, W9
CBNZ            W8, loc_55318

我们可以使用 unicorn 将这段汇编运行一下看看具体的值

# code for test-fla.elf
from unicorn import *
from unicorn.arm64_const import *
from keystone import *  # pip install keystone-engine
from capstone import *  # pip install capstone
BASE = 0x0
CODE = BASE + 0x0
CODE_SIZE = 0x100000
STACK = 0x7F00000000
STACK_SIZE = 0x100000
FS = 0x7FF0000000
FS_SIZE = 0x100000
ks = Ks(KS_ARCH_ARM64, KS_MODE_LITTLE_ENDIAN)  # 汇编引擎
uc = Uc(UC_ARCH_ARM64, UC_MODE_LITTLE_ENDIAN)  # 模拟执行引擎
cs = Cs(CS_ARCH_ARM64, CS_MODE_LITTLE_ENDIAN)  # 反汇编引擎
def hook_code(uc: unicorn.Uc, address, size, user_data):
    # print(hex(address))
    for i in cs.disasm(CODE_DATA[address - BASE:address - BASE + size], address):
        # arm 架构是 NZCV 寄存器,32 位的寄存器最左边开始的四位依次存储 N,Z,C,V 标志
        ZF_flag = (uc.reg_read(UC_ARM64_REG_NZCV) >>30)&1
        if i.mnemonic == "b.gt":
            print(f"0x{i.address}: {i.mnemonic} {i.op_str} ; ZF = {ZF_flag}")
            uc.reg_write(UC_ARM64_REG_PC, address + size)
        elif i.mnemonic == "cmp":
            w9 = uc.reg_read(UC_ARM64_REG_W9)
            print(f"0x{i.address}: {i.mnemonic} {i.op_str} ; ZF = {ZF_flag}, w9 = {hex(w9)}")
        elif i.mnemonic == "cbnz":
            w8 = uc.reg_read(UC_ARM64_REG_W8)
            print(f"0x{i.address}: {i.mnemonic} {i.op_str} ; ZF = {ZF_flag}, w8 = {hex(w8)}")
            uc.reg_write(UC_ARM64_REG_PC, address + size)
        else:
            print(f"0x{i.address}: {i.mnemonic} {i.op_str} ; ZF = {ZF_flag}")
def hook_mem_access(uc: unicorn.Uc, type, address, size, value, userdata):
    pc = uc.reg_read(UC_ARM64_REG_PC)  # UC_ARM64_REG_PC
    print('pc:%x type:%d addr:%x size:%x' % (pc, type, address, size))
    # uc.emu_stop()
    return True
def inituc(uc):
    uc.mem_map(CODE, CODE_SIZE, UC_PROT_ALL)
    uc.mem_map(STACK, STACK_SIZE, UC_PROT_ALL)
    uc.mem_write(CODE, CODE_DATA)
    uc.reg_write(UC_ARM64_REG_SP, STACK + 0x1000)
    uc.hook_add(UC_HOOK_CODE, hook_code)
    uc.hook_add(UC_HOOK_MEM_UNMAPPED | UC_HOOK_INTR, hook_mem_access)
shellcode = '''
MOV W8, #0x1B5
MOV W9, #0x16F
STR W8, [SP,#0x4C]
STR W9, [SP,#0x48]
LDR W8, [SP,#0x4C]
LDR W9, [SP,#0x48]
CMP W9, #0x4C
B.GT #0x55318
MOV W9, #1
MADD W8, W8, W8, W9
MOV W9, #7
UDIV W9, W8, W9
SUB W9, W9, W9,LSL#3
ADD W8, W8, W9
CBNZ W8, #0x55318
'''
CODE_DATA,count = ks.asm(shellcode)
CODE_DATA = bytes(CODE_DATA)
inituc(uc)
try:
    uc.emu_start(0x0, len(CODE_DATA))
except Exception as e:
    print(e)

输出如下,可以发现 cmp w9, #0x4c 将 ZF 标志位置为 1, b.gt 大于跳转永远成立, cbnz 跳转也永远成立,也就是不管怎么样,最终都会越过中间这段无用的指令,而跳转到 0x55318 的位置

0x0: mov w8, #0x1b5 ; ZF = 1
0x4: mov w9, #0x16f ; ZF = 1
0x8: str w8, [sp, #0x4c] ; ZF = 1
0x12: str w9, [sp, #0x48] ; ZF = 1
0x16: ldr w8, [sp, #0x4c] ; ZF = 1
0x20: ldr w9, [sp, #0x48] ; ZF = 1
0x24: cmp w9, #0x4c ; ZF = 1, w9 = 0x16f
0x28: b.gt #0x55318 ; ZF = 0
0x32: mov w9, #1 ; ZF = 0
0x36: madd w8, w8, w8, w9 ; ZF = 0
0x40: mov w9, #7 ; ZF = 0
0x44: udiv w9, w8, w9 ; ZF = 0
0x48: sub w9, w9, w9, lsl #3 ; ZF = 0
0x52: add w8, w8, w9 ; ZF = 0
0x56: cbnz w8, #0x55318 ; ZF = 0, w8 = 0x3

所以对于这种 ida 无法识别的无用指令,我们直接 nop 掉即可

  1. F1 F6 77 FF 1F 20 03 D5 1F 20 03 D5 1F 20 03 D5 , 这个花指令主要是用来进行 so 完整性校验的,但是我们主要是进行静态分析,所以对于影响我们看汇编的指令,直接 nop 掉

image-20240805010232627

  1. EB FF 1F D6 01 00 00 D4 , 这个被 ida 解析成了 svc 调用,实际上看看上下文根本没有给 x8 赋值的语句,所以这八字节也是花指令

image-20240805010341218

  1. 01 00 00 D4 DE 01 70 47 , 同样的,这里出现的 svc 也不是真的 svc, 是花指令,直接 patch 掉就好啦

image-20240805010451682

所以最终的去花代码如下

import ida_segment
import idautils
import idc
import ida_bytes
from keystone import *
def patch_nop(begin, end):  # arm64 中的 NOP 指令是 b'\x1F\x20\x03\xD5'
    while end > begin:
        ida_bytes.patch_bytes(begin, b'\x1F\x20\x03\xD5')
        begin = begin + 4
        
# 获取 text 段的起始地址
text_seg = ida_segment.get_segm_by_name(".text")
start, end = text_seg.start_ea, text_seg.end_ea
#start,end = 0x533A4,0x533E0#BL-BR 结构寄存器跳转单个情况测试
#start,end = 0x55288,0x55318#B.GT-CBNZ 跳转 垃圾指令测试
#start,end = 0x94048,0x94070#B.GT-CBNZ 跳转 垃圾指令测试 1
#去除花指令
pattern = ["01 00 00 D4 DE 01 70 47",
           "EB FF 1F D6 01 00 00 D4",
           "F1 F6 77 FF 1F 20 03 D5 1F 20 03 D5 1F 20 03 D5"]
for i in range(len(pattern)):
    cur_addr = start
    end_addr = end
    while cur_addr < end_addr:
        cur_addr = idc.find_binary(cur_addr, idc.SEARCH_DOWN, pattern[i])
        if cur_addr == idc.BADADDR:
            break
        else:
            print("patch flower code: " + hex(cur_addr))  # 打印提示信息
            #patch_nop(cur_addr, cur_addr + len(pattern[i].split(' ')))
        cur_addr = idc.next_head(cur_addr)
current_addr = start
while current_addr < end:
    # BL-BR 结构寄存器跳转
    if idc.print_insn_mnem(current_addr) == "BR":
        BR_addr = current_addr
        BL_addr,nop_count = 0,0
        temp_addr = current_addr
        for _ in range(4):
            if idc.print_insn_mnem(temp_addr) == "BL":
                BL_addr = temp_addr
            elif idc.print_insn_mnem(temp_addr) == "ADD":
                nop_count = idc.get_operand_value(temp_addr, 2)
            temp_addr = idc.prev_head(temp_addr)
        if BL_addr and nop_count:
            print(f"patch BL-BR from {hex(BL_addr)} to {hex(idc.next_head(BL_addr)+nop_count)}")
            patch_nop(BL_addr, idc.next_head(BL_addr) + nop_count)
    #B.GT-CBNZ 跳转
    elif idc.print_insn_mnem(current_addr) == "CBNZ":
        CBNZ_addr = current_addr
        CBNZ_jmp_addr = idc.get_operand_value(current_addr,1)
        BGT_addr = 0
        temp_addr = current_addr
        for _ in range(10):
            if idc.print_insn_mnem(temp_addr) == "B.GT":
                BGT_jmp_addr = idc.get_operand_value(temp_addr,0)
                if CBNZ_jmp_addr==BGT_jmp_addr:
                    print(f"patch B.GT-CBNZ junk code from {hex(CBNZ_addr+4)} to {hex(CBNZ_jmp_addr)}")
                    patch_nop(CBNZ_addr+4, CBNZ_jmp_addr)
                break
            temp_addr = idc.prev_head(temp_addr)
    current_addr = idc.next_head(current_addr)

# 自加载 libmetasec_ml.so

为了防止其他的 so 对我们的干扰,所以可以写个 demo 加载这个 so,但一加载这个 so 的时候程序就崩溃了并留下了下面这一串报错,既然调用了这个类 com.bytedance.mobsec.metasec.ml.MS ,那肯定和算法有些许的联系

image-20240814001314102

而这个 MS 类,他继承了 e0

image-20240814001439387

而这个 e0 类,他又继承了 ms.bd.c.l 这个类,这个类就是 libmetasec_ml.so 唯一动态注册的函数 a 所在的类,在 e0 中,又定义了四个函数,但是他们的函数竟然啥内容都没有

package ms.bd.c;
import com.bytedance.covode.number.Covode;
public class e0 extends l {
    static {
        Covode.recordClassIndex(1018605);
    }
    private static final void Bill() {
    }
    public strictfp void Francies() {
    }
    public static final void Louis() {
    }
    public static final void Zeoy() {
    }
}

把缺少的类补上去之后,随便调用一个字符串加密的函数

Log.d(TAG,(String)l.a(0x1000001, 0, 0L, "d8044a", new byte[]{0x30, 41, 3, 72, 10, 0x72, 39, 27, 100, 97, 0x7B, 0x7A, 81, 69, 12, 0x7F, 0x74, 13, 100, 0x76, 59}));

没想到竟然输出了乱码

image-20240814211204858

为什么调用本来的字符串加密算法不能正常输出解密之后的字符串呢?

我思考了一下有以下几种可能的情况存在

  1. 包名 / 签名校验:然而我 hook 了一圈都没找到相关的函数,包名改成和抖音一样的还是不行
  2. 异常环境检测:但是我放到正常的手机上,log 输出的还是同样的结果
  3. 检查 / 读取特定的文件:难道是检查 app 私有目录下有没有特定的文件,如果有特定的文件,就给解密的算法赋值正确的密钥?
  4. 利用 binder 或者 socket 和其他的进程通讯来获取初始密钥?

下面是我对第三种情况读取文件的排查过程,因为考虑到最安全的调用系统函数的方式是 svc 调用

# SVC 调用

svc 的各个 code 对应的含义可以在此处找到

ida 搜索 svc 特征汇编 01 00 00 D4 找到如下位置,在 sub_13E274

image-20240815220549586

对这个函数 hook 一下

function hook_svc() {
    var module = Process.findModuleByName("libmetasec_ml.so")
    Interceptor.attach(module.base.add(0x13E274),
        {
            onEnter: function (args) {
                console.log('hook svc code->',this.context.x8.toInt32());
            },
            onLeave: function (retval) {
            }
        }
    );
}

但是却崩溃了?

用 frida 打个内存读写的断点看看

function memory_read_hook(){
    var module = Process.findModuleByName("libmetasec_ml.so")
    MemoryAccessMonitor.enable(
                {
                    base:module.base.add(0x13E274),
                    size:48
                },{
                    onAccess: function (details) {
                        console.log(details.operation)
                        console.log(get_addr_in_so(details.from));
                    }
                }
            )
}
function get_addr_in_so(addr){
    var process_Obj_Module_Arr = Process.enumerateModules();
    for(var i = 0; i < process_Obj_Module_Arr.length; i++) {
        if(addr>process_Obj_Module_Arr[i].base && addr<process_Obj_Module_Arr[i].base.add(process_Obj_Module_Arr[i].size)){
            return addr.toString(16)+" is in "+process_Obj_Module_Arr[i].name+" offset: 0x"+(addr-process_Obj_Module_Arr[i].base).toString(16);
        }
    }
    return addr.toString(16);
}

需要注意的是我们需要在对 svc 的 hook 完成之后,延迟三秒在打内存读写断点

image-20240815230204317

frida 打印出了如下日志随后进程退出

image-20240815230400591

跳转到 0x13e030 看看,看来是对 svc 函数的完整性做了校验来防 hook

image-20240815230857961

那就对这个函数进行 hook 把校验的值强制改成 0x312768B , 没想到又崩溃了,但是打印的地址还是和先前相同,因为 frida MemoryAccessMonitor 自身的限制,只在第一次访问内存页的时候产生回调,后续的访问就不显示了,这个特性在 frida 的官方文档中有所提及

image-20240816200219287

接下来考虑打个硬件断点看看

stackplz 打硬件断点
./stackplz --rpc --stack

hook 代码如下

function log(msg) {
    console.log(`${msg}`);
}
async function SetHWBrk(brk_addr, brk_type) {
    try {
        let size_len = 4;
        let brk_options = {
            // brk_pid: Process.id,
            brk_pid: -1,
            brk_len: 4,
            brk_type: brk_type,
            brk_addr: brk_addr,
        };
        // open conn
        log(`[SetHWBrk] open conn`);
        // stackplz --rpc-path
        var conn = await Socket.connect({
            family: "ipv4",
            host: "localhost",
            port: 41718,
        });
        let payload = JSON.stringify(brk_options);
        log(`brk_options -> ${payload}`);
        let msg_len = payload.length;
        // send payload size
        let size_buffer = Memory.alloc(size_len);
        size_buffer.writeU32(msg_len);
        await conn.output.writeAll(size_buffer.readByteArray(size_len));
        // send payload
        let payload_buffer = Memory.alloc(payload.length);
        payload_buffer.writeUtf8String(payload);
        await conn.output.writeAll(payload_buffer.readByteArray(payload.length));
        // try read resp size
        let resp_size_buffer = await conn.input.readAll(size_len);
        let resp_size = resp_size_buffer.unwrap().readU32();
        let resp = await conn.input.readAll(resp_size);
        log(`resp -> ${hexdump(resp)}`);
        // close conn
        await conn.close();
    } catch (error) {
        log(`[SetHWBrk] error ${error}`);
    }
}
function do_hw_brk() {
    // modify here
    try {
        let lib = Process.getModuleByName("libmetasec_ml.so");
        SetHWBrk(lib.base.add(0x13E274), "r");
    } catch (error) {
    log(`error ${error}`);
    }
}

遇到这个 unable to create socket: Operation not permitted , 这是由于测试的 demo 没有给网络权限所以不能建立 socket

image-20240816201801046

AndroidManifest.xml 里面加上这个权限就可以了

<uses-permission android:name="android.permission.INTERNET"/>

但是貌似 hit 不到, 0x23c00 跳转过去是空的并没有代码

image-20240816205135003

使用 rwprocmem33 打硬件断点,发现硬件断点命中了上千万次,而检测点的偏移计算出来是 0x13e0e8

image-20240816213750249

这个检测点就是我们先前去除垃圾指令后看到的这个函数

image-20240816214140057

这不是和我们之前用 frida 的 MemoryAccessMonitor 找到的监测点一模一样吗,那为啥下面这个脚本没办法过检测呢?直接把 checksum 修改成和正确的值 0x312768B 一样不应该行得通的嘛

function bypass_svc_func_check() {
    var module = Process.findModuleByName("libmetasec_ml.so")
    Interceptor.attach(module.base.add(0x13E10C),
        {
            onEnter: function (args) {
                console.log('internal svc func check bypass, x8 ',this.context.x8,'-> 0x312768B');
                this.context.x8 = 0x312768B;
            },
            onLeave: function (retval) {
            }
        }
    );
}

但是我们想想,rwprocmem33 打印出来的硬件断点,命中次数达到了上千万次,那么这绝对是另外起了一个线程来专门对这个地方做检测的

所以这个检测线程既然如此暴力,那么我们索性也一不做二不休,直接把检测的汇编暴力 patch 了不就好了!

具体代码如下,使用 Memory.protect 将这个 so 设为 rwx , 然后直接修改内存,把 B.NE 跳转改为 B 跳转

image-20240816215710033

这样这个 so 不管起多少线程,其内部线程的执行逻辑都将以我们期望的方式运行

function bypass_svc_func_check() {
    var libso = Process.getModuleByName("libmetasec_ml.so");
    console.log("[name]:", libso.name);
    console.log("[base]:", libso.base);
    console.log("[size]:", ptr(libso.size));
    console.log("[path]:", libso.path);
    Memory.protect(ptr(libso.base), libso.size, 'rwx');
    Memory.writeByteArray(ptr(libso.base).add(0x13E110),[0X03, 0X00, 0X00, 0X14]);
}

终于我们也是成功的 hook 到了 svc 的调用码

image-20240816215930992

各个 svc code 的含义如下

hook svc code-> 56(__NR_openat) open /proc/self/exe
hook svc code-> 62(__NR3264_lseek)
hook svc code-> 63(__NR_read)
hook svc code-> 57(__NR_close)
hook svc code-> 135(__NR_rt_sigprocmask)
hook svc code-> 172(__NR_getpid)
hook svc code-> 178(__NR_gettid)
hook svc code-> 135(__NR_rt_sigprocmask)

但是感觉没有 open 什么有意义的文件的样子…

# svc 函数 hook 检测的 bug

但是这个检测是有 bug 的,其实这个点我其实在最初就发现了,但是本着严谨的态度于是就顺着这段检测代码想要的方式去过检测

回到检测函数我们看到这个检测函数对 sub_13E274 的 12 行汇编进行完整性检测

image-20240816221230480

但是被检查的函数,可是有 13 行的,所以我们直接对 RET 这行所在的地址进行 hook, 别的什么都不用做就可以成功的绕过完整性检测~

image-20240816222440542

# 字符串解密算法分析

那么如何才能成功的输出解密后的字符串呢?我又回去看了看 hook ms.bd.c.l.a 函数之后打印出来的日志,于是想着去调用最初的几个字符串参数去看看情况

image-20240818165242631

没想到 .ms.md 一起调用的时候, com.bytedance.ttnet.TTNetInit 可以正常输出

image-20240818165608816

而单独调用 com.bytedance.ttnet.TTNetInit 输出的却是乱码

image-20240818165636201

那先调用 .ms.md , 再调用之前解密出乱码的那个密文?

image-20240818165937173

这次字符串被成功的解密出来了!

image-20240818165957226

所以这个加密算法也很明了了,前两次被解密的字符串初始化了密钥,他们和后续字符串解密算法所使用的密钥有所关联的

现在我们用 jnitrace 去 trace 一下解密 .ms 时的 JNI 调用

jnitrace -l libmetasec_ml.so -b fuzzy com.ss.android.ugc.awemea

0x128b24 的位置读取了传入的字符串密钥和密文,它所在的函数名为 sub_128AAC

image-20240818172219054

image-20240818172227363

image-20240820010529912

0x5664c 的位置是出现了被解密的字符串,它所在的函数名为 sub_5611C

image-20240818172235640

进入 sub_128B88 后我们发现,实际上在 sub_5611C 最后调用的 sub_128B88 才是 NewStringUTF 的位置

image-20240820010457580

image-20240820010508273

接下来我们在读取输入的函数 sub_128AAC 打印一下堆栈,来看看究竟是什么函数调用了他们

结果如下,堆栈回溯的地址都指向了 sub_5611C

image-20240820014135813

在这个函数中,初始密钥为 0x71,0x62,0x13,0x14,0x5f,0x77,0x63,0x41,0x31,0x30 , 随后做了读取 str 密钥,密文的操作,并将初始密钥与传入的密钥循环异或生成二次密钥

image-20240820021932419

随后二次密钥与密文循环异或实现解密,但是如果没有没有实现字符串解密的两次初始化 .ms.md 的话,将会对解密后的数组再次异或密钥,并异或 0x55, 通过这种方式来隐去密钥的相关特征

image-20240820022014949

所以最终的字符串解密算法如下~

#code: 0x1000001
def douyin_str_dec(key: str, enc: list):
    init_key = [0x71, 0x62, 0x13, 0x14, 0x5f, 0x77, 0x63, 0x41, 0x31, 0x30]
    key = [ord(key[i % len(key)]) ^ init_key[i] for i in range(len(init_key))]
    ret = [enc[i] ^ key[i % len(key)] for i in range(len(enc))]
    return bytes(ret).decode()
# com.bytedance.ttnet.TTNetInit
print(douyin_str_dec("976bf9",
                     [43, 58, 72, 88, 91, 55, 46, 19, 99, 51, 38, 54, 64, 88, 77, 58, 52, 19, 115, 124, 28,1, 107, 19, 77, 7, 52, 31, 115]))
更新于 阅读次数