Skip to content

Commit 68a2c3d

Browse files
tls: fix UAF in server ALPN callback under concurrent handshakes (#29800)
## What does this PR do? Fixes a use-after-free in `selectALPNCallback` (`src/bun.js/api/bun/socket.zig:17`) when a `node:tls` / `Bun.listen` TLS server with `ALPNProtocols` handles overlapping handshakes. `SSL_CTX_set_alpn_select_cb` registers on the **listener-level** `SSL_CTX`, so its `arg` is shared across every accepted connection. The old code passed the per-connection `*TLSSocket` as `arg`, so each `onOpen` overwrote the previous one. When connection A's ClientHello is processed after connection B's `onOpen` has run — and B has since been freed — the callback dereferences a dangling pointer and feeds garbage `protos` into `SSL_select_next_proto`: ``` #9 SSL_select_next_proto (..., peer=0xb10008886384bf6d, peer_len=1431130639, supported="\002h2\bhttp/1.1", supported_len=12) #10 selectALPNCallback (in="\002h2\bhttp/1.1", inlen=12, arg=<freed>) at socket.zig:23 #11 bssl::ssl_negotiate_alpn #12 bssl::do_select_parameters #21 ssl_on_data at openssl.c:505 ``` (from `fetch-http2-client.test.ts` under `describe.concurrent` on `:alpine: 3.23 aarch64`) **Fix:** store the `*TLSSocket` on the per-connection `SSL` via `SSL_set_ex_data(ssl, 0, this)` and read it back from the `SSL*` parameter in the callback, ignoring the CTX-level `arg`. Slot 0 is otherwise unused in the codebase. ## How did you verify your code works? - `bun bd test test/js/node/tls/node-tls-server.test.ts test/js/node/tls/node-tls-connect.test.ts test/js/node/http2/node-http2.test.js test/js/web/fetch/fetch-http2-client.test.ts` → 349 pass, 0 fail - `bun run zig:check-all` → all platforms compile - The existing `connectionListener should emit the right amount of times, and with alpnProtocol available` test (50 parallel ALPN connections) covers the path; the original crash was caught by `fetch-http2-client.test.ts` on aarch64-musl CI
1 parent 8e13869 commit 68a2c3d

1 file changed

Lines changed: 10 additions & 3 deletions

File tree

src/bun.js/api/bun/socket.zig

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,12 @@ fn JSSocketType(comptime ssl: bool) type {
1414
}
1515
}
1616

17-
fn selectALPNCallback(_: ?*BoringSSL.SSL, out: [*c][*c]const u8, outlen: [*c]u8, in: [*c]const u8, inlen: c_uint, arg: ?*anyopaque) callconv(.c) c_int {
18-
const this = bun.cast(*TLSSocket, arg);
17+
fn selectALPNCallback(ssl: ?*BoringSSL.SSL, out: [*c][*c]const u8, outlen: [*c]u8, in: [*c]const u8, inlen: c_uint, _: ?*anyopaque) callconv(.c) c_int {
18+
// SSL_CTX_set_alpn_select_cb registers on the listener-level SSL_CTX, so its
19+
// `arg` is shared across every accepted connection — using it for a
20+
// per-connection *TLSSocket is a UAF when handshakes overlap. Read the
21+
// socket back from the per-SSL ex_data slot set in onOpen instead.
22+
const this = bun.cast(*TLSSocket, BoringSSL.SSL_get_ex_data(ssl, 0) orelse return BoringSSL.SSL_TLSEXT_ERR_NOACK);
1923
if (this.protos) |protos| {
2024
if (protos.len == 0) {
2125
return BoringSSL.SSL_TLSEXT_ERR_NOACK;
@@ -459,7 +463,10 @@ pub fn NewSocket(comptime ssl: bool) type {
459463
}
460464
if (this.protos) |protos| {
461465
if (this.isServer()) {
462-
BoringSSL.SSL_CTX_set_alpn_select_cb(BoringSSL.SSL_get_SSL_CTX(ssl_ptr), selectALPNCallback, bun.cast(*anyopaque, this));
466+
// Per-connection: callback reads `this` from the SSL,
467+
// not the CTX-level arg (shared across the listener).
468+
_ = BoringSSL.SSL_set_ex_data(ssl_ptr, 0, this);
469+
BoringSSL.SSL_CTX_set_alpn_select_cb(BoringSSL.SSL_get_SSL_CTX(ssl_ptr), selectALPNCallback, null);
463470
} else {
464471
_ = BoringSSL.SSL_set_alpn_protos(ssl_ptr, protos.ptr, @as(c_uint, @intCast(protos.len)));
465472
}

0 commit comments

Comments
 (0)