第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.js02_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 工具架构

自动脱壳与反混淆工具架构 输入:APK/IPA文件 壳识别模块 特征匹配 | 签名识别 | 行为检测 脱壳模块 Dump DEX | 内存修复 | 壳加载器模拟 | 动态执行 反混淆模块 字符串解密 | 控制流平坦化还原 | 类名/方法名重映射 输出:干净的DEX/二进制

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