利用WFP实现浏览访问域名重定向到指定 IP

做 Windows 网络开发,有时需要拦截特定域名的 DNS 查询,返回一个自定义的 IP 地址,从而实现流量引导、本地调试或访问控制。

本文以拦截 www.abc.com 的 DNS A 查询为例,完整说明如何通过 WFP(Windows Filtering Platform)在传输层拦截 DNS 请求,并返回指定 IP(如 192.168.1.100)。重点讲配置流程和关键决策点,辅以必要的代码片段说明。

整体流程

整个方案分五步走:

  1. 注册 Provider 和 SubLayer,建立自己的"地盘"
  2. 注册 Callout,绑定回调函数
  3. 添加过滤条件,只抓 UDP 53 端口的包
  4. 在回调里解析 DNS 请求,匹配域名后构造响应并注入
  5. 卸载时反序清理所有资源

第一步:创建 Provider 和 SubLayer

Provider 相当于"谁注册的规则",SubLayer 则是给同一 Provider 下的过滤器排优先级。两者都是后续注册 Filter 和 Callout 的前置依赖。

创建 SubLayer 时,weight 值直接决定了我们的过滤器在同一个层里的优先级。设置为 0x7FFF 这样一个较高的值,可以确保我们的过滤规则在系统默认规则之前被评估,避免被 Windows 防火墙或其他第三方软件抢先处理。

这一步调用两个 API:FwpmProviderAdd 和 FwpmSubLayerAdd。

第二步:注册 Callout

Callout 是 WFP 留给开发者注入自定义逻辑的"钩子"。

这里需要做两件事:

  1. 内核注册(FwpsCalloutRegister):把 classifyFn、notifyFn、flowDeleteFn 三个回调函数绑定到 Callout 上。classifyFn 是核心,每个匹配的包都会进来;notifyFn 处理 Callout 的生命周期事件;flowDeleteFn 用于清理流上下文。
  2. 管理层注册(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 响应需要重新注入回网络栈,这需要两个基础设施:

  1. NET_BUFFER_LIST (NdisAllocateNetBufferListPool):用于分配存放伪造数据包的内存结构。池化分配比每次都从零分配效率更高,对性能敏感的网络处理场景很重要。
  2. 注入句柄(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 会丢弃原始请求,只有伪造的响应包到达应用层。

第六步:清理动作

卸载驱动时,资源的释放顺序必须和初始化顺序严格相反

  1. 删除 Filter(FwpmFilterDeleteById)
  2. 删除用户态 Callout(FwpmCalloutDeleteByKey)
  3. 注销内核 Callout(FwpsCalloutUnregisterByKey)
  4. 删除 SubLayer(FwpmSubLayerDeleteByKey)
  5. 删除 Provider(FwpmProviderDeleteByKey)
  6. 释放 NBL 池(NdisFreeNetBufferListPool)
  7. 销毁注入句柄(FwpsInjectionHandleDestroy)

任何一个步骤失败都可能导致资源泄漏,所以需要用状态标志记录是否有失败,但不建议因为某个步骤失败就停止后续清理,否则泄漏更严重。

关键决策点总结

决策点

选择

原因

过滤层

OUTBOUND_TRANSPORT_V4

能同时拿到端口和 UDP 负载,适合 DNS 过滤

SubLayer 权重

0x7FFF

高优先级,确保先于系统规则被评估

Filter 动作

CALLOUT_TERMINATING

命中后终止后续处理,提升性能

注入类型

TRANSPORT

构造的是 UDP 数据,从传输层注入最自然

响应构造

处理 A

简化示例,实际可处理AAAA记录,可扩展支持其他类型

注意事项

  1. 异步注入:注入用异步接口,需要正确实现完成回调,在回调里释放 NBL 和响应数据,否则内存泄漏。
  2. MDL 生命周期:IoAllocateMdl 分配的 MDL 在注入完成后要释放,时机在完成回调里。
  3. NBL 所有权:FwpsInjectTransportReceiveAsync 调用后,NBL 的所有权转移给 WFP,不能手动释放。但 DNS 响应数据(completionContext)需要自己在完成回调里释放。
  4. 内存分配:构造 DNS 响应数据时用 NonPagedPoolNx 而非 NonPagedPool。一方面,classifyFn 回调可能在 DISPATCH_LEVEL 下执行,在该级别只能访问非分页内存;另一方面,NonPagedPoolNx 额外标记了不可执行(NX),这是微软推荐的安全做法,能防止缓冲区溢出后执行恶意代码。
  5. 只拦截 UDP:TCP 的 DNS 请求(DoH、DoT)不经过这个过滤逻辑,需要额外的层来处理。

 

  • 如果您有 Windows 网络过滤、流量拦截、IP重定向等相关功能需求,欢迎随时咨询合作。