mini_ssl: key passphrase char* stored as SSL_CTX passwd_cb userdata outlives the Ruby String
Summary
sslctx_initialize stores a raw RSTRING_PTR of the key passphrase as OpenSSL's
default_passwd_cb userdata, which the SSL_CTX retains for its whole lifetime. The
String it points into is a fresh, un-retained Open3.capture3 result, and a passphrase is far
under 616 bytes — so its bytes are embedded in the object slot and move when the GC compacts.
Reachability, stated honestly up front: I could not find a path through Puma's own API that
dereferences the pointer after sslctx_initialize returns. The only consumer,
SSL_CTX_use_PrivateKey_file, is called a few lines later in the same C frame, where the
String is still a live stack local and therefore conservatively pinned. So this is a latent
dangling pointer rather than a bug I can show breaking a running Puma. Filing it because the
pointer outlives the object it points into, initialize is a public method, and the fix is
small.
Cause
ext/puma_http11/mini_ssl.c:288-297:
if (!NIL_P(key_password_command)) {
key_password = rb_funcall(mini_ssl_ctx, rb_intern_const("key_password"), 0);
if (!NIL_P(key_password)) {
StringValue(key_password);
password_cb = password_callback;
password = RSTRING_PTR(key_password); /* :294 */
SSL_CTX_set_default_passwd_cb(ctx, password_cb);
SSL_CTX_set_default_passwd_cb_userdata(ctx, (void *) password); /* :296 RETAINED */
}
}password_callback (mini_ssl.c:194-204) does strlen() + memcpy() on that pointer.
The String comes from Puma::MiniSSL::Context#key_password (lib/puma/minissl.rb:331-339),
which shells out on every call and is not memoized:
def key_password
raise "Key password command not configured" if @key_password_command.nil?
stdout_str, stderr_str, status = Open3.capture3(@key_password_command)
return stdout_str.chomp if status.success?
...so nothing retains the result once sslctx_initialize returns — neither pinned (mechanism:
compaction moves the embedded bytes) nor referenced (mechanism: it is simply collectable).
Reproduction
initialize is public, so calling it a second time without key_password_command skips lines
295-296, leaving the stale userdata in place while SSL_CTX_use_PrivateKey_file (:303) still
invokes the callback:
# ctx1 configured with key_password_command, real encrypted RSA key
sslctx = Puma::MiniSSL::SSLContext.new(ctx_with_password_command) # first initialize: OK
GC.verify_compaction_references(expand_heap: true, toward: :empty)
# size-matched filler -- ruby allocates strings from size-pooled heaps
20.times { 2000.times { +("Z" * password.bytesize) } }
GC.start
sslctx.send(:initialize, ctx_without_password_command) # re-reads stale userdataActual, puma 8.0.2 and 7.2.1, with a 17-byte passphrase, the String retained in a global throughout (so this is purely the mobility mechanism):
password embedded? true len=17
first initialize: OK (encrypted key decrypted with the passphrase)
pw bytes moved: true
old addr holds: "ZZZZZZZZZZZZZZZZZ\x00\x00\x00\x00\x00\x00\x00"
second initialize raised: Puma::MiniSSL::SSLError: SSL_CTX_use_PrivateKey_file:
error in file '.../key.pem': error:1C800064:Provider routines::bad decryptControl, same script with the compaction removed:
pw bytes moved: false
old addr holds: "canarypassword123\x00f-8 -*"
RESULT: ok (stale userdata still read the correct passphrase)3/3 fail, control clean, on both 8.0.2 and 7.2.1. OpenSSL read the 17 Zs as the passphrase —
the size-matched filler landed exactly in the vacated slot.
I want to be clear that the double-initialize is a device to force the later dereference, not
something Puma does. Treat the executed result as evidence that the pointer really does go
stale, not as a production repro.
Suggested fix
Keep the passphrase alive and immobile for as long as the SSL_CTX holds a pointer to it —
e.g. rb_iv_set(self, "@key_password", key_password) so it is marked, and/or strdup it into
memory the SSL_CTX owns and free it when the context is freed. RB_GC_GUARD is not
sufficient, since the pointer must remain valid after the function returns.
Copying is probably the cleanest: the callback only needs a stable char *, and a strdup'd
copy removes any dependence on Ruby object lifetime or placement.
Environment
- puma 8.0.2 and 7.2.1 (the CRuby ext is byte-identical between them —
mini_ssl.c,puma_http11.candhttp11_parser.call hash-identical; the 7.x→8.x diff is JRuby-only) - ruby 4.0.6 (2026-07-14 revision 03b6d3f889) +PRISM [arm64-darwin23]
- Unchanged at HEAD:
ext/puma_http11/mini_ssl.c:294-296
Source: puma/puma