GCC 16.2 GNAT.Sockets 在 FreeBSD 上会在 accept() 失败后覆盖 EAGAIN

摘要

在 FreeBSD 15.1 amd64 上使用 GCC/GNAT 16.2.0 时,可以复现这样的行为: 非阻塞 accept() 返回 -1errno 被设置为 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.adbC_AcceptC_SocketDisable_SIGPIPESO_NOSIGPIPEsocket.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_AcceptSyscall_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_SIGPIPEgcc/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_AcceptC_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() 失败的运行时 测试。