Metasploit Deep Dive Part 4: Meterpreter's TLV Wire Protocol, From Real Protocol Source

This site has spent a lot of time reading raw packets: rst-forensics classifies TCP resets by TTL and window fingerprints, tcpdump-deep-dive reads BPF filters against real captures. Meterpreter’s own protocol deserves the same treatment, and unlike most of what’s been read on the wire on this site so far, this one comes straight from its own source rather than a capture, because there’s no live session in this part of the series yet. lib/rex/post/meterpreter/packet.rb in the real framework clone is unambiguous about the format, so this post builds the protocol description directly from that file.

Packet types

Five packet types exist, defined as plain integer constants:

PACKET_TYPE_REQUEST         = 0
PACKET_TYPE_RESPONSE        = 1
PACKET_TYPE_CONFIG          = 2
PACKET_TYPE_PLAIN_REQUEST   = 10
PACKET_TYPE_PLAIN_RESPONSE  = 11

REQUEST and RESPONSE are the normal request/reply pairs that make up the bulk of a session, every meterpreter command (ls, getuid, sysinfo) sends a REQUEST and gets back a matching RESPONSE. CONFIG carries session and transport configuration, not user commands. The PLAIN_ variants exist for packets sent before encryption is negotiated, covered below.

TLV: type, length, value, with a twist

Meterpreter’s protocol is TLV-encoded, but the “type” field does more than tag the data. It’s a bitmask combining a meta-type with a numeric ID:

TLV_META_TYPE_STRING        = (1 << 16)
TLV_META_TYPE_UINT          = (1 << 17)
TLV_META_TYPE_RAW           = (1 << 18)
TLV_META_TYPE_BOOL          = (1 << 19)
TLV_META_TYPE_QWORD         = (1 << 20)
TLV_META_TYPE_COMPRESSED    = (1 << 29)
TLV_META_TYPE_GROUP         = (1 << 30)
TLV_META_TYPE_COMPLEX       = (1 << 31)

An actual TLV type is one of these meta-types OR’d with a small numeric ID, for example:

TLV_TYPE_COMMAND_ID  = TLV_META_TYPE_UINT   |   1
TLV_TYPE_STRING      = TLV_META_TYPE_STRING |  10
TLV_TYPE_CHANNEL_ID  = TLV_META_TYPE_UINT   |  50

That design means a parser can determine the value’s shape (string, integer, raw bytes, boolean, group) directly from the type field without a separate lookup table, and it means the numeric ID space (the low bits) can be reused across different meta-types without collision. TLV_TYPE_STRING and a hypothetical TLV_TYPE_UINT sharing the same low bits 10 would never collide, because their meta-type bits differ.

TLV_META_TYPE_GROUP is what makes the format properly nested rather than flat: a group TLV’s value is itself a sequence of TLVs, which is how meterpreter represents structured data like an exception (TLV_TYPE_EXCEPTION, containing TLV_TYPE_EXCEPTION_CODE and TLV_TYPE_EXCEPTION_STRING as children) or, as of Metasploit 6.5, an entire C2 transport configuration.

The C2 group, new in 6.5

The real source has a block of TLV types that didn’t exist in older meterpreter builds, all falling under a TLV_TYPE_C2 group:

TLV_TYPE_C2                    = TLV_META_TYPE_GROUP  | 704
TLV_TYPE_C2_COMM_TIMEOUT       = TLV_META_TYPE_UINT   | 705
TLV_TYPE_C2_RETRY_TOTAL        = TLV_META_TYPE_UINT   | 706
TLV_TYPE_C2_RETRY_WAIT         = TLV_META_TYPE_UINT   | 707
TLV_TYPE_C2_URL                = TLV_META_TYPE_STRING | 708
TLV_TYPE_C2_URI                = TLV_META_TYPE_STRING | 709
TLV_TYPE_C2_PROXY_URL          = TLV_META_TYPE_STRING | 710
TLV_TYPE_C2_HEADERS            = TLV_META_TYPE_STRING | 715

This is the wire-level plumbing behind Malleable C2 profiles, the headline feature of Metasploit 6.5 covered in Part 1: a way to reshape meterpreter’s HTTP(S) traffic to look like something else (Rapid7’s own release notes demonstrate a profile that makes a Meterpreter session’s traffic resemble someone browsing Amazon). The TLV group carrying C2_URL, C2_URI, and C2_HEADERS as structured fields, rather than the older approach of baking transport config directly into the stager, is what lets that profile be swapped without regenerating the payload from scratch.

The packet header and the encryption layered on top

The Packet class defines a fixed header structure:

XOR_KEY_SIZE          = 4
GUID_SIZE             = 16
ENCRYPTED_FLAGS_SIZE  = 4
PACKET_LENGTH_SIZE    = 4
PACKET_TYPE_SIZE      = 4
PACKET_HEADER_SIZE = XOR_KEY_SIZE + GUID_SIZE + ENCRYPTED_FLAGS_SIZE + PACKET_LENGTH_SIZE + PACKET_TYPE_SIZE

That’s a 32-byte header: a 4-byte XOR key, a 16-byte session GUID, a 4-byte encryption-flags field, a 4-byte length, and a 4-byte type. The XOR obfuscation is applied to the entire header on every packet, using a randomly generated 4-byte key sent as the first four bytes, deliberately weak obfuscation, not real cryptography, whose only job is to keep the header from presenting as an obviously fixed, greppable byte pattern on the wire. The GUID identifies which session the packet belongs to, which matters once a single meterpreter instance can be multiplexing several transports or channels at once.

Real encryption is separate, and layered on the payload, not the header:

ENC_FLAG_AES256 = 0x1
ENC_FLAG_AES128 = 0x2

aes_encrypt builds an OpenSSL::Cipher.new("AES-#{size}-CBC") instance, generates an IV, and encrypts the TLV data (everything after the fixed header fields) before it’s ever put on the wire. to_r, the method that serializes a packet to raw bytes, checks the encryption flag and, if AES128 or AES256 is negotiated, encrypts before packing: key[:type], iv.length + ciphertext.length + HEADER_SIZE, self.type, iv, ciphertext. PACKET_TYPE_CONFIG packets are explicitly excluded from this encryption path, since CONFIG packets are part of how encryption itself gets negotiated in the first place, and the PLAIN_REQUEST/PLAIN_RESPONSE packet types exist specifically for that pre-negotiation window.

Why the two-layer scheme

Splitting weak, mandatory XOR obfuscation on the header from strong, negotiated AES encryption on the payload is a pragmatic design, not a security compromise. The header has to be parseable before a session key exists (you need the length field to know how many more bytes to read off the socket), so it can’t depend on a key that hasn’t been exchanged yet. The payload, everything containing actual command output, file contents, or credentials, only needs to move once the session is established and a real key is in place. XOR-then-AES is the shape that constraint produces: cheap obfuscation for the bootstrapping layer, real encryption for everything that actually matters.

Part 5 follows this same session from the other direction: not what’s inside the packets, but how the initial connection that carries them gets established in the first place, across the eleven-plus transport handlers the real framework ships.