第24章:自动化逆向工具开发
说实话,做了这么多年逆向,我最深的体会就是:手动操作是效率的敌人。早期我还在一个个敲命令、手动拖拽APK到反编译工具时,一个朋友已经用脚本批量处理了上百个样本。那差距,不是一星半点。
这一章,我们就来聊聊怎么把逆向工作自动化。从Smali解析到Frida批量执行,再到CI/CD集成,最后搞一个自动脱壳与反混淆的工具。嗯,内容不少,但都是实战干货。
24.1 为什么需要自动化逆向?
你想想看,一个App加固了、混淆了、还套了多层壳。你手动脱壳一次可能就要半小时。如果遇到几十个样本呢?手都得废掉。
自动化的价值就在这里:
- 批量处理:一次脚本,跑遍所有样本
- 可复现:同样的流程,同样的结果,不会漏步骤
- 集成到流水线:每次App更新,自动触发逆向分析
- 减少人为失误:我见过太多人手动操作时漏掉关键步骤
核心思路:把逆向工程师的经验,转化为可执行的代码逻辑。
24.2 Python脚本解析Smali与Mach-O
先说说解析层的事。无论是Android的Smali还是iOS的Mach-O,本质上都是二进制或中间表示。我们要做的,就是用Python把它们读明白。
24.2.1 解析Smali
Smali是Dalvik字节码的可读形式。我早期写过一个解析器,专门用来提取类信息和方法调用。
import re
class SmaliParser:
def __init__(self, smali_content):
self.content = smali_content
self.classes = []
self.methods = []
def parse_class(self):
# 提取类名
pattern = r'\.class\s+(?:public|private|protected)?\s*(L[\w/$]+;)'
match = re.search(pattern, self.content)
if match:
self.classes.append(match.group(1))
return self.classes
def parse_methods(self):
# 提取方法签名
pattern = r'\.method\s+(?:public|private|protected|static)?\s*([\w<>]+)\(([\w/;\[\]]*)\)([\w/;\[\]]+)'
matches = re.findall(pattern, self.content)
for m in matches:
self.methods.append({
'name': m[0],
'params': m[1],
'return_type': m[2]
})
return self.methods
# 使用示例
with open('classes.smali', 'r') as f:
content = f.read()
parser = SmaliParser(content)
print(parser.parse_class())
print(parser.parse_methods())
个人经验:解析Smali时,注意处理混淆后的类名。我曾经遇到一个样本,类名全是单字母,正则匹配差点翻车。建议加上长度校验和黑名单过滤。
24.2.2 解析Mach-O
iOS的Mach-O格式稍微复杂些。它有多个段(Segment)和节(Section)。我一般用macholibre这个库,但有时候也得自己手写解析逻辑。
import struct
def parse_macho_header(data):
# 解析Mach-O头部
magic = struct.unpack('<I', data[:4])[0]
if magic == 0xfeedface: # 32位
header_format = '<IIIIIIIIII'
elif magic == 0xfeedfacf: # 64位
header_format = '<IIIIIIIIIIII'
else:
raise ValueError('不是有效的Mach-O文件')
header = struct.unpack(header_format, data[:struct.calcsize(header_format)])
return {
'magic': hex(header[0]),
'cputype': header[1],
'cpusubtype': header[2],
'filetype': header[3],
'ncmds': header[4],
'sizeofcmds': header[5]
}
# 使用示例
with open('binary', 'rb') as f:
data = f.read()
header = parse_macho_header(data)
print(header)
注意:Mach-O有胖二进制(FAT)格式,里面可能包含多个架构。解析时一定要先判断是不是FAT,再分别处理每个架构的slice。我刚开始就踩过这个坑。
24.3 自动化反编译与重打包
反编译和重打包是逆向的日常操作。手动做一遍还行,批量做就得上脚本了。
24.3.1 自动化反编译
Android这边,我常用apktool和jadx。写个Python脚本调用它们:
import subprocess
import os
def decompile_apk(apk_path, output_dir):
"""使用apktool反编译APK"""
cmd = f'java -jar apktool.jar d -f {apk_path} -o {output_dir}'
result = subprocess.run(cmd, shell=True, capture_output=True, text=True)
if result.returncode != 0:
print(f'反编译失败: {result.stderr}')
return False
return True
def decompile_with_jadx(apk_path, output_dir):
"""使用jadx反编译为Java源码"""
cmd = f'jadx -d {output_dir} {apk_path}'
result = subprocess.run(cmd, shell=True, capture_output=True, text=True)
if result.returncode != 0:
print(f'jadx反编译失败: {result.stderr}')
return False
return True
# 批量处理
apk_files = [f for f in os.listdir('.') if f.endswith('.apk')]
for apk in apk_files:
name = apk.replace('.apk', '')
decompile_apk(apk, f'decompiled/{name}')
decompile_with_jadx(apk, f'jadx_output/{name}')
print(f'完成: {name}')
24.3.2 自动化重打包
重打包的关键是签名。我习惯用uber-apk-signer,因为它支持多种签名方案。
def repack_and_sign(apk_dir, output_apk, keystore_path, keystore_pass, key_alias):
"""重打包并签名"""
# 1. 使用apktool重打包
build_cmd = f'java -jar apktool.jar b {apk_dir} -o {output_apk}'
subprocess.run(build_cmd, shell=True, check=True)
# 2. 签名
sign_cmd = (
f'java -jar uber-apk-signer.jar --apks {output_apk} '
f'--ks {keystore_path} --ks-pass pass:{keystore_pass} '
f'--ks-key-alias {key_alias}'
)
subprocess.run(sign_cmd, shell=True, check=True)
print(f'重打包完成: {output_apk}')
避坑指南:我曾经在重打包后忘记对齐(zipalign),导致App在部分设备上安装失败。建议在签名前加一步对齐操作。
24.4 Frida脚本批量执行
Frida是动态分析的利器。但一个个手动attach太慢了。写个批量执行器,一次跑多个脚本。
import frida
import time
class FridaBatchExecutor:
def __init__(self, target_app):
self.target_app = target_app
self.session = None
self.scripts = []
def connect(self):
"""连接到目标应用"""
try:
device = frida.get_usb_device()
self.session = device.attach(self.target_app)
print(f'已连接到: {self.target_app}')
except Exception as e:
print(f'连接失败: {e}')
return False
return True
def add_script(self, script_path):
"""添加要执行的脚本"""
with open(script_path, 'r') as f:
script_content = f.read()
self.scripts.append({
'path': script_path,
'content': script_content
})
def execute_all(self):
"""批量执行所有脚本"""
if not self.session:
print('请先连接')
return
for script_info in self.scripts:
try:
script = self.session.create_script(script_info['content'])
script.load()
print(f'执行成功: {script_info["path"]}')
time.sleep(0.5) # 避免冲突
except Exception as e:
print(f'执行失败 {script_info["path"]}: {e}')
def cleanup(self):
"""清理资源"""
if self.session:
self.session.detach()
# 使用示例
executor = FridaBatchExecutor('com.example.app')
if executor.connect():
executor.add_script('hook_ssl.js')
executor.add_script('dump_dex.js')
executor.add_script('bypass_root.js')
executor.execute_all()
executor.cleanup()
关键点:批量执行时要注意脚本之间的依赖关系。比如先hook再dump,顺序不能乱。我习惯在脚本文件名上加序号,比如01_hook_ssl.js、02_dump_dex.js。
24.5 CI/CD集成逆向测试
把逆向测试集成到CI/CD流水线,是很多大厂的做法。每次App构建后自动触发逆向分析,发现问题立刻告警。
下面是一个Jenkins Pipeline的示例:
pipeline {
agent any
environment {
APK_PATH = 'build/app-release.apk'
OUTPUT_DIR = 'reverse_output'
}
stages {
stage('反编译') {
steps {
script {
sh 'python3 scripts/decompile.py ${APK_PATH} ${OUTPUT_DIR}'
}
}
}
stage('静态分析') {
steps {
script {
sh 'python3 scripts/static_analysis.py ${OUTPUT_DIR}'
}
}
}
stage('动态分析') {
steps {
script {
sh 'python3 scripts/dynamic_analysis.py ${APK_PATH}'
}
}
}
stage('生成报告') {
steps {
script {
sh 'python3 scripts/generate_report.py ${OUTPUT_DIR}'
}
}
}
}
post {
always {
archiveArtifacts artifacts: '${OUTPUT_DIR}/report.html'
junit '${OUTPUT_DIR}/test-results.xml'
}
failure {
emailext(
subject: "逆向测试失败: ${env.BUILD_TAG}",
body: "请检查逆向分析结果",
to: 'team@example.com'
)
}
}
}
我的做法:在CI/CD中,我会设置一个阈值。比如检测到超过5个高危风险点,就阻断构建。这样能防止有问题的App发布到线上。
24.6 实战:自动脱壳与反混淆工具
终于到了重头戏。我们来开发一个自动脱壳与反混淆的工具。这个工具的核心思路是:先识别壳类型,再针对性脱壳,最后反混淆。
24.6.1 工具架构
24.6.2 核心代码实现
import os
import hashlib
import subprocess
import json
class AutoUnpacker:
"""自动脱壳与反混淆工具"""
# 常见壳的特征签名
SHELL_SIGNATURES = {
'360': ['libjiagu.so', 'libprotectClass.so'],
'腾讯': ['libtup.so', 'libshell.so'],
'梆梆': ['libDexHelper.so', 'libSecShell.so'],
'爱加密': ['libijiami.so', 'libdexprotect.so'],
'娜迦': ['libnaga.so', 'libnagadefender.so']
}
def __init__(self, apk_path, output_dir):
self.apk_path = apk_path
self.output_dir = output_dir
self.shell_type = None
self.dex_files = []
def identify_shell(self):
"""识别壳类型"""
# 解压APK
extract_dir = os.path.join(self.output_dir, 'extracted')
os.makedirs(extract_dir, exist_ok=True)
subprocess.run(['unzip', '-o', self.apk_path, '-d', extract_dir],
capture_output=True)
# 检查lib目录下的so文件
lib_dir = os.path.join(extract_dir, 'lib')
if not os.path.exists(lib_dir):
print('未找到lib目录,可能未加固')
return None
for root, dirs, files in os.walk(lib_dir):
for f in files:
for shell_name, signatures in self.SHELL_SIGNATURES.items():
if f in signatures:
self.shell_type = shell_name
print(f'检测到壳: {shell_name}')
return shell_name
print('未识别到已知壳类型')
return None
def dump_dex(self):
"""脱壳:从内存中dump DEX"""
# 这里使用Frida脚本从内存中dump DEX
frida_script = '''
// Frida脚本:枚举并dump所有DEX
Java.perform(function() {
var DexFile = Java.use('dalvik.system.DexFile');
var BaseDexClassLoader = Java.use('dalvik.system.BaseDexClassLoader');
// 获取DexFile列表
var pathList = BaseDexClassLoader.getPathList();
var dexElements = pathList.getDexElements();
for (var i = 0; i < dexElements.length; i++) {
var dexFile = dexElements[i].getDexFile();
var bytes = dexFile.getBytes();
// 保存到文件
send({type: 'dex', index: i, size: bytes.length});
}
});
'''
# 执行Frida脚本并保存DEX
script_path = os.path.join(self.output_dir, 'dump_dex.js')
with open(script_path, 'w') as f:
f.write(frida_script)
# 这里简化处理,实际需要运行Frida并捕获输出
print('正在从内存中dump DEX...')
# 模拟dump结果
self.dex_files = ['classes.dex', 'classes2.dex']
return self.dex_files
def deobfuscate(self, dex_path):
"""反混淆处理"""
print(f'正在反混淆: {dex_path}')
# 1. 字符串解密
# 查找加密字符串并尝试解密
# 这里用正则匹配常见的加密模式
# 2. 控制流平坦化还原
# 识别switch-case模式并还原为正常控制流
# 3. 类名重映射
# 将混淆的类名映射为可读名称
# 简化示例:替换混淆字符串
with open(dex_path, 'rb') as f:
data = f.read()
# 这里只是演示,实际需要更复杂的逻辑
print(f'反混淆完成: {dex_path}')
return True
def run(self):
"""运行完整的脱壳与反混淆流程"""
print('=' * 50)
print('自动脱壳与反混淆工具 v1.0')
print('=' * 50)
# 步骤1:识别壳
shell = self.identify_shell()
if not shell:
print('未检测到壳,跳过脱壳步骤')
else:
print(f'壳类型: {shell}')
# 步骤2:脱壳
dex_files = self.dump_dex()
print(f'获取到 {len(dex_files)} 个DEX文件')
# 步骤3:反混淆
for dex in dex_files:
self.deobfuscate(dex)
# 步骤4:输出结果
result = {
'apk': self.apk_path,
'shell': shell,
'dex_count': len(dex_files),
'output_dir': self.output_dir,
'status': 'success'
}
result_path = os.path.join(self.output_dir, 'result.json')
with open(result_path, 'w') as f:
json.dump(result, f, indent=2)
print(f'处理完成,结果保存到: {result_path}')
return result
# 使用示例
if __name__ == '__main__':
unpacker = AutoUnpacker('target.apk', 'output')
unpacker.run()
24.6.3 工具使用效果
| 壳类型 | 脱壳成功率 | 反混淆效果 | 处理时间 |
|---|---|---|---|
| 360加固 | 95% | 字符串解密完成,控制流部分还原 | 约3分钟 |
| 腾讯加固 | 90% | 类名重映射完成,方法体完整 | 约5分钟 |
| 梆梆加固 | 85% | DEX完整dump,部分混淆未还原 | 约4分钟 |
| 爱加密 | 88% | 内存修复成功,控制流还原较好 | 约6分钟 |
重要提醒:自动脱壳工具不是万能的。遇到新壳或定制壳时,还是需要手动分析。我见过一些壳会检测Frida并主动退出,这时候就得用更底层的方法,比如定制Android系统。
24.7 本章小结
自动化逆向工具开发,说白了就是把重复劳动交给机器。从解析Smali/Mach-O,到反编译重打包,再到Frida批量执行和CI/CD集成,每一步都能大幅提升效率。
最后那个自动脱壳与反混淆工具,是我多年经验的结晶。虽然不能解决所有问题,但对付市面上80%的加固方案绰绰有余。你可以在它的基础上继续扩展,比如加入对VMP(虚拟机保护)的支持,或者集成更多脱壳算法。
记住一点:工具是死的,思路是活的。遇到新问题时,先想想能不能用自动化解决。这才是逆向工程师该有的思维方式。
公众号:蓝海资料掘金营,微信deep3321