Windows平台基于API Hook技术的WinInet网络库HttpDNS实现方案
背景:学而思网校直播课堂在线安装程序,是一个独立的应用程序,提供学生端的安装功能,为了减少安装包体积,避免引入第三方网络库,使用的是操作系统的WinInet网络库。为了更好的优化网络,提高网络连接的成功率,避免Local DNS造成的域名劫持等问题,采用HttpDNS方式实现域名解析。
01
为什么使用HttpDNS
相比于传统的DNS,HttpDNS主要有以下优势:
1. 域名防劫持:
使用Http(Https)协议进行域名解析,域名解析请求直接发送至HttpDNS服务器,绕过运营商Local DNS,避免域名劫持问题。
2. 调度精准:
由于运营商策略的多样性,其 Local DNS 的解析结果可能不是最近、最优的节点,HttpDNS能直接获取客户端 IP ,基于客户端 IP 获得最精准的解析结果,让客户端就近接入业务节点。
3. 实时生效 :
配合端上策略(热点域名预解析、缓存DNS解析结果、解析结果懒更新)实现毫秒级低解析延迟的域名解析效果。
02
HttpDNS实现方案
使用HttpDNS的通常方法有两个方案:
方案一:发起网络请求之前把域名使用HttpDNS解析为IP地址,然后请求的时候把域名替换为IP进行请求,但是这种方案存在两个问题需要解决:
1. 虚拟主机问题
从http/1.1开始,header中支持Host字段,用来实现访问虚拟主机的目的。http请求header中必须配置适当的Host才能正确访问想要的服务,默认情况下Host字段是请求地址中的域名。如果直接把请求的域名替换成IP地址则无法正确访问对应服务,所以需要所使用的网络库支持自定义Host字段。而WinInet是Windows系统库,不支持修改Host字段。所以不能简单的把域名替换为解析后的IP发起请求。另外在https协议中,虚拟主机同时带来SNI问题,即在TLS握手阶段就需要指定适当的Host信息,以保证服务端可以返回正确的证书,否则会导致SSL握手失败。
2. Https证书验证问题
把域名直接替换为IP地址带来的另一个问题是SSL/TLS握手时候的证书验证问题。主要原因是服务端证书和客户端的peer name不一致导致的。一个简单的解决方案是忽略SSL证书验证失败这个问题,但是这样会导致https请求成了不安全的请求。
方案二:如果第三方网络库提供域名解析的回调,可以自定义域名解析也可以实现HttpDNS。本文采用的就是这个方案,利用Windows的API Hook机制,对域名解析GetAddrInfoEx接口进行Hook,以实现自定义DNS解析,失败 情况下走默认DNS解析。
常用网络库提供的解决方案:
Qt5Network库:比如在qt 5.15版本中,connectToHostEncrypted这个接口,他提供了peer name参数来实现SSL握手阶段需要验证的peer name以解决证书验证域名不匹配的问题;
libcurl库:用curl_easy_setopt CURLOPT_RESO LVE提供自定义主机名到IP地址的解析,即可以自定义域名解析。
本文的解决方案:
由于我们项目需要只能使用Windows系统的WinInet网络库,该库不支持修改Host头,也不提供域名解析的回调。但是Windows的域名解析一般使用的gethostbyname,GetAddrInfo,GetAddrInfoEx这些API来实现的,如果我们Hook这些API,来实现HttpDNS解析过程,如果失败了,再走默认的域名解析过程,这样就可以实现了HttpDNS功能了。
03
Windows Hook原理与实现
1. HOOK的分类
Hook分为应用层(Ring3)Hook和内核层(Ring0)Hook,应用层Hook适用于x86和x64,而内核层Hook一般仅在x86平台适用,因为从Windows Vista的64版本开始引入的Patch Guard技术极大地限制了Windows x64内核挂钩的使用。咱们的项目用的是注入Hook下面的Inline Hook,用微软detours库实现的Windows Hook。如图一所示:
图一
2. Inline Hook 的技术原理
内联Hook直接修改内存中的任意函数的代码,将其劫持至Hook API。它的适用范围更广,因为只要是内存中有的函数它都能Hook。
Inline Hook的目标是系统函数,如下,图二是Hook之前的状态,procexp.exe进程调用ZwQuerySystemInformation()函数时,ZwQuery SystemInformation()的代码是正常的代码。图三是Hook后的状态,ZwQuerySystemInformation()函数开头5个字节已被修改,变成了jmp 0x10001120,也就是我们的注入代码的地址,之后便可以开始我们的自定义操作。0x1000116A我们先进行unhook操作(脱钩),目的是将ZwQuerySystemInformation()的代码恢复。大家可能有疑惑,为什么刚修改完又要恢复回来,原因很简单,Hook的目的是当调用某个函数时,我们能劫持进程的执行流。现在我们已经劫持了进程的执行流,便可以恢复ZwQuerySystemInformation()的代码,以便我们的注入代码可以正常调用ZwQuerySystemInformation()。执行完注入代码后,再次挂钩,监控该函数。
图二
图三
3. Inline Hook 的代码实现
// Code Hook函数BOOL
hookByCode(LPCWSTR szDllName,LPCSTR szFuncName,PROC pfnNew,PBYTE pOrgBytes)
{
FARPROC pfnOrg { 0 };
DWORD dwOldProtect{ 0 };
DWORD dwAddress { 0 };
BYTE pBuf[5] { 0xE9,0, };
PBYTE pByte { nullptr };
// 获取ntdll.ZwQuerySystemInformation函数的地址
pfnOrg = (FARPROC)GetProcAddress(GetModuleHandle(szDllName), szFuncName);
pByte = (PBYTE)pfnOrg;
// 若已被钩取,则返回FALSE
if (pByte[0] == 0xE9)
{
return FALSE;
}
// 为了修改5个字节,先向内存添加“写”属性
VirtualProtect((LPVOID)pfnOrg, 5, PAGE_EXECUTE_READWRITE, &dwOldProtect);
// 备份原有代码(5字节)
memcpy(pOrgBytes, pfnOrg, 5);
// 计算JMP地址(相对偏移)
dwAddress = (DWORD)pfnNew - (DWORD)pfnOrg - 5;
memcpy(&pBuf[1], &dwAddress, 4);
// “钩子”:修改5个字节(JMP XXXXXXXX)
memcpy(pfnOrg, pBuf, 5); // 恢复内存属性
VirtualProtect((LPVOID)pfnOrg, 5, dwOldProtect, &dwOldProtect);
return TRUE;
}
首先获取API的地址,并保存在pfnOrg中,然后修改内存段属性为RWX,备份原有代码(已便后续代码恢复),实时计算JMP的相对偏移,最后修改API前5字节的代码,恢复内存属性。
4、使用detours库实现Hook
detours库是微软提供的被广泛使用的用于API Hook的库,它封装了Hook的实现细节,使用起来非常方便。例如:GetAddrInfoEx是我们需要hook的API,声明Old_GetAddrInfoEx保留Hook之前的函数指针,New_GetAddrInfoEx为Hook后的函数指针,应用程序在适当的时机调用StartHook/StopHook以Hook对应的API。
INT (WSAAPI* Old_GetAddrInfoEx)(
__in_opt PCWSTR pName,
__in_opt PCWSTR pServiceName,
__in DWORD dwNameSpace,
__in_opt LPGUID lpNspId,
__in_opt const ADDRINFOEX* hints,
__deref_out PADDRINFOEXW* ppResult,
__in_opt struct timeval* timeout,
__in_opt LPOVERLAPPED lpOverlapped,
__in_opt LPLOOKUPSERVICE_COMPLETION_ROUTINE lpCompletionRoutine,
__out_opt LPHANDLE lpHandle) = GetAddrInfoEx;
INT WSAAPI New_GetAddrInfoEx(
__in_opt PCWSTR pName,
__in_opt PCWSTR pServiceName,
__in DWORD dwNameSpace,
__in_opt LPGUID lpNspId,
__in_opt const ADDRINFOEX* hints,
__deref_out PADDRINFOEXW* ppResult,
__in_opt struct timeval* timeout,
__in_opt LPOVERLAPPED lpOverlapped,
__in_opt LPLOOKUPSERVICE_COMPLETION_ROUTINE lpCompletionRoutine,
__out_opt LPHANDLE lpHandle)
{
// 这里可以实现自己的dns解析逻辑
// ...
// 自定义解析失败后,调用默认解析以兜底
return Old_GetAddrInfoEx(pName,
pServiceName,
dwNameSpace,
lpNspId,
hints,
ppResult,
timeout,
lpOverlapped,
lpCompletionRoutine,
lpHandle);
}
bool StartHook()
{
DetourTransactionBegin();
DetourUpdateThread(GetCurrentThread());
DetourAttach(&(PVOID&)Old_GetAddrInfoEx, New_GetAddrInfoEx);
LONG ret = DetourTransactionCommit();
return ret == NO_ERROR;
}
bool StopHook()
{
DetourTransactionBegin();
DetourUpdateThread(GetCurrentThread());
DetourDetach(&(PVOID&)Old_GetAddrInfoEx, New_GetAddrInfoEx);
LONG ret = DetourTransactionCommit();
return ret == NO_ERROR;
}
04
Hook过程
WinInet网络请求的一般过程如下图四所示,在发送HttpSendRequest请求的时候会调用域名解析函数GetAddrInfoEx函数完成域名的解析。在域名解析的时候Hook GetAddrInfoEx函数。 Hook后的WinInet网络请求过程如下图五所示,在Hook 域名解析函数GetAddrInfoEx的时候成功以后,就不再调用原有的域名解析函数GetAddrInfoEx,而是调用自定义的域名解析函数。在调用自定义的域名解析函数失败的时候,有个兜底的策略,还调回原来的域名解析函数GetAddrInfoEx。下面是自定义的域名解析函数New_GetAddrInfoEx。
图四
图五
自定义域名解析函数如下所示:
// 从私有堆上分配ADDRINFOEX空间
static void my_addressinfo_alloc(
__in_opt PCWSTR pServiceName,
__in DWORD dwNameSpace,
__in_opt LPGUID lpNspId,
__in_opt const ADDRINFOEX* hints,
__deref_out PADDRINFOEXW* ppResult,
__in_opt struct timeval* timeout,
__in_opt LPOVERLAPPED lpOverlapped,
__in_opt LPLOOKUPSERVICE_COMPLETION_ROUTINE lpCompletionRoutine,
__out_opt LPHANDLE lpHandle)
{
ADDRINFOEX my_hints = *hints;
my_hints.ai_family = AF_INET;
my_hints.ai_flags ^= (AI_CANONNAME | AI_FQDN);
Old_GetAddrInfoEx(L"localhost",
pServiceName,
dwNameSpace,
lpNspId,
&my_hints,
ppResult,
timeout,
lpOverlapped,
lpCompletionRoutine,
lpHandle);
}
INT WSAAPI New_GetAddrInfoEx(
__in_opt PCWSTR pName,
__in_opt PCWSTR pServiceName,
__in DWORD dwNameSpace,
__in_opt LPGUID lpNspId,
__in_opt const ADDRINFOEX* hints,
__deref_out PADDRINFOEXW* ppResult,
__in_opt struct timeval* timeout,
__in_opt LPOVERLAPPED lpOverlapped,
__in_opt LPLOOKUPSERVICE_COMPLETION_ROUTINE lpCompletionRoutine,
__out_opt LPHANDLE lpHandle)
{
do {
struct in_addr addr;
// ip和localhost不需要httpdns
if (pName == nullptr
|| hints == nullptr
|| InetPtonW(AF_INET, pName, (void*)&addr)
|| wcscmp(pName, L"localhost") == 0)
{
break;
}
// 从缓存或者云服务商获取该域名对应的ip列表
HttpDNS::IpList ipList = HttpDNS::instance()->getHostByName(pName);
if (ipList.size() == 0)
{
break;
}
// 由于GetAddrInfoEx调用时候在私有堆上分配的内存,自己new的对象无法正常释放,会导致崩溃
// blog: http://www.youngroe.com/2018/12/01/Windows/windows_client_dns_over_https/
ADDRINFOEX* pTarget = nullptr;
for (auto& ip : ipList) {
// 私有堆上分配ADDRINFOEX空间
ADDRINFOEX* pTemp = nullptr;
my_addressinfo_alloc(pServiceName,
dwNameSpace,
lpNspId,
hints,
&pTemp,
timeout,
lpOverlapped,
lpCompletionRoutine,
lpHandle);
if (pTemp == nullptr) {
continue;
}
if (*ppResult == nullptr) {
*ppResult = pTemp;
pTarget = *ppResult;
}
else {
assert(pTarget);
pTarget->ai_next = pTemp;
pTarget = pTarget->ai_next;
}
std::string ipa = CStringUtil::wstring2string(ip);
struct sockaddr_in* mysock = (struct sockaddr_in*)pTemp->ai_addr;
mysock->sin_addr.S_un.S_addr = inet_addr(ipa.c_str());
}
if (*ppResult == nullptr)
{
break;
}
return NO_ERROR;
} while (false);
return Old_GetAddrInfoEx(pName,
pServiceName,
dwNameSpace,
lpNspId,
hints,
ppResult,
timeout,
lpOverlapped,
lpCompletionRoutine,
lpHandle);
}
在Hook GetAddrInfoEx函数的实现过程中遇到了一个小问题,GetAddrInfoEx返回结果中的addrinfoexW内存分配问题。正常情况下返回结果中的addrinfoexW由GetAddrInfoEx函数在其私有堆上分配,然后调用者使用完结果后使用FreeAddrInfoEx 释放,但是当我们自己实现的时候很难获取到私有堆的句柄,这样就没办法为addrinfoexW分配内存,如果使用new分配内存会在FreeAddrInfoEx 释放时错误产生问题。我实现的时候通过一个简单粗暴的方式是通过调用原始的GetAddrInfoEx解析localhost然后直接使用结果中的addrinfoexW,因为是GetAddrInfoEx分配,所以最后使用FreeAddrInfoEx 释放也没问题。
注意问题:
我们只实现了IPv4的HttpDNS
过滤掉localhost这样的解析
过滤掉非域名类解析
05
总结
优点:使用 API Hook技术的WinInet网络库HttpDNS 可以起到降级的作用, 省去了DNS解析的一个全流程;使用这种技术对业务层是全透明的,不需要对业务层代码进行任何修改。
缺点:Hook GetAddrInfoEx函数的时候增加了多余的localhost的解析,有一定的性能影响。
06
参考文献
HTTPS(含SNI)业务场景“IP直连”方案说明
https://help.aliyun.com/document_detail/30143.html
HTTPS IP直连问题小结
https://blog.csdn.net/leelit/article/details/77829196
Windows客户端如何透明使用DNS-over-HTTPS
http://www.youngroe.com/2018/12/01/Windows/windows_client_dns_over_https/
TLS SNI问题
https://www.jianshu.com/p/f608611dc694
微软官方API HOOK库
https://github.com/microsoft/Detours
Windows Hook原理与实现
https://blog.csdn.net/m0_37552052/article/details/81453591
扫描下方二维码添加加「好未来技术」微信官方账号
进入好未来技术官方交流群与作者实时互动~
(若扫码无效,可通过微信号TAL-111111直接添加)
- 也许你还想看 -
我知道你“在看”哟~