#3984·puma

mini_ssl: key passphrase char* stored as SSL_CTX passwd_cb userdata outlives the Ruby String

Author: jeremyCreated Aug 5, 2026Updated Aug 5, 2026

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:

c
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:

ruby
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:

ruby
# 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 userdata

Actual, 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 decrypt

Control, 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.c and http11_parser.c all 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