Skip to content

SSL methods segfault when called after dispose() #66

Description

@SeanTAllen

Calling read(), receive(), can_send(), or send() on a disposed SSL session segfaults.

SSL.dispose() frees the OpenSSL session with SSL_free and nulls _ssl. It does not null _input and _output, and SSL_free frees those too, because SSL_set_bio handed ownership of both BIOs to the session. Neither field is checked anywhere else in the class. The null-out guards dispose() and _final() against a double free and nothing else, so every other method still passes a null SSL* or a pointer to a freed BIO to OpenSSL.

Reproduction

use "ssl/net"

actor Main
  new create(env: Env) =>
    try
      let ctx = recover val SSLContext end
      let ssl = ctx.client()?
      ssl.dispose()

      match ssl.read()
      | let _: Array[U8] iso => env.out.print("read() returned data")
      | None => env.out.print("read() returned None")
      end

      env.out.print("survived read()")
    end

Against ssl 2.0.1 and OpenSSL 3.6.2, that dies on the read():

Thread 17 "repro1" received signal SIGSEGV, Segmentation fault.
#0  0x00007ffff7ee5a05 in SSL_pending () from /usr/lib/libssl.so.3
rdi            0x0                 0

rdi holds the SSL* argument. It is the null that dispose() stored.

Every method, after dispose

Method After dispose()
read() segfault in SSL_pending, null SSL*
receive() segfault in BIO_write, freed _input
can_send() segfault in BIO_ctrl, freed _output
send() segfault in BIO_ctrl, freed _output
write() returns
alpn_selected() returns None
state() returns the same value it returned before the dispose

Every row observed against ssl 2.0.1 and OpenSSL 3.6.2.

write() and alpn_selected() come back only because SSL_write and SSL_get0_alpn_selected check their SSL* argument for null. Nothing in ssl arranges that, and I have not checked LibreSSL or OpenSSL 1.1.x. write() coming back is its own problem: it returns as though the write happened, and nothing is written.

The three BIO crashes are the ones no caller can work around. _input and _output are private, they are not null after dispose(), and they point at freed memory, which no null check can detect. Only dispose() can clear them.

The API problem

state() never crashes and never changes. dispose() leaves _state alone, so state() returns SSLReady for a session that completed a handshake and has since been freed. Drive a handshake between an in-process client and server, dispose the client, and state() returns SSLReady on both sides of the call.

Add null guards and the four crashes stop. A disposed session still cannot be told apart from a live one. state() returns SSLReady, read() returns None, can_send() returns false, receive() does nothing, and write() already returns as though it wrote — which is exactly what a live session with no data in flight looks like. There is no way to ask the API whether a session has been disposed.

Fixing it

The guards are mandatory whatever else we decide. A caller can hold an SSL ref alias across a dispose() that runs inside a callback, and no type we can write stops that alias from being used afterward. That is what happens in lori (ponylang/lori#310).

Modeling the dead session is the harder call. Adding a variant to type SSLState is (SSLHandshake | SSLAuthFail | SSLReady | SSLError) models it correctly, and it breaks any match \exhaustive\ over the union, so it belongs in a major release. An additive is_disposed(): Bool breaks nothing and is opt-in, which leaves a caller who forgets to check right where they started.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions