Summary
On master, SSLContext was changed from pinned to movable, but the compaction
callback only fixes up one of the four places its raw VALUE is stored. The ALPN and NPN
callbacks keep the pre-move address, so once the GC compacts a context that has already
completed a handshake, ssl_alpn_select_cb reads a stale VALUE — ALPN negotiation fails,
and it can SEGV.
This is not in any release: 3.3.0, 3.3.1, 4.0.0 and 4.0.2 all pin with rb_gc_mark and
are unaffected (verified by execution — the context never moves). It is a master-only
regression.
Cause
ext/openssl/ossl_ssl.c:56-60 on master marks the context as movable:
static void
ossl_sslctx_mark(void *ptr)
{
SSL_CTX *ctx = ptr;
rb_gc_mark_movable((VALUE)SSL_CTX_get_ex_data(ctx, ossl_sslctx_ex_ptr_idx));
}
and ossl_ssl.c:68-77 updates only the ex_data slot:
static void
ossl_sslctx_compact(void *ptr)
{
SSL_CTX *ctx = ptr;
VALUE self = (VALUE)SSL_CTX_get_ex_data(ctx, ossl_sslctx_ex_ptr_idx);
if (self) {
(void)SSL_CTX_set_ex_data(ctx, ossl_sslctx_ex_ptr_idx,
(void *)rb_gc_location(self));
}
}
But ossl_sslctx_setup stashes the same raw VALUE in three more places that the compact
callback never touches:
ossl_ssl.c:810 SSL_CTX_set_next_protos_advertised_cb(ctx, ssl_npn_advertise_cb, (void *)self);
ossl_ssl.c:814 SSL_CTX_set_next_proto_select_cb(ctx, ssl_npn_select_cb, (void *) self);
ossl_ssl.c:830 SSL_CTX_set_alpn_select_cb(ctx, ssl_alpn_select_cb, (void *) self);
and ossl_ssl.c:610-616 casts it straight back:
ssl_alpn_select_cb(SSL *ssl, const unsigned char **out, unsigned char *outlen,
const unsigned char *in, unsigned int inlen, void *arg)
{
VALUE sslctx_obj, cb;
sslctx_obj = (VALUE) arg;
cb = rb_attr_get(sslctx_obj, id_i_alpn_select_cb);
What makes this reachable rather than theoretical is that ossl_sslctx_setup is lazy and
one-shot — ossl_ssl.c:712 early-returns when the context is frozen, and ossl_ssl.c:834
freezes it at the end:
if(OBJ_FROZEN(self)) return Qnil;
...
rb_obj_freeze(self);
So the callbacks are registered exactly once, at the first handshake, and never re-registered
with a refreshed address. Any long-lived server context that has served at least one
connection and then experiences compaction is exposed.
Reproduction
require "openssl"
require "socket"
key = OpenSSL::PKey::RSA.new(2048)
cert = OpenSSL::X509::Certificate.new
cert.version = 2
cert.serial = 1
cert.subject = cert.issuer = OpenSSL::X509::Name.parse("/CN=localhost")
cert.public_key = key.public_key
cert.not_before = Time.now - 60
cert.not_after = Time.now + 3600
cert.sign(key, OpenSSL::Digest::SHA256.new)
ctx = OpenSSL::SSL::SSLContext.new
ctx.cert = cert
ctx.key = key
ctx.alpn_select_cb = ->(protocols) { protocols.include?("h2") ? "h2" : protocols.first }
# Parked off-stack: a local is conservatively pinned, which masks the bug.
$holder = [ctx]
srv = TCPServer.new("127.0.0.1", 0)
port = srv.addr[1]
$ssl_srv = OpenSSL::SSL::SSLServer.new(srv, $holder[0])
def handshake(port)
t = Thread.new { s = $ssl_srv.accept; s.close rescue nil }
cctx = OpenSSL::SSL::SSLContext.new
cctx.verify_mode = OpenSSL::SSL::VERIFY_NONE
cctx.alpn_protocols = ["h2", "http/1.1"]
sock = OpenSSL::SSL::SSLSocket.new(TCPSocket.new("127.0.0.1", port), cctx)
sock.connect
proto = sock.alpn_protocol
sock.close
t.join(5)
proto
end
# Required: this is what actually runs ossl_sslctx_setup and freezes the context,
# registering the ALPN callback with the pre-move address.
p handshake(port) # => "h2"
GC.verify_compaction_references(expand_heap: true, toward: :empty)
p handshake(port) # => raises
Actual, on master (9796ee8):
"h2"
OpenSSL::SSL::SSLError: SSL_connect returned=1 errno=0 state=error:
tlsv1 alert no application protocol (SSL alert number 120)
with the server side raising NoMethodError: undefined method 'call' for nil — i.e.
rb_attr_get on the stale address returned no callback. Fails 3/3; with the
verify_compaction_references line removed it returns "h2" both times, which is the
control.
Under a full-handshake load it SEGVs outright rather than raising:
[BUG] Segmentation fault at 0x00000001035a4fc0
openssl.bundle(ssl_alpn_select_cb+0x3c)
openssl.bundle(tls_handle_alpn)
openssl.bundle(tls_post_process_client_hello)
Dropping the alpn_select_cb and instead observing the object address directly shows the
move that the ALPN arg misses:
master 9796ee8 : SSLContext 0x124ca67d0 -> 0x124ee1748 MOVED=true -> handshake fails
openssl 4.0.2 : SSLContext 0x... -> 0x...(same) MOVED=false -> handshake ok
openssl 4.0.0 : MOVED=false -> ok
openssl 3.3.1 : MOVED=false -> ok
openssl 3.3.0 : MOVED=false -> ok
The releases are safe precisely because rb_gc_mark pins, so the three un-updated copies
never go stale.
Suggested fix
Update the other three stashes in ossl_sslctx_compact alongside the ex_data slot:
static void
ossl_sslctx_compact(void *ptr)
{
SSL_CTX *ctx = ptr;
VALUE self = (VALUE)SSL_CTX_get_ex_data(ctx, ossl_sslctx_ex_ptr_idx);
if (self) {
VALUE updated = rb_gc_location(self);
(void)SSL_CTX_set_ex_data(ctx, ossl_sslctx_ex_ptr_idx, (void *)updated);
/* these hold the same VALUE and must move with it */
if (SSL_CTX_get_alpn_select_cb(ctx) /* or track whether it was set */)
SSL_CTX_set_alpn_select_cb(ctx, ssl_alpn_select_cb, (void *)updated);
...
}
}
Since the callback arg is always the context itself, a tidier alternative is to stop passing
self as the arg entirely and recover it inside each callback from the SSL_CTX's ex_data —
which the compact callback already maintains — e.g. SSL_get_SSL_CTX(ssl) then
SSL_CTX_get_ex_data(..., ossl_sslctx_ex_ptr_idx). That leaves exactly one stored copy to
keep in sync instead of four.
The two NPN sites (:810, :814) have identical shape. I could not exercise them because
NPN is not negotiated at TLS 1.3, so those are by code reading only — but they need the
same treatment.
Environment
- ruby/openssl
master @ 9796ee8, built from source
- ruby 4.0.6 (2026-07-14 revision 03b6d3f889) +PRISM [arm64-darwin23]
- OpenSSL 3.5.5
- Unaffected: openssl gem 3.3.0, 3.3.1, 4.0.0, 4.0.2 (all verified by execution)
Summary
On
master,SSLContextwas changed from pinned to movable, but the compactioncallback only fixes up one of the four places its raw
VALUEis stored. The ALPN and NPNcallbacks keep the pre-move address, so once the GC compacts a context that has already
completed a handshake,
ssl_alpn_select_cbreads a staleVALUE— ALPN negotiation fails,and it can SEGV.
This is not in any release: 3.3.0, 3.3.1, 4.0.0 and 4.0.2 all pin with
rb_gc_markandare unaffected (verified by execution — the context never moves). It is a
master-onlyregression.
Cause
ext/openssl/ossl_ssl.c:56-60on master marks the context as movable:and
ossl_ssl.c:68-77updates only the ex_data slot:But
ossl_sslctx_setupstashes the same rawVALUEin three more places that the compactcallback never touches:
and
ossl_ssl.c:610-616casts it straight back:What makes this reachable rather than theoretical is that
ossl_sslctx_setupis lazy andone-shot —
ossl_ssl.c:712early-returns when the context is frozen, andossl_ssl.c:834freezes it at the end:
So the callbacks are registered exactly once, at the first handshake, and never re-registered
with a refreshed address. Any long-lived server context that has served at least one
connection and then experiences compaction is exposed.
Reproduction
Actual, on master (9796ee8):
with the server side raising
NoMethodError: undefined method 'call' for nil— i.e.rb_attr_geton the stale address returned no callback. Fails 3/3; with theverify_compaction_referencesline removed it returns"h2"both times, which is thecontrol.
Under a full-handshake load it SEGVs outright rather than raising:
Dropping the
alpn_select_cband instead observing the object address directly shows themove that the ALPN arg misses:
The releases are safe precisely because
rb_gc_markpins, so the three un-updated copiesnever go stale.
Suggested fix
Update the other three stashes in
ossl_sslctx_compactalongside the ex_data slot:Since the callback arg is always the context itself, a tidier alternative is to stop passing
selfas the arg entirely and recover it inside each callback from theSSL_CTX's ex_data —which the compact callback already maintains — e.g.
SSL_get_SSL_CTX(ssl)thenSSL_CTX_get_ex_data(..., ossl_sslctx_ex_ptr_idx). That leaves exactly one stored copy tokeep in sync instead of four.
The two NPN sites (
:810,:814) have identical shape. I could not exercise them becauseNPN is not negotiated at TLS 1.3, so those are by code reading only — but they need the
same treatment.
Environment
master@ 9796ee8, built from source