Skip to content

[bug] use-after-free: hloop_free() on a loop with an in-flight SOCKS5 server DNS query #891

Description

@cjl3230

标题

[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)

  1. event/socks5.c:188 —— 目标是域名时服务端自己解析:conn->dns = hdns_resolve(hevent_loop(io), conn->host, socks5_server_dns, conn);(conn->dns 存在 socks5_server_conn_t 里)
  2. event/hloop.c:356 hloop_cleanup() → hloop.c:365 hdns_resolver_free(loop);
  3. event/hdns.c:580 hdns_resolver_free() —— 遍历 r->queries 把每个在途 query HV_FREE(q)(hdns.c:592),但不通知持有该指针的 io
  4. hloop.c:385-388 —— 之后才遍历 loop->ios 做 hio_free(io)
  5. event/hevent.c:192 hio_free() → hio_close()
  6. event/nio.c:626-628 hio_close() → proxy_ctx_free(io->proxy)
  7. event/socks5.c:30 socks5_server_ctx_free() → socks5.c:33:if (conn->dns) hdns_cancel(conn->dns); ← conn->dns 早在第 3 步就被 free 了
  8. 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。

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions