
1. 双击就闪退的那类故障很多都指向同一件事一个用了两年的小工具某次系统更新完再双击窗口闪一下就没了。事件查看器里翻出来一条应用程序错误异常代码0xC0000005故障模块指向系统目录下的核心动态库。第一反应是安全软件拦了关掉重试一样第二反应是缺运行库装了一圈依旧。最后改了一个系统开关才好——改的就是数据执行保护。这类故障有个共同点程序本身没坏缺的也不是依赖库而是它做了一件在现代操作系统看来越界的事——把一段数据当成指令执行了。数据执行保护Data Execution Prevention简称DEP就是专门盯这件事的机制。它做的事情说起来特别朴素给每一页内存打个标记标明这块地是拿来放数据的还是放代码的凡是标了数据的页处理器一律不许从里面取指令。缓冲区溢出最经典的利用手法就是把一段精心构造的机器码写进栈或堆再想办法把控制流跳过去执行DEP 一开这条路径直接断掉。这篇内容适合三类人看。第一类是写 C/C、自己搞过运行时或者即时编译JIT的同学你们大概率会遇到动态生成的代码被 DEP 挡下来的情况报错还很莫名其妙。第二类是做安全测试和漏洞研究的需要搞清楚 DEP 作为一道防线到底有多硬、哪里可能被绕过。第三类是被某个老软件折磨、网上搜到关闭 DEP这个偏方但心里没底的人——我要先说明这不是个好主意后面会讲为什么。2. 一条指令取指时处理器凭什么判断这块内存能不能执行要理解 DEP得先回到处理器执行指令的最基本流程。程序运行起来之后代码和数据都在内存里从物理介质上看它们没有任何区别都是一串字节。那么处理器怎么知道某一次读取是要执行还是只是读数据早期确实没法精确区分靠的是编写程序时人为遵守的约定代码段放代码数据段放数据别乱跳。约定这种东西编译器会守攻击者不会守。现代处理器的做法是把判断下沉到页表项里。每个内存页在页表里都有一项描述记录着这页的物理地址、读写权限、用户态还是内核态可访问还有一位专门标记可执行。这一位在不同架构上有不同叫法NXNo eXecute是最通用的说法也有叫 XD 的。处理器在取指阶段会检查目标页的这一位如果是不可执行直接抛出一个异常指令根本没机会被执行。2.1 从约定到硬件强制的这一步跨越这个变化的意义在于以前防不住的东西现在从硬件层面被堵死了而且不需要程序配合。你可以把它理解成门禁从贴个纸条写着闲人免进升级成了刷卡才能开。纸条可以无视门禁无视不了。攻击者哪怕完全控制了程序的数据区也没法让处理器去执行那里面的内容——只要那一页的 NX 位是关着的。这一位是谁来设操作系统。加载器在把可执行文件映射进内存的时候会根据文件里的段属性把代码段映射成可读可执行把数据段映射成可读可写但不可执行。堆和栈则默认就是可读写、不可执行。所以一个正常的程序栈上永远只有数据永远不能执行。2.2 异常抛出之后系统层面发生了什么处理器抛异常之后控制权交给操作系统的异常处理机制。在 Windows 上这个异常被包装成STATUS_ACCESS_VIOLATION就是那个熟悉的 0xC0000005并且带有两个附加参数一个是访问类型一个是出错的虚拟地址。访问类型 0 表示读1 表示写8 表示执行。看到 8基本就可以确定是撞上 DEP 了而不是普通的空指针解引用。在 Linux 一类系统上对应的是SIGSEGV附加信息里si_code为SEGV_ACCERR表示权限错误而非地址不存在。区分这两种情况很关键地址不存在通常是野指针权限错误才可能是执行了不该执行的内存。提示排查时优先看异常参数里的访问类型这一条信息能直接把排查范围砍掉一大半。很多人一看到 0xC0000005 就往空指针方向查方向从一开始就偏了。2.3 它和你听过的 ASLR、栈保护不是一回事这三个东西经常被混在一起说其实各管一段。我用一个类比**栈保护编译器插的 Canary**相当于在返回地址前面放了个岗哨谁踩过界就报警ASLR地址空间随机化相当于每次把楼里的房间号重新打乱让攻击者猜不到该往哪跳DEP则相当于规定存放物品的房间不许住人。三个机制互相补位缺一个都会让攻击成本下降一大截。攻击者想绕过 DEP通常得用返回导向编程那套思路把已有代码段里的零散指令片段串起来执行这也正是后面各种防护机制要继续解决的问题。3. 硬件 NX 与软件检查DEP 的两条腿其实不太一样很多人以为 DEP 就是一个纯硬件特性其实操作系统层面的 DEP 实现是分两层的而且这两层的触发条件和覆盖范围差别很大。搞清楚这个分层后面很多为什么我改了设置没反应的疑问就迎刃而解。3.1 硬件 DEP 的生效前提硬件 DEP 依赖处理器的 NX 能力但仅仅处理器支持还不够还得满足几个条件。在 32 位系统上启用硬件 DEP 通常需要开启PAE物理地址扩展模式这也是为什么很多老机器上 DEP 实际上是关着的——系统装的时候没开 PAE硬件 DEP 就无从谈起。64 位系统上这个前提基本自动满足因为长模式本身就要求页表结构支持 NX 位。另一个容易被忽略的点是驱动程序。内核态代码同样受 DEP 约束如果一个老驱动在非分页池里动态生成代码并执行它在开了 DEP 的机器上会直接蓝屏错误码往往指向页错误。这类问题在十年前的老设备驱动里非常常见现在少了很多但做兼容性测试时仍然会遇到。3.2 软件层面的 DEP 相关检查除了硬件 NXWindows 还提供了一组基于软件的结构化异常处理检查通常和 DEP 一起作为数据执行保护这个笼统称呼出现在系统设置里。它的作用是校验异常处理链表的合法性防止攻击者通过覆盖栈上的异常注册记录来劫持控制流。这层检查是纯软件实现的不依赖处理器特性所以在没有 NX 的老机器上也能生效。需要注意的是这层检查只覆盖 32 位程序而且要求编译时带上了相应选项。如果你自己编译的程序没有开启它系统设置里勾了也白勾——这一点在排查时会让人非常困惑。3.3 四种系统策略覆盖范围完全不同系统级的 DEP 策略有四种理解它们的差别是后面所有操作的基础策略名覆盖范围能否单独排除某个程序典型使用场景OptIn仅对显式声明需要 DEP 的程序启用可以多数桌面版本的默认值OptOut对所有程序启用但允许手工加入例外可以需要统一开启又要留后门AlwaysOn对所有进程强制启用不可以服务器、安全要求高的环境AlwaysOff全部关闭不适用仅用于临时排查这里有个关键点OptIn 是默认不开。也就是说一个没有在可执行文件里声明自己兼容 DEP 的老程序在 OptIn 策略下是压根不受 DEP 约束的。这解释了为什么有些人的机器上老程序跑得好好的换台机器就崩——两台机器的策略不一样。4. 系统级策略怎么改改完为什么常常没反应改 DEP 设置有两条路径图形界面和命令行。两条路径改的是同一个东西但命令行那条能看到更多信息也更适合批量处理。4.1 图形界面那条路径在系统属性里依次进入高级、性能设置、数据执行保护标签页会看到两个单选项一个是仅对基本程序和服务启用一个是对除下列选定程序之外的所有程序启用。前者对应 OptIn后者对应 OptOut。选中第二项之后下面的列表框就可以添加例外程序。这个界面有个坑它只能往列表里加不能从系统层面彻底关闭硬件 DEP。很多人以为选了第一项就是关了 DEP其实只是把策略从 OptOut 切回了 OptIn那些明确声明了兼容 DEP 的程序照样受保护。真正的关闭要走命令行。4.2 启动配置里的 nx 参数系统启动配置数据里有一项专门控制这个命令大致长这样# 查看当前设置 bcdedit /enum {current} # 切换到指定策略四选一 bcdedit /set nx OptIn bcdedit /set nx OptOut bcdedit /set nx AlwaysOn bcdedit /set nx AlwaysOff改完之后必须重启才生效这一点没有例外。执行前建议先备份启动配置虽然这个参数本身风险不高但手滑改错项目的事情我见过不止一次。一个实操细节在某些系统上执行bcdedit /set nx AlwaysOff会返回参数不正确或者看似成功但重启后设置没变。原因见下一节。4.3 64 位系统上那个例外中的例外这是最容易让人抓狂的一点在 64 位系统上64 位进程的 DEP 是强制开启的无法通过任何系统级配置关掉。你把 nx 设成 AlwaysOff只对 32 位进程有效64 位进程照样受硬件保护。这个设计的逻辑很直白64 位进程有完整的页表空间和 NX 位支持没有任何兼容性理由去关闭它。而那些真正需要关闭 DEP 才能跑的老程序几乎清一色是 32 位的。所以实践中你会看到这样的现象一个 32 位老程序在 32 位系统上改了设置能跑在 64 位系统上同样改了设置也能跑但如果是 64 位程序崩了改设置是徒劳的只能从程序本身下手。注意把 AlwaysOff 当作万能解药是典型的误判。它既降低整机安全性又对 64 位进程无效还可能掩盖程序本身的真实缺陷。5. 单个进程的 DEP编译期开关与运行期开关系统级策略是面单个进程是点。做开发的更应该关心后者因为你能控制的其实是自己这个进程的行为而不是用户机器上的全局设置。5.1 链接器选项在文件里留下了什么在 Windows 平台上链接器有一个/NXCOMPAT选项作用和它的名字一样就是告诉系统这个程序兼容 DEP。它做的事情非常具体在 PE 文件头部的可选头里把一个叫DllCharacteristics的字段中的0x0100位置 1这个位对应的常量名是IMAGE_DLLCHARACTERISTICS_NX_COMPAT。验证方式很简单用dumpbin /headers看输出找 NX compatible 那一行dumpbin /headers your_app.exe | findstr /i NX compatible如果显示 NX compatible说明这个程序声明了自己要 DEP如果没这一行说明没声明。注意没声明不等于不受保护——在 OptOut 或 AlwaysOn 策略下系统照样会给它开 DEP只是那时候它自己没准备好崩溃就来了。从某个版本的开发工具开始这个选项对 32 位和 64 位目标默认是开启的。所以如果你用的是比较新的工具链程序崩溃多半不是没开 NXCOMPAT导致的而是代码里确实存在动态执行的需求。5.2 运行期开关的适用范围有多窄有个 API 叫SetProcessDEPPolicy看着像是程序自己随时开关 DEP的万能钥匙实际限制极多只对 32 位进程有效。64 位进程调用会失败。必须在进程启动早期调用而且只能调用一次。之后再调会返回失败。如果系统策略是 AlwaysOn调用会被拒绝返回访问被拒的错误。如果系统策略是 OptIn 且进程已在 PE 头里声明了 NXCOMPAT调用同样受限。用法大概是这样#include windows.h int main() { // 尝试为当前进程显式启用 DEP if (!SetProcessDEPPolicy(PROCESS_DEP_ENABLE)) { DWORD err GetLastError(); // 常见失败原因系统策略为 AlwaysOn、已调用过、或进程为 64 位 printf(SetProcessDEPPolicy failed: %lu\n, err); } // ... 后续逻辑 }反过来如果确实有一个老程序必须关掉 DEP 才能跑也可以在它自己的启动代码里请求关闭。但我要说清楚这是兜底方案不是首选方案。首选永远是把那段动态代码改成不需要执行的形式。5.3 动态生成代码场景下的正确姿势真正会撞上 DEP 的绝大多数是这类场景程序在运行时生成机器码然后跳过去执行。典型的包括脚本引擎、表达式求值器、某些加壳工具、以及一些老的音视频编解码实现。这些代码在生成阶段必须可写在执行阶段必须可执行这两件事本身是矛盾的——同时可写又可执行的内存页正是攻击者最喜欢的目标。正确做法是遵守W^X原则写和执行分开不要同时具备。具体到 Windows 平台有两种常见实现。第一种比较简单用VirtualAlloc直接申请可执行内存或者用VirtualProtect把已有内存临时改成可执行// 方式一生成阶段可读写执行前切换为可读可执行 DWORD oldProtect 0; VirtualProtect(codeBuf, codeSize, PAGE_EXECUTE_READ, oldProtect); // 调用生成好的代码 typedef void (*FnPtr)(); ((FnPtr)codeBuf)(); // 用完切回不可执行避免留下可执行内存块 VirtualProtect(codeBuf, codeSize, PAGE_READWRITE, oldProtect);第二种严格一些做双映射用两个虚拟地址映射同一块物理内存一个映射只读可执行一个映射只写不可读生成代码时通过写映射操作执行时通过执行映射跳转。这样在任何时刻同一个地址上都不会同时具备写和执行权限。实现起来要调用底层的内存映射接口代码量多一些但对安全性的提升是实打实的。还有一个细节经常被忘掉改完代码之后要调用FlushInstructionCache。在 x86 平台上因为缓存一致性做得比较好不调大多也能跑但在 ARM 一类平台上不刷新指令缓存就会出现明明写进去了却执行的还是旧指令的诡异现象。这个坑我在跨平台项目里踩过一次现象是函数返回结果随机不对查了两个下午才发现是这个原因。6. 程序被 DEP 拦下之后的完整排查链路这部分讲的是排查思路我按实际操作顺序写你可以照着走一遍。6.1 第一步确认到底是不是 DEP先看现象。程序崩溃时如果弹出应用程序错误记下异常代码和异常偏移。异常代码是 0xC0000005 就进入下一层判断。如果系统事件日志里能查到事件事件描述里往往会有故障模块和异常代码两项。接下来用调试器附加看异常记录的两个附加参数。第一个参数是访问类型第二个是出错地址。第一个参数等于 8基本可以确诊。如果是 0 或 1那说明是普通的野指针读写跟 DEP 没关系得往内存越界方向查。顺便说一句有些工具比如进程查看器一类的软件会把进程列表里的 DEP 状态直接标出来能看到每个进程当前是启用还是禁用。这个信息可以用来对照崩溃的那个进程显示的是启用而它偏偏需要执行动态代码那嫌疑就很明确了。6.2 第二步定位到具体是哪条指令在跳确诊之后再往下走需要拿到崩溃点的调用栈。用调试器加载崩溃转储或者直接附加进程执行一次自动分析重点看两样东西出错的指令地址落在哪个模块的哪个区间以及调用栈上最近一次跳转是从哪里来的。如果出错地址落在一块看起来不像任何模块的内存区域比如堆地址、栈地址附近那基本可以确定是跳到动态生成的内存里执行这条路出了问题。这时候要往上追找出是谁在往那块内存里写东西。常见的源头是运行时的代码生成器、某个第三方组件的初始化逻辑、或者一个不小心把函数指针算错了的地方。这一步最怕的是加壳或混淆过的模块因为调用栈会被打散看不出来源。遇到这种情况可以先把可疑模块单独拿出来做最小复现用一个空工程加载它看能不能稳定复现崩溃。能复现就说明问题在这个模块内部跟主程序无关。6.3 第三步按三档方案选修复路径定位到之后修复方案大致分三档从优到劣。档次方案适用场景代价最优改掉动态执行改用解释执行或函数表分派自己写的代码需要重构可能损失部分性能次优严格按 W^X 生成代码执行前后切换页属性必须保留代码生成的场景代码改动适中性能影响小兜底对单进程关闭 DEP或加入系统例外列表第三方老程序、短期内无法修改降低安全性且对 64 位进程无效我要特别强调第一档。很多人一说程序被 DEP 拦了就直接跳到最后一行其实真正的问题往往是设计上偷懒——本来可以用查表加跳转解决的分派逻辑非要生成机器码。改过来之后不仅没有 DEP 问题调试难度、可移植性、可维护性都会好很多。第二档是最常见的工程折中。要点是内存页的可写权限和执行权限不要同时存在用完立刻降权。这一点做到了即使攻击者能控制那块内存的内容也没法直接跳过去执行。第三档是不得已。而且要注意如果是 64 位程序第三档直接失效你只能回到前两档。7. 几个我踩过或者见别人踩过的坑7.1 把全局关闭 DEP当作解决方案这是流传最广的一个做法也是危害最大的。关闭全局 DEP 之后本来被挡住的一大批利用手法立刻可用整机的防护水平直接回到十几年前。更要命的是它把问题掩盖了程序里那个往数据区写代码再执行的设计缺陷还在只是暂时不崩了一旦换台机器、换个系统版本问题原样复现。我的一般建议是只在做隔离排查时临时关闭并且记着改回来。排查完立刻恢复成 OptOut 或者默认策略。如果确实有老程序跑不起来优先用单程序例外而不是关全局。7.2 32 位和 64 位进程策略不一致导致的白忙活前面提过一次这里再从排查角度说一遍。现象是同样的程序、同样的设置改动在某些机器上生效在另一些机器上完全没反应。原因就在于这两台机器上跑的那个进程位数不同——32 位进程受策略控制64 位进程不受。判断方法很简单看任务管理器里的进程列表32 位进程会标注出来。或者用命令行查一下进程的架构。这一步花不了十秒钟但能省下很多瞎试的时间。7.3 权限改回去了却忘了刷新指令缓存这个坑前面提过一句值得单独拎出来说。因为它造成的现象不是崩溃而是结果随机不正确特别难查。你生成的代码明明是对的执行出来却是旧版本或者第一次对第二次错。跨平台项目里在 x86 上测一整天都没问题一到 ARM 设备上就崩最后发现是少了那句刷新调用。我的做法是把它封装进生成并执行的工具函数里写完代码必然调一次刷新让调用方不需要记住这件事。这类易忘的收尾动作最好的处理方式就是不给它被遗忘的机会。7.4 用异常处理把崩溃吞掉有人发现程序崩在 DEP 上就在外层套一个结构化异常处理捕获 0xC0000005 然后继续往下跑。这种做法极其危险执行权限违例意味着控制流已经跑到不该去的地方了此时进程状态很可能已经不一致继续运行就是在不确定的状态上做确定的操作后续出现什么奇怪现象都不奇怪。正确的处理方式是捕获到之后记录足够的信息出错地址、调用栈、相关内存的属性和内容摘要然后干净地退出而不是假装没事继续。7.5 忽略策略变更需要重启这件事命令行改完设置很多人直接就去看程序能不能跑发现还是崩就认为这个方法没用。实际上没重启的话新的启动配置还没被加载跑的还是老策略。这类改了没生效的问题九成是漏了重启这一步。最后再分享一个我自己的习惯涉及系统级安全配置的改动我会先在一个不影响业务的机器上验证把改动前后的行为差异、需要重启几次、失败时怎么回退全都写在记录里。这些东西看着啰嗦但真出问题的时候那份记录能帮你在一分钟内回到已知良好的状态。DEP 这类开关本身不难改难的是改完之后还能清楚地知道自己在什么状态上。