之前的文章已经多次提到我现在在游戏发行公司工作,当前由于国内游戏和广告环境,反编译是作为游戏 Android SDK 工程师必备的技能,经常需要将上游 CP 提供的游戏母包反编译,然后接入一些我们自己的或者是第三方的 SDK 再重新编译打包并转成多个子包再上线。

在我接触这个行业之前,我以为反编译更多的是用在破解软件或者是某些公司对其他公司的技术窃取途径,没想到游戏发行居然能这么玩。

今天就来带大家入门 Android 反编译。

我们都知道,Android 应用程序打包之后得到的是一个 APK 文件,这个文件是可以直接安装到任何 Android 手机上的,所以我们反编译其实也就是对这个 APK 文件进行反编译。

Android 的反编译主要又分为两个部分,一是对代码的反编译,二是对资源的反编译。

准备

既然是对 APK 文件进行反编译,那么理应有个前提 —— 有一个 APK 文件。
为了能够更加简单的演示,以及对其他开发者的尊重,就不使用任何一款已上线的应用了,简单写个 Demo 就行,而且是越简单越好。

另一方面,现在上架应用市场的大多数应用都经过了加固处理,如果对其进行反编译还需要脱壳,相对来说会更加麻烦。

话不多说,新建一个项目,布局就一个 Button,没有其他东西:

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">
    <androidx.appcompat.widget.AppCompatButton
        android:id="@+id/button"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Button"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

逻辑就是点击这个 Button 后弹出一个 Toast

public class MainActivity extends AppCompatActivity {
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        findViewById(R.id.button).setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View view) {
                Toast.makeText(MainActivity.this, "Hello World", Toast.LENGTH_SHORT).show();
            }
        });
    }
}

编译打包成一个 APK 文件,并安装到手机上,效果如下:

Demo 演示效果

至此,准备工作完成。

反编译代码

要对应用的代码进行反编译,我们首先得大概了解 Dex 文件。

而在明白什么是 Dex 文件之前,要先了解一下 JVM、Dalvik 和 ART。

JVM 是 Java 虚拟机,用来运行 Java 字节码程序。Dalvik 是 Google 设计的用于 Android 平台的运行时环境,适合移动环境下内存和处理器速度有限的系统。ART 即 Android Runtime,是 Google 为了替换 Dalvik 设计的新 Android 运行时环境,在 Android 4.4 推出,比 Dalvik 的性能更好。

Android 程序一般使用 Java 语言开发,但是 Dalvik 虚拟机并不支持直接执行 Java 字节码,所以会对编译生成的 CLASS 文件(即以 .class 结尾的文件)进行翻译、重构、解释、压缩等处理,这个处理过程是由『DX』或『D8』进行处理,处理完成后生成的产物会以 .dex 结尾,称为 Dex 文件。Dex 文件格式是专为 Dalvik 设计的一种压缩格式。所以可以简单的理解为:Dex 文件是很多 CLASS 文件处理后的产物(类似 Jar 包),最终可以在 Android 运行时环境执行。

Dex 编译流程

上面这张图简化形象地描述了 Java 文件转化为 Dex 文件的过程,当然真的处理流程不会这么简单。

而我们反编译代码就是将这个操作反过来,通过将可以在 Android 运行时环境执行的 Dex 文件转化为可供我们阅读的代码。

这时候你可能就疑惑了,我们只有 APK 文件,去哪里找这个应用的 Dex 文件呢?

之前也说过,软件安装包 APK 文件实质上也是一个压缩包,所以只需解压 APK 文件即可,相同的操作在之前『获取手机应用 ICON 的方法』一文中也提到过。

解压之后,可以看到一个名为 classes.dex 的文件,这就是我们所需要的 Dex 文件,即存放所有 Java 代码的地方。

  • Dex2Jar

首先我们需要一个叫『Dex2Jar』的小工具,看名字就知道,它可以帮助我们把 Dex 文件转换成一个 Jar 文件。

下载解压后可以得到一大堆文件,我们暂时只需要用到一个文件,Windows 平台下用到的是 d2j-dex2jar.bat,Linux 或 macOS 平台用到的则是 d2j-dex2jar.sh

为了方便,我们把 classes.dex 文件复制到『Dex2Jar』解压后所在的目录,并打开命令行终端进入到该目录,执行以下命令:

➜   d2j-dex2jar classes.dex

一般执行结果如下所示:

dex2jar 正确执行

但也时常有报错:

dex2jar 执行报错

网上的文章说产生该报错可能是因为安装的『Dex2Jar』不是最新版,而实际上我安装了最新版也出现该情况。

尽管有报错,但『Dex2Jar』依然帮我把 Dex 文件转成了 Jar 文件,只不过多了一个错误日志的压缩包罢了。

classes-dex2jar.jar 这个文件,才是真正有用的文件。

  • JD-GUI

可是对于我们而言,Jar 文件也不是可读的,因此这里还需要再借助一下『JD-GUI』这个工具来将 Jar 文件转换成 Java 代码。

JD-GUI

JD-GUI』安装完后,坑还没完,你得确定你安装了 JRE,否则是无法运行的。

这时候你可能就会想,做 Android 开发的,怎么可能没有 JRE 呢,Android SDK 里都自带了呢。

Too young too simple.

按照之前在『Windows 平台 React Native 开发环境搭建笔记』提到的方法把 JRE 配置到环境变量后,打开『JD-GUI』,依然可能会报出需要 JRE 的提示:

JRE 1.8 Required

使用命令的方式来打开:

➜   java -jar jd-gui.exe

相信有部分用户能正常打开了,而没有正常打开的用户会发现,在『JD-GUI』的 Logo 一闪而过后,报了错:

用命令启动 JD-GUI 失败

应该可以猜到原因,上面报需要 JRE 的提示框中,点击确定后会打开一个网页:

Oracle Java

熟悉吗?我们刚开始学 Java 的时候就是来这个网站下载 JDK 的。

至于原因,你要清楚这两者的区别,Android SDK 中使用的是 OpenJDK,而我们安装 Java 开发环境的时候使用的是 Oracle JDK。

OpenJDK 是 Sun 在 2006 年末把 Java 开源而形成的项目,如 IcedTea、UltraViolet 等都是从 OpenJDK 源码衍生出的发行版,包括国内的大厂也有单独维护的基于 OpenJDK 的发行版,比如华为的毕昇JDK、阿里巴巴的 Dragonwell、腾讯的 Kona 等。

Oracle JDK 采用了商业实现,而 OpenJDK 使用的是开源的 FreeType。当然,相同是建立在两者共有的组件基础上的,Oracle JDK 中还会存在一些 OpenJDK 没有的、商用闭源的功能。

Oracle JDK 和 OpenJDK 区别

尽管我们编译的 OpenJDK 基本上可以认为性能、功能和执行逻辑上都和 Oracle JDK 是一致的,但这么一小点的差别就在『JD-GUI』中被体现出来了。

所以我也顺手在『JD-GUI』的 Github 开源项目上提了 Issue,发现包括微软工程师在内的不少用户都遇到这个问题,实际上这是 OpenJDK 的 Bug,『Android Studio』内置的 OpenJDK 1.8 版本恰好就包含了这个 Bug,而在『Android Studio』更新到 4.2 版本之后内置的 OpenJDK 也提升至 11.0.8,这个问题也就不再出现了,假如你用的是『Android Studio』内置的 OpenJDK 且不想升级『Android Studio』,另外再下载一套新的 JDK 即可。

更换 JDK 后应该就能正常启动『JD-GUI』了,然后把上面得到的 Jar 文件拉进来即可。

JD-GUI 反编译 Jar

可以发现,代码反编译操作十分成功,MainActivity 中的内容基本全部还原,但你会发现 setContentView()findViewById() 之类的方法传参并不是之前 Demo 中写的,因为这个参数实际上只是资源的 ID 值,那么这里反编译也就只能将相应的 ID 值进行还原,而无法变成像 R.layout.activity_mainR.id.button 这样直观的代码展示。

除了 MainActivity 之外,还有很多其它的代码也被反编译出来了,比如当前项目中引用的 AndroidX 的包,这些引用的 Library 也会作为代码的一部分被打包到「classes.dex」文件当中,因此反编译的时候这些代码也会一起被还原。

  • JADX

JD-GUI』算是我刚接触反编译时使用的工具,从上面的流程可以看到,反编译的操作其实是比较繁琐的,为了提高工作效率当然要寻找更优秀的工具,后来我便发现了『JADX』。

JADX

JADX』能够直接将 APK、Dex、AAR、AAB 等 Dalvik 字节码格式文件反编译,只需将文件拖进『JADX』即可。

将上面生成的 APK 文件用『JADX』打开:

JADX 反编译 APK

可以看到几乎与『JD-GUI』无异,但是一步到位真的太省事了。

反编译资源

你可能会有些奇怪,刚才解压 APK 文件后不是已经可以看到资源文件的目录了吗,之前『获取手机应用 ICON 的方法』一文也通过这种方法获取到了安装包里面的许多图片资源,那怎么还需要反编译资源呢?

这里说的当然不是图片资源,有时候会需要查看或者修改布局文件甚至是 AndroidManifest.xml 中的内容,而你如果直接打开上面解压后的文件,可能会是一脸懵逼的:

Brackets 打开被编译的 AndroidManifest

或者是这样的:

Sublime Text 打开被编译的 AndroidManifest

在 Android 打包的时候,资源文件会被编译,所以直接打开是无法看到明文的,因此我们还需要对资源进行反编译。

  • Apktool

于是,又需要另外一个工具 ——『Apktool』。

Apktool

Apktool』可以用于最大幅度地还原 APK 文件中的 9-patch 图片、布局、字符串等等一系列的资源。

下载下来后同样是得到一个 Jar 包,所以我们还是会通过命令来进行反编译操作。

把需要反编译的 APK 文件复制到和『Apktool』相同的目录,在命令行终端进到该文件夹中,执行以下命令:

➜   java -jar apktool.jar d Demo.apk

其中 d 是指 Decode 的意思,表示对后面的 Demo.apk 文件进行解码。还可以加上一些附加参数来控制 Decode 行为:

  • -f:如果目标文件夹已存在,则强制删除现有文件夹(默认如果目标文件夹已存在,则解码失败)。
  • -o:指定解码目标文件夹的名称(默认在当前命令行所在目录使用 APK 文件的名字来命名目标文件夹)。
  • -s:不反编译 Dex 文件,也就是说 classes.dex 文件会被保留(默认会将 Dex 文件解码成 Smali 文件)。
  • -r:不反编译资源文件,也就是说 resources.arsc 文件会被保留(默认会将 resources.arsc 解码成具体的资源文件)。

有时候 APK 包体太大,为了避免文件的复制或移动耗时太长,我会使用以下的命令:

➜   java -jar apktool.jar d -f {需要解码的Apk文件} -o {解码后项目存储目录}

不过使用该命令需要注意的是,需要解码的 APK 文件路径和解码后项目所在路径不能为同一目录,因为有 -f 参数,解码后指定的项目目录会执行一次清空操作,即假如被反编译的 APK 在这一目录,则会被删除,而此时反编译操作仍未结束,就会导致报错。

当然你按照上面的操作也有可能会反编译失败,比如报以下错误:

存在加密 Dex 导致 Apktool 报错

这是因为 APK 里有加密过的 Dex 文件,比如有些 APK 会在 /assets 目录下存放加密的 Dex 文件,就会报这个错。要解决这个错误需要将『Apktool』升级至 2.4.1 或以上版本,然后增加如下参数:

➜   java -jar apktool.jar d -f {需要解码的Apk文件} -o {解码后项目存储目录} --only-main-classes

这个参数是在 2.4.1 版本上新增的,源码判断如下:

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;

即反编译根目录下的以 classes 开头,并以 .dex 结尾的 Dex 文件。

除此之外,你还可能遇到一些奇奇怪怪的错误,比如:

Apktool 版本兼容报错

出现这个错误的原因很有可能是你之前使用过老版本的『Apktool』进行反编译操作,然后『Apktool』就会在你系统的 C:\Users\Administrator\apktool\framework 这个目录下生成一个名字为 1.apk 的缓存文件,将这个缓存文件删除掉,然后再重新执行反编译命令应该就可以成功了。

反编译成功后,就可以在默认目录或者指定目录中得到反编译的项目,项目结构跟我们平时开发的目录结构也是非常相似的。

现在打开 AndroidManifest.xml 瞧一瞧:

正确反编译的 AndroidManifest

这样就完全看得懂了吧,除了格式相比在『Android Studio』里面要压缩了许多之外,其他基本无异,完全可以通过文本编辑器的格式化插件或者是直接放进『Android Studio』里面格式化解决。

入侵

反编译后我们就可以对 App 进行修改,或者说,入侵。

在游戏发行领域,我们常用于为游戏母包注入自己或第三方的 SDK,如统计数据等,在这种情况下,反编译是允许的,因为我们获得了游戏研发商的授权,且对游戏本体功能没有造成任何损害。

在其他领域可就不一定了,比如市面上流行的很多破解版软件,实际上就是通过反编译技术对原作者的一种侵权行为。

当然,还有一些或许还在灰色地带的用法,比如说汉化,它并没有向破解软件一样入侵软件的原有逻辑,只是翻译其中的资源进行打包,但不管怎么说依然是对他人开发的程序进行了修改,虽然造福了用户,却依然不是一件值得吹捧的事。

这次就不去讨论本身这件事情的对或错,只是站在技术的角度来了解相关知识。

我们游戏发行常常需要修改的是 App 的资源文件,比如说包名,比如说应用图标,比如说应用名称等,跟我们平时开发中的差别不大,只需要把资源复制到相应的文件夹,修改资源文件内对应的部分即可,一般遵循有则覆盖无则追加的原则。

解释一下另外两个目录,/original 目录下存放的是未经反编译过、原始的 AndroidManifest.xml 文件,/smali 目录下存放的是反编译出来的所有代码。

进入 /smali 目录后你可以发现它的目录结构和我们平时开发时的 /app/src 目录结构十分相似,主要区别就是 Java 文件全都变成了 Smali 文件,因此这些 Smali 文件实际上也就是真正的源代码,只不过它的语法和 Java 完全不同,有点类似于汇编的语法,是 Android 虚拟机所使用的寄存器语言。

看不懂也是十分正常的,但是一旦能够看得懂 Smali 文件,你就可以做很恐怖的事情了 —— 随意修改应用程序内的逻辑,将其破解。

说实话我并没有学习过 Smali 的语法,但即使这样我也可以对这个 Demo 做一定程度的修改了,因为这个 Demo 实在写得太简单了,打开反编译后的 MainActivity,它处在对应的包目录下。

打开后会发现有两个与 MainActivity 相关的文件,一个是 MainActivity.smali,还有一个是 MainActivity$1.smali,一般情况下,当类内包含内部类或匿名类时,就会产生多个同名的以 $ 及后面数字或类名区分的 Smali 文件。

先来看「MainActivity.smali」:

.class public Lcom/example/reverse/MainActivity;
.super Landroidx/appcompat/app/AppCompatActivity;
.source "MainActivity.java"


# direct methods
.method public constructor <init>()V
    .locals 0

    .line 9
    invoke-direct {p0}, Landroidx/appcompat/app/AppCompatActivity;-><init>()V

    return-void
.end method


# virtual methods
.method protected onCreate(Landroid/os/Bundle;)V
    .locals 1

    .line 13
    invoke-super {p0, p1}, Landroidx/appcompat/app/AppCompatActivity;->onCreate(Landroid/os/Bundle;)V

    const p1, 0x7f0a001c

    .line 14
    invoke-virtual {p0, p1}, Lcom/example/reverse/MainActivity;->setContentView(I)V

    const p1, 0x7f070042

    .line 15
    invoke-virtual {p0, p1}, Lcom/example/reverse/MainActivity;->findViewById(I)Landroid/view/View;

    move-result-object p1

    new-instance v0, Lcom/example/reverse/MainActivity$1;

    invoke-direct {v0, p0}, Lcom/example/reverse/MainActivity$1;-><init>(Lcom/example/reverse/MainActivity;)V

    invoke-virtual {p1, v0}, Landroid/view/View;->setOnClickListener(Landroid/view/View$OnClickListener;)V

    return-void
.end method

反编译后的代码虽然咋一看与我们平时写的代码大相径庭,但仔细一行行读过去,其实也不是读不通。即使我对 Smali 的语法依然不熟悉,这里也不打算介绍,我们逐行阅读,依然能够读懂。

第一部分:

.class public Lcom/example/reverse/MainActivity;
.super Landroidx/appcompat/app/AppCompatActivity;
.source "MainActivity.java"

与 Java 中的 class 关键字相似,Smali 用 .class 来指定当前的类名,同时还包含了完整的包名。

.super 我们也很熟悉,在 Java 中可以理解为是指向父类的指针,在这里的作用应该和 extends 相似,即继承自 AppCompatActivity

.source 不难看出,描述的是源 Java 文件。

第二部分:

# direct methods
.method public constructor <init>()V
    .locals 0

    .line 9
    invoke-direct {p0}, Landroidx/appcompat/app/AppCompatActivity;-><init>()V

    return-void
.end method

.method 指代方法,constructor 顾名思义就是构造方法了,因为我们没有写 MainActivity 的构造方法,所以它会使用父类 AppCompatActivity 的构造方法。

第三部分:

# virtual methods
.method protected onCreate(Landroid/os/Bundle;)V
    .locals 1

    .line 13
    invoke-super {p0, p1}, Landroidx/appcompat/app/AppCompatActivity;->onCreate(Landroid/os/Bundle;)V

    const p1, 0x7f0a001c

    .line 14
    invoke-virtual {p0, p1}, Lcom/example/reverse/MainActivity;->setContentView(I)V

    const p1, 0x7f070042

    .line 15
    invoke-virtual {p0, p1}, Lcom/example/reverse/MainActivity;->findViewById(I)Landroid/view/View;

    move-result-object p1

    new-instance v0, Lcom/example/reverse/MainActivity$1;

    invoke-direct {v0, p0}, Lcom/example/reverse/MainActivity$1;-><init>(Lcom/example/reverse/MainActivity;)V

    invoke-virtual {p1, v0}, Landroid/view/View;->setOnClickListener(Landroid/view/View$OnClickListener;)V

    return-void
.end method

同样是方法,这里描述的是 onCreate() 方法,参数为 Bundle 对象,然后执行 super 也就是 AppCompatActivity 中的 onCreate() 方法,接着便看到了 setContentView() 方法被调用,参数 I 表示 int 类型。至此为项目默认生成的代码。

接下来是我们的逻辑,通过 findViewById() 找到我们的 View 控件,也就是我们在布局文件中的 Button,最后再调用 setOnClickListener() 传入我们的点击事件 View.OnClickListener

那我们点击事件的逻辑呢?

上文提到,当类内包含内部类或匿名类时,就会产生多个同名的以 $ 及后面数字或类名区分的 Smali 文件,所以接下来看 MainActivity$1.smali 文件:

.class Lcom/example/reverse/MainActivity$1;
.super Ljava/lang/Object;
.source "MainActivity.java"

# interfaces
.implements Landroid/view/View$OnClickListener;


# annotations
.annotation system Ldalvik/annotation/EnclosingMethod;
    value = Lcom/example/reverse/MainActivity;->onCreate(Landroid/os/Bundle;)V
.end annotation

.annotation system Ldalvik/annotation/InnerClass;
    accessFlags = 0x0
    name = null
.end annotation


# instance fields
.field final synthetic this$0:Lcom/example/reverse/MainActivity;


# direct methods
.method constructor <init>(Lcom/example/reverse/MainActivity;)V
    .locals 0

    .line 15
    iput-object p1, p0, Lcom/example/reverse/MainActivity$1;->this$0:Lcom/example/reverse/MainActivity;

    invoke-direct {p0}, Ljava/lang/Object;-><init>()V

    return-void
.end method


# virtual methods
.method public onClick(Landroid/view/View;)V
    .locals 2

    .line 18
    iget-object p1, p0, Lcom/example/reverse/MainActivity$1;->this$0:Lcom/example/reverse/MainActivity;

    const-string v0, "Hello World"

    const/4 v1, 0x0

    invoke-static {p1, v0, v1}, Landroid/widget/Toast;->makeText(Landroid/content/Context;Ljava/lang/CharSequence;I)Landroid/widget/Toast;

    move-result-object p1

    invoke-virtual {p1}, Landroid/widget/Toast;->show()V

    return-void
.end method

重复的内容就不过多解释了,来看刚刚没有提到的。

接口:

# interfaces
.implements Landroid/view/View$OnClickListener;

.implements 依然是老朋友,指实现了哪些接口,回到一开始我们写的代码,里面按钮中实现了一个点击事件,就是用的 View.OnClickListener

注解:

# annotations
.annotation system Ldalvik/annotation/EnclosingMethod;
    value = Lcom/example/reverse/MainActivity;->onCreate(Landroid/os/Bundle;)V
.end annotation

.annotation system Ldalvik/annotation/InnerClass;
    accessFlags = 0x0
    name = null
.end annotation

.annotation 就是注解,很容易联想到我们代码中常用的注解 @Override,这里指向了 onCreate() 方法,参数为 Bundle

实例域:

# instance fields
.field final synthetic this$0:Lcom/example/reverse/MainActivity;

到最重要的点击事件:

# virtual methods
.method public onClick(Landroid/view/View;)V
    .locals 2

    .line 18
    iget-object p1, p0, Lcom/example/reverse/MainActivity$1;->this$0:Lcom/example/reverse/MainActivity;

    const-string v0, "Hello World"

    const/4 v1, 0x0

    invoke-static {p1, v0, v1}, Landroid/widget/Toast;->makeText(Landroid/content/Context;Ljava/lang/CharSequence;I)Landroid/widget/Toast;

    move-result-object p1

    invoke-virtual {p1}, Landroid/widget/Toast;->show()V

    return-void
.end method

这里就是点击事件 onClick() 的实现,我们看到了定义的要显示的字符串,ToastmakeText() 方法以及 show() 方法。

至此,整个 MainActivity 就通读一遍了,虽然不会写,但只要有写过代码,并且比对原 Java 文件,相信都能读出个大概。

尽管不知道怎么写,但我们知道,字符串在这里只是显示作用,我们并没有对这个字符串做其他操作,所以可以直接修改这个字符串,当用户点击 Button 后,弹出的是我们修改后的内容,同时也不会影响到程序的正常运行。

而在我们游戏渠道打包的时候,大多数情况并不会手动修改里面的内容,因为实际上也没有太多必要,游戏逻辑是游戏研发商设计的,我们只需要注入 SDK,具体做法就是把我们的 SDK 打包并反编译成 Smali 文件,然后一一复制到母包中,覆盖原有的内容。

重新打包

不管是何种修改,肯定要重新打包才能够安装到手机上,重新打包也同样要用到『Apktool』,执行命令:

➜   java -jar apktool.jar b Demo -o New_Demo.apk

其中 b 是指 Build 的意思,后面指定了重新打包后的 APK 文件名称,这样你就会在同级目录中得到这个重新打包后的文件。

同样,为了简便可以用以下命令:

➜   java -jar apktool.jar b {解码后项目所在目录}

没有指定重新打包后的 APK 文件名,会默认在解码后的项目中创建一个名为 /dist 的文件夹,打开这个文件夹你就会发现一个和项目同名的 APK 文件,这就是重新打包后的文件了。

不过也不要高兴得太早,因为这个 APK 文件还不能直接安装在手机上。

APK Signature Scheme v1

重新打包后的 APK 文件不能直接安装在手机上,是因为这个安装包还没进行签名,平时我们要上线应用,都要先对应用进行签名,而现在经过我们反编译后原签名已经被破坏掉了,所以我们需要手动签名。

但是,假如我们反编译的是别人的 App,那我们从哪儿能拿到它原来的签名文件呢?很显然,根本没有办法拿到,因此我们只能拿自己的签名文件来对这个 APK 文件重新进行签名,但同时也表明我们重新打包出来的软件就是个十足的盗版软件。

这是 Android 为了保护版权而设立的机制,因此判定盗版 App 的标准就很明确了,只要你不是使用原版的签名,那就是盗版的,所以汉化软件实际上也是盗版软件,当然如果有人通过非法途径获取到了原版签名,那我也无话可说了。

签名的生成相信做 Android 开发的都不会陌生吧,直接到『Android Studio』中生成一个即可,有了签名文件后就可以执行命令:

➜   jarsigner -verbose -sigalg SHA1withRSA -digestalg SHA1 -keystore {签名文件} -storepass {KeyStore_Password} {待签名的APK文件} {Key_alias}

该命令执行完成后你可能会发现并没有新增其他文件,实际上它是生成一个和原来待签名文件名相同的已签名的 APK 文件,并且覆盖了原来待签名的文件。

当然,配置参数不同写法也不尽相同,比如我最常用的:

➜   jarsigner -keystore {签名文件} -storepass {KeyStore_Password} -keypass {Key_Password} -signedjar {签名后的APK文件} {待签名的APK文件} {Key_alias}

这种方法会指定签名后的文件名称,而不会覆盖原有的待签名文件。

相信你已经注意到,这里用于签名的工具实际上是 JDK 中用于给 Jar 包签名的『JarSigner』,它会使用 SHA1 或者 SHA256 算法对 APK 中每个文件生成摘要并进行 BASE64 编码写入到 MANIFEST.MF 文件中,然后使用 SHA1 算法对 MANIFEST.MF 二次摘要再写入到 CERT.SF 中,接着使用私钥对 CERT.SF 签名,签名结果与公钥和证书一起打包写入 CERT.RSA 里,最后将以上生成的这三个文件保存到 /META-INF 目录,压缩入 APK 内。

当你直接解压 APK 文件的时候也会发现这些经过签名生成的文件。

签名之后的 APK 文件现在已经可以安装到手机上了。

对齐

Android 还极度建议我们对签名后的 APK 文件进行一次对齐操作,因为这样可以使得我们的程序在 Android 系统中运行得更快。

对齐操作使用的是『ZipAlign』工具,该工具存放于 /{Android_SDK}/build-tools/{version} 目录下,将这个目录配置到系统环境变量当中就可以在任何位置执行此命令了。命令格式如下:

➜   zipalign 4 {已签名的APK文件} {对齐后的APK文件}

其中 4 是固定值不能改变,指的是字节对齐参数,代表对齐为 4 个字节,据说输入其他值起不到任何作用,在 4 个字节边界上对齐的意思就是,一般来说,是指编译器把 4 个字节作为一个单位来进行读取的结果,这样的话相比之前没有对齐的情况,CPU 能够对变量进行高效、快速的访问。

另外,验证一个 APK 文件是否对齐的命令如下:

➜   zipalign -c -v 4 {待验证的APK文件}

其中 -c 就是用于确认 APK 文件是否对齐,-v 则表示输出详细日志,是可选参数,同时也可以用在上方对齐的命令中,4 也是字节对齐参数。

最后就可以得到一个已签名并对齐的安装包了。

APK Signature Scheme v2

我们在老版本的『Android Studio』中打包时会要求勾选 V1 和 V2 签名(新版本中已无需勾选,默认会两者都启用):

老版本 Android Studio 可自主选择 V1 和 V2 签名

APK Signature Scheme v2 是一种全文件签名方案,该方案能够发现对 APK 的受保护部分进行的所有更改,从而有助于加快验证速度并增强完整性保证。

它从 Android 7.0(Nougat)开始支持,用于签名的工具『ApkSigner』内置在 Android SDK Build Tools 24.0.3 及更高的版本中。

官方文档中提到:

To make a APK installable on Android 6.0 (Marshmallow) and older devices, the APK should be signed using JAR signing before being signed with the v2 scheme.

为了使 APK 可在 Android 6.0(Marshmallow) 及更低版本的设备上安装,应先使用 JAR 签名功能对 APK 进行签名,然后再使用 v2 方案对其进行签名。

因此,尽管你仅使用 V1 方案签名也可以使 APK 在设备上正常安装运行,但应当同时采用 V2 方案,且两套签名方案有明确的先后顺序。

官方文档中还提到:

Caution: You must use zipalign at one of two specific points in the app-building process, depending on which app-signing tool you use:

  • If you use apksigner, zipalign must only be performed before the APK file has been signed. If you sign your APK using apksigner and make further changes to the APK, its signature is invalidated.
  • If you use jarsigner, zipalign must only be performed after the APK file has been signed.

注意:您必须在应用构建过程中的两个特定时间点之一使用 zipalign,具体在哪个时间点使用,取决于您所使用的应用签名工具:

  • 如果您使用的是 apksigner,只能在为 APK 文件签名之前执行 zipalign。如果您在使用 apksigner 为 APK 签名之后对 APK 做出了进一步更改,签名便会失效。
  • 如果您使用的是 jarsigner,只能在为 APK 文件签名之后执行 zipalign

这样顺序就更加明确了,先使用『JarSigner』进行 V1 方案签名,再使用『ZipAlign』做对齐操作,最后再使用『ApkSigner』进行 V2 方案签名。

V2 方案签名命令如下:

➜   java -jar apksigner.jar sign --ks {签名文件路径} --ks-key-alias {Key_alias} --ks-pass pass:{KeyStore_Password} --key-pass pass:{Key_Password} --out {V2签名后的APK文件} {对齐后的APK文件}

虽然上方文档要求我们手动进行 V1 和 V2 方案签名,但是实际上『ApkSigner』也是支持 V1 方案签名的。它有一个配置的选项:

The following options specify basic settings to apply to a signer:

  • --v1-signing-enabled <true | false>: Determines whether apksigner signs the given APK package using the traditional, JAR-based signing scheme. By default, the tool uses the values of --min-sdk-version and --max-sdk-version to decide when to apply this signature scheme.

以下选项指定要应用于签名者的基本设置:

  • --v1-signing-enabled <true | false>:确定 apksigner 是否会使用基于 JAR 的传统签名方案为给定的 APK 软件包签名。默认情况下,该工具会使用 --min-sdk-version--max-sdk-version 的值来决定何时采用此签名方案。

因此我们可以不需要手动使用『JarSigner』进行 V1 方案签名,直接使用『ApkSigner』进行 V2 方案签名即可。

查询 APK 文件签名情况可以使用以下命令:

➜   java -jar apksigner.jar verify -v {需要查看签名的 APK}

至此,V2 签名方案也操作完成了。

生成已签名的 APK 文件的同时,你可以发现还自动生成了一个以 APK 文件名命名的 IDSIG 文件,默认情况下,IDSIG 文件包含 APK 文件的完整 Merkle 树。使用此标志时,『ApkSigner』会生成一个 V4 签名方案的 IDSIG 文件,且不会嵌入完整的 Merkle 树。

Google 在 Android 9(Pie)中引入了 APK Signature Scheme v3,在 Android 11(R)中引入了 APK Signature Scheme v4,这里暂不介绍。

测试

无论是破解还是 SDK 注入,都需要对最终的安装包进行测试,确认其是否按照我们期望的效果正常运行。

把这个 APK 文件安装到手机上来查看效果:

修改过的 APK 演示效果

可以看到,无论是应用图标、应用名称,还是点击按钮后的响应逻辑,都修改成了我设定的样子,说明以上的所有操作都成功了。


以上就是 Android 反编译中的基本操作,当然也只是一个简单的入门,有很多东西没办法一一展开,以后有机会的话,可以再深入聊聊。

另外,还是要再次提醒,反编译技术仅用于学习和交流,请勿作于非法用途。

参考内容: