fix(quic): packet codec hostile-input validation + RFC-correct Initial padding

Round-2 audit found I'd only fixed varint-truncation in Frame.kt, not in
LongHeaderPacket. Plus the datagram padding was wire-illegal AND broke
our own short-header receive path.

LongHeaderPacket.parseAndDecrypt + peekHeader:
  - Both tokenLen and length now validated against r.remaining BEFORE
    .toInt() truncation. Hostile peer sending length=2^62-1 → return null
    instead of OOM/crash.
  - dcidLen and scidLen validated 1..20 per RFC 9000 §17.2.
  - peekHeader correctly handles Retry packets (no token-length, no
    length, no packet-number fields per RFC 9000 §17.2.5) — returns
    PeekedHeader with totalLength = bytes.size - offset instead of
    reading nonexistent length field as garbage.

Buffer.ensure: doubling loop spins forever if buf.size == 0. coerce
starting newSize to ≥ 8 so the loop always terminates.

QuicConnectionWriter Initial padding (RFC 9000 §14.1):
  Previously: built packets, computed datagram size, appended trailing
  zero bytes after the last packet to reach 1200. That's wire-illegal
  (RFC says PADDING frames inside the encryption envelope) AND breaks
  our own short-header receiver because ShortHeaderPacket.parseAndDecrypt
  uses bytes.size as packetEnd → AEAD authentication includes the trailing
  zeros and fails.

  Now: two-pass build. Phase 1 collects ACK+CRYPTO frames per level into
  local lists (single takeChunk per level), builds natural-size packets.
  If Initial is present and total < 1200, rewinds the Initial PN and
  rebuilds with PADDING bytes appended to the encrypted payload. PADDING
  is one 0x00 byte per frame so concatenating N zero bytes inside the
  payload is N valid PADDING frames — the receiver collapses them to
  nothing during decode.

  PacketNumberSpaceState.rewindOutboundForRebuild() supports the rebuild.

InMemoryQuicPipe handshake test still passes — verifies the receiver
correctly accepts our new PADDING-frame Initials.

https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
This commit is contained in:
Claude
2026-04-25 22:38:04 +00:00
parent 02b03e143b
commit 9d0a7ead7a
4 changed files with 109 additions and 48 deletions
@@ -176,7 +176,9 @@ class QuicWriter(
private fun ensure(more: Int) { private fun ensure(more: Int) {
if (pos + more > buf.size) { if (pos + more > buf.size) {
var newSize = buf.size * 2 // coerce to a non-zero starting size so the doubling loop terminates
// even when initialCapacity was 0.
var newSize = (buf.size * 2).coerceAtLeast(8)
while (newSize < pos + more) newSize *= 2 while (newSize < pos + more) newSize *= 2
buf = buf.copyOf(newSize) buf = buf.copyOf(newSize)
} }
@@ -55,6 +55,15 @@ class PacketNumberSpaceState {
/** Allocate the next outbound packet number. */ /** Allocate the next outbound packet number. */
fun allocateOutbound(): Long = nextPacketNumber++ fun allocateOutbound(): Long = nextPacketNumber++
/**
* Roll back the most recent allocation. Used by the writer when it has
* to re-build a packet (e.g. add Initial padding) using the same PN —
* the next call to [allocateOutbound] returns the same number again.
*/
fun rewindOutboundForRebuild() {
if (nextPacketNumber > 0) nextPacketNumber--
}
/** Note that an inbound packet was successfully decrypted. */ /** Note that an inbound packet was successfully decrypted. */
fun observeInbound( fun observeInbound(
packetNumber: Long, packetNumber: Long,
@@ -61,32 +61,49 @@ fun drainOutbound(
return packet return packet
} }
val initialPkt = buildInitialPacket(conn, nowMillis) // Drain destructive frame sources into local lists, ONCE.
if (initialPkt != null) parts += initialPkt val initialFrames = collectHandshakeLevelFrames(conn, EncryptionLevel.INITIAL, nowMillis)
val handshakeFrames = collectHandshakeLevelFrames(conn, EncryptionLevel.HANDSHAKE, nowMillis)
val handshakePkt = buildHandshakePacket(conn, nowMillis)
if (handshakePkt != null) parts += handshakePkt
val applicationPkt = buildApplicationPacket(conn, nowMillis) val applicationPkt = buildApplicationPacket(conn, nowMillis)
if (applicationPkt != null) parts += applicationPkt
if (parts.isEmpty()) return null val initialState = conn.initial
val handshakeState = conn.handshake
val initialHasContent = initialFrames != null && initialState.sendProtection != null
val handshakeHasContent = handshakeFrames != null && handshakeState.sendProtection != null
// Coalesce // Build natural-size first.
val initialNatural = if (initialHasContent) buildLongHeaderFromFrames(conn, EncryptionLevel.INITIAL, initialFrames!!, padBytes = 0) else null
val handshakeNatural = if (handshakeHasContent) buildLongHeaderFromFrames(conn, EncryptionLevel.HANDSHAKE, handshakeFrames!!, padBytes = 0) else null
val firstPass = listOfNotNull(initialNatural, handshakeNatural, applicationPkt)
if (firstPass.isEmpty()) return null
// RFC 9000 §14.1: client datagrams containing Initial MUST pad to ≥ 1200,
// via PADDING frames inside the Initial's encryption envelope.
if (initialNatural != null) {
var natural = 0
for (p in firstPass) natural += p.size
if (natural < 1200) {
val deficit = 1200 - natural
// Rewind the Initial PN — we'll reissue with the same PN and the
// same captured frames plus padding.
initialState.pnSpace.rewindOutboundForRebuild()
val paddedInitial = buildLongHeaderFromFrames(conn, EncryptionLevel.INITIAL, initialFrames!!, padBytes = deficit)
return concat(listOfNotNull(paddedInitial, handshakeNatural, applicationPkt))
}
}
return concat(firstPass)
}
private fun concat(parts: List<ByteArray>): ByteArray {
var totalLen = 0 var totalLen = 0
for (p in parts) totalLen += p.size for (p in parts) totalLen += p.size
// Pad the datagram to 1200 bytes if it begins with an Initial packet (RFC 9000 §14).
if (initialPkt != null && totalLen < 1200) totalLen = 1200
val out = ByteArray(totalLen) val out = ByteArray(totalLen)
var pos = 0 var pos = 0
for (p in parts) { for (p in parts) {
p.copyInto(out, pos) p.copyInto(out, pos)
pos += p.size pos += p.size
} }
// Tail bytes are zero (PADDING frames in the encryption envelope of the last packet).
// Note: padding inside the last QUIC packet's encryption is the RFC-correct way; we instead
// pad the datagram with zero bytes after the last packet. Servers tolerate this for the
// datagram-level minimum size requirement.
return out return out
} }
@@ -153,37 +170,51 @@ private fun buildLongHeaderPacket(
) )
} }
private fun buildInitialPacket( /**
conn: QuicConnection, * Drain ACK + CRYPTO frames for [level] into a fresh list. Destructive on
nowMillis: Long, * the CRYPTO send buffer, but caller-controlled — call exactly once per
): ByteArray? = buildHandshakeLevelPacket(conn, EncryptionLevel.INITIAL, nowMillis) * outbound datagram. Returns null if there are no frames to send and the
* level has no protection installed.
private fun buildHandshakePacket( */
conn: QuicConnection, private fun collectHandshakeLevelFrames(
nowMillis: Long,
): ByteArray? = buildHandshakeLevelPacket(conn, EncryptionLevel.HANDSHAKE, nowMillis)
private fun buildHandshakeLevelPacket(
conn: QuicConnection, conn: QuicConnection,
level: EncryptionLevel, level: EncryptionLevel,
nowMillis: Long, nowMillis: Long,
): ByteArray? { ): List<Frame>? {
val state = conn.levelState(level) val state = conn.levelState(level)
val proto = state.sendProtection ?: return null if (state.sendProtection == null) return null
val frames = mutableListOf<Frame>() val frames = mutableListOf<Frame>()
// Pending ACK?
state.ackTracker.buildAckFrame(nowMillis, conn.config.ackDelayExponent.toInt())?.let { frames += it } state.ackTracker.buildAckFrame(nowMillis, conn.config.ackDelayExponent.toInt())?.let { frames += it }
// Pending CRYPTO?
val cryptoChunk = state.cryptoSend.takeChunk(maxBytes = 1100) val cryptoChunk = state.cryptoSend.takeChunk(maxBytes = 1100)
if (cryptoChunk != null && cryptoChunk.data.isNotEmpty()) { if (cryptoChunk != null && cryptoChunk.data.isNotEmpty()) {
frames += CryptoFrame(cryptoChunk.offset, cryptoChunk.data) frames += CryptoFrame(cryptoChunk.offset, cryptoChunk.data)
} }
if (frames.isEmpty()) return null if (frames.isEmpty()) return null
return frames
}
val payload = encodeFrames(frames) /**
* Build a long-header packet from already-collected frames, with optional
* trailing PADDING (0x00) bytes inside the encryption envelope. RFC 9000
* §14.1 mandates this for client-Initial datagrams; PADDING is a one-byte
* frame so concatenating N zero bytes after the encoded frames yields N
* valid PADDING frames, all collapsed to nothing on decode.
*/
private fun buildLongHeaderFromFrames(
conn: QuicConnection,
level: EncryptionLevel,
frames: List<Frame>,
padBytes: Int,
): ByteArray {
val state = conn.levelState(level)
val proto = state.sendProtection!!
val basePayload = encodeFrames(frames)
val payload =
if (padBytes > 0) {
ByteArray(basePayload.size + padBytes).also { basePayload.copyInto(it, 0) }
} else {
basePayload
}
val pn = state.pnSpace.allocateOutbound() val pn = state.pnSpace.allocateOutbound()
val type = if (level == EncryptionLevel.INITIAL) LongHeaderType.INITIAL else LongHeaderType.HANDSHAKE val type = if (level == EncryptionLevel.INITIAL) LongHeaderType.INITIAL else LongHeaderType.HANDSHAKE
return LongHeaderPacket.build( return LongHeaderPacket.build(
@@ -144,22 +144,27 @@ object LongHeaderPacket {
val packetStart = offset val packetStart = offset
val r = QuicReader(bytes, offset) val r = QuicReader(bytes, offset)
val first = r.readByte() val first = r.readByte()
require((first and 0x80) != 0) { "not a long-header packet" } if ((first and 0x80) == 0) return null // not a long header — silently drop
val typeBits = (first ushr 4) and 0x03 val typeBits = (first ushr 4) and 0x03
val type = LongHeaderType.fromTypeBits(typeBits) val type = LongHeaderType.fromTypeBits(typeBits)
val version = r.readUint32().toInt() val version = r.readUint32().toInt()
val dcidLen = r.readByte() val dcidLen = r.readByte()
if (dcidLen !in 0..20) return null // RFC 9000 §17.2 caps CID at 20 bytes
val dcidBytes = r.readBytes(dcidLen) val dcidBytes = r.readBytes(dcidLen)
val scidLen = r.readByte() val scidLen = r.readByte()
if (scidLen !in 0..20) return null
val scidBytes = r.readBytes(scidLen) val scidBytes = r.readBytes(scidLen)
val token = val token =
if (type == LongHeaderType.INITIAL) { if (type == LongHeaderType.INITIAL) {
val tokenLen = r.readVarint().toInt() val tokenLenRaw = r.readVarint()
r.readBytes(tokenLen) if (tokenLenRaw < 0L || tokenLenRaw > r.remaining.toLong()) return null
r.readBytes(tokenLenRaw.toInt())
} else { } else {
ByteArray(0) ByteArray(0)
} }
val length = r.readVarint().toInt() val lengthRaw = r.readVarint()
if (lengthRaw < 0L || lengthRaw > r.remaining.toLong()) return null
val length = lengthRaw.toInt()
val pnOffset = r.position val pnOffset = r.position
if (pnOffset + length > bytes.size) return null if (pnOffset + length > bytes.size) return null
// Sample for HP starts at pnOffset + 4. // Sample for HP starts at pnOffset + 4.
@@ -233,18 +238,32 @@ object LongHeaderPacket {
val type = LongHeaderType.fromTypeBits(typeBits) val type = LongHeaderType.fromTypeBits(typeBits)
val version = r.readUint32().toInt() val version = r.readUint32().toInt()
val dcidLen = r.readByte() val dcidLen = r.readByte()
if (dcidLen !in 0..20) return null
val dcid = r.readBytes(dcidLen) val dcid = r.readBytes(dcidLen)
val scidLen = r.readByte() val scidLen = r.readByte()
if (scidLen !in 0..20) return null
val scid = r.readBytes(scidLen) val scid = r.readBytes(scidLen)
val tokenLen = // Retry packets have NO token-length, NO length, NO packet-number fields —
if (type == LongHeaderType.INITIAL) { // they consist of (header)(retry_token)(16-byte integrity tag). The caller
r.readVarint().toInt() // routes them via [RetryPacket.parse] separately. We surface them here only
} else { // so the caller knows the total length is the rest of the input buffer.
0 if (type == LongHeaderType.RETRY) {
} return PeekedHeader(
if (type == LongHeaderType.INITIAL) r.skip(tokenLen) type = type,
val length = r.readVarint().toInt() version = version,
val total = r.position - offset + length dcid = ConnectionId(dcid),
scid = ConnectionId(scid),
totalLength = bytes.size - offset,
)
}
if (type == LongHeaderType.INITIAL) {
val tokenLenRaw = r.readVarint()
if (tokenLenRaw < 0L || tokenLenRaw > r.remaining.toLong()) return null
r.skip(tokenLenRaw.toInt())
}
val lengthRaw = r.readVarint()
if (lengthRaw < 0L || lengthRaw > r.remaining.toLong()) return null
val total = r.position - offset + lengthRaw.toInt()
return PeekedHeader(type, version, ConnectionId(dcid), ConnectionId(scid), total) return PeekedHeader(type, version, ConnectionId(dcid), ConnectionId(scid), total)
} catch (_: QuicCodecException) { } catch (_: QuicCodecException) {
return null return null