Flash a $2 Pi-hole From Your Browser: The 9M2PJU ESP32-C3 AdBlocker
Flash a $2 Pi-hole From Your Browser: The 9M2PJU ESP32-C3 AdBlocker
There is a particular kind of satisfaction in solving a problem with the cheapest hardware that can possibly do the job. Most “DIY Pi-hole” projects on the ESP32 end up costing more than an actual Raspberry Pi, because they demand a board with PSRAM to hold the blocklist in memory. The original esp32-c3-adblock project from M-Abozaid flipped that assumption on its head by storing 140,000+ domains as 40-bit hashes in flash instead of strings in RAM.
I liked that project enough to fork it, fix everything about it that bugged me, and add a browser web flasher so nobody has to install PlatformIO just to try it. The result is 9M2PJU-ESP32-C3-AdBlocker, a Pi-hole-class DNS sinkhole that runs on a $2 ESP32-C3 with no PSRAM, flashes straight from Chrome, and now does zero-flash lookups for 93% of queries thanks to a Bloom filter in RAM.
It is open source, MIT licensed, and lives at github.com/9M2PJU/9M2PJU-ESP32-C3-AdBlocker. The web flasher is at https://9m2pju.github.io/9M2PJU-ESP32-C3-AdBlocker/.
What Is It, in One Breath
A small firmware for an ESP32-C3 board that turns it into a network-wide DNS ad-blocker. You point your devices (or your whole router) at it for DNS, and it answers 0.0.0.0 for any query in a 140,000+ domain blocklist and forwards everything else to your real upstream resolver. No per-device app, no browser extension, no subscription. One $2 board, one flash, and every device on the Wi-Fi stops talking to ad and tracker servers.
The Original Trick, in Case You Missed It
The conventional approach to an ESP32 DNS sinkhole loads the blocklist, the actual domain strings, into RAM and linear-scans or hash-sets them. StevenBlack’s base list alone is around 140,000 entries; storing those strings eats about 2.5 MB of RAM. That forces you onto an ESP32 with PSRAM, which puts you in the $8-and-up bracket, and at that point you might as well buy a Pi.
The original project’s insight was that you do not need the strings at all. You need to answer one question per DNS query: is this domain in the list? That is a set-membership test, and a set-membership test only needs a hash. So it stored every domain as a sorted 40-bit FNV-1a hash in flash and binary-searched the table on every query.
Forty bits is the sweet spot for a 4 MB flash budget. Collisions follow the birthday bound: at 141,000 domains you get zero collisions, at 537,000 you get about one unlucky over-blocked domain. Dropping to 32 bits would save 20% of the flash but cost around 7 collisions at 250k; going to 64 bits wastes three bytes per domain to solve a problem you do not have.
That trick is unchanged in this fork. What changed is everything around it.
What Is New in This Fork
Every item from the original project’s “how it could grow” list is done, plus a long list of correctness, performance, and security fixes that I found while actually using the thing on my home network. The headline changes:
Performance
- Bloom filter in RAM (128 KB) — skips flash for ~93% of non-blocked queries. Average lookup is now 0 flash reads instead of ~18. Only the ~7% of queries that pass the Bloom check do the binary search over flash.
- Async upstream forwarding — up to 32 concurrent queries instead of blocking on one upstream reply at a time. No more “one slow query freezes the sinkhole.”
- Custom domains sorted + binary-searched (was a linear scan of up to 200).
- Removed
delay(1)from the main loop — was capping throughput at ~1000 iterations per second. isBlockedusesmemchr+ length tracking instead ofstrlenper suffix.
Correctness
- AAAA sinkhole — blocked IPv6 queries now answer
::instead of leaking upstream. In the original, only A records were sinkholed, so any IPv6-capable client quietly bypassed the blocklist. - TCP DNS on port 53 — handles truncated-response and DNSSEC fallback. The original was UDP-only.
- 1232-byte DNS buffer (RFC 6891 max UDP) — was 600, so large replies were silently truncated.
- Client table with LRU eviction — was silently dropping stats past 96 clients.
- Banned IPs decoupled from the client table — banning an IP with no active client entry now persists correctly.
- Idle-gated remote blocklist fetch — only runs when DNS has been quiet for 3+ seconds, so a 20-second HTTPS download no longer freezes the household’s DNS.
millis()wraparound-safe timing — was broken every ~49 days of uptime.parseQueryrobust bounds checking — was fragile on DNS compression pointers.
Security
- Dashboard authentication — Basic Auth with a random password generated on first boot and printed on serial. The original had no auth, so anyone on the LAN could flash firmware or swap the blocklist.
- CSRF token on all mutating endpoints — a malicious page on the LAN cannot ban clients or upload firmware via the victim’s browser.
- CA-verified HTTPS for remote blocklist fetches — the original used
setInsecure(), which let a man-in-the-middle swap your blocklist for an empty one and silently disable blocking.
Features
- WiFiManager captive portal — first-boot Wi-Fi setup without recompiling. Connect to the
C3AdBlock-SetupAP, enter creds athttp://192.168.4.1, done. Wi-Fi can also be changed later from the dashboard. - Optional DHCP server — the C3 can hand itself out as DNS for true plug-and-play. Toggle from the dashboard (disable your router’s DHCP first).
- Blocklist v1 binary format with header (magic
CADB+ version + hash_bytes) — the firmware validates on load, so aHASH_BYTESmismatch is rejected instead of silently corrupting lookups. - Collision report —
build_blocklist.pywritesblocklist.bin.collisions.txtlisting any domain pairs sharing a 40-bit hash, so the operator can decide. - ETag + Cache-Control on the dashboard HTML — the 6 KB page is served with a 304 path instead of re-sent on every 3-second poll.
- Wi-Fi change from the dashboard — no reflash needed to switch networks.
Code Quality
- Dashboard HTML extracted to
src/dashboard_html.h(was inline inmain.cpp). - 33 Python tests for hashing, normalization, binary format, and Bloom filter correctness.
LOG/LOGNmacros to silence serial output with-DNDEBUG.- Removed hardcoded
/dev/ttyACM0fromplatformio.ini— PlatformIO auto-detects the port. - GitHub Actions CI on every push and PR: builds the firmware, runs the tests, uploads artifacts.
- Automated release workflow: push a tag, get a
full-flash.binand per-component assets published to GitHub Releases.
The Numbers, Side by Side
| original esp32-c3-adblock | this fork | |
|---|---|---|
| RAM | 54 KB | 56 KB (+2 KB for async pending table) |
| Flash | 1018 KB | 1032 KB (+14 KB for TCP DNS, DHCP, auth, captive portal, Bloom loader) |
| Lookups | ~18 flash reads per query | 0 flash reads for 93% of queries |
| Concurrent upstream queries | 1 | 32 |
| IPv6 (AAAA) sinkhole | no (leaked upstream) | yes (answers ::) |
| TCP DNS | no | yes (port 53) |
| Dashboard auth | none | Basic Auth + CSRF |
| First-boot Wi-Fi | recompile | captive portal |
| DHCP server | no | optional, toggle from dashboard |
| Remote blocklist HTTPS | insecure (setInsecure()) |
CA-verified |
| Web flasher | no | yes, browser-only |
| Tests | none | 33 Python tests + CI |
The cost of all of that is 2 KB of RAM and 14 KB of flash. On a 4 MB C3, that is rounding error.
The New Lookup Flow
With the Bloom filter in place, the per-query path is now:
query in ──▶ extract domain ──▶ FNV-1a hash (+ parent suffixes)
──▶ Bloom filter check (RAM, ~0.5 µs)
├─ miss ──▶ forward to upstream (async, non-blocking)
└─ hit ──▶ binary-search the flash hash table
├─ hit ──▶ answer 0.0.0.0 / :: (sinkholed)
└─ miss ──▶ forward to upstream (async, non-blocking)
About 93% of DNS queries are for domains not in the blocklist. The Bloom filter (128 KB of RAM) eliminates flash access for those — only the ~7% that pass the Bloom check do the ~18-flash-read binary search. Net result: the average query does 0 flash reads, not 18. That is the difference between a sinkhole that keeps up with a busy household and one that starts adding latency under load.
Hardware
The bar is deliberately low:
- Any ESP32-C3 board (tested on the C3 SuperMini), 4 MB flash, no PSRAM needed.
- A stable USB source — a phone charger or your router’s spare USB port. Cheap or loose USB-C to A adapters can brown out the radio during Wi-Fi transmit.
- A USB-A to USB-C dongle lets the board plug straight into the back of most routers, no power supply, no extra box.
There is a printable STL enclosure in the repo at hardware/esp32-c3-supermini-enclosure.stl. Printing notes: no supports, 0.2 mm layers, 15% infill is plenty. Keep the antenna end clear (the PCB zig-zag trace on the short edge opposite the USB-C port), and leave the vents open — the board idles around 45–55 °C.
Installation: Three Ways
Option A: Web Flasher (Recommended — No Software Install)
This is the bit I most wanted to add. You do not need PlatformIO, esptool, Python, or any toolchain. You need a Chromium-based browser and a USB cable.
- Open https://9m2pju.github.io/9M2PJU-ESP32-C3-AdBlocker/ in Chrome or Edge (Web Serial API required — Firefox and Safari are not supported).
- Plug in your ESP32-C3 via USB.
- Click CONNECT, select your ESP32-C3’s serial port.
- Choose Install — the browser erases the flash and writes the bootloader, partition table, firmware, and blocklist (~140k domains) in about 30–60 seconds.
- Continue to first-boot setup below.
The web flasher always serves the latest release automatically, so there is nothing to download manually and nothing to keep up to date. New release tagged on GitHub, the flasher serves it the next time someone clicks Connect.
Option B: Flash a Prebuilt Release with esptool (CLI)
For people who prefer the command line, or who are flashing a board that the browser cannot see:
- Download the latest release from GitHub Releases. The easiest option is
full-flash.bin(bootloader + partitions + firmware + blocklist in one file). Otherwise grabbootloader.bin,partitions.bin,firmware.bin, andblocklist-fs.binseparately. - Install esptool:
pip install esptool(orsudo apt install esptoolon Debian/Ubuntu). - Plug in the ESP32-C3 and find the serial port:
ls /dev/ttyACM* /dev/ttyUSB*on Linux,ls /dev/cu.usb*on macOS, Device Manager on Windows. - Flash the full image (one command, writes everything to 0x0):
esptool.py --chip esp32c3 --port /dev/ttyACM0 --baud 460800 \ write_flash 0x0 full-flash.bin
Or flash the individual files if your tool does not like merged images:
esptool.py --chip esp32c3 --port /dev/ttyACM0 --baud 460800 \ write_flash \ 0x1000 bootloader.bin \ 0x8000 partitions.bin \ 0x10000 firmware.bin \ 0x2b0000 blocklist-fs.bin
- Continue to first-boot setup below.
Option C: Build From Source (For Customization)
For anyone who wants a custom blocklist, a different partition layout, or who just likes building their own firmware:
- Install PlatformIO:
pip install platformio(or use the standalone installer at docs.platformio.org). - Clone the repo:
git clone https://github.com/9M2PJU/9M2PJU-ESP32-C3-AdBlocker.git cd 9M2PJU-ESP32-C3-AdBlocker
- (Optional) set Wi-Fi creds via
secrets.h— or skip and use the captive portal on first boot:
cp src/secrets.example.h src/secrets.h # edit src/secrets.h -> WIFI_SSID / WIFI_PASS
- Build the blocklist (default = StevenBlack base + Hagezi Light, ~140k domains, WhatsApp/social safe):
mkdir -p data python3 tools/build_blocklist.py data/blocklist.bin
See tools/build_blocklist.py --help for custom lists and Bloom filter sizing.
- Flash firmware + blocklist filesystem (the one and only USB flash):
pio run -t upload pio run -t uploadfs
- Continue to first-boot setup below.
First-Boot Setup
- Watch the serial console (optional but helpful — it shows the auth password and IP):
pio device monitor # if you built from source screen /dev/ttyACM0 115200 # or use screen/minicom
- Wi-Fi setup:
- If you set
secrets.hor flashed a prebuilt image, the device starts a captive portal AP called C3AdBlock-Setup. - Connect to it from your phone or laptop, then open http://192.168.4.1 (it may pop up automatically).
- Enter your Wi-Fi name and password, click Connect.
- The device saves your credentials, reboots, and joins your Wi-Fi.
- If you set
- Find the dashboard password:
- On the serial console, look for a line like:
[cfg] dashboard password: xY7kPm2nQa - Write this down. You can also find it later in the LittleFS file
/auth.cfg.
- On the serial console, look for a line like:
- Open the dashboard:
- Go to http://c3adblock.local in a browser (mDNS).
- If that does not work (some networks block mDNS), use the IP address shown on the serial console, e.g.
http://192.168.1.42. - You will get a Basic Auth prompt. Enter any username and the password from step 3.
Pointing Your Devices at the Ad-blocker
Three options, in order of effort:
Option 1: Manual DNS (simplest, no DHCP changes). Set your device’s DNS server to the C3’s IP address. On most OSes: Settings → Network → DNS → Custom → enter the C3’s IP. Test with dig @<c3-ip> doubleclick.net — it should return 0.0.0.0.
Option 2: Router DNS (whole-network, recommended). In your router’s DHCP settings, set the primary DNS server to the C3’s IP and the secondary to a real resolver (e.g. 1.1.1.1) as a fallback. Every device on your network now uses the C3 for DNS automatically.
Option 3: Built-in DHCP server (true plug-and-play). Disable your router’s DHCP server first (two DHCP servers on one network causes conflicts). In the C3 dashboard, scroll to DHCP SERVER and click Enable / Disable DHCP. The C3 now hands out IP addresses and tells every device to use itself as DNS. Reconnect your devices (or reboot them) so they pick up the new DHCP lease.
Testing
dig @<c3-ip> doubleclick.net # -> 0.0.0.0 (blocked, A record) dig @<c3-ip> doubleclick.net AAAA # -> :: (blocked, AAAA record) dig @<c3-ip> github.com # -> real IP (forwarded) dig @<c3-ip> github.com AAAA # -> real IPv6 (forwarded)
You should also see the blocked/allowed counts go up in the dashboard, and the querying device appear in the CLIENTS table.
Over-the-Air Updates
Once the board is on your network, you never need to plug it in again. The dashboard at http://c3adblock.local handles everything:
- Blocklist — drop a freshly built
blocklist.bininto Blocklist → Upload, or set a URL under Remote auto-update and the device pulls a prebuiltblocklist.binon a schedule. A GitHub release asset works well: update it once, and every device on the network fetches the new list. Remote fetches use CA-verified HTTPS (nosetInsecure) and only run when DNS has been idle for 3+ seconds, so they do not interrupt service. - Firmware — upload
.pio/build/c3/firmware.binunder Firmware → OTA update. The device verifies the image and reboots into it. Or push from the command line:pio run -t upload --upload-port c3adblock.local --upload-protocol espota
- Wi-Fi — change the Wi-Fi network from the dashboard, no reflash needed.
- DHCP — optionally enable the built-in DHCP server so the C3 hands itself out as the DNS server. Disable your router’s DHCP first, then toggle this on.
The 4 MB Flash Tradeoff
Firmware OTA needs two app slots, which on a 4 MB chip leaves about 1.3 MB for the blocklist, roughly 250,000 domains max. The aggressive 537k “ultimate” list only fits the single-app partition table, which means no firmware OTA. You pick the tradeoff in partitions.csv. For most home use, the default dual-slot, 250k-domain configuration is the right call: you keep OTA and a list large enough to cover the bulk of advertising and tracker domains.
How Domain Matching Works
The isBlocked function walks parent suffixes of the queried domain. For a query like ads.server.example.com, it checks, in order:
ads.server.example.comserver.example.comexample.com
It stops before the bare TLD (com) — a block on com would break the entire TLD and is never tested. This means you can block doubleclick.net and all subdomains (ad.doubleclick.net, stat.doubleclick.net, …) are caught automatically.
Each suffix is FNV-1a hashed (40-bit truncated), checked against the RAM Bloom filter first, and only on a Bloom hit is the flash hash table binary-searched. Custom domains (added via the dashboard) are kept in a separate sorted array and binary-searched too.
Blocklist Binary Format (v1)
Header (20 bytes): [0..3] magic 'CADB' [4] version (1) [5] hash_bytes (5) [6..9] num_hashes (uint32 LE) [10..13] bloom_bits (uint32 LE) [14] bloom_k (uint8) [15..19] reserved Bloom filter: ceil(bloom_bits / 8) bytes Hash table: num_hashes * 5 bytes (sorted, little-endian)
The firmware validates the magic, version, and hash_bytes on load — a mismatch (e.g. wrong HASH_BYTES) is rejected instead of silently corrupting lookups. Collisions are reported to blocklist.bin.collisions.txt by the build script, so you can audit them.
Gotchas, Learned the Hard Way
The README is unusually honest about the rough edges, because they will save you an evening of debugging:
- ModemManager (default on Fedora and Ubuntu) grabs
/dev/ttyACM0and toggles DTR/RTS, which resets the C3 and blocks serial. Fix:sudo systemctl stop ModemManager echo 'ATTRS{idVendor}=="303a", ENV{ID_MM_DEVICE_IGNORE}="1"' \ | sudo tee /etc/udev/rules.d/99-esp-no-modemmanager.rules sudo udevadm control --reload-rules && sudo udevadm trigger - The C3’s USB-Serial-JTAG console can swallow early boot output until the host connects. A
while(!Serial)in your own builds helps. - DNS clients add an EDNS OPT record. A blocked reply must contain only the question and answer (ANCOUNT=1, NSCOUNT=ARCOUNT=0) or it is malformed and clients reject it. This is the kind of detail that bites you the first time you write a DNS server and silently works forever after.
What Is Done, and What Could Still Grow
The project is a usable appliance, not just a demo. Done:
- Web dashboard with per-client stats, client ban, custom domains
- mDNS (
c3adblock.local) discovery - OTA for firmware and blocklist, plus scheduled remote blocklist pulls
- Bloom filter in RAM as a fast pre-filter (skip flash for ~93% of misses)
- Async upstream forwarding (32 concurrent queries, no blocking)
- TCP DNS support (port 53)
- AAAA sinkhole (blocked IPv6 queries answered with
::, not leaked upstream) - Dashboard auth (Basic Auth + CSRF token)
- WiFiManager captive portal (first-boot Wi-Fi setup without recompiling)
- Optional DHCP server (hands out C3 as DNS for plug-and-play)
- CA-verified HTTPS for remote blocklist fetches
- Blocklist v1 format with header validation
- Browser web flasher
- 33 Python tests + GitHub Actions CI
- Automated release workflow with
full-flash.binartifact
Still on the roadmap:
- Per-client blocking rules (different blocklists for different devices)
- Allowlist (override blocklist for specific domains)
- Query logging / export
Why It Matters
This is the kind of project that makes open source hardware fun. It takes a problem people assume needs a $35 board and 2 GB of SD card, and solves it on a $2 module that fits inside a 3D-printed box the size of a matchbox. The trick, storing hashes in flash and binary-searching them, is not new computer science, but it is the kind of engineering judgment that is easy to forget when RAM is cheap and flash is cheaper. Adding a Bloom filter on top so the average query never touches flash at all is the same kind of judgment: spend 128 KB of RAM to save 18 flash reads on 93% of queries, and the device goes from “clever demo” to “appliance you forget about.”
For ham radio operators and makers, the hash-in-flash plus Bloom filter pattern is also worth filing away. The same technique works anywhere you need fast set membership on a constrained device: callsign allowlists, APRS station deduplication tables, beacon ID registries, DMR talk-group filters. Anywhere you would be tempted to reach for a std::unordered_set and then run out of RAM, ask whether you actually need the values, or just the membership answer.
The project is MIT licensed, written in C++ with a Python blocklist builder, and lives at github.com/9M2PJU/9M2PJU-ESP32-C3-AdBlocker. Flash one from the browser at https://9m2pju.github.io/9M2PJU-ESP32-C3-AdBlocker/, plug it into your router, and the next time someone complains that ads are eating their phone battery over Wi-Fi, hand them a matchbox.
Sources and Further Reading
- 9M2PJU-ESP32-C3-AdBlocker, source, releases, and web flasher: https://github.com/9M2PJU/9M2PJU-ESP32-C3-AdBlocker
- Web flasher (browser, Chrome/Edge): https://9m2pju.github.io/9M2PJU-ESP32-C3-AdBlocker/
- Original upstream project, M-Abozaid/esp32-c3-adblock: https://github.com/M-Abozaid/esp32-c3-adblock
- Inspiration project, s60sc/ESP32_AdBlocker: https://github.com/s60sc/ESP32_AdBlocker
- StevenBlack hosts (default blocklist base): https://github.com/StevenBlack/hosts
- Hagezi DNS blocklists (default list extension): https://github.com/hagezi/dns-blocklists
- Pi-hole (the original Raspberry Pi ad-blocker): https://pi-hole.net/
- PlatformIO documentation: https://docs.platformio.org/
- esptool (ESP32 flash tool): https://github.com/espressif/esptool
- ESP Web Tools (the library behind the browser flasher): https://esphome.github.io/esp-web-tools/
- ESP32-C3 datasheet (Espressif): https://www.espressif.com/en/products/socs/esp32-c3
- HamRadio.my, amateur radio resources, tools and reviews: https://hamradio.my/
- About 9M2PJU, Malaysian amateur radio operator: https://hamradio.my/about-9m2pju-malaysian-amateur-radio-operator/


