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.
Calling
read(),receive(),can_send(), orsend()on a disposedSSLsession segfaults.SSL.dispose()frees the OpenSSL session withSSL_freeand nulls_ssl. It does not null_inputand_output, andSSL_freefrees those too, becauseSSL_set_biohanded ownership of both BIOs to the session. Neither field is checked anywhere else in the class. The null-out guardsdispose()and_final()against a double free and nothing else, so every other method still passes a nullSSL*or a pointer to a freed BIO to OpenSSL.Reproduction
Against ssl 2.0.1 and OpenSSL 3.6.2, that dies on the
read():rdiholds theSSL*argument. It is the null thatdispose()stored.Every method, after dispose
dispose()read()SSL_pending, nullSSL*receive()BIO_write, freed_inputcan_send()BIO_ctrl, freed_outputsend()BIO_ctrl, freed_outputwrite()alpn_selected()Nonestate()Every row observed against ssl 2.0.1 and OpenSSL 3.6.2.
write()andalpn_selected()come back only becauseSSL_writeandSSL_get0_alpn_selectedcheck theirSSL*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.
_inputand_outputare private, they are not null afterdispose(), and they point at freed memory, which no null check can detect. Onlydispose()can clear them.The API problem
state()never crashes and never changes.dispose()leaves_statealone, sostate()returnsSSLReadyfor a session that completed a handshake and has since been freed. Drive a handshake between an in-process client and server, dispose the client, andstate()returnsSSLReadyon 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()returnsSSLReady,read()returnsNone,can_send()returnsfalse,receive()does nothing, andwrite()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 refalias across adispose()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 anymatch \exhaustive\over the union, so it belongs in a major release. An additiveis_disposed(): Boolbreaks nothing and is opt-in, which leaves a caller who forgets to check right where they started.