吾爱破解 - 52pojie.cn

 找回密码
 注册[Register]

QQ登录

只需一步,快速开始

查看: 465|回复: 5
上一主题 下一主题
收起左侧

[Android 原创] 【系列文章】阿里 sgmian 番外篇:阿里花指令与数据类型规范

[复制链接]
跳转到指定楼层
楼主
rsds0duck 发表于 2026-11-5 11:57 回帖奖励

【系列文章】阿里 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 → 最后才谈类型和语义。类型和结构体具体怎么做,见第九节。


四、怎么快速认出来

  1. 看到 BR Xn,往前看 3~6 条指令,必有 ADR/ADRL Xn, ...
  2. 中间那段算术是 MOV/EOR/SUB/ADD/MVN/LDRSW 的组合,没有别的分支
  3. 落点经常在常量池(DCD/DCB)后面,这也是 IDA 容易切错的原因
  4. 形态 A 的表项是 qword,值是代码地址,可以 idc.get_qword()
  5. 反汇编里 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

八、踩过的坑

  1. BR 不一定都是 X8,还有 X14/X17/X23/X4
  2. 有的跳转是往回跳(循环),别默认都是往前走
  3. 表项是绝对地址,运行时可能被重定位;IDB 里是文件里的静态值,够用
  4. 函数尾部经常还有一段 cleanup(比如 0x5c8f4),合并时别漏
  5. patch_dword 只改 IDB,不改 .so;trace 的地址/指令不受影响,两边还是同一套
  6. 合并边界后忘了重新打函数原型,参数会一直是 __int64 a1,看着像没修好
  7. off_1C5xxx 里混着间接导入表,BLR 那种不是花指令
  8. 手算时注意 LDRSW 是符号扩展 32 位,栈上埋的常量也按 32 位算
  9. 一个函数里可能同时有多种形态,别以为处理完 A 就没了
  10. 批量脚本只处理有 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)检查清单

打完类型之后,回头过一遍:

  1. 函数原型有没有返回值(void 是最常见的错)
  2. 指针参数是 JNIEnv * 还是 JavaVM *,看 vtable 偏移确认
  3. 结构体字段的宽度,按最后一次写入定
  4. 栈上 60/72/64 字节的缓冲,合并成一个 char buf[N],别留 _OWORD[4]
  5. 缓存句柄都有名字,& 1 的标志位改成 *_ready
  6. F5 再跑一遍,确认没有新增的 positive sp 或 JUMPOUT

十、下次开新版本怎么办

  1. 先按函数扫 BR,用 list_br 看哪些有 xref
  2. 有 xref 的直接 patch_all
  3. 没 xref 的,找 ADR + 算术 + LDRSW [栈],手算两三条就能看出规律
  4. 合并边界(merge_func),再打原型
  5. F5 确认没有 JUMPOUT,然后才开始看业务

这套流程在 10102、10103、JNI_OnLoad、jni_cache_init 上都验证过,本质上就是三句话:

先修边界,再通控制流,最后才谈类型。

并且一个模型参与陌生的二进制逆向,最慢和最烦的也就是对于内存布局的摸索,这个推理是最复杂最慢的,还是推荐多多规范相关数据

免费评分

参与人数 1吾爱币 +1 收起 理由
sae1025 + 1 我很赞同!

查看全部评分

发帖前要善用【论坛搜索】功能,那里可能会有你要找的答案或者已经有人发布过相同内容了,请勿重复发帖。

沙发
Hmily 发表于 2026-11-5 14:16
文章都不长,建议合并到一个帖子中方便阅读。
3#
 楼主| rsds0duck 发表于 2026-11-5 14:53 |楼主
Hmily 发表于 2026-11-5 14:16
文章都不长,建议合并到一个帖子中方便阅读。

内容没重叠,为啥硬塞一篇里面去?

点评

同系列文章不长的情况下建议合并到一篇帖子中方便阅读,也方便合并加分。  详情 回复 发表于 2026-11-5 14:57
4#
Hmily 发表于 2026-11-5 14:57
rsds0duck 发表于 2026-11-5 14:53
内容没重叠,为啥硬塞一篇里面去?

同系列文章不长的情况下建议合并到一篇帖子中方便阅读,也方便合并加分。
5#
 楼主| rsds0duck 发表于 2026-11-5 15:13 |楼主
Hmily 发表于 2026-11-5 14:57
同系列文章不长的情况下建议合并到一篇帖子中方便阅读,也方便合并加分。

那把这两篇删了吧,我之后再补充一下发出来

点评

可以修改完善后@我删除需要删除的帖子就可以了。  详情 回复 发表于 2026-11-5 15:16
6#
Hmily 发表于 2026-11-5 15:16
rsds0duck 发表于 2026-11-5 15:13
那把这两篇删了吧,我之后再补充一下发出来

可以修改完善后@我删除需要删除的帖子就可以了。
您需要登录后才可以回帖 登录 | 注册[Register]

本版积分规则

返回列表

RSS订阅|小黑屋|处罚记录|联系我们|吾爱破解 - 52pojie.cn ( 京ICP备16042023号 | 京公网安备 11010502030087号 )

GMT+8, 2026-11-6 03:32

Powered by Discuz!

Copyright © 2001-2020, Tencent Cloud.

快速回复 返回顶部 返回列表