[GH-ISSUE #186] device: with RandomTrailers on, H1-H3 range width silently drops transport packets #88

Open
opened 2026-10-13 21:03:28 +00:00 by bsakaguchi · 9 comments
Owner

Originally created by @Jkaotlic on GitHub (Aug 25, 2026).
Original GitHub issue: https://github.com/amnezia-vpn/amneziawg-go/issues/186

Affected upstream source

  • Repository: https://github.com/amnezia-vpn/amneziawg-go
  • Version: v3.1.20260812
  • Immutable revision: 1f50ad736ecca22a9bfc7b4606805ec9ca49fe48
  • Introduced by 1f50ad7 ("feat: awg 3.1 features") — the same commit as #178.

Summary

DeterminePacketTypeAndPadding decides what a datagram is by trying init, then
response, then cookie, and only then transport. The first three branches are
guarded by the same condition, at device/receive.go:617, :628 and :639:

if size == expectedSize || randomTrailers && size > expectedSize {
    applyHash(headerBytes[:], packet[padding:padding+4], typeHash)
    if header.Contains(binary.LittleEndian.Uint32(headerBytes[:])) {
        return MessageInitiationSize, MessageInitiationType, padding
    }
}

With RandomTrailers off, size == expectedSize keeps transport packets out of
these three branches almost entirely. With it on, the second disjunct admits
every transport packet larger than S1 + MessageInitiationSize, and the whole
decision falls on header.Contains.

The four bytes it looks at sit at offsets S1 / S2 / S3. In a transport
packet those hold either the random S4 prefix (rand.Read in device/send.go)
or ciphertext — uniform in both cases, and applyHash is an XOR with a constant,
so uniform on the way out too. UintRange.Contains is lo <= num && num <= hi.

So each of H1, H2, H3 claims a passing transport packet with probability
width / 2³². A claimed packet goes to the handshake queue and dies at
CheckMAC1 (device/receive.go:365), which logs at Verbosef and increments
nothing.

Per-direction loss is 1 − Π(1 − Wᵢ/2³²) over the three ranges, where
W = Hi − Lo + 1. H4 is not part of it: the transport branch runs last and no
other branch reads it, so its width is free.

The failure mode is what makes this expensive to find. The packet was already
accepted by the kernel and is lost in userspace, so /proc/net/udp shows zero
drops, Udp RcvbufErrors stays zero, CPU does not move, there is no
fragmentation, and at default verbosity the log is silent. Every ordinary
health indicator says the server is fine.

Safe reproduction

No keys, no addresses, no configuration. This is the classification arithmetic
on its own, with real range values and the same uniform input a transport packet
presents. Each branch reads a different offset, hence an independent draw per
branch.

package main

import (
	"crypto/rand"
	"encoding/binary"
	"fmt"
)

type uintRange struct{ lo, hi uint32 }

func (r uintRange) Contains(v uint32) bool { return r.lo <= v && v <= r.hi }

func main() {
	// Ranges taken from a production interface.
	h := []uintRange{
		{405138553, 456138212},   // H1, width  50_999_660
		{680931238, 1038459501},  // H2, width 357_528_264
		{1114423399, 1432068193}, // H3, width 317_644_795
	}

	survive := 1.0
	for _, r := range h {
		survive *= 1 - float64(uint64(r.hi-r.lo)+1)/(1<<32)
	}

	const n = 2_000_000
	var b [4]byte
	lost := 0
	for i := 0; i < n; i++ {
		for _, r := range h {
			rand.Read(b[:])
			if r.Contains(binary.LittleEndian.Uint32(b[:])) {
				lost++ // classified as a handshake, dies at CheckMAC1
				break
			}
		}
	}

	fmt.Printf("expected %.3f%%   measured %.3f%%\n",
		100*(1-survive), 100*float64(lost)/n)
}

Observed on go1.26.2:

expected 16.113%   measured 16.082%

Confirmed on a live tunnel

Same three ranges, RandomTrailers = on, on a server running this device.
Ping through the tunnel, 64-byte packets, 100 probes; nothing changed between
the two runs except the width of H1–H3:

loss
widths 51.0M / 357.5M / 317.6M 25 %
widths narrowed to 25 000 each 0 % (100 of 100)

Packets that short never reach the H1 branch, so the prediction for them uses
H2 and H3 only: 15.1 % one way, 1 − (1 − 0.151)² = 27.9 % for a round
trip. 25 % on 100 probes sits inside sampling error.

Throughput recovered accordingly; nothing else about the interface changed.

Reachability

Any device with RandomTrailers = on and H1–H3 given as ranges. Both are
documented, supported configuration — 3.1 for the flag, 2.0 for ranged headers.
No attacker involvement: the loss is inflicted by the receiver on its own peers'
ordinary data packets, and it scales with how wide the operator made the ranges.

The values above came from an automatic generator that spread the ranges across
the 32-bit space without knowing width had a cost. That is an easy shape for a
generator to land on, and nothing in the device, the tools or the README warns
against it.

Suggested fixes

Three options, in increasing order of how much they change:

1. Refuse the combination in UAPI. device/uapi.go already rejects configs
that cannot work — the S%d must be more then %d to use headerProtection check
is the precedent. The same could apply when random_trailers is on and the
summed width of H1–H3 crosses a threshold. A loud error at setup is
enormously better than silent loss in production. This does not help interfaces
already running, but it stops new ones.

2. Try the transport branch first. Nearly a code move, and it takes the
transport loss to zero. The cost is symmetric though: handshake packets would
then be tested against H4 first and misclassified with probability
W4 / 2³². Since transport packets outnumber handshakes by many orders of
magnitude that is a large net win, but it does turn a wide H4 — free today —
into the expensive one, so it is a reweighting rather than a fix.

3. Do not commit to the first match. The real repair: when a packet claimed
by a handshake branch fails CheckMAC1 and also satisfies the transport
branch, fall through and try it as transport instead of dropping it. It costs
one extra classification attempt on a path that is already failing, and it
removes the width-versus-loss trade-off entirely.

Independently of which is chosen, it would be worth a line in the README: with
RandomTrailers on, the width of H1–H3 is not free.

Notes

Found while chasing a 25 % loss on a production tunnel where every server-side
counter read zero. Happy to open a PR for option 1 or 2 if either looks right to
you, and to supply the fuller measurements — including the loss curve against
packet size — if useful.

Filed a related note with the Any-Tech-ARCHITECT generator project
(Vadim-Khristenko/Any-Tech-ARCHITECT#14), since a config generator is where most
people will meet this; its own ranges are narrow enough not to be affected.

Originally created by @Jkaotlic on GitHub (Aug 25, 2026). Original GitHub issue: https://github.com/amnezia-vpn/amneziawg-go/issues/186 ## Affected upstream source - Repository: `https://github.com/amnezia-vpn/amneziawg-go` - Version: `v3.1.20260812` - Immutable revision: `1f50ad736ecca22a9bfc7b4606805ec9ca49fe48` - Introduced by `1f50ad7` ("feat: awg 3.1 features") — the same commit as #178. ## Summary `DeterminePacketTypeAndPadding` decides what a datagram is by trying init, then response, then cookie, and only then transport. The first three branches are guarded by the same condition, at `device/receive.go:617`, `:628` and `:639`: ```go if size == expectedSize || randomTrailers && size > expectedSize { applyHash(headerBytes[:], packet[padding:padding+4], typeHash) if header.Contains(binary.LittleEndian.Uint32(headerBytes[:])) { return MessageInitiationSize, MessageInitiationType, padding } } ``` With `RandomTrailers` off, `size == expectedSize` keeps transport packets out of these three branches almost entirely. With it on, the second disjunct admits every transport packet larger than `S1 + MessageInitiationSize`, and the whole decision falls on `header.Contains`. The four bytes it looks at sit at offsets `S1` / `S2` / `S3`. In a transport packet those hold either the random `S4` prefix (`rand.Read` in `device/send.go`) or ciphertext — uniform in both cases, and `applyHash` is an XOR with a constant, so uniform on the way out too. `UintRange.Contains` is `lo <= num && num <= hi`. So each of `H1`, `H2`, `H3` claims a passing transport packet with probability `width / 2³²`. A claimed packet goes to the handshake queue and dies at `CheckMAC1` (`device/receive.go:365`), which logs at `Verbosef` and increments nothing. Per-direction loss is `1 − Π(1 − Wᵢ/2³²)` over the three ranges, where `W = Hi − Lo + 1`. `H4` is not part of it: the transport branch runs last and no other branch reads it, so its width is free. The failure mode is what makes this expensive to find. The packet was already accepted by the kernel and is lost in userspace, so `/proc/net/udp` shows zero drops, `Udp RcvbufErrors` stays zero, CPU does not move, there is no fragmentation, and at default verbosity the log is silent. Every ordinary health indicator says the server is fine. ## Safe reproduction No keys, no addresses, no configuration. This is the classification arithmetic on its own, with real range values and the same uniform input a transport packet presents. Each branch reads a different offset, hence an independent draw per branch. ```go package main import ( "crypto/rand" "encoding/binary" "fmt" ) type uintRange struct{ lo, hi uint32 } func (r uintRange) Contains(v uint32) bool { return r.lo <= v && v <= r.hi } func main() { // Ranges taken from a production interface. h := []uintRange{ {405138553, 456138212}, // H1, width 50_999_660 {680931238, 1038459501}, // H2, width 357_528_264 {1114423399, 1432068193}, // H3, width 317_644_795 } survive := 1.0 for _, r := range h { survive *= 1 - float64(uint64(r.hi-r.lo)+1)/(1<<32) } const n = 2_000_000 var b [4]byte lost := 0 for i := 0; i < n; i++ { for _, r := range h { rand.Read(b[:]) if r.Contains(binary.LittleEndian.Uint32(b[:])) { lost++ // classified as a handshake, dies at CheckMAC1 break } } } fmt.Printf("expected %.3f%% measured %.3f%%\n", 100*(1-survive), 100*float64(lost)/n) } ``` Observed on `go1.26.2`: ``` expected 16.113% measured 16.082% ``` ## Confirmed on a live tunnel Same three ranges, `RandomTrailers = on`, on a server running this device. Ping through the tunnel, 64-byte packets, 100 probes; nothing changed between the two runs except the width of `H1`–`H3`: | | loss | |---|---| | widths 51.0M / 357.5M / 317.6M | **25 %** | | widths narrowed to 25 000 each | **0 %** (100 of 100) | Packets that short never reach the `H1` branch, so the prediction for them uses `H2` and `H3` only: 15.1 % one way, `1 − (1 − 0.151)² = 27.9 %` for a round trip. 25 % on 100 probes sits inside sampling error. Throughput recovered accordingly; nothing else about the interface changed. ## Reachability Any device with `RandomTrailers = on` and `H1`–`H3` given as ranges. Both are documented, supported configuration — 3.1 for the flag, 2.0 for ranged headers. No attacker involvement: the loss is inflicted by the receiver on its own peers' ordinary data packets, and it scales with how wide the operator made the ranges. The values above came from an automatic generator that spread the ranges across the 32-bit space without knowing width had a cost. That is an easy shape for a generator to land on, and nothing in the device, the tools or the README warns against it. ## Suggested fixes Three options, in increasing order of how much they change: **1. Refuse the combination in UAPI.** `device/uapi.go` already rejects configs that cannot work — the `S%d must be more then %d to use headerProtection` check is the precedent. The same could apply when `random_trailers` is on and the summed width of `H1`–`H3` crosses a threshold. A loud error at setup is enormously better than silent loss in production. This does not help interfaces already running, but it stops new ones. **2. Try the transport branch first.** Nearly a code move, and it takes the transport loss to zero. The cost is symmetric though: handshake packets would then be tested against `H4` first and misclassified with probability `W4 / 2³²`. Since transport packets outnumber handshakes by many orders of magnitude that is a large net win, but it does turn a wide `H4` — free today — into the expensive one, so it is a reweighting rather than a fix. **3. Do not commit to the first match.** The real repair: when a packet claimed by a handshake branch fails `CheckMAC1` and also satisfies the transport branch, fall through and try it as transport instead of dropping it. It costs one extra classification attempt on a path that is already failing, and it removes the width-versus-loss trade-off entirely. Independently of which is chosen, it would be worth a line in the README: with `RandomTrailers` on, the width of `H1`–`H3` is not free. ## Notes Found while chasing a 25 % loss on a production tunnel where every server-side counter read zero. Happy to open a PR for option 1 or 2 if either looks right to you, and to supply the fuller measurements — including the loss curve against packet size — if useful. Filed a related note with the Any-Tech-ARCHITECT generator project (Vadim-Khristenko/Any-Tech-ARCHITECT#14), since a config generator is where most people will meet this; its own ranges are narrow enough not to be affected.
Author
Owner

@Jkaotlic commented on GitHub (Aug 27, 2026):

Still present on v3.1.20260814. DeterminePacketTypeAndPadding is unchanged: the init, response and cookie branches still share the guard size == expectedSize || randomTrailers && size > expectedSize, and the transport branch is still tried last. The .20260813 / .20260814 fixes touched the HandshakeCookie buffer size and the trailer length on cookie replies, not the classifier.

Two things I did not know when filing this. Both widen the impact rather than change the mechanism.

1. The shipped defaults pair ContentPaddingAddition with RandomTrailers

From amnezia-client, client/core/utils/constants/protocolConstants.h on dev:

constexpr char defaultContentPaddingAddition[] = "10-100";
constexpr char defaultRandomTrailers[]         = "on";
constexpr char defaultDisableCookies[]         = "on";

client/server_scripts/awg/template.conf and client/server_scripts/awg/configure_container.sh both emit all three together, so this is the out-of-the-box 3.1 configuration, not an unusual one.

It matters here because ContentPaddingAddition appends 10–100 bytes to the plaintext before encryption, so every transport packet is that much larger. Packets that would previously have sat below padding + MessageInitiationSize — and therefore never reached the H1 branch at all — now cross it. The padding does not change the per-branch probability, but it enlarges the set of packets subject to it, and it does so by default. So the exposure is not limited to operators who chose wide ranges deliberately; the default client configuration pushes more traffic into the affected path.

2. The relaxed size check looks incidental rather than intended

RandomTrailers is consistently described as appending a trailer to handshake packets only — initiation, response and cookie reply. Transport packets are not mentioned in the release notes, the documentation, or third-party references. If the feature is not meant to alter transport framing, then transport packets being admitted into the three handshake branches is a side effect of the shared guard rather than intended behaviour.

That seems to me an argument for suggested fix 3 (on CheckMAC1 failure, fall through and retry the packet as transport): it restores the separation the feature appears to intend, rather than reweighting which header's width is the expensive one, and it leaves operators free to choose H1–H4 on obfuscation grounds alone.

Happy to put together a PR for either this or option 1 if one of them looks preferable.

<!-- gh-comment-id:5443036890 --> @Jkaotlic commented on GitHub (Aug 27, 2026): **Still present on `v3.1.20260814`.** `DeterminePacketTypeAndPadding` is unchanged: the init, response and cookie branches still share the guard `size == expectedSize || randomTrailers && size > expectedSize`, and the transport branch is still tried last. The `.20260813` / `.20260814` fixes touched the HandshakeCookie buffer size and the trailer length on cookie replies, not the classifier. Two things I did not know when filing this. Both widen the impact rather than change the mechanism. ### 1. The shipped defaults pair `ContentPaddingAddition` with `RandomTrailers` From `amnezia-client`, `client/core/utils/constants/protocolConstants.h` on `dev`: ```cpp constexpr char defaultContentPaddingAddition[] = "10-100"; constexpr char defaultRandomTrailers[] = "on"; constexpr char defaultDisableCookies[] = "on"; ``` `client/server_scripts/awg/template.conf` and `client/server_scripts/awg/configure_container.sh` both emit all three together, so this is the out-of-the-box 3.1 configuration, not an unusual one. It matters here because `ContentPaddingAddition` appends 10–100 bytes to the plaintext before encryption, so every transport packet is that much larger. Packets that would previously have sat below `padding + MessageInitiationSize` — and therefore never reached the `H1` branch at all — now cross it. The padding does not change the per-branch probability, but it enlarges the set of packets subject to it, and it does so by default. So the exposure is not limited to operators who chose wide ranges deliberately; the default client configuration pushes more traffic into the affected path. ### 2. The relaxed size check looks incidental rather than intended `RandomTrailers` is consistently described as appending a trailer to **handshake** packets only — initiation, response and cookie reply. Transport packets are not mentioned in the release notes, the documentation, or third-party references. If the feature is not meant to alter transport framing, then transport packets being admitted into the three handshake branches is a side effect of the shared guard rather than intended behaviour. That seems to me an argument for suggested fix 3 (on `CheckMAC1` failure, fall through and retry the packet as transport): it restores the separation the feature appears to intend, rather than reweighting which header's width is the expensive one, and it leaves operators free to choose `H1`–`H4` on obfuscation grounds alone. Happy to put together a PR for either this or option 1 if one of them looks preferable.
Author
Owner

@bivlked commented on GitHub (Aug 28, 2026):

Your analysis of the Go path is right, so I will not restate it. I hit the same thing independently two days after you filed, and I have three things to add: the kernel module carries the same defect, the per-branch probability is not always width / 2³², and I can put a number on how often the bad shape occurs.

Still present in today's releases. amneziawg-go v3.1.20260828 (identical to master at the time of writing): the three guards are at device/receive.go:618, :629, :640, the transport branch is still last at :651 with if size >= expectedSize. The kernel module v3.1.20260828 carries the same three guards. Only the line numbers have moved since your .20260814 check.

The kernel module has the same defect, and my measurements are from there, not from Go. amneziawg-linux-kernel-module, src/receive.c, awg_determine_type_and_padding, guards at lines 51, 62 and 73:

if ((random_trailers ? skb->len >= expected_len : skb->len == expected_len) &&
    (ptr = skb_header_pointer(skb, padding, sizeof(buf), buf)) != NULL &&
    u32_range_contains(wg->init_header,
        le32_to_cpu(awg_decoded_type(ptr, hash)))) {

Same branch order, same collapse of the length test, and the transport branch last with its own skb->len >= expected_len. I compared the two classifier functions specifically; I have not compared what happens after classification, so I would not claim the two code paths are identical beyond that. Filed separately as amnezia-vpn/amneziawg-linux-kernel-module#226, since the repositories are maintained apart.

Two of my own servers, both endpoints on the module 3.1.20260812, no amneziawg-go process anywhere, RandomTrailers on: 0.12 MB/s and complete ICMP loss between them. With the flag off the link is normal.

The four bytes a branch reads are not always uniform, and that changes the model. You wrote that they hold either the random S4 prefix or ciphertext. Sometimes they hold neither. A transport packet is laid out as

[ S4 bytes of junk ][ 04 00 00 00 ][ key_idx 4 ][ counter LE64 8 ][ ciphertext ]
0                 S4             S4+4         S4+8              S4+16

and the value is assembled little-endian, so the byte at offset +3 of the window decides the top of the range. message_header is a __le32 type whose comment in messages.h spells out that it is u8 type plus u8 reserved_zero[3], so for a data packet those four bytes are the constant 04 00 00 00; the high bytes of the __le64 counter are constant zero in practice as well. When the window's high byte lands on any of those, applyHash XORs a constant with a constant and the value is confined to a fixed 2²⁴ band. That branch then either never matches or matches almost every packet, and its range width is nearly irrelevant.

This is not a nitpick, it resolved a set of results I could not otherwise explain. On one production parameter set, H3 is 3.54 % wide and should have cost around 7 % round-trip; it cost 0.0 %, and it still cost 0.0 % when I widened it to half the value space. Its window overlaps the type field. H2 read zero as well, but for a duller reason: its branch has a higher size threshold than my ping packets reached, so it never ran on that traffic at all. H1 tracked your formula precisely because its window sits on key_idx and the low counter bytes. Working the layout through for that parameter set predicts 12.39 % round-trip loss against 12.0 % measured.

The practical consequence runs against the obvious advice: narrowing the ranges is not a reliable mitigation. A narrow range that happens to sit on the fixed band costs close to 100 % rather than a few percent, and the tunnel then looks dead rather than slow. If anyone reports 3.1 as completely broken while the handshake succeeds, that is a candidate explanation.

Width against loss, with width as the controlled variable. Same interface, both ends on the same configuration, five widths spanning five orders of magnitude, ping through the tunnel:

H1 width, share of 2³² predicted 1-(1-w)² measured
0.00002 % ~0 % 0.0 %
1.00 % 1.99 % 1.5 %
6.40 % 12.39 % 12.0 %
50.0 % 75.0 % 75.0 and 78.0 % in two runs
97.8 % 99.95 % 100 %

The round-trip form assumes the same width at both ends, which held here. The monotonicity across the series is also what tells me the configuration really was being applied at each step.

How often the bad shape occurs. You wrote that spreading ranges across the 32-bit space is an easy thing for a generator to land on. Here is a measured distribution for one such generator, which draws eight points from [0, 2³¹), sorts them and pairs them, with S1 and S2 from 20..120, S3 from 10..60 and S4 from 8..30. 100,000 draws, evaluated with the layout rule above, one-way loss on full-size transport packets. With RandomTrailers enabled, about 88 % of the range sets this generator produces would lose more than 5 % of their traffic, 22 % would lose more than 20 %, and between 2 and 3 % would lose more than half of everything they carry; the generator itself does not enable RandomTrailers, so these are properties of the range sets, not of deployed installs.

quantile one-way loss
5 % 2.5 %
median 13.4 %
95 % 29 %
99 % 100 %

Mean 15.8 %. The 99th percentile is the locked-branch case: about a third of sets have at least one branch whose window sits on a constant high byte, and that is where the heavy tail comes from.

I implemented this twice, each time from the code rather than from the other implementation, and the two agree: mean 15.8 % in both, median 13.3 % against 13.5 %, share of sets losing more than half 2.7 % against 2.2 %, and the same 17.0 % of sets with overlapping windows. The last one is the reassuring part, since two implementations sharing one mistake could still agree on a mean. The assumptions that matter, since this is where a recomputation will diverge: key_idx is constant for the life of a keypair, so a window whose high byte lands there is locked exactly as a window on the type field is. Treating it as varying instead drops the share of locked branches from roughly a third of all sets to roughly a quarter, and thins the tail with it. The transport padding is not a concern - send.c fills it with get_random_bytes on every packet. Session length matters far less than I expected: sampling packets across sessions from 512 up to 4e9 packets moves the mean by 0.03 percentage points and the share above 50 % from 2.8 % to 2.2 %. So the mean and the median are the stable figures; the tail should be read as approximate.

Two notes for anyone reproducing this. With GSO/GRO enabled the counters report 26 KB "packets" and you measure 18,615 datagrams/s instead of 110,884, because offload works on the healthy variant and has too little traffic to work on the broken one; disable it on both ends before comparing. And run a control pass of the same awg setconf without changing the parameter. I have had a case where setconf on its own accounted for the entire difference, which would have made any before-and-after reading look like a finding.

On the fixes, I would frame option 3 slightly differently, because it does not depend on knowing where the authentication check lives: treat a range match as a candidate classification and continue classifying if the candidate fails its authentication check, rather than committing to the first match. Option 1 is worth having whatever else lands, and it would need to live in the tools as well as the device, since the operator who most needs the error is the one pasting a configuration produced elsewhere. Whatever the threshold is, note that "narrow" is not automatically safe, for the reason above.

Raw runs, the loss curve against packet size and the layout arithmetic are available if any of it is useful.

<!-- gh-comment-id:5457699876 --> @bivlked commented on GitHub (Aug 28, 2026): Your analysis of the Go path is right, so I will not restate it. I hit the same thing independently two days after you filed, and I have three things to add: the kernel module carries the same defect, the per-branch probability is not always `width / 2³²`, and I can put a number on how often the bad shape occurs. **Still present in today's releases.** `amneziawg-go` `v3.1.20260828` (identical to `master` at the time of writing): the three guards are at `device/receive.go:618`, `:629`, `:640`, the transport branch is still last at `:651` with `if size >= expectedSize`. The kernel module `v3.1.20260828` carries the same three guards. Only the line numbers have moved since your `.20260814` check. **The kernel module has the same defect, and my measurements are from there, not from Go.** `amneziawg-linux-kernel-module`, `src/receive.c`, `awg_determine_type_and_padding`, guards at lines 51, 62 and 73: ```c if ((random_trailers ? skb->len >= expected_len : skb->len == expected_len) && (ptr = skb_header_pointer(skb, padding, sizeof(buf), buf)) != NULL && u32_range_contains(wg->init_header, le32_to_cpu(awg_decoded_type(ptr, hash)))) { ``` Same branch order, same collapse of the length test, and the transport branch last with its own `skb->len >= expected_len`. I compared the two classifier functions specifically; I have not compared what happens after classification, so I would not claim the two code paths are identical beyond that. Filed separately as `amnezia-vpn/amneziawg-linux-kernel-module#226`, since the repositories are maintained apart. Two of my own servers, both endpoints on the module `3.1.20260812`, no `amneziawg-go` process anywhere, `RandomTrailers` on: 0.12 MB/s and complete ICMP loss between them. With the flag off the link is normal. **The four bytes a branch reads are not always uniform, and that changes the model.** You wrote that they hold either the random `S4` prefix or ciphertext. Sometimes they hold neither. A transport packet is laid out as ``` [ S4 bytes of junk ][ 04 00 00 00 ][ key_idx 4 ][ counter LE64 8 ][ ciphertext ] 0 S4 S4+4 S4+8 S4+16 ``` and the value is assembled little-endian, so the byte at offset `+3` of the window decides the top of the range. `message_header` is a `__le32 type` whose comment in `messages.h` spells out that it is `u8 type` plus `u8 reserved_zero[3]`, so for a data packet those four bytes are the constant `04 00 00 00`; the high bytes of the `__le64` counter are constant zero in practice as well. When the window's high byte lands on any of those, `applyHash` XORs a constant with a constant and the value is confined to a fixed 2²⁴ band. That branch then either never matches or matches almost every packet, and its range width is nearly irrelevant. This is not a nitpick, it resolved a set of results I could not otherwise explain. On one production parameter set, `H3` is 3.54 % wide and should have cost around 7 % round-trip; it cost 0.0 %, and it still cost 0.0 % when I widened it to half the value space. Its window overlaps the type field. `H2` read zero as well, but for a duller reason: its branch has a higher size threshold than my ping packets reached, so it never ran on that traffic at all. `H1` tracked your formula precisely because its window sits on `key_idx` and the low counter bytes. Working the layout through for that parameter set predicts 12.39 % round-trip loss against 12.0 % measured. The practical consequence runs against the obvious advice: **narrowing the ranges is not a reliable mitigation.** A narrow range that happens to sit on the fixed band costs close to 100 % rather than a few percent, and the tunnel then looks dead rather than slow. If anyone reports 3.1 as completely broken while the handshake succeeds, that is a candidate explanation. **Width against loss, with width as the controlled variable.** Same interface, both ends on the same configuration, five widths spanning five orders of magnitude, `ping` through the tunnel: | `H1` width, share of 2³² | predicted `1-(1-w)²` | measured | |---|---|---| | 0.00002 % | ~0 % | 0.0 % | | 1.00 % | 1.99 % | 1.5 % | | 6.40 % | 12.39 % | 12.0 % | | 50.0 % | 75.0 % | 75.0 and 78.0 % in two runs | | 97.8 % | 99.95 % | 100 % | The round-trip form assumes the same width at both ends, which held here. The monotonicity across the series is also what tells me the configuration really was being applied at each step. **How often the bad shape occurs.** You wrote that spreading ranges across the 32-bit space is an easy thing for a generator to land on. Here is a measured distribution for one such generator, which draws eight points from `[0, 2³¹)`, sorts them and pairs them, with `S1` and `S2` from 20..120, `S3` from 10..60 and `S4` from 8..30. 100,000 draws, evaluated with the layout rule above, one-way loss on full-size transport packets. **With `RandomTrailers` enabled, about 88 % of the range sets this generator produces would lose more than 5 % of their traffic, 22 % would lose more than 20 %, and between 2 and 3 % would lose more than half of everything they carry; the generator itself does not enable `RandomTrailers`, so these are properties of the range sets, not of deployed installs.** | quantile | one-way loss | |---|---| | 5 % | 2.5 % | | median | 13.4 % | | 95 % | 29 % | | 99 % | 100 % | Mean 15.8 %. The 99th percentile is the locked-branch case: about a third of sets have at least one branch whose window sits on a constant high byte, and that is where the heavy tail comes from. I implemented this twice, each time from the code rather than from the other implementation, and the two agree: mean 15.8 % in both, median 13.3 % against 13.5 %, share of sets losing more than half 2.7 % against 2.2 %, and the same 17.0 % of sets with overlapping windows. The last one is the reassuring part, since two implementations sharing one mistake could still agree on a mean. **The assumptions that matter, since this is where a recomputation will diverge:** `key_idx` is constant for the life of a keypair, so a window whose high byte lands there is locked exactly as a window on the type field is. Treating it as varying instead drops the share of locked branches from roughly a third of all sets to roughly a quarter, and thins the tail with it. The transport padding is not a concern - `send.c` fills it with `get_random_bytes` on every packet. Session length matters far less than I expected: sampling packets across sessions from 512 up to 4e9 packets moves the mean by 0.03 percentage points and the share above 50 % from 2.8 % to 2.2 %. So the mean and the median are the stable figures; the tail should be read as approximate. **Two notes for anyone reproducing this.** With GSO/GRO enabled the counters report 26 KB "packets" and you measure 18,615 datagrams/s instead of 110,884, because offload works on the healthy variant and has too little traffic to work on the broken one; disable it on both ends before comparing. And run a control pass of the same `awg setconf` without changing the parameter. I have had a case where `setconf` on its own accounted for the entire difference, which would have made any before-and-after reading look like a finding. **On the fixes**, I would frame option 3 slightly differently, because it does not depend on knowing where the authentication check lives: treat a range match as a *candidate* classification and continue classifying if the candidate fails its authentication check, rather than committing to the first match. Option 1 is worth having whatever else lands, and it would need to live in the tools as well as the device, since the operator who most needs the error is the one pasting a configuration produced elsewhere. Whatever the threshold is, note that "narrow" is not automatically safe, for the reason above. Raw runs, the loss curve against packet size and the layout arithmetic are available if any of it is useful.
Author
Owner

@Jkaotlic commented on GitHub (Aug 29, 2026):

@bivlked Thanks — the kernel-module counterpart and the width-against-loss series are exactly the parts I could not supply, and the control pass advice on awg setconf is well taken.

One correction to the layout argument, at least for the Go path: the constant-band regime does not arise when HeaderProtectionKey is set, and I think that changes the practical advice rather than the mechanism.

The classifier does not read the raw bytes

DeterminePacketTypeAndPadding is handed a typeHash that its caller derives from the header-protection keystream (device/receive.go:144-157, v3.1.20260814; unchanged in v3.1.20260828):

cip, err := device.HeaderProtectionCipher(packet[:HeaderCipherNonceSize])

typeHash := typeHashBuf[:]
clear(typeHash)
if cip != nil {
    cip.XORKeyStream(typeHash, typeHash)
}

msgSize, msgType, padding := device.DeterminePacketTypeAndPadding(packet, typeHash)

The cipher's nonce is the first HeaderCipherNonceSize bytes of the datagram — the head of the junk padding — and RoutineEncryption refills that junk on every packet:

crypt := elem.buffer[:elem.padding]
rand.Read(crypt)

So the keystream is different per packet, and applyHash(headerBytes[:], packet[padding:padding+4], typeHash) XORs the candidate window with a fresh pseudorandom value each time. Whatever the window overlaps — junk, the type field, key_idx, the high counter bytes, or ciphertext — the resulting uint32 is uniform per packet. A branch cannot lock onto a fixed 2²⁴ band, and width / 2³² is exact again.

Two consequences:

  • With header protection configured, narrowing the ranges is a reliable mitigation and the heavy tail in your generated-set distribution collapses. Without it, your analysis applies as written.
  • A width check (suggested fix 1) is still worth having, but a threshold alone behaves quite differently depending on whether header protection is in use, which may be worth stating wherever the check ends up.

A smaller point on the type field, Go path only

The transport type field is not the constant 04 00 00 00 here. RoutineEncryption writes

binary.LittleEndian.PutUint32(fieldType, device.headers.transport.Load().PickOne())

i.e. a random draw from H4. So even with header protection disabled, a window sitting on that field sees values spread across H4 rather than a single constant — though key_idx and the high counter bytes behave as you describe. I have not read the module's send path, so this may genuinely differ there.

Field data

Two production servers, amneziawg-go, RandomTrailers = on, DisableCookies = on, ContentPaddingAddition = 10-100, HeaderProtectionKey set, MTU 1280, H1–H3 narrowed to 25,000 each with H4 left wide (132M). Predicted loss 3 × 25000 / 2³² ≈ 0.0017 % per direction.

Measured from a client behind the tunnel: 50 MB from two independent sources at 17.5 and 17.8 MB/s, time_appconnect 0.106 s median over five probes (TLS exchange itself ≈ 0.052 s). No loss signal of any kind, and one peer has carried 28.6 GiB since the last restart. A caching caveat for anyone repeating this: a second request to the same speed-test endpoint returned 43 MB/s from cache, so the honest figure needs a second source.

On versions

v3.1.20260828 does not touch the classifier, so this issue stands as filed. It does carry two fixes that matter to anyone running the 3.1 flags:

  • da11c9f — the space available for ContentPaddingAddition was computed from len(elem.packet) without MinMessageSize + padding, and used packetSize %= mtu instead of a clamp; it is now taken against the peer's UDP window with the full packet size.
  • b5928ef — the underload path is now skipped entirely when DisableCookies is on. Previously such a device would demand MAC2 under load while declining to send the cookie reply that produces it, which leaves peers unable to complete a handshake for as long as it considers itself loaded. Worth noting that the misclassified transport packets in this issue land in the handshake queue, which is what feeds IsUnderLoad() in the first place.

Both servers upgraded to .20260828 today; handshakes recovered in under 30 seconds and the measurements above are from after the upgrade.

<!-- gh-comment-id:5461087870 --> @Jkaotlic commented on GitHub (Aug 29, 2026): @bivlked Thanks — the kernel-module counterpart and the width-against-loss series are exactly the parts I could not supply, and the control pass advice on `awg setconf` is well taken. One correction to the layout argument, at least for the Go path: **the constant-band regime does not arise when `HeaderProtectionKey` is set**, and I think that changes the practical advice rather than the mechanism. ### The classifier does not read the raw bytes `DeterminePacketTypeAndPadding` is handed a `typeHash` that its caller derives from the header-protection keystream (`device/receive.go:144-157`, `v3.1.20260814`; unchanged in `v3.1.20260828`): ```go cip, err := device.HeaderProtectionCipher(packet[:HeaderCipherNonceSize]) typeHash := typeHashBuf[:] clear(typeHash) if cip != nil { cip.XORKeyStream(typeHash, typeHash) } msgSize, msgType, padding := device.DeterminePacketTypeAndPadding(packet, typeHash) ``` The cipher's nonce is the first `HeaderCipherNonceSize` bytes of the datagram — the head of the junk padding — and `RoutineEncryption` refills that junk on **every** packet: ```go crypt := elem.buffer[:elem.padding] rand.Read(crypt) ``` So the keystream is different per packet, and `applyHash(headerBytes[:], packet[padding:padding+4], typeHash)` XORs the candidate window with a fresh pseudorandom value each time. Whatever the window overlaps — junk, the type field, `key_idx`, the high counter bytes, or ciphertext — the resulting `uint32` is uniform per packet. A branch cannot lock onto a fixed 2²⁴ band, and `width / 2³²` is exact again. Two consequences: - **With header protection configured, narrowing the ranges is a reliable mitigation** and the heavy tail in your generated-set distribution collapses. Without it, your analysis applies as written. - A width check (suggested fix 1) is still worth having, but a threshold alone behaves quite differently depending on whether header protection is in use, which may be worth stating wherever the check ends up. ### A smaller point on the type field, Go path only The transport type field is not the constant `04 00 00 00` here. `RoutineEncryption` writes ```go binary.LittleEndian.PutUint32(fieldType, device.headers.transport.Load().PickOne()) ``` i.e. a random draw from `H4`. So even with header protection disabled, a window sitting on that field sees values spread across `H4` rather than a single constant — though `key_idx` and the high counter bytes behave as you describe. I have not read the module's send path, so this may genuinely differ there. ### Field data Two production servers, `amneziawg-go`, `RandomTrailers = on`, `DisableCookies = on`, `ContentPaddingAddition = 10-100`, `HeaderProtectionKey` set, MTU 1280, `H1`–`H3` narrowed to 25,000 each with `H4` left wide (132M). Predicted loss `3 × 25000 / 2³²` ≈ 0.0017 % per direction. Measured from a client behind the tunnel: 50 MB from two independent sources at 17.5 and 17.8 MB/s, `time_appconnect` 0.106 s median over five probes (TLS exchange itself ≈ 0.052 s). No loss signal of any kind, and one peer has carried 28.6 GiB since the last restart. A caching caveat for anyone repeating this: a second request to the same speed-test endpoint returned 43 MB/s from cache, so the honest figure needs a second source. ### On versions `v3.1.20260828` does not touch the classifier, so this issue stands as filed. It does carry two fixes that matter to anyone running the 3.1 flags: - `da11c9f` — the space available for `ContentPaddingAddition` was computed from `len(elem.packet)` without `MinMessageSize + padding`, and used `packetSize %= mtu` instead of a clamp; it is now taken against the peer's UDP window with the full packet size. - `b5928ef` — the underload path is now skipped entirely when `DisableCookies` is on. Previously such a device would demand MAC2 under load while declining to send the cookie reply that produces it, which leaves peers unable to complete a handshake for as long as it considers itself loaded. Worth noting that the misclassified transport packets in this issue land in the handshake queue, which is what feeds `IsUnderLoad()` in the first place. Both servers upgraded to `.20260828` today; handshakes recovered in under 30 seconds and the measurements above are from after the upgrade.
Author
Owner

@leonidorlov-hash commented on GitHub (Aug 30, 2026):

A data point that may be useful for the «what should operators do meanwhile» question: the official AmneziaWG documentation was recently updated (https://docs.amnezia.org/ru/documentation/amnezia-wg/) and now prescribes for AWG 3.1 with Header Protection enabled:

При использовании Header Protection рекомендуется оставлять H1-H4 в стандартных значениях совместимости: H1 = 1, H2 = 2, H3 = 3, H4 = 4. В этом режиме механизм пользовательских заголовков отключается, а скрытие типа сообщения выполняет Header Protection.

I.e. H1=1..H4=4 is now the documented «custom headers OFF» mode — the values are not a lazy default but a switch that disables the mechanism entirely (the parameter table says H1 is disabled at value 1, H2 at 2, etc.). That makes the shipped 1,2,3,4 config safe against exactly the loss you measured here: width-1 ranges equal to the real message types give ~2⁻³² misclassification per branch.

The same doc page also gained a related warning: with RandomTrailers on, setting S1=S2=S3=S4 is recommended «to reduce the risk of packet type misidentification» — which reads as an official acknowledgement of the classifier weakness analysed in this issue, addressed by configuration guidance rather than a code fix so far.

So for generators the practical rule today: AWG 3.1 + HeaderProtectionKey → H1–H4 = 1,2,3,4; never ship ranged H with RandomTrailers on.

<!-- gh-comment-id:5465744429 --> @leonidorlov-hash commented on GitHub (Aug 30, 2026): A data point that may be useful for the «what should operators do meanwhile» question: the official AmneziaWG documentation was recently updated (https://docs.amnezia.org/ru/documentation/amnezia-wg/) and now prescribes for AWG 3.1 with Header Protection enabled: > При использовании Header Protection рекомендуется оставлять H1-H4 в стандартных значениях совместимости: H1 = 1, H2 = 2, H3 = 3, H4 = 4. В этом режиме механизм пользовательских заголовков отключается, а скрытие типа сообщения выполняет Header Protection. I.e. `H1=1..H4=4` is now the documented «custom headers OFF» mode — the values are not a lazy default but a switch that disables the mechanism entirely (the parameter table says H1 is disabled at value 1, H2 at 2, etc.). That makes the shipped 1,2,3,4 config safe against exactly the loss you measured here: width-1 ranges equal to the real message types give ~2⁻³² misclassification per branch. The same doc page also gained a related warning: with `RandomTrailers` on, setting S1=S2=S3=S4 is recommended «to reduce the risk of packet type misidentification» — which reads as an official acknowledgement of the classifier weakness analysed in this issue, addressed by configuration guidance rather than a code fix so far. So for generators the practical rule today: AWG 3.1 + HeaderProtectionKey → H1–H4 = 1,2,3,4; never ship ranged H with RandomTrailers on.
Author
Owner

@bivlked commented on GitHub (Aug 30, 2026):

@Jkaotlic Both corrections are right, and I have checked them against the kernel module as well, since you noted you had not read its send path. It behaves the same way on both points.

The type field. src/send.c:291 writes

header->header.type = cpu_to_le32(
    u32_range_pick_one(peer->device->transport_header));

so the module draws it from H4 exactly as the Go path does. My layout sketch showing a constant 04 00 00 00 there is simply wrong, in both implementations.

The per-packet keystream. src/receive.c derives the hash the same way, from the head of the junk padding, which send.c:298 refills with get_random_bytes on every packet:

if (wg->header_protection.has_protection) {
    ptr = skb_header_pointer(skb, 0, sizeof(buf), buf);
    if (!ptr)
        return -EINVAL;

    protected = awg_header_protection_init(&state, wg, ptr);
    if (protected) {
        chacha20_crypt(&state, hash, hash, sizeof(hash));

So with HeaderProtectionKey set, the candidate window is XORed with a fresh pseudorandom value per packet and width / 2³² is exact, as you say.

One qualification belongs with that: the block is conditional. hash is declared hash[4] = {0} and the chacha step runs only under has_protection, so an interface configured without a header-protection key compares the raw window bytes. I am not going to claim anything about that case, because I have no clean measurement of it. Whether it changes anything for the first of your proposed fixes is worth checking; I cannot say from what I have.

Withdrawing my advice and correcting my numbers. My "narrowing a range is not automatically safe" does not hold for a configuration with header protection, and your field data settles it. The distribution I posted was computed with the locked-band rule, so its shape was wrong even though its centre was not. Recomputed with uniform per-branch probabilities over the same 100,000 generated sets:

as posted corrected
mean one-way loss 15.8 % 15.84 %
median 13.4 % 15.38 %
share above 5 % ~88 % 96.07 %
share above 20 % ~22 % 27.00 %
share above 50 % 2 to 3 % 0.00 %
sets with a locked branch roughly a third withdrawn

The mean survived because a locked branch still contributes width / 2³² on average over key draws. The shape, the median and both exceedance shares were wrong; the mean was not. The practical reading changes with them: without the tail this is a broad moderate loss rather than a lottery with a catastrophic end.

A disclosure about my own measurements. My harness takes the header-protection key as a separate option, and the runs I used for the per-header attribution did not include it, while the full-configuration runs did. So the table I posted mixed the two regimes in one column without saying so. The five-width series itself stands as recorded; what I failed to record is which regime it ran in, and I should have.

One thing I still cannot explain, now including under my own corrected reading. On that interface H3 measured 0.0 % at 3.54 % width and again 0.0 % when widened to half the value space. Under your model it should cost its width; under mine in the raw-byte regime it should too, since the top byte of its window falls on a byte that changes from packet to packet. My leading explanation is now that the variant set one range while omitting the other three and never applied. I will verify that rather than assume it: a re-run with proof that the configuration took effect, plus a control pass of setconf that changes nothing, and I will report back either way.

Thanks for the .20260828 notes. The DisableCookies underload fix is directly relevant here, since the misclassified transport packets land in the handshake queue that feeds IsUnderLoad() in the first place.

<!-- gh-comment-id:5467505356 --> @bivlked commented on GitHub (Aug 30, 2026): @Jkaotlic Both corrections are right, and I have checked them against the kernel module as well, since you noted you had not read its send path. It behaves the same way on both points. **The type field.** `src/send.c:291` writes ```c header->header.type = cpu_to_le32( u32_range_pick_one(peer->device->transport_header)); ``` so the module draws it from `H4` exactly as the Go path does. My layout sketch showing a constant `04 00 00 00` there is simply wrong, in both implementations. **The per-packet keystream.** `src/receive.c` derives the hash the same way, from the head of the junk padding, which `send.c:298` refills with `get_random_bytes` on every packet: ```c if (wg->header_protection.has_protection) { ptr = skb_header_pointer(skb, 0, sizeof(buf), buf); if (!ptr) return -EINVAL; protected = awg_header_protection_init(&state, wg, ptr); if (protected) { chacha20_crypt(&state, hash, hash, sizeof(hash)); ``` So with `HeaderProtectionKey` set, the candidate window is XORed with a fresh pseudorandom value per packet and `width / 2³²` is exact, as you say. One qualification belongs with that: the block is conditional. `hash` is declared `hash[4] = {0}` and the chacha step runs only under `has_protection`, so an interface configured without a header-protection key compares the raw window bytes. I am not going to claim anything about that case, because I have no clean measurement of it. Whether it changes anything for the first of your proposed fixes is worth checking; I cannot say from what I have. **Withdrawing my advice and correcting my numbers.** My "narrowing a range is not automatically safe" does not hold for a configuration with header protection, and your field data settles it. The distribution I posted was computed with the locked-band rule, so its shape was wrong even though its centre was not. Recomputed with uniform per-branch probabilities over the same 100,000 generated sets: | | as posted | corrected | |---|---|---| | mean one-way loss | 15.8 % | 15.84 % | | median | 13.4 % | 15.38 % | | share above 5 % | ~88 % | 96.07 % | | share above 20 % | ~22 % | 27.00 % | | share above 50 % | 2 to 3 % | **0.00 %** | | sets with a locked branch | roughly a third | withdrawn | The mean survived because a locked branch still contributes `width / 2³²` on average over key draws. The shape, the median and both exceedance shares were wrong; the mean was not. The practical reading changes with them: without the tail this is a broad moderate loss rather than a lottery with a catastrophic end. **A disclosure about my own measurements.** My harness takes the header-protection key as a separate option, and the runs I used for the per-header attribution did not include it, while the full-configuration runs did. So the table I posted mixed the two regimes in one column without saying so. The five-width series itself stands as recorded; what I failed to record is which regime it ran in, and I should have. **One thing I still cannot explain, now including under my own corrected reading.** On that interface `H3` measured 0.0 % at 3.54 % width and again 0.0 % when widened to half the value space. Under your model it should cost its width; under mine in the raw-byte regime it should too, since the top byte of its window falls on a byte that changes from packet to packet. My leading explanation is now that the variant set one range while omitting the other three and never applied. I will verify that rather than assume it: a re-run with proof that the configuration took effect, plus a control pass of `setconf` that changes nothing, and I will report back either way. Thanks for the `.20260828` notes. The `DisableCookies` underload fix is directly relevant here, since the misclassified transport packets land in the handshake queue that feeds `IsUnderLoad()` in the first place.
Author
Owner

@wiresock commented on GitHub (Sep 25, 2026):

Additional evidence for this issue. The kernel-module counterpart is
amnezia-vpn/amneziawg-linux-kernel-module#226, and the same fixtures apply to both. We reproduced this
while testing WireSock BoringTun interoperability with AWG 3.1. The reproducer is standalone and depends
only on upstream AWG framing rules.

Reproducer and drop-in device/ test:
github.com/Wiresock-Foundation/awg-first-match-reproducer@99152cf1d5

The two fixtures are one valid Transport packet under an installer-style profile: distinct S1–S4, disjoint
10⁸-wide H ranges, RandomTrailers on, HP on. They have the same length, receiver index, counter,
ciphertext, tag and plaintext, and differ only in the S4 prefix and HP-masked header.

repro/go/awg_fixture_classify_test.go
runs both through the real code. It follows RoutineReceiveIncoming's sequence:
HeaderProtectionCipher(packet[:12]), then typeHash, then DeterminePacketTypeAndPadding(). The test
expects Transport for both.

On master (b5928efb) it fails as intended:

    ambiguous-transport.bin (376 bytes): selected response at padding 41
    ambiguous-transport.bin: selected response, want transport
    redrawn-transport.bin (376 bytes): selected transport at padding 24
--- FAIL: TestAWGFixtureTransportClassification

The Response interpretation would then be rejected at CheckMAC1, and the Transport branch is never tried.
The Python reproducer confirms that both datagrams open with the transport key.

On #183's head (2b855b9) both fixtures are selected as Transport and the test passes. That covers only
these two fixtures, not every ambiguity case. The test may be useful as an additional regression test
there.

Live (against the kernel module, which applies the same selection rules): captured ambiguous frames
were delivered 0 / 10 times when re-sent byte-for-byte, and their same-counter prefix-redrawn
equivalents 10 / 10 times. Details are in the kernel issue and the repository.

<!-- gh-comment-id:5827382010 --> @wiresock commented on GitHub (Sep 25, 2026): Additional evidence for this issue. The kernel-module counterpart is amnezia-vpn/amneziawg-linux-kernel-module#226, and the same fixtures apply to both. We reproduced this while testing WireSock BoringTun interoperability with AWG 3.1. The reproducer is standalone and depends only on upstream AWG framing rules. **Reproducer and drop-in `device/` test:** https://github.com/Wiresock-Foundation/awg-first-match-reproducer/tree/99152cf1d54ab6df2e6c76d362bf08311dcc4c22 The two fixtures are one valid Transport packet under an installer-style profile: distinct S1–S4, disjoint 10⁸-wide H ranges, RandomTrailers on, HP on. They have the same length, receiver index, counter, ciphertext, tag and plaintext, and differ only in the S4 prefix and HP-masked header. [`repro/go/awg_fixture_classify_test.go`](https://github.com/Wiresock-Foundation/awg-first-match-reproducer/blob/99152cf1d54ab6df2e6c76d362bf08311dcc4c22/repro/go/awg_fixture_classify_test.go) runs both through the real code. It follows `RoutineReceiveIncoming`'s sequence: `HeaderProtectionCipher(packet[:12])`, then `typeHash`, then `DeterminePacketTypeAndPadding()`. The test expects Transport for both. On `master` (`b5928efb`) it fails as intended: ```text ambiguous-transport.bin (376 bytes): selected response at padding 41 ambiguous-transport.bin: selected response, want transport redrawn-transport.bin (376 bytes): selected transport at padding 24 --- FAIL: TestAWGFixtureTransportClassification ``` The Response interpretation would then be rejected at `CheckMAC1`, and the Transport branch is never tried. The Python reproducer confirms that both datagrams open with the transport key. On #183's head (`2b855b9`) both fixtures are selected as Transport and the test passes. That covers only these two fixtures, not every ambiguity case. The test may be useful as an additional regression test there. **Live** (against the kernel module, which applies the same selection rules): captured ambiguous frames were delivered **0 / 10** times when re-sent byte-for-byte, and their same-counter prefix-redrawn equivalents **10 / 10** times. Details are in the kernel issue and the repository.
Author
Owner

@Jkaotlic commented on GitHub (Sep 25, 2026):

Thanks for the reproducer, the standalone fixtures make this much easier to reason about.

A small side note: both of your fixtures depend on distinct S1–S4. The Response match lands at padding 41 while the real Transport padding is 24. If I read the selection logic right, setting S1 = S2 = S3 = S4 closes that particular path, because every candidate branch then reads its header window at the same offset. What remains is the plain width / 2³² chance per branch, which with header protection on and narrow H ranges is negligible. For what it's worth, our two servers run exactly that (equal S, 25,000-wide H1–H3, HP on, RandomTrailers on) and have been fine in practice.

I haven't run your test with equal S values, so this is reasoning, not a measurement. Still, it might be worth a line in the operator guidance until #183 lands: the official docs already recommend equal S with RandomTrailers "to reduce the risk of packet type misidentification", and your fixtures show one concrete reason why.

<!-- gh-comment-id:5830275533 --> @Jkaotlic commented on GitHub (Sep 25, 2026): Thanks for the reproducer, the standalone fixtures make this much easier to reason about. A small side note: both of your fixtures depend on distinct S1–S4. The Response match lands at padding 41 while the real Transport padding is 24. If I read the selection logic right, setting S1 = S2 = S3 = S4 closes that particular path, because every candidate branch then reads its header window at the same offset. What remains is the plain `width / 2³²` chance per branch, which with header protection on and narrow H ranges is negligible. For what it's worth, our two servers run exactly that (equal S, 25,000-wide H1–H3, HP on, RandomTrailers on) and have been fine in practice. I haven't run your test with equal S values, so this is reasoning, not a measurement. Still, it might be worth a line in the operator guidance until #183 lands: the official docs already recommend equal S with RandomTrailers "to reduce the risk of packet type misidentification", and your fixtures show one concrete reason why.
Author
Owner

@bivlked commented on GitHub (Sep 25, 2026):

@Jkaotlic I ran the equal-S case, and it comes out stronger than width / 2³²: for transport packets the rate is zero.

I used @wiresock's reproducer at 99152cf with its own framing code, synthetic keys, H ranges and HP key, RandomTrailers on, and only the S values changed. Each profile got 20,000 independently sealed transport datagrams with a random counter, plaintext and S4 prefix. Every datagram went through the real DeterminePacketTypeAndPadding at b5928ef, called the same way as in the drop-in test, and through the reproducer's kernel transliteration (kernel_classify in crosscheck_classifier.py).

S1-S4 datagram taken for a control message, Go b5928ef same, kernel transliteration
97 / 41 / 133 / 24 (the fixtures' profile) 372 B 1,318 / 20,000 (6.59 %) 1,318 / 20,000
24 / 24 / 24 / 24 372 B 0 / 20,000 0 / 20,000
41 / 41 / 41 / 41 389 B 0 / 20,000 0 / 20,000
97 / 97 / 97 / 97 445 B 0 / 20,000 0 / 20,000

The first row is the control. Same generator, same code, and the misreads show up at the rate the reproducer predicts, so the zeros below it are not a blind test. The reproducer's Python model agrees over 200,000 datagrams per profile, with header protection on and off.

It is zero rather than small because with equal S every branch reads its window at the same offset, and that offset holds the packet's own type field. After unmasking (or as is, with header protection off) the window is the sender's draw from H4, so a control branch could only match if that draw fell inside H1, H2 or H3. Disjoint ranges rule that out. The width / 2³² term belongs to branches that read junk or ciphertext, and with equal S there are none.

What this doesn't cover: only transport packets taken for control messages, synthetic keys, no live traffic, and both ends on the same configuration. On the module side it is the transliteration, not the module.

So I'd support the line in the operator guidance, and it can say more than "reduces the risk": with RandomTrailers on, S1 = S2 = S3 = S4 and disjoint H1-H4 close this path, with or without a header protection key. Our installer draws S1-S4 independently today and doesn't enable RandomTrailers. If it ever does, it will use one S for all four.

<!-- gh-comment-id:5839848058 --> @bivlked commented on GitHub (Sep 25, 2026): @Jkaotlic I ran the equal-S case, and it comes out stronger than `width / 2³²`: for transport packets the rate is zero. I used @wiresock's reproducer at [`99152cf`](https://github.com/Wiresock-Foundation/awg-first-match-reproducer/tree/99152cf1d54ab6df2e6c76d362bf08311dcc4c22) with its own framing code, synthetic keys, H ranges and HP key, RandomTrailers on, and only the S values changed. Each profile got 20,000 independently sealed transport datagrams with a random counter, plaintext and S4 prefix. Every datagram went through the real `DeterminePacketTypeAndPadding` at `b5928ef`, called the same way as in the drop-in test, and through the reproducer's kernel transliteration (`kernel_classify` in `crosscheck_classifier.py`). | S1-S4 | datagram | taken for a control message, Go `b5928ef` | same, kernel transliteration | |---|---|---|---| | 97 / 41 / 133 / 24 (the fixtures' profile) | 372 B | 1,318 / 20,000 (6.59 %) | 1,318 / 20,000 | | 24 / 24 / 24 / 24 | 372 B | 0 / 20,000 | 0 / 20,000 | | 41 / 41 / 41 / 41 | 389 B | 0 / 20,000 | 0 / 20,000 | | 97 / 97 / 97 / 97 | 445 B | 0 / 20,000 | 0 / 20,000 | The first row is the control. Same generator, same code, and the misreads show up at the rate the reproducer predicts, so the zeros below it are not a blind test. The reproducer's Python model agrees over 200,000 datagrams per profile, with header protection on and off. It is zero rather than small because with equal S every branch reads its window at the same offset, and that offset holds the packet's own type field. After unmasking (or as is, with header protection off) the window is the sender's draw from H4, so a control branch could only match if that draw fell inside H1, H2 or H3. Disjoint ranges rule that out. The `width / 2³²` term belongs to branches that read junk or ciphertext, and with equal S there are none. What this doesn't cover: only transport packets taken for control messages, synthetic keys, no live traffic, and both ends on the same configuration. On the module side it is the transliteration, not the module. So I'd support the line in the operator guidance, and it can say more than "reduces the risk": with RandomTrailers on, S1 = S2 = S3 = S4 and disjoint H1-H4 close this path, with or without a header protection key. Our installer draws S1-S4 independently today and doesn't enable RandomTrailers. If it ever does, it will use one S for all four.
Author
Owner

@Jkaotlic commented on GitHub (Sep 26, 2026):

Thanks for running it — zero rather than small is a cleaner result than I expected, and the explanation (with equal S every branch reads the packet's own type field, which is an H4 draw) makes it obvious in hindsight. Agreed on the stronger wording for the guidance.

<!-- gh-comment-id:5845881948 --> @Jkaotlic commented on GitHub (Sep 26, 2026): Thanks for running it — zero rather than small is a cleaner result than I expected, and the explanation (with equal S every branch reads the packet's own type field, which is an H4 draw) makes it obvious in hindsight. Agreed on the stronger wording for the guidance.
Sign in to join this conversation.
No labels
pull-request
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
amnezia-vpn/amneziawg-go#88
No description provided.