2026 年 10 月 28 日,FreeSWITCH 仓库挂出 GHSA-jj89-q47c-gh94,对应 CVE-2026-116785,CVSS 3.1 9.1(AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:H),CWE-122 / CWE-681 / CWE-787,受影响< 1.11.2,修复>= 1.11.2。报告者是 Tristan Madani(Talence Security),公告由 andywolk 发布。GHSA 页面没有列出修复提交的哈希,也没有 Patch 区块;NVD 的 references 把d557ca6d29e6da4f10824dc158100906a592b91a直接标成Patch(source 是[email protected]),它也确实与v1.11.2的文件实体一致。
公告给的结论可以压成三句:ws_read_frame()用 32 位字节序交换解码 64 位扩展长度,结果落进有符号 32 位变量;存活的那半字最高位为 1 时变成负数,绕过「长度 > 缓冲大小」的有符号比较,随后被填缓冲的循环当成巨大的无符号计数;写入越过 64 KiB 接收缓冲,远程代码执行未被证明。
这三句都对,但读者拿不到「哪一行、为什么、到底改了几处」。本文补这一段:把ws_read_frame()从取帧头到memcpy的十来行逐条摆开,把「一个负数为什么能同时骗过两道检查」算到具体字节值,再把修复侧逐 hunk 对一遍——对完会发现修复不是一个提交,同一天下午同一个函数被改了三笔,而 release notes 只给了一条语义描述。另有两处公告没写的细节:conf/minimal这套配置集其实默认加载了mod_xml_rpc;1.10 分支至今没有对应修复版本。
验证边界:源码取自signalwire/freeswitchtagv1.11.1(c2c59645f6,2026-05-26);修复侧取自三个提交的 API diff 与 tagv1.11.2(3f13ad1b1d,2026-08-09)的文件实体;数值路径用脚本按 C 表达式语义复算。本文没有编译 FreeSWITCH,也没有对运行中的服务发过包——凡涉及运行时行为的句子都标明是源码推导。
一、暴露面:两个开关,一处例外
mod_xml_rpc是把 FreeSWITCH 的命令总线和 XML-RPC/HTTP 接起来的模块,/RPC2上挂着freeswitch.api、freeswitch.management两个方法(mod_xml_rpc.c:1228-1231),/socket上挂着一个 WebSocket 升级端点。这个模块默认不加载——conf/vanilla/autoload_configs/modules.conf.xml:14是一行注释:
<!-- <load module="mod_xml_rpc"/> -->
insideout、sbc 两套配置集的同一行(各:14)也是注释。但conf/minimal不是。conf/minimal/autoload_configs/modules.conf.xml:9是未注释的<load module="mod_xml_rpc"/>,规规矩矩排在<!-- XML Interfaces -->段下:
<!-- XML Interfaces -->
<load module="mod_xml_rpc"/>
这不是笔误式的孤例,看起来是「minimal 配置集要留一个能跑命令的口子」的设计取舍。它意味着「模块默认关闭」这个说法只在 vanilla/insideout/sbc 三套配置集成立;用 minimal 起机的实例,第一个开关已经是开的。
第二个开关是 WebSocket 端点本身。enable-websocket在五套 conf 树的xml_rpc.conf.xml里一次都没出现(逐文件 grep,计数全为 0),只有模块自带的样例配置src/mod/xml_int/mod_xml_rpc/conf/autoload_configs/xml_rpc.conf.xml:9留了一行注释:
<!-- <param name="enable-websocket" value="true"/> -->
解析代码在mod_xml_rpc.c:134-135,取值走switch_true();因为globals在模块加载时被memset清零(:172),缺省即关。所以「能打到」的完整条件是:模块被加载 且enable-websocket被显式置 true。两者都满足后,服务监听在该模块的http-port(:128-129,缺省 8080,:143-145),绑定地址来自 abyss 的socket_unix.c:887:
name.sin_addr.s_addr = INADDR_ANY;
即 0.0.0.0。这两步的门槛都不高——改两个文件、重启一次而已。所以这个洞的真实风险不在「所有 FreeSWITCH 都能被打」,而在「凡是为了做监控面板、对接 CTI 后台、跑自动化拨测而打开过这个模块的实例,都在暴露面上,而面板类用法通常需要 websocket 才能工作」。
还有一处容易看反的地方:stock 的xml_rpc.conf.xml其实已经把鉴权参数填好了(conf/vanilla/autoload_configs/xml_rpc.conf.xml:6-8):
<param name="auth-realm" value="freeswitch"/>
<param name="auth-user" value="freeswitch"/>
<param name="auth-pass" value="works"/>
也就是说,一个按默认配置起机、只把两行注释去掉的实例,看起来是「有认证的」。下一节说明为什么这三个参数对/socket完全无效。

二、认证顺序:/socket在口令校验之前就分派了
abyss 的 handler 列表在这个模块里排成三项:xmlrpc_server_abyss_set_handler(..., "/RPC2", ...)(mod_xml_rpc.c:1245,经setHandler → ServerAddHandler3,libs/xmlrpc-c/src/xmlrpc_server_abyss.c:747)、handler_hook(:819,注册于:1258)、auth_hook(:709,注册于:1259)。ListAdd是尾部追加(libs/xmlrpc-c/lib/abyss/src/data.c:105-129),runUserHandler却从列表末尾往前遍历(server.c:565-567),所以执行顺序是auth_hook→handler_hook→/RPC2:最后注册的auth_hook最先跑。
/socket在auth_hook的第一个分支就被分派,handler_hook在分派之前不会被问到。websocket_hook自己从不返回TRUE(:603、:604、:706全是 FALSE),所以等 WS 会话结束、它返回 FALSE 之后,循环确实会继续下探到handler_hook——只是那时连接已经关了,handler_hook对/socket走:861的return FALSE。这个函数只认/api/、/webapi/、/txtapi/、/xmlapi/四个子串(strstr匹配,不是前缀比较,:848-859)。auth_hook的第一段不是鉴权,是分派:
if (globals.enable_websocket && !strncmp(r->requestInfo.uri, "/socket", 7)) {
// Chrome has no Authorization support yet
// https://code.google.com/p/chromium/issues/detail?id=123862
return websocket_hook(r);
}
(mod_xml_rpc.c:719-723)
三件事同时发生在这五行里。其一,URI 判定是 7 字节前缀比较,/socket、/socket/、/socketanything都命中。其二,命中后无条件return websocket_hook(r),函数不再往下走。其三,注释把动机写得很直白:浏览器在 WebSocket 握手时带不了Authorization头(引的是 2011 年的 chromium issue 123862),所以这一层干脆不校验——不是漏了,是主动跳过的。
校验在后面。/domains/前缀的请求在:744调http_directory_auth,其余非/pub的 URI 在:780-782调同一个函数:
} else {
if (globals.realm && strncmp(r->requestInfo.uri, "/pub", 4)) {
ret = !http_directory_auth(r, NULL);
}
}
(mod_xml_rpc.c:779-783)
所以/socket与鉴权是同一个函数里的先后两个分支,前者在前且不回头。这一层的具体方案是 HTTP Basic,不是 Digest:http_directory_auth从authorization头里取方案名与"basic"比较(:410、:416),先用switch_b64_decode把凭据解成user:pass(:418-421),再按auth-user/auth-pass或用户目录重新拼出user:pass、交给xmlrpc_base64Encode编码,最后与原始 token 做strcmp(编码在:458、:482,比较在:460、:484)。失败时回的 challenge 是
switch_snprintf(z, sizeof(z), "Basic realm=\"%s\"", domain_name ? domain_name : globals.realm);
ResponseAddField(r, "WWW-Authenticate", z);
ResponseStatus(r, 401);
(mod_xml_rpc.c:546-548)
代码里没有 digest 的分支。GHSA 正文把这一层写成 "HTTP digest checks",与源码不符:这里实现的是 Basic,方案名比较是strcasecmp(x, "basic")(:416),失败响应是Basic realm=(:546)。这个差别不影响结论——无论这一层叫 Basic 还是 Digest,它都在return websocket_hook(r)之后——但影响排查时的抓包判据:你在流量里看到的是 401 +Basic realm=,而不是 401 +nonce。
结论:任何能对 8080 建 TCP 连接、能发出一个带Upgrade: websocket/Sec-WebSocket-Protocol: websocket/Sec-WebSocket-Key/Sec-WebSocket-Version的请求的客户端,都能拿到 101,然后进入ws_read_frame()。门槛写在:603:四个头一个都不能少(if (!key || !version || !proto || !upgrade) return FALSE;),:604再要求upgrade与sec-websocket-protocol的值含websocket(strncasecmp,大小写不敏感);值为空串的 key/version/proto 会在ws_handshake_kvp(ws.c:231)的!key || !version || !*proto处被挡,空的 upgrade 过不了:604。除此之外什么都不验:不校验 key 是不是合法 base64,不校验 version 是不是 13,101 响应里的Sec-WebSocket-Accept由 key 现算,客户端给什么它就签什么。auth-realm/auth-user/auth-pass填不填、填什么,与这条路径无关。
三、帧解析链:从 14 字节到memcpy
websocket_hook拿到 101 之后进入读帧循环(mod_xml_rpc.c:625-626),每次调用ws_read_frame(wsh, &opcode, &data)。连接句柄wsh_t是ws_init里一次malloc(sizeof(*wsh))出来的(ws.c:336),结构体定义在ws.h:60-75:
typedef struct wsh_s {
ws_tsession_t *tsession;
char buffer[65536];
char wbuffer[65536];
size_t buflen;
issize_t datalen;
issize_t wdatalen;
char *payload;
issize_t plen;
issize_t rplen;
SSL *ssl;
int handshake;
uint8_t down;
int secure;
uint8_t close_sock;
} wsh_t;
issize_t是int的别名(ws.h:29)。接收缓冲buffer与发送缓冲wbuffer都是 65536 字节的内联数组——它们不是指针,而是同一块堆分配里的两段连续区域,这一点在第五节决定越界写的落点。
进函数。datalen记录本次读进buffer的字节数,need记录「按当前头部字段推算,总共需要多少字节」。开头先抓 14 个字节:
if ((wsh->datalen = ws_raw_read(wsh, wsh->buffer, 14)) < need) {
while (!wsh->down && (wsh->datalen += ws_raw_read(wsh, wsh->buffer + wsh->datalen, 14 - wsh->datalen)) < need) ;
(ws.c:406-407)
14 是「2 字节基础头 + 8 字节扩展长度 + 4 字节 mask key」的最大定长部分,这个数字后面会决定最小攻击帧的大小。补齐循环只保证凑到need(此时是 2)字节,凑到就往下走。
opcode 取低 4 位(:417),分派到WSOC_TEXT/WSOC_BINARY/WSOC_PING/WSOC_PONG这一支(:427-432)。然后依次:
int mask = (wsh->buffer[1] >> 7) & 1;
if (mask) {
need += 4;
...
}
wsh->plen = wsh->buffer[1] & 0x7f;
wsh->payload = &wsh->buffer[2];
if (wsh->plen == 127) {
uint64_t *u64;
need += 8;
...
u64 = (uint64_t *) wsh->payload;
wsh->payload += 8;
wsh->plen = ntohl((u_long)*u64);
} else if (wsh->plen == 126) {
(ws.c:434-465,省略了两处need > wsh->datalen的「头不够长」检查)
- buffer[1]的最高位是 MASK 位,RFC 6455 要求客户端发出的帧必须置 1。置 1 则need += 4,为 mask key 预留 4 字节。
- buffer[1] & 0x7f是 7 位长度码,取值 0–127。126 表示「后面跟 2 字节长度」,127 表示「后面跟 8 字节长度」。
- payload先指向buffer[2],即头部之后第一个字节;127 分支里再 +8,跳过 8 字节扩展长度;masked 时再 +4 跳过 mask key(:481-484)。注意payload始终指向「帧内的数据区」,而不是「帧内数据的副本」——它是wsh->buffer内部的指针,这是后面memcpy能写到结构体其他字段的前提。
- :463那一行是整个漏洞的种子:ntohl()的形参是uint32_t,(u_long)*u64这个 64 位值传进去被截成低 32 位,再做 32 位字节序翻转,结果赋给int类型的plen。
继续往下,剩余量计算与第一道闸:
need = (wsh->plen - (wsh->datalen - need));
if ((need + wsh->datalen) > (issize_t)wsh->buflen) {
/* too big - Ain't nobody got time fo