开卷有益 · 不求甚解
当今世界的数字通信在我们的社会中具有特别高的地位。金融交易通过网上银行进行,私人通信越来越局限于数字信使服务,甚至健康数据也正在经历向数字形式的转变。由于此类敏感数字数据的增长,在过去几十年中,对此类数据的安全传输的需求变得越来越重要。随着 SSL/TLS 等高性能和数字安全加密方法的引入,当今的数字通信主要是加密的。例如,在当时,攻击者可以将自己挂在客户端和服务器之间,并在没有加密的情况下读取数据流量,而今天他看到的只是一堆乱七八糟的字母。加密确实是保护敏感个人数据的福音,但它也有它的缺点,就像几乎所有东西一样。加密通信否定了分析通信的能力,这在逆向工程恶意软件或研究漏洞时非常重要。
拦截和解密加密通信的最著名的解决方案之一是所谓的“中间人”攻击。在这种情况下,攻击者或分析师假装是客户值得信赖的通信伙伴。然而,由于客户端通常不知道客户端的通信伙伴(以下称为服务器)如何进行通信或行为,因此攻击者(或分析员)将通信转发到服务器并假装是客户端。例如,要通过 TLS 建立加密通信,需要一个证书,服务器在建立连接时将其发送给客户端。因此,使用 MitM 证书(假证书)在 MitM 代理和客户端之间建立连接,并使用服务器证书在 MitM 代理和服务器之间建立连接。
由于此设置,客户端和服务器之间的通信通过 MitM 代理进行路由,并且可以在其上进行处理而无需加密。
有一些预防措施可以防止此类攻击,尤其是在移动设备上。最著名的措施之一是所谓的“证书固定”。这涉及将预期的服务器证书或证书的散列存储在客户端本身的二进制文件中。如果客户端随后从所谓的服务器接收到证书,则将其与嵌入的证书进行比较或通过哈希值进行验证。如果此验证不成功,则中止连接。
解决此问题的一种可能方法是修改certificate pinning
本身:
这种方法是可行的,但在许多情况下,它非常耗时,因为根据版本的不同,pinning 的实现可能会有很大差异,如果从一个著名的图书馆。此外,特别是对于恶意软件,有几种不同的固定实现方式,这就是为什么通用方法通常无法实现目标的原因。
有一件事是肯定的:为了获得未加密的通信,客户端应用程序必须受到“攻击”。这导致我们问为什么我们不直接从目标应用程序中提取解密的 SSL/TLS 流或密钥材料。
大多数执行加密通信的应用程序都使用广泛可用的库来执行此操作,例如 OpenSSL 和 NSS。这些库尽量使数据的加密保持抽象,这样库的使用非常方便。除其他外,它们封装了 TLS 握手以及加密数据的发送和接收。
使用 TLS 库的常见程序流程如下所示:
应用程序想要建立到服务器的安全 TLS 连接。为此,它使用 TLS 库来执行握手,如下所示:
建立 TLS 连接后,现在可以使用 TLS 库的读写功能发送和接收数据,如下图所示。
正是这些 TLS-read 和 TLS-write 函数被目标应用程序分别用于从 TLS 流中读取和写入明文。因此,我们的工具friTap正在Hook它们以接收加密数据包的明文。除此之外,friTap 还能够提取使用过的 TLS 密钥。
friTap有两种操作模式。一种是从 TLS 有效负载中获取明文作为 PCAP,另一种是获取使用的 TLS 密钥。为了获得解密的 TLS 有效负载,我们需要以下-p
参数:
$ ./friTap.py –m –p decryptedTLS.pcap <target_app>
…
[*] BoringSSL.so found & will be hooked on Android!
[*] Android dynamic loader hooked.
[*] Logging pcap to decryptedTLS.pcap
该-m
参数表明我们正在分析上述示例中的移动应用程序。为了从目标应用程序中提取 TLS 密钥,我们需要以下-k
参数:
$ ./friTap.py –m –k TLS_keys.log <target_app>
…
[*] BoringSSL.so found & will be hooked on Android!
[*] Android dynamic loader hooked.
[*] Logging keylog file to TLS_keys.log
结果 friTap使用NSS Key Log FormatTLS_keys.log
将所有 TLS 密钥写入文件。
在了解了整体方法之后,让我们深入了解friTap的内部结构。
friTap建立在动态检测工具包FRIDA 之上,它允许开发人员、逆向工程师和安全研究人员动态分析和检测程序。FRIDA 允许您在目标程序中执行 Javascript 代码,这使您能够Hook函数、读写程序内存、执行自定义代码等。为使用 FRIDA 提供了一个 Python API,这使得它非常用户友好。
为了实现这一点,FRIDA 将QuickJS Javascript 引擎(也可以更改为V8 运行时)注入目标进程和一个代理,该代理稍后充当工具化进程与其自己的工具之间的通信接口。注入引擎和代理后,用户可以在目标进程内执行自己的 Javascript 代码并从中接收数据。更多关于 FRIDA 内部运作的信息可以在这里找到。
可以在下图中看到 friTap 流程的粗略概述,在接下来的部分中将对此进行更详细的说明。将friTap JS脚本加载到目标进程后的第一步是识别目标进程的操作系统(os):
然后将加载特定于操作系统的代理。此代理枚举目标进程中所有已加载的库/模块。FRIDA 为此提供了一个函数,它为每个加载的模块返回其名称、基地址、大小和文件系统中的路径。根据模块的名称,friTap 可以识别 SSL/TLS 库。根据版本和操作系统,加载模块的名称可能会有很大差异。friTap 尝试使用富有表现力的正则表达式尽可能地覆盖受支持库的所有潜在模块名称。特定于操作系统的代理确定支持哪些库以及如何实现其Hook:
当检测到支持的库时,friTap 会尝试Hook相应库的SSL-read()
、SSL-write()
和SSL-keyexport()
函数以及为此所需的所有其他函数。有时目标库不提供密钥导出功能,在这种情况下,friTap 必须解析堆才能在目标进程的内存中找到密钥。
接下来我们要深入研究 friTap 中提到的部分的实现细节。如上所述,friTap 首先检查我们的目标进程在哪个平台上运行并调用,而不是其各自的操作系统特定代理:
function load_os_specific_agent() {
if(isWindows()){
load_windows_hooking_agent()
}else if(isAndroid()){
load_android_hooking_agent()
}else if(isLinux()){
load_linux_hooking_agent()
}else if(isiOS()){
load_ios_hooking_agent()
}else if(isMacOS()){
load_macos_hooking_agent()
}else{
log("Error: not supported plattform!\nIf you want to have support for this plattform please make an issue at our github page.")
}}
此代理为检测到的库安装Hook。首先,受支持的 SSL/TLS 库的枚举是安全的 ( module_library_mapping
) 并提供给不同的钩子。在下文中,我们将看到这是如何在 Android 上完成的:
export function load_android_hooking_agent() {
module_library_mapping[plattform_name] = [[/.*libssl_sb.so/, boring_execute],[/.*libssl\.so/, boring_execute],[/.*libgnutls\.so/, gnutls_execute],[/.*libwolfssl\.so/, wolfssl_execute],[/.*libnspr[0-9]?\.so/,nss_execute], [/libmbedtls\.so.*/, mbedTLS_execute]];
install_java_hooks();
hook_native_Android_SSL_Libs(module_library_mapping);
hook_Android_Dynamic_Loader(module_library_mapping);
}
如果支持,friTap 会安装基于 java 的钩子。现在这些 java 钩子只为 Android 应用程序安装。接下来安装平台(操作系统)特定的钩子。找到支持的 SSL/TLS 库后,开始搜索模块内的相应功能(读取、写入、密钥导出)。这是使用 中的映射函数完成的module_library_mapping
。当我们仔细查看枚举时,我们可以看到对于每个检测到的库,都映射了一个适当的所谓<libname>-execute
函数。此映射函数包含SSL-read()
,SSL-write()
和SSL-keyexport()
钩子。严格来说,对于每个识别出的库,其平台特定的钩子(读、写、导出)都会为相应的库安装。幸运的是,大多数Hook实现都是平台独立的,只有少数平台存在差异。这意味着特定库的整体Hook实现由独立于操作系统的超类提供。在下文中,我们看到了 Android OpenSSL Hook实现以及从其超类继承的实现:
/* from openssl_boringssl_android.ts */
export class OpenSSL_BoringSSL_Android extends OpenSSL_BoringSSL { constructor(public moduleName:String, public socket_library:String){
super(moduleName,socket_library);
}
execute_hooks(){
this.install_plaintext_read_hook();
this.install_plaintext_write_hook();
this.install_tls_keys_callback_hook();
}
}
export function boring_execute(moduleName:String){
var boring_ssl = new OpenSSL_BoringSSL_Android(moduleName,socket_library);
boring_ssl.execute_hooks();
}
库的特定功能只有在超类中Hook。这是通过库的特定函数名(SSL_read、SSL_write…)来完成的,这些函数名被传递给我们的readAddresses()
函数以获得Hook的地址。
/* super class openssl_boringssl.ts */
export class OpenSSL_BoringSSL { // global variables
library_method_mapping: { [key: string]: Array<String> } = {};
addresses: { [key: string]: NativePointer };
...
constructor(public moduleName:String, public socket_library:String,public passed_library_method_mapping?: { [key: string]: Array<String> }){
if(typeof passed_library_method_mapping !== 'undefined'){
this.library_method_mapping = passed_library_method_mapping;
}else{
this.library_method_mapping[`*${moduleName}*`] = ["SSL_read", "SSL_write", "SSL_get_fd", "SSL_get_session", "SSL_SESSION_get_id", "SSL_new", "SSL_CTX_set_keylog_callback"]
this.library_method_mapping[`*${socket_library}*`] = ["getpeername", "getsockname", "ntohs", "ntohl"]
}
this.addresses = readAddresses(this.library_method_mapping);
...
}
...
FRIDA 为ApiResolver提供了一个函数enumerateMatches("exports:" + library_name + "!" + method)
:在单个字符串中传递函数名称、模块名称和类型(导出、导入)。如果找到匹配项,则返回有关此函数的信息,其中 friTap 只需要并存储地址。下面是 friTapreadAddresses()
函数的完整列表:
//File: agent/shared/shared_functions.ts/**
* Read the addresses for the given methods from the given modules
* @param {{[key: string]: Array<String> }} library_method_mapping A string indexed list of arrays, mapping modules to methods
* @return {{[key: string]: NativePointer }} A string indexed list of NativePointers, which point to the respective methods
*/
export function readAddresses(library_method_mapping: { [key: string]: Array<String> }): { [key: string]: NativePointer } {
var resolver = new ApiResolver("module")
var addresses: { [key: string]: NativePointer } = {}
for (let library_name in library_method_mapping) {
library_method_mapping[library_name].forEach(function (method) {
var matches = resolver.enumerateMatches("exports:" + library_name + "!" + method)
var match_number = 0;
var method_name = method.toString();
if(method_name.endsWith("*")){
method_name = method_name.substring(0,method_name.length-1)
}
if (matches.length == 0) {
throw "Could not find " + library_name + "!" + method
}
else if (matches.length == 1){
devlog("Found " + method + " " + matches[0].address)
}else{
for (var k = 0; k < matches.length; k++) {
if(matches[k].name.endsWith(method_name)){
match_number = k;
devlog("Found " + method + " " + matches[match_number].address)
break;
}
}
}
addresses[method_name] = matches[match_number].address;
})
}
return addresses
}
在所有相关函数地址都可用后,friTap 最终会在进入或离开各自函数时安装钩子。稍后再谈。
有可能要分析的程序在程序启动时没有加载 SSL/TLS 库或在其他时间再次加载 SSL/TLS 库。对于这种情况,friTap 在操作系统的相应标准库中Hook一个函数。以下是Android的实现:
/* File agent/android/android_agent.ts */function hook_Android_Dynamic_Loader(module_library_mapping: { [key: string]: Array<[any, (moduleName: string)=>void]> }): void{
...
const regex_libdl = /.*libdl.*\.so/
const libdl = moduleNames.find(element => element.match(regex_libdl))
...
let dl_exports = Process.getModuleByName(libdl).enumerateExports()
var dlopen = "dlopen"
for (var ex of dl_exports) {
if (ex.name === "android_dlopen_ext") {
dlopen = "android_dlopen_ext"
break
}
}
Interceptor.attach(Module.getExportByName(libdl, dlopen), {
onEnter: function (args) {
this.moduleName = args[0].readCString()
},
onLeave: function (retval: any) {
if (this.moduleName != undefined) {
for(let map of module_library_mapping[plattform_name]){
let regex = map[0]
let func = map[1]
if (regex.test(this.moduleName)){
log(`${this.moduleName} was loaded & will be hooked on Android!`)
func(this.moduleName)
}
}
}
}
})
console.log(`[*] Android dynamic loader hooked.`)
...
}
现在,所有用于提取流或密钥材料的函数都应该已被识别,以便 friTap 可以使用Hook来提取明文有效负载或 TLS 密钥。
让我们深入了解Hook实现本身。不同支持的库和平台之间的检测方式部分不同,但都遵循相同的原则。
库的读取函数通常具有以下结构的函数签名:
int read (void*, void*, int)
第一个参数是指向 SSL 对象的指针,该对象保存有关在后台使用的 SSL 会话的所有信息。此对象用于标识接收数据的 SSL/TLS 流。第二个参数是一个指向临时缓冲区的指针,该缓冲区保存从 SSL/TLS 流接收的未加密数据。第三个参数是从 SSL/TLS 流接收的数据可以存储在缓冲区中的最大字节数。
对于 friTap,第二个参数,即包含未加密数据的缓冲区,是重要的参数。要读取这个缓冲区的内容,friTap 需要指向它的指针和接收到的字节数。FRIDA 的拦截器允许定义函数开始和结束的钩子。这些回调在函数执行之前和执行之后执行。函数 start 的钩子的回调函数被传递给钩子函数的所有参数。因此回调函数能够提取和操作所有传递的参数。friTap 利用这一点,从参数中提取 read 函数的第二个指针,该指针指向保存接收到的未加密数据的缓冲区。该实现在这里作为其他实现的示例(使用 OpenSSL),它看起来像这样:
Interceptor.attach(addresses["SSL_read"],
{
onEnter: function (args: any) {
var message = getPortsAndAddresses(SSL_get_fd(args[0]) as number, true, addresses)
message["ssl_session_id"] = getSslSessionId(args[0])
message["function"] = "SSL_read"
this.message = message
this.buf = args[1]
}
...
})
指向缓冲区的指针在名为 的参数数组中args
,严格来说在第二个位置(它是第二个函数参数)。现在使用 将其保存在执行上下文中this.buf = args[1]
,因为只有在执行读取函数后缓冲区才会被接收到的数据填充。
函数端的钩子只有一个参数,函数的返回值。在读取函数的情况下,这是接收到的字节数,这对于读取缓冲区很重要。函数末尾的钩子如下所示,再次以 OpenSSL 为例进行演示:
Interceptor.attach(addresses["SSL_read"],
{
...
onLeave: function (retval: any) {
retval |= 0 // Cast retval to 32-bit integer.
if (retval <= 0) {
return
}
const buffer_content = this.buf.readByteArray(retval)
this.message["contentType"] = "datalog"
send(this.message, buffer_content)
}
})
retval
是读取函数的返回值,即接收到的字节数。现在可以使用 读取先前保存的指向缓冲区的指针readByteArray()
。通过读取函数的返回值,friTap 可以准确地知道需要从缓冲区读取多少字节。然后将提取的字节存储在一个字典对象中,其中除了数据之外还包含端口号、发送方和接收方地址等信息。然后将send()
其从目标进程发送到主脚本(python 脚本),然后由主脚本处理此信息。
与读取函数一样,写入函数对于 friTap 支持的所有库具有相同的函数签名:
int write (void*, void*, int)
第一个参数是指向 SSL 对象的指针,该对象包含有关在后台使用的 SSL 会话的所有信息。此对象用于标识发送数据的 SSL/TLS 流。第二个参数是一个指向缓冲区的指针,该缓冲区以未加密的形式保存要传输的数据。第三个参数指定应通过关联的 SSL/TLS 流发送来自引用缓冲区的字节数。
与 read 函数不同,friTap 所需的所有信息在函数执行之前就已经可用。该实现再次以 OpenSSL 的实现为例:
Interceptor.attach(addresses["SSL_write"],
{
onEnter: function (args: any) {
var message = getPortsAndAddresses(SSL_get_fd(args[0]) as number, false, addresses)
message["ssl_session_id"] = getSslSessionId(args[0])
message["function"] = "SSL_write"
message["contentType"] = "datalog"
const bytesToBeSent = args[1].readByteArray(parseInt(args[2]))
send(message, bytesToBeSent)
}
})
args[1]
是指向缓冲区的指针,args[2]
要发送的字节数。readByteArray()
可以从缓冲区要发送的字节。然后将提取的字节存储在字典对象中,该对象除了包含数据之外还包含端口号、发送方和接收方地址等信息。然后将send()
其从目标进程发送到主脚本(Python 脚本),然后由主脚本处理此信息。
除了Hook读取和写入功能外,friTap 还提供了导出握手期间创建/接收的所有密钥的能力。然后可以使用这些密钥来解密加密的 TLS 流量。Wirehsark 提供了指定客户端连接到服务器时 friTap 创建的键盘日志文件的能力。此功能的实现差异很大。这是由于各个库的默认行为,尤其是取决于操作系统。
同样,我们想展示一个基于 Linux 上 OpenSSL 实现的示例:
const SSL_CTX_set_keylog_callback = ObjC.available ? new NativeFunction(addresses["SSL_CTX_set_info_callback"], "void", ["pointer", "pointer"]) : new NativeFunction(addresses["SSL_CTX_set_keylog_callback"], "void", ["pointer", "pointer"])const keylog_callback = new NativeCallback(function (ctxPtr, linePtr: NativePointer) {
var message: { [key: string]: string | number | null } = {}
message["contentType"] = "keylog"
message["keylog"] = linePtr.readCString()
send(message)
}, "void", ["pointer", "pointer"])
如果选择 OpenSSL 作为动态加载的库,默认会导出很多函数。好在函数SSL_CTX_set_keylog_callback
(linux桌面)也导出了。该函数使用户能够定义一个回调函数,每当生成或接收到新的密钥材料时都会调用该回调函数。调用此函数时会传递两个参数:与连接关联的 SSL 对象和新生成或接收的字符串形式的密钥材料。FRIDA 允许您定义自己的回调函数,我们为此用例所做的。friTap 创建一个新的回调函数,它读取传递的字符串并将其存储在一个字典对象中,该对象被发送到主脚本(python 脚本)并由它处理(记录或写出)。
为了注册自己的回调,该函数SSL_CTX_set_keylog_callback
必须在握手之前调用一次,回调函数作为参数。friTap 钩住了这个SSL_new
方法。该函数在握手之前调用,但也在 SSL 上下文创建之后调用,即绑定选项已经设置,以便回调函数可以接收后续握手的密钥材料。
对于每个操作系统,friTap 都知道通常的库/模块以及最终负责加载新库的函数。当新库加载到程序存储器中时,会检查新模块的名称以查看它是否与任何 SSL/TLS 库名称匹配。如果是这种情况,那么通常的读取、写入和密钥导出功能就会被Hook。
friTap 可以在这里下载:https://github.com/fkie-cad/friTap
近期阅读文章
,质量尚可的,大部分较新,但也可能有老文章。开卷有益,不求甚解
,不需面面俱到,能学到一个小技巧就赚了。译文仅供参考
,具体内容表达以及含义, 以原文为准
(译文来自自动翻译)尽量阅读原文
。(点击原文跳转)每日早读
基本自动化发布(不定期删除),这是一项测试
最新动态: Follow Me
微信/微博:
red4blue
公众号/知乎:
blueteams