【系列文章】阿里 sgmian 初始化详细分析 10102
前情提要
时间: 2026 年 11 月 04 日 星期三 18:59:50 CST
作者: 人生导师
依旧是慢慢搞的,配合模型进行分析吧,要不是真的难办,随项目写的太烂了不过没办法,有的时候就是当时想到的
推荐有虚拟机分析经验的朋友看,并且跟出来也非常非常恶心
正文
10102 是最简单的一个了,只做准备工作没有多线程调用,把三个参数传递进去然后绑定最终返回 0
命令号进来以后会进行一些取余之类的操作,大概如下,模型也是几秒钟就出来了
// 0x5a310 ~ 0x5a378,简化后
a = cmd / 10000; // 0x68db8bad, asr #0x2c
r = cmd % 10000; // MSUB:cmd - a*10000
b = r / 100; // 先乘 0x147B(5243),到 callee 里再 asr #0x13(>>19)
c = cmd % 100; // 0x51eb851f, asr #0x25 得 cmd/100,再 MSUB 回余数
cmd = 10102 代进去:
a = 10102 / 10000 = 1
b = (10102 / 100) % 100 = 1
c = 10102 % 100 = 2
然后是 ADR X14, loc_5A380 加一串 EOR/SUB/BR 的混淆跳转。IDA 是看不出来花指令的,但 trace 直接跳吧:
line 44 0x5a3ac br x14
line 45 0x5a3c0 asr w9, w8, #0x13 <-- 落在 doCommand_decode_and_invoke(我给它的重命名)
这块怎么说呢?花指令这个玩意给我搞的懵逼了,我看 ida 自己分析那个函数边界,我给他当真了,然后我还在想这些东西到底怎么搞的,寄存器咋不对劲啊,然后给 trace 日志和 ida 都拿出来比较,想起来花指令是模糊了函数边界,上下汇编是一体的不能拆开看,然后可以发现把解析后的三个数字传递进去,后面还跟着一个 1,当时只能把它当一个数字,因为不知道是干啥的(后来看 vm_invoke 才知道是选表的 flag)
还有就是直接进来时候 x0 的值给了 x19,还有一个 x4 这个东西是一个结构体
x4 结构体
这个结构体以我的实力肯定是推不出来的,但是 DeepSeek 一眼解决,我只能讲讲为什么
前面那些参数都可以一眼看出来,唯独这个 x4 是ADD X4, SP, #40(也就是 SP+0x28)
vm_invoke(
a,
b,
c,
/*flag=*/1,
struct ctx *ctx /*X4=SP+0x28*/,
int *status /*X5=SP+0x24*/
);
我来带各位从内存布局上开始推吧,先把这个内存布局推出来然后再根据它实际代码执行进行理解
LOAD:000000000005A3C0 ASR W9, W8, #19 ; doCommand 解码后组 ctx {a,b,c,env,args},再交给 VM 分发
LOAD:000000000005A3C4 STR X3, [SP,#64] ; ctx.args = X3(Java Object[])
LOAD:000000000005A3C8 ADD W1, W9, W8,LSR#31
LOAD:000000000005A3CC ADD X4, SP, #40
LOAD:000000000005A3D0 ADD X5, SP, #36
LOAD:000000000005A3D4 MOV W3, #1
LOAD:000000000005A3D8 STP WZR, W0, [SP,#36]
LOAD:000000000005A3DC STP XZR, X19, [SP,#48] ; ctx.env = X19(doCommandNative 保存的 JNIEnv*)
LOAD:000000000005A3E0 STP W1, W2, [SP,#44]
以 10 进制来看更好看一些,先说 x5 吧,这个简单一眼看出来了:0x5A3D0 把 x5 指到 SP+36,紧接着 0x5A3D8 的 STP WZR, W0, [SP,#36] 第一个寄存器就是 wzr,写的正是 x5 指的那块,所以 x5 顺手就被清零了
以 x4 地址为基线来看,w0 写入的地址是 sp+40,也就是 x4 的基线,然后 w1、w2 后续写入了 sp+44、sp+48 这两个内存地址也就是 x4+4 和 x4+8 然后是 0x5A3DC 这个地址它存的是 sp+48 到 sp+55 为 0、sp+56 到 sp+63(x 寄存器是 8 字节的) 为 x19(刚刚进入函数时期设置的 JNIEnv) 然后 x3 并没变,直接被赋值给了 sp+64(x4+24)
struct ctx { // sizeof = 0x20
int32_t a; // +0 : W0
int32_t b; // +4 : W1
int32_t c; // +8 : W2
uint32_t reserved; // +12 : XZR 的高半,恒 0,目前没见谁读
JNIEnv* env; // +16 : X19
jobjectArray args; // +24 : X3 jobjectArray 是一个引用,只有一个64位引用,不是具体数据
};
虽然内存布局是这样的,0x5A3DC 是把 sp+48 到 sp+55 这块清零了,但是后续执行 w2 那条却又 tm 的写进去数据了 W2(覆盖原 XZR 低 4 字节),不知道是设计这样还是编译器优化这样的
把 IDA 里被花指令拆开的那两段函数边界合并成一整段以后,hex-rays 给出的更直白:先 ctx.reserved = 0、ctx.env = env,再 ctx.c = code % 100,正好就是那个 8 字节 STP 被低 4 字节覆盖回来的顺序。+0xC 那半个字节从构造到 handler 入口都没人读过,所以这个名字先挂着 reserved 吧,不排除后面哪条命令会用它
vm_inovke
这个名字不知道准不准确,不过只能先这样叫了,它接手了上面的所有参数,并且在它的入口处有一个小小的反调试,针对于 brk 的检测
也不是很严格,就是看当前执行的这个函数头是不是 brk,不是很理解这个是为什么,为啥会在函数头插入 brk 呢?之前技术栈都没到内核的吧?还希望各位大佬解惑一手
然后把那个引用给清了,不知道为啥,还是没看懂,不过问题不大反正已经存到 x4 了,然后加载了两段数据,DeepSeek 给它们命名为
LOAD:0000000000057014 STR XZR, [SP,#0x40]
LOAD:0000000000057018 ADRL X8, g_vm_script_table
LOAD:0000000000057020 LDR X8, [X8]
LOAD:0000000000057024 ADRL X9, g_vm_native_table
LOAD:000000000005702C LDR X9, [X9]
然后对上层传递进来的 w3 进行了一个比较(这层是 w24),看看传递进来的是不是 0,然后根据结果选表:非 0 走 g_vm_native_table,为 0 走 g_vm_script_table。也就是说 doCommand 传进来的那个 1 不是什么数字,就是个 flag,10102 这种 C++ 侧的命令走 native 表(这里 F5 一下就可以非常明确的看见 if 了),然后加了一个读写锁,然后就开始了参数的赋值,随后进入了 0x567a8 函数(解析命令号)
LOAD:0000000000057044 MOV X0, X25 // 前文比较后得到的是 g_vm_native_table 的数据
LOAD:0000000000057048 MOV W1, W22 // a
LOAD:000000000005704C MOV W2, W21 // b
LOAD:0000000000057050 MOV W3, W20 // c
LOAD:0000000000057054 MOV W4, WZR // create=0:只查不建
LOAD:0000000000057058 ADD X5, SP, #arg_40 // &handler,查到的函数指针写这里
0x567a8:命令表查表
这个函数不是"不重要",它其实是 VM 的核心 —— 命令分发就是在这里查表。0x56830 那段也是被花指令拆出去的,合并边界、再把里面 8 个 BR X8 换成 B 之后,一次就能 F5 出来:
cmd = a * 10000 + b * 100 + c; // 又把三个数字拼回原命令号
v31 = *(_DWORD *)(table + 8); // 第一级表的条目数
for (i = 0; **(_DWORD **)(*(_QWORD *)table + 8 * i) != a; ++i)
if (i + 1 == v31) goto 没找到;
entry_a = *(_QWORD **)(*(_QWORD *)table + 8 * i);
三级表,正好对应命令号拆出来的三个数字:
第一级:entry_a[0] 的 int(+0) == a
第二级:entry_b[0] 的 int(+4) == b
第三级:entry_c[0] 的 int(+8) == c
命中之后:
*handler = (__int64)entry_b[1] ^ entry_c[2]; // qword[1] ^ qword[2]
entry_b 的第二个 qword 和 entry_c 的第三个 qword 异或回来才是 handler 真地址。10102 的 (a,b,c) = (1,1,2) 找到的就是 0x5bc2c。
没命中的时候看第五个参数。之前我以为它是"清零了不知道干啥",其实是 create:
create == 0(vm_invoke 传的就是 0):只查不建,直接返回错误码
create != 0:calloc 一个条目填进去,还会调表头 +0x18 的插入回调;key 用 srand48(time(0)) + lrand48() 现生成一个随机值
返回码全是带 key 前缀的:
err = cmd * 10000 + 9904; // a 层没找到
err = cmd * 10000 + 9905; // b 层没找到
err = cmd * 10000 + 9906; // c 层没找到
err = cmd * 10000 + 9999; // create 时条目已经存在
这也正好解释了 vm_invoke 的 hook 分支里那个 key * 10000 + 9907 —— 9904~9907 是同一套带 key 的状态码,拿到码就能反推是哪一层出的问题。
然后回到 vm_invoke:
LOAD:000000000005708C CBZ X8, loc_5713C
LOAD:0000000000057158 loc_57158 ; CODE XREF: vm_invoke_impl+7C↑j
LOAD:0000000000057158 LDR X8, [SP,#arg_40]
LOAD:000000000005715C MOV X0, X23
LOAD:0000000000057160 MOV X1, X19
LOAD:0000000000057164 BLR X8 ; 查表成功:v13(ctx, status) 调用 handler
LOAD:0000000000057168 STR X0, [SP,#arg_10]
LOAD:000000000005716C LDR X8, [SP,#arg_10]
LOAD:0000000000057170 STR X8, [SP,#arg_8]
走这个分支走查表成功的函数,也就是我们的目标,这次就是 0x5bc2c 这个地址
handler:0x5bc2c
v13(ctx, status) 进来以后就是 10102 的真身。x0 是 ctx,x1 是 status,函数头先把这俩存了:
0x5bc48 MOV X19, X1 ; status
0x5bc4c MOV X20, X0 ; ctx
后面所有 [X19] 都是写状态码。然后又是一组 ADR/BR 跳板跳到 0x5bc98 —— 这函数也是花指令切开的,尾部还单独被 IDA 认成了一个函数(0x5c8f4 那段)。把边界合并、BR 顺手改成 B 以后,整个 handler 一次就能 F5 出来,这才看得出流程。
第一句是:
*status = 9999;
先丢个哨兵。后面每个失败分支把 status 改成 9901/9902/9903,成功改 0。所以看到 9999 就说明压根没走到底。
两段懒初始化
第一次进来先补字符串表,byte_2F6890 守着。一堆 off_1C5EXx 调用,参数都是 (dst, len, 密文, key, keylen, 常量),只是算法有 xor+add / sub+xor 好几种变体。解出来的就是后面要用的字符串:
| 全局 |
内容 |
| g_str_slot |
slot |
| g_str_J |
J |
| g_str_libsg |
libsg |
| g_str_so_dash |
so- |
| g_str_main |
main |
| g_str_ro_build_version_release |
ro.build.version.release |
| g_str_ro_build_version_sdk |
ro.build.version.sdk |
| g_str_SGPluginExtras |
55 字节,全限定类名 |
顺手验证了一下 xor+add 这个变体:out[i] = (key[i % keylen] ^ enc[i]) + 加数。拿它把 resolve_jni_onload 里那 11 个字节解出来,正好是 JNI_OnLoad。
第二段是 runtime 初始化,byte_2F4F14 守着:
desc = { 0, &g_tag_plve, 1, 0, 0 };
rt = get_runtime_singleton();
ret = (*(rt + 0x10))(1, 32, 12, 0, &desc, &rc);
g_vm_init_flag = rc ? 1 : ret;
if (g_vm_init_flag > 0)
sg_set_sLoadVersion();
(1, 32, 12) 这组数字先不深究,反正 >0 就是起来了,顺手把 sLoadVersion 置 1。10102 自己不干活,它就是把 SDK 的运行环境拉起来。
参数契约
env = ctx->env;
len = env->functions[171](env, ctx->args); // GetArrayLength
if (len != 3 && len != 4) { *status = 9901; ... }
0x558 / 8 = 171,JNI 函数表里就是 GetArrayLength。所以 Java 侧必须传 3 个或者 4 个元素的 Object[]:
args[0] 主工程名,正常是 "main"
args[1] 版本号,比如 "6.8.260901"
args[2] so 的完整路径,可以为空
args[3] 可选,一个带静态 long slot 字段的 Class
前三个用 array_string_element_to_cstr(env, args, i) 转成 cstr(里面就是 GetObjectArrayElement + GetStringUTFChars)。
args[3]:藏在 Java 侧的 slot
if (init_ok && len > 3) {
cls = GetObjectArrayElement(env, args, 3);
fid = GetStaticFieldID(env, cls, g_str_slot, g_str_J); // "slot", "J"
slot = env->functions[151](env, cls, fid); // GetStaticLongField
}
151 就是 GetStaticLongField。这个 slot 不是数据,是函数指针,而且 XOR 过:
if (slot != 0 && slot != 0x37)
fn = (void *)(slot ^ 0x37);
else
fn = 0; // 没配,走下面正常加载
0 和 0x37 都当没配。这条是给 Java 侧已经拿到过插件句柄的场景准备的,能直接跳过 dlopen。
正常路径:从名字/路径到 JNI_OnLoad
args[2] 非空就直接转成 String;空的话现拼库名去找:
buf = "libsg" + args[0] + "so-" + args[1]; // libsgmainso-6.8.260901
path = find_module_path_in_maps(getpid(), buf);
拿不到路径 → 9902,到此为止。
然后是插件对象:
if (!g_cached_plugin_obj) {
rt = get_runtime_singleton();
if (rt)
g_cached_plugin_obj = (*(rt + 8))(1, 1, 4, 0, 0);
}
接着先看 args[0]:
if (strcmp(args[0], "main") == 0 && g_cached_plugin_obj)
(*(g_cached_plugin_obj + 0x40))(g_cached_plugin_obj); // 绑定 main
if (strcmp(args[0], "main") == 0) {
*status = 0;
return Integer(0); // 直接收工
}
所以传 "main" 那次是不会去加载 so 的,插件对象在就顺手绑一下,然后把 Integer(0) 还回去。整条 10102 里最快的一条路。
不是 main 才继续。如果上面 args[3] 没给 slot,就从 SGPluginExtras 类上再取一次静态 long "slot":
cls = FindClass(env, g_str_SGPluginExtras);
gcls = NewGlobalRef(env, cls);
fid = GetStaticFieldID(env, gcls, g_str_slot, g_str_J);
slot = GetStaticLongField(env, gcls, fid);
拿到值还是老规矩:不是 0 也不是 0x37,就 fn = slot ^ 0x37 直接开跑。
都没有就加载:
sdk = atoi_with_default(get_system_property("ro.build.version.sdk", 0), 0);
release = get_system_property("ro.build.version.release", 0);
if (sdk <= 23 && (!release || release[0] != 'N'))
handle = dlopen(path, 1);
else
handle = find_module_base_in_maps(getpid(), basename(path), 0);
fn = resolve_jni_onload(handle, release, sdk);
resolve_jni_onload 里才把 JNI_OnLoad 解出来,然后分两条:
if (sdk > 23 || (release && release[0] == 'N'))
fn = elf_dlsym(handle, "JNI_OnLoad"); // 自己走 ELF 的 .dynsym
else {
fn = dlsym(handle, "JNI_OnLoad");
dlclose(handle);
}
也就是说 Android N 以后他们不用 dlopen 的句柄了,改成按 basename 在 maps 里找到模块基址,再自己解析 ELF 的 .dynsym 找符号。这个 elf_dlsym 就是手写的符号查找:校验 ELF magic → 扫 program header 找 PT_DYNAMIC → 遍历 dyn 条目 → 拿字符串表比对名字。
拿到 fn:
fn(NULL, get_runtime_singleton()); // JNI_OnLoad(NULL, runtime)
再给 runtime 排一个 init 回调:
cb = malloc(0x18);
cb->path = strdup(path);
cb->version = strdup(version);
cb->next = 0;
init = { sub_5CF38, cb, 30 };
(*(rt + 8))(1, 8, 1, &init, 0);
最后 *status = 0; return Integer(0);(这个 Integer 到底哪来的,见下一节)。
收尾和状态码
所有分支都汇到 0x5caa0:依次 free 掉 String 对象、三个 cstr、两个 property 字符串,再返回 Integer 或者 NULL。
| status |
含义 |
| 9999 |
哨兵,正常不会带出去 |
| 9901 |
参数不对(len 不是 3/4,或者 args[0..2] 拿不到) |
| 9902 |
so 路径无效 / 模块加载失败 |
| 9903 |
JNI_OnLoad 解析失败 |
| 0 |
成功,返回 Integer(0) |
追一下 Integer:其实 JNI_OnLoad 里就缓存好了
handler 里两次 return Integer(0),当时只看到 jni_NewObject_cached(env, 0),那 Integer 这玩意到底哪来的?往上翻一层就找到了。
Integer 的 class 和构造器在全局缓存里,初始化它的函数是 jni_cache_init(JavaVM *vm),入口 0x14f29c。注意这家伙也是花指令重灾区:IDA 之前只把中间一块(0x59ca8 那段)认成了函数,真正的 JNI_OnLoad 入口在 0x59c40。把边界合并、BR 全换成 B 之后,整条链是这样:
jint JNI_OnLoad(JavaVM *vm, void *reserved)
{
if (!(byte_2F4E87 & 1)) { // 一次性解字符串:"libc.so"、"JNICLibrary"
decrypt(...);
byte_2F4E87 = 1;
}
if (!vm) return -1;
qword_2F4E38 = dlopen("libc.so", 2); // 后面 __system_property_get 用
jni_cache_init(vm); // ← Integer 就在这里
env = jni_GetEnv();
if (env) {
...
cls = FindClass(env, "JNICLibrary");
if (cls) {
...
thunk_register_command_handlers(); // 10102 的 handler 进表就是这步
...
}
}
return 0;
}
jni_cache_init 里 Integer 就三句,标准的 JNI 三件套:
cls = (*env)->FindClass(env, "java/lang/Integer");
g_integer_class = (*env)->NewGlobalRef(env, cls);
g_integer_ctor = (*env)->GetMethodID(env, g_integer_class, "<init>", "(I)V");
同一个函数里还顺手缓存了 SecException、getBytes 这些,全是 FindClass → NewGlobalRef → GetMethodID。参数就是 JavaVM *:里面先用 vm->[6](GetEnv)拿 env,拿不到再走 vm->[4](AttachCurrentThread)。
真正 new 的地方是 jni_NewObject_cached:
void *jni_NewObject_cached(JNIEnv *env, int value)
{
if (!env || !*env) return 0;
obj = (*env)->NewObject(env, g_integer_class, g_integer_ctor, value);
if ((*env)->ExceptionCheck(env))
(*env)->ExceptionClear(env);
return obj;
}
JNI 函数表下标:NewObject = 28(0xE0)、ExceptionCheck = 228(0x720)、ExceptionClear = 17(0x88)。
handler 里两处调用,都是 value = 0:
- 0x5c4b4:
args[0] == "main" 的分支
- 0x5ca60:so 加载成功、JNI_OnLoad 调完之后
返回的 jobject 走 X0 原样往回传:
jni_NewObject_cached → handler → vm_invoke → doCommand_decode_and_invoke
→ jni_doCommandNative 的 X0 → JVM
所以 Java 侧 doCommandNative 拿到的确实是 Integer 对象,不是 int。这也是为什么错误要单独走 *status:返回值只有一个寄存器,值走 X0,错误走 status,两边互不干扰。
补丁:Integer 缓存来源
jni_cache_init 这个函数缓存了很多很多的类,先对整数类进行了查找,以下代码
v13 = (__int64)(*env)->FindClass(env, g_str_java_lang_Integer); // 查找这个类
g_integer_class = (__int64)(*env)->NewGlobalRef(env, (jobject)v13) // 看这个类在不在,然后对它反射
g_integer_ctor = (__int64)(*env)->GetMethodID(env, (jclass)g_integer_class, g_str_init, g_str_sig_I_V); // 然后查找它实际方法ID并缓存
然后 10102 直接调用
v38 = jni_NewObject_cached((JNIEnv *)*p_env, 0);
// NewObject 包装(用缓存的 class / methodID)
void *jni_NewObject_cached(JNIEnv *env, int value)
{
jobject v5; // [xsp+8h] [xbp-28h]
_ReadStatusReg(ARM64_SYSREG(3, 3, 13, 0, 2));
if ( !env || !*env )
return 0LL;
v5 = (*env)->NewObject(env, (jclass)g_integer_class, (jmethodID)g_integer_ctor, (unsigned int)value);
if ( (*env)->ExceptionCheck(env) )
(*env)->ExceptionClear(env);
return v5;
}
就直接实现返回 Java 整数 0 了
顺手把 env 的类型还原了
jni_cache_init 里拿到 env 之后,后面所有 JNI 调用都是基于它:
if (!(*vm)->GetEnv(vm, (void **)&env, 65540))
...
(*env)->FindClass(env, "java/lang/Integer");
不过这是把类型打对之后的样子。原始反编译里它是 v15,调用全长这样:
(*(__int64 (__fastcall **)(__int64))(*(_QWORD *)v15 + 1824LL))(v15); // ExceptionCheck
(*(__int64 (__fastcall **)(__int64, __int64))(*(_QWORD *)v15 + 168LL))(v15, v13); // NewGlobalRef
*(_QWORD *)v15 取的是 env 指向结构的第一格 —— 也就是函数表指针 const JNINativeInterface *functions,再加偏移就是表里的成员。所以 v15 的类型就是 JNIEnv *(等价于 const struct JNINativeInterface **),不是 void *。
打上类型之后,偏移自动变成成员名,一眼就能读:
(*env)->ExceptionCheck(env)
(*env)->ExceptionClear(env)
(*env)->NewGlobalRef(env, (jobject)v13)
(*env)->GetMethodID(env, (jclass)g_integer_class, "<init>", "(I)V")
(*env)->DeleteLocalRef(env, v2)
这张表顺手记下来,以后看到 *(_QWORD *)x + 偏移 就知道是不是 JNI 调用、是哪个:
| 偏移 |
下标 |
函数 |
| 0x30 |
6 |
FindClass |
| 0xA8 |
21 |
NewGlobalRef |
| 0xB8 |
23 |
DeleteLocalRef |
| 0x108 |
33 |
GetMethodID |
| 0x88 |
17 |
ExceptionClear |
| 0x720 |
228 |
ExceptionCheck |
一个小尾巴:第一处 FindClass 还是 *(_QWORD *)v19[0] + 48 的原始形态,因为 env = v19[0] 之后 Hex-Rays 没把第一次使用替换过来,逻辑没问题。
10102 小结
看着是"最简单的一个",其实是整个 SDK 的启动开关:
- 解字符串、把 runtime 拉起来、置 sLoadVersion
- 校验 Object[] 是 3 个还是 4 个
- 拿插件对象(runtime 的一次 (1,1,4) 调用,缓存住)
- "main" 只绑定就返回;其他名字才找 so → JNI_OnLoad → 调起来 → 挂 init 回调
- 所有路径都返回 Integer(0),错误全靠 status 带出去
后面 10101/10103 那些命令能跑,靠的就是这里把 runtime 和插件对象准备好了。所以别被它"返回 0"骗了,它做的准备工作比看起来多。
我感觉就这些东西,搞 10 个小厂也不会有这么多的价值,对我来说,10102 让我对数据类型的组合有了更深入的理解
也算是迈上更高层级的一步,之前都是对独立算法或者虚拟机进行分析的,现在是对体系内容进行分析,下一个就开始巨难的提升了
10101 开始有多线程了,没招了,慢慢分析吧