文章总结: 本文记录了对某App加固样本的内存脱壳与签名逆向过程。通过分析加固特征,利用/proc/pid/mem内存转储获取已解密代码,成功还原HMAC-SHA256签名算法及密钥分发规则。针对未执行方法,提出基于ArtMethod主动调用的全量脱壳方案,并解决动态patchlibart.so引发的崩溃问题。 综合评分: 85 文章分类: 逆向分析,移动安全,漏洞分析
App 抽取壳的内存脱壳与请求签名逆向
星野安全 星野安全
看雪学苑
2026年9月9日 17:59 上海
在小说阅读器读本章
去阅读
在公众号小说中沉浸阅读
目标是逆向某App的网络请求签名 sign。
#
该样本使用了**企业版加固,核心业务方法采用了指令抽取保护,反编译后方法体均为 nop 桩。跟进分析发现,**采用运行期惰性恢复机制:只有被调用到的方法,壳才会将对应的 code_item 回填至内存。由于发起网络请求必然触发签名计算,直接通过 /proc/pid/mem 转储内存即可获取已解密的签名逻辑。
本文记录加固特征分析、/proc/pid/mem 内存脱壳、签名算法还原与 Python 离线复现的过程;后半部分针对未执行代码,记录基于 ArtMethod 级主动调用的全量脱壳方案,以及排查和解决**动态 patch libart.so 引发崩溃的对抗细节。
加固特征分析
入口 Application 为 s.h.e.l.l.S,经典的**特征。解压 APK 后检查 assets 与 lib 目录,关键文件分布如下:
assets/ijiami.dat (~36 MB) 加密的业务 DEX 载荷
assets/ijiami.ajm
assets/ijm_lib/<abi>/libexec.so, libexecmain.so **壳 native(ijm=ijiami)
assets/libijmDataEncryption_<abi>.so
assets/IJMDal.Data
lib/<abi>/libijm-emulator.so ** VMP / 指令模拟
lib/<abi>/libzxprotect.so ** RASP(反 frida / 反调试)
该加固方案结合了指令抽取与 VMP:大部分业务方法指令被抽取,少量逻辑由 libijm-emulator.so 解释执行,加密后的业务 DEX 存放在 ijiami.dat 中,由壳在运行时动态解密并映射加载。未脱壳前 jadx 查看原始 base APK 仅有 143 个类,主要为 ARouter 路由表。
壳的指令回填采用按需惰性策略:方法首次执行时才由壳回填 code_item,未执行的方法在内存中保持 nop 占位。以网络拦截器 jobs.android.retrofitnetwork.BasicParamsInterceptor 为例,在未触发网络请求时,jadx 反编译结果全为 return null,并提示 Invalid debug info offset:
由于网络请求触发前必然执行签名计算,签名相关的类与方法在内存中已被解密还原。因此无需全量脱壳,直接转储运行时内存即可获取有效字节码。
基于 /proc/pid/mem 的内存转储
常规注入手段在此场景下受限,Frida 在 attach 或 spawn 阶段均会被 libzxprotect.so 拦截终止:
attach -> target terminated with signal 9 # 注入 agent 即触发 SIGKILL
spawn -> unexpectedly timed out while waiting for app to launch
由于进程内注入面临反调试对抗,直接root 权限读取 /proc/pid/mem 是一种无侵入方案。该方法不涉及目标进程内的代码执行与线程创建,且内核接口允许直接读取 VMA 权限被设置为不可读(如 -wxp)的 DEX 内存页。
dump 流程如下:首先解析 /proc/pid/maps,筛选出名为 [anon:dalvik-DEX data] 的内存段,在各段起始地址检索 DEX 魔数 dex\n035\0。DEX 头部前 0x70 字节中,用于重建文件的关键字段如下:
偏移 0x08 checksum Adler32(覆盖 magic 之后到文件尾)
偏移 0x0C signature SHA-1,20 字节(同上覆盖范围)
偏移 0x20 file_size 整份 DEX 字节数
读取到 file_size 后,按该长度连续转储内存。对于体积较大的 DEX,其在 maps 中常跨越多个相邻的匿名内存映射区域。此时需按虚拟地址连续读取超出当前段大小的数据(相邻 VMA 在虚拟地址空间上连续),最后按 file_size 截断。
内存中转储的 DEX 镜像虽已包含回填的 code_item,但头部 checksum 与 signature 仍需修复。需按照 DEX 规范重新计算 Adler32 与 SHA-1 并回写,以确保 jadx 正常反编译。
pid=$(pidof com.job.android)
# 过滤全部 DEX 内存段(本例共 496 个 [anon:dalvik-DEX data])
su -c "grep 'dalvik-DEX data' /proc/$pid/maps"
# 在 PC 计算好页对齐的 skip 和 count,从 /proc/pid/mem 提取
su -c "dd if=/proc/$pid/mem bs=4096 skip=<dex_page> count=<npages> 2>/dev/null" > region.bin
# 离线处理:按 header 的 file_size 截断,重算 signature(data[0x20:]) 与 checksum(data[0x0c:])
修复后共提取出 16 个有效 DEX 文件,包含 55,054 个类。
在 full_6f9d1b1000.dex 中定位到了签名核心类 EncryptAndSignUtil 与 SignFor51。检查其 CodeItem:doEncryptOrSign 包含 439 条指令,getRequestBodyAfter 包含 202 条指令,getSignJsonDataFromMap 包含 52 条指令,hmacSha256 包含 42 条指令,指令流完整,未被抽空为 nop。签名相关逻辑已成功恢复。
签名定位:SignFor51 与 EncryptAndSignUtil
检索常用密码学 API(MessageDigest、Mac、Cipher)的交叉引用,定位至核心签名类 com.jobs.network.digest.SignFor51。
该类方法实现均为标准加密原语:hmacSha256 对应标准 HMAC-SHA256,getSHA256 对应 SHA-256,toHexString 使用 Formatter("%02x") 生成小写十六进制字符串。反编译代码完整可读:
请求签名的装配逻辑位于拦截器 com.jobs.network.EncryptAndSignUtil。首先由 isNeedSign 判定目标 host 是否处于验签范围(涵盖 VAPI / IM / AppApiV3 / Young-Cupid 站点,且无 needSign 请求头或该头值为 true)。
核心装配逻辑位于 getRequestBodyAfter(以 young/cupid 业务线为例):
// message = URL host 之后的 path 部分 + getSignJsonDataFromMap(params) 参数转 Gson JSON
String url = request.url().getUrl();
String sign = SignFor51.hmacSha256(
signKeyForHost.getSignKey(), // key
url.substring(url.indexOf(host) + host.length())
+ getSignJsonDataFromMap(map)); // message
return request.newBuilder()
.addHeader("sign", sign)
.post(RequestBody.create(MEDIA_TYPE_JSON, getSignJsonDataFromMap(map)))
.build();
签名计算公式为:
sign = HMAC-SHA256(perHostKey, urlAfterHost + gsonJson(params))
结果置于 HTTP 请求头的 sign 字段。其中 urlAfterHost 为 host 之后的完整 URI。需要注意拦截器的执行次序:CommonParamInterceptor 会先行将公共 query 参数(partner、guid、uuid、clientid、apiversion 等)追加至 URL 中,后续执行的 doEncryptOrSign 对截取后的完整字符串进行签名,因此该截取段包含 query 查询串,而非仅有 path。
参数序列化与签名的关键约束如下:
getSignJsonDataFromMap 遍历参数 Map(实际传入类型为 LinkedHashMap,保证键值插入顺序),对各 value 执行 String.valueOf(v) 转换为字符串存入 JsonObject,最终调用 toString() 输出无冗余空格的 JSON 串。签名拼接的 message 尾部内容与实际发送的 POST 请求体来源于同一次序列化结果,二者需保持逐字节一致。请求头中的 Client-Time 由 CommonHeaderInterceptor 单独生成,取 GMT+8 时区整点秒级时间戳(分、秒、毫秒归零),不参与 cupid 站点的 HMAC 计算。
在 getRequestBodyAfter 中还存在两套分支逻辑:非 young/cupid 站点调用 SignFor51.getRequestBody,计算公式为 signData = MD5(SHA256(clientTime + jsonData + signKey));旧版 appapi 站点调用 CQEncrypt.encrypt(formData, true),body 格式为 application/x-www-form-urlencoded。
数据流向梳理如下:
密钥分发与站点映射
密钥按 host 维度进行配置。枚举类 EncryptAndSignUtil$SignKey 在 <clinit> 中定义了明文密钥常量:
V_API = "8a9f1f19……3456" (32 hex)
V_API_FOR_CAMPUS = "9hnrejix……0t1k"
V_API_FOR_YJS = "1960a9b2……c2cb"
IM = "w$mm……ctvH"
APP_API = "44kC……tNc8"
SIGH_KEY_XY = "lhs3aygg……nt3r" (64 字符)
SIGN_KEY_51JOB = "……" cupid / young 默认
getSignKeyForHost(url) 依据请求 host 选取对应的 key,部分站点结合 query 参数做二级匹配:
switch (host) {
case "appapi.51job.com": case "appapi.51jobapp.com": return APP_API;
case "vapi.51job.com": case "vapi.51jobapp.com":
if (clientid == "000013") return V_API_FOR_CAMPUS;
return clientid == "000004" ? V_API_FOR_YJS : V_API;
case "cupid.51job.com": case "cupid.51jobapp.com":
case "youngapi.yingjiesheng.com": case "youngapi.51job.com":
return api_key == "XY" ? SIGH_KEY_XY : SIGN_KEY_51JOB;
case "im.51job.com": case "im.51jobapp.com": return IM;
default: return V_API;
}
例如首页职位列表请求发往 cupid.51job.com,匹配的密钥即为 SIGN_KEY_51JOB。
未执行方法恢复:ArtMethod 级主动调用与 libart patch 对抗
静态内存转储仅能获取已在运行期触发的方法。针对未被执行、仍为 nop 占位的冷门业务逻辑,需借助进程内 ArtMethod 级主动调用机制实现全量解密。
其原理是在运行时遍历已加载 DexFile 的 ClassDef,解析所有 direct 与 virtual 方法对应的 ArtMethod 指针,主动调用 ArtMethod::GetCodeItem()。由于**在首次解析方法 code_item 时完成回填,主动遍历调用可促使壳执行解密流程,随后顺着 CodeItem* 指针将解密后的字节码转储落盘。
测试基于修改版 Vector 框架实现。Vector 基于 Zygisk 并在 ART 层提供 hook 能力。方案中将 HookInline 底层接入 stealth-poc 的内核无痕 hook 引擎(shpte KPM):利用内核缺页异常机制将目标 libart 函数重定向至克隆的 ghost 内存页执行,避开用户态内存校验与 maps 扫描,未开启时回退至 Dobby。脱壳逻辑位于 native/src/unpack,通过系统属性进行调度:dexfind 负责通过 ART VisitClasses 枚举类,trigger 负责对各方法调用 GetCodeItem 强制触发解密并输出数据。
初次注入测试时,App 在启动 1 秒内发生崩溃。抓取的 tombstone 堆栈信息如下:
崩溃表现为主线程向 libart.so 的 .text 代码段执行写操作时触发 SEGV_ACCERR。崩溃现场 pc 位于匿名可执行段,x18 寄存器指向**的 libexec.so,x27 = 0xd503233f(AArch64 下的 PACIASP 指令),x1 = 0x1000(4KB 内存页大小)。
分析原因为:**壳在启动初期会通过 mmap 创建私有内存空间,逐页覆盖原始 libart 函数序言(包含 PAC 保护指令)以建立其私有 hook 机制。若脱壳 worker 线程在此阶段并发 attach 至 ART 并解析符号,将与壳的内存覆盖逻辑产生写冲突并引发异常。对照实验表明:关闭脱壳开关(unpack=0)时 App 正常运行;开启后一旦 worker 执行 AttachCurrentThread,主线程即触发崩溃。
检查 libexec.so 导入表可以印证上述行为:虽然敏感字符串在静态分析中已加密,但其导入的系统 API 组合明确展示了其运行时行为特征:mmap、mprotect、memcpy 用于修改代码段属性并回写指令,dlopen、dl_iterate_phdr 用于模块定位,ptrace、kill、fork 负责反调试检测,sigsetjmp 与 siglongjmp 用于异常处理:
定位冲突原因后,对 Vector 进行了针对性调整:
-
增加 worker 启动延时(
worker_delay_ms)对 libart 的重写操作集中于进程启动初期。通过设置延时,使 worker 线程等待壳完成自身初始化与 hook 部署后再执行 attach。实测延时配置为 12000ms(12 秒)。
-
回退至纯 Dobby 模式
KPM 依赖的页表操作与内存映射在敏感时机同样易与壳的内存补丁产生冲突。针对转储 CodeItem 场景,Dobby 已满足需求,故将 KPM 设为默认关闭。
-
支持多 VMA 连续转储
增强对单个 DEX 跨越多个内存映射区域时的连续读取逻辑。
通过 APatch 的 resetprop 配置如下系统属性:
resetprop persist.kpmhook.unpack 1
resetprop persist.kpmhook.target com.job.android
resetprop persist.kpmhook.unpack.stealth 0 # 仅使用 Dobby,不启用 KPM
resetprop persist.kpmhook.unpack.traceless 0
resetprop persist.kpmhook.unpack.dexfind 1 # VisitClasses 枚举目标 DEX
resetprop persist.kpmhook.unpack.trigger 1 # 逐方法调用 GetCodeItem 强制回填 -> captures.txt
resetprop persist.kpmhook.unpack.worker_delay_ms 12000 # 延时 12 秒,避开壳初始化冲突
resetprop persist.kpmhook.unpack.extout 1 # 输出至应用外部目录,绕过 MLS 策略
再次启动 App,在 12 秒延时窗口期内交互触发业务逻辑。脱壳逻辑在延时结束后正常执行,在指定目录生成了 captures.txt 及结构 DEX 文件。全量遍历共转储 36 万条 CodeItem 记录(格式为 <region_start> <method_idx> <codeitem_hex>)。
对转储数据进行格式有效性校验:CodeItem 头部记录的 ins_size 需与方法 proto 声明的参数槽位大小匹配(虚方法需计入 this 引用)。编写脚本校验指令序列与反汇编自洽性,36 万条记录匹配率为 1.000,确认指令流完整且未出现索引错位。
该方案为后续分析未经执行触发的冷分支逻辑提供了完整的代码还原能力。
签名还原与 Python 实现
签名算法基于标准 HMAC-SHA256,Python 实现核心逻辑如下:
def sign_cupid(after_host, params, host="cupid.51job.com"):
key = sign_key_for(host) # 对应 getSignKeyForHost,cupid 默认 SIGN_KEY_51JOB
message = "/" + after_host.lstrip("/") + gson_json(params) # after_host 包含 host 之后完整字符串(含 query),带前导 /
return hmac.new(key.encode(), message.encode(), hashlib.sha256).hexdigest()
其中 after_host 必须保留公共 query 参数;gson_json 还原 Java 端 getSignJsonDataFromMap 的序列化规则。计算得到的哈希值写入 HTTP sign 请求头;网关头 Client-Time(GMT+8 时间戳)不计入 HMAC。完整实现已开源至 job51-cli。
接口验证
根据 com.job.android.network.MyNetWorkConfig.getCommonQueryParams 组装公共 query 参数(api_key=51job、clientid=000007、version=16.15.0,以及网关校验的秒级时间戳 timestamp),使用 cupid 默认的 SIGN_KEY_51JOB 计算签名,构造 HTTP 请求进行服务端校验:
-
抓包比对
抓取客户端发出的
open/index/notice-infos请求,提取其sign头部。对相同请求 URL(截取 host 之后的完整串,GET 请求无 body)使用SIGN_KEY_51JOB计算 HMAC-SHA256,计算结果与抓包中的sign字段完全一致。 -
差分测试
请求
open/noauth/index/common-switch,携带正确签名返回 HTTP 200 并进入业务参数错误响应({"status":"100004","message":"参数校验错误"});若篡改签名中的任一字符重放,服务端返回{"status":"110011","message":"鉴权失败,签名错误"}。 -
业务接口调用
调用职位搜索接口
open/good-job-tab/search-new-job-list,服务端返回110104 用户令牌 user-token 不能为空,证明签名验证通过,仅拦截于登录态校验。
进一步测试免登录公开接口 open/noauth/gold-two-silver-three/search-job-list,携带签名请求成功返回岗位列表({"status":"1","message":"成功"}),各岗位职位信息完整;职位详情接口 open/noauth/jobs/detail/base/{jobId} 同样无需登录态即可获取完整 JD。实测中 uuid、partner 参数传入随机值均不影响返回结果,表明该网关仅针对 sign 进行签名校验。
验证过程明确了两个关键细节:网关校验的时间戳位于 query 参数 timestamp;签名校验范围覆盖 URL 中的完整 query 字符串。
将上述算法封装为 CLI 工具 job51-cli,可实现脱机的职位数据抓取:
复盘
-
惰性抽取机制下的取舍
该 App 采用按需回填策略,仅执行过的代码会被解密回写。对于网络请求、加解密等高频核心逻辑,在应用触发对应操作后,直接利用 root 权限读取
/proc/pid/mem即可Dump完整 CodeItem,避开了注入层面的反调试检测。 -
ART 脱壳的时机对抗
在启动初期会对
libart.so注入私有 hook 并修改内存属性。脱壳线程若在壳初始化完成前强行 attach 并解析符号,易引发并发内存访问崩溃。引入启动延时(如等待 12 秒)避开初始化窗口,是一种实用的应对手段。 -
签名还原细节
签名核心算法为 HMAC-SHA256,其校验严格依赖序列化格式(
LinkedHashMap键值插入顺序、value 强制转换为字符串)。
工具
看雪ID:星野安全
https://bbs.kanxue.com/user-home-1085268.htm
*本文为看雪论坛优秀文章,由 星野安全 原创,转载请注明来自看雪社区
9月10日【议题征集】截止
往期推荐
HTB Nimbus渗透测试靶机 Writeup
当高频观测不再经过异常路径:Shadow Cave 与常驻式插桩架构
D3CTF 2026 d3llvm.apk 反调试定位与加密 SO的Dump
一串反引号,十层突破:n1ctf‑2018‑easy_harder_php 完整利用链
实现一个EDR不可见的网络通信(将lwip移植到nt内核中)
球分享
球点赞
球在看
点击阅读原文查看更多
免责声明:
本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。
任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。
本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我。
本文转载自:看雪学苑 星野安全 星野安全《App 抽取壳的内存脱壳与请求签名逆向》
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。










评论