查看原文
其他

你们要的 [Yaklang websocket劫持]教程来了!

WaY Yak Project 2023-04-27


背景

随着Web应用的发展与动态网页的普及,越来越多的场景需要数据动态刷新功能。在早期时,我们通常使用轮询的方式(即客户端每隔一段时间询问一次服务器)来实现,但是这种实现方式缺点很明显: 大量请求实际上是无效的,这导致了大量带宽的浪费。这时候我们急需一个新的技术来解决这一痛点,Websocket应运而生: WebSocket是一种网络传输协议,可在单个TCP连接上进行全双工通信,位于OSI模型的应用层。

Websocket的诞生也给我们带来了新的挑战,我们能否对websocket的请求与响应进行劫持与修改呢?要想做到这一点,我们首先得了解websocket协议。


websocket劫持协议细节

等等,看到这个标题的时候先别急着划走,实际上websocket协议比我们想象中的要简单,他实际上几乎等同于原始的TCP socket,只不过多出了额外的协议头以及一个升级的过程。

我们先来看websocket的升级过程,先是客户端发起协议升级请求,其采用标准的HTTP报文格式,且必须使用GET请求方法:

GET / HTTP/1.1Host: localhost:8080Origin: http://127.0.0.1:3000Connection: UpgradeUpgrade: websocketSec-WebSocket-Version: 13Sec-WebSocket-Key: w4v7O6xFTi36lq3RNcgctw==

这里我们需要关注的最后四行的特殊请求头:

  • Connection: Upgrade:表示要升级协议

  • Upgrade: websocket:表示要升级到websocket协议

  • Sec-WebSocket-Version: 13:表示websocket的版本。如果服务端不支持该版本,需要返回一个Sec-WebSocket-Versionheader,里面包含服务端支持的版本号

  • Sec-WebSocket-Key:与后面服务端响应头Sec-WebSocket-Accept配套,提供基本的校验。其本身是一个bas64编码过的随机16字节


服务器返回101状态码的响应,至此完成协议升级:

HTTP/1.1 101 Switching ProtocolsConnection:UpgradeUpgrade: websocketSec-WebSocket-Accept: Oy4NRAQ13jhfONC7bP8dTKb4PTU=

这里我们需要的关注的是最后的Sec-WebSocket-Accept请求头,其与前文的Sec-WebSocket-Key对应,主要有以下两个目的:

  • 确保服务器理解 WebSocket 协议

  • 防止客户端意外请求 WebSocket 升级


    Sec-WebSocket-Accept请求头是由Sec-WebSocket-Key计算而成的,其伪代码如下:

    toBase64(sha1(Sec-WebSocket-Key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"))

    协议升级后,双方开始使用websocket协议进行通讯。我们来看看websocket的协议细节,一个经典的概览图如下:

    0 1 2 3 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 +-+-+-+-+-------+-+-------------+-------------------------------+ |F|R|R|R| opcode|M| Payload len | Extended payload length | |I|S|S|S| (4) |A| (7) | (16/64) | |N|V|V|V| |S| | (if payload len==126/127) | | |1|2|3| |K| | | +-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - + | Extended payload length continued, if payload len == 127 | + - - - - - - - - - - - - - - - +-------------------------------+ | |Masking-key, if MASK set to 1 | +-------------------------------+-------------------------------+ | Masking-key (continued) | Payload Data | +-------------------------------- - - - - - - - - - - - - - - - + : Payload Data continued ... : + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + | Payload Data continued ... | +---------------------------------------------------------------+

    如果看不懂无所谓,我们逐个字段进行讲解:


    FIN:1 bit

    如果是1,表示这是消息的最后一个分片,如果是0,表示不是消息的最后一个分片。通常为1

    RSV1, RSV2, RSV3:各占1 bit

    一般情况下全为0。当客户端、服务端协商采用WebSocket扩展时,这三个标志位可以非0,且值的含义由扩展进行定义。如果出现非零的值,且并没有采用WebSocket扩展,连接出错。

    Opcode: 4 bit

    操作代码,Opcode的值决定了应该如何解析后续的数据,可以简单地理解为消息类型,一般通讯时为%x1或%x2。可选值如下:

    • %x0:表示一个延续帧。当Opcode为0时,表示本次数据传输采用了数据分片,当前收到的数据帧为其中一个数据分片

    • %x1:表示这是一个文本帧(frame)

    • %x2:表示这是一个二进制帧(frame)

    • %x3-7:保留的操作代码,用于后续定义的非控制帧

    • %x8:表示连接断开

    • %x9:表示这是一个ping操作

    • %xA:表示这是一个pong操作

    • %xB-F:保留的操作代码,用于后续定义的控制帧

    Mask: 1 bit

    表示是否要对数据进行掩码操作。客户端向服务端发送数据时该bit为1,否则为0。掩码算法在后续Masking key提到。

    Payload length: 数据的长度,单位是字节。其可能为7/7+16/1+64 bit。

    假设数据长度 = x,如果

    • 0<=x<=125:用这7个bit来代表数据长度。

    • 126<=x<=65535:7个bit设置为126(1111110)。后续2个字节代表一个16位的无符号整数,该无符号整数的值为数据的长度(大端序)。

    • 65535<x:7个bit设置为127(1111111)。后续8个字节代表一个64位的无符号整数,该无符号整数的值为数据的长度(大端序)。

    Masking-key:0/32 bit

    假如前文所述Mask为1,则此Masking-key占32 bit(即四个字节),否则为0 bit。Masking-key用于将客户端传输给服务器的数据进行掩码操作。前文的Payload length,不包括Masking-key的长度。

    具体的掩码算法伪代码如下:

    设原数据为bytes,Masking-key为key,则:

    for i in range(len(bytes)):

    bytes[i] ^= key[i&3]

    Payload data:(x+y) byte

    载荷数据包括了扩展数据、应用数据。其中,扩展数据x字节,应用数据y字节。

    在前文的升级阶段没有协商使用扩展的话,扩展数据数据为0字节。剩下的应用数据就是传输的原始socket内容,因此也一般会结合其他压缩算法/协议使用,如protobuf。







    websocket劫持实现

    在了解了websocket协议之后,我们实现websocket劫持就变得很简单了,用一张流程图来展示:

    其中重点主要是原始数据与websocket帧之间的转换。

    01

    解析原始数据

    前面说过,websocket协议实际上几乎只是比原始socket多了一个头,那么我们解析原始数据可以分为以下几步:

    1. 设初始n=2,即抛弃前两个websocket头字节

    2. 判断第2个byte的后7个bit(payload length),如果为126,则n+2,如果为127,则n+8

    3. 判断第2个byte的第1个bit(mask位)是否为1,如果为1,则从n~n+4位取出masking-key,并将n+4,将n位后的数据进行掩码处理

    4. 返回n位后的数据,即为原始数据


    02

    重新封装成websocket帧

    可以分为以下几步:

    1. 第1个byte照抄(也可以根据需要修改后4位bit及opcode,修改消息类型)

    1. 第2个byte第1个bit(mask位)照抄,后7位bit根据修改后的数据长度进行处理

    1. 如果数据长度大于125,则要写入uint16或uint64的数据长度字节(大端序)

    1. 如果mask位为1,则生成并写入32位的随机masking-key,再将数据进行掩码处理与写入,此时即封装好了的websocket帧


    websocket劫持实现时遇到的坑点

    这里讲下在websocket劫持实现时遇到的坑点,仅供参考。

    01

    保持协议的完整性

    实际上前文提到的劫持所使用的技术都是中间人技术,这里我遇到的坑点就是没保持协议的完整性,我在处理时从服务器端接收到了101状态码的响应,但却没有将其写入回客户端,导致客户端断开,整个websocket的升级也就失败了,所以需要提醒的就是在劫持时要保持协议的完整性,该发送或接收到的内容都要到位。

    02

    实现FrameReader而非简单的Read


    我之前的一个错误实例如下:



    这里实际上犯了几个错误:

    1. reader.Read()是非阻塞的,也就是说如果缓冲中没有数据的话,它会不断地返回0和EOF,但是我这里判断如果n<=0则会不断continue,这会导致不断创建新的4096字节的bytes,无法释放

    1. 后续我将b作为websocket帧来处理,但是b的大小只有4096,假如数据量超大,这样写毫无疑问是错误的

    后来其他师傅发现了这个bug并指出这几点错误,我才意识到我应该抽象出一个FrameReader来去读取websocket帧,根据读取到的前几个字节来判断最终要读取的长度。



    新版Yak的websocket尝鲜


    01

    websocket劫持尝鲜


    经过一番努力之后,终于实现了websocket劫持功能,在Yak的mitm标准库中新增了wscallback与wsforcetext两个函数,我们来看一个简单的用例:


    go fn{ mitm.Start(8084, mitm.wsforcetext(true),mitm.wscallback( fn(data, isRequest){ if isRequest { data = "Hijack request" } else { data = "Hijack Response" } return data }))}
    for { time.sleep(1)}


    wscallback参数接受一个函数作为参数,该函数拥有2个参数: data([]byte类型)和isRequest(bool类型)并接收一个返回值(必须存在返回值),作为修改后的数据。


    isRequest参数用于判断劫持到的是否为websocket请求(true即websocket请求,false为websocket响应),data参数则为劫持到的原始数据。


    接下来我们使用go来启动一个websocket的测试服务器,这里需要安装依赖:"github.com/gorilla/websocket":


    package main
    import ( "fmt" "net/http" "os" "time"
    "github.com/gorilla/websocket")
    func main() { var upgrader = websocket.Upgrader{}
    f, err := os.CreateTemp("", "test-*.html") if err != nil { panic(err) } f.Write([]byte(`<!DOCTYPE html><html><head> <meta charset="UTF-8"/> <title>Sample of websocket with golang</title> <script src="http://apps.bdimg.com/libs/jquery/2.1.4/jquery.min.js"></script> <script> $(function() { var ws = new WebSocket('ws://' + window.location.host + '/ws'); ws.onmessage = function(e) { $('<li>').text(event.data).appendTo($ul); ws.send('{"message":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}'); }; var $ul = $('#msg-list'); }); </script></head><body><ul id="msg-list"></ul></body></html>`)) index := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.ServeFile(w, r, f.Name()) }) http.Handle("/", index) http.Handle("/index.html", index) http.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) { // msg := &RecvMessage{}
    ws, err := upgrader.Upgrade(w, r, nil) if err != nil { panic(err) return } defer ws.Close()
    go func() { for { _, msg, err := ws.ReadMessage() if err != nil { panic(err) return } fmt.Printf("server recv from client: %s\n", msg) } }()
    for { time.Sleep(time.Second) ws.WriteJSON(map[string]interface{}{ "message": fmt.Sprintf("Golang Websocket Message: %v", time.Now()), }) } })
    err = http.ListenAndServe(":8884", nil) if err != nil { panic(err) }}

    现在,我们访问http://127.0.0.1:8884,会发现屏幕会每秒输出一条json内容,例如:

    {"message":"Golang Websocket Message: 2022-09-05 15:17:22.497926 +0800 CST m=+7.689153001"}

    同时,在终端中会每秒输出一条以下内容:

    server recv from client: {"message":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}

    这时候我们挂上代理http://127.0.0.1:8084/,重启websocket服务器进行访问,然后会发现上述的内容都会发生改变,屏幕输出的内容变为:

    Hijack Response

    同时,终端输出的内容变为:

    server recv from client: Hijack request
    这说明我们成功对websocket的请求与响应进行了劫持!

    02

    直接发起websocket请求

    还是使用上述的websocket的测试服务器作为服务端,启动。

    Yak中编写如下代码,运行:

    rsp, req, err = poc.Websocket(`GET /ws HTTP/1.1Host: 127.0.0.1:8884Accept-Encoding: gzip, deflateAccept-Language: zh-CN,zh;q=0.9Connection: UpgradeSec-WebSocket-Key: LIb4U+i+y+phoP4B2y6uoA==Sec-WebSocket-Version: 13Upgrade: websocket
    `, poc.websocketFromServer(func(data, cancel){ dump(data)}), poc.websocketOnClient(func(wsClient) { go fn { for { wsClient.WriteText(`{"message": "hello"}`) time.Sleep(1) } }}))die(err)

    解释一下上述代码,poc.Websocket指定了这个请求需要去对websocket请求进行收发处理,其实际上是 poc.Http(`...`,poc.websocket(true)) 的简写。第一个参数是我们熟悉的websocket升级请求,后面跟着的是可选参数函数:

    1. poc.websocketFromServer,这个函数接受一个函数作为参数,其中data为从服务端接收到的数据,cancel是一个无参数函数,用于直接中断websocket连接。

    1. poc.websocketOnClient,这个函数接受一个函数作为参数,其中 wsClient是一个结构体,可以直接使用其的一些方法,如:

      1. c.Stop(),结束websocket连接

      2. c.Write([]byte),往websocket写入内容

      3. c.WriteText([]byte),同 c.Write([]byte)

      4. ...

    通过程序输出可以看到我们正常建立了websocket连接并完成了收发。


    新版Yakit的websocket劫持尝鲜

    Yak版本 1.1.2

    Yakit版本 1.1.2


    01

    websocket劫持

    正常启动Yakit的MITM,然后也启动上文提到的websocket服务器:


    挂载代理访问http://127.0.0.1:8884/,出现websocket升级的请求,手动放行:


    等待websocket协议升级完成后,我们成功劫持到了websocket的请求,按下劫持响应并修改请求内容,最后按下提交数据:


    可以看到服务器已经接收到修改过后的请求:


    同时我们拦截到了服务器的响应,修改响应内容然后按下提交数据:


    发现浏览器中显示我们修改过后的响应:


    02

    websocket fuzzer

    在MITM中的HTTP History找到websocket的升级响应,按下FUZZ按钮:


    跳转到websocket fuzzer页面,我们尝试建立连接:


    建立websocket连接完成后可以在右侧看到实时的服务器请求与响应:


    我们尝试在下方发送数据框发送websocket请求:


    可以看到成功发送websocket请求:




    往期内容推荐

    技术解析|朋友圈为何突然出现了那么多只“羊”


    只需几步,轻松实现即时分享,探索渗透协作新模式!


    安全研发启蒙课:低成本实现的被动扫描工具


    Web Fuzzer 高级进阶:支持前端 AES-ECB 加密Web 安全测试实战


    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存