Internet Protocols · Protocol Design

Version Fields, Feature Detection, and Downgrade Semantics

Why a version number in the header is a weak signal, how peers actually establish a common dialect, and what a negotiated downgrade exposes to an attacker.

The intuitive design for versioning is a number in the header. The sender writes the version it is using, the receiver compares it against the versions it supports, and if they match the message is parsed under the matching rules. This is how IPv4 and IPv6 coexist in the same header field, and how TLS and DTLS label their records. It is also a design that fails in several well-documented ways, and the workarounds that protocols have adopted are more instructive than the original idea.

The first problem is that a version number describes the whole message, not the individual features inside it. Two implementations that both claim version 3 can differ in which optional parts of version 3 they implement, and the number gives no way to detect the difference. The second is that the field is visible to anything on the path, so it becomes an ossification point: a middlebox that has learned to expect a particular value will drop messages carrying any other, which converts a version negotiation into a version permission request. The third is that a version field is a downgrade surface, because an attacker who can rewrite it can force both endpoints onto the weakest common version.

What a version number is actually asserting

In IPv4 the version field is the first four bits of the header and its value is 4. In IPv6 it is also the first four bits and the value is 6. The two protocols are distinguished at the link layer by the EtherType, and the version field is a redundant check: a packet with an EtherType of IPv4 and a version field of 6 is malformed and discarded. The field’s job is not negotiation; it is a way for a receiver to confirm that the bytes it received match the framing it expected.

That is the honest use of a version field: a discriminator at the point where framing changes. It is not a capability list, and protocols that try to make it one run into the granularity problem.

TLS 1.2 and earlier used the version field in the record header and in the handshake, and the two could differ: a ClientHello could advertise a maximum version while the record layer ran an older one. TLS 1.3 removed that flexibility by fixing the record version to 0x0303 for all protected records and moving the real version negotiation into an extension called supported_versions, which carries a list. The list is what makes the difference: a peer states everything it can do, and the other side picks one. A single number can only state a maximum, and a maximum is compatible with a peer that supports 1, 3, and 5 but not 4.

The general principle is that a version negotiation should be a set intersection, not a comparison. The set can be encoded as a list of versions, as a bitmask, or as a highest-version-plus-assumptions rule, and the choice determines how much future flexibility the protocol retains. Encoding it as a list is a special case of negotiation in the first flight, where the client states everything it can do and the server selects.

The monotonic assumption and where it breaks

Most version schemes assume that versions are totally ordered and that a later version is a superset of an earlier one. Under that assumption, advertising a maximum is sufficient: the peer picks the lower of the two maxima, and everything the lower version can do is available.

The assumption breaks whenever a version removes a feature or changes a default. TLS 1.3 removed compression, renegotiation, and the RSA key exchange. An implementation that advertises a maximum of 1.3 and receives a peer that only supports 1.2 has to fall back to a version with materially different security properties, not merely fewer features. That is not a superset relationship; it is a different protocol that shares a name.

HTTP versioning broke the assumption in a different way. HTTP/1.1 and HTTP/2 are not ordered as a superset; HTTP/2 removes the ability to pipeline requests on one connection and replaces it with multiplexed streams, and it changes header encoding entirely. The negotiation is not a version number in the message but an Upgrade header in HTTP/1.1 or an ALPN identifier at the TLS layer. Both mechanisms establish the version out of band, before the first byte of HTTP is parsed, which avoids the ambiguity of a version field that could be interpreted under either set of rules.

ALPN is the cleaner of the two. It is a TLS extension carrying a list of protocol identifiers, each a length-prefixed byte string. The client sends the list, the server selects one, and the selection is protected by the handshake transcript, so an attacker cannot alter it without breaking the Finished message. The selection is one value from the client’s list, which makes it a set intersection with no ambiguity about which side chose.

The Upgrade header has the weakness that it is a hop-by-hop mechanism carried in a request, so it can be stripped by an intermediary, and the client’s fallback is to continue with HTTP/1.1. That is a correct fallback and also an invisible one: the client cannot tell whether the upgrade was declined by the server or removed by something in the path.

Downgrade is a rewrite attack, and the defence is in the transcript

Any negotiation that occurs in the clear is a target. If the client offers version 3 and the server would accept version 3, an attacker positioned between them can rewrite the client’s offer to version 1, rewrite the server’s acceptance to version 1, and both endpoints will complete a version 1 handshake believing they negotiated with each other. Everything after that is protected under a weaker scheme, and neither endpoint has any evidence that the downgrade occurred.

TLS 1.3 defends against this with a value in the handshake transcript. The ServerHello carries a random value, and the last eight bytes of that random are overwritten with a fixed constant derived from the negotiated version when the server is willing to negotiate a lower version than the client offered. The client checks for that marker, and the marker is covered by the Finished message’s MAC, so an attacker who rewrites the ServerHello cannot recompute the Finished message without the handshake keys. The client sees a downgrade marker it did not expect, or a Finished message that fails to verify, and aborts.

The design detail worth extracting is that the defence lives inside the authenticated transcript rather than in the version field. A version field in the clear can always be rewritten; a value covered by a MAC cannot. The corollary is that any protocol that negotiates a version before it has keys has a downgrade problem, and the mitigation has to be either a signature over the negotiation or a refusal to negotiate downward.

SSLv2 and SSLv3 interoperability produced exactly this class of attack, and the fix in TLS 1.3 was not to patch the version field but to remove the ability to negotiate below a floor. The floor is enforced by the client refusing to offer old versions, which is a policy decision rather than a protocol mechanism, and it is why the practical defence against downgrade is often configuration rather than code.

Feature detection as an alternative to versioning

A protocol can avoid version numbers entirely by making every capability a separate, independently negotiated item. This is what TLS extensions do: supported_groups, signature_algorithms, record_size_limit, and max_fragment_length are each a separate extension with its own presence or absence. An implementation’s capability set is the set of extensions it sends, and a peer’s capability set is the set it receives. There is no version number to compare, and adding a capability does not require a new version.

The cost is message size and combinatorial state. A ClientHello with twenty extensions is several hundred bytes before any key material, and the number of legal combinations is the product of the individual option sets. Implementations cope by treating extensions as independent where possible and by defining small sets of legal combinations where they are not. The specification has to state which combinations are valid, and that statement is effectively a version definition expressed in a less compact form.

Feature detection also has a failure mode that version numbers do not: a peer can claim a capability it does not implement. The claim is unverifiable at negotiation time, and the first evidence of a problem is a protocol error later in the connection. Version numbers have the same problem in principle, since a version field is also a claim, but the coarser granularity makes it easier to test.

The practical middle ground, used by HTTP for header fields and by many binary protocols for optional features, is a capability advertisement that is separate from the message. The peer states what it supports in a dedicated message, the other side records it, and subsequent messages are interpreted against the recorded set. That is one round trip and one piece of state per peer, and it is the cheapest form of negotiation that survives a middlebox, because the advertisement is a normal message that a middlebox has no reason to strip.

Numbers that constrain the design

Version fields are usually small because they sit in a fixed header. IPv4 and IPv6 use four bits, which allows sixteen values and required a new EtherType to distinguish the protocols anyway. TLS uses two bytes in the record header and two bytes in the handshake, and the handshake field was originally intended as a maximum supported version. HTTP/2’s version is not in the frame at all; it is established by ALPN or by the HTTP/1.1 upgrade mechanism, and the frame header has no version field, which means a future HTTP/3 frame format cannot be distinguished from HTTP/2 at the frame layer. It is distinguished at the transport layer instead, by running over QUIC rather than TCP.

That last example is the strongest argument for versioning at the framing boundary rather than inside the message. When the framing changes, a receiver that has already committed to parsing one framing cannot recover by reading a field inside it. Placing the discriminator at the layer that selects the parser, and protecting it with whatever authentication that layer provides, is more robust than placing it where a middlebox can read and rewrite it.

Where the version must live inside the message, the next best design is to make it a list and to authenticate the selection. Where neither is possible, the fallback is a strict floor: refuse to speak versions below a threshold, and accept that some peers will be unable to connect. That is a policy with a real cost in reachability, and it is the reason old versions persist long after they are known to be weak. The mechanics of retiring a version without breaking the network are covered in retiring a protocol version.

References

  1. RFC 8446: The Transport Layer Security (TLS) Protocol Version 1.3 — IETF
  2. RFC 5246: The Transport Layer Security (TLS) Protocol Version 1.2 — IETF
  3. RFC 9110: HTTP Semantics — IETF
  4. RFC 7540: Hypertext Transfer Protocol Version 2 (HTTP/2) — IETF
  5. RFC 9113: HTTP/2 — IETF