看雪学院 前天 23:25
深入解析某加固方案:结构体还原、脱壳与Dex解密
index_new5.html
../../../zaker_core/zaker_tpl_static/wap/tpl_guoji1.html

 

本文详细剖析了一款加固方案的技术细节,包括使用IDA Pro、010 Editor等工具进行逆向分析。文章重点介绍了如何通过动态调试定位并修复被加固的ELF文件,实现自实现的Linker脱壳,以及对Dex文件进行解密和分析。文中还阐述了加固技术的核心原理,如符号覆盖、内存权限修改以及Dex文件在内存中的重构与加载,为理解和应对复杂的移动应用安全防护提供了宝贵的实践经验。

🔍 **ELF文件修复与脱壳**:文章详细阐述了如何通过动态调试和静态修补,定位并修复被加固SO文件的ELF结构。通过分析linker的callFunction,识别出`.init.array`的保护机制,并利用内存dump和IDA Pro的`patch_byte`功能,逐步还原被加密的ELF文件段。特别地,通过分析内存中的数据结构,成功实现了自实现Linker加载真实ELF的功能,为后续分析奠定了基础。

🔑 **Dex解密与内存加载**:在成功脱壳后,文章重点分析了Dex文件的解密过程。通过定位Dex在内存中的起始地址,并利用TEA算法对Dex Header进行解密。当直接修改内存权限失败时,文章介绍了通过`mmap`分配新内存并将原始Dex移动到新内存区域进行解密和加载的备用方案。最终,通过拼接Dex文件路径,解密并加载了所有Dex文件,完成了Dex的还原。

🛡️ **加固技术与原理剖析**:文章深入分析了多种加固技术,包括SO文件的符号覆盖操作,即将真实SO的符号表和字符串表覆盖到壳SO中,使得Linker在查找JNI_OnLoad等函数时能够指向真实SO的函数。此外,还涉及了内存权限的修改(`mprotect`)、Cache刷新以及对Android系统版本(如Yunos)的兼容性处理,展现了加固方案的复杂性和精妙之处。

⚙️ **自实现Linker与结构体还原**:文章的一大亮点是详细介绍了自实现Linker加载真实ELF的过程。通过对内存中数据的分析,逐步还原了`realElfInfo`、`segmentInfo`和`z_stream`等关键结构体。这使得能够清晰地理解Linker如何在没有系统Linker帮助的情况下,自行解析ELF文件头、加载Segment、进行解密和解压,最终将真实的ELF文件映射到内存中。

SharkFall 2025-11-13 18:00 上海

看雪论坛作者ID:SharkFall

内容涉及到结构体还原, 自实现linker脱壳修复, dex解密和dump分析, 以及加固技术原

逆向环境

01

◆lg g5 android 4.4.4

◆ida 9.1

◆010editor

◆记事本, 记录一些重要信息, 哈哈

◆一双小手, 大手也没事, 按的准键盘就可以

Java 层简单分析

02

拿到Apk后先简单分析一下Application, 没有发现骚操作, 确定直接加载lib中so后, 直接进入Native分析。

调试/分析壳子ELF

03

ida打开so后发现导入是空的, 看来了壳子也做了保护, 分析一下。

这里shdr有一些问题, 查看段表找不到 .init.array, 那就直接动态调试抓把

断点.init / .init.array

挂上调试, 直接断在linker的callFunction, br .init / .init.array 的位置

会先加载Bugly.so, 可以直接跳过, 直到libshella.so加载后再f7跟进去即可

AUV, 您猜怎么着, 断住了!

(其实这里已经分析下去了, 忘了截图, 后补的图)

调试发现只调用了一次 callFunction, 说明只有一个 .init.array

ida静态跳到0x944, 开始分析, 乍一看没有看出什么, 卒

动态分析一下, 一行一行跟着汇编看

补了一下, 发现这里先取了so在内存中的起始地址给elfStart

initArray01 & 0xFFFFF000 取函数页起始地址, *elfStart != 0x464C457F 判断elf magic, 每次向下减一页大小, 直到找到elf magic

(还发现遍历了一下phdr, 但是没有引用, 暂不知道用途)

修复壳子ELF

调试时, 发现这一块在读elfStart+idx的字节, 再各种异或后存回原处, 直接猜测是解密

结合动态调试和静态修补, 以及接下来的函数调用中其中一个函数代码乱码状态, 最终可以确定是动态解密。

elfStart_backup这个是一个拼接的int, 前一个字(short)代表需要解密的起始idx, 后一个字(short)代表需要解密的结束idx

起名elfStart_backup是因为, 取完解密的idx以后就赋值为elfStart地址被后面引用了

那就run to解密完成的地方, 直接dump这一小块, 填进idb中 (偷个懒, 不用重新打开so分析)

mprotect是恢复内存属性为read exec, 断在这里dump即可

auto i, fp, startend;
fp = fopen("d:\\dump_split.so","wb");
start = 0x766AF000; // elfStart + startIdx
end = 0x766B0AB4; // elfStart + endIdx
for (i = start; i <= end; i++) {
fputc(Byte(i),fp);
}
fclose(fp);

dump后再使用idapy填回idb即可开始分析

import idc
def set_arm_code_range(start_ea, end_ea):
"""
将指定范围设置为 ARM 代码
"""
current = start_ea
while current < end_ea:
idc.split_sreg_range(current, "T"0)
current += 4
print(f"Converted range 0x{start_ea:X} - 0x{end_ea:X} to ARM code")
# 使用示例
set_arm_code_range(0x16c30x24b4)import ida_bytes
import os
def patch_elf_from_file(offset, file_path, size=None):
"""
从文件读取内容并修补到ELF的指定偏移
:param offset: ELF文件中的偏移地址
:param file_path: 包含修补数据的文件路径
:param size: 要修补的字节数(如果为None,则使用文件大小)
"""
if not os.path.exists(file_path):
print("Error: File not found - %s" % file_path)
return False
with open(file_path, "rb"as f:
file_data = f.read()
if size is not None:
file_data = file_data[:size]
# 修补数据
for i, byte in enumerate(file_data):
ida_bytes.patch_byte(offset + i, byte)
print("Successfully patched %d bytes from %s at offset 0x%X" %
(len(file_data), file_path, offset))
return True
# 使用示例
if __name__ == "__main__":
# 将patch.bin文件的内容写入到ELF的0x1000偏移处
patch_elf_from_file(0x1000"dump_elf_dec_0x1000_0x2ab4")

ai大法好, 哈哈, 效率+++

接下来在恢复完内存权限后, 做了cache刷新, 避免cup指令缓存没有更新

等一下 syscall 983042 ??

孤陋寡闻了, arm中 syscall调用号983042是cacheflush

接下来就走到了较为核心的逻辑, sub_766B074C是JNI_OnLoad函数, 先下断点

但是这里没有调用, 因为getnenv("DEX_PATH")没有取到东西

然后继续往下走, .init.array就返回来了, 回到了linker

直接f9, 我们就到了JNI_OnLoad函数, 之前断过, 这里是走到了libdvm call JNI_OnLoad

readOffset是0x6dc0, 命名为buildSdkVersion是因为这个值用完以后就被赋值buildSdkVersion了

记住这里的0x6dc0, 下面会考

跟进sub_766B05B4看一下

根据elf开始地址, 在内存中寻找so的完整路径, 然后写入a1, 结束返回上一层

现在jniOnLoad 长这个样子, 跟进sub_766AF63C

修补自实现linker加载so的info结构体

这里的info不是linker的soinfo, 而是elf的结构信息 !

这里的info不是linker的soinfo, 而是elf的结构信息 !

这里的info不是linker的soinfo, 而是elf的结构信息 !

重要的事情说三遍

sub_766AF63C 也是有一点难以阅读, 不过大概总览了一下, 猜测是自实现linker加载elf

有了这个方向, 下面的分析就事半功倍了

这里就是用完0x6dc0后写成buildSdkVerison的地方

先打开soFd, 在读取壳子so+0x6dc0的位置

那么为什么是0x6dc0呢, 这有什么特殊呢, 为什么不读取内存中这个位置呢 ???

小小的脑袋有大大的问号 ???

看了一下内存中libshella.so的内存占用, 计算了一下大小只有33kb

但是so静态大小是98kb, 那很明显了, 后半部分就是藏着的真实elf

继续跟着分析, 发现一个无根之木, size 从哪里来 ???

这里就要开始补全结构体了, 先设置minVAddr为char[88]

然后就很明了了, 从0x6dc0读取的0x58字节, 必然是一个结构体

开始逆向分析结构体中每一个字段代表什么

根据log直接得知第一个int和第二个int分别是minvaddr和size

直接开补 !!!

这里注意, 没有定义的地方也需要保留, 不然结构体大小对不上, 就会飘红, 干扰正常分析

这里对着变量 y 一下, 直接设置刚刚声明的结构体类型

接着就柳暗花明了, 继续猛猛补全即可。

看到这里mmap已经可以完全确定了, 这是一个自实现linker加载真实elf, 那么下面就可以更大胆的猜测了。

补全过程比较枯燥, 就不一个一个字段展示流程了, 展示一下大的点的分析基本就很明了了。

通过这里可以推断这是另一个结构体, 占据24字节, 再结合 [再mmap] 推测应该是segment信息

那么another中第一个short就是segmentInfo结构体存储对应的偏移, 第二个short是segment的数量

(short类型根据汇编LDRH确定)

再往下根据log再结合猜测就可以再补齐很多字段

再记录一下segmentInfo的补全

v48是从v50做页对齐来的, 那么v50就是loadBias+minVAddr了, 那也就是segmentInfo的第一个int就是起始虚拟地址, 后面用来re-map

v46是page end对齐减去(v50 + segmentInfos[1]), 那么segmentInfo的第二个int就是segmentMemSize

猜测是segmentInfo的第二个int是memSize而不是fileSize的原因在下面

这里的从文件读取才是fileSize, 又确定了fileSize和segOffset字段

这里还像是做了一个解密的样子

一路进来, 是不是很眼熟

让我们google一下, 原来是tea算法, 如果想静态解密可以写脚本试试了

那么segmentInfo[5]自然就是解密的数据大小, 单位应该是bit

加密以后又做了一个xz解压, 这里zStream的结构体直接导入就可以

修补结构体完成

最后, 经过了七七四十九天的修补, realElfInfo, segmentInfo, zStream全部补全的差不多以后就非常明了了。

struct realElfInfo
{
int minVAddr;
int totalSize;
    __int16 segmentInfoOffset;
    __int16 segmentCount;
char notUsed[4];
int strtabOffset;
int symtabOffset;
int initFuncOffset;
int initArrayFuncsOffset;
char notUsed_1[8];
bool unusedBool;
    __int16 initArrayFuncCount;
    __int16 neededLibCount;
    __int16 short6;
int neededLibNameIdxArrOffset;
int bucketNum1;
int bucketNum2;
int bucketOffset;
int relaOffset;
int realSymCount;
int pltSymCount;
int pltOffset;
    __int16 short2;
    __int16 short3;
    __int16 short4;
    __int16 short5;
};
struct segmentInfo
{
int minVAddr;
int memSize;
int segmentOffset;
int segFileSize;
int flags;
int segDecryptBits;
};
struct z_stream
{
char *next_in;
unsignedint avail_in;
unsignedint total_in;
char *next_out;
unsignedint avail_out;
unsignedint total_out;
char *msg;
void *state;
void *zalloc;
void *zfree;
void *opaque;
int data_type;
unsignedint adler;
unsignedint reserved;
};

手搓字节码修复真实So

04

补完结构体仅仅是可以看清楚逻辑, 真实elf到现在还没有修复出来, 目测是只能手搓字节码回填了。

找真实So字段信息

这里可以静态解析所有字段, 不过我选择动态一把梭, 直接拿数据, 哈哈哈

都解密完了以后, 就要dlopen neededLib 再重定位函数了

这是几个neededLibName的下标数组, copy一下

再跟进去就是重定位函数, 一眼顶针

这几个地址在前面预先计算好了, 修复的时候就要手动填进dynamic节

巧妙的壳子So符号覆盖操作

再往下做了一个很好玩的操作, 把真实so的符号表, 字符串表 覆盖 到壳子so的符号表和字符串表,这里先记一下。

再恢复segment的flag

再call .init / .init.array就结束了

修复真实ELF

这里根据上面的消息, 尝试修复真实elf

把dump出的整块内存丢进010ediotr, 再把ehdr, phdr, dynamic都手打字节码拼一下, 再使用SoFixer32修复一下即可。

修复完成

丢进ida直接分析, 完美不报错,, 就是导入函数没有, 但是影响不大, 一边调试, 一边命名 -- 静态分析青春版

还请各位大佬指点一下, 这种导入函数看不到是不是plt, rela没修好的原因 ?

正常f5, 但是因为没有import函数, 很多需要函数调用需要手动重命名

截至这里, 真实so就修复好了

调试/分析真实so逻辑

05

分析.init.array函数

linker加载完so就到了.init.array函数, 这里分析一下.init.array先

到了真实elf以后就全都是thumb指令集了, getEnv还是没取到东西, 直接返回

剩下几个.init.array都长这样, 暂且跳过分析

分析JNI_OnLoad函数

前面sub_765B163C的函数call完.init.arry, 出来后就到了调用了真实elf的JNI_OnLoad

壳子So符号覆盖答案揭晓

但是为什么还是dlsym 找JNI_OnLoad呢? 明明是自实现linker加载的elf, 怎么可能通过dlsym找到非linker加载的elf的函数呢?

答案揭晓, 这里的memcpy符号表, 让linker在壳子so找JNI_OnLoad符号的时候, 返回已经修改后的JNI_OnLoad函数。

此时, 壳子so的符号表中的JNI_OnLoad已经指向了真实elf的JNI_OnLoad

跟入分析, 进去后发现就是thumb指令集了, 大概还原了一下

初始化了很多字符串

再注册了native函数, 然后就等待java调用了

全部打上断点, 等待调用

分析 load函数

第一个跑到了load函数, 分析一下

跟入 sub_7660B16C, 非常明显了, 进入下一个

跟入 sub_7660C91C, 呜呜呜, 调试一半手滑点错了, 程序死了, 还好一边调试, 一遍改idb

判断jvm版本是不是2.开头

跟入 sub_76717470, 注册receiver

跟入 sub_76725AF4, 注册了txShell的loadClass函数

这里先不着急看, 先跟着调试看看对于 java.vm.version 的版本的不同分支做了什么

现在的 load 函数长这样, 大概流程: 

1.取buildSdkInt

2.注册recevier

3.判断jvm版本, 进入不同分支加载dex

4.注册**MeShell的loadClass函数

5.定位了artInterpreterToInterpreterBridge和art_quick_to_interpreter_bridge函数

跟入看看这个 sub_CFC4(env, context, 0) 分支, 先从java层取几个值

内存解密 Dex

然后寻找apk对应的DexFile, 找到以后在maps中取/data/dalvik-cache/data@app@com.xxxxxx.apk@classes.dex的起始地址。

找到classes.dex在内存中的位置以后:

1.解析dex, 取到dex中data_size+data_off的偏移, 也就是data之后的位置, 这里应该是原始dex的位置

2.修改clasess.dex内存区域权限为读写

3.找到原始dex的内存地址以后使用tea算法解密dexHeader

4.如果直接修改classes.dex内存区域的权限为读写失败就使用保底方案, 读取原始dex的大小, 并mmap出一片内存, 将原始dex move到mmap出来的内存区域

这里取原始dex地址的时候做了一个页对齐的操作, 向后对齐

解密dexHeader以后就可以尝试dump出来并且还原原始dex了

010editor去掉没用的头部, 粘贴原始dexheader以后丢进jdax发现就可以正常解析了

发现还是有注入data到dex中, 说明不止一个dex, 后面应该还有别的dex, 暂且先不管, 继续跟着跑

加载 Dex

拼接了mixSoPath和mixDexPath以后就开始解密并加载所有dex了

先加载mix.dex (空壳dex, 只有类), 然后取了mix.dex的DexFile的mCookie去内存解析, 一路找到DvmDex结构体指针

然后解析了原始dex header, 重新pack了结构体

struct dexHeader
{
int unused;
int memAddr;
int strIdxTab;
int typeIdxTab;
int fieldIdxTab;
int methodIdxTab;
int protoIdxTab;
int clsDefIdxTab;
int linkData;
char unused3[8];
int memAddr3_Normal;
char unused2[44];
int memAddr2_Android2;
};

dexCreateClassLookup, 创建类查找表

填充DvmDex结构体,

将所有DexFile放进elements数组以后, 替换原dexElements数组

然后函数就return退出了, 回到了load函数最后一个部分

没什么太麻烦的点, 直接上图,实际上这里是没有跑到的, 我的样本reg native返回0, 然后就退出了

这里还check了是不是yunos, android 4.4 时代的yunos, 我搜了搜貌似是阿里做的定制版系统, 可惜没有见过

定位了artInterpreterToInterpreterBridge和art_quick_to_interpreter_bridge函数

分析 changeEnv函数

load函数结束以后, 直接f9就来到了changeEnv函数

函数逻辑比较简单, remove了mAllApplications中的mInitialApplication, 也就是加固自身的application

再替换原始application

1.修改mApplicationInfo的clsName为原始application name

2.通过loadedApk.makeApplication构造原始application

3.替换activityThread的mInitialApplication为原始application

分析 runCreate函数

非常简单, 调用了原始application的onCreate就结束了

还有两个函数, 分别是receiver和txEntries, 没有调用到, 函数体也比较简单, 贴个反汇编把

接着f9几下, 程序就正常进入了

小结

06

一路调下来感觉最惊艳我的还是自实现linker加载真实elf, 真是麻雀虽小,五脏俱全, 只用了大概一两百行就做出了完整加载实现, 受益匪浅。

还有解密壳子so的操作, 通过地址页边界对齐, 再逐次减去页大小向下寻找elf头, 真是非常骚的操作。

*本文为看雪论坛优秀文章,由 SharkFall原创,转载请注明来自看雪社区

球分享

球点赞

球在看

点击阅读原文查看更多

阅读原文

跳转微信打开

Fish AI Reader

Fish AI Reader

AI辅助创作,多种专业模板,深度分析,高质量内容生成。从观点提取到深度思考,FishAI为您提供全方位的创作支持。新版本引入自定义参数,让您的创作更加个性化和精准。

FishAI

FishAI

鱼阅,AI 时代的下一个智能信息助手,助你摆脱信息焦虑

联系邮箱 441953276@qq.com

相关标签

加固 脱壳 Dex解密 ELF修复 逆向工程 Android安全 linker 结构体还原 Anti-Tampering Unpacking Dex Decryption ELF Repair Reverse Engineering Android Security
相关文章