Influencing Python's choice of SSL/TLS cipher-suite

I'm debugging a fault being received by an application that uses the Python [v2.7] SOAP library (module name 'suds'). I want to look inside the SSL datastream, much as I can between SoapUI and the same server. SoapUI, being the typical Java client, doesn't default to a particularly high grade of cryptography, and so with the private key I can first record and then later inspect the cleartext data with Wireshark.

But Python, like many others, seems to default to enabling a lot of more secure cipher-suites that enable perfect-forward-secrecy. In order to snoop on those, you need to get the client to dump out the pre-master key somewhere.This post shows how you can you get Python's SSL module to use a different cipher specification string.

I've met a similar problem before when dealing with a flaky SSL implementation in an LDAP server; I needed to be able to get OpenLDAP client tools to use a particular [set of] ciphers, and was able to isolate those that exhibited a problem (which could have then be disabled). In that case I was able to use an environment variable LDAP_TLS_CIPHER_SUITE (IIRC)...

I needed something similar for Python, if it existed.

First, let's look at the stack at the call-graph of modules involved: suds > urllib2 > httplib > ssl > _ssl

The '_ssl' module is where it loads into the OpenSSL native libraries.

What I'm looking for is likely to exist either at the httplib module layer, or the ssl module layer.

Looking at httplib, we see the following

try:
    import ssl
except ImportError:
    pass
else:
    class HTTPSConnection(HTTPConnection):
        "This class allows communication via SSL."

        default_port = HTTPS_PORT

        def __init__(self, host, port=None, key_file=None, cert_file=None,
                     strict=None, timeout=socket._GLOBAL_DEFAULT_TIMEOUT,
                     source_address=None):
            HTTPConnection.__init__(self, host, port, strict, timeout,
                                    source_address)
            self.key_file = key_file
            self.cert_file = cert_file

        def connect(self):
            "Connect to a host on a given (SSL) port."

            sock = socket.create_connection((self.host, self.port),
                                            self.timeout, self.source_address)
            if self._tunnel_host:
                self.sock = sock
                self._tunnel()
            self.sock = ssl.wrap_socket(sock, self.key_file, self.cert_file)

Hmm, there doesn't appear to expose any mechanism for influencing cipher choice, so let's go down the stack to the ssl module:

# Disable weak or insecure ciphers by default
# (OpenSSL's default setting is 'DEFAULT:!aNULL:!eNULL')
_DEFAULT_CIPHERS = 'DEFAULT:!aNULL:!eNULL:!LOW:!EXPORT:!SSLv2'

class SSLSocket(socket):

    """This class implements a subtype of socket.socket that wraps
    the underlying OS socket in an SSL context when necessary, and
    provides read and write methods over that channel."""

    def __init__(self, sock, keyfile=None, certfile=None,
                 server_side=False, cert_reqs=CERT_NONE,
                 ssl_version=PROTOCOL_SSLv23, ca_certs=None,
                 do_handshake_on_connect=True,
                 suppress_ragged_eofs=True, ciphers=None):
        socket.__init__(self, _sock=sock._sock)

        ...
        for attr in _delegate_methods:
            try:
                delattr(self, attr)
            except AttributeError:
                pass

        if ciphers is None and ssl_version != _SSLv2_IF_EXISTS:
            ciphers = _DEFAULT_CIPHERS

        ...
        else:
            # yes, create the SSL object
            self._connected = True
            self._sslobj = _ssl.sslwrap(self._sock, server_side,
                                        keyfile, certfile,
                                        cert_reqs, ssl_version, ca_certs,
                                        ciphers)

Hmmm, so it seems that the default are entirely specified in the 'ssl' module, are there is no environment variable or similar to control it from outside the process.

So if we want to enable lower-grade crypto, we would need to change the _DEFAULT_CIPHERS (perhaps to match OpenSSL's default), or put in some more code to feed that down the layers all the way from 'suds' to 'ssl'. I suppose some keyword-arguments would be the tidiest way. But as a system administrator, I'd rather have the ability to do this for any [Python] application, not just the ones have considered this fairly minor use case.

I don't want to change _DEFAULT_CIPHERS, because that would be degrading security for all users of the module, which is clearly the wrong thing to do. I'd prefer to have this local to a process, not to a program. I think an environment variable would be the cleanest way for this. Since there doesn't appear to be a standard one, we'll have to invent on. How about the following:

  • 'PYTHON_SSL_CIPHER_SPEC', which would take the usual string of OpenSSL ciphers such as 'DEFAULT:!aNULL:!eNULL:!LOW:!EXPORT:!SSLv2', and
  • 'PYTHON_SSL_VERSION', which would take one of the _PROTOCOL_NAMES values from ssl.py (currently 'TLSv1', 'SSLv23' and 'SSLv3')
So, planning the change, we would need to:
  1. import os
  2. In SSLSocket constructor, add code to see if 'PYTHON_SSL_CIPHER_SPEC' in os.environ, and if so, to set  'ciphers' before the test that sets it to the defaults. Note that the environment variable should, at least in this case, override any explicit setting.... although this is debatable, as if anything can set it, then it will generally have some way of adjusting it... perhaps.
  3. Similarly, look up any provided value for PYTHON_SSL_VERSION using a reverse lookup of the _PROTOCOL_NAMES dictionary

New version:

# At the top of the file
import os
...
 
class SSLSocket(socket):

    """This class implements a subtype of socket.socket that wraps
    the underlying OS socket in an SSL context when necessary, and
    provides read and write methods over that channel."""

    def __init__(self, sock, keyfile=None, certfile=None,
                 server_side=False, cert_reqs=CERT_NONE,
                 ssl_version=PROTOCOL_SSLv23, ca_certs=None,
                 do_handshake_on_connect=True,
                 suppress_ragged_eofs=True, ciphers=None):
        ...

        if 'PYTHON_SSL_CIPHER_SPEC' in os.environ:
            ciphers = os.environ['PYTHON_SSL_CIPHER_SPEC']

        if 'PYTHON_SSL_VERSION' in os.environ:
            matches = [ k for k,v in _PROTOCOL_NAMES.items() if v == os.environ['PYTHON_SSL_VERSION'] ]
            if len(matches) > 0:
                ssl_version = matches[0]

        if ciphers is None and ssl_version != _SSLv2_IF_EXISTS:
            ciphers = _DEFAULT_CIPHERS
        ...

To use it, start a wireshark capture, and run your command with the environment variable in place.

PYTHON_SSL_CIPHER_SPEC='AES128-SHA' python ....

You should see the manual page for 'ciphers'. In this case, I used the same cipher that SoapUI was using (the manual-page has a useful table to turn this into OpenSSL's idea of the cipher.

If you've configured wireshark with the private key, you should now be able to see what's inside.

Right, now let the real debugging begin!...

Comments

Popular posts from this blog

ORA-12170: TNS:Connect timeout — resolved

Getting MySQL server to run with SSL

From DNS Packet Capture to analysis in Kibana