抽丝剥茧看ApkTool的反编译流程

作者简介:Serpit,Android开发工程师,2023年加入37手游技术部,目前负责国内游戏发行 Android SDK 开发。

前言

由于做需求时,最近遇到了一个apktool反编译时报错,虽然问题简单,在排查解决问题的同时,借此机会顺便学习一下apktool的源码,了解apktool是如何实现反编译的。

关于apktool的使用方式,前面已有文章有相关的介绍,链接如下,感兴趣的同学可以先学习一下~

Android 逆向入门保姆级教程

一个APK是如何诞生的

在看apktool源码之前,我们需要了解一下APK是如何通过我们的项目代码编程一个APK文件的,使得后续了解反编译过程更简单。主要步骤如下:

  • 资源处理,利用aapt打包生成res资源文件,生成resources.arsc、R.java和res文件
  • 源代码通过编译生成class文件进而转化成dex文件
  • 通过apkbuilder工具将resources.arsc、res文件、assets文件和dex文件打包生成apk
  • 签名
  • 签名后文件对齐

开始阅读源码

由于团队中使用的apktool 2.7.0版本,所以本文阅读的源码也基于2.7.0。源码链接如下:

github.com/iBotPeaches...

Main方法

java程序的入口都是从Main方法开始的,因此先来看看Main方法。

java 复制代码
public static void main(String[] args) throws BrutException {

        // headless
        System.setProperty("java.awt.headless", "true");

        // set verbosity default
        Verbosity verbosity = Verbosity.NORMAL;

        // cli parser
        CommandLineParser parser = new DefaultParser();
        CommandLine commandLine;

        // load options
        _Options();

        try {
            commandLine = parser.parse(allOptions, args, false);

            if (! OSDetection.is64Bit()) {
                System.err.println("32 bit support is deprecated. Apktool will not support 32bit on v3.0.0.");
            }
        } catch (ParseException ex) {
            System.err.println(ex.getMessage());
            usage();
            System.exit(1);
            return;
        }

        // check for verbose / quiet
        if (commandLine.hasOption("-v") || commandLine.hasOption("--verbose")) {
            verbosity = Verbosity.VERBOSE;
        } else if (commandLine.hasOption("-q") || commandLine.hasOption("--quiet")) {
            verbosity = Verbosity.QUIET;
        }
        setupLogging(verbosity);

        // check for advance mode
        if (commandLine.hasOption("advance") || commandLine.hasOption("advanced")) {
            setAdvanceMode();
        }

        boolean cmdFound = false;
        for (String opt : commandLine.getArgs()) {
            if (opt.equalsIgnoreCase("d") || opt.equalsIgnoreCase("decode")) {
                cmdDecode(commandLine);
                cmdFound = true;
            } else if (opt.equalsIgnoreCase("b") || opt.equalsIgnoreCase("build")) {
                cmdBuild(commandLine);
                cmdFound = true;
            } else if (opt.equalsIgnoreCase("if") || opt.equalsIgnoreCase("install-framework")) {
                cmdInstallFramework(commandLine);
                cmdFound = true;
            } else if (opt.equalsIgnoreCase("empty-framework-dir")) {
                cmdEmptyFrameworkDirectory(commandLine);
                cmdFound = true;
            } else if (opt.equalsIgnoreCase("list-frameworks")) {
                cmdListFrameworks(commandLine);
                cmdFound = true;
            } else if (opt.equalsIgnoreCase("publicize-resources")) {
                cmdPublicizeResources(commandLine);
                cmdFound = true;
            }
        }

        // if no commands ran, run the version / usage check.
        if (!cmdFound) {
            if (commandLine.hasOption("version")) {
                _version();
                System.exit(0);
            } else {
                usage();
            }
        }
    }

Main方法不长,主要分为两部分

  • 加载命令行
  • 匹配命令并执行相应的命令

由于我们关注的是反编译流程,所以找到反编译相关的入口代码 cmdDecode(commandLine)

java 复制代码
if (opt.equalsIgnoreCase("d") || opt.equalsIgnoreCase("decode")){
    cmdDecode(commandLine);
    cmdFound = true;
}

顺带说一句,这里也可以根据我们apktool的使用命令 apktool d xxx.apk来匹配到这一行代码

反编译的准备工作

进入cmdDecode后,代码如下

java 复制代码
private static void cmdDecode(CommandLine cli) throws AndrolibException {
        ApkDecoder decoder = new ApkDecoder();

        int paraCount = cli.getArgList().size();
        String apkName = cli.getArgList().get(paraCount - 1);
        File outDir;

        // check for options
        if (cli.hasOption("s") || cli.hasOption("no-src")) {
            decoder.setDecodeSources(ApkDecoder.DECODE_SOURCES_NONE);
        }
        if (cli.hasOption("only-main-classes")) {
            decoder.setDecodeSources(ApkDecoder.DECODE_SOURCES_SMALI_ONLY_MAIN_CLASSES);
        }
        if (cli.hasOption("d") || cli.hasOption("debug")) {
            System.err.println("SmaliDebugging has been removed in 2.1.0 onward. Please see: https://github.com/iBotPeaches/Apktool/issues/1061");
            System.exit(1);
        }
        if (cli.hasOption("b") || cli.hasOption("no-debug-info")) {
            decoder.setBaksmaliDebugMode(false);
        }
        if (cli.hasOption("t") || cli.hasOption("frame-tag")) {
            decoder.setFrameworkTag(cli.getOptionValue("t"));
        }
        if (cli.hasOption("f") || cli.hasOption("force")) {
            decoder.setForceDelete(true);
        }
        if (cli.hasOption("r") || cli.hasOption("no-res")) {
            decoder.setDecodeResources(ApkDecoder.DECODE_RESOURCES_NONE);
        }
        if (cli.hasOption("force-manifest")) {
            decoder.setForceDecodeManifest(ApkDecoder.FORCE_DECODE_MANIFEST_FULL);
        }
        if (cli.hasOption("no-assets")) {
            decoder.setDecodeAssets(ApkDecoder.DECODE_ASSETS_NONE);
        }
        if (cli.hasOption("k") || cli.hasOption("keep-broken-res")) {
            decoder.setKeepBrokenResources(true);
        }
        if (cli.hasOption("p") || cli.hasOption("frame-path")) {
            decoder.setFrameworkDir(cli.getOptionValue("p"));
        }
        if (cli.hasOption("m") || cli.hasOption("match-original")) {
            decoder.setAnalysisMode(true);
        }
        if (cli.hasOption("api") || cli.hasOption("api-level")) {
            decoder.setApiLevel(Integer.parseInt(cli.getOptionValue("api")));
        }
        if (cli.hasOption("o") || cli.hasOption("output")) {
            outDir = new File(cli.getOptionValue("o"));
        } else {
            // make out folder manually using name of apk
            String outName = apkName;
            outName = outName.endsWith(".apk") ? outName.substring(0,
                    outName.length() - 4).trim() : outName + ".out";

            // make file from path
            outName = new File(outName).getName();
            outDir = new File(outName);
        }

        decoder.setOutDir(outDir);
        decoder.setApkFile(new File(apkName));

        try {
            decoder.decode();
        } catch (OutDirExistsException ex) {
            System.err
                    .println("Destination directory ("
                            + outDir.getAbsolutePath()
                            + ") "
                            + "already exists. Use -f switch if you want to overwrite it.");
            System.exit(1);
        } catch (InFileNotFoundException ex) {
            System.err.println("Input file (" + apkName + ") " + "was not found or was not readable.");
            System.exit(1);
        } catch (CantFindFrameworkResException ex) {
            System.err
                    .println("Can't find framework resources for package of id: "
                            + ex.getPkgId()
                            + ". You must install proper "
                            + "framework files, see project website for more info.");
            System.exit(1);
        } catch (IOException ex) {
            System.err.println("Could not modify file. Please ensure you have permission.");
            System.exit(1);
        } catch (DirectoryException ex) {
            System.err.println("Could not modify internal dex files. Please ensure you have permission.");
            System.exit(1);
        } finally {
            try {
                decoder.close();
            } catch (IOException ignored) {}
        }
    }

在这里,逻辑是读取配置,初始化ApkDecoder并设置一系列传入的参数,最后调用到decode来执行反编操作。

java 复制代码
decoder.decode();

至此,反编译的前置流程已经结束,进入decode方法后,就是真正的反编译流程了。在开始看反编译前,简单了解一下apk里面都有哪些东西,毕竟apk文件是反编译流程的输入,还是有必要知道的。(大神可以忽略这一环节~)

APK的组成

话不多说,直接上图

可以看到,解压APK后的文件分成:

  • AndroidManifest.xml:清单文件
  • assets:资源文件,编译的时候不会编译assets中的文件
  • lib:三方库存放的地方,一般都是so库存放的位置
  • META-INF:包体信息,跟(V1)签名相关的文件会在这里
  • res:项目中的res文件目录
  • resources.arsc:编译后的二进制资源文件
  • classes.dex:源码编译后的dex文件,供Dalvik虚拟机使用

decode

创建反编译的输出路径

java 复制代码
File outDir = getOutDir();
AndrolibResources.sKeepBroken = mKeepBrokenResources;
if (!mForceDelete && outDir.exists()) {
    throw new OutDirExistsException();
}
if (!mApkFile.isFile() || !mApkFile.canRead()) {
     throw new InFileNotFoundException();
}

try {
    OS.rmdir(outDir);
} catch (BrutException ex) {
    throw new AndrolibException(ex);
}
    outDir.mkdirs();

    LOGGER.info("Using Apktool " + Androlib.getVersion() + " on " + mApkFile.getName());

资源解析

java 复制代码
if (hasResources()) {
    switch (mDecodeResources) {
        case DECODE_RESOURCES_NONE:
            mAndrolib.decodeResourcesRaw(mApkFile, outDir);
                if (mForceDecodeManifest == FORCE_DECODE_MANIFEST_FULL) {
                    // done after raw decoding of resources because copyToDir overwrites dest files
                    if (hasManifest()) {
                            mAndrolib.decodeManifestWithResources(mApkFile, outDir, getResTable());
                        }
                    }
                    break;
                    case DECODE_RESOURCES_FULL:
                        if (hasManifest()) {
                            mAndrolib.decodeManifestWithResources(mApkFile, outDir, getResTable());
                        }
                        mAndrolib.decodeResourcesFull(mApkFile, outDir, getResTable());
                        break;
                }
            } else {
                // if there's no resources.arsc, decode the manifest without looking
                // up attribute references
                if (hasManifest()) {
                    if (mDecodeResources == DECODE_RESOURCES_FULL
                            || mForceDecodeManifest == FORCE_DECODE_MANIFEST_FULL) {
                        mAndrolib.decodeManifestFull(mApkFile, outDir, getResTable());
                    }
                    else {
                        mAndrolib.decodeManifestRaw(mApkFile, outDir);
                    }
                }
            }

首先判断是否存在resources.arsc文件,有则判断是否包含清单文件并解码清单文件,然后接着解码resource.arsc。由于先前执行apktool的参数中没有额外的配置,因此默认都会走到DECODE_RESOURCES_FULL的case中

java 复制代码
 case DECODE_RESOURCES_FULL:
                        if (hasManifest()) {
                            mAndrolib.decodeManifestWithResources(mApkFile, outDir, getResTable());
                        }
                        mAndrolib.decodeResourcesFull(mApkFile, outDir, getResTable());
                        break;

至此,处理了两个文件resources.arscAndroidManifest.xml

源码解析

java 复制代码
if (hasSources()) {
    switch (mDecodeSources) {
     case DECODE_SOURCES_NONE:
        mAndrolib.decodeSourcesRaw(mApkFile, outDir, "classes.dex");
        break;
    case DECODE_SOURCES_SMALI:
    case DECODE_SOURCES_SMALI_ONLY_MAIN_CLASSES:
        mAndrolib.decodeSourcesSmali(mApkFile, outDir, "classes.dex", mBakDeb, mApiLevel);
        break;
    }
}

首先判断是否有class.dex文件,然后执行解码操作,这里有两个情况,分别是DECODE_SOURCES_NONE对应no-src命令,因为默认输入的命令中无此命令,故先忽略,看下面的case,解码.dex格式的文件。

可以留意一下Androlib的decodeSourcesSmali,这个方法的作用是将dex解析成smali源码。利用的是第三方库dexlib2来处理字节码,最后生成smail文件。

处理完class.dex之后,会继续处理其余dex,调用的还是decodeSourcesSmali进行处理。

java 复制代码
if (hasMultipleSources()) {
                // foreach unknown dex file in root, lets disassemble it
                Set<String> files = mApkFile.getDirectory().getFiles(true);
                for (String file : files) {
                    if (file.endsWith(".dex")) {
                        if (! file.equalsIgnoreCase("classes.dex")) {
                            switch(mDecodeSources) {
                                case DECODE_SOURCES_NONE:
                                    mAndrolib.decodeSourcesRaw(mApkFile, outDir, file);
                                    break;
                                case DECODE_SOURCES_SMALI:
                                    mAndrolib.decodeSourcesSmali(mApkFile, outDir, file, mBakDeb, mApiLevel);
                                    break;
                                case DECODE_SOURCES_SMALI_ONLY_MAIN_CLASSES:
                                    if (file.startsWith("classes") && file.endsWith(".dex")) {
                                        mAndrolib.decodeSourcesSmali(mApkFile, outDir, file, mBakDeb, mApiLevel);
                                    } else {
                                        mAndrolib.decodeSourcesRaw(mApkFile, outDir, file);
                                    }
                                    break;
                            }
                        }
                    }
                }
            }

至此,源码解析阶段处理了所有的class.dex文件。将smail源码解出来放到smali文件夹中

RawFile处理

在处理完dex后,接着处理lib/libs,assets,kotlin这几个文件夹的东西

java 复制代码
mAndrolib.decodeRawFiles(mApkFile, outDir, mDecodeAssets);


public void decodeRawFiles(ExtFile apkFile, File outDir, short decodeAssetMode)
            throws AndrolibException {
        LOGGER.info("Copying assets and libs...");
        try {
            Directory in = apkFile.getDirectory();

            if (decodeAssetMode == ApkDecoder.DECODE_ASSETS_FULL) {
                if (in.containsDir("assets")) {
                    in.copyToDir(outDir, "assets");
                }
            }
            if (in.containsDir("lib")) {
                in.copyToDir(outDir, "lib");
            }
            if (in.containsDir("libs")) {
                in.copyToDir(outDir, "libs");
            }
            if (in.containsDir("kotlin")) {
                in.copyToDir(outDir, "kotlin");
            }
        } catch (DirectoryException ex) {
            throw new AndrolibException(ex);
        }
    }

这部分代码还是相当简单的,就是纯拷贝出去的逻辑

处理未知文件

在Androlib中定义了正常的APK文件会有哪些文件

java 复制代码
private final static String[] APK_STANDARD_ALL_FILENAMES = new String[] {
            "classes.dex", "AndroidManifest.xml", "resources.arsc", "res", "r", "R",
            "lib", "libs", "assets", "META-INF", "kotlin" };
java 复制代码
 mAndrolib.decodeUnknownFiles(mApkFile, outDir);

 public void decodeUnknownFiles(ExtFile apkFile, File outDir)
            throws AndrolibException {
        LOGGER.info("Copying unknown files...");
        File unknownOut = new File(outDir, UNK_DIRNAME);
        try {
            Directory unk = apkFile.getDirectory();

            // loop all items in container recursively, ignoring any that are pre-defined by aapt
            Set<String> files = unk.getFiles(true);
            for (String file : files) {
                if (!isAPKFileNames(file) && !file.endsWith(".dex")) {

                    // copy file out of archive into special "unknown" folder
                    unk.copyToDir(unknownOut, file);
                    // lets record the name of the file, and its compression type
                    // so that we may re-include it the same way
                    mResUnknownFiles.addUnknownFileInfo(file, String.valueOf(unk.getCompressionLevel(file)));
                }
            }
        } catch (DirectoryException ex) {
            throw new AndrolibException(ex);
        }
    }

除了定义的文件,其余出现的文件,会被定义为unknown文件。这些文件会被复制到unknown文件夹中,并记录文件名和压缩类型,方便后续可以用相同的方式获取到它们

处理META-INF&记录信息

至此,还剩META-INF文件外,其他都得到了处理。但源码中还有一个记录的流程

java 复制代码
mUncompressedFiles = new ArrayList<>();
mAndrolib.recordUncompressedFiles(mApkFile, mUncompressedFiles);
mAndrolib.writeOriginalFiles(mApkFile, outDir);
writeMetaFile();

首先,recordUncompressedFiles记录未压缩的文件,这里的作用主要是将未压缩的文件记录到apktool.yml中的doNotCompress字段中。

然后,writeOriginalFiles中会将META-INF文件拷贝出去。

java 复制代码
public void writeOriginalFiles(ExtFile apkFile, File outDir)
            throws AndrolibException {
        LOGGER.info("Copying original files...");
        File originalDir = new File(outDir, "original");
        if (!originalDir.exists()) {
            originalDir.mkdirs();
        }

        try {
            Directory in = apkFile.getDirectory();
            if (in.containsFile("AndroidManifest.xml")) {
                in.copyToDir(originalDir, "AndroidManifest.xml");
            }
            if (in.containsFile("stamp-cert-sha256")) {
                in.copyToDir(originalDir, "stamp-cert-sha256");
            }
            if (in.containsDir("META-INF")) {
                in.copyToDir(originalDir, "META-INF");

                if (in.containsDir("META-INF/services")) {
                    // If the original APK contains the folder META-INF/services folder
                    // that is used for service locators (like coroutines on android),
                    // copy it to the destination folder so it does not get dropped.
                    LOGGER.info("Copying META-INF/services directory");
                    in.copyToDir(outDir, "META-INF/services");
                }
            }
        } catch (DirectoryException ex) {
            throw new AndrolibException(ex);
        }
    }

最后,writeMetaFile生成apktool.yml,具体对应的写入逻辑下方有备注

java 复制代码
private void writeMetaFile() throws AndrolibException {
        MetaInfo meta = new MetaInfo();
        meta.version = Androlib.getVersion();
        meta.apkFileName = mApkFile.getName();

        if (mResTable != null) {
            meta.isFrameworkApk = mAndrolib.isFrameworkApk(mResTable);
            putUsesFramework(meta); //对应usesFramework字段
            putSdkInfo(meta); //对应sdkInfo字段
            putPackageInfo(meta); //对应packageInfo字段
            putVersionInfo(meta); //对应versionInfo字段
            putSharedLibraryInfo(meta); //对应sharedLibrary字段
            putSparseResourcesInfo(meta); //对应sparseResources字段
        } else {
            putMinSdkInfo(meta);
        }
        putUnknownInfo(meta);
        putFileCompressionInfo(meta);

        mAndrolib.writeMetaFile(mOutDir, meta);
    }

放一个apktool.yml的样例

yml 复制代码
!!brut.androlib.meta.MetaInfo
apkFileName: demo.apk
compressionType: false
doNotCompress:
- resources.arsc
- png
- mp4
- ogg
- assets/cache/https.__res.xxh5.z7xz.com_xxh5dev/assetsid.txt
- assets/cache/https.__res.xxh5.z7xz.com_xxh5dev/filetable.bin
isFrameworkApk: false
packageInfo:
  forcedPackageId: '127'
  renameManifestPackage: null
sdkInfo:
  minSdkVersion: '19'
  targetSdkVersion: '26'
sharedLibrary: false
sparseResources: false
unknownFiles:
  xxxhttp/okhttp3/internal/publicsuffix/publicsuffixes.gz: '0'
usesFramework:
  ids:
  - 1
  tag: null
version: 2.7.0
versionInfo:
  versionCode: '1055'
  versionName: 9.6.4

小结

可以看到,apktool的源码还是很简单明了的,反编译的主流程就是: 读取命令参数 -> 生成ApkDecoder -> 反编译开始 -> 逐步处理各个文件夹/文件并输出到目标文件夹中 -> 生成apktool.yml

值得一提的是,每个文件夹/文件的处理核心逻辑都在Androlib类中,包括但不限于dex转smail的封装、清单文件解析等。但本文的主旨只是领略apktool的反编译流程,未深入涉及到具体的每种文件处理细节,后续会针对某种文件细节会出相应的博客分析,敬请期待~

makefile 复制代码
最后的最后: 
我们是37手游移动客户端开发团队,致力于为游戏行业提供高质量的SDK开发服务。 
欢迎关注我们,了解更多移动开发和游戏 SDK 技术动态~ 
技术问题/交流/进群等可以加官方微信 MobileTeam37
相关推荐
雨白7 小时前
Jetpack系列(二):Lifecycle与LiveData结合,打造响应式UI
android·android jetpack
kk爱闹9 小时前
【挑战14天学完python和pytorch】- day01
android·pytorch·python
每次的天空11 小时前
Android-自定义View的实战学习总结
android·学习·kotlin·音视频
恋猫de小郭11 小时前
Flutter Widget Preview 功能已合并到 master,提前在体验毛坯的预览支持
android·flutter·ios
断剑重铸之日12 小时前
Android自定义相机开发(类似OCR扫描相机)
android
随心最为安12 小时前
Android Library Maven 发布完整流程指南
android
岁月玲珑12 小时前
【使用Android Studio调试手机app时候手机老掉线问题】
android·ide·android studio
还鮟16 小时前
CTF Web的数组巧用
android
小蜜蜂嗡嗡18 小时前
Android Studio flutter项目运行、打包时间太长
android·flutter·android studio
aqi0018 小时前
FFmpeg开发笔记(七十一)使用国产的QPlayer2实现双播放器观看视频
android·ffmpeg·音视频·流媒体