抽丝剥茧看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
相关推荐
想取一个与众不同的名字好难1 分钟前
android studio导入OpenCv并改造成.kts版本
android·ide·android studio
Jewel1051 小时前
Flutter代码混淆
android·flutter·ios
Yawesh_best2 小时前
MySQL(5)【数据类型 —— 字符串类型】
android·mysql·adb
曾经的三心草4 小时前
Mysql之约束与事件
android·数据库·mysql·事件·约束
guoruijun_2012_48 小时前
fastadmin多个表crud连表操作步骤
android·java·开发语言
Winston Wood8 小时前
一文了解Android中的AudioFlinger
android·音频
B.-10 小时前
Flutter 应用在真机上调试的流程
android·flutter·ios·xcode·android-studio
有趣的杰克10 小时前
Flutter【04】高性能表单架构设计
android·flutter·dart
大耳猫15 小时前
主动测量View的宽高
android·ui
帅次18 小时前
Android CoordinatorLayout:打造高效交互界面的利器
android·gradle·android studio·rxjava·android jetpack·androidx·appcompat