[GH-ISSUE #3020] Windows: large CIDR exclusion lists make AmneziaWG connect path quadratic and can crash the client #1383

Open
opened 2026-10-13 20:41:16 +00:00 by bsakaguchi · 1 comment
Owner

Originally created by @ShiroKSH on GitHub (Aug 22, 2026).
Original GitHub issue: https://github.com/amnezia-vpn/amnezia-client/issues/3020

Environment

  • Windows 11 Pro 25H2, build 26200.9168, x64
  • AmneziaVPN 4.8.15.4
  • AmneziaWG 2
  • Site split tunneling: VPN for all sites except listed addresses
  • 12,840 IPv4 CIDR prefixes plus 6 existing domain entries

The CIDR source was the August 22 desktop asset from lib4u/amnezia-tunneling-ru:

Observed behavior

Starting from a working six-entry exclusion list and 35 IPv4 routes:

  1. The 12,840 CIDRs were added to Conf/ExceptSites, producing 12,846 map entries and a 1,062,416-byte QSettings registry value.
  2. AmneziaVPN was started and connected to the existing AmneziaWG 2 profile.
  3. The Windows route table grew very slowly. It had 10,732 IPv4 routes at 17:28:49 and reached 12,875 at 17:29:47, roughly 40 routes per second near the end.
  4. The application and desktop became severely laggy. Connecting from the normal UI stopped working reliably, and the application later crashed.
  5. Windows Event Log recorded an Application Error event at 2026-08-22 17:33:51 +03:00:
Faulting application: AmneziaVPN.exe 4.8.15.4
Faulting module: AmneziaVPN.exe 4.8.15.4
Exception code: 0xc0000005
Fault offset: 0x00000000000e9256
Report ID: ec1e34e6-e497-4846-8376-511a32301abe
  1. With the large list persisted, the GUI remained unusable. Restoring the previous registry backup reduced ExceptSites to 788 bytes / 6 entries; the application then started and connected normally again.

The observed run populated the same QVariantMap representation that AmneziaVPN stores in QSettings. Before writing the large value, the encoder was checked by decoding and re-encoding the original six-entry value byte-for-byte. The client subsequently parsed the large value and installed all 12,840 routes, so this was not a malformed JSON/QSettings failure.

Reproduction

  1. On Windows, create or select an AmneziaWG 2 profile.
  2. Enable site split tunneling in the all-sites-except-listed mode.
  3. Load the linked 12,840-prefix amnezia-ip.json list into the exclusion list.
  4. Connect.
  5. Observe the time required for route creation, GUI responsiveness, and the IPv4 route count.
  6. Restart the client with the large list still persisted.

Expected: route setup is bounded and the GUI remains responsive; a list too large for the implementation is rejected before it can make the client hard to recover.

Actual: route setup takes minutes, the GUI becomes difficult or impossible to use, and this run ended in an access-violation crash. Recovery required restoring the registry backup outside the application.

Source-level cause of the slow connect

The slow path is deterministic:

  1. Daemon::activate() calls addExclusionRoute() once for every excluded CIDR.
  2. WireguardUtilsWindows::addExclusionRoute() delegates each item to WindowsRouteMonitor.
  3. WindowsRouteMonitor::addExclusionRoute() calls GetIpForwardTable2(), rebuilds interface metrics, and scans the returned table in updateExclusionRoute() for every single prefix.

Because the table grows by one route on every iteration, 12,840 exclusions with a 35-route baseline require at least 82,875,780 route-row visits, 12,840 full routing-table snapshots, and 12,840 interface-metric table reads before normal Windows API overhead.

This per-prefix implementation was introduced by #314 / commit 07c38e9b6ca879729197d67f0ebc1f02c186632f and is unchanged in 5.0.1.5 and current dev (7d4f3e0f5090b74903609179653d1f669d2ad08a).

The quadratic route setup explains the multi-minute connection and system pressure. The exact source location of the later 0xc0000005 crash is not proven without a dump or matching PDB; the Event Log only identifies the executable and offset.

Suggested fix

Add a bulk exclusion-route API for Windows:

  • fetch the non-VPN routing table once for the batch;
  • update interface metrics once;
  • compute the best physical route for every exclusion against that snapshot;
  • create and record the exclusion rows without refetching and rescanning the growing table;
  • run the existing one-snapshot reconciliation path after the batch;
  • add a focused large-list performance/regression test.

A preflight limit with a recoverable warning would also be safer than accepting a list that can make the stored configuration unusable, but batching should address the underlying scaling bug.

  • #2572 concerns the separate VLESS RouterWin::routeAddList() path and its wcmSvc suspension workaround. AmneziaWG 2 uses WindowsRouteMonitor, so that fix does not cover this case.
  • #2938 / #2930 fixed a domain-resolution stack overflow caused by nested flushDns().waitForFinished() callbacks. This list contained 12,840 CIDRs and only six domain entries; the observed exception was 0xc0000005, not 0xc00000fd.
  • #1135 reported the broad boot-hang symptom but was closed without addressing this Windows route-scaling path.
Originally created by @ShiroKSH on GitHub (Aug 22, 2026). Original GitHub issue: https://github.com/amnezia-vpn/amnezia-client/issues/3020 ## Environment - Windows 11 Pro 25H2, build 26200.9168, x64 - AmneziaVPN 4.8.15.4 - AmneziaWG 2 - Site split tunneling: VPN for all sites except listed addresses - 12,840 IPv4 CIDR prefixes plus 6 existing domain entries The CIDR source was the August 22 desktop asset from `lib4u/amnezia-tunneling-ru`: - Asset: https://github.com/lib4u/amnezia-tunneling-ru/releases/download/latest/amnezia-ip.json - Size: 727,649 bytes - SHA-256: `97976ce1ea6ee948be3eb3ac9bc3f88bb4c1f6b88c7cdb0483260ad9c29fe7ed` - GitHub asset ID: `524459599` ## Observed behavior Starting from a working six-entry exclusion list and 35 IPv4 routes: 1. The 12,840 CIDRs were added to `Conf/ExceptSites`, producing 12,846 map entries and a 1,062,416-byte QSettings registry value. 2. AmneziaVPN was started and connected to the existing AmneziaWG 2 profile. 3. The Windows route table grew very slowly. It had 10,732 IPv4 routes at 17:28:49 and reached 12,875 at 17:29:47, roughly 40 routes per second near the end. 4. The application and desktop became severely laggy. Connecting from the normal UI stopped working reliably, and the application later crashed. 5. Windows Event Log recorded an `Application Error` event at 2026-08-22 17:33:51 +03:00: ```text Faulting application: AmneziaVPN.exe 4.8.15.4 Faulting module: AmneziaVPN.exe 4.8.15.4 Exception code: 0xc0000005 Fault offset: 0x00000000000e9256 Report ID: ec1e34e6-e497-4846-8376-511a32301abe ``` 6. With the large list persisted, the GUI remained unusable. Restoring the previous registry backup reduced `ExceptSites` to 788 bytes / 6 entries; the application then started and connected normally again. The observed run populated the same QVariantMap representation that AmneziaVPN stores in QSettings. Before writing the large value, the encoder was checked by decoding and re-encoding the original six-entry value byte-for-byte. The client subsequently parsed the large value and installed all 12,840 routes, so this was not a malformed JSON/QSettings failure. ## Reproduction 1. On Windows, create or select an AmneziaWG 2 profile. 2. Enable site split tunneling in the all-sites-except-listed mode. 3. Load the linked 12,840-prefix `amnezia-ip.json` list into the exclusion list. 4. Connect. 5. Observe the time required for route creation, GUI responsiveness, and the IPv4 route count. 6. Restart the client with the large list still persisted. Expected: route setup is bounded and the GUI remains responsive; a list too large for the implementation is rejected before it can make the client hard to recover. Actual: route setup takes minutes, the GUI becomes difficult or impossible to use, and this run ended in an access-violation crash. Recovery required restoring the registry backup outside the application. ## Source-level cause of the slow connect The slow path is deterministic: 1. [`Daemon::activate()`](https://github.com/amnezia-vpn/amnezia-client/blob/4.8.15.4/client/daemon/daemon.cpp#L134-L136) calls `addExclusionRoute()` once for every excluded CIDR. 2. [`WireguardUtilsWindows::addExclusionRoute()`](https://github.com/amnezia-vpn/amnezia-client/blob/4.8.15.4/client/platforms/windows/daemon/wireguardutilswindows.cpp#L309-L310) delegates each item to `WindowsRouteMonitor`. 3. [`WindowsRouteMonitor::addExclusionRoute()`](https://github.com/amnezia-vpn/amnezia-client/blob/4.8.15.4/client/platforms/windows/daemon/windowsroutemonitor.cpp#L368-L432) calls `GetIpForwardTable2()`, rebuilds interface metrics, and scans the returned table in `updateExclusionRoute()` for every single prefix. Because the table grows by one route on every iteration, 12,840 exclusions with a 35-route baseline require at least 82,875,780 route-row visits, 12,840 full routing-table snapshots, and 12,840 interface-metric table reads before normal Windows API overhead. This per-prefix implementation was introduced by #314 / commit `07c38e9b6ca879729197d67f0ebc1f02c186632f` and is unchanged in 5.0.1.5 and current `dev` (`7d4f3e0f5090b74903609179653d1f669d2ad08a`). The quadratic route setup explains the multi-minute connection and system pressure. The exact source location of the later `0xc0000005` crash is not proven without a dump or matching PDB; the Event Log only identifies the executable and offset. ## Suggested fix Add a bulk exclusion-route API for Windows: - fetch the non-VPN routing table once for the batch; - update interface metrics once; - compute the best physical route for every exclusion against that snapshot; - create and record the exclusion rows without refetching and rescanning the growing table; - run the existing one-snapshot reconciliation path after the batch; - add a focused large-list performance/regression test. A preflight limit with a recoverable warning would also be safer than accepting a list that can make the stored configuration unusable, but batching should address the underlying scaling bug. ## Related issues - #2572 concerns the separate VLESS `RouterWin::routeAddList()` path and its `wcmSvc` suspension workaround. AmneziaWG 2 uses `WindowsRouteMonitor`, so that fix does not cover this case. - #2938 / #2930 fixed a domain-resolution stack overflow caused by nested `flushDns().waitForFinished()` callbacks. This list contained 12,840 CIDRs and only six domain entries; the observed exception was `0xc0000005`, not `0xc00000fd`. - #1135 reported the broad boot-hang symptom but was closed without addressing this Windows route-scaling path.
Author
Owner

@HeisLuka commented on GitHub (Sep 9, 2026):

I traced this through the current dev source and the slowdown looks deterministic in the Windows exclusion-route path.

Daemon::activate() currently adds every excluded prefix individually. On Windows each item reaches WindowsRouteMonitor::addExclusionRoute(), which does all of the following for every prefix:

  1. GetIpForwardTable2(family)
  2. updateInterfaceMetrics(family) -> GetIpInterfaceTable(family)
  3. a full routing-table scan in updateExclusionRoute()

Because successful exclusions are also added to the Windows routing table, the table being fetched/scanned grows while the list is processed. For N exclusions and R initial routes, the route-row work is approximately:

N * R + N * (N - 1) / 2

so the connect path becomes effectively O(N^2).

Suggested implementation shape

I would fix this at the batch boundary rather than trying to micro-optimize updateExclusionRoute().

At the generic WireguardUtils level, something like:

virtual bool addExclusionRoutes(const QList<IPAddress>& prefixes) {
    for (const IPAddress& prefix : prefixes) {
        if (!addExclusionRoute(prefix)) {
            return false;
        }
    }
    return true;
}

This keeps Linux/macOS behavior unchanged by default.

WireguardUtilsWindows could override it and delegate to a Windows batch method:

bool WireguardUtilsWindows::addExclusionRoutes(
    const QList<IPAddress>& prefixes) {
    return m_routeMonitor->addExclusionRoutes(prefixes);
}

Then WindowsRouteMonitor::addExclusionRoutes() can:

  1. validate/filter the prefixes;
  2. split them into IPv4 and IPv6 groups;
  3. for each non-empty family, call GetIpForwardTable2() once;
  4. call updateInterfaceMetrics() once for that family;
  5. build all exclusion rows;
  6. resolve every row against the same immutable routing-table snapshot;
  7. insert the resulting rows into m_exclusionRoutes;
  8. reconcile captured routes once after the whole family batch.

In pseudocode:

for family in {IPv4, IPv6}:
    prefixes = pending prefixes for family
    if empty: continue

    routes = GetIpForwardTable2(family)
    updateInterfaceMetrics(family)

    for prefix in prefixes:
        row = buildExclusionRow(prefix)
        resolvePhysicalRoute(row, routes)
        stage(row)

    install/stash staged rows
    updateCapturedRoutes(family, routes)

The important point is that route selection for all prefixes in the batch uses the same snapshot; installing exclusion routes must not trigger a fresh FIB/interface observation for every item.

Daemon ownership/refcount detail

Daemon::m_excludedAddrSet is a reference count, so I would also add a small batch helper there:

  • already-owned prefixes: only increment the refcount;
  • only previously unseen prefixes go to wgutils()->addExclusionRoutes();
  • commit the new refcount entries only after the platform batch succeeds;
  • activate() / switchServer() should check the batch result instead of silently continuing if exclusion setup fails.

This preserves the current multi-hop/server-switch ownership semantics while allowing Windows to optimize only the actually-new routes.

Failure handling

One small API issue is that WindowsRouteMonitor::updateExclusionRoute() currently returns void and logs CreateIpForwardEntry2() failures internally. For a transactional batch it would be useful either to return a status or split route selection from route installation, so a failed batch can roll back only routes created by that batch and avoid committing incorrect ownership state in Daemon.

Regression test

I would make the primary regression test API-call based, not timing based. With e.g. 10,000 IPv4 exclusions, activation should perform O(1) GetIpForwardTable2(AF_INET) / GetIpInterfaceTable(AF_INET) calls for the exclusion batch, not 10,000 calls each.

Useful semantic cases to keep in the same test matrix:

  • duplicate prefix already owned by another active hop -> refcount increment only;
  • mixed IPv4/IPv6 -> one snapshot/metric refresh per family;
  • exact pre-existing physical route -> preserve the current no-duplicate-route behavior;
  • partial route creation failure -> no stale refcount ownership and rollback of routes created by the failed batch;
  • server switch -> add new exclusions before removing old ones;
  • later Windows route-change notification -> normal routeChanged() reconciliation still updates next-hop/interface.

This should remove the quadratic setup behavior without changing the actual route-selection policy.

<!-- gh-comment-id:5606894837 --> @HeisLuka commented on GitHub (Sep 9, 2026): I traced this through the current `dev` source and the slowdown looks deterministic in the Windows exclusion-route path. `Daemon::activate()` currently adds every excluded prefix individually. On Windows each item reaches `WindowsRouteMonitor::addExclusionRoute()`, which does all of the following for every prefix: 1. `GetIpForwardTable2(family)` 2. `updateInterfaceMetrics(family)` -> `GetIpInterfaceTable(family)` 3. a full routing-table scan in `updateExclusionRoute()` Because successful exclusions are also added to the Windows routing table, the table being fetched/scanned grows while the list is processed. For N exclusions and R initial routes, the route-row work is approximately: `N * R + N * (N - 1) / 2` so the connect path becomes effectively O(N^2). ### Suggested implementation shape I would fix this at the batch boundary rather than trying to micro-optimize `updateExclusionRoute()`. At the generic `WireguardUtils` level, something like: ```cpp virtual bool addExclusionRoutes(const QList<IPAddress>& prefixes) { for (const IPAddress& prefix : prefixes) { if (!addExclusionRoute(prefix)) { return false; } } return true; } ``` This keeps Linux/macOS behavior unchanged by default. `WireguardUtilsWindows` could override it and delegate to a Windows batch method: ```cpp bool WireguardUtilsWindows::addExclusionRoutes( const QList<IPAddress>& prefixes) { return m_routeMonitor->addExclusionRoutes(prefixes); } ``` Then `WindowsRouteMonitor::addExclusionRoutes()` can: 1. validate/filter the prefixes; 2. split them into IPv4 and IPv6 groups; 3. for each non-empty family, call `GetIpForwardTable2()` once; 4. call `updateInterfaceMetrics()` once for that family; 5. build all exclusion rows; 6. resolve every row against the same immutable routing-table snapshot; 7. insert the resulting rows into `m_exclusionRoutes`; 8. reconcile captured routes once after the whole family batch. In pseudocode: ```text for family in {IPv4, IPv6}: prefixes = pending prefixes for family if empty: continue routes = GetIpForwardTable2(family) updateInterfaceMetrics(family) for prefix in prefixes: row = buildExclusionRow(prefix) resolvePhysicalRoute(row, routes) stage(row) install/stash staged rows updateCapturedRoutes(family, routes) ``` The important point is that route selection for all prefixes in the batch uses the same snapshot; installing exclusion routes must not trigger a fresh FIB/interface observation for every item. ### `Daemon` ownership/refcount detail `Daemon::m_excludedAddrSet` is a reference count, so I would also add a small batch helper there: * already-owned prefixes: only increment the refcount; * only previously unseen prefixes go to `wgutils()->addExclusionRoutes()`; * commit the new refcount entries only after the platform batch succeeds; * `activate()` / `switchServer()` should check the batch result instead of silently continuing if exclusion setup fails. This preserves the current multi-hop/server-switch ownership semantics while allowing Windows to optimize only the actually-new routes. ### Failure handling One small API issue is that `WindowsRouteMonitor::updateExclusionRoute()` currently returns `void` and logs `CreateIpForwardEntry2()` failures internally. For a transactional batch it would be useful either to return a status or split route selection from route installation, so a failed batch can roll back only routes created by that batch and avoid committing incorrect ownership state in `Daemon`. ### Regression test I would make the primary regression test API-call based, not timing based. With e.g. 10,000 IPv4 exclusions, activation should perform O(1) `GetIpForwardTable2(AF_INET)` / `GetIpInterfaceTable(AF_INET)` calls for the exclusion batch, not 10,000 calls each. Useful semantic cases to keep in the same test matrix: * duplicate prefix already owned by another active hop -> refcount increment only; * mixed IPv4/IPv6 -> one snapshot/metric refresh per family; * exact pre-existing physical route -> preserve the current no-duplicate-route behavior; * partial route creation failure -> no stale refcount ownership and rollback of routes created by the failed batch; * server switch -> add new exclusions before removing old ones; * later Windows route-change notification -> normal `routeChanged()` reconciliation still updates next-hop/interface. This should remove the quadratic setup behavior without changing the actual route-selection policy.
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/amnezia-client#1383
No description provided.