广告拦截器是"广告刺客"吗?硬件去广告的隐私与合规边界
【免费下载链接】esp32-c3-adblockPi-hole-class DNS ad-blocker on a $2 ESP32-C3 (no PSRAM): 537k domains as 40-bit FNV-1a hashes in flash, binary-searched. UDP DNS sinkhole + web dashboard. https://youtube.com/shorts/RaxszOUMi8E?feature=share项目地址: https://gitcode.com/GitHub_Trending/es/esp32-c3-adblock
把 53.7 万个广告域名压成 40 位哈希塞进 4 MB Flash,用约 50 KB 内存、10 毫秒完成一次"该不该拦"的判定——esp32-c3-adblock这颗 2 美元的 ESP32-C3 最近接连被 Tom's Hardware、XDA Developers 等媒体转载,社区里关于它的解析文章也扎堆出现。风光背后,一个更值得讨论的问题被热闹掩盖了:它是一个"广告刺客"——潜伏在 DNS 咽喉上,精准刺向每个广告域名。但刺客站在阴影里,同一双眼睛既能替你做裁决,也天然掌握着全网的浏览轨迹。本文结合仓库源码,把它的技术底座、数据流向、信任模型与合规边界逐层拆开。
刺客的位置:它站在每一条 DNS 查询的咽喉上
项目的定位在 README.md 里一句话讲完:Pi-hole 级别的 DNS 广告拦截器,跑在 2 美元的 ESP32-C3 上,不需要 PSRAM。它不修改网页、不注入脚本、不碰任何内容字节,只做一件事——把进入网络的每条 DNS 查询"截胡",在域名解析的瞬间决定放行或刺成0.0.0.0:
query in ──▶ extract domain ──▶ FNV-1a hash (+ parent suffixes) ──▶ binary-search the flash hash table ├─ hit ──▶ answer 0.0.0.0 (sinkholed) └─ miss ──▶ forward to upstream resolver, relay the reply这行流程图的每一环都有代码可查。核心匹配逻辑在 src/main.cpp 中:查询域名被解析后,先做 40 位截断的 FNV-1a 哈希,再带父域后缀逐级上探(isBlocked()),命中的查询由buildBlocked()回一个只有 question + answer、ANCOUNT=1的合法应答,指向0.0.0.0——也就是"黑洞拦截"。未命中则交给forwardUpstream(),带独立随机事务 ID 转发给上游并校验应答归属,避免迟到的旧应答被错发给下一个客户端。
这里的关键设计决策是:黑名单不驻留内存,而是以排序哈希的形式躺在 Flash 里。141K 域名约占 0.7 MB Flash、零碰撞;537K 域名也只有一个生日碰撞。为了让二分查找更快,src/main.cpp 还维护了一个 4096 条 × 5 字节 ≈ 20 KB 的一级索引blIndex,外加 256 路直映射缓存,把每次判定的 Flash 读取压到个位数。整条链路只占约 50 KB RAM——这就是"哈希存 Flash 吊打字符串存 RAM"的底层逻辑。
"刺客"的比喻在这里成立:它不见血、不碰内容,只在域名解析的毫秒窗口内精准一击。但请记住这个位置——它是整个局域网设备的默认 DNS 服务器,意味着每一台手机、电视、电脑发出的每一条解析请求,都先经过它。问题随之而来:它到底"看见"了什么?
它"看见"的比它"拦截"的多:DNS 层的元数据全景
很多人以为 DNS 拦截器只"看见"广告域名,这是误解。从 src/main.cpp 的客户端表可以看到,它对每个来路都做了完整的登记:
static Dev* getClient(uint32_t ip) { ... c->ip = ip; c->blocked = c->allowed = 0; ... getMac(ip, c->mac); return c; }getMac()通过etharp_find_addr()从 ARP 表里捞出客户端的物理地址。于是仪表盘上出现的不只是"拦截数、放行数",而是按客户端拆分的 IP + MAC + 拦截/放行计数(见 src/page.h 的stats.json渲染逻辑),甚至还有 RSSI、芯片温度、空闲堆栈。换句话说:谁家的哪台设备、在什么时间、访问了哪些域名、各多少次,这台"2 美元的小盒子"全都门儿清。
这不是它的 bug,而是 DNS 拦截的技术宿命:DNS 是明文 UDP 协议,解析请求里就带着完整域名;在 DNS 层拦截,就等于天然获得一份全网络的"浏览元数据时间线"。传统浏览器插件只看到自己那个标签页的请求,而 DNS sinkhole 看到的是整个家庭网络的域名级流量全景。再叠加一个细节:客户端往往附带 EDNS OPT 记录,src/main.cpp 的 README 特别提醒被拦应答必须只含 question + answer,否则会被判为畸形——这说明它连协议细节都做了处理,工作得相当"专业"。
当然,刺客的视野也有明确边界:它只覆盖端口 53 的明文 DNS。客户端一旦启用 DoH/DoT 加密 DNS,或走 IPv6 解析(固件使用 IPv4 的IPAddress与 UDP 套接字),流量便绕过它——这既是能力的上限,也是隐私上的一道天然分界。
数据流向的三张账单:上游、黑名单源与更新通道
"看见什么"之后,更尖锐的问题是"数据去哪儿"。仓库源码里可以完整勾出三条数据链路:
第一张账单:默认上游 Quad9。src/main.cpp 里UPSTREAM_IP默认是9.9.9.9。所有未被拦截的查询,最终都转发给这个由瑞士非营利基金会运营的解析器。也就是说,即便你架设了自建拦截器,放行的那部分浏览元数据依然会流向一个第三方——隐私模型只是从"你的 ISP 知道"变成"你的 ISP + Quad9 知道"。
第二张账单:黑名单来源。tools/build_blocklist.py 的默认源是 StevenBlack hosts 基础列表 + Hagezi Light 通配符列表(约 10 万条,广告/追踪/恶意域名,但保留社交与即时通讯可用);而仓库 README 说明,默认列表由 GitHub Actions每周一自动重建并发布为稳定资产——设备端的远程自动更新会按设定周期(默认 24 小时)去拉取。
第三张账单,也是最微妙的一张:更新通道与内容校验。看 src/main.cpp 的fetchBlocklist():
WiFiClientSecure cs; cs.setInsecure(); // blocklist isn't secret -> skip cert pinning注释的立场很坦诚:"黑名单不是秘密,所以跳过证书固定"。但跳过证书校验意味着 HTTPS 通道可以被中间人替换;而换下来的内容,commitNewBlocklist()只校验了文件大小是 5 的倍数且非空,没有任何签名或哈希校验。换言之:攻击者若能劫持这条更新链路,可以推给你一份"看起来合法"的黑名单,轻则把半个互联网误杀成黑洞,重则让设备长期处于不可用状态。项目之所以能承受这种风险,靠的正是它的 Fail-Open 设计——替换期间numHashes=0,设备直接放行全部流量,"宁放行不误杀"。这是一个教科书式的取舍:在内容不可信的前提下,把失败模式设计成最不坏的那一种。
另外别忘了设备本地的明文小账本:自定义域名写入 LittleFS 的/custom.txt、被封禁设备列表写入/banned.txt,都是可读文本。设备掉进他人之手,这些就是现成的网络画像。
开源自证清白 vs 闭源商业拦截器:一份可以核对的信任账
"刺客在暗处"带来的信任问题,开源的回答方式很独特:把整个作案现场摆到桌面上,任人核对。这个仓库的完整程度相当高——固件源码(src/main.cpp)、仪表盘页面(src/page.h)、黑名单构建工具(tools/build_blocklist.py)与测试、分区表(partitions.csv)、预编译产物(docs/下的firmware.bin、littlefs.bin)、甚至 3D 打印外壳的 STL 文件(hardware/esp32-c3-supermini-enclosure.stl)都在仓库里。MIT 许可,意味着你不仅"相信它不偷看",还可以亲自验证它不偷看。
更难得的是,项目自身的安全迭代史就写在代码注释里,堪称开源信任模型的活教材:
- 早期版本的状态变更端点对局域网内任何人开放——任何设备都能重刷固件、改写黑名单(src/main.cpp 的
requireAuth()注释直言不讳); - 于是全部变更端点加上 HTTP Basic Auth,网络 OTA 加
OTA_PASS; - 由于浏览器会自动重放缓存的 Basic Auth 凭证,普通
<img>/表单即可发动跨站请求,项目又引入X-Requested-With: c3-adblock自定义头阻断 CSRF(src/page.h 的 JS 与 src/main.cpp 的requireAuth()一一对应); - 自定义域名经
/addblock写入后会被 HTML 转义再渲染,堵住了存储型 XSS; - 最后,如果
secrets.h还保留着仓库示例里的占位口令,固件会在串口告警、仪表盘会亮红色横幅(见 src/page.h 的credwarn)。
对照之下,闭源商业拦截器的信任账往往只有一行"承诺":隐私政策怎么写、固件是否回传遥测、黑名单是否掺入商业白名单,用户无从验证。开源的优越性不在于"它一定安全",而在于把信任从"相信厂商"降级为"可以验证"——正如项目 README 反复强调的:Basic Auth 只是局域网信任边界的控制手段,不是加密;明文 HTTP 上的凭证可被嗅探。自证清白的前提,是用户真的动手改了 src/secrets.example.h 复制出来的占位符。开源解决的是"能否审计",审计之后采不采取行动,依然在用户手里。
合规灰区:当"拦截"成为一种竞争行为
最后一个问题最棘手:拦截行为本身,越界了吗?答案取决于"谁、在谁的网络上、替谁做决定"。
对个人自用而言,家庭网络上的 DNS 过滤与 Pi-hole、AdGuard Home 同属一类实践。司法实践对此大体留出了空间:德国法院在围绕 AdBlock Plus 的系列诉讼中几经裁决,最终形成的共识是拦截广告的软件本身并不违法,争议焦点落在付费白名单等商业模式的透明性上。数据保护法视角也类似——GDPR 第 2(2)(c) 条与《个人信息保护法》第七十二条都把"纯粹个人或家庭事务"排除在适用之外,家庭网关上的行为数据通常落在这一豁免之内。
但一旦拦截者进入经营者的角色,性质立刻改变。国内早有硬件级拦截被诉的先例:爱奇艺诉极路由案中,路由器固件内置广告过滤功能,一审判例认定其构成不正当竞争;更早的腾讯诉世界之窗浏览器案,同样围绕浏览器内置广告拦截展开。这些判例的共同逻辑是:《反不正当竞争法》规制的是经营者在市场竞争中的行为,拦截者若以商业产品形态大规模介入他人流量,就踏入了司法审查的射程。对照之下,个人在自己路由器上自建设备、服务自家设备,主体与场景都不满足"经营者竞争行为"的构成要件——这也是 DIY 派拦截器相对安全的位置。
灰区里还有一层更日常的博弈:DNS 层拦截无法表达正则、通配符与$修饰符(tools/build_blocklist.py 会统计并跳过这类规则),也无法从被拦父域中"救出"子域;广告 SDK 与业务功能共用域名的场景里,拦截就是误伤——直播、投屏、验证码偶尔被拦,正是 src/page.h 里Pause按钮存在的理由。至于广告生态本身:拦截器掐断了发行方的变现命脉,这是它的功能,也是它注定站在商业对立面的原因。
给"刺客"立规矩:可验证、可暂停、可交代
绕回标题的疑问:广告拦截器是"广告刺客"吗?从技术上看,它确实是——隐蔽、廉价、直取域名解析的咽喉。但"刺客"一词真正令人不安的,不是它的刀快,而是它在暗处。同一颗芯片上的力量,既可以执行你的广告判决,也可以在没有审计的情况下成为整张网络的窥探点。
esp32-c3-adblock给出的治理答案,全部写在代码里:可验证——源码、构建工具、产物都在仓库,黑名单源公开,碰撞与误杀概率有据可查;可暂停——/pause?s=300支持定时恢复,封禁设备的能力也被设计成可逆操作;可交代——Fail-Open 让故障时刻选择放行而非沉默的误杀,仪表盘让"它到底拦了什么"始终透明。把这三条底线守住,"刺客"就是工具;守不住,它就是监控。边界从来不在技术,而在谁控制它、能否被核对、数据流向何处——以及,你是否亲手改掉了那个CHANGE_ME_WEB_PASSWORD。
【免费下载链接】esp32-c3-adblockPi-hole-class DNS ad-blocker on a $2 ESP32-C3 (no PSRAM): 537k domains as 40-bit FNV-1a hashes in flash, binary-searched. UDP DNS sinkhole + web dashboard. https://youtube.com/shorts/RaxszOUMi8E?feature=share项目地址: https://gitcode.com/GitHub_Trending/es/esp32-c3-adblock
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考