ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

鸿蒙适配实战:dns_client+DoH力破DNS劫持

鸿蒙适配实战:dns_client+DoH力破DNS劫持 1. 先从一次线上事故说起鸿蒙 App 被劫持的现场接到的这个活儿是把 Flutter 里的 dns_client 三方库挪到鸿蒙环境跑起来。起因很直接——App 在某地市上线后发现部分用户打开详情页总会先跳到一个电竞广告页查来查去不是我们代码的问题是运营商/路由器的 DNS 解析结果被改了域名被解析到了不受信的其他 IP。要解决这类 DNS 劫持问题最干净的办法就是不让 App 依赖系统解析器而是在应用内部把 DNS 查询切换到独立、可校验的加密链路也就是 DNS-over-HTTPSDoH。dns_client 是 Flutter 生态里口碑不错的一款纯 Dart 实现的 DNS 客户端库支持自定义 DNS 报文收发与解析配合 DoH 封装正好能满足这个需求。但把它接到鸿蒙真机上并没有想象中那么顺利。这篇文章就把我这几周踩过的坑、补过的洞和最终落地的方案完整梳理一遍给同样在做鸿蒙网络层加固的朋友做个参考。1.1 现象请求被“指鹿为马”当时用户反馈的路径很规律从首页点开某个运营位页面偶发会先闪出一个全屏广告关掉之后才进入真正的页面。我们一开始怀疑是 WebView 里的 JS 注入但把 WebView 禁掉之后问题依旧。后来在那个 Android 设备上抓包看到一件很气人的事我们请求api.example.com系统 DNS 返回的 A 记录指向的是192.168.1.88这种内网 C 段地址。这显然不是我们机房 IP。顺着链路查下去发现是当地宽带接入设备/运营商 LocalDNS 在缓存或转发环节动了手脚。这类劫持之所以能发生是因为传统 DNS 查询默认走 UDP 53 端口明文发送中间任何一跳网络设备都可以篡改响应而客户端协议本身没有任何校验能力。到了鸿蒙设备上情况更麻烦。Flutter 在鸿蒙生态里运行时默认仍然走系统的getaddrinfo链路去解析域名也就是说你写的HttpClient也好、dio也好如果只把 URL 交给上层网络库底层解析实际由鸿蒙网络栈代劳。你没法在 Dart 层控制“这 53 端口的包到底是谁回的回的内容是不是真的”。所以我们当时的判断是必须在 Flutter 层建立一个完全独立于系统解析器的 DNS 解析模块让域名解析结果由我们自己控制的信任链路提供再把这个结果显式交给网络请求去连接。1.2 为什么鸿蒙环境特别容易踩这个雷不是说 Android/iOS 就没有 DNS 劫持而是鸿蒙生态刚起步的阶段有个客观短板开发工具链、排障文档和可用的安全组件比成熟生态少。Android 上遇到劫持社区里有大量现成的 hook 方案、抓包教程、Nagios 类监控脚本iOS 上也有 Network Extension 这种系统级能力。鸿蒙这边对开发者来说最难受的是第三方库大多没有做过鸿蒙化适配出问题之后你连“是不是 Flutter 引擎网络栈实现差异导致的”都没法快速定位。更现实的一点是HarmonyOS NEXT 这种基于鸿蒙内核和自研网络栈的形态对传统 Linux/Android 的网络行为有很多细节差异。比如 UDP Socket 的 connect 行为、DNS 缓存读取路径、系统证书库的位置和格式都跟 Android 不完全一致。这意味着你从 pub.dev 拉下来一个纯 Dart 写的网络库以为“纯 Dart 应该哪都能跑”实际上碰到底层dart:io的边界行为时仍然会被打得措手不及。这个背景想清楚之后后面的适配工作就有主线了先让 dns_client 在鸿蒙运行时把最基本的 DNS 查询能力跑通再围绕它搭建 DoH 加密链路最后把它作为 Flutter 网络层的默认解析器接入业务代码。2. dns_client 干了什么报文级解析与 DoH 的取舍2.1 它不是“改 Hosts”而是自己拼 DNS 报文很多人一听到“自定义 DNS 解析”下意识觉得就是把某个域名写死在配置文件里或者改一份 Hosts 映射。实际上 dns_client 做的事情要底层得多它直接在 Dart 层构造 DNS Query 报文通过 UDP/TCP 发给指定的 DNS 服务器然后把返回的二进制 DNS Response 报文拆开解析出 A/AAAA/CNAME/TXT/NS 等记录。一个标准 DNS 报文结构其实不复杂最前面是 12 字节的 Header包含事务 ID、标志位、问题数、资源记录数等字段紧接着是 Question 区域里面是你要查询的域名和记录类型再往后是 Answer 区域里面有解析结果。dns_client 的核心价值就是把这些二进制协议细节封装好了你只需要调用它的 API 就能拿到结构化结果。import package:dns_client/dns_client.dart; void main() async { final dns DnsClient( servers: [InternetAddress(223.5.5.5)], timeout: const Duration(seconds: 3), retries: 2, ); final result await dns.lookup( api.example.com, options: LookupOptions( recordType: RecordType.dnsA, oneShot: true, ), ); for (final record in result.records) { print(${record.type}: ${record.value}); } }这段代码跑起来之后你会发现它并不是去调用InternetAddress.lookup()这种系统解析接口而是走了一整套自定义的查询逻辑构造 Header、填充 Question、发送 UDP 报文、校验事务 ID、解析响应。这也是我在鸿蒙适配中第一件要做的事——确认这些纯 Dart 逻辑在新运行时的表现和预期一致。2.2 DoH 为什么要走 HTTPS防劫持的关键一步单纯把 DNS 查询从系统层搬到 dns_client还不足以防劫持。因为如果你仍然走 UDP 53 明文包中间设备该篡改还是能篡改。dns_client 提供了DnsClient走 UDP/TCP 的能力也提供了基本的 TLS/DoT 支持但真正要让解析结果可靠我选择把 DoH 作为主链路。DoH 的思路说起来很直白DNS 查询本身还是那一套报文但是把它塞进 HTTPS 请求的 body 里发给一个支持 DoH 的服务器服务器再把 DNS Response 也放在 HTTPS 响应里返回。这样传输过程有 TLS 加密中间设备看不到明文 DNS 内容自然也没法定向篡改。用生活类比来解释传统 DNS 就像你在广场上大声喊“去图书馆怎么走”路过的人谁都能告诉你一个答案也可能故意指错路。DoH 则像是你走进一家银行柜台把问题写在一张密封信封里递给柜员柜员盖章回复整个过程中信封内容不会被外面的人看到。我实际用的是自定义的 DoH 封装构造 DNS Query 报文用HttpClient以application/dns-message类型 POST 到 DoH 服务端拿到响应 body 之后再交给 dns_client 的报文解析能力去还原记录。这样既保留了dns_client对二进制报文的解析能力又没有把 Dart 层兼容性押在某个封装过深的第三方 DoH 客户端上鸿蒙环境里出问题也容易排查。2.3 鸿蒙化之前的混合方案为什么不用那些封装好的 DoH 包直接干活我调研过几个有的依赖平台 Channel 做原生证书校验有的在鸿蒙引擎里会触发Unsupported error。而 dns_client 这个组合拳的好处在于底层报文构造是纯 Dart不涉及原生实现替换DoH 传输层HttpClient在鸿蒙 Flutter 引擎里可用证书校验策略可以自己掌控鸿蒙和 Android 证书库差异导致的坑可以绕开。这套混合方案是整个鸿蒙化适配的物理基础。后面三个坎全是在这个基础上踩出来的。3. 鸿蒙化适配要跨的三个坎证书、权限、生命周期3.1 坎一HTTPS 证书校验失败现象把上面那段代码放到鸿蒙真机上跑dns.lookup()直接抛HandshakeException日志里能看到CERTIFICATE_VERIFY_FAILED。在 Android 模拟器和 iOS 模拟器上都没这个问题代码一样行为却不同。排查链路我第一反应是看鸿蒙 Flutter 引擎是否完整实现了dart:io的SecureSocket。对照日志发现握手阶段能连上但证书验证那一步挂了。随后我对比了 Android 设备上系统 CA 证书的读取路径和鸿蒙的差异发现鸿蒙的根证书库并不是/system/etc/security/cacerts那种 Android 常用的 PEM 目录结构其证书存储和校验逻辑有自己的实现。因此 Flutter 引擎在构造SecurityContext时默认的根证书列表可能没被正确加载。修复我采用了一个可配置的SecurityContext显式传入一份常用的 CA Bundle。具体做法是把我们信任的根证书文件中打包进 assets初始化时用SecurityContext.defaultContext作为基础再通过setTrustedCertificates把 assets 里的 PEM 内容加载进去。import dart:io; import package:flutter/services.dart show rootBundle; FutureSecurityContext buildSecurityContext() async { final ctx SecurityContext.defaultContext; try { final caData await rootBundle.loadString(assets/certs/ca-bundle.pem); ctx.setTrustedCertificatesBytes(caData.codeUnits); } catch (e) { // 加载失败时回退到系统默认但需要额外告警监控 debugPrint(load trusted ca failed: $e); } return ctx; }这步做完之后DoH 请求第一次在鸿蒙上握手成功。注意这里我没有用badCertificateCallback全局放行因为那是把安全防线全拆了只能用来本地临时调试。3.2 坎二网络权限与隐私接口的声明现象在真机上跑起来之后又一个经典报错迎面而来SocketException: Permission denied。鸿蒙应用的网络权限不是像 Android 那样在AndroidManifest.xml里写一句INTERNET就行它需要在module.json5里显式声明ohos.permission.INTERNET。如果你的应用还需要监听网络状态变化也要把相关权限一并列好。排查链路一开始我以为 Flutter 项目只需要关心 pubspec.yaml鸿蒙的权限配置属于 DevEco 工程侧容易忽略。实际上当你用 flutter 命令跑鸿蒙工程时系统会生成对应模块配置但如果你的工程是手动把 Flutter 模块嵌进去的module.json5里的权限列表很可能根本没有网络权限。修复在entry/src/main/module.json5的requestPermissions里加上{ module: { requestPermissions: [ { name: ohos.permission.INTERNET } ] } }这个坑基本是鸿蒙和 Android 双端开发时最容易被忽略的差异点。Android 项目里很多底层能力默认带着鸿蒙这边却要逐个声明尤其是涉及到网络、蓝牙、存储这些高危或者普通权限时漏一个就白屏一个。3.3 坎三Socket 生命周期与页面切换的纠缠现象DoH 链路稳住之后又遇到一个很隐蔽的问题从 App 切到后台再回来或者页面快速 push/pop 时偶发出现TimeoutException而且不是每次都必现。排查链路我拉长日志周期后发现DnsClient内部的 UDP Socket 在页面生命周期走到 inactive 时会被系统回收但 Dart 层的查询状态还挂着等到超时才释放。这种事情在 Android 上几乎不会暴露因为 Android 进程被杀之前 Socket 通常还稳着鸿蒙对后台进程和网络资源的调度更狠Socket 说断就断。修复我在封装层做了两个措施一是控制查询并发同一域名不允许多个 socket 同时发出去二是在 App 生命周期切到inactive或paused时主动取消所有未完成的 lookup future。class DoHResolver { final _inflight String, FutureLookupResult{}; FutureLookupResult resolve(String domain, {LookupOptions? options}) { final existing _inflight[domain]; if (existing ! null) { return existing; } final future _doLookup(domain, options: options); _inflight[domain] future; future.whenComplete(() _inflight.remove(domain)); return future; } void cancelAll() { for (final future in _inflight.values) { // 触发 future 的取消逻辑内部会关闭 socket future.ignore(); } _inflight.clear(); } }这三个坑跨过之后dns_client 在鸿蒙上的基础能力才算真正稳定。接下来要做的是把它织进一个可供业务层直接使用的安全解析层。4. 落地封装把它变成鸿蒙项目里的安全解析层4.1 整体架构设计我不会允许业务代码里到处直接 new 一个DnsClient那样后续想换 DoH 服务器、加缓存、做熔断都无从下手。所以封装层设计了三块内容解析器接口与实现定义Resolver接口内部实现走 dns_client DoH 链路域名解析缓存自己做 TTL 缓存避免每次网络请求都打一次 DoH 服务多服务器故障转移主 DoH 服务超时后自动切到备用服务保证高可用。abstract class Resolver { FutureLookupResult resolve(String domain, {LookupOptions? options}); } class DoHResolver implements Resolver { DoHResolver({ required ListString servers, SecurityContext? securityContext, Duration timeout const Duration(seconds: 4), int retries 2, }) : _servers servers, _timeout timeout, _retries retries, _securityContext securityContext; final ListString _servers; final Duration _timeout; final int _retries; final SecurityContext? _securityContext; final _cache String, _CachedResult{}; }4.2 关键代码实现DoH 请求的核心逻辑是构造一个符合 RFC 8484 的请求。简单来说用 dns_client 的报文构造能力生成 DNS Query 字节流然后通过 HTTPS POST 发给 DoH 服务器。响应体是一段 DNS Response 报文再交给 dns_client 解析。FutureLookupResult _lookupViaDoh(String domain, String server) async { final client HttpClient(context: _securityContext); client.connectionTimeout _timeout; final queryData buildDnsQuery(domain); // 内部用 dns_client 生成报文 final url Uri.parse($server/dns-query); final request await client.postUrl(url); request.headers.contentType ContentType(application, dns-message); request.headers.add(accept, application/dns-message); request.add(queryData); final response await request.close(); if (response.statusCode ! 200) { throw DoHException(DoH server returns ${response.statusCode}); } final body await response.foldListint(int[], (acc, chunk) { acc.addAll(chunk); return acc; }); client.close(); return parseDnsResponse(body); // 内部用 dns_client 解析报文 }故障转移的处理我用了简单但有效的策略把所有 DoH 服务器排成队列逐个尝试。如果第一个服务在超时时间内没有返回就立即切换到下一个。这里有一个很重要的体验细节不要在首次尝试时等待全部超时才切换那样用户感知会非常差。我做成每台服务器最多等timeout / retries的时间比如总超时 4 秒、重试 2 次那么第一台服务器只给它 2 秒失败就切下一台。4.3 与 Flutter 网络层的对接这是整个适配里业务价值最大的一步让 App 里所有的 HTTP 请求都能走到这套 DoH 解析链路。最直接的做法是给HttpClient设置一个自定义的connectionFactory或者用dio时自定义HttpClientAdapter。以dio为例你可以让 adapter 在建立连接前把 host 换成 dns_client 解析出来的 IP同时把原始域名放进Host请求头交给服务器做 SNI/虚拟主机校验class DoHHttpClientAdapter extends IOHttpClientAdapter { DoHHttpClientAdapter({required this.resolver}); final DoHResolver resolver; override FutureResponseBody fetch( RequestOptions options, StreamUint8List? requestStream, Future? cancelFuture, ) async { final host options.uri.host; final result await resolver.resolve(host); final firstIp result.records .where((r) r.type RecordType.dnsA) .map((r) r.value) .firstOrNull; if (firstIp ! null) { final originalUri options.uri; final newUri originalUri.replace(host: firstIp); options options.copyWith( baseUrl: newUri.toString(), headers: {...options.headers, Host: host}, ); } return super.fetch(options, requestStream, cancelFuture); } }这样一改App 内所有走 dio 的网络请求在鸿蒙设备上都不再依赖系统解析器域名解析的最后一公里完全由我们自己控制的 DoH 服务决定。需要提醒的是这种方案只适合绝对可信的业务域名如果你的 App 需要访问大量用户输入的外部域名还是建议做白名单策略避免某些域名在 DoH 侧解析失败导致全网受牵连。5. 验证是否有效不只是“能上网”适配完成之后我做了三轮验证每一轮都有明确的判断标准。5.1 第一轮抓包确认流量走向用鸿蒙设备能用的抓包工具我这边用的是开发环境自带的网络抓包能力配合 Wi-Fi 侧抓包看两个指标UDP 53 端口的 DNS 明文包是否消失443 端口是否出现发往 DoH 服务器的 TLS 流量。如果抓包结果显示业务请求发起前后只有一条到 DoH 服务器的 HTTPS 连接且没有任何本地 DNS 流量说明应用确实绕开了系统解析器。这里要特别注意一个细节Flutter 引擎自身在启动时可能还会有系统 DNS 解析行为比如加载某些配置时会查localhost这不影响业务链路的验证别被干扰项绕进去。5.2 第二轮主动制造劫持现场我在局域网里搭了一台 DNS 劫持模拟设备把api.example.com的解析结果强行改成指向一个测试 IP同时在 Wi-Fi 路由层面拦截普通 UDP 53 请求。然后分别在以下两种情况下发起业务请求关掉 DoH、使用系统默认解析请求被劫持页面无法打开日志显示连上了那个测试 IP开启 DoH 解析请求正常返回连接的是真实业务 IP。这个对照实验是验证整套链路最有力度的证据。它证明的不仅是 DoH 服务器能解析域名更是“一旦走了 dns_client DoH中间设备就再也没有办法篡改解析结果”。5.3 第三轮业务回归技术链路通了之后我还做了日常业务回归登录、支付回调、图片加载、WebView 页面。尤其要注意 WebView 里的流量——如果你的页面内容由 WebView 承载那 WebView 自身的 DNS 解析并不会走 Flutter 层的 DoH它大概率仍然由系统解析。这是目前这类方案的边界Flutter 层网络请求可控但 WebView 还是要靠系统 DNS。为了覆盖 WebView 场景要么做 Chromium 内核的定制要么在 WebView 层做独立的代理配置这块要看你的业务容忍度。6. 个人向的几条边界提醒适配做完不代表所有 DNS 问题就一劳永逸。有几点我特别想对准备照抄这份方案的人说。第一别把内网域名也塞进 DoH。你的 App 里很可能有consul.local、app.internal这类私网域名DoH 公共服务器是解析不了的。我在封装层加了一个域名后缀白名单凡是命中内网后缀的直接走系统解析只有业务公网域名才走 DoH。这样既保住了安全链路又不影响内部环境的正常调试。第二选 DoH 服务商要克制。公开 DoH 服务有不少但延迟、可用性、数据留存策略各不相同。在生产环境里我建议优先选你业务所在区域网络可达性好、且你实际压测过的服务商不要盲目追求“看起来更安全”的境外服务。网络不是越绕越好解析延迟每多 50ms用户打开 App 首屏就慢一大截。可以配置多个服务商做故障转移但每个都要有真实的线上监控数据支撑。第三不要忽视缓存。DNS 解析结果本身有 TTL但很多网络库为了“省事”会把解析结果缓存得特别久这在 IP 变动频繁的场景下反而会引发连接失败。我这边做了一个分级缓存TTL 内的直接复用过了 TTL 但还没到硬超时的时间窗口内会做一次后台预解析如果预解析成功就无感更新缓存预解析失败则触发一次主链路查询并告警。这个策略在稳定性上明显好于“拿到结果就永远不更新”。第四鸿蒙的适配不是一锤子买卖。鸿蒙系统版本迭代节奏快Flutter 鸿蒙引擎也是跟着社区 fork 在走dart:io的某些实现可能在下一个版本又变。建议在 CI 里专门留一台鸿蒙真机做网络层冒烟测试把 DoH 解析、证书校验、Socket 生命周期这几个关键场景固化成自动化用例别等线上出了问题才去查日志。最后再分享一个实际体验这套东西在鸿蒙上能从 0 跑到真机最关键的不是某个炫酷 API而是对每一层“为什么这么设计”都保持警惕。证书失败时不要急着关校验权限报错时不要急着改最低版本Socket 超时时不要只是把 timeout 调到无限大——每一条异常链路的背后都是鸿蒙和传统 Android 生态在底层实现上的真实差异。把这些差异记录下来下次再适配别的纯 Dart 网络库时你会比这次快得多。
返回列表