拓冰建站拓冰建站
首页 / 资讯中心 / 正文

Windows网络监控实战:IAT Hook拦截send函数与DLL注入详解

简介针对 Windows Socket API 中 ws2_32.dll 的发送函数进行拦截并将发出的数据写入 OB 文件。资料面向系统级程序员、网络安全审计与监控工具开发者适合掌握 Winsock 编程和钩子技术基础的学习者。压缩包共 23 个文件大小约 898KB包括完整 VC 工程源代码、头文件、编译生成的动态链接库与调试符号文件以及工程配置和使用说明文本。实现上采用 API 钩子或导入地址表替换方式在调用 send 前捕获数据缓冲区并复制到指定文件然后放行原始调用确保网络通信不被破坏。已有 1258 人学习下载可直接编译验证能帮助理解出站数据记录、钩子机制设计与底层拦截原理并为网络行为审计、调试分析或教学实验提供可运行的参考样本与排错思路。1. 项目概述与核心需求解析1.1 这个项目到底在解决什么问题先说结论这其实是一个典型的Windows 网络数据监视需求——你想知道一个程序在某个时间点到底往对端发送了什么数据。比起在应用层打日志、改业务代码直接在ws2_32.dll的send函数上做拦截属于一种“旁路监听”的思路不改目标程序源码、不依赖业务日志系统只要它走 Winsock 发数据就能在 socket 层把内容截获下来。标题里的ob 文件我理解是目标方自己定义的一种输出文件比如 observer 记录文件、output 二进制日志文件也可能是 .obj 目标文件的笔误。但从工程惯例看大多数人是把发送内容以二进制或十六进制格式追加到一个自定义日志文件里。下面我会基于“自定义观察文件”这个假设来展开如果你实际想输出成 .obj 格式思路完全一致改一下文件写入的封装就行。适合谁参考三类人一是做网络调试的客户端开发想确认自己的协议栈是不是按预期发数据二是做安全研究和教学的同学需要理解 Winsock 调用链和 IAT Hook 的原理三是游戏外挂、辅助工具作者想分析某个程序的上报数据——当然这里要提醒一句所有拦截调试行为请限制在自有程序或获得授权的目标上别拿去干违规的事。1.2 为什么选择“拦截 send”而不是其他方案要监控一个程序发送的数据其实有好几条路在应用层改代码加日志——问题是改完要重新编译、重新发版而且很多时候你根本没源码。用 Wireshark 抓包——这个能看到数据包但数据可能是加密的而且你得先定位到对应连接抓包还得有管理员权限用起来重。用 API Monitor 类的工具——能看调用但性能开销大不适合长时间挂载也不方便集成到自己的工具链里。相比之下自己实现一个 DLL 钩子去拦截send灵活度最高。你可以精确控制要记录的内容、格式、保存路径也可以只对特定 socket 做过滤而且注入方式选好了之后对目标进程的侵入性相对可控。在 Windows 平台ws2_32.dll是 Winsock2 的标准实现几乎所有 socket 程序最终都会调用它导出的send、sendto等函数所以在这个位置拦截覆盖面最广。这里涉及两个核心前置知识函数导出的查找过程和IAT Hook 的基本原理。我下面拆开讲。2. 核心原理从 send 调用到 IAT Hook2.1 一个 send 调用在进程内部是怎么流转的在 Windows 下应用程序调用send时编译器的链接器会在生成的可执行文件里留下一个导入表Import Address TableIAT条目。这个条目说白了就是一个函数指针槽位程序运行时系统会从这个槽位获取真正的函数地址。具体流程大致是程序里写了send(sock, buf, len, 0)。编译器把这条调用编译成“call [IAT中的send槽位]”。程序启动时Windows 加载器读取 PE 文件里的导入表找到ws2_32.dll!send的真实地址填入 IAT 槽位。程序调用时实际上是跳转到这个真实地址。所以只要你能在程序运行时把 IAT 里那个槽位的值改成自己的函数地址那么程序再调用send时就会先进你的函数。这就是 IAT Hook 的原理。IAT Hook 的核心优势是实现简单、稳定不需要修改函数开头的机器码即不做 inline hook不会因为指令长度截断、跳转目标计算错误导致进程崩溃。缺点也明显它只能拦截那些通过导入表调用的程序。如果一个程序用GetProcAddress动态获取send地址再调用IAT 里根本没有对应条目你就拦不到。2.2 为什么用 DLL 注入来做拦截拦截代码要生效必须先跑在目标进程的地址空间里。Windows 提供了几种方式全局钩子SetWindowsHookEx可以注入 DLL但依赖 GUI 消息机制对 socket 程序不一定有利。远程线程注入CreateRemoteThread在目标进程里创建一个线程让线程执行 LoadLibrary 加载你的 DLLDLL 的 DllMain 里做 IAT Hook。这个方案适用范围广服务端/客户端程序都行。AppInit_DLLs 注册表方式让系统在加载 user32.dll 时自动加载你的 DLL不需要额外注入代码但影响所有 GUI 程序容易误伤而且很多安全软件会拦。SetWindowsHookEx 挂 WH_GETMESSAGE也是一种注入手段但同样依赖消息循环。推荐用的是CreateRemoteThread LoadLibrary代码量不大、可控性好。如果你的拦截工具要长时间运行可以考虑写成服务 注入器分离的架构如果只是临时调试写一个控制台注入器就够了。成功注入后你的 DLL 会在 DllMain 中执行 Hook 逻辑这个时机很关键——下面说实操的时候我再细讲。3. 实际操作完整实现 send 拦截并写入 ob 文件3.1 项目准备与工程结构设计我用的开发环境是 Visual Studio 2019或更新版本语言用 C/C。整个工程建议拆成两块HookDll实现拦截逻辑和文件输出编译成 DLL。Injector负责将 HookDll 注入目标进程编译成 EXE。这样拆分的好处是注入器可以随时换注入方式HookDll 的改动也不影响注入器。如果你用 C# 做注入器也可以但 HookDll 本身建议用 C/C因为底层指针操作和 PE 结构解析在 C/C 里写起来最顺手。这里补充说明一下标题里的ob 文件处理方式。我建议在 DLL 里初始化时创建一个文件句柄每次拦截到 send 数据就以“时间戳 socket 数据长度 原始数据块”这样的记录格式追加写入。文件后缀叫 .ob 没问题本质上就是你自己的二进制日志。如果你要兼容 .obj 目标文件格式可以按 COFF 规范来写节区结构但一般情况没有这个必要。3.2 核心代码IAT Hook 的完整实现先放完整的 HookDll 核心代码再逐段解释。// HookDll.cpp #include windows.h #include ws2tcpip.h #include stdio.h #include stdint.h #pragma comment(lib, ws2_32.lib) // 定义 send 函数的函数指针类型 typedef int (WSAAPI* SendFunc)(SOCKET s, const char* buf, int len, int flags); // 保存原始 send 函数地址 static SendFunc g_OriginalSend NULL; // 输出文件句柄 static FILE* g_LogFile NULL; static CRITICAL_SECTION g_LogLock; // ------------- 文件输出 ------------ void OpenLogFile() { char logPath[MAX_PATH] { 0 }; GetModuleFileNameA(NULL, logPath, MAX_PATH); // 在 exe 同目录下生成观察文件 char* slash strrchr(logPath, \\); if (slash) *(slash 1) 0; strcat_s(logPath, send_capture.ob); g_LogFile fopen(logPath, ab); InitializeCriticalSection(g_LogLock); } void CloseLogFile() { if (g_LogFile) { fclose(g_LogFile); g_LogFile NULL; } DeleteCriticalSection(g_LogLock); } void WriteLogData(SOCKET s, const char* buf, int len) { if (!g_LogFile) return; EnterCriticalSection(g_LogLock); // 记录头时间戳 socket 长度 DWORD timestamp GetTickCount(); fwrite(timestamp, sizeof(timestamp), 1, g_LogFile); fwrite(s, sizeof(s), 1, g_LogFile); fwrite(len, sizeof(len), 1, g_LogFile); // 记录体原始数据 fwrite(buf, 1, len, g_LogFile); fflush(g_LogFile); LeaveCriticalSection(g_LogLock); } // ------------- 拦截后的替换函数 ------------ int WSAAPI HookSend(SOCKET s, const char* buf, int len, int flags) { // 记录数据 WriteLogData(s, buf, len); // 调用原始 send if (g_OriginalSend) { return g_OriginalSend(s, buf, len, flags); } // 没有原始函数时直接返回错误 WSASetLastError(WSAEINVAL); return SOCKET_ERROR; } // ------------- 查找 ws2_32.dll 中 send 的地址 ------------ FARPROC GetSendAddress() { HMODULE hWs2_32 GetModuleHandleA(ws2_32.dll); if (!hWs2_32) { hWs2_32 LoadLibraryA(ws2_32.dll); } if (!hWs2_32) return NULL; return GetProcAddress(hWs2_32, send); } // ------------- IAT Hook 的核心逻辑 ------------ void HookIAT() { // 1. 拿到当前模块通常是 exe的导入表 DWORD_PTR dwBase (DWORD_PTR)GetModuleHandleA(NULL); if (!dwBase) return; // 2. 解析 PE 结构定位导入表 PIMAGE_DOS_HEADER pDos (PIMAGE_DOS_HEADER)dwBase; if (pDos-e_magic ! IMAGE_DOS_SIGNATURE) return; PIMAGE_NT_HEADERS pNt (PIMAGE_NT_HEADERS)(dwBase pDos-e_lfanew); if (pNt-Signature ! IMAGE_NT_SIGNATURE) return; DWORD importRVA pNt-OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT].VirtualAddress; if (!importRVA) return; // 3. 遍历导入描述符 PIMAGE_IMPORT_DESCRIPTOR pImport (PIMAGE_IMPORT_DESCRIPTOR)(dwBase importRVA); while (pImport-Name) { const char* dllName (const char*)(dwBase pImport-Name); if (_stricmp(dllName, ws2_32.dll) 0) { // 找到 ws2_32.dll 的导入表 PIMAGE_THUNK_DATA pThunk (PIMAGE_THUNK_DATA)(dwBase pImport-FirstThunk); PIMAGE_THUNK_DATA pOrigThunk (PIMAGE_THUNK_DATA)(dwBase pImport-OriginalFirstThunk); for (; pThunk-u1.AddressOfData; pThunk, pOrigThunk) { // 按序号导入的情况 if (pOrigThunk-u1.Ordinal IMAGE_ORDINAL_FLAG) { continue; } // 按名称导入 PIMAGE_IMPORT_BY_NAME pByName (PIMAGE_IMPORT_BY_NAME)(dwBase pOrigThunk-u1.AddressOfData); if (strcmp(pByName-Name, send) 0) { // 保存原始函数地址 g_OriginalSend (SendFunc)GetSendAddress(); if (!g_OriginalSend) return; // 修改 IAT 槽位注意内存页面保护 DWORD oldProtect 0; VirtualProtect(pThunk-u1.Function, sizeof(FARPROC), PAGE_READWRITE, oldProtect); pThunk-u1.Function (ULONGLONG)HookSend; VirtualProtect(pThunk-u1.Function, sizeof(FARPROC), oldProtect, oldProtect); return; } } } pImport; } }3.3 代码逐段解读与关键细节这段代码的核心在HookIAT的三个阶段拿模块基址、遍历导入表、定位目标函数槽位并改写。第一步通过GetModuleHandleA(NULL)拿到的是主模块的基址。之所以不写死 0x00400000是因为 ASLR 开启后每次启动的基址都可能变。然后通过 DOS 头的e_lfanew跳到 NT 头这是 PE 结构解析的固定套路。第二步遍历导入表时你可能会发现导入描述符里的Name字段是RVA相对虚拟地址需要加上模块基址才是真实内存地址。我代码里用dwBase pImport-Name来转换。同理FirstThunk和OriginalFirstThunk也是 RVA。第三步找send这个函数名时我只处理了按名称导入的情况。坑点在于有很多程序是通过序号导入ws2_32.dll函数的那样的话OriginalFirstThunk指向的高位标记了序号函数名根本不在导入表里。这种情况下 IAT Hook 不好做只能退回到 inline hook。还有一个很多新手会忽略的问题修改 IAT 槽位之前必须用 VirtualProtect 把槽位所在页面的保护属性从只读改成可写改完之后还要恢复原保护属性。Windows 加载器加载完 PE 后通常会把导入表所在的节设为只读直接写入会导致访问违例。3.4 DllMain 中初始化与退出清理BOOL APIENTRY DllMain(HMODULE hModule, DWORD ul_reason_for_call, LPVOID lpReserved) { switch (ul_reason_for_call) { case DLL_PROCESS_ATTACH: // 尽量不要在 DllMain 里做太多工作但快速 Hook 是可以的 DisableThreadLibraryCalls(hModule); OpenLogFile(); HookIAT(); break; case DLL_PROCESS_DETACH: HookUnIAT(); // 恢复 IAT CloseLogFile(); break; case DLL_THREAD_ATTACH: case DLL_THREAD_DETACH: break; } return TRUE; }DllMain 里有几个常见的坑逐一说明。第一个坑是不能在 DllMain 里调用 LoadLibrary。如果目标进程的加载器锁还在持有你在这里 LoadLibrary 另一个 DLL容易造成死锁。我在GetSendAddress里之所以要检查GetModuleHandleA先用就是为了避免不必要的 LoadLibrary。正常情况下目标程序既然调用了sendws2_32.dll早就被加载了直接 GetModuleHandle 就能拿到。第二个坑是CRT 函数的使用。如果你在 DllMain 里调用fopen、printf这类函数而 CRT 还没完成初始化或者是静态链接的 CRT就可能出问题。稳妥的做法是把OpenLogFile推迟到第一次拦截到 send 时再执行或者在DLL_PROCESS_ATTACH里只记录状态真正打开文件放到HookSend第一次执行时。第三个坑是HookUnIAT的恢复逻辑实际上就是把之前保存的g_OriginalSend写回 IAT 槽位。因为槽位的地址在HookIAT里可以记录下来恢复时直接用。卸载 DLL 时如果不恢复目标程序后续调用 send 会跳到已卸载的地址空间必然崩溃。3.5 注入器创建远程线程加载 DLL这边用一个简洁的控制台程序实现注入// Injector.cpp #include windows.h #include tlhelp32.h #include stdio.h DWORD GetProcessIdByName(const char* name) { HANDLE snap CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); if (snap INVALID_HANDLE_VALUE) return 0; PROCESSENTRY32 pe { sizeof(pe) }; DWORD pid 0; if (Process32First(snap, pe)) { do { if (_stricmp(pe.szExeFile, name) 0) { pid pe.th32ProcessID; break; } } while (Process32Next(snap, pe)); } CloseHandle(snap); return pid; } void InjectDll(DWORD pid, const char* dllPath) { HANDLE hProcess OpenProcess(PROCESS_ALL_ACCESS, FALSE, pid); if (!hProcess) { printf(OpenProcess failed: %u\n, GetLastError()); return; } // 在目标进程内分配空间写入 DLL 路径 size_t len strlen(dllPath) 1; void* remoteBuf VirtualAllocEx(hProcess, NULL, len, MEM_COMMIT, PAGE_READWRITE); if (!remoteBuf) { printf(VirtualAllocEx failed: %u\n, GetLastError()); CloseHandle(hProcess); return; } WriteProcessMemory(hProcess, remoteBuf, dllPath, len, NULL); // 创建远程线程加载 DLL HMODULE hKernel32 GetModuleHandleA(kernel32.dll); FARPROC pLoadLibrary GetProcAddress(hKernel32, LoadLibraryA); HANDLE hThread CreateRemoteThread(hProcess, NULL, 0, (LPTHREAD_START_ROUTINE)pLoadLibrary, remoteBuf, 0, NULL); if (hThread) { WaitForSingleObject(hThread, INFINITE); CloseHandle(hThread); } VirtualFreeEx(hProcess, remoteBuf, 0, MEM_RELEASE); CloseHandle(hProcess); } int main(int argc, char* argv[]) { if (argc 3) { printf(Usage: Injector process_name dll_path\n); return 0; } DWORD pid GetProcessIdByName(argv[1]); if (!pid) { printf(Process not found: %s\n, argv[1]); return 0; } printf(Injecting into pid %u...\n, pid); InjectDll(pid, argv[2]); printf(Done.\n); return 0; }注入器的实现思路不复杂OpenProcess 拿权限 → VirtualAllocEx 在目标进程里分配内存 → WriteProcessMemory 写入 DLL 绝对路径 → CreateRemoteThread 以 LoadLibraryA 为入口执行加载。路径建议写绝对路径因为远程线程的工作目录通常和注入器不同。4. 数据记录到 ob 文件格式设计与实践优化4.1 为什么用二进制格式而不是文本把发送内容写进 .ob 文件时我推荐用二进制格式而不是直接把字符串写进去。原因有三个第一send 发送的数据不一定是以\0结尾的字符串可能包含很多不可见字符。如果按文本文件打开你会看到一堆乱码而且没法区分边界。第二二进制格式可以用“记录头 记录体”的结构来组织后续写一个解析器就能精确还原每次发送的内容。文本格式很难做到这一点尤其是你想统计每个 socket 的发送量时。第三.ob 这个后缀本身就暗示了这是目标文件/观察文件更接近二进制处理思维。你完全可以在里面存储十六进制文本但那样文件体积会膨胀一倍而且 fl 性能不如直接写二进制。我设计的记录格式是每条记录由 12 字节头 数据块组成。头部包含 4 字节时间戳用 GetTickCount 取毫秒4 字节 SOCKET 句柄值4 字节数据长度数据块就是原始 buf 内容。如果你是 64 位程序SOCKET 实际是 8 字节这里要注意对齐建议改成 8 字节宽度的类型或者干脆把头部分别写成独立的 fwrite让编译器自己处理。4.2 文件写入性能与多线程安全问题Winsock 程序一般会有多个线程同时发送数据所以WriteLogData里的g_LogLock临界区是必须的。否则两个线程同时 fwrite会导致记录交错、文件损坏。性能上每次 send 都fflush一次确实会影响目标程序的发送效率。如果用来做实时调试可以接受如果要长时间挂载建议改成定时 flush 或者累计一定字节数再 flush。也可以用一个独立的日志线程把要写入的数据放进队列由日志线程统一写文件这样对 send 调用的影响最小。我在实际项目里踩过一个坑一旦给 send 加上日志原本不会卡顿的地方开始随机延迟尤其是高频小包发送场景。后来就是把 flush 从每个包都刷改成了 500ms 刷一次问题才消失。4.3 解析 .ob 文件的配套脚本写一个配套的解析器能让你的工具链完整。以 Python 为例解析逻辑很简单import struct import sys from datetime import datetime def parse_ob(path): with open(path, rb) as f: while True: header f.read(12) if len(header) 12: break timestamp, sock, length struct.unpack(III, header) data f.read(length) if len(data) length: break print(f[{datetime.fromtimestamp(timestamp/1000):%H:%M:%S.%f}] fsocket{sock} len{length} data{data.hex( )}) print(f ascii: {data!r}) if __name__ __main__: parse_ob(sys.argv[1])输出样式可以按需调整你既想看十六进制也看想看可读 ASCII这个脚本都能满足。需要注意的是GetTickCount 的 0 点是从系统启动开始的不是 Unix 纪元所以时间戳转换成可读时间时得加上 boot time。如果你只关心时间差那直接用原始值就行。5. 常见问题与排查技巧实录5.1 拦截不到 sendIAT 表里没有目标这是最常见的失败模式。你注入了 DLL程序也正常跑但 .ob 文件里啥都没有。排查路径如下用 Process Explorer 或 x64dbg 查看模块导入表确认目标 exe 确实导入了ws2_32.dll的send函数。如果程序是通过 WSASend、或者自己封装了动态获取那 IAT Hook 就拦不到 send。程序可能是延迟加载delay load的 ws2_32.dll导入表里可能没有 send而是有一个延迟加载描述结构这个结构在首次调用时才populate。这种情况得做延迟加载钩子或者直接上 inline hook。你有可能是注入到了错误的进程。比如程序有多个进程实例你注入的是父进程而网络操作在子进程里。5.2 程序崩溃内存访问违例或函数指针错误崩溃一般集中在两个点遍历导入表时越界访问。这通常是因为你解析 PE 结构时基址搞错了。检查一下pDos-e_lfanew是否合理pNT-Signature是否正确。调用原始 send 时崩溃。如果你g_OriginalSend拿到的地址不对或者目标进程里 ws2_32.dll 根本没有被加载调用必然崩溃。确保在调用原函数前校验g_OriginalSend ! NULL。还有一类崩溃是栈不平衡。Winsock 的send是WSAPI调用约定也就是__stdcall。如果你的函数指针声明成了默认的__cdecl调用原函数时栈清理就会错位。我在代码里专门用了WSAAPI宏就是这个原因。新手最容易在这个地方翻车。5.3 递归调用无限循环想象这个场景你在HookSend里调用send来记录日志或者做其他网络操作结果那个send又被你的钩子拦住于是无限递归最终栈溢出。解决思路有三个层次最直接日志文件用本地文件写入不要走网络也就不存在调 send。如果一定要在网络行为里触发其他 send可以在HookSend里加一个线程局部变量做重入标志标志为真时不走钩子直接调原始函数。也可以把 IAT 槽位在进入HookSend时临时恢复原函数地址退出时再改回钩子。这个方案最彻底但代码复杂度高一些不推荐新手用。5.4 64 位系统下的兼容问题在 64 位 Windows 下SOCKET 句柄是 64 位的函数指针也是 64 位的。上面代码里pThunk-u1.Function用的ULONGLONG是没问题的但如果你把函数地址存到DWORD里会被截断成 32 位调用时必崩。另外DLL 的位数必须和目标进程一致。你写的是 32 位 DLL就不能注入 64 位进程反之亦然。如果目标程序是 32 位的但跑在 64 位系统上你需要用 32 位注入器去注入 32 位 DLL。5.5 管理员权限与注入失败CreateRemoteThread 注入需要PROCESS_ALL_ACCESS权限。如果目标进程以管理员身份运行你的注入器也得管理员权限。如果目标进程是受保护的进程比如某些反作弊、安全软件注册的受保护进程注入会直接失败这是系统安全机制不是代码问题。解决思路注入器用管理员权限运行目标程序如果是自开发可以调试态绕过保护或者干脆直接静态改造源码。6. 扩展从 send 到 recv、从 IAT 到 Inline Hook项目做到这一步主体功能已经通了。如果你想做得更完善这几个方向可以考虑。扩展一拦截 recv。send 是“发出去的数据”recv 是“收进来的数据”。做法几乎一样也是在导入表里找recv名字替换函数。如果同时拦截 send 和 recv你就能完整看到进程的网络收发内容。需要注意recv的 buffer 是目标程序分配好的你只能在调用后记录实际收到的长度不能直接读未写入的内存。扩展二增加过滤条件。实际调试中你可能只想看某个 socket 的数据或者只想看特定端口。可以在WriteLogData里加判断比如记录之前先getpeername拿对端端口过滤掉不感兴趣的连接。这样 .ob 文件不会膨胀得太快。扩展三IAT Hook 升级为 Inline Hook。如果目标程序用了 GetProcAddress 动态获取 send 地址IAT Hook 就失效了。Inline Hook 的做法是直接修改 send 函数开头的几条指令改成跳转到你的函数。这个优点是覆盖面更广缺点是需要处理指令长度、线程同步、指令缓存刷新等问题风险高不少。真要实现的话可以用 Microsoft Detours 库它对 x86/x64 都有现成的支持比自己用汇编写跳板稳得多。我个人建议如果不是特别必要先用 IAT Hook 就足够了。它简单、稳定、好排查。等确定 IAT 搞不定再上 Detours 也不迟。再分享一个我在实际项目中踩过的坑在做 IAT Hook 时如果你用 Debug 模式编译 DLL而目标程序是 Release 模式的可能会因为 CRT 初始化差异导致在 DllMain 里能用但正式运行时行为异常。后来我所有用于注入的 DLL 都用 Release /MT静态链接运行时编译尽量避免运行时库的交叉问题。还有个小技巧如果你希望 .ob 文件能自动按日期滚动可以在OpenLogFile里用GetLocalTime拼接文件名比如send_capture_20250216.ob。这样长时间挂载时单文件不会变得过大排查问题时也更容易按时间定位。最后我再啰嗦一句安全合规的问题拦截其他程序网络数据这件事一定要控制在自己有权限的范围内。自己写的程序、公司内部的调试环境、获得了明确授权的测试样本——这些场景随便折腾都没事未经授权去监控别人的程序不管是出于好奇还是其他目的都会惹上麻烦。技术本身是中性的重要的是用它做什么。本文还有配套的精品资源点击获取
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门