Dissecting OpenDPI (SSL/TLS)

A Short Introduction

Okay, this is going to be sort of demanding. Secure Socket Layer (SSL) and now rather Transport Layer Security (TLS) are at the heart of our current encrypted communication via the Internet. Let’s not get too much into the history here, but consider this: SSL was developed by Netscape in the nineties and version 3.0 was used as the base for the open standard TLS 1.0 via RFC 2246 in 1999. On the wire, the protocol presents itself on top of TCP. (A variation is DTLS, which is used on top of UDP.) The unencrypted header structures are well-defined and thus perfect candidates for DPI, especially for Lightweight Inspection (LI). I’m going to refrain from putting “packet” into the term, because LI may work on top of streams as well, as it is not aware of packets. That makes things easy by unwinding the protocol stack and avoiding packet parsing/manipulation inside the engine itself.

OpenDPI, why dost thou stray?

Let’s take a look at how OpenDPI handles said protocols. The keen eye registers lots and lots of things the code does in over 300 LOC. We won’t go into details, but to summarise:

  • Looks for SSL 2.0/3.0 and TLS 1.0/1.1
  • Tries to follow the handshake
  • Tries to identify “other” protocols by parsing exchanged certificates

Frankly, that’s insanity. Why? Because following the handshake and parsing certificates for strings is very unstable in packets on top of a TCP connection, which can always split packets in strange places. A good flow for DPI would be: simple detection, then TCP reassembly, then event extraction, and only then other protocols can be defined on top of TLS. Remember, unwinding the stack…

A closer look at the SSL detection reveals a few weak spots. To cover one instance, take a look at SSL 2.0 handling:

// SSLv2 Record
if (packet->payload[2] == 0x01 && packet->payload[3] == 0x03 &&
    (packet->payload[4] == 0x00 || packet->payload[4] == 0x01 || packet->payload[4] == 0x02) &&
    (packet->payload_packet_len - packet->payload[1] == 2)) {

An SSL 2.0 record is padded with two bytes, which indicate the upcoming record length. That’s why 0x01 (old-school ClientHello) is matched against byte number three here. However, the specification also states that the two bytes may indicate a following padding byte, so the detection will simply fail in such a case! What’s even more weird is that this code then tries to match SSL 3.0 (0x03 0x00), TLS 1.0 (0x03 0x01) and 1.1 (0x03 0x02). The last check very bluntly tries to verify that the length advertised is really two bytes short, so the extra header fits right in. But only one byte is pulled in — this mostly works for network byte order. ;) Also, the header could have been 3 bytes long, but this will never fail, because the detection fails earlier as noted above.

Let’s Break It Down

The code could use a lot of refactoring and the handshake follow and certificate name extraction needs to move somewhere else, so that it’s not prone to TCP’s oddities. My current approach doesn’t account for SSL 2.0, mostly because I haven’t got any traces of it, so I can’t verify yet. I exchanged a few emails with Shane Alcock (Author of libprotoident over at WAND), and he said the following: “Yeah, we just don’t see enough SSL 2.0 in our test traffic for the record wrapper to be a noticeable factor. I only tend to add rules for things that I can observe in packet traces — I learned fairly early on that there can be pretty big differences between protocol specifications and the resulting implementations. […] So yes, SSLv2 is out there but only in very small amounts. Ultimately it comes down to where you want to fall on the accuracy vs. effort scale — it doesn’t take a huge amount of effort to get to 90% accuracy, but that last 10% is a battle of sharply diminishing returns. Not including it means you might miss a handful of flows, but not enough to have a noticeable effect on your accuracy.

LI_DESCRIBE_APP(tls)
{
	struct tls {
		uint8_t record_type;
		uint16_t version;
		uint16_t data_length;
	} __packed *ptr = (void *)packet->app.raw;
	uint16_t decoded;

	if (packet->app_len < sizeof(struct tls)) {
		return (0);
	}

	decoded = be16dec(&ptr->data_length);

	if (!decoded || decoded > 0x4000) {
		/* no empty records possible, also <= 2^14 */
		return (0);
	}

	switch (ptr->record_type) {
	case 20:	/* change_cipher_spec */
	case 21:	/* alert */
	case 22:	/* handshake */
	case 23:	/* application_data */
		break;
	default:
		return (0);
	}

	switch (be16dec(&ptr->version)) {
	case 0x0300:	/* SSL 3.0 */
	case 0x0301:	/* TLS 1.0 */
	case 0x0302:	/* TLS 1.1 */
	case 0x0303:	/* TLS 1.2 */
		break;
	default:
		return (0);
	}

	return (1);
}

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.