Understanding Captive Portal Design and Bypass Methods

Quite a few years ago a client had a requirement for a Captive Portal page on their guest networks - we explored the commercial offerings available, but they required ongoing subscriptions/long contracts and were outside the clients budget for what amounted to a requirement for a disclaimer page (advising users that traffic was logged, and that it would be passed to law enforcement if requested - the client was a hotel, and I won’t elaborate on the circumstances that led to the request).

This presented an opportunity - we set about on a deep dive into how Captive Portals worked so we could build something out. To understand better, we set about running packet captures on various device types on various different portals in commercial settings. Many beers, burgers and coffees were consumed in the name of R&D.

Broadly, all OS’s handle the device flow for captive portal registration in the same way:

  1. The device associates with the Wi-Fi and pulls an IP address via DHCP. Crucially, getting an IP tells the device nothing about whether it actually has a route to the internet - it just means the network let it in the door.
  2. To find out whether that door leads anywhere, the OS runs a connectivity check: it requests a URL where it already knows the exact answer. Before it can make that request, though, it has to resolve the hostname - so it fires off a DNS lookup on UDP port 53. (Pay attention to this, we’ll be coming back to it later ;))
  3. With an IP in hand, it makes an HTTP request to a known endpoint and inspects what comes back.
  4. If the response is exactly what it expected, the device concludes it’s online and stays quiet. If the response has been tampered with - a redirect, or the wrong page body - it concludes there’s a portal in the way and pops up that login sheet we’ve all sat swearing at in hotel lobbies.

Here’s the flow end to end:

flowchart TD
    A[Join Wi-Fi + DHCP
Associated, gets an IP] --> B[DNS query - UDP 53
Resolve captive.apple.com] B --> C[HTTP probe to that IP
Fetch known page / 204] C --> D{Compare response
Expected vs. altered?} D -->|Match| E[Online
Success page / HTTP 204
No portal shown] D -->|Altered| F[Captive portal
Redirect or wrong body
Login sheet pops up] R[Resolver reachable
before login] -.->|open pre-auth| B classDef neutral fill:#F1EFE8,stroke:#5F5E5A,color:#2C2C2A; classDef probe fill:#E6F1FB,stroke:#185FA5,color:#042C53; classDef decision fill:#EEEDFE,stroke:#534AB7,color:#26215C; classDef good fill:#E1F5EE,stroke:#0F6E56,color:#04342C; classDef portal fill:#FAEEDA,stroke:#854F0B,color:#412402; class A neutral; class B,C probe; class D decision; class E good; class F portal; class R neutral;

The connectivity check, per vendor

The packet captures made the pattern obvious once we knew what to look for. Every vendor is doing the same thing with slightly different plumbing.

Apple requests http://captive.apple.com/hotspot-detect.html over plain HTTP and expects a 200 OK carrying a tiny fixed page - an HTML document whose body is just the word Success. Apple rotates through a handful of interchangeable hostnames (captive.apple.com, www.appleiphonecell.com, www.itools.info) but they all return the same thing. Note that Apple checks the body, not just the status code - that detail matters in a moment.

Android/Google hit http://connectivitycheck.gstatic.com/generate_204 and expect an HTTP 204 No Content - a valid response with an empty body. There’s nothing to compare: a genuine reach to Google produces a 204, whereas a portal can’t serve a valid 204 and its login page, so it’s forced to give itself away with a 200 or a 302. Modern Android also fires a parallel HTTPS probe to make interception harder to fake.

Windows does much the same with http://www.msftconnecttest.com/connecttest.txt (expecting the exact body Microsoft Connect Test), plus a DNS-only check on dns.msftncsi.com.

Why the “wrong” answer is the whole point

A device decides it’s behind a portal when the response is anything other than the one it memorised. In practice that’s one of three tells:

  • a redirect (302/307) whose Location header points at the portal’s login page - the most common;
  • the right status but the wrong body - a 200 OK serving the portal’s HTML instead of Success, which is exactly why Apple inspects the body;
  • any other status the OS wasn’t expecting.

And here’s the thing that turned into our opportunity. Notice what the network has to permit for any of this to work. The portal itself depends on the device’s DNS query resolving and its HTTP probe leaving the building - otherwise there’s nothing to intercept and redirect. So a pre-authentication guest network is never a total block. It’s a walled garden with two doors deliberately propped open: DNS out on UDP 53, and HTTP out to be caught and redirected. The check is designed around plain, interceptable HTTP precisely because that’s what lets a portal insert itself.

Which is to say: before a guest has agreed to a single thing, the network is already carrying their traffic on our behalf. Once we understood that the resolver stays reachable pre-auth, the shape of a lightweight, self-hosted portal - and, later, the more interesting question of what else that open UDP 53 path could carry - fell into place. That’s when we realised that (most) Captive Portals are bypassable.

Breaking out of the Garden

Most filtering takes place at Layer 3, because actual inspection of traffic traversing networks is costly in terms of CPU, and most domestic routers are simply underpowered for the task - the risk of misidentifying traffic also adds a support overhead, so while it’s possible to run IDS/IPS-like technology to find traffic running on the wrong port (Snort and Suricata can both do this), it’s extremely uncommon. Networks filter on the basis of ports, not what traffic is travelling on those ports. When joined to a pre-authenticated captive portal, we know that we need to be able to resolve DNS, which means that UDP 53 needs to be permitted outbound - but what if we controlled a host listening on UDP 53, but that wasn’t serving DNS on that port, it was running a VPN server?

We tested this by spinning up an OpenVPN server, setting the listening port to 53 and popping down to a local establishment with a running portal…

In the years since, this technique has saved me filling in countless forms and submitting (fake) information to hundreds of mailing lists, so i’d argue that I’m saving countless marketing teams unnecessary effort.

Thanks to Mike and Nick for prompting me to write this - sometimes I forget that I know things that other folks might be interested in…..

Written on August 6, 2026