【系列文章】阿里 sgmian 番外篇:阿里花指令与数据类型规范
前情提要
时间: 2026 年 11 月 05 日 星期四 11:00:00 CST
作者: 人生导师
这篇不聊业务流程,专门把阿里 libsgmainso 这套花指令拆开讲清楚:它长什么样、怎么一眼认出来、怎么在 IDA 里批量 patch,方便以后换版本或者开新会话时直接照着干。
所有例子都来自 libsgmainso-6.8.260901.so,地址是 IDB 里的静态地址。
一、它和传统花指令不一样
传统花指令一般是往指令流里塞垃圾字节、跳进指令中间、或者用条件跳转把真实指令拆成两半。
阿里这套不是:
- 指令是合法的,静态分析能顺着走
- 做法是
ADR 取基址 → 若干算术/查表 → BR 寄存器
- 真正的损招是把 BR 的落点放在常量池后面,让 IDA 自动分析把落点当成新函数入口
结果就是:函数被切成好几段,看起来一堆 BR,F5 只能看到一小截,剩下的全成了 JUMPOUT。
二、三种形态
形态 A:查表跳板(最常见)
ADRL X8, off_1C5D20
LDR X8, [X8, #(off_1C5E58 - 0x1C5D20)]
ADD X8, X8, #0x6C
BR X8
- 表在
0x1C5D20 附近,表项是 qword 绝对代码地址(IDB 里能直接读)
- 目标 = 表项值 + 立即数
- 立即数每个站点都不一样,相当于"从同一个锚点偏移到真正的落点"
实例(都在 cmd10102_handler 里):
| BR 站点 |
表项地址 |
表项值 |
加数 |
目标 |
| 0x5c15c |
0x1C5E58 |
0x5C0F4 |
0x6C |
0x5C160 |
| 0x5c1a8 |
0x1C5DD0 |
0x5C0F0 |
0xBC |
0x5C1AC |
| 0x5c1d8 |
0x1C5DC8 |
0x5C0F7 |
0xE5 |
0x5C1DC |
| 0x5c35c |
0x1C5E50 |
0x5CA41 |
0x5F |
0x5CAA0 |
形态 A 的好处是:IDA 一般能自己算出来,反汇编里会直接跟一个 ; loc_XXXX 注释。
形态 B:算术 + 栈常量
STR W9, [SP,#0x1C] ; 先把常量 0xA1 埋进栈
...
ADR X14, loc_5A380 ; 基址 = 下一条指令地址
LDRSW X4, =0x12A
MOV X9, #0x79
EOR X4, X4, X9
SUB X4, X4, #0xCF
MOV X17, #0x65
EOR X4, X4, X17
LDRSW X11, [X13] ; 读栈上的常量
SUB X4, X4, X11
ADD X14, X14, X4
BR X14
手算:
((0x12A ^ 0x79) - 0xCF) ^ 0x65 - 0xA1
= (0x153 - 0xCF) ^ 0x65 - 0xA1
= 0x84 ^ 0x65 - 0xA1
= 0xE1 - 0xA1
= 0x40
0x5A380 + 0x40 = 0x5A3C0 ← 目标
这类 IDA 通常给不出 xref,必须手算。常量就是函数前面 MOV Wn,#imm + STR Wn,[SP,#x] 埋的那个,后面用 LDRSW Xm,[base] 读回来。
形态 C:函数入口的小跳板
MOV W8, #0x3E
STUR W8, [X29,#...]
...
SUB X8, X29, #-(0x20+var_2C)
ADR X17, loc_5CDD4
LDRSW X11, =0xE
LDRSW X13, [X8]
EOR X11, X11, X13
ADD X17, X17, X11
BR X17
DCD 0xE
和形态 B 一样,只是基址用 ADR 到紧邻的 loc_。libsgmainso 里很多函数的"入口"其实已经是第二段了,真正的 prologue 在别处(比如 JNI_OnLoad 的入口是 0x59c40,不是 IDA 认的 0x59ca8)。
cmd10103_handler 就这一处:
(0x1DE - 0xC3) - 0xEB = 0x30
0x5CB94 + 0x30 = 0x5CBC4
三、为什么 F5 会出问题
被花指令切过的函数,反编译通常是这样的:
void sub_59CA8()
{
if ( (byte_2F4E87 & 1) == 0 )
__asm { BR X8; loc_59CD0 }
__asm { BR X8; loc_59D6C }
}
或者头部出现:
// positive sp value has been detected, the output may be wrong!
这不是 Hex-Rays 不行,是函数边界错了。边界一错,栈帧就错,后面的向量全歪:
- 栈变量全变成
arg_*,而且提示 "argument member"
- 没法给栈变量打类型,
set_type 会失败
- 反编译只能看到第一段,后面全是
JUMPOUT
- 寄存器参数变成
__usercall,参数名对不上
所以顺序永远是:先手工确认边界 → 再 patch BR → 最后才谈类型和语义。类型和结构体具体怎么做,见第九节。
四、怎么快速认出来
- 看到
BR Xn,往前看 3~6 条指令,必有 ADR/ADRL Xn, ...
- 中间那段算术是
MOV/EOR/SUB/ADD/MVN/LDRSW 的组合,没有别的分支
- 落点经常在常量池(DCD/DCB)后面,这也是 IDA 容易切错的原因
- 形态 A 的表项是
qword,值是代码地址,可以 idc.get_qword()
- 反汇编里 IDA 已经算出目标的话,会显示
; loc_XXXX,可以直接用
区分一下:BLR Xn 走 off_1C5xxx 那种多半是间接导入表(比如 off_1C5EB0 = decrypt_string_xor_add),不是花指令,不要误杀。
五、标准 patch 流程
1)先列出函数里所有 BR 和目标
import idc, idautils
def list_br(lo, hi):
for ea in idautils.Heads(lo, hi):
if idc.print_insn_mnem(ea) == "BR":
print(hex(ea), [hex(x.to) for x in idautils.XrefsFrom(ea) if x.iscode])
list_br(0x5bc2c, 0x5cb58) 之类,先看清楚哪些有目标、哪些要手算。
2)合并被切碎的边界
import ida_funcs, ida_auto, ida_hexrays, idautils
def merge_func(start, end):
for ea in [x for x in idautils.Functions() if start < x < end]:
ida_funcs.del_func(ea)
if ida_funcs.get_func(start):
ida_funcs.del_func(start)
ok = ida_funcs.add_func(start, end)
ida_auto.plan_and_wait(start, end)
ida_hexrays.mark_cfunc_dirty(start)
print("merge", hex(start), hex(end), ok)
注意:
del_func(ea) 删的是包含 ea 的整个函数,所以落在 chunk 上也能删掉整段
- 数据池夹在中间不影响合并,函数可以跨数据
ida_funcs.set_func_end() 失败,一般是范围内还有别的函数,先删干净再设
- 合并完要重新给函数打原型,否则参数还是
__int64 a1
3)把 BR 换成 B
编码很简单,AArch64 的 B 是 0x14000000 | (offset >> 2):
def br2b(ea, tgt):
imm = ((tgt - ea) >> 2) & 0x03FFFFFF
idc.patch_dword(ea, 0x14000000 | imm)
批量版(只处理有 xref 且在函数范围内的):
def patch_all(lo, hi):
n = 0
for ea in idautils.Heads(lo, hi):
if idc.print_insn_mnem(ea) == "BR":
t = [x.to for x in idautils.XrefsFrom(ea)
if x.iscode and lo <= x.to < hi]
if len(t) == 1:
br2b(ea, t[0]); n += 1
ida_auto.plan_and_wait(lo, hi)
ida_hexrays.mark_cfunc_dirty(lo)
print("patched", n)
没有 xref 的(形态 B/C)先手算,再单独 br2b(0x5cbb0, 0x5CBC4)。
4)验证
c = ida_hexrays.decompile(lo)
print(c)
能一路读到底、没有 JUMPOUT、参数名是 ctx/status 这种,就算完事。
参数名能不能变成 ctx/status,取决于类型打没打对——这一步见第九节。
六、一个完整例子:cmd10103_handler
原始:cmd10103_handler = 0x5cb58..0x5cbb4,后面 0x5cbc4..0x5cc04 是孤儿代码
步骤:
# 1. 手算 BR X4 @0x5cbb0:
# (0x1DE - 0xC3) - 0xEB = 0x30
# 0x5CB94 + 0x30 = 0x5CBC4
# 2. br2b(0x5cbb0, 0x5CBC4)
# 3. ida_funcs.set_func_end(0x5cb58, 0x5cc0c)
# 4. ida_auto.plan_and_wait(0x5cbb4, 0x5cc0c)
# 5. set_type(0x5cb58, "__int64 cmd10103_handler(struct sgm_vm_ctx *ctx, int *status)")
结果:
__int64 cmd10103_handler(struct sgm_vm_ctx *ctx, int *status)
{
v4 = sub_151498();
*status = sub_A5074(v4, ctx->env, 0) ? 0 : -1;
return 0;
}
七、已经处理过的清单(本 so)
| 函数 |
入口 |
形态 |
BR 数 |
合并边界 |
| JNI_OnLoad |
0x59c40 |
A/B 混合 |
11 |
0x59c40 ← 0x59ca8 |
| vm_resolve_command |
0x567a8 |
A |
9 |
0x567a8 ← 0x56830 |
| cmd10102_handler |
0x5bc2c |
A |
22 |
0x5bc2c ← 0x5bc98 ← 0x5c8f4 |
| cmd10103_handler |
0x5cb58 |
B |
1 |
尾部扩到 0x5cc0c |
| jni_cache_init |
0x14f29c |
A |
35 |
0x14f29c ← 0x150d98 |
| jni_NewObject_cached |
0x152fb0 |
C |
1 |
0x152fb0 ← 0x153018 |
八、踩过的坑
BR 不一定都是 X8,还有 X14/X17/X23/X4
- 有的跳转是往回跳(循环),别默认都是往前走
- 表项是绝对地址,运行时可能被重定位;IDB 里是文件里的静态值,够用
- 函数尾部经常还有一段 cleanup(比如
0x5c8f4),合并时别漏
patch_dword 只改 IDB,不改 .so;trace 的地址/指令不受影响,两边还是同一套
- 合并边界后忘了重新打函数原型,参数会一直是
__int64 a1,看着像没修好
off_1C5xxx 里混着间接导入表,BLR 那种不是花指令
- 手算时注意
LDRSW 是符号扩展 32 位,栈上埋的常量也按 32 位算
- 一个函数里可能同时有多种形态,别以为处理完 A 就没了
- 批量脚本只处理有 xref 的 BR,跑完记得看一眼剩下几个没处理
九、类型与结构体
花指令 patch 完只是"能读了",真正决定 F5 能不能看懂的是类型。这一节记一下类型还原的顺序和几个必踩的点。
顺序和前面一样:边界 → 控制流 → 类型。边界没合并之前别碰类型,set_type 会直接失败。
1)第一件事:分清 JNIEnv 和 JavaVM
这两个都是二级指针,反编译里长得一模一样,但函数表完全不同,认错了后面全歪。
JNIEnv* -> *(env) 是 JNINativeInterface*,成员按 JNI 规范排
JavaVM* -> *(vm) 是 JNIInvokeInterface*,只有 5 个方法
最快的判断方式是看它调了哪些偏移:
JNINativeInterface(JNIEnv)
| 偏移 |
下标 |
成员 |
| 0x20 |
4 |
GetVersion |
| 0x28 |
5 |
DefineClass |
| 0x30 |
6 |
FindClass |
| 0x88 |
17 |
ExceptionClear |
| 0xA8 |
21 |
NewGlobalRef |
| 0xB8 |
23 |
DeleteLocalRef |
| 0x108 |
33 |
GetMethodID |
| 0x388 |
113 |
GetStaticMethodID |
| 0x390 |
114 |
CallStaticObjectMethod |
| 0x3A8 |
117 |
CallStaticBooleanMethod |
| 0x468 |
141 |
CallStaticVoidMethod |
| 0x4B8 |
151 |
GetStaticLongField |
| 0x558 |
171 |
GetArrayLength |
| 0x720 |
228 |
ExceptionCheck |
JNIInvokeInterface(JavaVM)
| 偏移 |
下标 |
成员 |
| 0x18 |
3 |
DestroyJavaVM |
| 0x20 |
4 |
AttachCurrentThread |
| 0x28 |
5 |
DetachCurrentThread |
| 0x30 |
6 |
GetEnv |
| 0x38 |
7 |
AttachCurrentThreadAsDaemon |
实例如下,一开始我把 0x304158 当成了缓存的 JNIEnv(因为函数叫 sgm_get_xxx、返回值往下传),直到看见它同时出现这三个调用:
(*(...)(*(_QWORD *)v + 0x30))(v, &out, 65540); // GetEnv
(*(...)(*(_QWORD *)v + 0x20))(v, &out, &args); // AttachCurrentThread
(*(...)(*(_QWORD *)v + 0x28))(v); // DetachCurrentThread
三个偏移全落在 JNIInvokeInterface 上,所以它是 JavaVM *,不是 JNIEnv *。改正之后,整条链的参数类型才跟着对:
JavaVM *sgm_get_java_vm(void);
int sgm_init_securitybody(JavaVM *vm, JNIEnv *env);
JNIEnv *jni_GetEnv(int *p_attached); // 第二个输出是"是否 attach 了线程"
void jni_DetachCurrentThread(void);
2)打上类型,裸偏移自动变成员名
*(_QWORD *)x 取的就是 functions 表指针。给参数打上 JNIEnv * 之后,IDA 会自己查 JNINativeInterface 的成员:
// 之前
(*(__int64 (__fastcall **)(__int64, __int64))(*(_QWORD *)a1 + 904LL))(a1, v6, ...);
// 之后
(*env)->GetStaticMethodID(env, v6, ...);
前提是本地类型库里的 JNINativeInterface 是全的(IDA 自带的一般都有)。如果显示还是裸偏移,检查是不是类型被识别成了 void *,或者 env 是从一个 __int64 局部变量拷过来的——那种情况要先把那个局部变量也改了。
3)函数原型要和返回值对齐
jni_ExceptionCheck 原来被当成 void,调用点全是这种:
jni_ExceptionCheck();
if ( (v2 & 1 | (v5 == 0)) != 0 ) // v2 是个没赋过值的 w0
把原型改成 jboolean jni_ExceptionCheck(JNIEnv *env),Hex-Rays 才会把返回值接上:
if ( (jni_ExceptionCheck(env) & 1 | (v5 == 0)) != 0 )
同类问题还有 jni_GetEnv(返回 JNIEnv * 而不是 void)、jni_NewStringUTF(返回 jstring)。看到反编译里出现"声明了但没赋值的局部变量",基本都是原型的返回值没打。
4)结构体:从构造点反推字段
先找两个地方——谁在往里写、谁在读,布局自然就出来了。
例子 sgm_jni_desc,构造点是一个 24 字节的栈结构:
v4[0] = a1; // 类名
v4[1] = a2; // 方法签名
v4[2] = a3; // 模式
sub_F6870(v4, 0);
被调方按 a1[0] / a1[1] / a1[2] 读,于是:
struct sgm_jni_desc { // sizeof = 0x18
const char *cls_name; // +0x00
const char *sig; // +0x08
int mode; // +0x10
};
定义完 sgm_jni_desc 之后,反编译从:
v4[0] = a1; v4[1] = a2; v4[2] = a3;
变成:
desc.cls_name = cls_name;
desc.sig = sig;
desc.mode = mode;
5)栈上的结构体:看 STP 的宽度和覆盖顺序
命令 handler 的 ctx 就是这么推出来的。原始指令:
STP WZR, W0, [SP,#36] ; [0x24]=0, [0x28]=W0
STP W1, W2, [SP,#44] ; [0x2c]=W1, [0x30]=W2 ← b, c
STP XZR, X19, [SP,#48] ; [0x30]=0(8字节), [0x38]=env
STR X3, [SP,#64] ; [0x40]=args
注意第二组 STP XZR, X19 是 8 字节写,会把前面 W2 写过的 [0x30] 再清零一次;但后面 STP W1, W2 又覆盖回来了。这种"先清零再覆盖"不要紧,按最后写入的宽度定字段大小就行:
struct sgm_vm_ctx { // sizeof = 0x20
int32_t a; // +0 W0
int32_t b; // +4 W1
int32_t c; // +8 W2
uint32_t reserved; // +12 没人读,先挂着
JNIEnv *env; // +16 X19
jobjectArray args; // +24 X3
};
+0xC 那半个字从构造到使用都没人读,就老实叫 reserved,别硬编语义。
6)给全局句柄命名
缓存类的东西,按"归属 + 类型"起名,读反编译时不用回头查:
g_cls_<类名> 缓存的 jclass(NewGlobalRef 之后)
g_m_<方法名> 缓存的方法名字符串
g_sig_<签名> 缓存的签名字符串
g_<模块>_<语义>_mid 缓存的 jmethodID
g_<模块>_class 该类本身的全局引用
g_<模块>_ready 一次性初始化标志(byte,& 1)
命名之后,这种链一眼就能看明白:
v6 = (*env)->FindClass(env, g_cls_ApmMonitorAdapter);
v5 = (*env)->GetStaticMethodID(env, v6, g_m_stageStart, g_sig_String_String_V);
g_apm_stageStart_mid = v5;
g_apm_class = (*env)->NewGlobalRef(env, v6);
7)检查清单
打完类型之后,回头过一遍:
- 函数原型有没有返回值(
void 是最常见的错)
- 指针参数是
JNIEnv * 还是 JavaVM *,看 vtable 偏移确认
- 结构体字段的宽度,按最后一次写入定
- 栈上 60/72/64 字节的缓冲,合并成一个
char buf[N],别留 _OWORD[4]
- 缓存句柄都有名字,
& 1 的标志位改成 *_ready
- F5 再跑一遍,确认没有新增的
positive sp 或 JUMPOUT
十、下次开新版本怎么办
- 先按函数扫
BR,用 list_br 看哪些有 xref
- 有 xref 的直接
patch_all
- 没 xref 的,找
ADR + 算术 + LDRSW [栈],手算两三条就能看出规律
- 合并边界(
merge_func),再打原型
- F5 确认没有
JUMPOUT,然后才开始看业务
这套流程在 10102、10103、JNI_OnLoad、jni_cache_init 上都验证过,本质上就是三句话:
先修边界,再通控制流,最后才谈类型。
并且一个模型参与陌生的二进制逆向,最慢和最烦的也就是对于内存布局的摸索,这个推理是最复杂最慢的,还是推荐多多规范相关数据