mirror of
https://github.com/amnezia-vpn/amneziawg-linux-kernel-module.git
synced 2026-10-14 05:41:56 +00:00
[GH-ISSUE #226] receive: with RandomTrailers on, sufficiently long transport packets are admitted to the handshake range checks and dropped #129
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-linux-kernel-module#129
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 @bivlked on GitHub (Aug 28, 2026).
Original GitHub issue: https://github.com/amnezia-vpn/amneziawg-linux-kernel-module/issues/226
Affected source
amnezia-vpn/amneziawg-linux-kernel-modulev3.1.20260812(46803204e7ec) throughv3.1.20260828(3c38e168beb7), i.e. current at the time of writingsrc/receive.c, functionawg_determine_type_and_padding, guards at lines 51, 62 and 733.1.20260812(/sys/module/amneziawg/version) withamneziawg-tools v3.1.20260812This is the kernel-module counterpart of
amnezia-vpn/amneziawg-go#186, which analyses the same construct inamneziawg-go. Filing separately because the repositories are maintained apart and that issue does not cover the module. I compared the two classifier functions; I have not compared what either does after classification.What happens
awg_determine_type_and_paddingclassifies a datagram by trying initiation, then response, then cookie, and only then transport. The first three share one guard:With
random_trailersclear,skb->len == expected_lenkeeps transport packets out of these branches. With it set the test becomesskb->len >= expected_len, so any transport packet at least as long as the corresponding padded handshake size is admitted to the range check, and the classification then rests entirely onu32_range_contains.The thresholds follow from the paddings set by
S1-S4(netlink.c:817,:829,:841,:853): initiationS1 + 148, responseS2 + 92, cookieS3 + 64. A packet shorter than a given threshold skips that branch, which is why the effect depends on traffic profile as well as on configuration.A transport packet claimed by one of these branches is passed to the handshake path (
wg_receive_handshake_packet) and is then observed to fail MAC1 validation and be dropped; the receiver logsReceived packet with invalid mac1at verbose level and increments no counter. Because the datagram was already accepted by the socket and is discarded above it, the ordinary indicators an operator would check do not move.What decides the probability, which is not simply width / 2³²
awg_decoded_typeXORs the four bytes at the branch's padding offset with a constant, andu32_range_containsis a plain bounds test, so the hit probability is the probability that those four bytes, read little-endian, fall inside the range. Whether that iswidth / 2³²depends on where the window lands. A transport packet ismessage_headeris documented inmessages.hasu8 typeplusu8 reserved_zero[3]encoded little-endian, so forMESSAGE_DATAthose four bytes are the constant04 00 00 00; the high bytes of the counter are constant zero in practice. If the window's byte at+3lands on any constant byte, the XOR of two constants is a constant and the value is confined to a fixed 2²⁴ band: the branch then either never fires or fires on nearly every packet, almost independently of the range width. If it lands on junk,key_idx, the low counter bytes or ciphertext, the value is spread and the probability iswidth / 2³².This matters for advice as much as for analysis: narrowing a range is not automatically safe, since a narrow range sitting on the fixed band costs close to every packet.
Reproduction
Two peers, obfuscation as generated by any 3.x tooling, plus
RandomTrailers = onon the receiving side. ChooseS1so that the window[S1, S1+4)does not overlap[S4, S4+4)or the counter's high bytes, so that the branch is in the spread regime, and makeH1wide. Then vary only the width ofH1, recreating the interface between runs, and measure withpingthrough the tunnel.Two things are needed for the measurement to mean anything. Disable segmentation offload on both ends (
ethtool -K <iface> gso off gro off tso off), otherwise the packet counters report multi-kilobyte "packets" on the healthy variant only and the comparison is between two different quantities. And run a control pass of the sameawg setconfwithout changing anything. I have had a case wheresetconfon its own accounted for the whole difference, which would have made any before-and-after reading look like a finding.Observed on a
vethpair with both endpoints on one host, so that provider and physical link are out of the picture, 100 probes per point, the same width configured at both ends:H1width, share of 2³²1-(1-w)²The round-trip form assumes the same width at both ends; for unequal receivers it is
1-(1-w_A)(1-w_B).On production hardware, two peers with both endpoints on the module and trailers on: 0.12 MB/s and complete ICMP loss, normal throughput with the flag off.
One result worth stating because it is what led to the layout analysis above: on the same interface
H3was 3.54 % wide and should have cost about 7 % round-trip, yet measured 0.0 %, and still measured 0.0 % when widened to half the value space. Its window overlaps the type field. Anyone reproducing per-header attribution should expect this and check where each window lands before concluding that a branch is harmless.Scope
Any interface with
random_trailersset and ranged headers configured, both of which are documented and supported. There is no attacker: the loss is inflicted by the receiver on its own peers' ordinary data packets. For one automatic range generator (it draws eight points from[0, 2³¹), sorts them and pairs them, withS1andS2from 20..120,S3from 10..60 andS4from 8..30), evaluating the layout rule over 100,000 generated sets gives a mean one-way loss of 15.8 % on full-size packets and a median of 13.4 %, with about 22 % of sets above 20 % and 2 to 3 % above 50 %. Roughly a third of the sets have at least one branch whose window sits on a constant high byte, which is where the tail above 50 % comes from. I implemented this twice, each time from the code rather than from the other, and the two agree on the mean to two decimals; the tail is the part that shifts with how one treats fields that stay constant within a session,key_idxabove all, so it is given as a range rather than a figure.Suggested direction
Rather than prescribing a patch: treat a range match as a candidate classification and continue classifying when the candidate fails its authentication check, instead of committing to the first match. That restores the separation the feature appears to intend, since
RandomTrailersis consistently described as appending a trailer to handshake messages rather than to transport framing, and it leaves operators free to choose the header ranges on obfuscation grounds alone.Rejecting the combination at configuration time would turn silent loss into a loud error, which is a large improvement even though it does not help interfaces already running;
uapi/netlinkalready refuses configurations that cannot work, so there is precedent. Note that any width threshold used for such a check is not sufficient on its own, for the constant-band reason above.Happy to test a patch on the affected nodes.
@leonidorlov-hash commented on GitHub (Aug 30, 2026):
Related: the official AmneziaWG documentation was recently updated (https://docs.amnezia.org/ru/documentation/amnezia-wg/) and now prescribes
H1=1, H2=2, H3=3, H4=4when Header Protection is enabled — framed as «custom-header mechanism off, Header Protection hides the message type». Width-1 ranges equal to the real message types avoid the loss mechanism analysed here entirely.The doc also added a recommendation to set S1=S2=S3=S4 when RandomTrailers is on, explicitly to reduce packet-type misidentification — apparently an official acknowledgement of the classifier weakness, so far addressed via configuration guidance rather than a code change.
@bivlked commented on GitHub (Aug 30, 2026):
Correction to the analysis in the issue body. Two parts of it are wrong, as pointed out in
amneziawg-go#186and confirmed by me against this module's own source. The classifier defect itself and the five-width series are unaffected; the prevalence analysis and theH3interpretation are corrected below.1. The transport type field is not a constant.
src/send.c:291writes it asu32_range_pick_one(peer->device->transport_header), a draw fromH4. My sketch showing04 00 00 00at that offset is wrong.2. The constant-band regime does not arise when
HeaderProtectionKeyis set.src/receive.cderives the hash from a chacha keystream whose nonce is the head of the junk padding, andsend.c:298refills that padding withget_random_byteson every packet, so the candidate window is XORed with a fresh value each time and the probability is exactlywidth / 2³².That block is conditional, however:
hash[4] = {0}atreceive.c:105, and the chacha step runs only underif (wg->header_protection.has_protection). An interface configured without a header-protection key compares the raw window bytes. I make no claim about that case, having no clean measurement of it.Consequences for what the body says. Withdraw "narrowing a range is not automatically safe": with header protection configured it is a sound mitigation. The claim that roughly a third of generated sets contain a locked branch is withdrawn entirely. The prevalence figures should read:
The heavy tail was an artefact of the wrong rule, not a property of the generator. The mean is unchanged because a locked branch still contributes
width / 2³²on average over key draws.I am also withdrawing the explanation offered for the
H3observation in the Reproduction section, not the recorded result itself. The 0.0 % stands as measured; what does not survive either reading of the code is the account I gave of why. The likeliest cause is that the variant configured one range while omitting the other three and never applied. I will re-run it with an explicit check that the configuration took effect, plus a control pass ofawg setconfthat changes nothing, and post the result.A note for anyone reproducing. My harness takes the header-protection key as a separate option. The runs used for the per-header attribution in the issue body did not enable it, while the full-configuration runs did, so that table mixed two different regimes in one column without saying so. They should not be compared side by side.
@wiresock commented on GitHub (Sep 25, 2026):
We reproduced this while testing WireSock BoringTun interoperability with AWG 3.1. The reproducer below is
standalone and depends only on upstream AWG framing rules. It adds a deterministic fixture pair and live
same-counter evidence to this issue.
Reproducer (synthetic keys and parameters; Python +
cryptography):github.com/Wiresock-Foundation/awg-first-match-reproducer@99152cf1d5Both fixtures are one valid Transport packet (counter 42) under an installer-style profile: distinct
S1–S4, disjoint 10⁸-wide H ranges, RandomTrailers on, HP on. They have the same length (376), receiver
index, counter, ciphertext, Poly1305 tag and plaintext, and differ only in the S4 prefix and HP-masked header.
ambiguous-transport.binredrawn-transport.binawg_determine_type_and_padding()at4569c4cselectscheckverifies all of the above, andgeneraterebuilds the fixtures byte-for-byte from a fixed seed. Aseparate transliteration of
prepare_skb_header()+awg_determine_type_and_padding()agrees with thereproducer's classifier on 100,000 / 100,000 synthetic datagrams.
Live, against the module (3.1.20260812,
receive.cidentical to4569c4c, amneziawg-install AWG 3.1profile):
Their same-counter equivalents, with only the S4 prefix and masked header redrawn: 10 / 10.
those it sent. NIC captures showed no loss on the wire.
These rates are profile- and length-specific, not a universal loss probability. For the synthetic profile,
a population of 376-byte packets is misread at about 6.8 %, close to 1 − (1 − 10⁸/2³²)³.
Regarding #242: the fixture pair may be useful as an additional regression test. It is an authenticated
Transport datagram that the current code sends down the Response path, together with its same-counter twin.
When adapting the fixture to #242's test environment, its receiver index (
0xd551b0f6) would need to beregistered as a known keypair because that change consults keypair state.
@bivlked commented on GitHub (Sep 25, 2026):
@wiresock thanks, a fixture pair is much better evidence than the loss rates I started this issue with. I checked it on my side:
SHA256SUMSverifies,check fixturesprintsREPRODUCED, and the drop-in test fails on amneziawg-gob5928efexactly as you show, Response at padding 41 for the ambiguous fixture and Transport at 24 for the redrawn one.On the equal-S question from amneziawg-go#186, I ran it and posted the numbers there: https://github.com/amnezia-vpn/amneziawg-go/issues/186#issuecomment-5839848058. With S1 = S2 = S3 = S4 and disjoint H ranges, none of 20,000 transport datagrams per profile was taken for a control message, both in the real Go classifier and in your kernel transliteration, against 6.59 % with the fixtures' distinct S. For the module that is the transliteration, not a live run. It works around the classifier rather than fixing it, so #242 or an equivalent is still needed.
The H3 re-run I promised on 30 Aug is still outstanding; I will post it here when it is done.
@roquie commented on GitHub (Sep 25, 2026):
Independent confirmation from a production deployment: kernel module
v3.1.20260906on both ends (x86_64, kernels 6.8.0-generic and 7.0.0-azure), toolsv3.1.20260812.Config:
HeaderProtectionKeyset,S1=47 S2=77 S3=29 S4=18,H1–H4non-overlapping ranges 100,000,001 wide each,ContentPaddingAddition = 0-48,RandomTrailers = on.Observed:
This matches the classifier analysis above. Every transport packet exceeds all three handshake thresholds, so P(drop) = (|H1| + |H2| + |H3|) / 2^32 = 3 · 100000001 / 2^32 ≈ 6.98%.
With
RandomTrailers = off(H ranges also narrowed to 65536) loss went to 0% and TCP recovered to ~245/408 Mbit/s. That is consistent with #242 changing behaviour only when trailers are enabled.@bivlked commented on GitHub (Oct 2, 2026):
@roquie thanks, this is the first live run on the module in this thread, and it fits well. With three ranges of w = 100000001 / 2^32 each, the exact figure is 1 - (1 - w)^3 ≈ 6.82 %, so your measured 6.8 % is even closer than the sum (6.98 % counts the rare double hits twice).
If you have time for one more run: the same setup with S1 = S2 = S3 = S4, trailers still on. In amneziawg-go#186 equal S took the misreads from 6.59 % to zero, both in the Go classifier and in a copy of the module's classifier (numbers here). I haven't seen it checked on a live module yet. If your loss drops to zero there too, people who want RandomTrailers have a workaround until #242 is merged.
The H3 re-run I promised here is still on my list.