Turning A Pico 2 W Into A Rickroll Captive Portal
I wanted a Raspberry Pi Pico 2 W to create an open WiFi network and rickroll anyone who joined it. The device would not use an existing router or an internet connection. It had to be the access point, hand out addresses, answer DNS, and serve the page itself.
The result is a small C program built against pico-sdk 2.3.0. A phone joins an SSID called Free_WiFi, its operating system detects a captive portal, and the sign-in window opens onto a looping GIF stored in the Pico's flash.
The complete implementation is in the pico-2-w-rickroll repository. The prank is simple. The interesting part is the chain of network behavior that makes it automatic, and the memory-lifetime details that let a microcontroller serve an image larger than its RAM.
This is the same reason I keep this site static and understandable. A small system is most useful when I can follow the entire path from an input to the bytes it produces.
The Captive Portal Starts With DNS
Joining an open WiFi network does not itself open a browser. The operating system first checks whether the network can reach the internet. Android documents simultaneous HTTP and HTTPS validation probes, Windows NCSI sends a plain HTTP request and checks the response, and Apple devices display a login screen for captive WiFi networks. A response other than the expected success result tells the operating system that the network may require a sign-in page.
The Pico controls the complete path:
phone joins Free_WiFi
|
v
DHCP gives it an address, router, and DNS server
|
v
wildcard DNS points the connectivity check at 192.168.4.1
|
v
HTTP returns 302 Location: http://192.168.4.1/
|
v
the operating system opens its captive-portal window
|
v
the Pico serves the HTML and rickroll.gif
Owning DNS is the load-bearing part. DHCP advertises 192.168.4.1 as both the router and DNS server. The wildcard DNS service returns that address, so the phone's HTTP probe reaches the Pico even though it asked for a vendor-owned hostname.
The router does not need a growing list of probe URLs. It has only three outcomes:
/ -> 200 text/html
/rickroll.gif -> 200 image/gif
everything else -> 302 http://192.168.4.1/
That final wildcard catches every unrecognized HTTP path without hard-coding the current vendor endpoints. A malformed request is rejected with 400 Bad Request before it reaches the router.
Keeping The Network Code Small
I chose C because the official SDK already provides the CYW43439 WiFi driver and lwIP support. The DHCP and wildcard-DNS servers are vendored without changes from the official pico-examples repository. I wrote the HTTP request parser, router, and server around lwIP's raw TCP API.
The firmware uses the SDK's polling integration rather than a background network thread. The SDK documentation describes pico_cyw43_arch_lwip_poll as the single-core polling option. In this program, the main loop polls WiFi and feeds the watchdog. Every lwIP callback runs in the same context, so there is no shared connection state moving between the main loop and an interrupt handler.
There is also no dynamic allocation in the code I wrote. The HTTP server has a fixed pool of eight connection slots. An idle connection is aborted after about 20 seconds, and a full pool refuses a new connection instead of silently waiting forever.
The parts with actual decision logic stay independent of the embedded SDK. The request parser and router compile as ordinary C in a separate host test project. If either accidentally includes lwIP or Pico headers, that build breaks. It is a small structural rule, but it means parsing and routing can be tested without a board attached.
Serving A Large GIF Without Putting It In RAM
The generated GIF is 995,080 bytes at 320 by 220 pixels. The Pico 2 W has 520 KB of SRAM, so copying the complete image into a response buffer was never an option.
The asset assembly file uses GNU assembler's incbin directive to place the HTML and GIF directly in the firmware image. The RP2350 can read its external flash through the execute-in-place mapping, so the server treats the GIF as an immutable byte range and passes pointers into that range to lwIP.
The copy flag on tcp_write() depends on the lifetime of the data:
- response headers use
TCP_WRITE_FLAG_COPYbecause they live inside a connection slot that can be reused - the GIF body does not use the copy flag because its bytes remain unchanged in flash for the lifetime of every connection
The lwIP raw TCP documentation requires no-copy data to remain unchanged until the remote side acknowledges it. Flash satisfies that requirement. Reversing those flags would either leave lwIP pointing at reused header memory or waste RAM copying the body.
The Failure That Looked Like A Reset
The first concurrent test had a misleading result. Two GIF downloads completed, but a third reset partway through. Nothing crashed and there was no obvious out-of-memory report.
Each no-copy write still needs lwIP metadata: a TCP segment and a small PBUF_ROM header. One connection could queue about 17 segments, so three connections needed about 51. The configured segment pool held only 48. Once the pool was exhausted, tcp_write() returned ERR_MEM. If nothing was queued, the acknowledgement callback that normally resumed the transfer never fired, and the idle timeout eventually killed it.
The reliability fix raised the segment and pbuf pools to 192 entries for the eight-connection limit. The poll callback also retries a stalled response, so temporary pool pressure can recover instead of becoming terminal.
On hardware, I verified eight concurrent downloads byte-for-byte against the source GIF. With twelve simultaneous requests, four were refused and the eight available slots still completed correctly. I also checked malformed requests, the three main operating-system probe paths, and 40 sequential requests for a connection-slot leak.
Powering It Away From A Computer
Once the network path worked, I wanted the board to run without staying attached to a laptop. My first attempt was a power bank connected through the Pico's micro-USB port. It did not work well: the load was low enough that the power bank decided nothing useful was connected and switched its output off.
I had thought of the cutoff as roughly 100 mA, but that is not a universal USB or Pico value. It is a product-specific decision inside the power bank. As one documented example, Huawei describes an 80 mA automatic cutoff on its power banks. Other models use different thresholds or provide a special low-current mode.
For the next test, I connected three AAA batteries in series directly to the power pins:
battery positive -> physical pin 39, VSYS
battery negative -> physical pin 38, GND
Three fresh alkaline cells provide about 4.5 V. The official Pico 2 W datasheet allows approximately 1.8 V to 5.5 V on VSYS, so that supply is inside the board's input range. The batteries also avoid the power bank's load-detection logic entirely.
Polarity and power-source isolation still matter. Anyone reproducing this should verify the battery holder with a meter before connecting it. The same datasheet recommends feeding a second supply into VSYS through suitable power ORing instead of casually connecting external batteries and USB power at the same time.
What It Does Not Intercept
This is a captive portal, not a transparent internet proxy. It works because operating systems deliberately make connectivity checks that include plain HTTP. A real domain that upgrades to HTTPS cannot be intercepted cleanly: the Pico does not have a certificate trusted for somebody else's hostname. Showing a certificate warning would be worse than letting that request fail.
Wildcard DNS is also limited to clients and traffic that use the resolver advertised by DHCP. The Pico 2 W datasheet specifies SoftAP support for up to four clients. The eight HTTP connection slots protect concurrent requests; they do not raise that four-station WiFi limit.
The network is intentionally open and has no internet access. I would only run it on a network I control, with an SSID that does not impersonate a real business. The joke is the page; creating a support or security problem for somebody else is not part of it.
The Result
The finished board is a complete tiny network appliance: access point, DHCP, DNS, HTTP server, static assets, timeouts, and watchdog in one firmware image. The host tests and firmware build remain separate, and the risky transfer path has a byte-exact hardware check.
The most useful lesson was not how to display a GIF. It was that each boundary had a different constraint. DNS controls where the probe goes. The 302 controls what the operating system concludes. Flash lifetime makes zero-copy transfer safe. A fixed pool makes overload predictable. Even the portable power source has policy of its own when the current draw becomes too small.