Executive Summary
During an architecture review of Apple's open-source container engine (apple/container), a critical resource exhaustion flaw was uncovered in the Swift-NIO port-forwarding component (Sources/SocketForwarder/ConnectHandler.swift). When an inbound client connection aborts or disconnects while the backend socket is still being established, the error-handling closure incorrectly invoked context.channel.close() on the already-inactive frontend channel rather than closing the newly allocated backend channel. This left backend sockets open indefinitely, leaking one file descriptor per aborted connection and causing a complete Denial of Service (EMFILE / 'Too many open files').
Coordinated Disclosure Timeline
Identified socket file descriptor leak in ConnectHandler.swift; submitted vulnerability disclosure directly to Apple container team.
Apple maintainer (@jglogan) reviewed findings ('Good catch') and requested cross-platform reproduction.
Formulated and submitted a byte-for-byte standalone Swift-NIO reproduction on Linux demonstrating monotonic FD growth (from 25 to 2,387 leaked FDs), alongside a macOS native test script.
Apple maintainer (@egernst) verified the bug: 'I appreciate your digging, and I do think we have a bug to fix here.'
Apple opened public issue #2261 with credit ('Thanks to @madhanalagarsamy for helping identify this bug!') and shipped the official fix in PR #2260.
01.Architecture & ConnectHandler Pipeline
Apple Container uses Apple's Swift-NIO framework for high-performance asynchronous networking. The ConnectHandler is an inbound channel handler responsible for bridging an incoming client socket to a backend container service whenever port-forwarding is enabled (e.g. container run -p 18080:80):
ClientBootstrap(group: context.eventLoop)
.connectTimeout(self.connectTimeout)
.connect(to: serverAddress)
.assumeIsolatedUnsafeUnchecked()
.whenComplete { result in
switch result {
case .success(let channel):
guard context.channel.isActive else {
self.log?.trace("backend - frontend channel closed, closing backend connection")
context.channel.close(promise: nil) // BUG: Closes dead frontend instead of newly opened backend `channel`
return
}
self.log?.trace("backend - connected")
self.glue(channel, context: context)
case .failure(let error):
...
}
}02.Root Cause: Misdirected Channel Closure & FD Leaks
Notice the guard context.channel.isActive branch: when the frontend client disconnects during the connection establishment phase, context.channel is already inactive. Instead of shutting down the newly established backend channel (the socket returned in result), the code called context.channel.close(promise: nil) — closing the already-dead frontend again! The backend socket was orphaned, never closed, and leaked a live file descriptor inside the host process.
Reproduction & Proof of Concept (PoC)
The proof of concept tested rapid connection aborts, proving deterministic, monotonic file descriptor growth until the OS limit is exhausted.
- Constructed a standalone Swift-NIO test harness compiling the unmodified SocketForwarder files from apple/container on Linux.
- Ran a client script initiating TCP connections to the forwarder port and terminating them immediately before the handshake finished.
- Tracked active file descriptors via /proc/self/fd: baseline 25 FDs rose to 270 at 500 iterations, 981 at 2,000 iterations, and 2,387 at 5,000 iterations.
- Demonstrated the patch: Changing context.channel.close() to channel.close() resulted in a settled FD count of 24 (delta 0).
- Formulated macOS validation script using container run -p 18080:80 and monitoring daemon handle growth with lsof -p $PID.
#!/usr/bin/env python3
import socket, time
HOST, PORT = "127.0.0.1", 18080
for i in range(1, 5001):
try:
s = socket.create_connection((HOST, PORT), timeout=0.5)
s.close() # Drop client immediately; races against backend connect
except Exception:
pass
if i % 500 == 0:
print(f"[+] {i} connections aborted")
time.sleep(0.01)Impact & Blast Radius
Remediation & Mitigation
@@ -64,7 +64,7 @@ extension ConnectHandler {
case .success(let channel):
guard context.channel.isActive else {
self.log?.trace("backend - frontend channel closed, closing backend connection")
- context.channel.close(promise: nil)
+ channel.close(promise: nil)
return
}