基于Frida的Windows厚客户端SSL流量解析研究 – 作者:he0xwhale

在最近对windows厚客户端的渗透测试中,需要对该客户端的ssl加密信息进行解密,解密后才能对其web后端进行渗透测试。于是,在githubs上找到了ssl流量的解密工具ssl_logger,但其是基于linux/darwin平台,所以将其修改为win平台。本文对ssl_logger的实现流程进行了分析,并借此机会学习了frida的基本使用方法。

修改之后的ssl_logger,我放在了github上:ssl_logger

说明

本文系作者原创,欢迎转载,但请注明出处

联系方式:[email protected]

特别感谢Jason Geffner共享的ssl_logger脚本

作者也是frida的初学者,文章如果有错误或者纰漏,欢迎不吝赐教,共同学习进步

ssl_logger安装及使用

安装

由于ssl_logger基于frida,所以首先需要安装frida,本文使用的是python3,安装的frida版本为14

1.安装frida

pip install frida

安装过程中,会遇到无法正常下载的问题,这里参考这篇文章:安卓应用层协议/框架通杀抓包:实战篇,主要问题在于pip下载的脚本中写死了pip源,手动修改为国内源后,可以正常下载

2.安装frida-tools

pip install frida-tools

3.安装hexdump

如果要使用verbose显示详细流量信息,需要安装hexdump

pip install hexdump

4.下载ssl_logger脚本,可以使用git下载

git clone https://github.com/he0xwhale/ssl_logger.git

使用

这里假设需要分析的客户端二进制文件为sample.exe

1.运行sample.exe

2.运行ssl_logger:

选项说明:

verbose:在控制台输出详细信息

pcap:将流量记录到pcap文件

python ssl_logger.py sample.exe -verbose -pcap sample.pcap

3.对sample.exe进行相应的操作,结束后,在命令行界面ctlr+z结束ssl_logger

4.使用wireshark打开pcap文件,会发现已经解密的流量

5.可以使用burpsuite的repeater功能对流量重放,从而进行渗透测试

ssl_logger如何工作

原理分析

ssl_logger主要通过frida对openssl库中的SSL_read() 和SSL_write()进行hook:

在收到服务端响应后,SSL_read()会将加密的流量读出来,这时,流量已经是解密的

在发送请求之前,SSL_write()会首先对流量进行加密,这时,就可以读取到明文的请求内容

基本流程分析

ssl_logger的基本流程如下:

ssl_logger

main流程分析

1.解析命令行参数:

process:将要分析的进程

verbose:是否将详细内容输出到控制台

pcap:将要写入的pcap文件的名称

args = parser.add_argument_group("Arguments")
args.add_argument("-pcap", metavar="<path>", required=False,
help="Name of PCAP file to write")
args.add_argument("-verbose", required=False, action="store_const",
const=True, help="Show verbose output")
args.add_argument("process", metavar="<process name | process id>",
help="Process whose SSL calls to log")
parsed = parser.parse_args()

2.调用ssl_log方法,该方法是实现ssl流量记录的主方法

ssl_log(int(parsed.process) if parsed.process.isdigit() else parsed.process,
parsed.pcap, parsed.verbose)

ssl_log方法流程分析

1.frida附加到将要分析的进程上,并返回session对象

session =frida.attach(process)

2.如果需要记录流量,则首先将pcap文件的文件头写入到指定的pcap文件中

if pcap:
pcap_file = open(pcap, "wb", 0)
for writes in (
("=I", 0xa1b2c3d4),     # Magic number
("=H", 2),              # Major version number
("=H", 4),              # Minor version number
("=i", time.timezone),  # GMT to local correction
("=I", 0),              # Accuracy of timestamps
("=I", 65535),          # Max length of captured packets
("=I", 228)):           # Data link type (LINKTYPE_IPV4)
pcap_file.write(struct.pack(writes[0], writes[1]))

3.创建javascript脚本,并将目标进程的message消息和on_message方法进行关联,然后加载个该脚本。当进程有message消息发来的时候,就会回调on_message方法

script =session.create_script(_FRIDA_SCRIPT)script.on("message",on_message)script.load()
  • 这三句是firda的核心部分,frida通过回调的方式实现了对进程的hook
  • on_message方法实现了回调的主要内容,随后分析

4.等待用户输入,如果用户输入了ctlr+z,则取消对进程的附加;如果开启了pcap选项,则将pcap文件关闭(注意:如果没有正常关闭pcap文件,流量就不会被记录到文件中)

print("Press Ctrl+z to stop logging.")
try:
 sys.stdin.read()
except KeyboardInterrupt:
 print("KeyboardInterrupt")
 pass

session.detach()
if pcap:
 pcap_file.close()

_FRIDA_SCRIPT脚本分析

linux和windows平台上的差异主要体现在这个脚本上

1.initializeGlobals()

初始化全局变量,并创建NativeFunction

初始化全局变量

为了能够调用被attach程序中的Function,需要首先搜索到这些方法在内存中的地址,这里使用了Module.findExportByName方法

var funcs=[
"SSL_read", "SSL_write", "SSL_get_fd", "SSL_get_session",
"SSL_SESSION_get_id","getpeername", "getsockname", "ntohs", "ntohl"
]
funcs.forEach(function(f){
let address=Module.findExportByName(null,f)
addresses[f]=address
// console.log(f+" address:"+address)
})

需要调用的function如下:

SSL_read:从SSL连接中读取指定字节的内容到缓存区buff

SSL_write:从缓存区中读取指定字节的内容写入到SSL连接中

SSL_get_fd:获取指向SSL对象的文件描述符,用于后面获取IP和端口,具体参考官方文档:SSL_get_fd

SSL_get_session:返回在ssl对象中实际使用的SSL_SESSION对象,具体请参考官方文档

SSL_SESSION_get_id:返回指定session对象的内部

session id

值,具体请参考官方文档

getpeername:返回socket的对端地址

getsockname:返回指定socket描述符的当前名称

ntohs:将16位值由TCP/IP网络字节序转换为主机字节序

ntohl:将32位值由TCP/IP网络字节序转换为主机字节序

2.创建NativeFunction

SSL_get_fd = new NativeFunction(addresses["SSL_get_fd"], "int",
["pointer"]);
SSL_get_session = new NativeFunction(addresses["SSL_get_session"],
"pointer", ["pointer"]);
SSL_SESSION_get_id = new NativeFunction(addresses["SSL_SESSION_get_id"],
"pointer", ["pointer", "pointer"]);
getpeername = new NativeFunction(addresses["getpeername"], "int", ["int",
"pointer", "pointer"]);
getsockname = new NativeFunction(addresses["getsockname"], "int", ["int",
"pointer", "pointer"]);
ntohs = new NativeFunction(addresses["ntohs"], "uint16", ["uint16"]);
ntohl = new NativeFunction(addresses["ntohl"], "uint32", ["uint32"]);

NativeFunction:该类允许创建一个代码内部指定地址的实际调用,其参数详解如下:

address: represents the actual address of the function we want to call. This parameter must be passed as a NativePointer

returnType: represents the return value returned by the function we want to call

argTypes: represent the arguments of the function we want to call. The supported types are the following: void, pointer, int, uint, long, ulong, char, uchar, float, double, int8, uint8, int16, uint16, int32, uint32, int64, uint64 and bool.

具体可以参考:Calling native functions with Frida

2.拦截器拦截SSL_readSSL_write

拦截器拦截SSL_read

Interceptor.attach(addresses["SSL_read"],
{
onEnter: function (args)
{
var message = getPortsAndAddresses(SSL_get_fd(args[0]), true);
message["ssl_session_id"] = getSslSessionId(args[0]);
message["function"] = "SSL_read";
this.message = message;
this.buf = args[1];
},
onLeave: function (retval)
{
retval |= 0; // Cast retval to 32-bit integer.
if (retval <= 0)
{
return;
}
send(this.message, Memory.readByteArray(this.buf, retval));
}
});

Interceptor.attach(target,callbacks[, data])):通过拦截器的attach方法拦截SSL_read:

target:SSL_read的地址

callbacks:该回调对象包含一个或者多个onEnter和onLeave方法

onEnter:SSL_read函数即将被调用之前,会回调该方法

onLeave:SSL_read函数返回之前,会回调该方法

onEnter:获取ssl_session_id以及收到的对端响应内容buf

获取文件描述符:SSL_get_fd(args[0])

获取端口和IP地址:getPortsAndAddresses,返回message对象。该方法为自定义方法,稍后分析

获取SslSessionId:getSslSessionId(args[0]),该方法为自定义方法,稍后分析

指定message的function值为SSL_read

存储message以及buf,buf用于写入接收到的数据,:this.buf = args[1];

onLeave:

1.SSL_read函数执行完成返回前,已经成功将SSL中的数据读到了buff中,再通过send方法将buf中retval(SSL_read函数的返回值为读取到的字节数)个字节的内容发送给调试进程(frida-based application :这里就是ssl_logger这个python脚本)。在ssl_logger.py中on_message方法会接收到这里发出的消息

2.对返回值的处理:

将retval强制转换成32位整数:retval |= 0; // Cast retval to 32-bit integer.

如果retval小于0,表示数据读取失败,直接返回

Memory.readByteArray(address, length)方法:从地址address处,读取length长度的内容

拦截器拦截SSL_write

Interceptor.attach(addresses["SSL_write"],
{
onEnter: function (args)
{
var message = "Requests."
var message = getPortsAndAddresses(SSL_get_fd(args[0]), false);
message["ssl_session_id"] = getSslSessionId(args[0]);
message["function"] = "SSL_write";
send(message, Memory.readByteArray(args[1], parseInt(args[2])));
},
onLeave: function (retval){}
}

SSL_write函数执行之前,buf中的数据已经存在,所以只需要在进入该函数之后,就可以读取到buff中的内容,然后将其发送出去message当中包含的信息和拦截器对SSL_read的处理一致

3. 其他方法

getPortsAndAddresses方法

作用:通过调用getsockname/getpeername

获取端口和IP地址

代码解析:

function getPortsAndAddresses(sockfd, isRead)
{
//1
var message = {};
//2
var addrlen = Memory.alloc(4);
var addr = Memory.alloc(16);
//3
var src_dst = ["src", "dst"];
for (var i = 0; i < src_dst.length; i++)
{
Memory.writeU32(addrlen, 16);
if ((src_dst[i] == "src") ^ isRead)
{
getsockname(sockfd, addr, addrlen);
}
else
{
getpeername(sockfd, addr, addrlen);
}
message[src_dst[i] + "_port"] = ntohs(Memory.readU16(addr.add(2)));
message[src_dst[i] + "_addr"] = ntohl(Memory.readU32(addr.add(4)));
}
return message;
}

定义message:存储源地址/源端口以及目的地址/目的端口

var message ={};

1.由于getsockname/getpeername(SOCKET s,sockaddr *name,int *namelen)的参数sockaddr *name的类型为结构体sockaddr,其长度为16个字节,所以需要在堆上申请相应大小的空间:

var addr =Memory.alloc(16);

2.参数namelen的类型为int,同理,需要申请4个字节大小的空间:

var addrlen =Memory.alloc(4);

3.接着就需要调用getsocknamegetpeername得到src_addr/src_portdst_addrdst_port,这里需要注意的有几点:

传入的参数中,addr是需要返回的sockaddr结构,其中包含IP和端口,只有addrlen需要我们写入值,也就是sockaddr结构的长度:16个字节。所以这里调用Memory.writeU32写入16。调用writeU32的的原因是,该参数类型为int。

sockaddr_in结构的定义如下:

struct sockaddr_in {
short   sin_family;
u_short sin_port;
struct  in_addr sin_addr;
char    sin_zero[8];
};

1.sin_family类型为short,长度为16;sin_port类型为u_short,长度为16;结构体in_addr的大小为32字节。所以,读取端口的时候,需要将指针addr+2,然后使用Memory.readU16读取:Memory.readU16(addr.add(2))。因为sin_port为网络字节序,所以需要调用ntohs转换为主机字节序。

2.同理,读取IP时,需要将指针addr+4,然后使用Memory.readU32读取:Memory.readU32(addr.add(4))

getSslSessionId方法

作用:获取SSL的sessionId

代码分析:

function getSslSessionId(ssl)
{
var session = SSL_get_session(ssl);
if (session == 0)
{
return 0;
}
var len = Memory.alloc(4);
var p = SSL_SESSION_get_id(session, len);
len = Memory.readU32(len);
var session_id = "";
for (var i = 0; i < len; i++)
{
// Read a byte, convert it to a hex string (0xAB ==> "AB"), and append
// it to session_id.
session_id +=
("0" + Memory.readU8(p.add(i)).toString(16).toUpperCase()).substr(-2);
}
return session_id;
}

1.首先通过调用本地SSL_get_session方法,获取到SSL_SESSION对象session

var session =SSL_get_session(ssl);

2.为了能够调用SSL_SESSION_get_id方法,首先需要分配32字节(参数len的类型为int)的空间;然后调用该方法,得到返回值p(指向session_id的指针);然后读取len指针指向的内容(SSL_SESSION_get_id方法会将id的长度写入len中)

var len = Memory.alloc(4);
var p = SSL_SESSION_get_id(session, len);
len = Memory.readU32(len);

3.为了能够将session_id成功的读取出来,需要逐字节进行转换,然后进行拼接。对每个字节需要进行的处理:首先在指定位置读取8位比特Memory.readU8(p.add(i)),转换为16进制toString(16),然后转为为大写toUpperCase(),在开头添加”0″+之后,截取最后两位:substr(-2)

log_pcap方法流程分析

1.该方法接收8个参数,分别为:

pcap_file: 打开的pcap文件

ssl_session_id: SSL会话的ID

function: 被拦截的方法(“SSL_read” 或者 “SSL_write”)

src_addr: 记录数据包的源地址

src_port: 记录数据包的源端口

dst_addr: 记录数据包的目的地址

dst_port: 记录数据包的目的端口

data: 解密包数据

2.生成时间戳:time.time(),这里的时间写入pacap文件的时间,不知道wireshark是怎么生成这个值的。这个值将会写入pacap文件的时间戳字段,其C结构体定义可以参考:网络编程-pcap数据包格式

3.接着随机生成seq和ack,并存储到ssl_sessions字典中(该变量为全局变量,在程序启动时已经初始化),依据function是SSL_read还是SSL_write来对seq和ack赋值。这里的疑惑点是:为什么没有直接将原始数据读取出来保存,而是需要自己随机生成?当然,我们的关注点是解密后的SSL数据,所以,这里并不是关注的重点。有关seq和ack的解释可以参考:TCP Sequence and Acknowledgement Numbers Explained为什么三次握手的时候ack=seq+1

if ssl_session_id not in ssl_sessions:
     ssl_sessions[ssl_session_id] = (random.randint(0, 0xFFFFFFFF),
                                     random.randint(0, 0xFFFFFFFF))
   client_sent, server_sent = ssl_sessions[ssl_session_id]

   if function == "SSL_read":
    #这里的 client_sent+1是依据谋篇修改后的ssl_logger改的,原始代码为:client_sent。修改是否正确,值的商榷
     seq, ack = (server_sent, client_sent+1)
   else:
     seq, ack = (client_sent, server_sent)

4.接着,将pcap的流量包头写入pcap文件中:

其中涉及到了PCAP流量包头的格式,IPv4数据包头的格式,TCP数据包头的格式,具体赋值含义由于和SSL解密流量关系不大,这里先不进行分析

python的struct模块用于将字节解析成打包的二进制数据,具体可以参考:Interpret bytes as packed binary data

for writes in (
# PCAP record (packet) header
("=I", int(t)),                   # Timestamp seconds
("=I", int((t * 1000000) % 1000000)),  # Timestamp microseconds
("=I", 40 + len(data)),           # Number of octets saved
("=i", 40 + len(data)),           # Actual length of packet
# IPv4 header
(">B", 0x45),                     # Version and Header Length
(">B", 0),                        # Type of Service
(">H", 40 + len(data)),           # Total Length
(">H", 0),                        # Identification
(">H", 0x4000),                   # Flags and Fragment Offset
(">B", 0xFF),                     # Time to Live
(">B", 6),                        # Protocol
(">H", 0),                        # Header Checksum
(">I", src_addr),                 # Source Address
(">I", dst_addr),                 # Destination Address
# TCP header
(">H", src_port),                 # Source Port
(">H", dst_port),                 # Destination Port
(">I", seq),                      # Sequence Number
(">I", ack),                      # Acknowledgment Number
(">H", 0x5018),                   # Header Length and Flags
(">H", 0xFFFF),                   # Window Size
(">H", 0),                        # Checksum
(">H", 0)):                       # Urgent Pointer
pcap_file.write(struct.pack(writes[0], writes[1]))

5.将解密数据写入pcap文件:pcap_file.write(data)

6.为了使得seq和ack保持正确,需要将这两个值更新:

if function == "SSL_read":
server_sent += len(data)
else:
client_sent += len(data)
ssl_sessions[ssl_session_id] = (client_sent, server_sent)

on_message方法流程分析

on_message方法接收两个参数:

message:包含“type”和其他属性的字典,具体包含哪些属性,和message本身的类型有关

data:捕获到的加密数据字符串

2.异常判断:

首先判断是不是受到了error信息,如果收到了错误信息,就把错误信息输出,然后关闭掉当前进程(应该指的是ssl_logger的进程),然后返回

if message["type"] == "error":
pprint.pprint(message)
os.kill(os.getpid(), signal.SIGTERM)
return

如果返回的数据长度为0,同样返回到main

if len(data) == 0:
return

3.读取payload信息,并输出其详细内容

p = message["payload"]
if verbose:
src_addr = socket.inet_ntop(socket.AF_INET,
struct.pack(">I", p["src_addr"]))
dst_addr = socket.inet_ntop(socket.AF_INET,
struct.pack(">I", p["dst_addr"]))
print("SSL Session: " + p["ssl_session_id"])
print ("[%s] %s:%d --> %s:%d" % (
p["function"],
src_addr,
p["src_port"],
dst_addr,
p["dst_port"]))
hexdump.hexdump(data)
print()

payload中包含了多个属性值

src_addr:源地址

dst_addr:目的地址

ssl_session_id:ssl的session_id

function:javascript中hook掉的函数

src_port:原端口

dst_port:目的端口

由于在payload中src_addr和dst_addr的格式为类字节形式,所以需要调用inet_ntop将格式转换为正常的IP格式,类似:’7.10.0.5’

官方文档:

socket.inet_ntop(address_family, packed_ip)¶

Convert a packed IP address (a bytes-like objectof some number of bytes) to its standard, family-specific string representation (for example, '7.10.0.5'or '5aef:2b::8'). inet_ntop()is useful when a library or network protocol returns an object of type struct in_addr(similar to inet_ntoa()) or struct in6_addr.

使用hexdump的hexdump方法显示数据

如果有pcap选项,则将内容输出到pcap文件

if pcap:
src_addr = socket.inet_ntop(socket.AF_INET,
struct.pack(">I", p["src_addr"]))
dst_addr = socket.inet_ntop(socket.AF_INET,
struct.pack(">I", p["dst_addr"]))
log_pcap(pcap_file, p["ssl_session_id"], p["function"],  p["src_addr"],
p["src_port"], p["dst_addr"], p["dst_port"], data)

这里同样调用了inet_ntop方法转换IP格式,但是后面并没有调用,这里需要进一步确认原因 (这里属于多余代码,删除即可)

通过调用log_pcap方法来记录流量文件,稍后分析该方法

进一步思考

存在的不足/bug

读取到的IP和端口都是0,原因未知,需要进一步分析

更多的feature

现在只是将ssl解密流量记录下来,如果要更方便的进行渗透测试,最好是可以将流量重定向到类似burpsuite这样的工具中,提高测试效率

附录

本文共享链接:基于frida的windows厚客户端ssl流量解析研究

来源:freebuf.com 2021-03-11 16:40:01 by: he0xwhale

© 版权声明
THE END
喜欢就支持一下吧
点赞0
分享
评论 抢沙发

请登录后发表评论