基于 ipurple.team 的研究整理,结合 LdrShuffle / EPI / iPurple 三个 PoC 进行深度解析
一、技术背景
1.1 传统代码注入的困境
传统的进程注入技术(如 CreateRemoteThread + WriteProcessMemory)在 EDR 普及的今天已经很难不被发现:
1 2 3 4
| 传统注入流程: 调用 CreateRemoteThread / NtCreateThreadEx → EDR API 钩子拦截 在远程进程创建新线程 → 线程创建遥测被捕获 新线程指向 MEM_PRIVATE 区域的 shellcode → 内存扫描检测
|
攻击者需要一种不创建新线程、不调用可疑 API 的注入方式。
1.2 EntryPoint Hijacking 的思路
核心思想:利用 Windows Loader 自身的行为来执行恶意代码,而不是主动调用任何 API。
Windows 加载器在 DLL 加载完成后会自动调用 DllMain(),攻击者通过篡改 DLL 的 EntryPoint 字段,让加载器”自愿”将执行流导向攻击者控制的代码。
二、Windows 内存管理基础:PEB → LDR 链表
2.1 PEB(Process Environment Block)
每个 Windows 进程都有一个 PEB 结构,存储进程的所有运行时信息。PEB 中有一个 pLdr 指针,指向 PEB_LDR_DATA,维护着所有已加载 DLL 的信息。
1 2 3 4 5 6
| PEB 结构访问方式(x64): 通过 TEB(Thread Environment Block)的 self 字段 TEB 位于 GS 段寄存器偏移 0x30 处 TEB → self 字段(偏移 0x60)→ PEB 地址
获取 PEB 的代码:
|
1 2 3 4 5
| PPEB pPeb = (PPEB)(__readgsqword(0x60));
PPEB pPeb = (PPEB)(__readfsdword(0x30));
|
为什么用 __readgsqword(0x60)?
在 x64 Windows 中,GS 段寄存器指向 TEB(Thread Environment Block)。TEB 偏移 0x60 处存放的是指向 PEB 的指针。这是操作系统在创建线程时自动设置的,任何用户态代码都可以访问。
2.2 PEB_LDR_DATA 结构
1
| PEB → pLdr(偏移 0x18)→ PEB_LDR_DATA
|
PEB_LDR_DATA 内有三条双向链表,按不同顺序组织 DLL:
| 链表字段 |
排序方式 |
用途 |
InLoadOrderModuleList |
按加载顺序 |
调试用 |
InMemoryOrderModuleList |
按内存地址 |
本技术使用的链表 |
InInitializationOrderModuleList |
按初始化顺序 |
加载器用 |
2.3 LDR_DATA_TABLE_ENTRY2 结构详解
链表中每个节点都是 LDR_DATA_TABLE_ENTRY2 结构,包含一个 DLL 的全部关键信息:
1 2 3 4 5 6 7 8 9 10 11 12
| typedef struct _LDR_DATA_TABLE_ENTRY2 { LIST_ENTRY InLoadOrderLinks; LIST_ENTRY InMemoryOrderLinks; LIST_ENTRY InInitializationOrderLinks; PVOID DllBase; PVOID EntryPoint; ULONG SizeOfImage; UNICODE_STRING FullDllName; UNICODE_STRING BaseDllName; PVOID OriginalBase; } LDR_DATA_TABLE_ENTRY2, *PLDR_DATA_TABLE_ENTRY2;
|
关键字段解释:
| 字段 |
正常值(以 kernelbase.dll 为例) |
被劫持后的值 |
DllBase |
0x00007FFB10000000(DLL 加载地址) |
不变 |
EntryPoint |
0x00007FFB10001234(DllMain 地址) |
被覆写为攻击者 shellcode 地址 |
OriginalBase |
与 DllBase 相同 |
被覆写为备份数据 |
三、DLL 加载与 DllMain 调用机制
3.1 DllMain 的触发时机
Windows 加载器(ntdll!Ldrp* 系列函数)在以下事件时自动调用 DllMain():
1 2 3 4
| DLL_PROCESS_ATTACH — DLL 被加载到进程时(进程启动) DLL_THREAD_ATTACH — 新线程在进程中创建时(线程创建) DLL_THREAD_DETACH — 线程退出时 DLL_PROCESS_DETACH — DLL 从进程中卸载时
|
攻击利用的正是 DLL_THREAD_ATTACH 时机:当进程创建新线程时,加载器遍历所有已加载的 DLL,调用它们的 DllMain。
3.2 加载器的执行流程
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| 进程创建新线程 ↓ ntdll!LdrpRunThreadRoutines ↓ 遍历 InMemoryOrderModuleList 链表 ↓ 对每个 DLL:读取 LDR_DATA_TABLE_ENTRY2.EntryPoint ↓ 判断:DontCallForThreads == 0 ? 如果为 1,则跳过此 DLL 的 DllMain 调用 ↓ 调用 EntryPoint 指向的函数(即 DllMain) ↓ 正常情况下:DllMain() 被调用 劫持情况下:攻击者的 shellcode 被执行
|
3.3 DontCallForThreads 标志位
LDR_DATA_TABLE_ENTRY2 中有一个 DontCallForThreads 字段:
1 2
| DontCallForThreads == 0 → 线程创建时会调用该 DLL 的 DllMain(可被利用) DontCallForThreads == 1 → 线程创建时跳过该 DLL(无法利用)
|
四、完整攻击流程(以 LdrShuffle PoC 为例)
4.1 攻击前置条件
攻击者需要已经获得目标进程内的代码执行权限(通过任何先期攻击手段),这一步 EntryPoint Hijacking 不涉及。
4.2 攻击步骤详解
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| 步骤1:获取 PEB 地址 ↓ 步骤2:从 PEB 出发,遍历 InMemoryOrderModuleList 找到目标 DLL(kernelbase.dll) ↓ 步骤3:备份原始的 EntryPoint 和 OriginalBase ↓ 步骤4:构造 DATA_T 结构体,写入堆内存 ↓ 步骤5:将 DATA_T 的地址写入 OriginalBase 字段 ↓ 步骤6:将 EntryPoint 覆写为 DATA_T.runner(攻击者 loader 地址) ↓ 步骤7:等待... 进程自然创建新线程 ↓ 步骤8:加载器触发 DllMain → 读取 EntryPoint → 跳转到 shellcode ↓ 步骤9:立刻恢复 EntryPoint 和 OriginalBase
|
4.3 DATA_T 结构体详解
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| typedef struct _DATA_T { ULONG_PTR runner; ULONG_PTR bakOriginalBase; ULONG_PTR bakEntryPoint; HANDLE event;
ULONG_PTR ret; DWORD createThread; ULONG_PTR function; DWORD dwArgs; ULONG_PTR args[MAX_ARGS]; } DATA_T, *PDATA_T;
|
结构体的内存布局(x64):
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| 偏移 字段 大小 作用 0x00 runner 8字节 Shellcode/loader 的地址( EntryPoint 被重定向到这里) 0x08 bakOriginalBase 8字节 保存被覆写前的 OriginalBase 0x10 bakEntryPoint 8字节 保存被覆写前的 EntryPoint 0x18 event 8字节 事件句柄,Runner 执行完后触发 0x20 ret 8字节 API 返回值存放 0x28 createThread 4字节 1=需要在新线程中调用(处理 wininet/winhttp) 0x2C (padding) 4字节 对齐填充 0x30 function 8字节 要调用的 API 地址 0x38 dwArgs 4字节 参数个数 0x3C (padding) 4字节 对齐填充 0x40 args[0] 8字节 第一个参数 0x48 args[1] 8字节 第二个参数 ... args[N] 8字节 第 N 个参数
|
为什么用堆内存? 堆内存不像栈那样有固定模式,EDR 很难区分合法的堆分配和攻击者的堆分配。
五、代码逐行解析
5.1 RestoreLdr() — 恢复被篡改的 LDR 结构
1
| BOOL RestoreLdr(IN ULONG_PTR dllBase) {
|
函数接收 DLL 基地址(DllBase),恢复该 DLL 的 EntryPoint 和 OriginalBase
1 2 3 4 5
| #ifdef _WIN64 PPEB pPeb = (PPEB)(__readgsqword(0x60)); #elif _WIN32 PPEB pPeb = (PPEB)(__readgsqword(0x30)); #endif
|
1 2
| PDATA_T pDataT = NULL; PEB_LDR_DATA* pPebLdr = (PEB_LDR_DATA*)pPeb->pLdr;
|
1 2 3 4 5 6
|
PLDR_DATA_TABLE_ENTRY2 pDte = (PLDR_DATA_TABLE_ENTRY2)( (ULONG_PTR)pPebLdr->InMemoryOrderModuleList.Flink - 0x10 );
|
为什么要减去 0x10?
InMemoryOrderModuleList 是一个 LIST_ENTRY(16 字节),位于 LDR_DATA_TABLE_ENTRY2 结构体偏移 0x10 处。链表的 Flink 指针指向的是节点中的 LIST_ENTRY 成员,要得到整个 LDR_DATA_TABLE_ENTRY2 的起始地址,需要往回退 0x10 字节。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| while (pDte) { if (pDte->BaseDllName.Length != NULL) { if (pDte->DllBase == (PVOID)dllBase) { pDataT = (PDATA_T)pDte->OriginalBase;
pDte->EntryPoint = (PLDR_INIT_ROUTINE)pDataT->bakEntryPoint; pDte->OriginalBase = pDataT->bakOriginalBase;
return TRUE; } } else { break; }
pDte = *(PLDR_DATA_TABLE_ENTRY2*)(pDte); } return FALSE; }
|
5.2 DLL 筛选逻辑
1 2
| if (pDte->EntryPoint != NULL && pDte->DontCallForThreads == 0 && i > 5)
|
| 条件 |
原因 |
EntryPoint != NULL |
该 DLL 有 DllMain,可以被劫持 |
DontCallForThreads == 0 |
该 DLL 在线程创建时会触发 DllMain |
i > 5 |
跳过前 5 个 DLL,避免劫持 ntdll/ntoskrnl 等核心 DLL 导致崩溃 |
为什么跳过前 5 个 DLL?
1 2 3 4 5 6 7
| 进程加载顺序(典型): 0: ntdll.dll ← 核心,改了必崩 1: kernel32.dll ← 核心 2: kernelbase.dll ← 常见攻击目标,但需要等进程稳定后再改 3: ucrtbase.dll ← C 运行时 4: ...其他系统 DLL 5+: 可以安全操作的 DLL
|
5.3 死锁问题与 createThread 字段
为什么需要这个字段?
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| 问题场景: DllMain 被调用时,Loader 持有 Loader Lock(加载器锁) 在 Loader Lock 下调用某些 API 会死锁:
DllMain → InternetOpenW → 内部创建线程 → 新线程需要 Loader Lock → 死锁
会死锁的 API: InternetOpenW / InternetConnectW (WinINet) HttpOpenRequestW / HttpSendRequestW (WinINet) WinHttpOpen / WinHttpConnect (WinHTTP)
不会死锁的 API: GetComputerNameW / GetUserNameW (无创建线程) VirtualAlloc(PAGE_READWRITE) (无创建线程)
|
解决方法:
1 2 3 4
| DllMain 中(有 Loader Lock): 只执行简单操作:设置 event,读取 DATA_T 中的 function 地址 如果 createThread == 1:使用 QueueUserWorkItem 在线程池中执行 如果 createThread == 0:直接调用
|
六、三个 PoC 的差异对比
| 特性 |
EPI (Kudaes, 2023) |
LdrShuffle (RWXstoned, 2025) |
iPurple |
| 目标 DLL |
kernelbase.dll |
任意符合条件的 DLL |
kernelbase.dll |
| 同进程注入 |
✗ |
✗ |
✓ |
| 远程进程注入 |
✓ |
✓ |
✓ |
| 执行方式 |
QueueUserWorkItem(线程池) |
Runner() 读取堆内存 |
NtQueryInformationProcess |
| Shellcode 处理 |
分配内存→解密→执行 |
写入堆内存 |
N/A |
| PEB 恢复 |
操作后恢复 PEB |
操作后恢复 LDR 结构 |
操作后恢复 |
| 使用命令 |
epi.exe -p <PID> |
LdrInject.exe <PID> <shellcode.bin> |
GUI 工具 |
EPI 的执行流程
1 2 3 4 5 6 7
| epi.exe -p 6832
1. 分配内存空间 2. 写入 loader(解密、分配、运行 shellcode) 3. 修补目标进程的 PEB(EntryPoint → loader 地址) 4. 利用进程线程池执行 5. 执行后恢复 PEB
|
LdrShuffle 的执行流程
1 2 3 4 5
| # 同进程注入 LdrShuffle.exe
# 远程进程注入 LdrInject.exe 3612 demon.x64.bin
|
七、如何用 WinDbg 验证 EntryPoint
7.1 查看 LDR 结构
1 2
| // WinDbg 中执行 dt nt!_LDR_DATA_TABLE_ENTRY 0x7ffdc640bcb0
|
输出示例:
1 2 3 4 5 6 7 8 9
| nt!_LDR_DATA_TABLE_ENTRY +0x000 InLoadOrderLinks : _LIST_ENTRY [ 0x7ffc`a800b4e0 - 0x7ff6`4e20b010 ] +0x010 InMemoryOrderLinks : _LIST_ENTRY [ 0x7ffc`a800b4f0 - 0x7ff6`4e20b020 ] +0x020 InInitializationOrderLinks: _LIST_ENTRY [ 0x7ffc`a800b010 - 0x7ffc`a8015be0 ] +0x030 DllBase : 0x00007ffc`a8000000 Void ← kernelbase.dll 基地址 +0x038 EntryPoint : 0x00007ffc`a8001234 Void ← DllMain 地址 +0x040 SizeOfImage : 0x1c0000 +0x048 FullDllName : _UNICODE_STRING "C:\Windows\System32\kernelbase.dll" +0x058 BaseDllName : _UNICODE_STRING "kernelbase.dll"
|
7.2 列出所有已加载模块
1 2 3 4 5
| // 列出模块列表 lm m kernelbase
// 详细输出 lmDm kernelbase
|
7.3 验证 EntryPoint 是否被篡改
正常情况下,EntryPoint 应该等于 DllBase + AddressOfEntryPoint(PE 头中的入口点偏移):
1 2 3 4 5 6 7
| // 计算正确值 ? kernelbase + <AddressOfEntryPoint>
// 比较 ? 0x00007ffc`a8000000 + 0x1234 → 0x00007ffc`a8001234 dt nt!_LDR_DATA_TABLE_ENTRY <LDR地址> // 看 EntryPoint 字段是否匹配
|
八、检测方法详解
8.1 方法一:LDR 结构完整性校验(LdrShuffleDetect)
这是最有效的检测方法,基于三个告警条件:
条件 1:EntryPoint 超出 DllBase 范围
1 2 3 4 5 6 7 8
| 检测逻辑: EntryPoint 地址 不在 [DllBase, DllBase + SizeOfImage) 范围内
原理: 正常的 EntryPoint(DllMain)一定在 DLL 映像内存范围内 如果被篡改为 shellcode 地址,地址一定在堆/私有内存中,必然在映像范围外
检测代码示意:
|
1 2 3 4 5 6 7
| ULONG_PTR epAddress = (ULONG_PTR)pDte->EntryPoint; ULONG_PTR dllStart = (ULONG_PTR)pDte->DllBase; ULONG_PTR dllEnd = dllStart + pDte->SizeOfImage;
if (epAddress < dllStart || epAddress >= dllEnd) { }
|
条件 2:EntryPoint 内存类型异常
1 2 3 4 5 6 7 8
| 检测逻辑: 用 VirtualQuery 查询 EntryPoint 地址的内存类型 正常应为 MEM_IMAGE (0x1000000) 被劫持后变为 MEM_PRIVATE (0x20000)
原理: DLL 的代码段在内存中标记为 MEM_IMAGE(从文件映射) shellcode 在堆上执行,标记为 MEM_PRIVATE
|
1 2 3 4 5 6 7
| MEMORY_BASIC_INFORMATION mbi; VirtualQuery((LPCVOID)pDte->EntryPoint, &mbi, sizeof(mbi));
if (mbi.Type != MEM_IMAGE) { }
|
条件 3:OriginalBase 异常
1 2 3 4 5 6 7
| 检测逻辑: OriginalBase 的值不应与 DllBase 相同(正常情况下 OriginalBase 可能等于 DllBase) 如果 OriginalBase 看起来像堆地址而非 DLL 地址 → 被覆写过
原理: 攻击者需要备份 OriginalBase,会把它写入堆内存的 DATA_T 中 如果读取 OriginalBase 发现它指向堆区域 → 可疑
|
1 2 3 4 5 6 7 8
| ULONG_PTR originalBase = (ULONG_PTR)pDte->OriginalBase; ULONG_PTR dllBase = (ULONG_PTR)pDte->DllBase;
if (originalBase != dllBase && !IsAddressInRange(originalBase)) { }
|
告警置信度矩阵
| 触发条件组合 |
场景 |
置信度 |
| 1 + 2 + 3 |
三个条件全部触发 |
Critical |
| 1 + 2 |
EP 超出范围 + 内存类型为私有 |
Critical |
| 3 |
OriginalBase 是堆指针 |
High |
| 2 |
EntryPoint 不在映像内存中 |
Medium |
| 全部不触发 |
正常 |
Clean |
完整性校验公式
1 2 3
| LDR_DATA_TABLE_ENTRY.EntryPoint == DllBase + IMAGE_NT_HEADERS.OptionalHeader.AddressOfEntryPoint
如果不相等 → EntryPoint 被篡改
|
8.2 方法二:Sysmon Event ID 10 监控
监控句柄访问中的 GrantedAccess = 0x143A:
1 2 3 4 5 6 7 8 9 10
| 0x143A 拆解:
0x0002 PROCESS_VM_READ — 读取目标进程内存 0x0020 PROCESS_VM_WRITE — 写入目标进程内存 0x0008 PROCESS_CREATE_THREAD — 创建线程(用于 CreateRemoteThread) 0x0100 PROCESS_VM_OPERATION — 虚拟内存操作(VirtualAllocEx) 0x0400 PROCESS_QUERY_INFORMATION 0x1000 PROCESS_DUP_HANDLE — 复制句柄
组合 0x143A = 0x1000 + 0x0400 + 0x0020 + 0x08 + 0x02(近似)
|
检测规则:
1 2 3 4
| Sysmon Event ID 10(ProcessAccess)中: 找到 GrantedAccess 包含 0x143A 的事件 关联该事件中目标进程是否有出站网络连接 → 如果有,高度可疑
|
8.3 方法三:WriteProcessMemory 监控
1 2 3 4 5
| 监控 WriteProcessMemory API 调用: 目标地址是否在已加载 DLL 的 DllBase 范围内 写入的数据是否与 EntryPoint 相关
这需要 EDR 的 API 钩子能力,或 ETW 提供的遥测数据
|
8.4 方法四:定期扫描高价值进程
1 2 3 4 5 6 7 8
| 对以下进程定期执行 LdrShuffleDetect 类检查: - lsass.exe (凭证存储) - svchost.exe (系统服务) - chrome.exe (浏览器,C2 通信常见) - OUTLOOK.EXE (Office,钓鱼入口) - MsMpEng.exe (EDR 自身)
检查频率:建议每 10 秒一次(LdrShuffleDetect 默认频率)
|
九、LdrShuffleDetect 检测工具实现原理
9.1 枚举所有进程
1 2 3 4 5 6 7 8 9 10 11 12
| HANDLE hSnap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
PROCESSENTRY32W pe = {sizeof(pe)}; if (Process32FirstW(hSnap, &pe)) { do { if (pe.th32ProcessID == 0 || pe.th32ProcessID == 4) continue; } while (Process32NextW(hSnap, &pe)); }
|
为什么跳过 PID 0 和 PID 4?
PID 0 是 System Idle Process,PID 4 是 System 进程,它们的内存结构与普通用户态进程不同,对它们进行内存读取会出错。
9.2 获取目标进程 PEB 地址
1 2 3 4 5 6 7 8 9 10
| HANDLE hTmp = OpenProcess( PROCESS_QUERY_LIMITED_INFORMATION, FALSE, targetPid );
DWORD sz = MAX_PATH; QueryFullProcessImageNameW(hTmp, 0, e.name, &sz);
|
9.3 读取目标进程的 LDR 结构
1 2 3 4 5
|
ReadProcessMemory(hProcess, pPeb, &pebBuf, sizeof(pebBuf), &bytesRead); ReadProcessMemory(hProcess, pPeb->pLdr, &ldrBuf, sizeof(ldrBuf), &bytesRead);
|
十、攻击利用的真实场景
场景 1:C2 持久化
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| 前提:攻击者已通过钓鱼获得进程内代码执行权限
攻击者操作: 1. 把 shellcode(C2 回连代码)写入目标进程堆内存 2. 劫持 kernelbase.dll 的 EntryPoint 指向 shellcode 3. 进程在日常运行中自然创建线程 4. DllMain 触发 → 执行 C2 回连 5. 恢复 EntryPoint,无痕迹
EDR 视角: ✗ 没有 CreateRemoteThread 调用 ✗ 没有新线程被创建 ✗ EntryPoint 只在几毫秒内被篡改 ✗ 扫描时已恢复正常
|
场景 2:横向移动
1 2 3 4 5 6 7
| 前提:攻击者需要在远程进程中执行代码
攻击者操作: 1. 使用 WriteProcessMemory 将 shellcode 写入远程进程 2. 劫持远程进程 kernelbase.dll 的 EntryPoint 3. 等待远程进程创建线程(如 Windows Service) 4. 恶意代码被执行
|
场景 3:绕过 EDR 内存扫描
1 2 3 4 5 6 7 8
| EDR 的内存扫描通常: 定期扫描 MEM_PRIVATE 区域的可执行代码 检测可疑的内存分配模式
EntryPoint Hijacking 的优势: 恶意代码可以放在 MEM_IMAGE 区域(DLL 内部的合法代码段) EntryPoint 只在执行时被短暂修改 恢复后内存扫描看到的是正常的 DLL 映像
|
十一、防御建议
11.1 端点检测
1 2 3 4 5
| 1. 部署 LdrShuffleDetect 类工具,每 10 秒扫描高价值进程 2. 检查 LDR_DATA_TABLE_ENTRY 的完整性: - EntryPoint 是否在 [DllBase, DllBase + SizeOfImage) 范围内 - OriginalBase 是否与 DllBase 一致 3. 监控 VirtualQuery 调用,关注 MEM_IMAGE → MEM_PRIVATE 的转换
|
11.2 Sysmon 规则
1 2 3 4 5 6 7 8
| <Sysmon> <RuleGroup> <ProcessAccess onmatch="include"> <GrantedAccess condition="contains">0x143A</GrantedAccess> </ProcessAccess> </RuleGroup> </Sysmon>
|
11.3 EDR 能力提升
1 2 3 4 5 6 7 8 9 10
| 传统 EDR 关注的: ✗ API 钩子(CreateRemoteThread、VirtualAllocEx) ✗ 线程创建遥测 ✗ 内存扫描
应该增加的: ✓ PEB/LDR 结构完整性校验 ✓ EntryPoint 地址范围检查 ✓ WriteProcessMemory 对 DLL 区域的监控 ✓ 多行为关联(句柄访问 + 出站流量)
|
11.4 SOC 操作手册
1 2 3 4 5 6 7 8
| 当告警触发时:
1. 确认告警来源进程是否为高价值目标(lsass/浏览器/Office) 2. 检查该进程是否有异常出站连接(关联 EDR 网络遥测) 3. 使用 LdrShuffleDetect 确认被劫持的具体 DLL 4. 取证:记录 EntryPoint 的原始值和被篡改后的值 5. 隔离进程,终止可疑连接 6. 回溯攻击链:WriteProcessMemory 的来源进程是什么
|
十二、技术演进与未来
| 时间 |
进展 |
意义 |
| 2023 |
EPI 发布 |
首个 EntryPoint Injection 的公开 PoC |
| 2025 |
LdrShuffle 发布 |
支持远程进程注入,更成熟的实现 |
| 2026 |
iPurple 工具 |
工具化,带 GUI,降低使用门槛 |
攻击趋势:
- 不依赖可疑 API 的注入技术将成为主流
- 利用 Windows Loader 自身机制的”Living off the Land”思路
- 短暂窗口期的攻击将挑战传统定时扫描的检测模式
参考资料