[漏洞分析] CVE-2023-38545 curl"史上最严重的漏洞"分析
文章目录
漏洞简介
漏洞编号: CVE-2023-38545
漏洞产品: (curl)[https://github.com/curl/curl]
影响范围: libcurl 7.69.0 - 8.3.0
利用条件: 需要在使用socks5代理的情况下,并且可以指定curl的url参数,约等于需要完全控制curl的参数
利用效果: 程序崩溃,拒绝服务
漏洞复现
环境搭建
参考:https://gist.github.com/xen0bit/0dccb11605abbeb6021963e2b1a811d3
可以不用这个docker,直接下载对应版本的curl编译,并开启调试符号:
wget https://github.com/curl/curl/releases/download/curl-7_74_0/curl-7.74.0.tar.gz
tar -vxf curl-7.74.0
cd curl-7.74.0.tar.gz
./configure --with-openssl --enable-debug
make
这里我不想make install
,可以直接使用编译好的curl进行测试:
-
编译好的curl在
curl-7.74.0/src/.libs/curl
-
libcurl在
curl-7.74.0/lib/.libs/libcurl.so
漏洞复现
在没有make install的情况下,需要设置环境变量LD_LIBRARY_PATH
让curl使用刚编译的libcurl
export LD_LIBRARY_PATH=/root/cve-2023-38545/curl-7.74.0/lib/.libs/
然后下载这个建议python socks5代理工具:https://github.com/MisterDaneel/pysoxy
直接运行即可:
python3 pysoxy.py
然后执行
./src/.libs/curl -vvv -x socks5h://localhost:9050 $(python3 -c "print(('A'*10000), end='')")
漏洞原理
补丁分析
根据不定信息,我们可以发现,漏洞的重点在于hostname_len,当hostname_len过长的时候,会导致memcpy 越界写堆溢出。
除此之外,在补丁的上半部分,判断hostname_len是否大于255:
- 漏洞版本中,大于255并不会返回错误,而是将socks5_resolve_local 设置为true,根据名字我们可以看出该变量代表是否进行本地处理(在这里意味着本地解析域名)
- 修复版本中,如果hostname_len大于255则会直接返回错误代码
CURLPX_LONG_HOSTNAME
调用栈
调用栈如下,但并不是一次调用就结束的,在easy_transfer
函数中,如果没有成功读取到消息,则会一直重复尝试。
代码分析
由于整个curl过程状态机还是非常复杂的,所以我们重点放在漏洞相关的SOCKS相关函数中
设置代理状态
在parse_proxy
函数中设置了代理状态:
由于我们的运行参数./src/.libs/curl -vvv -x socks5h://localhost:9050 $(python3 -c "print(('A'*10000), end='')")
使用的是socks5h
,所以这里设置的状态是CURLPROXY_SOCKS5_HOSTNAME
很重要,后面会遇到。
第一次执行Curl_SOCKS5
接下来直接看漏洞所在函数Curl_SOCKS5:
curl-7.74.0\lib\socks.c : 484
CURLproxycode Curl_SOCKS5(const char *proxy_user,
const char *proxy_password,
const char *hostname,
int remote_port,
int sockindex,
struct connectdata *conn,
bool *done)
{
··· ···
//[1]这里我们的状态是CURLPROXY_SOCKS5_HOSTNAME 而不是CURLPROXY_SOCKS5,所以是false
bool socks5_resolve_local =
(conn->socks_proxy.proxytype == CURLPROXY_SOCKS5) ? TRUE : FALSE;
const size_t hostname_len = strlen(hostname);
··· ···
if(!SOCKS_STATE(sx->state) && !*done)
sxstate(conn, CONNECT_SOCKS_INIT); //[2]设置初始状态
switch(sx->state) {
case CONNECT_SOCKS_INIT: //[2]初始化逻辑
··· ···
//[3]关键逻辑hostname_len 是10000 大于255
if(!socks5_resolve_local && hostname_len > 255) {
infof(conn->data, "SOCKS5: server resolving disabled for hostnames of "
"length > 255 [actual len=%zu]\n", hostname_len);
socks5_resolve_local = TRUE; //[3]处理方式不是返回错误,而是将 socks5_resolve_local 设置为true
}
··· ···
sxstate(conn, CONNECT_SOCKS_READ);//[4]更新状态,按顺序更新状态机
goto CONNECT_SOCKS_READ_INIT;
CONNECT_SOCKS_READ_INIT:
case CONNECT_SOCKS_READ_INIT:
··· ···
case CONNECT_SOCKS_READ:
··· ···
if(result && (CURLE_AGAIN != result)) {
···
}
··· ···
else if(actualread != sx->outstanding) {
/* remain in reading state */
sx->outstanding -= actualread;
sx->outp += actualread;
return CURLPX_OK; //[5]暂时返回ok
}
··· ···
}
[1] 这里判断proxy的类型,由于我们使用的参数是socksh
所以我们的proxy类型为CURLPROXY_SOCKS5_HOSTNAME
,所以这里socks5_resolve_local
的结果是false。
[2] 第一次执行这个函数的时候会设置初始状态机CONNECT_SOCKS_INIT
,并在下面状态机处理逻辑中进入初始化逻辑
[3] 在初始化中会经历补丁的第一个点,判断hostname_len
长度,我们执行的命令中,A*10000
就是作为hostname
存在。这里由于逻辑有误,导致hostname
即便超过255也不会返回错误,而是将socks5_resolve_local
改成true继续执行。
[4] 按照逻辑一步一步更新状态机CONNECT_SOCKS_READ
,并根据状态机继续执行
[5] 返回OK,但整个请求并没有完成,当前SXSTATE状态机是CONNECT_SOCKS_READ
第二次执行Curl_SOCKS5
由于我们请求的域名肯定是不存在的,所以curl是没有完成任务的,所以在easy_transfer
中还会继续重复刚刚的逻辑,也就是会第二次执行到Curl_SOCKS5
函数:
curl-7.74.0\lib\socks.c : 484
CURLproxycode Curl_SOCKS5(const char *proxy_user,
const char *proxy_password,
const char *hostname,
int remote_port,
int sockindex,
struct connectdata *conn,
bool *done)
{
//[1]缓冲区整个长只有600
unsigned char *socksreq = &conn->cnnct.socksreq[0];
//[2]这里我们的状态是CURLPROXY_SOCKS5_HOSTNAME 而不是CURLPROXY_SOCKS5,所以是false
bool socks5_resolve_local =
(conn->socks_proxy.proxytype == CURLPROXY_SOCKS5) ? TRUE : FALSE;
const size_t hostname_len = strlen(hostname);
··· ···
switch(sx->state) {
case CONNECT_SOCKS_READ: //[3]当前状态机是CONNECT_SOCKS_READ
··· ···
if(result && (CURLE_AGAIN != result)) {
··· ···
}
··· ···
else if(socksreq[1] == 0) {
/* DONE! No authentication needed. Send request. */
sxstate(conn, CONNECT_REQ_INIT); //[3]在初始化的时候设置为1,第二次执会走到这里
goto CONNECT_REQ_INIT;
}
··· ···
CONNECT_REQ_INIT:
case CONNECT_REQ_INIT:
//[4]由于上面给socks5_resolve_local设置为了false,所以跳过这里
if(socks5_resolve_local) {
··· ···
}
goto CONNECT_RESOLVE_REMOTE;// 跳转到CONNECT_RESOLVE_REMOTE
··· ···
CONNECT_RESOLVE_REMOTE:
case CONNECT_RESOLVE_REMOTE:
/* Authentication is complete, now specify destination to the proxy */
len = 0;
socksreq[len++] = 5; /* version (SOCKS5) */
socksreq[len++] = 1; /* connect */
socksreq[len++] = 0; /* must be zero */
if(!socks5_resolve_local) {
socksreq[len++] = 3; /* ATYP: domain name = 3 */
socksreq[len++] = (char) hostname_len; /* one byte address length */
memcpy(&socksreq[len], hostname, hostname_len); /* address w/o NULL */ //[5]溢出
len += hostname_len;
infof(data, "SOCKS5 connect to %s:%d (remotely resolved)\n",
hostname, remote_port);
}
··· ···
}
[1] 溢出缓冲区的来源,socksreq
只有600的长度,并且这个缓冲区在使用的时候,单个子缓冲区只有255最大值(因为是用char表示长度)
#define SOCKS_REQUEST_BUFSIZE 600 /* room for large user/pw (255 max each) */
struct connstate {
enum connect_t state;
unsigned char socksreq[SOCKS_REQUEST_BUFSIZE];
··· ···
};
struct connectdata {
struct Curl_easy *data;
struct connstate cnnct;
··· ···
}
[2] 跟第一次一样,socks5_resolve_local
被初始化为false
[3] 上一次返回后状态机是CONNECT_SOCKS_READ
,这一次从CONNECT_SOCKS_READ
开始处理,跳过了初始化阶段的hostname
长度判断,并将状态机更新为CONNECT_REQ_INIT
[4] 在CONNECT_REQ_INIT
状态机处理中,由于socks5_resolve_local
是false,则跳过了这一步直接到CONNECT_RESOLVE_REMOTE
[5] 直接向最大长度600的缓冲区拷贝了10000个A,造成堆溢出,程序崩溃。
修复后
补丁更新后,在第一次CONNECT_SOCKS_INIT
状态机处理的时候,判断hostname长度大于255就会直接返回错误,程序就会异常退出。
总结
关于为什么要这么设计,可以参考开发人员的这篇博客,里面写了新路历程。curl的状态机挺复杂的,以复现这个漏洞为目的的话去研究状态机实数多余。这里仅关注漏洞触发的相关逻辑。
目前来看利用比较难,因为只是一次性的,不能持续交互,该结构体后面也没有什么函数指针。当前能达到的最佳效果就是拒绝服务,并且利用条件还比较苛刻,需要在使用SOCKS5的场景下可以指定curl的hostname,其实约等于需要攻击者完全控制curl的参数,所以不必太过惊慌。
参考
https://mp.weixin.qq.com/s/V0DoskAs05S1XhEFjgMkpw
https://gist.github.com/xen0bit/0dccb11605abbeb6021963e2b1a811d3
https://github.com/curl/curl/commit/fb4415d8aee6c1045be932a34fe6107c2f5ed147
https://daniel.haxx.se/blog/2023/10/11/how-i-made-a-heap-overflow-in-curl/