资讯详情

资讯详情

VEH与硬件断点结合的Ldr劫持:低痕迹执行流控制机制解析

做一个纯技术的机制拆解不提供完整可落地的武器代码但会把链路讲透——这也是我给自己定的底线。这两年做样本分析和EDR检测规则我越来越觉得进程内执行流控制是个很有意思的题目。大家熟悉的思路无非是远程线程、APC注入、IAT Hook但这些手法在内存扫描和行为检测面前留下的痕迹都太重了。我最近重新把VEH加硬件断点这条路线翻出来做了一遍梳理标题里写的“Ldr劫持”本质上是借向量化异常处理机制在系统加载器处理模块初始化流程的时候用调试寄存器把执行流拽到自己的异常处理函数里绕开常规HOOK点直接用异常这个通道接管执行权。这篇文章先更第一部分把技术动机、底层链路和整体设计讲清楚具体的实践细节和对抗优化放到后面几更慢慢展开。适合已经对PE加载、异常分发有基础、想深入了解加载器与断点机制配合的读者也适合做检测侧的朋友作为参考。1. 为什么这条路值得走软断点与内存断点的暴露面1.1 软断点为什么容易暴露先聊软断点。用INT3实现断点也就是往目标地址写一个0xCC字节这是调试器最常用的手段。问题也随之而来任何做内存扫描的安全产品都可以通过特征匹配直接找出代码段里的0xCC哪怕你只改一个字节。进阶一点的做法是用硬件执行断点替代软断点但很多分析工程师在落地上还是会习惯性选择0xCC因为简单、直观、不需要搞懂调试寄存器。更重要的是完整性校验。现在不少模块会做加载后的内存哈希校验如果你的注入逻辑或者钩子逻辑改了原始字节和磁盘镜像一对比就露馅了。软断点和Inline Hook在这个场景下都会死得很难看除非你做好VEH拦截校验函数这类兜底操作否则检测成本非常低。1.2 内存断点的页属性局限内存断点本质上是利用页权限来触发异常。把目标内存页改成PAGE_GUARD或者PAGE_NOACCESS程序访问这一页时就会产生异常再由异常处理函数去判断命中的具体地址。听起来很万能但有两个问题很致命。一是工具扫描能发现页权限异常。检查某个内存区域的保护属性发现不该加Guard的页面被加了Guard这就是一个非常显眼的信号。二是失效机制的粒度太粗。页面通常是4KB起步你只是想拦住一个函数入口结果整页都变了同页的其他代码执行也会误触发。处理函数里还得做二次判定写少了各种误判写多了又是一大坨逻辑。内存断点适合调试器在交互式场景下使用放到自动化执行流控制里体验并不好。1.3 硬件断点的核心优势硬件调试寄存器则是完全不同的思路。它不修改任何代码字节也不改页权限CPU在指令执行阶段直接比较当前指令地址是否落在DR0-DR3指定的地址上命中后触发异常。整个过程对内存和代码本身是零修改的。这意味着几件事恶意代码扫描扫不到你改动的字节因为你根本没改完整性校验也发现不了异常代码还是原始代码断点的精度是指令级的比页粒度精确得多。代价是数量有限同时只能下四个硬件断点DR7寄存器里的控制位还得精心设置管理起来比软断点复杂。我最初对这套机制产生兴趣正是因为“零修改”这个特性。你想在一个受保护较强的进程里拿执行权又不想让内存扫描产品看到任何字节级别的改动硬件断点几乎是唯一不需要依赖特定系统漏洞的干净路径。2. VEH的异常分发机制与断点的第一次相遇2.1 异常在用户态怎么流转硬件断点命中的瞬间CPU会触发一个调试异常。这个异常会先经过内核态的处理再转回用户态进行分发。用户态侧收到异常后会经历一条经典的链路先看是否有调试器附加再看有没有注册向量化异常处理也就是VEH最后才轮到线程的SEH链。关键点在于顺序。VEH的处理优先级在SEH之前而且VEH是一张独立的全局链表不由当前函数调用栈决定。你只要调用注册接口把自己挂到链首就能在任何线程、任何调用深度里第一时间拿到异常控制权。SEH还需要栈上存在对应的异常处理节点结构化得比较死板而VEH在控制权抢占这件事上要灵活得多。另一个值得注意的细节是异常处理函数返回的常量直接决定后续行为。如果返回“继续执行”系统会尝试恢复现场再次执行出错的指令如果返回“继续搜索”异常会继续往SEH或默认处理流程走。这个选择在断点方案里是核心决策点后面会详细说。2.2 注册VEH的时机和调用约定注册VEH就是调一个系统提供的API传入处理函数指针和一个是否“先注册”的布尔值。处理函数本身有自己的调用约定接收两个参数一个指向异常记录的指针一个指向异常上下文记录的指针。异常记录里我们能拿到异常代码和异常地址。需要关注的是异常代码硬件断点命中在用户态看到的异常代码通常是0x80000004也就是单步异常而DR0-DR3断点命中也是调试异常的一种。异常地址则是CPU触发异常时正在尝试执行的指令地址这个值就是我们判断断点是否命中的依据。异常上下文记录则更重要它是一个结构体保存了断点发生时这个线程的完整寄存器快照。处理函数可以读取、甚至修改这些寄存器值。修改完成后只要返回“继续执行”系统就会用修改后的上下文恢复线程的寄存器状态。这就是整个劫持方案的关键接口本质上我们是借异常的通道直接把线程的指令指针改到自己的代码上。2.3 与SEH相比VEH接断点爽在哪里有人会问我用结构化异常处理也能接断点为什么要用VEH区别在于作用范围。SEH和当前函数栈帧绑定异常处理节点通常只能覆盖当前函数内部的一段代码。如果你的断点目标是一个通用函数而这个函数会被各种不同调用栈触达你要保证每个调用位置都有对应的SEH节点覆盖这在实际工程里非常麻烦。VEH是进程级的一次注册全局生效任何线程、任何调用栈只要触发了对应异常都会先问过你的处理函数。另外一点就是可控性。VEH链表是可以通过接口遍历和操作的处理函数内部可以维护自己的状态机。你可以在第一次命中时做初始化第二次命中时做清理灵活性远高于SEH那种“要么处理要么交给外层”的二元逻辑。对我们要做的断点编排来说这个状态机的存在几乎是必需的。3. Ldr这个目标怎么选出来的3.1 Ldr系列函数在进程里的站位现在把目光转向劫持目标。标题里的“Ldr劫持”不是指劫持加载器本身而是指在加载器运行的某个关键函数上下手等待它的执行时机。系统加载器在进程初始化阶段会做模块加载、依赖解析、初始化例程调用这一系列动作。其中负责调用DLL初始化例程的内部函数基本是每个模块加载必经的一站。很多注入方案喜欢直接Inline Hook这个函数因为钩住它就能在每个DLL加载初始化之前切入执行权。但直接Inline Hook的弱点很明显修改函数头部字节本身就是一种内存特征而且如果某些安全产品对常见关键函数做了热补丁校验你的修改可能在很早期就被发现。硬件断点加VEH的组合则能看到同样的时机窗口却不会留下字节级痕迹。3.2 硬件断点落在这个函数上的时机窗口硬件断点下在这个内部函数入口之后等到目标进程加载某个模块、进入这个函数执行时CPU命中调试异常VEH处理函数接管执行权。这个时机非常特殊因为它处在模块初始化例程被调用之前。在这个时机点当前线程已经处于加载器的关键路径上模块的基址和句柄通常已经在寄存器或栈参数里准备就绪。处理函数可以直接拿到这些参数不需要再去遍历PEB或者模块链表省去大量定位代码。对后续想做模块隐藏、初始化劫持或者参数篡改的方案来说这是一个天然的窗口。不过要注意这个内部函数在不同系统版本中的函数签名和调用约定可能都有差异。参数是从寄存器传还是从栈传、是否有跳板指令、入口处是否已经做了栈帧初始化这些问题必须按版本适配。这也是这类技术经常被吐槽“稳定性差”的原因——它不是写一次就能跑一辈子的逻辑。3.3 和直接InlineHook的差异对比我把两种方案做过一次完整对比差异可以总结成一张表维度Inline HookVEH 硬件断点代码字节修改需要通常是修改函数头部跳板不需要内存零修改内存扫描特征高跳板指令容易被特征识别低没有字节特征完整性校验会触发需额外处理不影响原始内容断点数量无限制可钩多个函数硬件限制最多四个地址多线程命中处理由钩子函数内部管理断点命中与异常分发天然隔离系统兼容性需要适配函数签名但改动相对小需要同时适配断点控制与异常上下文恢复检测侧可见性扫描代码段即可得强信号需要检查线程上下文和VEH链手段不同看完这张表就明白为什么我要专门梳理这条链路了。Inline Hook在工程实现上更省事但检测侧也更容易分析VEH加硬件断点牺牲了一些实现便利换来的是更低的内存痕迹和更自由的时机控制。4. 三大件拼起来一次完整的执行流接管4.1 初始化顺序先备好处理函数再下断前面把三块技术都铺垫完了接下来是整个链路怎么拼。不管目标是什么函数整体顺序都应该是这样先注册VEH再设置加载器目标函数对应的断点绝不能反过来。原因很简单。VEH处理函数必须先于断点异常出现在系统分发的链表里否则断点一触发系统找不到我们注册的处理逻辑就会把异常继续往上抛甚至直接终止进程。先注册处理函数再下断点这个顺序是铁律。设置硬件断点本身也有讲究。你需要拿到目标线程的句柄通过线程上下文的操作接口读出当前上下文修改DR0-DR3和DR7的相关位。DR7里既有全局使能位也有局部使能位还要设置断点的类型和长度。比如你是想在指令执行时命中那类型就设成执行断点长度通常用1字节即可。这些控制位的组合方式在各个系统架构文档里都有明确说明照着设置就行。4.2 断点命中后处理函数里到底要干什么断点命中进入VEH处理函数后第一件事是校验异常记录里的异常地址和目标地址是否匹配。这一步不能省因为VEH是进程级的其它线程如果本身有调试异常也会进到我们函数里。你必须在入口就判断是不是自己下的那个断点不是就直接返回继续搜索把异常还给系统。确认是自己下的断点之后真正的执行流控制才开始。这时候要修改异常上下文里的指令指针让它指向我们准备好的代码片段。这里有一个非常关键的问题这段代码要放在哪里常见做法是把代码片段放到申请的可执行内存里同时要处理好相对寻址的问题。直接把指令指针改到一个全新地址如果代码里还有针对原模块的引用就必须同步修正。更稳的做法是设置一个跳板。在异常上下文里把指令指针改成目标代码片段入口而目标代码片段开头保存当前寄存器状态然后执行我们自己的逻辑最后再恢复寄存器并从断点处的下一条指令继续执行。这个跳板逻辑要单独维护一套栈帧约定不能依赖原函数正常的栈结构。4.3 从断点到执行完怎么把线程上下文收拾干净执行完自己的逻辑之后很多人以为直接返回“继续执行”就行实际上这里藏着最大的坑。如果断点地址就在函数入口你修改指令指针跳到别处执行完一轮最后如果不把指令指针恢复到断点下一条指令返回“继续执行”后CPU会再次执行那条触发异常的指令接着就是一个新的断点异常无限循环。所以整个处理流程里最后一步必须修改异常上下文让指令指针等于原断点地址加指令长度同时清理DR7里对应的使能位保证同一轮劫持不会重复触发。还要注意控制寄存器状态。如果处理函数内部修改了某些标志位或者通用寄存器返回前要么恢复原始值要么确保目标逻辑不再依赖它们。调试异常触发时系统保存的上下文就是原始现场我们越少改动这个现场整个流程就越安全。我的经验是能不改寄存器就不改实在要改一定要在跳板代码末尾原样恢复。4.4 一个最小可验证的骨架思路不写完整PoC但可以给一个验证VEH能否接到硬件断点异常的最小思路。申请一块可执行内存写一小段简单的功能代码比如给某个全局变量加一注册VEH处理函数打印异常信息和指令地址设置DR0指向这段代码的第一条指令使能断点手动调用这段代码观察处理函数是否触发。如果处理函数里能正确打印出异常记录里的断点地址那就证明VEH和硬件断点的链路已经通了。接下来才是把这段功能代码替换成你要做的逻辑再把断点地址换成加载器函数入口。很多人一上来就想着完整实现结果异常分发链路的细节没搞透最后连断点是否触发都判断不了。先跑通最小验证再逐层加业务逻辑是我建议的路径。5. 从蓝队视角看这个技术留下的痕迹5.1 调试寄存器不是无痕的硬件断点虽然不修改代码字节但不代表它在检测侧完全隐形。线程的上下文结构里就保存着DR0-DR7的值任何能够枚举线程并读取上下文的检测程序都能看到某个线程设置了调试寄存器。真正的无痕技术是不存在的它只是把痕迹从代码段转移到了线程上下文里。更微妙的是正常业务进程的线程几乎不会去设置调试寄存器除了调试器附加或者性能监控类工具一般线程的DR0-DR7都是零。所以只要检测侧发现线程上下文里DR0-DR7非零且配合上异常分发链路的异常记录这个信号的关联价值就已经很高了。做排查时先把所有线程的DR0-DR7都拉一遍能过滤掉大量干扰项。5.2 VEH链的异常取证VEH链本身也是一个检测点。进程里如果存在一个陌生地址的处理函数注册在向量化异常链表里而这个地址落在可执行堆或者映射的内存区段可信度就非常可疑。检测侧可以通过遍历VEH链表节点拿到处理函数地址再通过反向解析地址判断它到底属于哪个模块。异常历史也是取证的重要来源。系统的事件追踪里很多异常信息是可记录的虽然普通产品不会默认全量记录但调试器附加时可以看到异常分发全过程。对分析人员来说如果发现某个进程持续出现调试异常并且异常地址集中在系统加载器的函数区间那基本可以断定有人在使用断点劫持技术。5.3 检测侧的一些判定思路我整理几条检测侧可以落地的信号不涉及具体产品名称只是思路参考。信号维度可观察特征判定思路线程上下文DR0-DR3非零DR7的使能位开启结合进程类型判断是否可疑VEH链表处理函数地址落在非模块区域或可执行堆上遍历链表后检查归属异常频率短时间内多次调试异常且地址收敛关联异常地址与已知关键函数行为时机异常集中在模块加载和初始化阶段异常分布与加载事件做关联分析内存映射存在大量可执行内存申请结合链路上的跳板代码判断这套信号组合起来能有效覆盖大多数硬件断点加VEH的劫持行为。单一信号很容易被误报但多个信号同时命中时基本可以确认存在非常规执行流控制。6. 实践中的坑和我的取舍6.1 断点循环和误触发我前面已经提过无限循环的问题这里再展开讲讲。踩过一次的坑是我在跳板代码里恢复了大部分寄存器但漏了DR7的清零结果返回“继续执行”后CPU又命中同一个断点处理函数又被调用而且此时DR7还处于使能状态整个进程直接进入异常风暴。只要断点地址还是原来那个清零DR7必须放在修改指令指针的同一步操作里顺序不能拆开。误触发则是另一个维度。硬件断点如果设成线程粒度那只有指定线程命中才触发如果设成进程粒度任何线程执行到断点地址都会触发。很多系统API由多线程并发调用进程粒度断点很容易在意外线程里命中处理函数还没来得及判断线程ID就执行了劫持逻辑轻则逻辑错乱重则直接崩溃。我的做法是处理函数入口先校验当前线程ID只有目标线程才执行劫持其它线程一律继续搜索异常。6.2 线程亲和性与断点绑定问题还有一次在排查为什么断点偶发不触发后来发现是线程亲和性问题。目标线程可能在多核之间迁移但调试寄存器的状态本身是线程上下文里的操作系统在切换线程时会自动保存和恢复理论上不存在核间丢失。真正的问题是某些系统组件会主动重置线程上下文。例如调试器附加、性能监控事件触发都可能覆盖寄存器状态。解决思路是在关键时机点做一次二次确认。但频繁读取线程上下文本身也是开销每读一次都要进入内核态。折中方案是断点设置后先触发一轮自检调用确认断点生效再跑正式逻辑。宁可多花一次自检的时间也不要让正式劫持流程在断点失效的情况下静默失败。6.3 技术路线的取舍做完这一轮梳理我对这条技术路线的评价是它值得了解但不值得无脑套用。硬件断点加VEH的优势在于低内存痕迹和精确的时机控制代价是实现复杂度高、系统版本适配麻烦、调试困难。如果目标场景只是快速拿执行权直接做远程线程或APC注入反而更高效但如果你面对的是一个有内存扫描和完整性校验的对抗环境那这条链路的价值就体现出来了。从项目演进的角度看我更倾向于把这套机制拆成可复用的组件VEH注册封装成一个模块硬件断点管理器单独维护执行流跳板逻辑再抽象一层。三个组件各自独立测试最后再组装整体链路。这种工程化拆分能让问题定位快很多调试寄存器设置出问题时不需要翻找异常处理代码。7. 第一更的尾巴这一更主要讲了机制和链路没有给可落地的完整实现。下一更我想重点写两件事一是异常上下文在64位进程下的结构差异和修改注意事项二是如何设计一个更稳定的跳板代码让劫持后恢复现场的成功率更高。如果你自己也想动手验证可以先把最小VEH加硬件断点链路跑通——用一个普通函数当目标断点命中后只是把指令指针改到另一个无害地址再改回来整个流程就足够看清楚异常分发的全貌了。我在这个过程中最大的体会是断点本身很简单难的是对异常分发和线程上下文的精确控制。每一处看起来繁琐的“恢复原状”步骤都是在为系统稳定性兜底。对这种底层机制感兴趣的朋友建议从调试器基础开始补把单步执行、寄存器读写和异常分发真正打通之后再碰加载器劫持这类进阶场景会顺手很多。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →