简介:面向企业终端数据防护场景下需要处理被易赛通加密文件的运维与安全研究人员,这份技术文档围绕数据泄露防护客户端的文件解密思路展开,适合具备一定逆向与Windows驱动基础、希望理解加密判定机制的读者参考。资源包仅含1个docx文档,体积约29KB,紧凑呈现主进程、守护进程与驱动模块的职责划分,并梳理按进程名和扩展名列表决定是否加解密的规律,进而讨论可落地的解密方案设计。现有2086人学习,说明该主题在终端数据防泄漏与文件恢复实践中关注度较高。文档价值在于把现象观察、模块结构与规则归纳串联起来,可帮助读者建立分析同类DLP客户端加解密行为的思路,并作为编写文件还原工具前的原理参考。
1. 先给结论:驱动层 Hook 拦在前面,应用层正面硬刚基本没戏
想在应用层直接读被加密的文档、或者从内存里抓明文的人,最后大多会绕回同一个结论:文件过滤驱动把创建、读取、写入这几个 API 全挂在自己的钩子上,加解密对上层完全透明,你看到的乱码不是文件编码坏了,而是驱动压根没给你解密。破局点不在写更狠的 hook 去对冲,而是换个问法:这个数据泄露防护客户端凭什么给某些进程解密、又不给另一些进程解密?判定条件一旦摸清,解密就从正面攻城变成借刀杀人。这篇把某 DLP 客户端的加密判定模型拆开,给出可复现的完整思路、代码骨架,以及我批量处理时翻车的那几个地方,做数据迁移、取证分析、授权环境下安全评估的人可以直接照着抄。
2. 摸清对手:三段式架构和一张二维判定表
2.1 主进程、守护进程、过滤驱动,各管一段
这类客户端不管换多少个名字,内部的职责划分惊人地一致,基本都是三段式。主进程负责图形界面、用户登录和加密策略的下发更新,一般装在固定的安装目录下,并且会区分 32 位和 64 位两个版本;守护进程专门盯着主进程,被结束就重新拉起来,顺带做一些完整性校验;真正干活的是那颗文件系统过滤驱动,挂在卷设备栈上,把上层发下来的创建、读、写请求截下来做加解密。
我第一次拆这套东西时也走过弯路,想着把服务和主进程停掉不就完了,结果服务停了、进程没了,文件拷出来照样是密文。原因很直接:驱动加载之后,加解密这条路径完全不依赖用户态那几个程序还在不在跑。用户态的三件套是管理层,驱动是执行层,管理层挂掉只是策略不再更新,执行层继续按最后一次下发的规则工作。这个认知非常关键,它决定了后面所有思路的方向。
也有人会选择内核调试加 hook 对冲,我不推荐。需要双机内核调试环境、驱动要重新签名、崩一次蓝屏重启五分钟起步,而且对方推一次更新就得从头再来一遍。更现实的做法是把驱动当黑匣子,只研究它的输入和输出,也就是什么条件下它给你解密、什么条件下它给你加密。黑匣子思路能成立的前提是:它的判定逻辑一定挂在某种稳定、可观测的边界上,否则 Office、压缩软件、各家自研系统全得挂,产品根本没法交付。
2.2 加密与否只看两张表:进程名列表与扩展名列表
长期观察下来,判定模型其实很朴素,就是两张表。第一张是进程名列表,记录了哪些进程可以享受加解密服务;第二张是进程名到扩展名的映射,规定某个进程在处理哪些后缀时才触发加解密。判定动作发生在文件创建和修改的那一瞬间,依据的是发起这次操作的进程映像名,不是文件内容、不是路径、也不是当前登录用户。
| 进程名在列表内 | 扩展名在列表内 | 写入结果 | 读取结果 |
|---|---|---|---|
| 是 | 是 | 加密落盘 | 返回解密后的明文 |
| 是 | 否 | 明文落盘 | 原样返回 |
| 否 | 是 | 明文落盘 | 原样返回 |
| 否 | 否 | 明文落盘 | 原样返回 |
为什么要用进程名而不是别的特征?站在产品兼容性角度想一下就明白了。按文件内容判断,压缩包里套文档怎么办;按路径判断,U 盘拷来拷去、网络盘映射怎么办;按用户判断,同一台机器上多账号切换怎么办。进程映像名是最稳定、最容易维护、也最容易在驱动里拿到的一个判据,代价就是它太好伪造。
这张表能推出两个直接可用的结论。读方向:只要进程名落在列表里,驱动就会主动帮我把密文解成明文交到我手上,我甚至不需要知道它用的是什么算法。写方向:只要落地时用的扩展名不在列表里,驱动就不会重新加密,明文可以安安稳稳躺在磁盘上。很多人只想到前半段,兴冲冲读出了明文,随手存成 .docx,结果又被加密回去,白忙一场。后半段才是能不能真正把文件拿走的分水岭。
2.3 用最小实验把列表边界试出来
上面那套模型是推断,落到自己环境里必须实测,因为不同版本、不同策略模板下,两张表的内容完全不一样。我一般会设计一个最小实验:准备三个可执行体,分别是系统自带的 notepad.exe、自己编译的一个小工具、以及把那个小工具复制改名成 notepad.exe 的副本;再准备三个测试文件,一个落在扩展名列表里的(比如 .txt 或 .docx),一个明显不在列表里的(比如 .abc),一个中等常见的(比如 .log)。三乘三九组,每组做一次写入和一次读回。
观察方法不用太复杂,看文件头就够了。.docx 和 .xlsx 本质是 zip,正常密文不可能以 PK 开头;.pdf 的头是 %PDF;纯文本直接肉眼扫前几十个字节就能分辨。读回的时候重点看两件事:读到的字节是不是明文,以及读完之后磁盘上那个文件有没有被重新加密。后者经常被忽略,但它决定了你的落地策略。
提示:整个实验只在授权设备上用副本做,原始文件先备份一份放只读位置,别拿唯一的业务数据去赌。
九组跑完,你会得到一张自己的判定表,把"哪些进程名被信任""每个被信任进程对应哪些扩展名"两列填出来。这张表就是后面所有自动化流程的输入。填表过程中如果发现某个扩展名介于两者之间、时灵时不灵,通常是策略分组或者文件大小阈值在起作用,先放一放,优先处理边界清晰的那批。
3. 借刀杀人:让受信任的进程替我把文件读出来
3.1 为什么改进程名比改代码更有效
驱动不认识代码,它只认识调用方是谁。内核里拿到的就是一个映像名和一条完整路径,判定逻辑拿这个去比对白名单。这意味着我在自己进程里做任何事都是无效的,哪怕我把 ReadFile 的调用链研究得再透,判定依然发生在我下面一层,对象依然是"谁发起了这次文件操作"。所以最短路径不是对抗判定,而是满足判定——让发起操作的那个进程,长成白名单里的样子。
实现上有三条路:复制改名、远程线程注入、DLL 加载。注入和加载对目标进程有要求,容易被守护进程的完整性校验盯上,稳定性也差。复制改名最土,但最稳,因为它没有修改任何别的进程,只是让操作系统认为"这次操作是 notepad.exe 发起的"。而 notepad.exe 这种系统组件几乎必然在列表里,否则用户连个 txt 都编辑不了,产品没法用。
复制改名的细节在于,映像名取的是文件名,不是完整路径。所以我完全可以把工具塞在任意目录,只要最终那个 exe 的文件名和白名单里的某项完全一致就行。这里有个前提:目标产品没有额外校验数字签名或者安装路径。实测下来多数版本只比对文件名,但这事儿不能想当然,先跑一遍最小实验确认,再决定要不要上这招。
3.2 三步流程:伪名读取、中性扩展名落地、二次改名
流程拆开就是三步,每一步都有它存在的理由,少一步就会翻车。
第一步,用伪名的进程去读已加密的源文件。此时驱动认为这是受信任进程的读取请求,会把解密后的明文交给我。注意这一步只读,不写回原路径,原始文件保持原样,出问题还能重来。
第二步,把拿到的明文写到磁盘上,但落地扩展名必须选一个不在列表里的,比如 .temp、.bak 或者自造的后缀。这一步是整个流程的核心,因为它决定了明文会不会被再加密一次。选扩展名的原则很简单:越冷门越好,最好是产品厂商根本想不到要纳入白名单的那种。
第三步,换一个进程把 .temp 改成原本该有的扩展名。为什么不在第二步直接写成目标扩展名?因为写这个动作如果在白名单进程里发生、目标扩展名又恰好在列表里,就可能触发加密,前面的功夫全废。改名这个操作通常不触发内容加解密,但为了稳妥,我习惯让改名由一个普通的、不在白名单里的进程去做,彻底避开判定。
3.3 代码骨架:伪名启动与读取落盘
先解决"怎么让自己以伪名跑起来"。思路是程序首次启动时把自己复制一份成白名单名字,再用这个副本启动自己,副本的映像名自然就是白名单里的名字。下面这段是入口部分:
import os import shutil import subprocess import sys TRUSTED_NAME = "notepad.exe" # 替换成实测得到的白名单进程名 WORK_DIR = r"C:\work\dec" # 工作目录,需提前创建 def relaunch_as_trusted(payload_path): """把自身复制成白名单文件名,再用副本启动,使映像名落在白名单内""" src = os.path.abspath(sys.argv[0]) dst = os.path.join(WORK_DIR, TRUSTED_NAME) # 判定看文件名,不看所在路径 if not os.path.exists(dst): shutil.copy2(src, dst) # 保留时间戳等元数据 # 子进程的映像名即 TRUSTED_NAME,此时文件操作才会被解密 return subprocess.run([dst, payload_path], capture_output=True) if __name__ == "__main__": if os.path.basename(sys.argv[0]).lower() != TRUSTED_NAME.lower(): proc = relaunch_as_trusted(sys.argv[1]) # 第一次进来,先变形再重跑 print(proc.stdout.decode("utf-8", "ignore")) print(proc.stderr.decode("utf-8", "ignore")) else: do_decrypt(sys.argv[1]) # 二次进入,此时已是伪名这段的关键是那个自我判断:如果当前映像名不等于白名单名字,就复制自己并重新启动;如果已经相等,说明这次是从副本里跑起来的,直接进业务逻辑。capture_output是为了把子进程的输出带回来,不然第一次启动的那个壳进程什么都看不到,容易误判成执行失败。
核心的读写部分反而简单,分块搬运就行,不要一次性 read 整个文件:
CHUNK = 1024 * 1024 # 1MB 一块,兼顾大文件和内存占用 def do_decrypt(src_path): """以当前伪名进程读取源文件,落到中性扩展名,避开再加密""" base = os.path.basename(src_path) tmp = os.path.join(WORK_DIR, base + ".temp") # .temp 需确认不在扩展名列表 with open(src_path, "rb") as fi, open(tmp, "wb") as fo: while True: buf = fi.read(CHUNK) # 读取时驱动按进程名提供解密,拿到的是明文 if not buf: break fo.write(buf) # 落盘扩展名脱离了列表,不会被再次加密 return tmpCHUNK取 1MB 是个经验值,太小了系统调用次数上去,太大了碰上几百 MB 的视频容易把内存吃满。rb和wb必须用二进制模式,文本模式在 Windows 上会把\r\n自动转换,对二进制文档来说是灾难性的破坏,而且这种破坏在大小上未必看得出来。
改名收尾单独放一个函数:
def finalize(tmp_path, out_path): """由普通进程完成最终改名,目标扩展名可以是 .docx 这类受管控后缀""" if os.path.exists(out_path): os.remove(out_path) os.replace(tmp_path, out_path) # 同卷内为原子替换,跨卷会退化为复制+删除os.replace在同一个卷内是原子的,跨卷就不保证了,会退化成复制再删除,大文件上耗时明显。这一点在后面批量处理时是要单独处理的坑。
4. 从单个 Demo 到批量流水线:遍历、映射与完整性校验
4.1 先扫目录再解密:扩展名清单怎么来
单文件跑通之后,真实场景往往是一个目录几百个文件、十几个扩展名混在一起。这时候不能见一个处理一个,得先扫一遍再决定策略。扫描阶段只做两件事:统计扩展名分布,以及记录每个文件的大小和修改时间。扩展名分布决定了我要准备多少种校验规则,大小和修改时间则用于后面判断有没有漏处理。
扫的时候有个细节容易忽略:Windows 上存在双扩展名的情况,比如报表.xlsx.temp这种中间态文件。如果上一次处理中断了,这些残留文件会被下一次扫描当成源文件重新处理一遍,结果就是数量对不上、内容还可能出现空文件。我的做法是扫描阶段就按后缀过滤掉所有.temp、.bak以及自造的中间后缀,并且在输出目录里维护一份处理清单,重跑时先读清单跳过已完成的条目。
4.2 落地扩展名的选择原则
选落地扩展名不是随手起个名字就行,它直接决定明文会不会被再加密。三条原则:一是不在扩展名列表内,这点靠最小实验确认;二是足够冷门,避免厂商下一个版本把它纳进去;三是便于自己识别和清理,别用一堆随机字符串,出问题的时候排查起来头大。
我习惯用.tmpdec这种自造后缀,一方面几乎不可能撞上任何产品的白名单,另一方面在目录里一眼能看出来哪些是中间产物。同时在输出目录建一个隐藏的清单文件,记录每个源文件对应的中间文件名,这样即使中途断了,重跑也能接上。
4.3 校验:怎么确认解出来的是真明文
最怕的情况是流程跑完了、文件也生成了、大小看着也对,打开一看还是乱码。所以每一个文件处理完都必须校验,不能靠"跑完没报错"来判断成功。校验方法按格式分,看文件头是最通用的一招:
| 扩展名 | 期望文件头 | 附加校验 |
|---|---|---|
| .docx / .xlsx / .pptx | 50 4B 03 04(zip 魔数) | 尝试用 zipfile 打开列目录 |
| 25 50 44 46(%PDF) | 尾部应出现 %%EOF | |
| .png | 89 50 4E 47 | 解析 IHDR 块宽高 |
| .jpg | FF D8 FF | 尾部应为 FF D9 |
| .txt / .log | 无固定魔数 | 解码成功率 + 可打印字符占比 |
拿 docx 来说,只要是合法的 docx,前四个字节必然是 zip 的魔数,因为整个格式就是一堆 XML 打的包。校验失败的条目不要直接删,移到failed子目录并记录原因,绝大多数情况下失败原因集中在三类:源文件本身就是坏的、进程名没对上导致读到的是密文、落地扩展名选错了导致写回时又被加密。
4.4 批量执行的代码骨架
把前面几块拼起来,批量版本大概长这样:
import os import shutil SKIP_EXT = {".temp", ".bak", ".tmpdec"} # 中间产物,扫描时直接跳过 def walk_and_decrypt(root, out_root, decrypter, finalizer): """遍历目录,逐个文件走伪名解密流程,失败项集中隔离""" failed = [] for dirpath, _, files in os.walk(root): for name in files: src = os.path.join(dirpath, name) ext = os.path.splitext(name)[1].lower() if ext in SKIP_EXT or ext == "": # 中间产物和无后缀文件不处理 continue try: tmp = decrypter(src) # 伪名进程读取,落到 .tmpdec if not check_magic(tmp, ext): # 文件头校验,确认真明文 raise ValueError("magic mismatch") rel = os.path.relpath(src, root) dst = os.path.join(out_root, rel) # 保持原始目录结构 os.makedirs(os.path.dirname(dst), exist_ok=True) # 先在本盘落好再改名,避免跨卷 replace 退化为复制 local_tmp = os.path.join(os.path.dirname(dst), os.path.basename(dst) + ".tmpdec") shutil.move(tmp, local_tmp) finalizer(local_tmp, dst) except Exception as e: failed.append((src, repr(e))) # 记录失败原因,不中断整批 return failed这段里有两处是踩过坑之后加的。一处是os.makedirs(..., exist_ok=True),保持输出目录和源目录同构,几百个文件平铺在一个目录里之后根本没法对账。另一处是先把中间文件shutil.move到目标盘,再调finalizer,这样最终的os.replace永远发生在同一个卷内,是原子的,不会出现改到一半断电留下半个文件的情况。
check_magic按上一节的表实现,返回布尔值即可。整个循环用 try 包住单个文件而不是整批,一个坏文件不该拖垮剩下几百个。
5. 避坑与排查:五种必然翻车的场景
5.1 五个踩坑记录
第一个坑:改名的解释器跑不起来。现象是把 python 解释器复制成notepad.exe后一执行就报找不到标准库。原因很简单,解释器靠自身路径推导sys.prefix,改名之后它按新名字去找同级目录,当然找不到。解决办法有两个:一是用打包工具把脚本打成独立的单文件可执行程序再改名,二是把整个解释器目录连同标准库一起复制,只改主程序名。我后来统一走打包路线,省事。
第二个坑:读出来是明文,写回去又变密文。现象是解密后的文件在没装客户端的机器上打开还是乱码。原因是落地时直接用了.docx这种受管控扩展名,写动作发生在白名单进程里,被驱动重新加密了一遍。解决就是老老实实先落中性扩展名,再用另一个不进白名单的进程改名。
第三个坑:以为扩展名是判据,其实不是。现象是改成.tmpdec之后仍然被加密。原因是某些策略模板不看扩展名,或者按内容特征判断。这种情况说明你环境里的判定模型和本文的不一致,必须回到第 2 章重做最小实验,别硬套。安全类的东西最忌讳"别人说行我就照抄"。
第四个坑:大文件吃到内存爆掉。现象是几百 MB 的视频解密到一半进程被杀,系统开始频繁换页。原因是一把read()把整个文件读进内存。改成分块读写,块大小 1MB 到 8MB 之间,按机器内存调。
第五个坑:中途中断后重跑,结果对不上。现象是第二次跑完发现输出比源文件多,还冒出一些几 KB 的空文件。原因是上一次残留的.temp文件被当成源文件又处理了一遍。解决办法是扫描阶段就把中间后缀全部剔除,并且维护一份已完成清单,重跑时按清单跳过。
5.2 失败时的排查顺序
出问题别上来就改代码,按顺序往下排,基本三步能定位。
第一步,看中间文件。也就是那个.temp/.tmpdec,用十六进制工具打开看前 16 个字节。如果这里是明文、最终文件是密文,问题一定在改名的最后一步;如果这里就是密文,问题在读的那一步,进程名没生效。
第二步,验证进程名。任务管理器里看实际跑起来的那个进程,映像名到底是不是你以为的那个。打包工具的单文件模式会在临时目录解压再启动,很容易出现你改了外层名字、实际干活的却是内层进程的情况。
第三步,回到最小实验。拿一个文件、一个小工具,重新跑一遍九组测试,确认两张表和当初是不是还一样。策略更新是会被下发的,昨天能跑今天不一定能跑,这不是玄学,是策略变更。
注意:出现大批量失败时,别急着怀疑代码,先确认客户端版本和策略有没有更新,这是最常见也最容易被忽略的原因。
6. 把「进程名白名单」当成一类通用思路
回过头看,这套东西真正有价值的地方不是某个具体的字节偏移或者某个 exe 名字,而是那个抽象的判定模型:一个安全机制,如果它必须在"兼容海量应用"和"精确管控"之间做取舍,那它最终一定会退到某个稳定、粗粒度、可被枚举的判据上。进程名白名单就是典型的一种,因为扩展名会被改、路径会被绕、内容会被套壳,只有发起操作的进程身份足够稳定,产品才能保证用户日常办公不出问题。
这个模型可以直接迁移到别的地方去识别。比如某些备份软件在读取受管控文件时同样享受解密待遇,因为产品得保证备份能正常工作;某些导出工具、某些数据库客户端,也会被单独加进白名单。遇到一类新的管控机制,我现在的第一反应不是去想怎么攻破它,而是先列一张表:它到底凭什么判断"该给谁开绿灯"。这张表列出来,后面的事情都是工程问题。
再给一个具体技巧,也是我踩坑之后养成习惯的一条:永远先处理副本,永远不碰原始文件。整条流水线里,源目录只读,所有中间产物和输出都落在另一个目录,改名的最后一步也先落到目标盘再原子替换。这样即使流程中途出错、进程被杀、策略临时更新,原始数据一根头发都不会少。真正麻烦的从来不是解不出来,而是解得出来却把源文件弄坏了——那才是没有后悔药的。
我现在每接一个类似的分析任务,都强制先跑一遍最小实验填那张九宫格表,然后再动手写一行代码。看着慢,其实是省时间的,因为八成的问题在这一步就已经暴露出来了。希望帮到你。
本文还有配套的精品资源,点击获取