利用WFP实现浏览访问域名重定向到指定 IP
做 Windows 网络开发,有时需要拦截特定域名的 DNS 查询,返回一个自定义的 IP 地址,从而实现流量引导、本地调试或访问控制。
本文以拦截 www.abc.com 的 DNS A 查询为例,完整说明如何通过 WFP(Windows Filtering Platform)在传输层拦截 DNS 请求,并返回指定 IP(如 192.168.1.100)。重点讲配置流程和关键决策点,辅以必要的代码片段说明。
整体流程
整个方案分五步走:
- 注册 Provider 和 SubLayer,建立自己的"地盘"
- 注册 Callout,绑定回调函数
- 添加过滤条件,只抓 UDP 53 端口的包
- 在回调里解析 DNS 请求,匹配域名后构造响应并注入
- 卸载时反序清理所有资源

第一步:创建 Provider 和 SubLayer
Provider 相当于"谁注册的规则",SubLayer 则是给同一 Provider 下的过滤器排优先级。两者都是后续注册 Filter 和 Callout 的前置依赖。
创建 SubLayer 时,weight 值直接决定了我们的过滤器在同一个层里的优先级。设置为 0x7FFF 这样一个较高的值,可以确保我们的过滤规则在系统默认规则之前被评估,避免被 Windows 防火墙或其他第三方软件抢先处理。
这一步调用两个 API:FwpmProviderAdd 和 FwpmSubLayerAdd。
第二步:注册 Callout
Callout 是 WFP 留给开发者注入自定义逻辑的"钩子"。
这里需要做两件事:
- 内核注册(FwpsCalloutRegister):把 classifyFn、notifyFn、flowDeleteFn 三个回调函数绑定到 Callout 上。classifyFn 是核心,每个匹配的包都会进来;notifyFn 处理 Callout 的生命周期事件;flowDeleteFn 用于清理流上下文。
- 管理层注册(FwpmCalloutAdd):在过滤引擎的管理层注册这个 Callout,并指定它挂载的层。这里选择 FWPM_LAYER_OUTBOUND_TRANSPORT_V4。
为什么选传输层而不选网络层?因为网络层(IPPACKET)虽然也能解析到 UDP 端口,但呈现的是完整的 IP 包,可能包含分片,需要自己处理分片重组。而传输层拿到的已经是分片重组后的完整 UDP 数据报,且能关联到进程信息,过滤条件更丰富,实现起来更省事。
第三步:添加过滤条件
Callout 注册完还不会自动生效,必须添加一个 Filter 把它"激活"。
Filter 的核心是一组条件(Conditions)加上一个动作(Action)。我们需要两个条件:
- 协议必须是 UDP(FWPM_CONDITION_IP_PROTOCOL == IPPROTO_UDP)
- 目的端口必须是 53(FWPM_CONDITION_IP_REMOTE_PORT == 53)
动作设置为 FWP_ACTION_CALLOUT_TERMINATING,意思是:一旦匹配这两个条件,立即终止后续过滤器的处理,把包交给我们的 Callout。这样既保证了性能,也避免了和其他过滤器的冲突。
这一步调用 FwpmFilterAdd。
第四步:准备注入资源
伪造的 DNS 响应需要重新注入回网络栈,这需要两个基础设施:
- NET_BUFFER_LIST 池(NdisAllocateNetBufferListPool):用于分配存放伪造数据包的内存结构。池化分配比每次都从零分配效率更高,对性能敏感的网络处理场景很重要。
- 注入句柄(FwpsInjectionHandleCreate):创建一个传输层的注入句柄。这里选择 FWPS_INJECTION_TYPE_TRANSPORT,因为我们的伪造包是构造好的 UDP 数据,需要从传输层注入。之后调用 FwpsInjectTransportReceiveAsync 异步注入时就用这个句柄。
这两个资源在初始化阶段一次性准备好,避免每次注入时重复创建开销。
第五步:回调函数里做什么
当 DNS 请求命中过滤条件后,classifyFn 回调被调用。这个回调的函数原型是:
typedef void (NTAPI *FWPS_CALLOUT_CLASSIFY_FN2)(
_In_ const FWPS_INCOMING_VALUES0* nFixedValues,
_In_ const FWPS_INCOMING_METADATA_VALUES0* inMetaValues,
_Inout_opt_ void* layerData,
_In_opt_ const void* classifyContext,
_In_ const FWPS_FILTER2* filter,
_In_ UINT64 flowContext,
_Inout_ FWPS_CLASSIFY_OUT0* classifyOut);
整个处理流程如下:
5.1 提取包信息
从 inFixedValues 中取出远程地址、本地地址、端口、网卡索引等字段。对于 OUTBOUND_TRANSPORT_V4 层,可用的字段包括:
- FWPS_FIELD_OUTBOUND_TRANSPORT_V4_IP_REMOTE_ADDRESS:目的 IP
- FWPS_FIELD_OUTBOUND_TRANSPORT_V4_IP_LOCAL_ADDRESS:源 IP
- FWPS_FIELD_OUTBOUND_TRANSPORT_V4_IP_REMOTE_PORT:目的端口
- FWPS_FIELD_OUTBOUND_TRANSPORT_V4_IP_LOCAL_PORT:源端口
- FWPS_FIELD_OUTBOUND_TRANSPORT_V4_INTERFACE_INDEX:网卡索引
这些信息在后续构造响应包时需要用到,特别是网卡索引关系到包从哪个网卡注入回去。
5.2 提取并解析 DNS 请求
在 classifyFn 回调中,layerData 指向的是 NET_BUFFER_LIST 结构。通过 NdisGetDataBuffer 可以提取出完整的 UDP 数据报:
- UDP 头固定占 8 字节(源端口 2 字节 + 目的端口 2 字节 + 长度 2 字节 + 校验和 2 字节)
- UDP 头后面紧跟着 DNS 消息
DNS 请求(QR=0)的格式如下:DNS Query (www.abc.com, A record)
1 1 1 1 1 1
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
| ID | 事务ID(与响应匹配)
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
|QR| Opcode |AA|TC|RD|RA| Z|AD|CD| RCODE | QR=0 表示查询
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
| QDCOUNT | 查询区段数量(=1)
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
| ANCOUNT | 回答区段数量(=0)
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
| NSCOUNT | 授权区段数量(=0)
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
| ARCOUNT | 附加区段数量(=0)
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
| |
/ QNAME / 域名(www.abc.com)
/ /
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
| QTYPE | 查询类型(A=1)
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
| QCLASS | 查询类(IN=1)
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
QNAME 部分的编码方式是:每个标签以长度字节开头,后跟标签内容,最后以 0x00 结束。例如 www.abc.com 编码为:
|
03 |
77 |
77 |
77 |
03 |
61 |
62 |
63 |
03 |
63 |
6f |
6d |
00 |
|
3 |
w |
w |
w |
3 |
a |
b |
c |
3 |
c |
o |
m |
end |
先检查 DNS 头的 QR 位(第 3 字节的最高位)——如果是 1 表示这是 DNS 响应,直接跳过;只有 QR=0 的查询才需要处理。
然后解析 DNS 的 Question 区,提取出查询的域名。对于 www.abc.com 这样的域名,通过 RuleManagerMatch 判断是否匹配规则列表。
5.3 检查查询类型并构造响应
从 Question 区末尾取出 QTYPE(查询类型),判断是 A 记录(IPv4,QTYPE=1)还是 AAAA 记录(IPv6,QTYPE=28)。
收到 A 记录查询后,构造一个包含 192.168.1.100 的响应。需要在原请求基础上做以下修改:
修改 DNS 头:
|
字段 |
原值 |
新值 |
说明 |
|
QR |
0 |
1 |
改为响应 |
|
Opcode |
0 |
0 |
保持不变 |
|
AA |
0 |
1 |
设为权威回答 |
|
RCODE |
0 |
0 |
无错误 |
|
QDCOUNT |
1 |
1 |
保留原查询 |
|
ANCOUNT |
0 |
1 |
新增 1 条回答 |
DNS 响应(QR=1)的格式如下:
DNS Response (www.abc.com -> 192.168.1.100)
1 1 1 1 1 1
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
| ID | 与请求的事务ID一致
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
|QR| Opcode |AA|TC|RD|RA| Z|AD|CD| RCODE | QR=1, AA=1
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
| QDCOUNT | 查询区段数量(=1)
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
| ANCOUNT | 回答区段数量(=1)
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
| NSCOUNT | 授权区段数量(=0)
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
| ARCOUNT | 附加区段数量(=0)
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
| |
/ QNAME / 原样回显查询的域名
/ /
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
| QTYPE | 原样回显(A=1)
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
| QCLASS | 原样回显(IN=1)
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
| |
/ NAME / 响应区:原样回显域名
/ /
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
| TYPE | 类型(A=1)
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
| CLASS | 类(IN=1)
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
| TTL | 生存时间(如 300 秒)
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
| RDLENGTH | 数据长度(IPv4=4)
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
| RDATA | 192.168.1.100(4字节)
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
RDATA 区存放 IP 地址,192.168.1.100 按网络序(大端)写为 C0 A8 01 64。
构造时需要在原请求的 DNS 头上做修改:翻转 QR 位,设置响应标志,填充 Answer 区。同时还要在数据前面封装 8 字节的 UDP 头,因为注入时要从传输层出发。
5.4 封装 IP 头并注入
构造好的 DNS 响应还需要在前面加上 UDP 头才能从传输层注入:
UDP Header(8 字节)
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
| 源端口(DNS服务器=53) |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
| 目的端口(客户端端口) |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
| UDP长度 |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
| UDP校验和 |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
| DNS响应(上面整段) |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
然后用 FwpsConstructIpHeaderForTransportPacket0 在 NBL 前面封装 IP 头:
status = FwpsConstructIpHeaderForTransportPacket0(
pInjectNbl, // 包含 UDP+DNS 数据的 NBL
0, // headerIncludeHeaderLength
AF_INET, // IPv4
(UCHAR*)&srcIp, // 源 IP(DNS 服务器地址)
(UCHAR*)&dstIp, // 目的 IP(客户端地址)
IPPROTO_UDP, // 协议类型
0, // endpointHandle
NULL, // controlData
0, // controlDataLength
0, // flags
NULL, // reserved
interfaceIndex, // 网卡索引
subInterfaceIndex // 子网卡索引
);
封装完成后调用 FwpsInjectTransportReceiveAsync 异步注入。注入完成后的回调里负责释放 NBL 和之前分配的 DNS 响应数据内存。
5.5 阻止原始包
最后一步很重要:必须把 classifyOut->actionType 设置为 FWP_ACTION_BLOCK,同时设置 FWPS_CLASSIFY_OUT_FLAG_ABSORB 标志。
如果不阻止原始包,真实的 DNS 响应回来后会和伪造包冲突,导致应用程序拿到错误的 IP。设置这个标志后,WFP 会丢弃原始请求,只有伪造的响应包到达应用层。
第六步:清理动作
卸载驱动时,资源的释放顺序必须和初始化顺序严格相反:
- 删除 Filter(FwpmFilterDeleteById)
- 删除用户态 Callout(FwpmCalloutDeleteByKey)
- 注销内核 Callout(FwpsCalloutUnregisterByKey)
- 删除 SubLayer(FwpmSubLayerDeleteByKey)
- 删除 Provider(FwpmProviderDeleteByKey)
- 释放 NBL 池(NdisFreeNetBufferListPool)
- 销毁注入句柄(FwpsInjectionHandleDestroy)
任何一个步骤失败都可能导致资源泄漏,所以需要用状态标志记录是否有失败,但不建议因为某个步骤失败就停止后续清理,否则泄漏更严重。
关键决策点总结
|
决策点 |
选择 |
原因 |
|
过滤层 |
OUTBOUND_TRANSPORT_V4 |
能同时拿到端口和 UDP 负载,适合 DNS 过滤 |
|
SubLayer 权重 |
0x7FFF |
高优先级,确保先于系统规则被评估 |
|
Filter 动作 |
CALLOUT_TERMINATING |
命中后终止后续处理,提升性能 |
|
注入类型 |
TRANSPORT |
构造的是 UDP 数据,从传输层注入最自然 |
|
响应构造 |
处理 A |
简化示例,实际可处理AAAA记录,可扩展支持其他类型 |
注意事项
- 异步注入:注入用异步接口,需要正确实现完成回调,在回调里释放 NBL 和响应数据,否则内存泄漏。
- MDL 生命周期:IoAllocateMdl 分配的 MDL 在注入完成后要释放,时机在完成回调里。
- NBL 所有权:FwpsInjectTransportReceiveAsync 调用后,NBL 的所有权转移给 WFP,不能手动释放。但 DNS 响应数据(completionContext)需要自己在完成回调里释放。
- 内存分配:构造 DNS 响应数据时用 NonPagedPoolNx 而非 NonPagedPool。一方面,classifyFn 回调可能在 DISPATCH_LEVEL 下执行,在该级别只能访问非分页内存;另一方面,NonPagedPoolNx 额外标记了不可执行(NX),这是微软推荐的安全做法,能防止缓冲区溢出后执行恶意代码。
- 只拦截 UDP:TCP 的 DNS 请求(DoH、DoT)不经过这个过滤逻辑,需要额外的层来处理。
- 如果您有 Windows 网络过滤、流量拦截、IP重定向等相关功能需求,欢迎随时咨询合作。