[GH-ISSUE #71] u16_range_from_string accepts values above UINT16_MAX and silently truncates AWG 3.x ranges #33

Open
opened 2026-10-13 21:02:35 +00:00 by bsakaguchi · 0 comments
Owner

Originally created by @2Grey on GitHub (Aug 31, 2026).
Original GitHub issue: https://github.com/amnezia-vpn/amneziawg-tools/issues/71

Summary

u16_range_from_string() validates parsed values against UINT32_MAX, but stores them in a range composed of two uint16_t values.

As a result, values from 65536 through 4294967295 are accepted and silently truncated modulo 65536.

This affects AWG 3.x timing, padding and persistent-keepalive parameters parsed by both configuration files and the awg set command.

Affected source

  • Repository: amnezia-vpn/amneziawg-tools
  • Confirmed in tag: v3.1.20260812
  • File: src/type.c
  • Function: u16_range_from_string()

Source:

https://github.com/amnezia-vpn/amneziawg-tools/blob/v3.1.20260812/src/type.c#L40-L59

Root cause

The parser uses unsigned long:

unsigned long lo, hi;

but validates both values against UINT32_MAX:

lo = strtoul(str, &end, 10);
if (end == str || lo > UINT32_MAX)
	return false;

/* ... */

hi = strtoul(end + 1, &end, 10);
if (*end || hi > UINT32_MAX || hi < lo)
	return false;

The result is then passed to:

*range = u16_range_init(lo, hi);

However, u16_range_init() takes uint16_t arguments:

https://github.com/amnezia-vpn/amneziawg-tools/blob/v3.1.20260812/src/type.h#L32-L43

typedef uint32_t u16_range_t;

static inline u16_range_t u16_range_init(uint16_t lo, uint16_t hi)
{
	return (u16_range_t)((uint32_t)(hi) << 16 | (uint32_t)(lo));
}

The implicit conversion to uint16_t silently discards the upper bits.

Examples

Input Parser result Stored range
65535 accepted 65535
65536 accepted 0
65537 accepted 1
70000 accepted 4464
65535-65536 accepted 65535-0
70000-70010 accepted 4464-4474
4294967295 accepted 65535

The ascending-range check happens before truncation:

hi < lo

Therefore an input that is ascending before conversion can become descending after conversion. For example:

65535-65536

becomes:

65535-0

Affected configuration parameters

u16_range_from_string() is used for:

  • ContentPaddingAddition
  • RekeyAfterTime
  • RekeyTimeout
  • RejectAfterTime
  • KeepaliveTimeout
  • MaxHandshakeAttempts
  • peer PersistentKeepalive

References:

https://github.com/amnezia-vpn/amneziawg-tools/blob/v3.1.20260812/src/config.c#L552-L575

https://github.com/amnezia-vpn/amneziawg-tools/blob/v3.1.20260812/src/config.c#L587-L599

The same parser is also used by the corresponding awg set command-line options.

Reproduction

Create an AmneziaWG interface:

ip link add awg-test type amneziawg

Set a value immediately above the uint16_t limit:

awg set awg-test reject-after-time 65536

The command is expected to reject the value, but the parser accepts it and stores zero.

Read it back:

awg show awg-test reject-after-time

Expected:

Unable to parse RejectAfterTime: `65536'

Actual:

0

Another useful case is:

awg set awg-test reject-after-time 65535-65536
awg show awg-test reject-after-time

The parser accepts an ascending input, but after truncation the packed range becomes equivalent to:

65535-0

Clean up:

ip link delete awg-test

A kernel module is not required to demonstrate the parser bug itself. It can also be reproduced with a small unit test:

#include <stdio.h>
#include "type.h"

int main(void)
{
	u16_range_t range;

	if (!u16_range_from_string(&range, "70000"))
		return 1;

	printf("%u-%u\n",
	       u16_range_lo(range),
	       u16_range_hi(range));

	return 0;
}

Actual output:

4464-4464

Impact

Silent configuration corruption

A value such as:

RejectAfterTime = 65536

is silently converted to zero. Since zero commonly means “use the default”, the running configuration no longer matches the supplied configuration.

This is difficult to diagnose because awg setconf succeeds.

Invalid packed ranges

Inputs crossing the 65535 boundary can become descending ranges after conversion:

65535-65536 → 65535-0

The kernel does not validate the ordering when accepting the packed NLA_U32 timer attributes.

Its range selector calls:

get_random_u32_inclusive(lo, hi)

with the truncated values. A descending range can therefore underflow the random-range calculation and produce values unrelated to the original configuration.

Client/server mismatch

If another implementation correctly rejects the value or uses a wider type, the two peers may end up with different timer or padding behavior despite receiving the same textual configuration.

Expected behavior

All inputs above UINT16_MAX should be rejected before constructing u16_range_t.

For example:

65535       valid
65536       invalid
10-20       valid
65535-65536 invalid
70000       invalid

No accepted input should be able to produce a range where:

lo > hi

after conversion.

Suggested fix

Validate against UINT16_MAX:

- if (end == str || lo > UINT32_MAX)
+ if (end == str || lo > UINT16_MAX)
	return false;

and:

- if (*end || hi > UINT32_MAX || hi < lo)
+ if (*end || hi > UINT16_MAX || hi < lo)
	return false;

It would also be safer to handle strtoul() overflow explicitly:

errno = 0;
lo = strtoul(str, &end, 10);

if (end == str || errno == ERANGE || lo > UINT16_MAX)
	return false;

The same should be done before parsing the upper bound.

Suggested tests

Add parser tests for at least:

0                 valid
1                 valid
65535             valid
65536             invalid
70000             invalid
0-65535           valid
10-20             valid
20-10             invalid
65535-65536       invalid
70000-70010       invalid
4294967295        invalid
99999999999999999 invalid

The tests should verify both the parser return value and the resulting lo/hi values for accepted inputs.

Originally created by @2Grey on GitHub (Aug 31, 2026). Original GitHub issue: https://github.com/amnezia-vpn/amneziawg-tools/issues/71 ## Summary `u16_range_from_string()` validates parsed values against `UINT32_MAX`, but stores them in a range composed of two `uint16_t` values. As a result, values from `65536` through `4294967295` are accepted and silently truncated modulo 65536. This affects AWG 3.x timing, padding and persistent-keepalive parameters parsed by both configuration files and the `awg set` command. ## Affected source - Repository: `amnezia-vpn/amneziawg-tools` - Confirmed in tag: `v3.1.20260812` - File: `src/type.c` - Function: `u16_range_from_string()` Source: https://github.com/amnezia-vpn/amneziawg-tools/blob/v3.1.20260812/src/type.c#L40-L59 ## Root cause The parser uses `unsigned long`: ```c unsigned long lo, hi; ``` but validates both values against `UINT32_MAX`: ```c lo = strtoul(str, &end, 10); if (end == str || lo > UINT32_MAX) return false; /* ... */ hi = strtoul(end + 1, &end, 10); if (*end || hi > UINT32_MAX || hi < lo) return false; ``` The result is then passed to: ```c *range = u16_range_init(lo, hi); ``` However, `u16_range_init()` takes `uint16_t` arguments: https://github.com/amnezia-vpn/amneziawg-tools/blob/v3.1.20260812/src/type.h#L32-L43 ```c typedef uint32_t u16_range_t; static inline u16_range_t u16_range_init(uint16_t lo, uint16_t hi) { return (u16_range_t)((uint32_t)(hi) << 16 | (uint32_t)(lo)); } ``` The implicit conversion to `uint16_t` silently discards the upper bits. ## Examples | Input | Parser result | Stored range | |---|---:|---:| | `65535` | accepted | `65535` | | `65536` | accepted | `0` | | `65537` | accepted | `1` | | `70000` | accepted | `4464` | | `65535-65536` | accepted | `65535-0` | | `70000-70010` | accepted | `4464-4474` | | `4294967295` | accepted | `65535` | The ascending-range check happens before truncation: ```c hi < lo ``` Therefore an input that is ascending before conversion can become descending after conversion. For example: ```text 65535-65536 ``` becomes: ```text 65535-0 ``` ## Affected configuration parameters `u16_range_from_string()` is used for: - `ContentPaddingAddition` - `RekeyAfterTime` - `RekeyTimeout` - `RejectAfterTime` - `KeepaliveTimeout` - `MaxHandshakeAttempts` - peer `PersistentKeepalive` References: https://github.com/amnezia-vpn/amneziawg-tools/blob/v3.1.20260812/src/config.c#L552-L575 https://github.com/amnezia-vpn/amneziawg-tools/blob/v3.1.20260812/src/config.c#L587-L599 The same parser is also used by the corresponding `awg set` command-line options. ## Reproduction Create an AmneziaWG interface: ```bash ip link add awg-test type amneziawg ``` Set a value immediately above the `uint16_t` limit: ```bash awg set awg-test reject-after-time 65536 ``` The command is expected to reject the value, but the parser accepts it and stores zero. Read it back: ```bash awg show awg-test reject-after-time ``` Expected: ```text Unable to parse RejectAfterTime: `65536' ``` Actual: ```text 0 ``` Another useful case is: ```bash awg set awg-test reject-after-time 65535-65536 awg show awg-test reject-after-time ``` The parser accepts an ascending input, but after truncation the packed range becomes equivalent to: ```text 65535-0 ``` Clean up: ```bash ip link delete awg-test ``` A kernel module is not required to demonstrate the parser bug itself. It can also be reproduced with a small unit test: ```c #include <stdio.h> #include "type.h" int main(void) { u16_range_t range; if (!u16_range_from_string(&range, "70000")) return 1; printf("%u-%u\n", u16_range_lo(range), u16_range_hi(range)); return 0; } ``` Actual output: ```text 4464-4464 ``` ## Impact ### Silent configuration corruption A value such as: ```ini RejectAfterTime = 65536 ``` is silently converted to zero. Since zero commonly means “use the default”, the running configuration no longer matches the supplied configuration. This is difficult to diagnose because `awg setconf` succeeds. ### Invalid packed ranges Inputs crossing the 65535 boundary can become descending ranges after conversion: ```text 65535-65536 → 65535-0 ``` The kernel does not validate the ordering when accepting the packed `NLA_U32` timer attributes. Its range selector calls: ```c get_random_u32_inclusive(lo, hi) ``` with the truncated values. A descending range can therefore underflow the random-range calculation and produce values unrelated to the original configuration. ### Client/server mismatch If another implementation correctly rejects the value or uses a wider type, the two peers may end up with different timer or padding behavior despite receiving the same textual configuration. ## Expected behavior All inputs above `UINT16_MAX` should be rejected before constructing `u16_range_t`. For example: ```text 65535 valid 65536 invalid 10-20 valid 65535-65536 invalid 70000 invalid ``` No accepted input should be able to produce a range where: ```text lo > hi ``` after conversion. ## Suggested fix Validate against `UINT16_MAX`: ```diff - if (end == str || lo > UINT32_MAX) + if (end == str || lo > UINT16_MAX) return false; ``` and: ```diff - if (*end || hi > UINT32_MAX || hi < lo) + if (*end || hi > UINT16_MAX || hi < lo) return false; ``` It would also be safer to handle `strtoul()` overflow explicitly: ```c errno = 0; lo = strtoul(str, &end, 10); if (end == str || errno == ERANGE || lo > UINT16_MAX) return false; ``` The same should be done before parsing the upper bound. ## Suggested tests Add parser tests for at least: ```text 0 valid 1 valid 65535 valid 65536 invalid 70000 invalid 0-65535 valid 10-20 valid 20-10 invalid 65535-65536 invalid 70000-70010 invalid 4294967295 invalid 99999999999999999 invalid ``` The tests should verify both the parser return value and the resulting `lo`/`hi` values for accepted inputs.
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-tools#33
No description provided.