[GH-ISSUE #226] receive: with RandomTrailers on, sufficiently long transport packets are admitted to the handshake range checks and dropped #129

Open
opened 2026-10-13 21:06:49 +00:00 by bsakaguchi · 6 comments
Owner

Originally created by @bivlked on GitHub (Aug 28, 2026).
Original GitHub issue: https://github.com/amnezia-vpn/amneziawg-linux-kernel-module/issues/226

Correction, 30 Aug 2026. Parts of the analysis below are wrong and are
corrected in this comment:
the transport type field is drawn from H4 rather than being constant, the
constant-band regime does not arise when HeaderProtectionKey is set, the
prevalence figures are restated, and the H3 explanation is withdrawn. The
classifier defect itself and the measured five-width series are unaffected.

Affected source

  • Repository: amnezia-vpn/amneziawg-linux-kernel-module
  • Present in v3.1.20260812 (46803204e7ec) through v3.1.20260828 (3c38e168beb7), i.e. current at the time of writing
  • File: src/receive.c, function awg_determine_type_and_padding, guards at lines 51, 62 and 73
  • Observed on 3.1.20260812 (/sys/module/amneziawg/version) with amneziawg-tools v3.1.20260812

This is the kernel-module counterpart of amnezia-vpn/amneziawg-go#186, which analyses the same construct in amneziawg-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_padding classifies a datagram by trying initiation, then response, then cookie, and only then transport. The first three share one guard:

padding = wg->init_padding;
expected_len = padding + sizeof(struct message_handshake_initiation);
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)))) {

With random_trailers clear, skb->len == expected_len keeps transport packets out of these branches. With it set the test becomes skb->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 on u32_range_contains.

The thresholds follow from the paddings set by S1-S4 (netlink.c:817, :829, :841, :853): initiation S1 + 148, response S2 + 92, cookie S3 + 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 logs Received packet with invalid mac1 at 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_type XORs the four bytes at the branch's padding offset with a constant, and u32_range_contains is a plain bounds test, so the hit probability is the probability that those four bytes, read little-endian, fall inside the range. Whether that is width / 2³² depends on where the window lands. A transport packet is

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

message_header is documented in messages.h as u8 type plus u8 reserved_zero[3] encoded little-endian, so for MESSAGE_DATA those four bytes are the constant 04 00 00 00; the high bytes of the counter are constant zero in practice. If the window's byte at +3 lands 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 is width / 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 = on on the receiving side. Choose S1 so 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 make H1 wide. Then vary only the width of H1, recreating the interface between runs, and measure with ping through 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 same awg setconf without changing anything. I have had a case where setconf on its own accounted for the whole difference, which would have made any before-and-after reading look like a finding.

Observed on a veth pair 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:

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; 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 H3 was 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_trailers set 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, with S1 and S2 from 20..120, S3 from 10..60 and S4 from 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_idx above 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 RandomTrailers is 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/netlink already 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.

Originally created by @bivlked on GitHub (Aug 28, 2026). Original GitHub issue: https://github.com/amnezia-vpn/amneziawg-linux-kernel-module/issues/226 > **Correction, 30 Aug 2026.** Parts of the analysis below are wrong and are > corrected in [this comment](https://github.com/amnezia-vpn/amneziawg-linux-kernel-module/issues/226#issuecomment-5467505732): > the transport type field is drawn from `H4` rather than being constant, the > constant-band regime does not arise when `HeaderProtectionKey` is set, the > prevalence figures are restated, and the `H3` explanation is withdrawn. The > classifier defect itself and the measured five-width series are unaffected. ## Affected source - Repository: `amnezia-vpn/amneziawg-linux-kernel-module` - Present in `v3.1.20260812` (`46803204e7ec`) through `v3.1.20260828` (`3c38e168beb7`), i.e. current at the time of writing - File: `src/receive.c`, function `awg_determine_type_and_padding`, guards at lines 51, 62 and 73 - Observed on `3.1.20260812` (`/sys/module/amneziawg/version`) with `amneziawg-tools v3.1.20260812` This is the kernel-module counterpart of `amnezia-vpn/amneziawg-go#186`, which analyses the same construct in `amneziawg-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_padding` classifies a datagram by trying initiation, then response, then cookie, and only then transport. The first three share one guard: ```c padding = wg->init_padding; expected_len = padding + sizeof(struct message_handshake_initiation); 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)))) { ``` With `random_trailers` clear, `skb->len == expected_len` keeps transport packets out of these branches. With it set the test becomes `skb->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 on `u32_range_contains`. The thresholds follow from the paddings set by `S1`-`S4` (`netlink.c:817`, `:829`, `:841`, `:853`): initiation `S1 + 148`, response `S2 + 92`, cookie `S3 + 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 logs `Received packet with invalid mac1` at 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_type` XORs the four bytes at the branch's padding offset with a constant, and `u32_range_contains` is a plain bounds test, so the hit probability is the probability that those four bytes, read little-endian, fall inside the range. Whether that is `width / 2³²` depends on where the window lands. A transport packet is ``` [ S4 bytes of junk ][ 04 00 00 00 ][ key_idx 4 ][ counter LE64 8 ][ ciphertext ] 0 S4 S4+4 S4+8 S4+16 ``` `message_header` is documented in `messages.h` as `u8 type` plus `u8 reserved_zero[3]` encoded little-endian, so for `MESSAGE_DATA` those four bytes are the constant `04 00 00 00`; the high bytes of the counter are constant zero in practice. If the window's byte at `+3` lands 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 is `width / 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 = on` on the receiving side. Choose `S1` so 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 make `H1` wide. Then vary only the width of `H1`, recreating the interface between runs, and measure with `ping` through 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 same `awg setconf` without changing anything. I have had a case where `setconf` on its own accounted for the whole difference, which would have made any before-and-after reading look like a finding. Observed on a `veth` pair 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: | `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; 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 `H3` was 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_trailers` set 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, with `S1` and `S2` from 20..120, `S3` from 10..60 and `S4` from 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_idx` above 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 `RandomTrailers` is 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`/`netlink` already 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.
Author
Owner

@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=4 when 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.

<!-- gh-comment-id:5465744538 --> @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=4` when 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.
Author
Owner

@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#186 and confirmed by me against this module's own source. The classifier defect itself and the five-width series are unaffected; the prevalence analysis and the H3 interpretation are corrected below.

1. The transport type field is not a constant. src/send.c:291 writes it as u32_range_pick_one(peer->device->transport_header), a draw from H4. My sketch showing 04 00 00 00 at that offset is wrong.

2. The constant-band regime does not arise when HeaderProtectionKey is set. src/receive.c derives the hash from a chacha keystream whose nonce is the head of the junk padding, and send.c:298 refills that padding with get_random_bytes on every packet, so the candidate window is XORed with a fresh value each time and the probability is exactly width / 2³².

That block is conditional, however: hash[4] = {0} at receive.c:105, and the chacha step runs only under if (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:

as filed 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 %

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 H3 observation 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 of awg setconf that 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.

<!-- gh-comment-id:5467505732 --> @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#186` and confirmed by me against this module's own source. The classifier defect itself and the five-width series are unaffected; the prevalence analysis and the `H3` interpretation are corrected below. **1. The transport type field is not a constant.** `src/send.c:291` writes it as `u32_range_pick_one(peer->device->transport_header)`, a draw from `H4`. My sketch showing `04 00 00 00` at that offset is wrong. **2. The constant-band regime does not arise when `HeaderProtectionKey` is set.** `src/receive.c` derives the hash from a chacha keystream whose nonce is the head of the junk padding, and `send.c:298` refills that padding with `get_random_bytes` on every packet, so the candidate window is XORed with a fresh value each time and the probability is exactly `width / 2³²`. That block is conditional, however: `hash[4] = {0}` at `receive.c:105`, and the chacha step runs only under `if (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: | | as filed | 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 %** | 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 `H3` observation 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 of `awg setconf` that 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.
Author
Owner

@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@99152cf1d5

Both 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.bin redrawn-transport.bin
AEAD open as Transport verifies verifies
awg_determine_type_and_padding() at 4569c4c selects Response Transport
result mac1 fails, dropped; Transport never tried selected as Transport; Transport AEAD verifies

check verifies all of the above, and generate rebuilds the fixtures byte-for-byte from a fixed seed. A
separate transliteration of prepare_skb_header() + awg_determine_type_and_padding() agrees with the
reproducer's classifier on 100,000 / 100,000 synthetic datagrams.

Live, against the module (3.1.20260812, receive.c identical to 4569c4c, amneziawg-install AWG 3.1
profile):

  • Captured ambiguous Transport frames re-sent byte-for-byte: 0 / 10 delivered to the tunnel interface.
    Their same-counter equivalents, with only the S4 prefix and masked header redrawn: 10 / 10.
  • About 4.6–4.8 % of Transport frames were ambiguous under that profile, both those sent to the module and
    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 be
registered as a known keypair because that change consults keypair state.

<!-- gh-comment-id:5827320113 --> @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`): https://github.com/Wiresock-Foundation/awg-first-match-reproducer/tree/99152cf1d54ab6df2e6c76d362bf08311dcc4c22 Both 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.bin` | `redrawn-transport.bin` | |---|---|---| | AEAD open as Transport | verifies | verifies | | `awg_determine_type_and_padding()` at `4569c4c` selects | **Response** | Transport | | result | mac1 fails, dropped; Transport never tried | selected as Transport; Transport AEAD verifies | `check` verifies all of the above, and `generate` rebuilds the fixtures byte-for-byte from a fixed seed. A separate transliteration of `prepare_skb_header()` + `awg_determine_type_and_padding()` agrees with the reproducer's classifier on 100,000 / 100,000 synthetic datagrams. **Live, against the module** (3.1.20260812, `receive.c` identical to `4569c4c`, amneziawg-install AWG 3.1 profile): - Captured ambiguous Transport frames re-sent byte-for-byte: **0 / 10** delivered to the tunnel interface. Their same-counter equivalents, with only the S4 prefix and masked header redrawn: **10 / 10**. - About 4.6–4.8 % of Transport frames were ambiguous under that profile, both those sent to the module and 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 be registered as a known keypair because that change consults keypair state.
Author
Owner

@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: SHA256SUMS verifies, check fixtures prints REPRODUCED, and the drop-in test fails on amneziawg-go b5928ef exactly 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.

<!-- gh-comment-id:5839854225 --> @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: `SHA256SUMS` verifies, `check fixtures` prints `REPRODUCED`, and the drop-in test fails on amneziawg-go `b5928ef` exactly 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.
Author
Owner

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

Independent confirmation from a production deployment: kernel module v3.1.20260906 on both ends (x86_64, kernels 6.8.0-generic and 7.0.0-azure), tools v3.1.20260812.

Config: HeaderProtectionKey set, S1=47 S2=77 S3=29 S4=18, H1–H4 non-overlapping ranges 100,000,001 wide each, ContentPaddingAddition = 0-48, RandomTrailers = on.

Observed:

  • a constant ~6.8% loss through the tunnel in both directions, regardless of rate (UDP at 5/20/50 Mbit/s) and payload size (200 and 1200 bytes);
  • cubic TCP collapsed to ~0.65 Mbit/s;
  • the same path outside the tunnel had 0% loss;
  • tcpdump on the receiver's NIC saw every datagram (2519 captured for 2500 sent), so the drop happens inside the module.

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.

<!-- gh-comment-id:5840955116 --> @roquie commented on GitHub (Sep 25, 2026): Independent confirmation from a production deployment: kernel module `v3.1.20260906` on both ends (x86_64, kernels 6.8.0-generic and 7.0.0-azure), tools `v3.1.20260812`. Config: `HeaderProtectionKey` set, `S1=47 S2=77 S3=29 S4=18`, `H1`–`H4` non-overlapping ranges 100,000,001 wide each, `ContentPaddingAddition = 0-48`, `RandomTrailers = on`. Observed: - a constant ~6.8% loss through the tunnel in both directions, regardless of rate (UDP at 5/20/50 Mbit/s) and payload size (200 and 1200 bytes); - cubic TCP collapsed to ~0.65 Mbit/s; - the same path outside the tunnel had 0% loss; - tcpdump on the receiver's NIC saw every datagram (2519 captured for 2500 sent), so the drop happens inside the module. 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.
Author
Owner

@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.

<!-- gh-comment-id:5947131761 --> @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](https://github.com/amnezia-vpn/amneziawg-go/issues/186#issuecomment-5839848058)). 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.
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-linux-kernel-module#129
No description provided.