解决 SecoClient 在 Windows 10 上因 bind 失败无法连接的问题

 

背景

俗话说,每个搞网络的公司都会开发一套 VPN 协议,华为当然也不例外,有自己的硬件 VPN 解决方案,能支持 SSL VPN、IPSec、L2TP 等常见的隧道协议。VPN 的客户端叫做 SecoClient,与其他各大厂一样(没错我说的就是 思科 AnyConnect / 深信服 EasyConnect 之流),也是只面向客户提供下载的。上一次用到 SecoClient 是去年六月份,因为某些原因始终无法在我笔记本 Windows 10 上连接;最近需要接入某个超算的网络,又遇到了同样的问题。从杰哥的测试得知,SecoClient 在 MacBook (Intel / M1) 上完全没问题。于是我立刻下单了一台 M1 MBP,问题解决!为了给老师节省科研经费,我还是决定仔细研究一下这个问题究竟是怎么回事。

问题定位

首先,安装的 SecoClient 需要是 7.0.2.33 及以上的版本,否则它自带的虚拟网卡驱动(在设备管理器中可以看到,叫 SVNAdapter)签名是坏的,无法正常使用(而我显然不会因为一个 VPN 关掉驱动程序强制签名)。我遇到的错误是连接时能够通过身份认证,但在几秒后立刻报“启动网卡异常”,没有给出具体错误信息。SecoClient 的连接客户端程序名为 SecoClientCS.exe,日志是 %APPDATA%\SecoClient\log 下的 SecoClient_SecoClientCS_*.log。通过简单的翻找,就能找到错误:

[ VNIC   INFO   2021-01-28 20:37:59.000467 ][Harry Chen] [65535][Set IP and MASK][begin]
[ VNIC   INFO   2021-01-28 20:37:59.000468 ][Harry Chen] [65535][VNIC IP is [REDACTED]]
[ VNIC   INFO   2021-01-28 20:37:59.000470 ][Harry Chen] [65535][VNIC mask is 255.255.255.0]
[ VNIC   INFO   2021-01-28 20:37:59.000651 ][Harry Chen] [65535][Set IP and MASK][success]
[ VNIC   WARN   2021-01-28 20:37:59.000678 ][Harry Chen] [65535][VNIC Init][bind port error, try to set again]
[ VNIC   WARN   2021-01-28 20:37:59.000680 ][Harry Chen] [65535][VNIC Init][bind port error, try to set again]
[ VNIC   WARN   2021-01-28 20:37:59.000681 ][Harry Chen] [65535][VNIC Init][bind port error, try to set again]
[ VNIC   WARN   2021-01-28 20:37:59.000683 ][Harry Chen] [65535][VNIC Init][bind port error, try to set again]
[ VNIC   WARN   2021-01-28 20:37:59.000685 ][Harry Chen] [65535][VNIC Init][bind port error, try to set again]
[ VNIC   ERROR  2021-01-28 20:37:59.000686 ][Harry Chen] [65535][VNIC Init failed][reason:bind port error]
[ NETF   ERROR  2021-01-28 20:37:59.000688 ][Harry Chen] [65535][netf filter failed][reason:netf init fail]
[ CNEM   ERROR  2021-01-28 20:37:59.000689 ][Harry Chen] [65535][Cnem vnic set failed][reason:vnic set failed]
[ CNEM   ERROR  2021-01-28 20:37:59.000691 ][Harry Chen] [65535][Cnem run failed][reason:vnic set failed]

猜想是连接过程中试图 bind 到某个端口失败了,但用相关的关键词在网上无法找到任何相关信息,也无从得知它究竟用什么协议 bind 到了什么端口。没有办法,只能掏出万能的 IDA 了。通过 bind port error 可以定位到相关的 printf 格式化字符串,但是没能找到相应的程序位置。换了个思路,从导入的 bind 符号找交叉引用,一下子就找到了相关的代码。用万能的 F5 反汇编一下,再根据语义重命名符号,得到了下面的伪代码:

count = 0;
while (true) {
    send_fd = call_socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
    if (send_fd == -1 ) {
        sub_43EBA0(14, 3, "[%lu][VNIC Init failed][reason:socket send fd error]", 65535);
        return 1;
    }
    call_setsockopt(send_fd);
    recv_fd = call_socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
    if (send_fd == -1) {
        sub_43EBA0(14, 3, "[%lu][VNIC Init failed][reason:socket receive fd error]", 65535);
        return 1;
    }
    v6 = 2;
    v7 = (bind_port << 8) | ((uint16_t)bind_port >> 8);
    v8 = inet_addr("127.0.0.1");
    call_setsockopt(recv_fd);
    if (++count > 4 || call_bind(recv_fd, (const struct sockaddr *)&v6, 16) == 0) break;
    sub_43EBA0(14, 2, "[%lu][VNIC Init][bind port error, try to set again]", 65535);
    ++bind_port;
    call_close(recv_fd);
    call_close(send_fd);
}

其中 bind_port 是一个全局变量,初始值是 49327call_xx 是对名为 xx 的 socket API 的直接包装,都是我重命名之后的符号。sub_43EBA0 显然是格式化打印,不再深究逻辑了。代码的用途看起来还是比较清晰的:尝试初始化一个 UDP socket,并从本机的 49327 开始尝试进行 bind,至多五次。这也恰好对应了日志里面的五个 bind port error

看到这里,不禁要在心里把华为的开发人员痛骂一通:连端口号都硬编码,还有什么事情做不出来?当然,我立刻猜测问题可能是因为有程序已经占用了这些端口,但是用 netstat -a -b -n 检查之后我就否定了这一可能性。自然地,这也引出了另一个问题:为什么 bind 没人使用的端口会失败呢?

问题探源

为了复现问题,我用 WinSock 写了个最小环境来模拟 SecoClient 的行为,代码如下:

#include <winsock2.h>
#include <ws2tcpip.h>
#include <stdio.h>

#pragma comment(lib, "Ws2_32.lib")

int main() {
    // initialize winsock
    WSADATA wsaData;
    int result = WSAStartup(MAKEWORD(2, 2), &wsaData);
    if (result != 0) {
        printf("WSAStartup failed: %d\n", result);
        return 1;
    }
    // initialize socket
    SOCKET udp_socket = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
    if (udp_socket == INVALID_SOCKET) {
        printf("Error at socket(): %ld\n", WSAGetLastError());
        WSACleanup();
        return 1;
    }
    
    int port;
    scanf("%d", &port);
    
    struct sockaddr_in sin;
    sin.sin_family = AF_INET;
    sin.sin_port = htons(port);
    sin.sin_addr.s_addr = inet_addr("127.0.0.1");
    // bind to address
    result = bind(udp_socket, (struct sockaddr *)&sin, 16);
    if (result == SOCKET_ERROR) {
        printf("bind failed with error: %d\n", WSAGetLastError());
        closesocket(udp_socket);
        WSACleanup();
        return 1;
    }
}

它的功能很简单,新建一个 UDP socket bind 到指定的端口。用 VS toolchain 编译运行,立刻得到错误码 10013,并且能够稳定复现。Windows Sockets Error Codes 中,对此有以下的解释:

Permission denied.
An attempt was made to access a socket in a way forbidden by its access permissions. An example is using a broadcast address for sendto without broadcast permission being set using setsockopt(SO_BROADCAST).
Another possible reason for the WSAEACCES error is that when the bind function is called (on Windows NT 4.0 with SP4 and later), another application, service, or kernel mode driver is bound to the same address with exclusive access. Such exclusive access is a new feature of Windows NT 4.0 with SP4 and later, and is implemented by using the SO_EXCLUSIVEADDRUSE option.

看来可能是由于有别人使用了排他的方法占用了这些端口。但正如刚才测试的,并没有人真的 bind 到这些端口上了。经过一番搜索,发现 Windows 10 存在一个特性,能够将一部分的端口范围保留给系统服务使用。使用 netsh interface ipv4 show excludedportrange protocol=udp 查询,得到:

netsh interface ipv4>show excl protocol = udp

协议 udp 端口排除范围

开始端口    结束端口
----------    --------
     49152       49251
     49252       49351
     ......

此时问题的直接原因已经很清晰了:SecoClient 需要的 4932749334 端口恰好落在了第一个范围中,于是自然就无法成功 bind 了。但我试图使用 netsh interface ipv4 delete excludedportrange protocol = udp startport=49152 number=100 来删除,却被告知拒绝访问。因此,需要进一步找到导致这一现象的底层原因。

又经过一些搜索,得知 Windows 10 的 Hyper-V 会自作主张地调整系统发起连接时可用的动态端口范围(可使用 netsh int ipv4 show dynamicport udp/tcp 查询),并把 50000 - 50059 这 60 个端口被标记成 administrative 排除(即用户态程序彻底无法使用),导致了 Docker 等软件无法正常工作。虽然这么做显然不厚道,但就算是 Hyper-V 修改后的动态端口号范围也是从 49152 开始的,并且我们遇到问题的这 100 个端口并非 Hyper-V 所为。继续搜索得知,即使禁用了 Hyper-V,这个问题可能还是会发生。好在,我找到了另一个解决方案(见 123),指出可能是 Windows 的 NAT 驱动引发的问题。果然,停止 winnat 服务之后,排除的端口列表一下子就全部消失了,只剩下了 Hyper-V 的那一项,VPN 连接也不再报错了。此时可以重新启动 winnat 服务,否则 Windows 的网络共享和无线热点等功能会无法正常工作。

结论

至此,问题的根本诱因也就水落石出了:软件上,华为工程师硬编码了 VPN 客户端可以尝试的端口,并且没有提供足够友好的出错提示;系统上,Windows 的 NAT 驱动没有及时地从排除端口列表中删除不再使用的端口。更不幸的是,Hyper-V 改变了原有的动态端口范围,使得 NAT 驱动会占用的端口(从 49152 起)恰好与 SecoClient 硬编码的端口范围(4932749334)有很大的重合概率,从而 VPN 客户端无法 bind 到任何一个硬编码的端口。这几个原因的叠加,最终导致了这一困惑我半年多的问题。