Newshot
Account

FreeSWITCH mod_xml_rpc WebSocket 帧长度截断堆越界写(CVE-2026-116785)

Summary

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 ,修复

Original text(via FreeBuf)

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完全无效。

image.png
image.png

二、认证顺序:/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
      

The text above was extracted automatically from the source page and may miss images, tables or code blocks. The original is authoritative.

Published by FreeBuf
This site only summarises and aggregates; copyright stays with the original author. Headlines and summaries follow the source — where they differ, the original wins.
Read the original ↗