mirror of
https://github.com/amnezia-vpn/amneziawg-go.git
synced 2026-10-14 05:41:54 +00:00
[GH-ISSUE #186] device: with RandomTrailers on, H1-H3 range width silently drops transport packets #88
Labels
No labels
pull-request
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
amnezia-vpn/amneziawg-go#88
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Originally created by @Jkaotlic on GitHub (Aug 25, 2026).
Original GitHub issue: https://github.com/amnezia-vpn/amneziawg-go/issues/186
Affected upstream source
https://github.com/amnezia-vpn/amneziawg-gov3.1.202608121f50ad736ecca22a9bfc7b4606805ec9ca49fe481f50ad7("feat: awg 3.1 features") — the same commit as #178.Summary
DeterminePacketTypeAndPaddingdecides what a datagram is by trying init, thenresponse, then cookie, and only then transport. The first three branches are
guarded by the same condition, at
device/receive.go:617,:628and:639:With
RandomTrailersoff,size == expectedSizekeeps transport packets out ofthese three branches almost entirely. With it on, the second disjunct admits
every transport packet larger than
S1 + MessageInitiationSize, and the wholedecision falls on
header.Contains.The four bytes it looks at sit at offsets
S1/S2/S3. In a transportpacket those hold either the random
S4prefix (rand.Readindevice/send.go)or ciphertext — uniform in both cases, and
applyHashis an XOR with a constant,so uniform on the way out too.
UintRange.Containsislo <= num && num <= hi.So each of
H1,H2,H3claims a passing transport packet with probabilitywidth / 2³². A claimed packet goes to the handshake queue and dies atCheckMAC1(device/receive.go:365), which logs atVerbosefand incrementsnothing.
Per-direction loss is
1 − Π(1 − Wᵢ/2³²)over the three ranges, whereW = Hi − Lo + 1.H4is not part of it: the transport branch runs last and noother 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/udpshows zerodrops,
Udp RcvbufErrorsstays zero, CPU does not move, there is nofragmentation, 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.
Observed on
go1.26.2: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:Packets that short never reach the
H1branch, so the prediction for them usesH2andH3only: 15.1 % one way,1 − (1 − 0.151)² = 27.9 %for a roundtrip. 25 % on 100 probes sits inside sampling error.
Throughput recovered accordingly; nothing else about the interface changed.
Reachability
Any device with
RandomTrailers = onandH1–H3given as ranges. Both aredocumented, 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.goalready rejects configsthat cannot work — the
S%d must be more then %d to use headerProtectioncheckis the precedent. The same could apply when
random_trailersis on and thesummed width of
H1–H3crosses a threshold. A loud error at setup isenormously 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
H4first and misclassified with probabilityW4 / 2³². Since transport packets outnumber handshakes by many orders ofmagnitude 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
CheckMAC1and also satisfies the transportbranch, 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
RandomTrailerson, the width ofH1–H3is 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.
@Jkaotlic commented on GitHub (Aug 27, 2026):
Still present on
v3.1.20260814.DeterminePacketTypeAndPaddingis unchanged: the init, response and cookie branches still share the guardsize == expectedSize || randomTrailers && size > expectedSize, and the transport branch is still tried last. The.20260813/.20260814fixes 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
ContentPaddingAdditionwithRandomTrailersFrom
amnezia-client,client/core/utils/constants/protocolConstants.hondev:client/server_scripts/awg/template.confandclient/server_scripts/awg/configure_container.shboth emit all three together, so this is the out-of-the-box 3.1 configuration, not an unusual one.It matters here because
ContentPaddingAdditionappends 10–100 bytes to the plaintext before encryption, so every transport packet is that much larger. Packets that would previously have sat belowpadding + MessageInitiationSize— and therefore never reached theH1branch 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
RandomTrailersis 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
CheckMAC1failure, 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 chooseH1–H4on obfuscation grounds alone.Happy to put together a PR for either this or option 1 if one of them looks preferable.
@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-gov3.1.20260828(identical tomasterat the time of writing): the three guards are atdevice/receive.go:618,:629,:640, the transport branch is still last at:651withif size >= expectedSize. The kernel modulev3.1.20260828carries the same three guards. Only the line numbers have moved since your.20260814check.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: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 asamnezia-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, noamneziawg-goprocess anywhere,RandomTrailerson: 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
S4prefix or ciphertext. Sometimes they hold neither. A transport packet is laid out asand the value is assembled little-endian, so the byte at offset
+3of the window decides the top of the range.message_headeris a__le32 typewhose comment inmessages.hspells out that it isu8 typeplusu8 reserved_zero[3], so for a data packet those four bytes are the constant04 00 00 00; the high bytes of the__le64counter are constant zero in practice as well. When the window's high byte lands on any of those,applyHashXORs 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,
H3is 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.H2read 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.H1tracked your formula precisely because its window sits onkey_idxand 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,
pingthrough the tunnel:H1width, share of 2³²1-(1-w)²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, withS1andS2from 20..120,S3from 10..60 andS4from 8..30. 100,000 draws, evaluated with the layout rule above, one-way loss on full-size transport packets. WithRandomTrailersenabled, 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 enableRandomTrailers, so these are properties of the range sets, not of deployed installs.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_idxis 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.cfills it withget_random_byteson 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 setconfwithout changing the parameter. I have had a case wheresetconfon 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.
@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 setconfis well taken.One correction to the layout argument, at least for the Go path: the constant-band regime does not arise when
HeaderProtectionKeyis set, and I think that changes the practical advice rather than the mechanism.The classifier does not read the raw bytes
DeterminePacketTypeAndPaddingis handed atypeHashthat its caller derives from the header-protection keystream (device/receive.go:144-157,v3.1.20260814; unchanged inv3.1.20260828):The cipher's nonce is the first
HeaderCipherNonceSizebytes of the datagram — the head of the junk padding — andRoutineEncryptionrefills that junk on every packet: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 resultinguint32is uniform per packet. A branch cannot lock onto a fixed 2²⁴ band, andwidth / 2³²is exact again.Two consequences:
A smaller point on the type field, Go path only
The transport type field is not the constant
04 00 00 00here.RoutineEncryptionwritesi.e. a random draw from
H4. So even with header protection disabled, a window sitting on that field sees values spread acrossH4rather than a single constant — thoughkey_idxand 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,HeaderProtectionKeyset, MTU 1280,H1–H3narrowed to 25,000 each withH4left wide (132M). Predicted loss3 × 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_appconnect0.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.20260828does 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 forContentPaddingAdditionwas computed fromlen(elem.packet)withoutMinMessageSize + padding, and usedpacketSize %= mtuinstead 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 whenDisableCookiesis 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 feedsIsUnderLoad()in the first place.Both servers upgraded to
.20260828today; handshakes recovered in under 30 seconds and the measurements above are from after the upgrade.@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:
I.e.
H1=1..H4=4is 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
RandomTrailerson, 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.
@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:291writesso the module draws it from
H4exactly as the Go path does. My layout sketch showing a constant04 00 00 00there is simply wrong, in both implementations.The per-packet keystream.
src/receive.cderives the hash the same way, from the head of the junk padding, whichsend.c:298refills withget_random_byteson every packet:So with
HeaderProtectionKeyset, the candidate window is XORed with a fresh pseudorandom value per packet andwidth / 2³²is exact, as you say.One qualification belongs with that: the block is conditional.
hashis declaredhash[4] = {0}and the chacha step runs only underhas_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:
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
H3measured 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 ofsetconfthat changes nothing, and I will report back either way.Thanks for the
.20260828notes. TheDisableCookiesunderload fix is directly relevant here, since the misclassified transport packets land in the handshake queue that feedsIsUnderLoad()in the first place.@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@99152cf1d5The 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.goruns both through the real code. It follows
RoutineReceiveIncoming's sequence:HeaderProtectionCipher(packet[:12]), thentypeHash, thenDeterminePacketTypeAndPadding(). The testexpects Transport for both.
On
master(b5928efb) it fails as intended: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 onlythese 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.
@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.
@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
99152cfwith 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 realDeterminePacketTypeAndPaddingatb5928ef, called the same way as in the drop-in test, and through the reproducer's kernel transliteration (kernel_classifyincrosscheck_classifier.py).b5928efThe 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.
@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.