标题
[bug] use-after-free: hloop_free() on a loop with an in-flight SOCKS5 server DNS query
正文
摘要 / TL;DR:hloop_cleanup() 先 hdns_resolver_free() 释放全部在途 hdns_t,之后才遍历 loop->ios 释放 hio_t;而 SOCKS5 服务端连接的 socks5_server_ctx_free() 会在这趟遍历里对已被释放的 conn->dns 调 hdns_cancel() → use-after-free(进程在 hloop_free() 内崩溃)。
环境
复现
只用 hloop_create_socks5_proxy_server() + 一个裸 TCP 客户端。要点是让服务端的 DNS 查询停在在途状态:用黑洞地址 192.0.2.1(RFC 5737 TEST-NET-1)作 nameserver,查询永远不会回来,然后在 600ms 后直接 hloop_free()。
// unittest/uaf_socks5_hdns.c 构建: cmake -DBUILD_UNITTEST=ON .. && 构建后运行
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <process.h>
#include "hloop.h"
#include "hsocket.h"
static void stop_cb(htimer_t* t) { hloop_stop(hevent_loop(t)); }
typedef struct { int port; int use_domain; } client_arg_t;
// 裸 SOCKS5 客户端:握手到 CONNECT 为止,之后挂住不回
static void raw_client(void* p) {
client_arg_t* arg = (client_arg_t*)p;
SOCKET c = socket(AF_INET, SOCK_STREAM, 0);
sockaddr_in a; memset(&a, 0, sizeof(a));
a.sin_family = AF_INET; a.sin_addr.s_addr = htonl(INADDR_LOOPBACK); a.sin_port = htons((u_short)arg->port);
if (connect(c, (sockaddr*)&a, sizeof(a)) != 0) return;
unsigned char greet[] = {0x05, 0x01, 0x00}; // VER=5 NMETHODS=1 NOAUTH
send(c, (char*)greet, sizeof(greet), 0);
unsigned char sel[2];
recv(c, (char*)sel, 2, 0);
unsigned char req[4 + 1 + 255 + 2]; int n = 0;
req[n++] = 0x05; req[n++] = 0x01; req[n++] = 0x00; // CONNECT
if (arg->use_domain) {
req[n++] = 0x03; // ATYP=DOMAIN -> 服务端 hdns_resolve
const char* host = "never-resolves.example"; int hlen = (int)strlen(host);
req[n++] = (unsigned char)hlen; memcpy(req + n, host, hlen); n += hlen;
} else {
req[n++] = 0x01; // ATYP=IPV4 -> 服务端不查 DNS
req[n++] = 127; req[n++] = 0; req[n++] = 0; req[n++] = 1;
}
req[n++] = 0x00; req[n++] = 0x50; // port 80
send(c, (char*)req, n, 0); // 注意:port 必须在 send 之前补齐
Sleep(3000); closesocket(c);
free(arg);
}
// use_domain=0: 目标用 IPv4 字面量(服务端不查 DNS,对照组)
// use_domain=1: 目标用域名,hdns 查询在飞时释放 loop(复现组)
static void run_case(const char* title, int use_domain) {
printf("---- %s\n", title);
hloop_t* loop = hloop_new(0);
proxy_setting_t s; memset(&s, 0, sizeof(s));
s.protocol = PROXY_PROTOCOL_SOCKS5;
strncpy(s.proxy_host, "127.0.0.1", sizeof(s.proxy_host) - 1);
hio_t* srv = hloop_create_socks5_proxy_server(loop, &s);
if (srv == NULL) { printf(" server create failed\n"); hloop_free(&loop); return; }
client_arg_t* arg = (client_arg_t*)malloc(sizeof(client_arg_t));
arg->port = (int)sockaddr_port((sockaddr_u*)hio_localaddr(srv));
arg->use_domain = use_domain;
_beginthread(raw_client, 0, arg);
htimer_add(loop, stop_cb, 600, 1);
hloop_run(loop);
printf("---- hloop_free() 开始\n");
hloop_free(&loop);
printf("---- hloop_free() 正常返回\n");
}
int main() {
setvbuf(stdout, NULL, _IONBF, 0);
WSADATA wsa; WSAStartup(MAKEWORD(2, 2), &wsa);
_putenv((char*)"HV_DNS_NAMESERVER=192.0.2.1"); // 黑洞 nameserver,把查询钉在在途
run_case("control: target = IPv4 literal, no local DNS", 0);
run_case("repro: target = domain, hdns in flight", 1);
printf("done (no crash)\n");
return 0;
}
结果
---- control: target = IPv4 literal, no local DNS
socks5 server on 127.0.0.1:55852
client: method reply = 05 00
client: CONNECT(127.0.0.1:80) 已发出 -> 服务端不走 hdns
---- hloop_free() 开始
---- hloop_free() 正常返回 <-- 对照组干净
---- repro: target = domain, hdns in flight
socks5 server on 127.0.0.1:55859
client: method reply = 05 00
client: CONNECT(domain never-resolves.example:80) 已发出 -> 服务端进 hdns
---- hloop_free() 开始
<-- 复现组在此崩溃,后面的 printf 与 "done (no crash)" 都没输出
两组唯一的差别就是服务端有没有在途的 hdns_t。
机制(源码链条,均按 62f1292)
event/socks5.c:188 —— 目标是域名时服务端自己解析:conn->dns = hdns_resolve(hevent_loop(io), conn->host, socks5_server_dns, conn);(conn->dns 存在 socks5_server_conn_t 里)
event/hloop.c:356 hloop_cleanup() → hloop.c:365 hdns_resolver_free(loop);
event/hdns.c:580 hdns_resolver_free() —— 遍历 r->queries 把每个在途 query HV_FREE(q)(hdns.c:592),但不通知持有该指针的 io
hloop.c:385-388 —— 之后才遍历 loop->ios 做 hio_free(io)
event/hevent.c:192 hio_free() → hio_close()
event/nio.c:626-628 hio_close() → proxy_ctx_free(io->proxy)
event/socks5.c:30 socks5_server_ctx_free() → socks5.c:33:if (conn->dns) hdns_cancel(conn->dns); ← conn->dns 早在第 3 步就被 free 了
event/hdns.c:1043 hdns_cancel() 对已释放内存读 q->delivering(1049)、解引用 q->node.next(1050)、写 q->timer/q->defer_timer(1051-1052)并再次 HV_FREE(q)(1053)→ double free / 堆破坏
hdns_cancel() 里的 q->delivering 早退保护救不了这种情形:q 本身已经不是活对象了。
顺带说明:客户端侧(hio_connect + proxy_setting_t.target_host 为域名)不受影响,因为那个域名是交给代理解析的,本地不调 hdns_resolve。全树里唯一一个活过 hdns_resolver_free() 的 hdns_t 持有者就是 socks5_server_conn_t。
建议
两个层次,任选:
(a) 最小改动,只堵这个点 —— socks5_server_conn_t 里有 hio_t* io,而 hloop_free() 在进入 hloop_cleanup() 之前已经置好 loop->status = HLOOP_STATUS_DESTROY(hloop.c:462),所以 ctx_free 里可以直接判断:
static void socks5_server_ctx_free(void* ctx) {
socks5_server_conn_t* conn = (socks5_server_conn_t*)ctx;
if (conn) {
// 释放 loop 时 hdns_resolver_free() 已经把这些 hdns_t 收走了,
// 此时再 cancel 就是操作已释放内存
if (conn->dns && conn->io && conn->io->loop->status != HLOOP_STATUS_DESTROY)
hdns_cancel(conn->dns);
HV_FREE(conn);
}
}
(b) 更彻底:把 hloop_cleanup() 里的 io 扫尾挪到 hdns_resolver_free() 之前。这样任何持有 hdns_t 的 io 都有机会在 resolver 还活着时正常 cancel,不必逐个打补丁。我看了一下应该可行——hdns_resolver_free() 只释放 queries/cache/r 本身,并不碰 r->io4/io6(见 hdns.c:600 的 NOTE),而这两个 io 由循环的 io 扫尾负责关闭,先扫 io 正好把它们关掉。不过这条会改变 teardown 的整体顺序,需要您判断有没有别的依赖。
需要注意 (a) 只是让这一个场景不崩;如果以后再有别的模块把 hdns_t 存进自己的 ctx,同样的坑会再来一次,(b) 才是根治。
备注
我这边是给 libhv 写文档时做实测发现的,已复现多次(每次都在 hloop_free() 内崩,不含任何概率性)。如果需要我可以补一个 unittest/ 的完整可编译文件或直接开 PR。
标题
[bug] use-after-free: hloop_free() on a loop with an in-flight SOCKS5 server DNS query正文
摘要 / TL;DR:
hloop_cleanup()先hdns_resolver_free()释放全部在途hdns_t,之后才遍历loop->ios释放hio_t;而 SOCKS5 服务端连接的socks5_server_ctx_free()会在这趟遍历里对已被释放的conn->dns调hdns_cancel()→ use-after-free(进程在hloop_free()内崩溃)。环境
62f1292(HV_VERSION= 1.3.4),Windows 11 + mingw-w64 gcc/g++ 8.1.0复现
只用
hloop_create_socks5_proxy_server()+ 一个裸 TCP 客户端。要点是让服务端的 DNS 查询停在在途状态:用黑洞地址192.0.2.1(RFC 5737 TEST-NET-1)作 nameserver,查询永远不会回来,然后在 600ms 后直接hloop_free()。结果
两组唯一的差别就是服务端有没有在途的
hdns_t。机制(源码链条,均按
62f1292)event/socks5.c:188—— 目标是域名时服务端自己解析:conn->dns = hdns_resolve(hevent_loop(io), conn->host, socks5_server_dns, conn);(conn->dns存在socks5_server_conn_t里)event/hloop.c:356 hloop_cleanup()→hloop.c:365 hdns_resolver_free(loop);event/hdns.c:580 hdns_resolver_free()—— 遍历r->queries把每个在途 queryHV_FREE(q)(hdns.c:592),但不通知持有该指针的 iohloop.c:385-388—— 之后才遍历loop->ios做hio_free(io)event/hevent.c:192hio_free()→hio_close()event/nio.c:626-628hio_close()→proxy_ctx_free(io->proxy)event/socks5.c:30 socks5_server_ctx_free()→socks5.c:33:if (conn->dns) hdns_cancel(conn->dns);←conn->dns早在第 3 步就被 free 了event/hdns.c:1043 hdns_cancel()对已释放内存读q->delivering(1049)、解引用q->node.next(1050)、写q->timer/q->defer_timer(1051-1052)并再次HV_FREE(q)(1053)→ double free / 堆破坏hdns_cancel()里的q->delivering早退保护救不了这种情形:q本身已经不是活对象了。顺带说明:客户端侧(
hio_connect+proxy_setting_t.target_host为域名)不受影响,因为那个域名是交给代理解析的,本地不调hdns_resolve。全树里唯一一个活过hdns_resolver_free()的hdns_t持有者就是socks5_server_conn_t。建议
两个层次,任选:
(a) 最小改动,只堵这个点 ——
socks5_server_conn_t里有hio_t* io,而hloop_free()在进入hloop_cleanup()之前已经置好loop->status = HLOOP_STATUS_DESTROY(hloop.c:462),所以 ctx_free 里可以直接判断:(b) 更彻底:把
hloop_cleanup()里的 io 扫尾挪到hdns_resolver_free()之前。这样任何持有hdns_t的 io 都有机会在 resolver 还活着时正常 cancel,不必逐个打补丁。我看了一下应该可行——hdns_resolver_free()只释放 queries/cache/r本身,并不碰r->io4/io6(见hdns.c:600的 NOTE),而这两个 io 由循环的 io 扫尾负责关闭,先扫 io 正好把它们关掉。不过这条会改变 teardown 的整体顺序,需要您判断有没有别的依赖。需要注意 (a) 只是让这一个场景不崩;如果以后再有别的模块把
hdns_t存进自己的 ctx,同样的坑会再来一次,(b) 才是根治。备注
我这边是给 libhv 写文档时做实测发现的,已复现多次(每次都在
hloop_free()内崩,不含任何概率性)。如果需要我可以补一个unittest/的完整可编译文件或直接开 PR。