BrickellTechnologies

Research

FZ-2026-008: Firezone Gateways Trusted the Proxy IPs Clients Sent Them

Elias Hasas · ·8 min read

Firezone is an open-source remote access platform built on WireGuard. Administrators define Resources (a hostname, a wildcard domain, a CIDR range), Policies decide which groups of users may reach which Resources, and Gateways deployed inside each network forward the permitted traffic. Firezone’s architecture docs put the rule plainly: “Connections in Firezone are always default-deny. Policies must be created to allow access.”

On 31 August we reported to Firezone that a signed-in user with access to a single DNS Resource could get past that rule. By sending the Gateway a list of addresses of their own choosing, the user could reach any host the Gateway itself could reach, including hosts that belonged to no Resource at all. Firezone merged a fix about ten hours after the report and published it as advisory FZ-2026-008 on 8 September.

  • Advisory: FZ-2026-008, “Gateway can forward traffic to arbitrary hosts”
  • Affected: Firezone Gateway 1.4.6 through 1.6.0
  • Fixed in: Gateway 1.6.1, released 7 September 2026
  • Credit: Elias Hasas

Firezone publishes its own advisory series, and no CVE has been assigned for this issue.

How DNS Resources work

A DNS Resource names a domain, such as gitlab.internal or *.corp.example.com, rather than an IP address. Firezone’s critical sequences page describes the flow. A small resolver inside the Client intercepts DNS queries. When a query matches a DNS Resource, the resolver answers with a placeholder address from a reserved range, 100.96.0.0/11 for IPv4 or fd00:2021:1111:8000::/107 for IPv6, and remembers which name it handed that address out for. The application connects to the placeholder, and the Client carries the traffic through the tunnel to the Gateway.

The Gateway has to know what each placeholder stands for. So once the tunnel is up, the Client sends it an AssignedIps event over Firezone’s peer-to-peer control protocol, containing the domain, the Resource it belongs to and the proxy IPs the Client chose. The Gateway resolves the domain itself, maps each proxy IP to a real address, and from then on rewrites traffic for the proxy IPs to the real host.

The Client chooses the proxy IPs, and the Client runs on a device the user controls. Whatever the Gateway does with those addresses has to hold up when the user sends something other than what the official Client would.

What the Gateway checked

The event arrives as a plain list of addresses. In tunnel-proto/src/p2p_control.rs (all excerpts are from the gateway-1.6.0 tag):

pub struct AssignedIps {
    pub resource: ResourceId,
    pub domain: DomainName,
    pub proxy_ips: Vec<IpAddr>,
}

The Gateway checked two things before acting on it: that the Client was authorized for the named Resource, and that the domain really fell under that Resource’s address. ClientOnGateway::setup_nat in gateway/client_on_gateway.rs makes the second check and then stores every proxy IP as a translation entry:

anyhow::ensure!(crate::dns::is_subdomain(&name, address));

// ...

for proxy_ip in proxy_ips {
    // ...
    let state = self
        .permanent_translations
        .entry(proxy_ip)
        .or_insert_with(|| TranslationState::new(name.clone()));

    state.resources.insert(resource_id);
    state.resolved_ip = maybe_real_ip.copied();
}

Nothing in that path asked whether a proxy IP was inside the reserved range. Each stored address then became a route. recalculate_dns_filters installs one routing-table entry per translation entry, carrying the Resource’s port and protocol filters, and an entry in that table is what permits a Client to send to an address:

for (addr, TranslationState { resources, .. }) in &self.permanent_translations {
    for resource_id in resources {
        // ...
        self.routing_table.upsert(
            IpNetwork::from(*addr),
            RouteEntry { filter, resource_id: *resource_id },
        );
    }
}

The last step happens when packets arrive. The Gateway only rewrites destinations inside the reserved range, and it sends anything outside that range on unchanged, because CIDR and Internet Resource traffic legitimately takes that path:

// Packets for CIDR resources / Internet resource are forwarded as is.
if !is_dns_addr(dst) {
    return Ok(TranslateOutboundResult::Send(packet));
}

Put together: a Client authorized for one DNS Resource announces 10.0.5.20 as one of its proxy IPs. The Gateway accepts it, installs a route for 10.0.5.20 with that Resource’s filters, and forwards the Client’s packets to 10.0.5.20 without translating them. The function that would have caught the problem, is_dns_addr, is defined in the same file. Before the fix it was consulted only as packets passed through, never when a Client registered its proxy IPs.

Who is affected

Any Gateway from 1.4.6 through 1.6.0 that serves at least one DNS Resource. The attacker has to be a legitimate user of the Firezone account, hold a Policy that grants a DNS Resource on that Gateway, and run a modified Client. Firezone’s advisory states that an unauthenticated attacker can’t reach this and that unmodified Clients only send proxy IPs from the reserved ranges. Users whose Policies grant only CIDR or Internet Resources never reach this code path.

Reach is bounded by two things. The first is the network: anything the Gateway host can route to is in scope, which normally means other hosts on the Gateway’s network, any networks routed behind it, and on a cloud Gateway the instance metadata service at 169.254.169.254. The second is the authorized DNS Resource’s filters, because the injected routes inherit them. A Resource limited to TCP 443 gives the attacker TCP 443 on every reachable host. A Resource created without filters gives every port.

Two more details from our testing. The Gateway didn’t limit how many proxy IPs a Client registered, so the attacker wasn’t restricted to a few chosen hosts. And in the Gateway’s flow logs, traffic to the bypassed hosts appeared under the authorized DNS Resource’s name, with the real destination in inner_dst_ip. A review that only looks at which Resources a user touched would see nothing unusual.

Reproduction

We first reproduced this end to end in August against Firezone’s published Portal and Gateway container images, built from main between the 1.6.0 and 1.6.1 releases. The only modified component was the Client: a twelve-line change so that the proxy IPs it announced came from an environment variable. An attacker controls their own device, so that choice was already theirs.

The test account’s Policy granted a DNS Resource, **.httpbin, and a CIDR Resource, 10.20.0.0/16. Two hosts belonged to no Resource: 172.21.0.200, on the network the Gateway uses for the DNS Resource, and 172.31.0.50, on a separate internal network behind the Gateway.

The control used the stock Client, with local routes sending both hosts into the tunnel:

curl http://172.21.0.200/get  -> HTTP 000
curl http://172.31.0.50/get   -> HTTP 000
Unroutable packet: Unknown resource ... dst=172.21.0.200:80
Unroutable packet: Unknown resource ... dst=172.31.0.50:80

The attack used the same account and the same Policy. The modified Client resolved download.httpbin, a real name under the authorized Resource, so the subdomain check passed, and listed both hosts among its proxy IPs:

curl http://172.21.0.200/get  -> HTTP 200
curl http://172.31.0.50/get   -> HTTP 200
  "origin": "172.31.0.100"

Gateway: Setting up DNS resource NAT domain=download.httpbin resolved_ips={172.21.0.101}
         proxy_ips={172.21.0.200, 172.21.0.202, 172.21.0.203, 172.31.0.50}

172.31.0.100 is the Gateway’s own address on the internal network, so the request to 172.31.0.50 left through the Gateway onto a network the Client held no Resource for.

We re-ran the core of it today as a unit test inside Firezone’s own tunnel-proto crate, using its existing test helpers: a Client, a DNS Resource that allows one UDP port, and a setup_nat call carrying the legitimate proxy IP 100.96.0.1 plus two addresses outside the reserved range. On the gateway-1.6.0 tag:

CONTROL  10.10.10.10      before AssignedIps: IcmpError (prohibited)
CONTROL  169.254.169.254  before AssignedIps: IcmpError (prohibited)
setup_nat(proxy_ips = {100.96.0.1, 10.10.10.10, 169.254.169.254}): Ok
ATTACK   10.10.10.10      after AssignedIps:  Send, dst=10.10.10.10
ATTACK   169.254.169.254  after AssignedIps:  Send, dst=169.254.169.254
legit    100.96.0.1       after AssignedIps:  Send, dst=10.0.0.1

Before the event, the Gateway refuses packets to both addresses. Afterwards it forwards them untranslated, while the legitimate proxy IP is still rewritten to the Resource’s real address. On the gateway-1.6.1 tag the same call is rejected and nothing gets installed:

setup_nat(...): Err: Proxy IP 10.10.10.10 is not within 100.96.0.0/11 or fd00:2021:1111:8000::/107
ATTACK   10.10.10.10      after AssignedIps:  IcmpError (prohibited)
ATTACK   169.254.169.254  after AssignedIps:  IcmpError (prohibited)

Firezone’s own regression test for the fix passes on 1.6.1 as well.

The fix

Thomas Eizinger fixed it in PR #14970 (commit 8d0258f), merged on 1 September. setup_nat now validates every proxy IP before it touches any state:

+        // Proxy IPs are chosen by the Client; anything we accept here ends up in our routing table.
+        for proxy_ip in &proxy_ips {
+            anyhow::ensure!(
+                is_dns_addr(*proxy_ip),
+                "Proxy IP {proxy_ip} is not within {IPV4_RESOURCES} or {IPV6_RESOURCES}"
+            );
+        }

One address outside the reserved ranges rejects the whole event, so a Client can’t slip a single bad address in among valid ones. The PR also adds a regression test that tries public addresses and the ranges Firezone reserves for Client tunnel IPs, and asserts that no translation state is left behind.

The fix lives entirely in the Gateway. Clients don’t need to be updated.

What to do

Upgrade your Gateways

Move every Gateway to 1.6.1 or later; 1.6.3 is current. Firezone’s upgrade guide covers each deployment method. A Site is only as protected as its oldest Gateway, so check all of them.

Firezone’s advisory says it found no evidence that the vulnerability was exploited or that anyone attempted to exploit it. If you want to check your own deployment, look in Gateway flow logs for traffic recorded under a DNS Resource whose destination isn’t an address that Resource’s domains resolve to.

Limit what each Gateway can reach

This bug turned a Gateway’s network reach into the user’s reach. A Gateway still needs that reach to serve its Resources, but it rarely needs all of it:

  • Give every DNS Resource port and protocol filters. They were the only limit on this bug, and a Resource without them allowed every port.
  • Place Gateways in subnets or security groups that can reach the Resources they serve and little else. Hosts that should never be reachable through Firezone shouldn’t be routable from the Gateway either.
  • On cloud Gateways, block forwarded traffic to the instance metadata address with a host firewall rule.

Why the test suite didn’t catch it

Firezone fuzzes its tunnel code against a reference model of how Clients and Gateways should behave, which is more rigorous testing than most projects do. The generators that feed that model draw proxy IPs only from the reserved ranges, just as the real Client does, so the suite never produced the input that broke the Gateway.

That pattern applies well beyond Firezone. When one side of a protocol runs on hardware the user owns, tests built around the official implementation only show what happens when that side behaves.

Disclosure timeline

  • 31 August 2026: reported to Firezone through GitHub private vulnerability reporting
  • 1 September 2026: fix merged in PR #14970
  • 7 September 2026: Gateway 1.6.1 released
  • 8 September 2026: Firezone published FZ-2026-008

Thanks to Thomas Eizinger and the Firezone team for a fix within hours and a clear advisory.

References

If you run Firezone or another zero-trust access product and want to know whether its policies hold against a user who controls their own client, that’s part of our penetration testing work.

Know what an attacker would reach first.

Tell us what you run and what worries you. We will come back with a scope, a fixed price and the earliest week we can start.