ESP32-C3 Adblock runs Pi-hole-style DNS blocking on a $2 chip

ESP32-C3 Adblock runs Pi-hole-style DNS blocking on a $2 chip

esp32-c3-adblock is a GitHub project that presents a Pi-hole-style DNS ad-blocker running on a $2 ESP32-C3, with no PSRAM required. Its README says most ESP32 DNS sinkholes load the blocklist as domain strings into RAM, which is why they demand PSRAM. This project does something else: it stores each blocked domain as a fixed 5-byte (40-bit) hash in flash, sorted, and binary-searches the table.

The lookup path is short. A DNS query comes in, the firmware extracts the domain and computes an FNV-1a hash of it (plus its parent suffixes), then binary-searches the flash hash table. On a hit it answers 0.0.0.0, so the domain is sinkholed. On a miss it forwards the query to an upstream resolver and relays the reply. A blocked domain also blocks its subdomains. The README says 140,000+ domains fit in about 0.7 MB of flash and are matched in about 10 ms, using about 50 KB of RAM.

The README defends the 40-bit choice as the sweet spot for this flash budget. Collisions follow the birthday bound: at 141k domains you expect about 0, at 537k about 1, and a collision just means one unlucky domain gets over-blocked. Dropping to 32 bits would save 20% of the flash but cost about 7 collisions at 250k domains. Going to 64 bits would waste 3 bytes per domain on a problem the list does not have. The README also says the approach is not a C3 workaround: on a 16 MB ESP32-S3, these hashes would hold about 2.7M domains, against about 466k for strings in 8 MB of PSRAM. In its words, hashes in flash beat strings in PSRAM basically everywhere.

Hardware and setup. The tested target is a C3 SuperMini with 4 MB flash. A classic ESP32 (DevKit or WROOM, 4 MB) also builds, but that port is community-contributed and only compile-tested. The README advises a stable USB power source, such as a phone charger or a router's USB port, because cheap or loose USB-C to A adapters can brown out the radio during WiFi transmit. It also ships a printable case (an STL file), with notes: no supports, 0.2 mm layers, about 15% infill, keep the PCB antenna end clear, and leave the vents open because the board idles around 45 to 55 °C.

Getting started takes one USB flash. You copy a secrets template, build the blocklist with a Python script, then run PlatformIO twice, once for the firmware and once for the blocklist filesystem. After that, firmware and blocklist both update over WiFi. The default list combines StevenBlack base with Hagezi Light, about 100k entries. The build script accepts hosts files, plain domain lists, and AdGuard or Adblock basic rules (including @@ exceptions); rules a DNS hash list cannot express, such as regex, wildcards, $ modifiers and cosmetic rules, are skipped and counted. If a source cannot be downloaded, the build stops rather than quietly producing a smaller list. A fresh default list is rebuilt every Monday by GitHub Actions and published at a stable URL, so a device can pull it on a schedule. WiFi can be set through an on-device captive portal (an access point named C3-AdBlock-XXXX) instead of hardcoded credentials. You then point a device's DNS at the C3, or add it as a secondary resolver.

The web dashboard at c3adblock.local shows per-client block and allow counts, lets you ban a client, and lets you add custom domains. There is a flash-space tradeoff on 4 MB chips: firmware OTA needs two app slots, leaving about 1.3 MB for the blocklist (about 250k domains max). The aggressive 537k "ultimate" list fits only the single-app partition table, with no firmware OTA.

The README has a long security section. State-changing endpoints (/ban, /addblock, /unblock, /forgetwifi, /upload, /update, /setupdate, /fetchnow) require HTTP Basic Auth, and network OTA requires an OTA password. Those used to be open to anyone on the LAN. Mutating endpoints also require a custom X-Requested-With header to block CSRF through cached credentials, and custom domain names are HTML-escaped to close a stored-XSS path. The README is explicit about limits: everything is plain HTTP on port 80, so Basic Auth is a LAN-trust-boundary control, not encryption, and the setup portal's access point is open by design.

On the roadmap, a bucketed prefix index would cut about 18 flash reads per lookup to about 1 to 2 (issue #3); acting as a DHCP server is also listed as not done. The project says it was inspired by s60sc/ESP32_AdBlocker but is an independent from-scratch implementation, and it is MIT licensed.

Key facts

  • The blocklist is stored as sorted 5-byte (40-bit) hashes in flash and binary-searched, so no PSRAM is needed on the $2 ESP32-C3.
  • The README says 140,000+ domains fit in about 0.7 MB of flash, matched in about 10 ms, using about 50 KB of RAM.
  • With 40-bit hashes, expected collisions are about 0 at 141k domains and about 1 at 537k; 32 bits would save 20% of flash but cost about 7 collisions at 250k.
  • On 4 MB flash, keeping firmware OTA leaves about 1.3 MB (about 250k domains max); the 537k list needs the single-app layout with no OTA.
  • Write endpoints require HTTP Basic Auth, but the dashboard runs over plain HTTP on port 80, so the README calls it a LAN-trust control, not encryption.

Why it matters

The project's argument is that a DNS sinkhole does not need the blocklist as strings in RAM. Hashes in flash, binary-searched, get 140,000+ domains onto a $2 board with about 50 KB of RAM. The README claims this is no C3-only trick: on a 16 MB ESP32-S3 the same hashes would hold about 2.7M domains, versus about 466k for strings in 8 MB of PSRAM. It is a clear case of picking a data structure to fit the hardware's real constraint, which here is flash, not RAM.

Who it affects

Home-network tinkerers who want a Pi-hole-style blocker without a Raspberry Pi or a spare PC, and embedded developers working on PSRAM-less ESP32 boards. The README suggests plugging it into a router's spare USB port with a USB-A to USB-C dongle. Anyone who builds it puts a small device in the path of every DNS query on their network, so its configuration matters.

How to use it

You need an ESP32-C3 board with 4 MB flash (tested on a C3 SuperMini). Copy src/secrets.example.h to src/secrets.h and set real WEB_USER, WEB_PASS and OTA_PASS values; WiFi credentials are optional because the captive portal can handle them. Build the blocklist with python3 tools/build_blocklist.py data/blocklist.bin, then run pio run -t upload and pio run -t uploadfs. Use a current PlatformIO, since the older distro package (for example 4.3.4) is too old and fails to build. Point a device's DNS at the C3, or use it as a secondary resolver, and test with dig: doubleclick.net should return 0.0.0.0 and github.com a real IP. Later updates go over WiFi, including a weekly default list pulled from a stable GitHub release URL. A one-click browser installer is described as on the way, with hosting still to be decided. The project is MIT licensed.

How solid is it

The README is the only source here, and it is specific. It gives concrete figures for domain count, flash size, RAM and the collision maths, and it describes the lookup path in detail. The ~10 ms match time and ~50 KB RAM figures are stated without benchmark methodology, and the README does not say whether 10 ms is an average or a worst case. The C3 SuperMini is the tested target; classic ESP32 builds are community-contributed and only compile-tested. The README says the project was featured on Tom's Hardware, XDA Developers and Korben, but gives no detail of that coverage.

Risks and caveats

Hash collisions mean a small chance that a legitimate domain is over-blocked: about 0 expected at 141k domains, about 1 at 537k. Security is deliberately limited. Everything runs over plain HTTP on port 80, the Basic Auth credentials are only base64 and can be read by anyone sniffing the LAN (open or guest WiFi, ARP spoofing), and the WiFi setup portal's access point is open by design, so the WiFi password typed into it is only as safe as that brief radio link. If the placeholder passwords from the example file are left in place, the device still boots and runs, with a serial warning and a dashboard banner. On 4 MB flash you must choose between firmware OTA (about 250k domains max) and the 537k list, which has no OTA. The bucketed index that would cut flash reads per lookup from about 18 to about 1 to 2 is not built yet, and the README lists no throughput figure.