文章总结: 本文对比四种在ElasticEDR下执行Shellcode的技术,发现仅NtContinue结合retgadget成功绕过检测。文章剖析了EDR基于调用栈分析的拦截机制,指出该方法通过伪造调用栈源地址规避规则。最后建议防守方加强内核态检测、监控NtContinue调用及异常上下文切换,以应对此类高级绕过技术。 综合评分: 91 文章分类: 红队,渗透测试,逆向分析,安全建设
绕过 Elatic EDR 用户态 Hook:从原理到实战
原创
老鑫安全 老鑫安全
老鑫安全
2026年2月15日 15:08 四川
前言
最近在研究 EDR 绕过技术,做了一组对比实验。用四种不同的方式实现同样的功能(分配内存 → 写入 shellcode → 改权限 → 执行),然后在 Elastic EDR 环境下跑一遍,看看哪些会被拦截。
结果挺有意思:
| 版本 | 技术 | 结果 | | — | — | — | | winapi_version | kernel32 API | 被拦截 | | ntdll_direct | 直接调 ntdll | 被拦截 | | direct_syscall | SysWhispers 风格 | 被拦截 | | ntcontinue_version | NtContinue + ret gadget | 未拦截 |
下面详细说说每个版本为什么被拦截(或没被拦截)。
一、WinAPI 版本:最老实的写法
// 标准写法,人人都会
LPVOID mem = VirtualAlloc(NULL, 0x1000, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
memcpy(mem, shellcode, sizeof(shellcode));
VirtualProtect(mem, size, PAGE_EXECUTE_READ, &oldProtect);
((void(*)())mem)();
img
触发的规则
Suspicious Memory Size Protection via VirtualProtect
- • 规则文件:
defense_evasion_suspicious_memory_size_protection_via_virtualprotect.toml - • 规则 ID:
c771303c-a200-4df3-bb76-3e5f87a18438
这条规则检测的是:
process.Ext.api.name in ("VirtualProtect", "VirtualProtectEx") and
process.Ext.api.parameters.protection like "*X*" and
not process.Ext.api.parameters.protection_old like "*X*" and
process.Ext.api.parameters.size == 1
翻译成人话:你调了 VirtualProtect,把内存从不可执行改成可执行,而且改的大小只有 1 字节。这太可疑了,——正常程序谁会只改 1 字节的权限?改成别的? 别想了绕过去了就说明厂商脑子不正常
为什么被抓
调用链是这样的:
你的代码 → kernel32!VirtualProtect → ntdll!NtProtectVirtualMemory → syscall
EDR 在 kernel32 和 ntdll 都下了 hook,你的每一步操作它都看得清清楚楚。而且调用栈里最后一个用户模块是你的 exe,没有签名,直接就被标记为可疑。
二、ntdll 直接调用版本:绕过 kernel32
// 跳过 kernel32,直接调 ntdll
auto NtAllocateVirtualMemory = (NtAllocateVirtualMemory_t)GetProcAddress(ntdll, "NtAllocateVirtualMemory");
NtAllocateVirtualMemory(process, &baseAddr, 0, ®ionSize, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
img
触发的规则
1. Suspicious Memory Size Protection via VirtualProtect
- • 同上,还是被抓
2. Native API Call from Unsigned Module
- • 规则文件:
defense_evasion_native_api_call_from_unsigned_module.toml - • 规则 ID:
27202cd8-c5a6-4b82-9bb8-f3da69a4d5d3
这条规则检测的是:
process.Ext.api.behaviors == "native_api" and
process.Ext.api.name in ("VirtualAlloc", "VirtualProtect", ...) and
_arraysearch(process.thread.Ext.call_stack_final_user_module.code_signature,
$entry, $entry.trusted == false or $entry.exists == false)
翻译:你直接调了 Native API(绕过了 kernel32),而且调用栈里最后一个用户模块没有可信签名。
为什么被抓
虽然绕过了 kernel32 的 hook,但 ntdll 的 hook 还在。EDR 看到:
- 1. 有人直接调 NtProtectVirtualMemory
- 2. 调用者是个没签名的 exe
- 3. 正常程序不会这么干
直接标记为恶意行为。
三、Direct Syscall 版本:SysWhispers 风格
这是网上最流行的绕过方式,SysWhispers、Hell’s Gate 等工具都是这个思路。
// 自己构造 syscall stub
unsigned char SyscallStub[] = {
0x4C, 0x8B, 0xD1, // mov r10, rcx
0xB8, 0x00, 0x00, 0x00, 0x00, // mov eax, SSN
0x0F, 0x05, // syscall <-- 关键
0xC3 // ret
};
// 运行时 patch SSN,然后调用
*(DWORD*)(SyscallStub + 4) = ssn;
((SyscallFunc)SyscallStub)(args...);
img
触发的规则
1. Suspicious Memory Protection Change via VirtualProtect
- • 规则文件:
defense_evasion_suspicious_memory_protection_change_via_virtualprotect.toml - • 规则 ID:
8fcf2b81-8322-423b-a1b4-6bba722f599a
检测逻辑:
process.Ext.api.behaviors == "allocate_shellcode" and
process.Ext.api.parameters.protection == "R-X" and
process.Ext.api.parameters.protection_old == "RW-" and
process.Ext.api.metadata.target_address_name == "Unbacked"
翻译:你把一块”Unbacked”内存(不属于任何已知模块)从 RW 改成了 RX。这是典型的 shellcode 执行模式。
2. Shellcode Execution from Low Reputation Module
- • 规则文件:
defense_evasion_shellcode_execution_from_low_reputation_module.toml - • 规则 ID:
9fda6a38-3822-45b6-b621-02f750e8cf0d
这是个序列规则,检测:
- 1. 先加载了一个低信誉/未签名的模块
- 2. 然后出现了 shellcode 行为(allocate_shellcode、execute_shellcode)
为什么被抓
虽然绕过了 ntdll 的 hook(syscall 指令直接进内核),但 EDR 还有其他检测手段:
- 1. 调用栈分析:syscall 返回后,调用栈显示返回地址在你的 exe 里,而不是 ntdll.dll。正常情况下 syscall 应该从 ntdll 发起。
- 2. 内存属性检测:你的 syscall stub 在一块 RWX 内存里,这本身就很可疑。
- 3. 行为模式:RW→RX 的内存权限变化 + 未签名模块 = 高度可疑
四、NtContinue 版本:为什么能绕过
这是我们的版本,核心思路是利用 NtContinue 做上下文切换,配合 ntdll 内部的 ret gadget 来伪造调用栈。
img
技术原理
// 1. 捕获当前上下文(相当于 setjmp)
RtlCaptureContext(&ctx);
// 2. 构造 syscall 上下文
CONTEXT sc = ctx;
sc.Rax = ssn; // syscall number
sc.R10 = args[0]; // 第一个参数
sc.Rcx = args[0];
sc.Rdx = args[1];
sc.R8 = args[2];
sc.R9 = args[3];
sc.Rip = syscall_addr; // 指向 ntdll 里的 syscall 指令
// 3. 关键:设置返回地址为 ntdll 内部的 ret gadget
stack[0] = ntdll_ret_gadget; // syscall 返回到这里
stack[1] = &SyscallLanding; // ret gadget 返回到这里
// 4. 通过 NtContinue 切换到 syscall 上下文
NtContinue(&sc, FALSE);
执行流程:
NtContinue(syscall_ctx)
↓
ntdll!syscall 指令
↓
内核执行
↓
返回到 ntdll_ret_gadget (ntdll.dll 内部)
↓
ret 到 SyscallLanding()
↓
NtContinue(restore_ctx) 恢复原始上下文
为什么能绕过
对照前面触发的规则,看看我们是怎么规避的:
1. 调用栈检测
规则检查 call_stack_final_user_module,也就是调用栈里最后一个用户态模块。
- • Direct Syscall:最后一个用户模块是你的 exe
- • NtContinue 版本:syscall 返回到 ntdll 的 ret gadget,所以调用栈显示最后一个用户模块是 ntdll.dll
规则里有这个排除:
not process.thread.Ext.call_stack_final_user_module.path like (
"c:\\windows\\system32\\ntdll.dll",
"c:\\windows\\syswow64\\ntdll.dll",
...
)
我们的调用栈看起来像是从 ntdll 发起的正常调用。
2. Syscall 字节检测
有些规则会扫描内存里的 0F 05(syscall 指令)字节。
- • Direct Syscall:你的代码里有 syscall 字节
- • NtContinue 版本:我们用的是 ntdll 里原本就有的 syscall 指令,自己的代码里没有
3. Native API 行为检测
规则检测 process.Ext.api.behaviors == "native_api"。
NtContinue 本身是个合法的 API,用于异常处理和上下文切换。我们只是”借用”它来执行 syscall,行为模式和直接调用 Native API 不一样。
4. 内存属性
我们没有创建 RWX 内存来放 syscall stub,所有执行都发生在 ntdll 内部。
关键点总结
| 检测点 | Direct Syscall | NtContinue 版本 | | — | — | — | | 调用栈最终模块 | 你的 exe | ntdll.dll | | syscall 字节位置 | 你的代码里 | ntdll 里 | | 内存属性 | 需要 RWX | 不需要 | | 行为模式 | native_api | 正常上下文切换 |
五、EDR 规则的局限性
看完这些规则,能发现几个特点:
1. 严重依赖调用栈分析
几乎所有规则都检查 call_stack_final_user_module。只要你能让调用栈看起来”正常”,就能绑过大部分检测。
2. 白名单机制
规则里有大量的 hash 白名单和路径排除:
not process.thread.Ext.call_stack_final_user_module.hash.sha256 in (
"76509a878c4328e71e5d9c0622588e28716466bc9e53209...",
...
)
这说明 EDR 厂商也知道会有误报,所以加了很多例外。但这也意味着如果你的行为能模仿这些”正常”程序,就不会被检测。
3. 签名检查
_arraysearch(process.thread.Ext.call_stack_final_user_module.code_signature,
$entry, $entry.trusted == false or $entry.exists == false)
未签名的模块会被重点关注。但如果调用栈显示是从 ntdll(有微软签名)发起的,这个检查就过了。
六、防御建议
如果你是防守方,这里有几个思路:
- 1. 内核态检测:用户态的 hook 和调用栈分析都可以被绑过,内核态的 ETW 或 minifilter 更可靠
- 2. NtContinue 监控:正常程序很少用 NtContinue,如果一个进程频繁调用它,值得关注
- 3. 上下文切换检测:检测 RIP 突然跳到 ntdll 中间位置的情况
- 4. 行为序列分析:单个 API 调用可能看起来正常,但 NtContinue → syscall → 内存操作 这个序列就很可疑
七、代码
代码被EDR杀了
编译:
cd cpp_versions
mkdir build && cd build
cmake ..
cmake --build . --config Release
八、参考
- • SysWhispers – 经典的 direct syscall 工具
- • Elastic protections-artifacts – 本文分析的 EDR 规则来源
- • MITRE ATT&CK T1055 (Process Injection)
- • MITRE ATT&CK T1106 (Native API)
免责声明:本文仅用于安全研究和教育目的。测试代码只执行一条 ret 指令,不包含任何恶意功能。
免责声明:
本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。
任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。
本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我。
本文转载自:老鑫安全 老鑫安全 老鑫安全《绕过 Elatic EDR 用户态 Hook:从原理到实战》
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。










评论