1. 背景

由于Smali代码实在过于难写, 又没有专门学习过Smali代码, 最多看的懂一点点.

在这个前提下, 我们有两个方案可以使用:

  • Smali
  • Xposed

Smali咱们不熟悉, 那么只能选择Xposed. 并且在我们的生产环境中, 不太方便安装Xposed框架, 所以,我们可以使用另外一种解决方案LSPatch

2. LSPatch

LSPatch: A non-root Xposed framework extending from LSPosed

Rootless implementation of LSPosed framework, integrating Xposed API by inserting dex and so into the target APK.

2.1 介绍

LSPatch可以在无ROOT环境下给应用使用Xposed的功能, 它是通过注入DEX和SO到目标APP中实现的

LSPatch提供了2个方案对我们目标应用打包:

  • 通过lspatch.jar包进行注入
  • 通过管理器进行注入

秉着学习的想法,我们可以对lspatch.jar源码学习, 看看它是如何实现文件注入的

2.2 lspatch.jar文件注入流程

https://github.com/LSPosed/LSPatch/blob/master/patch/src/main/java/org/lsposed/patch/LSPatch.java

在上面的入口文件中, 我们可以看到doCommandLine方法, 可以看到 patch方法为注入的入口

public void doCommandLine() throws PatchError, IOException {
    for (var apk : apkPaths) {
        File srcApkFile = new File(apk).getAbsoluteFile();

        String apkFileName = srcApkFile.getName();

        var outputDir = new File(outputPath);
        outputDir.mkdirs();

        File outputFile = new File(outputDir, String.format(
                Locale.getDefault(), "%s-%d-lspatched.apk",
                FilenameUtils.getBaseName(apkFileName),
                LSPConfig.instance.VERSION_CODE)
        ).getAbsoluteFile();

        if (outputFile.exists() && !forceOverwrite)
            throw new PatchError(outputPath + " exists. Use --force to overwrite");
        logger.i("Processing " + srcApkFile + " -> " + outputFile);
        // 注入的入口方法
        patch(srcApkFile, outputFile);
    }
}

patch方法主要做了以下几件事:

  1. ZFile读取应用信息
  2. 签名相关

    1. 生成签名信息
    2. 获取原始签名
  3. Patch

    1. 根据下面的信息修改manifest信息
    2. useManager 是否使用管理器进行模块管理
    3. debuggableFlag 应用是否开启debug
    4. overrideVersionCode 重写VersionCode
    5. sigbypassLevel 过签名校验等级
    6. originalSignature 原始的签名信息
    7. appComponentFactory
  4. 保存文件到assets

    1. LSPatch配置到apk assets中
    2. 保存metaloader.dex到apk assets中
    3. 保存loader.dex 到assets中 (manager使用)
    4. 保存liblspatch.so到asset中
    5. 注入xposed模块
  5. 创建APK压缩包
  6. APK 4字节对齐

我们在这里不去过度关心它到底是如何解析原始APK, 给新的APK打入东西, 因为这都有LSPatch源码中ZFile 提供的能力, 我们不过于深究, 也不是我们要太了解的东西.


public void patch(File srcApkFile, File outputFile) throws PatchError, IOException {
    if (!srcApkFile.exists())
        throw new PatchError("The source apk file does not exit. Please provide a correct path.");

    outputFile.delete();

    logger.d("apk path: " + srcApkFile);

    logger.i("Parsing original apk...");

    try (var dstZFile = ZFile.openReadWrite(outputFile, Z_FILE_OPTIONS);
         var srcZFile = dstZFile.addNestedZip((ignore) -> ORIGINAL_APK_ASSET_PATH, srcApkFile, false)) {

        // sign apk
        try {
            var keyStore = KeyStore.getInstance(KeyStore.getDefaultType());
            if (keystoreArgs.get(0) == null) {
                logger.i("Register apk signer with default keystore...");
                try (var is = getClass().getClassLoader().getResourceAsStream("assets/keystore")) {
                    keyStore.load(is, keystoreArgs.get(1).toCharArray());
                }
            } else {
                logger.i("Register apk signer with custom keystore...");
                try (var is = new FileInputStream(keystoreArgs.get(0))) {
                    keyStore.load(is, keystoreArgs.get(1).toCharArray());
                }
            }
            var entry = (KeyStore.PrivateKeyEntry) keyStore.getEntry(keystoreArgs.get(2), new KeyStore.PasswordProtection(keystoreArgs.get(3).toCharArray()));
            new SigningExtension(SigningOptions.builder()
                    .setMinSdkVersion(28)
                    .setV2SigningEnabled(true)
                    .setCertificates((X509Certificate[]) entry.getCertificateChain())
                    .setKey(entry.getPrivateKey())
                    .build()).register(dstZFile);
        } catch (Exception e) {
            throw new PatchError("Failed to register signer", e);
        }

        String originalSignature = null;
        if (sigbypassLevel > 0) {
            originalSignature  = ApkSignatureHelper.getApkSignInfo(srcApkFile.getAbsolutePath());
            if (originalSignature == null || originalSignature.isEmpty()) {
                throw new PatchError("get original signature failed");
            }
            logger.d("Original signature\n" + originalSignature);
        }

        // copy out manifest file from zlib
        var manifestEntry = srcZFile.get(ANDROID_MANIFEST_XML);
        if (manifestEntry == null)
            throw new PatchError("Provided file is not a valid apk");

        // parse the app appComponentFactory full name from the manifest file
        final String appComponentFactory;
        int minSdkVersion;
        try (var is = manifestEntry.open()) {
            var pair = ManifestParser.parseManifestFile(is);
            if (pair == null)
                throw new PatchError("Failed to parse AndroidManifest.xml");
            appComponentFactory = pair.appComponentFactory;
            minSdkVersion = pair.minSdkVersion;
            logger.d("original appComponentFactory class: " + appComponentFactory);
            logger.d("original minSdkVersion: " + minSdkVersion);
        }

        logger.i("Patching apk...");
        // modify manifest
        final var config = new PatchConfig(useManager, debuggableFlag, overrideVersionCode, sigbypassLevel, originalSignature, appComponentFactory);
        final var configBytes = new Gson().toJson(config).getBytes(StandardCharsets.UTF_8);
        final var metadata = Base64.getEncoder().encodeToString(configBytes);
        try (var is = new ByteArrayInputStream(modifyManifestFile(manifestEntry.open(), metadata, minSdkVersion))) {
            dstZFile.add(ANDROID_MANIFEST_XML, is);
        } catch (Throwable e) {
            throw new PatchError("Error when modifying manifest", e);
        }

        logger.i("Adding config...");
        // save lspatch config to asset..
        try (var is = new ByteArrayInputStream(configBytes)) {
            dstZFile.add(CONFIG_ASSET_PATH, is);
        } catch (Throwable e) {
            throw new PatchError("Error when saving config");
        }

        logger.i("Adding metaloader dex...");
        try (var is = getClass().getClassLoader().getResourceAsStream(Constants.META_LOADER_DEX_ASSET_PATH)) {
            dstZFile.add("classes.dex", is);
        } catch (Throwable e) {
            throw new PatchError("Error when adding dex", e);
        }

        if (!useManager) {
            logger.i("Adding loader dex...");
            try (var is = getClass().getClassLoader().getResourceAsStream(LOADER_DEX_ASSET_PATH)) {
                dstZFile.add(LOADER_DEX_ASSET_PATH, is);
            } catch (Throwable e) {
                throw new PatchError("Error when adding assets", e);
            }

            logger.i("Adding native lib...");
            // copy so and dex files into the unzipped apk
            // do not put liblspatch.so into apk!lib because x86 native bridge causes crash
            for (String arch : ARCHES) {
                String entryName = "assets/lspatch/so/" + arch + "/liblspatch.so";
                try (var is = getClass().getClassLoader().getResourceAsStream(entryName)) {
                    dstZFile.add(entryName, is, false); // no compress for so
                } catch (Throwable e) {
                    // More exception info
                    throw new PatchError("Error when adding native lib", e);
                }
                logger.d("added " + entryName);
            }

            logger.i("Embedding modules...");
            embedModules(dstZFile);
        }

        // create zip link
        logger.d("Creating nested apk link...");

        for (StoredEntry entry : srcZFile.entries()) {
            String name = entry.getCentralDirectoryHeader().getName();
            if (name.startsWith("classes") && name.endsWith(".dex")) continue;
            if (dstZFile.get(name) != null) continue;
            if (name.equals("AndroidManifest.xml")) continue;
            if (name.startsWith("META-INF") && (name.endsWith(".SF") || name.endsWith(".MF") || name.endsWith(".RSA"))) continue;
            srcZFile.addFileLink(name, name);
        }

        dstZFile.realign();

        logger.i("Writing apk...");
    }
    logger.i("Done. Output APK: " + outputFile.getAbsolutePath());
}

3. 实战

假设兄弟们都会Xposed模块的编写, 那么实在其实就没有什么好讲的了. 关于Xposed模块编写网上太多了. 我这里就不再浪费篇幅去说了.

简单的几步:

  1. Xposed模块编写
  2. 在Lsposed的机器上测试功能
  3. 使用Lspatch打包APK+模块(集成模式)
  4. 在没有安装LSPosed环境的设备测试
  5. 发布

流程非常简单, 全程无痛点.

再次, 感谢Lsposed团队发布这么好的工具给大家使用!!!

参考文章

https://zhuanlan.zhihu.com/p/569114307

https://github.com/LSPosed/LSPatch