本次分析的鼎鼎大名的frida-dexdump,这个脚本已经发布pypi包,可以通过pip安装也可以在github下载脚本通过python RPC调用使用,它与前两个脱壳脚本有着本质的区别,前面是通过hook系统函数找到dexfile对象进行dump,而dexdump是在内存搜索dex文件特征后判断该dex是否是一个真实的dex文件,然后保存到文件。
【frida-dexdump地址】
原理分析
本次分析展示的代码是笔者根据frida-dexdump作者的源码照抄下来,稍有改动的代码
frida-dexdump执行流程,让读者大致有个轮廓
[ol]
[/ol]
全部代码展示
自己手写的代码注释很全,每个函数都有注释,并不是所有的函数都有用,下文会讲解重点函数,辅助的函数看注释就好。
/**
* Author: samle
* CreateTime: 2024/09/04
*
*
*
* myfrida_dexdump执行流程
* 1.内存搜索dex文件特征,保存到result中
* 2.保存到文件
* .
*
*/
//取map_off偏移后验证该位置是否在dex文件内存区间中,是的话返回,否则返回null
function get_maps_address(dexptr,range_base,range_end){
var maps_offset = dexptr.add(0x34).readUInt();
if(maps_offset === 0 ){
return null;
}
var maps_address = dexptr.add(maps_offset);
if(maps_address range_end){
return null;
}
return maps_address;
}
//验证了maps_end的两个情况不存在,就是有效地址
function get_maps_end(maps, range_base , range_end){
var maps_size = maps.readUInt();
if(maps_size 50){
return null;
}
var maps_end = maps.add(maps_size * 0xc + 4);
//这里有个疑问,地址是向高处生长的,应该是maps_end > base || maps_end range_end){
return null;
}
return maps_end;
}
/*
map结构
map_offset是map_list的文件偏移,通常在dex文件末尾
map_list有两个成员,map_item有四个成员
maplist
uint size
list map_item[size]
map_item
ushort type
ushort unused
uint size
uint offset
如果要想知道整个map_list的长度,用size + map_item[size]就可以了
4 + 12 * size
enum TYPE_CODES {
TYPE_HEADER_ITEM = 0x0000,
TYPE_STRING_ID_ITEM = 0x0001,
TYPE_TYPE_ID_ITEM = 0x0002,
TYPE_PROTO_ID_ITEM = 0x0003,
TYPE_FIELD_ID_ITEM = 0x0004,
TYPE_METHOD_ID_ITEM = 0x0005,
TYPE_CLASS_DEF_ITEM = 0x0006,
TYPE_MAP_LIST = 0x1000,
TYPE_TYPE_LIST = 0x1001,
TYPE_ANNOTATION_SET_REF_LIST = 0x1002,
TYPE_ANNOTATION_SET_ITEM = 0x1003,
TYPE_CLASS_DATA_ITEM = 0x2000,
TYPE_CODE_ITEM = 0x2001,
TYPE_STRING_DATA_ITEM = 0x2002,
TYPE_DEBUG_INFO_ITEM = 0x2003,
TYPE_ANNOTATION_ITEM = 0x2004,
TYPE_ENCODED_ARRAY_ITEM = 0x2005,
TYPE_ANNOTATIONS_DIRECTORY_ITEM = 0x2006
};
*/
//获取dex头的map_off偏移和dex尾部的偏移是否一致
function verify_by_maps(dexptr,mapsptr){
var maps_offset = dexptr.add(0x34).readUInt();
var maps_size = mapsptr.readUInt();
for(var i = 0; i range_end){
return false;
}
if(enable_verify_maps){
var maps_address = get_maps_address(dexptr,range.base,range_end);
if(!maps_address){
return false;
}
var maps_end = get_maps_end(dexptr,range.base,range_end);
if(!maps_end){
return false;
}
return verify_by_maps(dexptr,maps_address);
}
else{
return dexptr.add(0x3c).readUInt() == 0x70;
}
}
return false;
}
//通过maps_list的尾部-dex_base求dex的真实长度。可信度极高
function get_dex_real_size(dexptr,range_base,range_end){
var dex_size = dexptr.add(0x20).readUInt();
var maps_address = get_maps_address(dexptr,range_base,range_end)
if(!maps_address){
return dex_size;
}
var maps_end = get_maps_end(maps_address,range_base,range_end)
if(!maps_end){
return dex_size;
}
return maps_end.sub(dexptr).toInt32();
}
//验证字符串off,类型off,原型off,字段off,方法off的位置是否在文件头后面,dex尾部前面
function verify_ids_off(dexptr,dex_size){
var string_ids_off = dexptr.add(0x3c).readUInt();
var type_ids_off = dexptr.add(0x44).readUInt();
var proto_ids_off = dexptr.add(0x4c).readUInt();
var field_ids_off = dexptr.add(0x54).readUInt();
var method_ids_off = dexptr.add(0x5c).readUInt();
return string_ids_off = 0x70 && type_ids_off = 0x70 && proto_ids_off = 0x70 && field_ids_off = 0x70 && method_ids_off = 0x70
}
var pattern = "64 65 78 0a 30 ?? ?? 00";
var DeepPattern = "70 00 00 00";
function search_dex(deepSearch){
// console.log("deepSearch value is ",deepSearch.toString())
var result = []
Process.enumerateRanges("r--").forEach(function(range){
try{
// console.log("search_dex begin...")
//遍历进程的maps文件的可读内存段
/*
if(range.file.path.indexOf("testdex") != -1){
console.log("rang.base = ",range.base,range.size,range.file.path);
}
*/
Memory.scanSync(range.base,range.size,pattern).forEach(function(match){
//if(range.file.path.indexOf("testdex") != -1){
//}
//console.log("rang.base = ",range.base,range.size,range.file.path);
//console.log("base",range.base,range.file.path);
//排除系统自带dex文件-通过字符串排除
if( range.file && range.file.path &&
( range.file.path.startsWith("/data/dalvik-cache/") || range.file.path.startsWith("/system/") || range.file.path.startsWith("/apex/com") ) ){
return;
}
/*
range.base 输出的是vdex 内存页开始
match.address 输出的dex 匹配特征后的开头
console.log("range",hexdump(range.base),{
offset:0,
length:0x70,
header:true,
ansi:false})
console.log("match",hexdump(match.address),{
offset:0,
length:0x70,
header:true,
ansi:false})
range.base match.address 0x7af96d7000 5586944 0x7af96d7030 8
*/
//console.log("range.base match.address",range.base, range.size,match.address,match.size)
//console.log("rang.base = ",range.base,range.size,range.file.path);
//验证dex文件的合法性,获取真实长度
//console.log("验证dex文件的合法性,获取真实长度,进入前");
//var dex_size1 = match.address.add(0x20).readUInt();
//console.log("验证dex文件的合法性,获取真实长度 ",dex_size1);
if(verify(match.address,range,false)){
//console.log("验证dex文件的合法性,获取真实长度,进入后");
var dex_size = get_dex_real_size(match.address,range.base,range.base.add(range.size));
//数组操作
result.push({
"addr": match.address,
"size": dex_size
});
//console.log("当前 result length = ",result.length)
//console.log("result addr size ",result[0].addr,result[0].size)
//减去vdex的头
var max_size = range.size - match.address.sub(range.base).toInt32();
if(deepSearch && max_size != dex_size){
result.push({
"addr": match.address,
"size": max_size
})
}
//console.log("当前 result length = ",result.length)
}
})
//如果开启了深度搜索
if(deepSearch){
Memory.scanSync(range.base,range.size,DeepPattern).forEach(function(match){
var dex_base = match.address.sub(0x3c);
//匹配dex文件头长度0x70,反推dex的基址,如果基址不在这个内存页,则不是一个dex文件
if(dex_base end) {
return;
}
if (!range.protection.startsWith("r")) {
console.log("Set read permission for memory range: " + base + "-" + range_end);
Memory.protect(range.base, range.size, "r" + range.protection.substr(1, 2));
}
});
}
//没有使用
function memorydump(address, size) {
var ptr = new NativePointer(address);
setReadPermission(ptr, size);
return ptr.readByteArray(size);
}
function fix_header(dex_base, size){
/*
dex_size = len(dex_bytes)
if dex_bytes[:4] != b"dex\n":
dex_bytes = b"dex\n035\x00" + dex_bytes[8:]
if dex_size >= 0x24:
dex_bytes = dex_bytes[:0x20] + struct.Struct("[I]= 0x28:
dex_bytes = dex_bytes[:0x24] + struct.Struct("[I]= 0x2C and dex_bytes[0x28:0x2C] not in [b'\x78\x56\x34\x12', b'\x12\x34\x56\x78']:
dex_bytes = dex_bytes[:0x28] + b'\x78\x56\x34\x12' + dex_bytes[0x2C:]
return dex_bytes
*/
var magic = [0x64,0x65,0x78,0x0a,0x30,0x33,0x35,0x00]
if(dex_base.readCString(4) != "dex\n"){
Memory.writeByteArray(magic,dex_base);
}
}
function main(){
var dexsz =[]
dexsz = search_dex(false)
console.error("当前 dexsz length = ",dexsz.length)
dexsz.forEach(function(value,index,array){
//dump dex
//var dex_bytes = memorydump(value.addr,value.size);
//不修复文件头了,暂时不清楚内存属性的问题
// dex_bytes_file = fix_header(dex_bytes,value.size);
//fix header
//写文件到手机
//写入前确认app拥有读写sd的权限
var file = new File("/sdcard/Download/samle/" + value.size + ".dex","wb");
file.write(Memory.readByteArray(value.addr,value.size))
file.flush();
file.close();
console.log("写入dex 文件大小 :0x" ,value.size.toString(16));
//console.log(e.stack)
})
}
setImmediate(main)
扫描进程内存maps文件,读取所有可读内存段
这一步借助的是Frida提供的进程相关的API
/*Process.enumerateRanges(protection|specifier) :枚举满足给定保护的内存范围,该保护以字符串形式表示:rwx,其中rw-表示“至少可读写”。返回一个包含以下属性的对象数组:
base :基址作为 NativePointer
size :大小(字节)
protection :保护字符串
file :(可用时)文件映射详细信息作为对象,包含:
path :完整的文件系统路径作为字符串
offset :磁盘上映射文件的偏移量,以字节为单位
size :磁盘上映射文件的大小,以字节为单位
*/
Process.enumerateRanges("r--").forEach(function(range){}
foreach是数组遍历,enumerateRangers返回的是一个数组。延申几个Process的api
id:操作系统特定ID
state:指定 running 、 stopped 、 waiting 、 uninterruptible 或 halted 的字符串
context:对于ia32/x64/arm,使用pc和sp为NativePointer对象,分别指定EIP/RIP/ pc和ESP/RSP/ sp。其他特定于处理器的键也可用,例如eax、rax、r0、x0等。
Process.getModuleByAddress(address),
Process.findModuleByName(name),
Process.getModuleByName(name):
查找与指定的地址或名称匹配的模块,返回值是一个地址或名称。如果找不到模块,find-开头的函数返回 null,而 get-开头的函数抛出异常。
查找包含地址范围的详细信息。返回一个对象。
通Frida提供的API匹配可读内存段的dex文件特征(dex0??)
主要是通过Frida提供的内存匹配特征的API在可读内存中搜索指定特征,由于搜索结果会匹配到许多系统dex文件,所以通过去调用系统相关的dex文件,保留剩下的。
这个可能会遇到imeout was reached 错误,关于错误请看frida作者的解释
Memory.scanSync(range.base,range.size,pattern).forEach(function(match){
//排除系统自带dex文件-通过字符串排除
if( range.file && range.file.path &&
( range.file.path.startsWith("/data/dalvik-cache/") || range.file.path.startsWith("/system/") || range.file.path.startsWith("/apex/com") ) ){
return;
}
if(verify(match.address,range,false)){
var dex_size = get_dex_real_size(match.address,range.base,range.base.add(range.size));
result.push({
"addr": match.address,
"size": dex_size
});
var max_size = range.size - match.address.sub(range.base).toInt32();
if(deepSearch && max_size != dex_size){
result.push({
"addr": match.address,
"size": max_size
})
}
}
})
深度搜索
Memory.scanSync(range.base,range.size,DeepPattern).forEach(function(match){
var dex_base = match.address.sub(0x3c);
//匹配dex文件头长度0x70,反推dex的基址,如果基址不在这个内存页,则不是一个dex文件
if(dex_base
普通匹配是通过"64 65 78 0a 30 ?? ?? 00",而深度搜索是通过dexheader的头部的大小匹配的,这一点很有有意思,如果dexheader没有被篡改过,那这个值固定是0x70。那么在内存中扫描0x70的小端存储【"70 00 00 00"】,然后反推dexheader在判断这个是不是一个有效dex文件(dex-magic和verify函数验证)。这一手不可不谓巧妙。
修复dex文件头
原作者修复文件头是通过python完成的,因为原脚本是RPC调用的。我这个脚本没有实现修复文件头,看下作者的文件头修复
def fix_header(dex_bytes):
import struct
dex_size = len(dex_bytes)
if dex_bytes[:4] != b"dex\n":
dex_bytes = b"dex\n035\x00" + dex_bytes[8:]
if dex_size >= 0x24:
dex_bytes = dex_bytes[:0x20] + struct.Struct("[I]= 0x28:
dex_bytes = dex_bytes[:0x24] + struct.Struct("[I]= 0x2C and dex_bytes[0x28:0x2C] not in [b'\x78\x56\x34\x12', b'\x12\x34\x56\x78']:
dex_bytes = dex_bytes[:0x28] + b'\x78\x56\x34\x12' + dex_bytes[0x2C:]
return dex_bytes
fix_header一共修复了文件头的四个属性
1.dex magic 【破环dump出来的dex文件】
2.dex size 【破环dump出来的dex文件】
3.dexheader size 【破环dump出来的dex文件】
4.字节序,【破环dump出来的dex文件】
保存到文件
out_path = os.path.join(self.output, "classes{}.dex".format('%d' % idx if idx != 1 else ''))
with open(out_path, 'wb') as out:
out.write(bs)
logger.info("[+] DexMd5={}, SavePath={}, DexSize={}"
.format(md, out_path, hex(dex['size'])))
idx += 1
保存到脚本目录的包名下,以classes1.dex依次递增。
与原作者的区别
排除系统函数
我在做排除系统目录下dex文件时,比原作者多排除以/apax/com开头文件,这个路径加载都是android运行时的文件
//排除系统自带dex文件-通过字符串排除
if( range.file && range.file.path &&
( range.file.path.startsWith("/data/dalvik-cache/") || range.file.path.startsWith("/system/") || range.file.path.startsWith("/apex/com") ) ){
return;
}
处理被抹掉magic的情况
//增加vdex后移到dex头,而dex头被抹掉的情况
var Vdex2Dex_base = range.base.add(0x30);
if(Vdex2Dex_base.readCString(4) != "dex\n" && verify(Vdex2Dex_base,range,true) ){
console.log("dex magic被抹掉的情况出现了")
var real_dex_size = get_dex_real_size(Vdex2Dex_base, range.base, range.base.add(range.size))
result.push({
"addr" : Vdex2Dex_base ,
"size" : real_dex_size
})
}
> range
0 1 2 3 4 5 6 7 8 9 A B C D E F 0123456789ABCDEF
7af96d7000 76 64 65 78 30 32 31 00 30 30 32 00 01 00 00 00 vdex021.002.....
7af96d7010 0f ad 00 00 00 00 00 00 00 00 00 00 59 4f e9 71 ............YO.q
7af96d7020 9c 89 54 00 00 00 00 00 00 00 00 00 00 00 00 00 ..T.............
7af96d7030 64 65 78 0a 30 33 37 00 21 36 a6 d9 eb 59 7d 26 dex.037.!6...Y}&
7af96d7040 b7 b1 4a 6b f3 33 71 05 60 70 e5 d2 a7 e7 1b 70 ..Jk.3q.`p.....p
7af96d7050 98 89 54 00 70 00 00 00 78 56 34 12 00 00 00 00 ..T.p...xV4.....
7af96d7060 00 00 00 00 c8 88 54 00 73 b4 00 00 70 00 00 00 ......T.s...p...
7af96d7070 18 12 00 00 3c d2 02 00 28 1c 00 00 9c 1a 03 00 ....
我在输出日志是发现range匹配到的是vdex头,match匹配的dex头,如果出现magic被抹掉的情况,原作者的判断会出问题
> if(range.base.readCString(4) != "dex\n" && verify(range.base,range,true)){ }
verify的第一个参数就不是dex_base了,而是vdex_base。所以我在我的脚本这里兼容了一下这种情况。
将vdex向后移动0x30个字节到达dex_base开始验证dex文件的有效性。
总结
至此,分析了通过hook系统函数和通过内存暴力搜索dex特征的脚本,对脱壳的理解,使用frida上又进步了一点。有想学习frida脱壳脚本的也和我一样。手写我分析的脱壳原理一二三的脚本之后应该会对脱壳理解的更深,可以自己修改开源的这些脚本来解决特殊的apk加固场景。