GCC 16.2 GNAT.Sockets 在 FreeBSD 上会在 accept() 失败后覆盖 EAGAIN
摘要
在 FreeBSD 15.1 amd64 上使用 GCC/GNAT 16.2.0 时,可以复现这样的行为:
非阻塞 accept() 返回 -1 且 errno 被设置为 EAGAIN 后,
GNAT.Sockets.Accept_Socket 却报告 BAD_FILE_DESCRIPTOR。
该行为通过一个只依赖 GNAT.Sockets 和 Ada 标准库包的独立 Ada 程序复现。
系统调用跟踪显示,FreeBSD 内核返回的是 EAGAIN。随后 GNAT 运行时使用表示失败的
返回值 -1 调用其禁用 SIGPIPE 的辅助函数。在 FreeBSD 上,该辅助函数调用
setsockopt(-1, SOL_SOCKET, SO_NOSIGPIPE, ...)。这个调用以 EBADF 失败,并在
GNAT.Sockets 将错误转换成 Ada 异常之前,覆盖了 accept() 留下的 EAGAIN。
观察到的调用和错误顺序如下:
FreeBSD kernel
accept(listener) -> -1, errno = EAGAIN
GNAT runtime
GNAT.Sockets.Thin.C_Accept
-> Disable_SIGPIPE(-1)
-> setsockopt(-1, SO_NOSIGPIPE, ...) -> -1, errno = EBADF
GNAT.Sockets
-> Socket_Error: [9] Bad file descriptor
C_Socket 中的 Syscall_Socket 之后也存在同样无条件调用 Disable_SIGPIPE 的模式。
本次调查没有执行会触发 socket() 失败的运行时测试,因此这里将其记录为源码
审计发现,而不是已经复现的运行时结果。
源码补丁只在底层 socket 操作成功时(R /= Failure)调用 Disable_SIGPIPE。
测试环境
复现该行为的环境如下:
OS: FreeBSD 15.1-RELEASE-p3
Kernel: FreeBSD 15.1, __FreeBSD_version 1501000
Arch: amd64
GNAT: GNATMAKE 16.2.0
GCC src: GCC 16.2.0 release
本次工作使用的启用 Ada 的 GCC 16.2 port 源码树基于 GCC 16.2.0 发布版 tarball:
SHA256 (gcc-16.2.0.tar.xz) =
e6738e29597f733270731aa90600f37ffdc045079dfc27ec7e8192cc81085c3e
我还审计了用于构建该编译器的本地 FreeBSD port 补丁集。这些补丁均未修改
g-socthi.adb、C_Accept、C_Socket、Disable_SIGPIPE、SO_NOSIGPIPE
或 socket.c。上游 GCC 16.2.0 源码中包含与已复现故障相同的控制流。
上游源码参考:
https://github.com/gcc-mirror/gcc/blob/releases/gcc-16.2.0/gcc/ada/libgnat/g-socthi.adb
https://github.com/gcc-mirror/gcc/blob/releases/gcc-16.2.0/gcc/ada/socket.c
https://github.com/gcc-mirror/gcc/blob/releases/gcc-16.2.0/gcc/ada/libgnat/g-socket.adb
独立复现程序
下面的程序创建一个 TCP 监听 socket,将其切换为非阻塞模式,然后在没有客户端等待
连接时调用 Accept_Socket。
with Ada.Exceptions;
with Ada.Text_IO;
with GNAT.Sockets;
procedure Accept_Eagain is
use Ada.Exceptions;
use Ada.Text_IO;
use GNAT.Sockets;
Server : Socket_Type;
Client : Socket_Type;
Peer : Sock_Addr_Type;
Req : Request_Type := (Name => Non_Blocking_IO, Enabled => True);
begin
Initialize;
Create_Socket (Server, Family_Inet, Socket_Stream);
Bind_Socket
(Server,
(Family => Family_Inet,
Addr => Inet_Addr ("127.0.0.1"),
Port => Any_Port));
Listen_Socket (Server);
Control_Socket (Server, Req);
Accept_Socket (Server, Client, Peer);
Put_Line ("unexpected accept success");
Close_Socket (Client);
Close_Socket (Server);
exception
when E : Socket_Error =>
Put_Line (Exception_Name (E) & ": " & Exception_Message (E));
Close_Socket (Server);
end Accept_Eagain;
使用已安装的 GNAT 16.2 编译器构建并运行:
gnatmake16 -q accept_eagain.adb
./accept_eagain
观察到的结果:
GNAT.SOCKETS.SOCKET_ERROR: [9] Bad file descriptor
下面的跟踪表明,有效监听 socket 上的 accept() 先返回 EAGAIN,随后一次
setsockopt() 调用将 errno 改成了 EBADF。
系统调用跟踪
使用 truss 运行复现程序,可以看到如下 errno 变化:
setsockopt(3,SOL_SOCKET,SO_NOSIGPIPE,...) = 0
accept(3,...) ERR#35 'Resource temporarily unavailable'
setsockopt(-1,SOL_SOCKET,SO_NOSIGPIPE,...) ERR#9 'Bad file descriptor'
第一次 setsockopt() 调用操作的是有效监听描述符 3。
接下来的两个调用显示了 errno 的变化。非阻塞监听 socket 没有可接受连接时,
accept() 返回 -1 并设置 EAGAIN。紧接着 GNAT 运行时使用描述符 -1 调用
setsockopt()。第二次系统调用将 errno 改成 EBADF。
跟踪记录显示,EAGAIN 来自 accept(),而 EBADF 只来自之后的 setsockopt()
调用。原始 EAGAIN 是在 accept() 返回后于 GNAT 运行时内部被覆盖的。
GCC 16.2.0 中的源码路径
在 gcc/ada/libgnat/g-socthi.adb 中,C_Accept 将 Syscall_Accept 的结果保存到
R。实现使用 R /= Failure 保护非阻塞 socket 的管理操作,但之后的 SIGPIPE
设置却无条件执行:
if not SOSC.Thread_Blocking_IO
and then R /= Failure
then
...
end if;
Disable_SIGPIPE (R);
return R;
因此,即使 Syscall_Accept 返回 Failure(-1),运行时仍然执行:
Disable_SIGPIPE (-1);
Disable_SIGPIPE 从 gcc/ada/socket.c 以 __gnat_disable_sigpipe 名称导入。
在定义了 SO_NOSIGPIPE 的系统上,其实现会调用:
setsockopt (fd, SOL_SOCKET, SO_NOSIGPIPE, ...)
FreeBSD 提供 SO_NOSIGPIPE,因此表示失败的返回值 -1 会传入 setsockopt(-1, ...),
并使其将 errno 设置为 EBADF。
更高层的 g-socket.adb 随后大致执行:
Res := C_Accept (...);
if Res = Failure then
Raise_Socket_Error (Socket_Errno);
end if;
此时,Socket_Errno 读到的是 EBADF,而不是来自 accept() 的 EAGAIN。
在已复现的路径中,运行时会对 accept() 返回的 Failure 值继续做后处理,
随后发生的 setsockopt() 又在调用者读取 errno 之前覆盖了它。
相关的 C_Socket 发现
同一个 GCC 16.2.0 文件中的 C_Socket 也包含以下模式:
R := Syscall_Socket (Domain, Typ, Protocol);
...
Disable_SIGPIPE (R);
return R;
如果 Syscall_Socket 返回 Failure,那么按同样的逻辑,在定义 SO_NOSIGPIPE 的
目标系统上也可能执行到 Disable_SIGPIPE (-1)。
C_Accept 失败已经复现。本次调查没有执行会触发 Syscall_Socket 失败的运行时
测试,因此 C_Socket 情形记录为相关源码审计发现,而不是独立复现的运行时结果。
补丁
补丁只在系统调用结果不是 Failure 时执行描述符后处理。针对 GCC 16.2.0
g-socthi.adb 的完整补丁如下:
--- gcc/ada/libgnat/g-socthi.adb.orig
+++ gcc/ada/libgnat/g-socthi.adb
@@ -187,7 +187,10 @@
Discard := C_Ioctl (R, SOSC.FIONBIO, Val'Access);
end if;
- Disable_SIGPIPE (R);
+ if R /= Failure then
+ Disable_SIGPIPE (R);
+ end if;
+
return R;
end C_Accept;
@@ -533,7 +536,10 @@
Discard := C_Ioctl (R, SOSC.FIONBIO, Val'Access);
Set_Non_Blocking_Socket (R, False);
end if;
- Disable_SIGPIPE (R);
+ if R /= Failure then
+ Disable_SIGPIPE (R);
+ end if;
+
return R;
end C_Socket;
补丁对 C_Accept 和 C_Socket 都应用相同的条件检查。两个修改块都可以干净地
应用到 GCC 16.2.0 发布版源码。
验证状态
C_Accept 补丁通过了以下检查:
[PASS] standalone Ada reproducer using only GNAT.Sockets and the Ada standard library
[PASS] installed GNAT 16.2.0 reproduces BAD_FILE_DESCRIPTOR
[PASS] baseline truss records accept() returning EAGAIN first
[PASS] baseline truss records setsockopt(-1, SO_NOSIGPIPE) returning EBADF
[PASS] upstream GCC 16.2.0 source contains the matching control flow
[PASS] local FreeBSD GCC port patches do not introduce or modify this path
[PASS] both C_Accept/C_Socket patch hunks apply without rejects
[PASS] patched g-socthi.adb compiles as a single GNAT runtime unit
[PASS] temporary libgnat.a with only g-socthi.o replaced links successfully
[PASS] patched reproducer reports EAGAIN instead of EBADF
[PASS] patched truss contains no setsockopt(-1, SO_NOSIGPIPE) call
补丁后的运行时验证
无需完整 GCC bootstrap 即完成了补丁后运行时的验证。已安装的 g-socthi.ali
记录了构建该运行时单元时使用的编译器选项。复制已安装的 g-socthi.adb,应用两个
修改块后,使用这些已记录选项和已安装的 GNAT 16.2.0 编译器,只编译这个运行时主体。
随后将已安装的静态运行时库复制到临时目录,并只替换 g-socthi.o:
cp /usr/local/lib/gcc16/gcc/x86_64-portbld-freebsd15.1/16.2.0/adalib/libgnat.a \
/tmp/libgnat.a
ar r /tmp/libgnat.a g-socthi.o
ranlib /tmp/libgnat.a
在这个安装环境中,gnatlink16 通过绝对路径将已安装的 libgnat.a 传给 gcc16,
因此仅添加 -L 目录不足以替换它。为了生成验证可执行文件,我重新生成并编译了绑定器
输出,然后重新执行最终的 gcc16 链接命令,用临时的已打补丁 libgnat.a 替换已安装
归档库。
使用未修改的运行时时,复现程序报告:
GNAT.SOCKETS.SOCKET_ERROR: [9] Bad file descriptor
相关跟踪为:
setsockopt(3,SOL_SOCKET,SO_NOSIGPIPE,...) = 0
accept(3,...) ERR#35 'Resource temporarily unavailable'
setsockopt(-1,SOL_SOCKET,SO_NOSIGPIPE,...) ERR#9 'Bad file descriptor'
在运行时归档库的其余内容保持不变的情况下链接已打补丁的 g-socthi.o,同一个
复现程序会报告:
GNAT.SOCKETS.SOCKET_ERROR: [35] Resource temporarily unavailable
相关跟踪变为:
setsockopt(3,SOL_SOCKET,SO_NOSIGPIPE,...) = 0
accept(3,...) ERR#35 'Resource temporarily unavailable'
在这次跟踪中,accept() 返回 EAGAIN,之后也没有发生
setsockopt(-1, SO_NOSIGPIPE, ...) 调用。
C_Socket 修改块仅由源码审计支持;本次调查没有执行会触发 socket() 失败的运行时
测试。