Skip to content

Commit 37d7180

Browse files
ambvgpshead
andauthored
[3.10] gh-108310: Fix CVE-2023-40217: Check for & avoid the ssl pre-close flaw (#108318)
gh-108310: Fix CVE-2023-40217: Check for & avoid the ssl pre-close flaw Instances of `ssl.SSLSocket` were vulnerable to a bypass of the TLS handshake and included protections (like certificate verification) and treating sent unencrypted data as if it were post-handshake TLS encrypted data. The vulnerability is caused when a socket is connected, data is sent by the malicious peer and stored in a buffer, and then the malicious peer closes the socket within a small timing window before the other peers’ TLS handshake can begin. After this sequence of events the closed socket will not immediately attempt a TLS handshake due to not being connected but will also allow the buffered data to be read as if a successful TLS handshake had occurred. Co-authored-by: Gregory P. Smith [Google LLC] <[email protected]>
1 parent 7d44551 commit 37d7180

File tree

3 files changed

+252
-1
lines changed

3 files changed

+252
-1
lines changed

Lib/ssl.py

+30-1
Original file line numberDiff line numberDiff line change
@@ -1033,7 +1033,7 @@ def _create(cls, sock, server_side=False, do_handshake_on_connect=True,
10331033
)
10341034
self = cls.__new__(cls, **kwargs)
10351035
super(SSLSocket, self).__init__(**kwargs)
1036-
self.settimeout(sock.gettimeout())
1036+
sock_timeout = sock.gettimeout()
10371037
sock.detach()
10381038

10391039
self._context = context
@@ -1052,9 +1052,38 @@ def _create(cls, sock, server_side=False, do_handshake_on_connect=True,
10521052
if e.errno != errno.ENOTCONN:
10531053
raise
10541054
connected = False
1055+
blocking = self.getblocking()
1056+
self.setblocking(False)
1057+
try:
1058+
# We are not connected so this is not supposed to block, but
1059+
# testing revealed otherwise on macOS and Windows so we do
1060+
# the non-blocking dance regardless. Our raise when any data
1061+
# is found means consuming the data is harmless.
1062+
notconn_pre_handshake_data = self.recv(1)
1063+
except OSError as e:
1064+
# EINVAL occurs for recv(1) on non-connected on unix sockets.
1065+
if e.errno not in (errno.ENOTCONN, errno.EINVAL):
1066+
raise
1067+
notconn_pre_handshake_data = b''
1068+
self.setblocking(blocking)
1069+
if notconn_pre_handshake_data:
1070+
# This prevents pending data sent to the socket before it was
1071+
# closed from escaping to the caller who could otherwise
1072+
# presume it came through a successful TLS connection.
1073+
reason = "Closed before TLS handshake with data in recv buffer."
1074+
notconn_pre_handshake_data_error = SSLError(e.errno, reason)
1075+
# Add the SSLError attributes that _ssl.c always adds.
1076+
notconn_pre_handshake_data_error.reason = reason
1077+
notconn_pre_handshake_data_error.library = None
1078+
try:
1079+
self.close()
1080+
except OSError:
1081+
pass
1082+
raise notconn_pre_handshake_data_error
10551083
else:
10561084
connected = True
10571085

1086+
self.settimeout(sock_timeout) # Must come after setblocking() calls.
10581087
self._connected = connected
10591088
if connected:
10601089
# create the SSL object

Lib/test/test_ssl.py

+215
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,14 @@
99
from test.support import socket_helper
1010
from test.support import threading_helper
1111
from test.support import warnings_helper
12+
import re
1213
import socket
1314
import select
15+
import struct
1416
import time
1517
import datetime
1618
import gc
19+
import http.client
1720
import os
1821
import errno
1922
import pprint
@@ -4904,6 +4907,218 @@ def sni_cb(sock, servername, ctx):
49044907
s.connect((HOST, server.port))
49054908

49064909

4910+
def set_socket_so_linger_on_with_zero_timeout(sock):
4911+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER, struct.pack('ii', 1, 0))
4912+
4913+
4914+
class TestPreHandshakeClose(unittest.TestCase):
4915+
"""Verify behavior of close sockets with received data before to the handshake.
4916+
"""
4917+
4918+
class SingleConnectionTestServerThread(threading.Thread):
4919+
4920+
def __init__(self, *, name, call_after_accept):
4921+
self.call_after_accept = call_after_accept
4922+
self.received_data = b'' # set by .run()
4923+
self.wrap_error = None # set by .run()
4924+
self.listener = None # set by .start()
4925+
self.port = None # set by .start()
4926+
super().__init__(name=name)
4927+
4928+
def __enter__(self):
4929+
self.start()
4930+
return self
4931+
4932+
def __exit__(self, *args):
4933+
try:
4934+
if self.listener:
4935+
self.listener.close()
4936+
except OSError:
4937+
pass
4938+
self.join()
4939+
self.wrap_error = None # avoid dangling references
4940+
4941+
def start(self):
4942+
self.ssl_ctx = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
4943+
self.ssl_ctx.verify_mode = ssl.CERT_REQUIRED
4944+
self.ssl_ctx.load_verify_locations(cafile=ONLYCERT)
4945+
self.ssl_ctx.load_cert_chain(certfile=ONLYCERT, keyfile=ONLYKEY)
4946+
self.listener = socket.socket()
4947+
self.port = socket_helper.bind_port(self.listener)
4948+
self.listener.settimeout(2.0)
4949+
self.listener.listen(1)
4950+
super().start()
4951+
4952+
def run(self):
4953+
conn, address = self.listener.accept()
4954+
self.listener.close()
4955+
with conn:
4956+
if self.call_after_accept(conn):
4957+
return
4958+
try:
4959+
tls_socket = self.ssl_ctx.wrap_socket(conn, server_side=True)
4960+
except OSError as err: # ssl.SSLError inherits from OSError
4961+
self.wrap_error = err
4962+
else:
4963+
try:
4964+
self.received_data = tls_socket.recv(400)
4965+
except OSError:
4966+
pass # closed, protocol error, etc.
4967+
4968+
def non_linux_skip_if_other_okay_error(self, err):
4969+
if sys.platform == "linux":
4970+
return # Expect the full test setup to always work on Linux.
4971+
if (isinstance(err, ConnectionResetError) or
4972+
(isinstance(err, OSError) and err.errno == errno.EINVAL) or
4973+
re.search('wrong.version.number', getattr(err, "reason", ""), re.I)):
4974+
# On Windows the TCP RST leads to a ConnectionResetError
4975+
# (ECONNRESET) which Linux doesn't appear to surface to userspace.
4976+
# If wrap_socket() winds up on the "if connected:" path and doing
4977+
# the actual wrapping... we get an SSLError from OpenSSL. Typically
4978+
# WRONG_VERSION_NUMBER. While appropriate, neither is the scenario
4979+
# we're specifically trying to test. The way this test is written
4980+
# is known to work on Linux. We'll skip it anywhere else that it
4981+
# does not present as doing so.
4982+
self.skipTest(f"Could not recreate conditions on {sys.platform}:"
4983+
f" {err=}")
4984+
# If maintaining this conditional winds up being a problem.
4985+
# just turn this into an unconditional skip anything but Linux.
4986+
# The important thing is that our CI has the logic covered.
4987+
4988+
def test_preauth_data_to_tls_server(self):
4989+
server_accept_called = threading.Event()
4990+
ready_for_server_wrap_socket = threading.Event()
4991+
4992+
def call_after_accept(unused):
4993+
server_accept_called.set()
4994+
if not ready_for_server_wrap_socket.wait(2.0):
4995+
raise RuntimeError("wrap_socket event never set, test may fail.")
4996+
return False # Tell the server thread to continue.
4997+
4998+
server = self.SingleConnectionTestServerThread(
4999+
call_after_accept=call_after_accept,
5000+
name="preauth_data_to_tls_server")
5001+
server.__enter__() # starts it
5002+
self.addCleanup(server.__exit__) # ... & unittest.TestCase stops it.
5003+
5004+
with socket.socket() as client:
5005+
client.connect(server.listener.getsockname())
5006+
# This forces an immediate connection close via RST on .close().
5007+
set_socket_so_linger_on_with_zero_timeout(client)
5008+
client.setblocking(False)
5009+
5010+
server_accept_called.wait()
5011+
client.send(b"DELETE /data HTTP/1.0\r\n\r\n")
5012+
client.close() # RST
5013+
5014+
ready_for_server_wrap_socket.set()
5015+
server.join()
5016+
wrap_error = server.wrap_error
5017+
self.assertEqual(b"", server.received_data)
5018+
self.assertIsInstance(wrap_error, OSError) # All platforms.
5019+
self.non_linux_skip_if_other_okay_error(wrap_error)
5020+
self.assertIsInstance(wrap_error, ssl.SSLError)
5021+
self.assertIn("before TLS handshake with data", wrap_error.args[1])
5022+
self.assertIn("before TLS handshake with data", wrap_error.reason)
5023+
self.assertNotEqual(0, wrap_error.args[0])
5024+
self.assertIsNone(wrap_error.library, msg="attr must exist")
5025+
5026+
def test_preauth_data_to_tls_client(self):
5027+
client_can_continue_with_wrap_socket = threading.Event()
5028+
5029+
def call_after_accept(conn_to_client):
5030+
# This forces an immediate connection close via RST on .close().
5031+
set_socket_so_linger_on_with_zero_timeout(conn_to_client)
5032+
conn_to_client.send(
5033+
b"HTTP/1.0 307 Temporary Redirect\r\n"
5034+
b"Location: https://example.com/someone-elses-server\r\n"
5035+
b"\r\n")
5036+
conn_to_client.close() # RST
5037+
client_can_continue_with_wrap_socket.set()
5038+
return True # Tell the server to stop.
5039+
5040+
server = self.SingleConnectionTestServerThread(
5041+
call_after_accept=call_after_accept,
5042+
name="preauth_data_to_tls_client")
5043+
server.__enter__() # starts it
5044+
self.addCleanup(server.__exit__) # ... & unittest.TestCase stops it.
5045+
5046+
# Redundant; call_after_accept sets SO_LINGER on the accepted conn.
5047+
set_socket_so_linger_on_with_zero_timeout(server.listener)
5048+
5049+
with socket.socket() as client:
5050+
client.connect(server.listener.getsockname())
5051+
if not client_can_continue_with_wrap_socket.wait(2.0):
5052+
self.fail("test server took too long.")
5053+
ssl_ctx = ssl.create_default_context()
5054+
try:
5055+
tls_client = ssl_ctx.wrap_socket(
5056+
client, server_hostname="localhost")
5057+
except OSError as err: # SSLError inherits from OSError
5058+
wrap_error = err
5059+
received_data = b""
5060+
else:
5061+
wrap_error = None
5062+
received_data = tls_client.recv(400)
5063+
tls_client.close()
5064+
5065+
server.join()
5066+
self.assertEqual(b"", received_data)
5067+
self.assertIsInstance(wrap_error, OSError) # All platforms.
5068+
self.non_linux_skip_if_other_okay_error(wrap_error)
5069+
self.assertIsInstance(wrap_error, ssl.SSLError)
5070+
self.assertIn("before TLS handshake with data", wrap_error.args[1])
5071+
self.assertIn("before TLS handshake with data", wrap_error.reason)
5072+
self.assertNotEqual(0, wrap_error.args[0])
5073+
self.assertIsNone(wrap_error.library, msg="attr must exist")
5074+
5075+
def test_https_client_non_tls_response_ignored(self):
5076+
5077+
server_responding = threading.Event()
5078+
5079+
class SynchronizedHTTPSConnection(http.client.HTTPSConnection):
5080+
def connect(self):
5081+
http.client.HTTPConnection.connect(self)
5082+
# Wait for our fault injection server to have done its thing.
5083+
if not server_responding.wait(1.0) and support.verbose:
5084+
sys.stdout.write("server_responding event never set.")
5085+
self.sock = self._context.wrap_socket(
5086+
self.sock, server_hostname=self.host)
5087+
5088+
def call_after_accept(conn_to_client):
5089+
# This forces an immediate connection close via RST on .close().
5090+
set_socket_so_linger_on_with_zero_timeout(conn_to_client)
5091+
conn_to_client.send(
5092+
b"HTTP/1.0 402 Payment Required\r\n"
5093+
b"\r\n")
5094+
conn_to_client.close() # RST
5095+
server_responding.set()
5096+
return True # Tell the server to stop.
5097+
5098+
server = self.SingleConnectionTestServerThread(
5099+
call_after_accept=call_after_accept,
5100+
name="non_tls_http_RST_responder")
5101+
server.__enter__() # starts it
5102+
self.addCleanup(server.__exit__) # ... & unittest.TestCase stops it.
5103+
# Redundant; call_after_accept sets SO_LINGER on the accepted conn.
5104+
set_socket_so_linger_on_with_zero_timeout(server.listener)
5105+
5106+
connection = SynchronizedHTTPSConnection(
5107+
f"localhost",
5108+
port=server.port,
5109+
context=ssl.create_default_context(),
5110+
timeout=2.0,
5111+
)
5112+
# There are lots of reasons this raises as desired, long before this
5113+
# test was added. Sending the request requires a successful TLS wrapped
5114+
# socket; that fails if the connection is broken. It may seem pointless
5115+
# to test this. It serves as an illustration of something that we never
5116+
# want to happen... properly not happening.
5117+
with self.assertRaises(OSError) as err_ctx:
5118+
connection.request("HEAD", "/test", headers={"Host": "localhost"})
5119+
response = connection.getresponse()
5120+
5121+
49075122
def setUpModule():
49085123
if support.verbose:
49095124
plats = {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
Fixed an issue where instances of :class:`ssl.SSLSocket` were vulnerable to
2+
a bypass of the TLS handshake and included protections (like certificate
3+
verification) and treating sent unencrypted data as if it were
4+
post-handshake TLS encrypted data. Security issue reported as
5+
`CVE-2023-40217
6+
<https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2023-40217>`_ by
7+
Aapo Oksman. Patch by Gregory P. Smith.

0 commit comments

Comments
 (0)