Windows 崩溃转储取证(纯 Python,不依赖 WinDbg)
何时使用
- 磁盘上出现大量
.dmp,要知道是什么在崩、为什么崩 - 某个程序反复崩溃(尤其是有"看门狗"反复拉起的崩溃风暴)
- 无法安装 WinDbg / Visual Studio 的受限环境
- 需要回答"这段崩溃是确定性缺陷,还是随机内存抖动"
崩溃类问题:先做路由
"崩溃"相关的问题有几条不同的路,别混用——按真正想知道的答案分流:
| 你想知道的 | 对应做法 | 它解决什么 |
|---|---|---|
| "这个 dmp 是什么崩的 / 根因在哪" | 本文 | 解析 minidump 二进制,定位崩溃地址与函数 |
| "上次那个崩溃现在还有吗 / 修好了吗" | 时序与复现统计 | WER 崩溃归档按日统计、进程 PID 稳定性、事件日志时间窗 |
| "电脑老是蓝屏/死机/冻结,系统级排查" | 系统诊断流程 | 症状路由 → 事件日志 → 驱动更新 → 虚拟显示驱动残留 |
一句话记:打开转储找根因 → 看它还在不在发生 → 蓝屏/死机的系统级会诊。
若用户只说"电脑出问题了",先问一句"是弹了错误/生成了 dmp,还是直接蓝屏重启?"
0. 先做分钟级侦查(不急着解析 dump)
import glob, os, datetime
# 按实际堆 dump 的位置改这里的搜索根目录(常见:各盘根目录、%LOCALAPPDATA%\CrashDumps)
DMP_DIR = r'D:\'
fs = sorted(glob.glob(os.path.join(DMP_DIR, '*.dmp')), key=os.path.getmtime)
print(len(fs), sum(os.path.getsize(f) for f in fs) / 1048576, 'MB')
print(datetime.datetime.fromtimestamp(os.path.getmtime(fs[0])),
datetime.datetime.fromtimestamp(os.path.getmtime(fs[-1])))
# 崩溃速率 —— 判断是"偶发"还是"风暴"
span = (os.path.getmtime(fs[-1]) - os.path.getmtime(fs[0])) / 60
print('%.1f 次/分钟' % (len(fs) / span))
文件名本身就是情报:<进程名>-20260917-224610-244.dmp = 进程名 + 年月日 + 时分秒 + PID(或 TID)。
一眼能看出是哪个进程、崩得多密。
1. MINIDUMP 结构(八种常用流)
import struct
d = open(path, 'rb').read()
sig, ver, nstreams, dirrva = struct.unpack('<IIII', d[:16]) # sig 必须是 0x504D444D 'MDMP'
st = {}
for i in range(nstreams):
t, sz, loc = struct.unpack('<III', d[dirrva + i*12: dirrva + i*12 + 12])
st[t] = (sz, loc)
| 流号 | 名称 | 用途 |
|---|---|---|
| 3 | ThreadList | 线程清单;每项 48 字节 |
| 4 | ModuleList | 已加载模块;每项 108 字节 |
| 5 | MemoryList | 内存段(拿代码/栈字节的唯一来源) |
| 6 | Exception | 异常记录 |
| 7 | SystemInfo | CPU 架构 |
| 9 | Memory64List | 大 dump 的内存段 |
| 15 | MiscInfo | 杂项(偏移不稳,别依赖) |
2. ⚠️ 最容易踩的坑:MINIDUMP_STRING 的前置长度
ModuleNameRva 指向的是一个 MINIDUMP_STRING,不是裸字符串:
struct MINIDUMP_STRING { ULONG32 Length; WCHAR Buffer[]; }
直接在该 RVA 处按 UTF-16 解码,会得到 4 字节垃圾 + 少一个字符的半截名字。
(实测:\x66\x00\x00\x00E:\app\... 被解成 f E:\app\...。)正确写法:
def read_str(dd, rv):
ln = struct.unpack('<I', dd[rv:rv+4])[0] # 先读长度!
return dd[rv+4:rv+4+ln].decode('utf-16-le', 'replace')
模块表解析(已实测正确):
mods = []
loc = st[4][1]
for i in range(struct.unpack('<I', d[loc:loc+4])[0]):
m = loc + 4 + i*108 # 108 = sizeof(MINIDUMP_MODULE)
base, size, cksum, tds, nameRva = struct.unpack('<QIIII', d[m:m+24])
mods.append((base, size, read_str(d, nameRva).split('\\')[-1]))
3. 异常记录:偏移 +0x18 是崩溃地址
实测确认的布局(网上说法不一,以实际二进制为准):
+0x00 ThreadId (4) + Alignment (4)
+0x08 ExceptionCode (4) + ExceptionFlags (4)
+0x10 ExceptionRecord (8)
+0x18 ExceptionAddress (8) <== 崩溃指令地址
+0x20 NumberParameters (4) + Alignment (4)
+0x28 ExceptionInformation[n] (8×n)
l6 = st[6][1]
tid = struct.unpack('<I', d[l6:l6+4])[0]
code = struct.unpack('<I', d[l6+8:l6+12])[0]
eaddr = struct.unpack('<Q', d[l6+0x18:l6+0x20])[0]
np = struct.unpack('<I', d[l6+0x20:l6+0x24])[0]
ps = struct.unpack('<%dQ' % np, d[l6+0x28:l6+0x28+np*8])
0xC0000005 = ACCESS_VIOLATION:
- ps[0] → 0 = 读、1 = 写
- ps[1] → 出事的目标地址(读空指针/悬垂指针的证据)
- ps[2] → 出事的指令地址
判读诀窍:错误地址指向 0x000001xx... / 0x000002xx... 这类小值堆地址,
且是读失败 → 典型的 use-after-free / 悬垂指针(对象释放了,指针还在)。
若代码里已有 test rdx,rdx / jz(判空)却仍崩,说明作者知道可能为空、但没防"已释放"。
4. 从 MemoryList 取回代码页,手工反汇编
minidump 默认不包含代码段(除非是 full dump),但能读出崩溃指令附近的字节——
因为它们往往被当作栈/数据页一并保存了。稳健实现:
segs = []
loc = st[5][1]
for i in range(struct.unpack('<I', d[loc:loc+4])[0]):
e = loc + 4 + i*16 # 每项 16 字节
sa, sd, sdata = struct.unpack('<QII', d[e:e+16])
segs.append((sa, sd, sdata)) # 起始地址 / 大小 / 数据文件偏移
def readmem(addr, ln):
out, a = b'', addr
for sa, sd, foff in segs:
if sa <= a < sa + sd:
take = min(ln - len(out), sa + sd - a)
out += d[foff + (a - sa): foff + (a - sa) + take]
a += take
if len(out) >= ln:
return out
return out if len(out) == ln else None
拿到字节后,可用 capstone(若有)或手工对照 x64 指令表判读;
经验上只看首字节 + ModRM就足以识别 mov / call / lock cmpxchg / test / jz 这几类关键指令。
5. 用 .pdata 反查崩溃属于哪个函数
.pdata 是 x64 异常处理表,每项 12 字节,可直接定位函数边界:
# 先解析 PE 节区表,取出 .pdata 的虚拟地址 / 大小 / 文件偏移
pv, pvsz, pr = pdata_vaddr, pdata_vsize, pdata_roff
for i in range(pvsz // 12):
b, en, uw = struct.unpack('<III', d[pr + i*12: pr + i*12 + 12])
if b <= crash_offset < en:
print('落在函数 0x%X ~ 0x%X(%d 字节)' % (b, en, en - b))
价值:多个 dump 的崩溃偏移总是落在同 1~2 个函数里 → 证明是确定性逻辑缺陷,
而非随机内存抖动。这是"厂商代码问题、外部改不了"的硬证据。
6. 判定"崩溃风暴"的驱动机制
关键手法:读进程创建时间,看谁在反复重生。
import ctypes, datetime
from ctypes import wintypes
k32 = ctypes.WinDLL('kernel32', use_last_error=True)
k32.OpenProcess.restype = ctypes.c_void_p
def create_time(pid):
h = k32.OpenProcess(0x1000, False, pid) # PROCESS_QUERY_LIMITED_INFORMATION
c = wintypes.FILETIME()
k32.GetProcessTimes(ctypes.c_void_p(h), ctypes.byref(c),
ctypes.byref(c), ctypes.byref(c), ctypes.byref(c))
k32.CloseHandle(ctypes.c_void_p(h))
v = (c.dwHighDateTime << 32) | c.dwLowDateTime
return datetime.datetime.fromtimestamp(v / 1e7 - 11644473600)
注意区分两种形态:
- 主服务 PID 长期不变、但短命子进程不断新增 → 看门狗在拉起崩溃的子任务
- 主服务 PID 频繁变化 → 真正的主进程重启循环
7. 止血方法(按代价从低到高)
| 手段 | 说明 |
|---|---|
| 终止并让看门狗重拉 | 拿到 PROCESS_TERMINATE 句柄即可,通常不需要管理员。能立刻止血 |
| 重跑厂商安装器的修复开关 | 例如 xxx-installer.exe --install-tsf --version-dir <dir>。注意参数风格,单横线/双横线各家不同,报 "no action specified" 说明用错了 |
| 清空进程状态目录 | 慎用,会丢用户数据;先备份 dict / conf 等用户资产 |
| 等厂商更新 | 若已是最新版,只能反馈 |
⚠️ 重要教训:改配置文件不会影响已在运行的进程
实测踩过:改了 config.json 后崩溃速率毫无变化——
因为主进程启动后配置早已读进内存,它是永不重启的长驻服务,文件改动无从生效。
必须先让进程真正重启(换 PID),改动才生效。
8. 环境限制(受限/沙箱环境)
- 部分环境中
sc.exe/wmic.exe/reg.exe/cscript/mshta可能被策略拦截
→ 改用标准库winreg、ctypes、subprocess.run(['tasklist', ...]) - 在循环里反复调
tasklist会明显卡顿 → 改为一次性抓全量,再在 Python 里过滤 - Python 3.12+ 的 f-string 里别嵌反斜杠;拼路径用
os.sep或chr(92)
9. 别被这些带偏
- ❌ 用
strings/ 正则去 dump 里找错误信息 —— 日志通常是压缩的,找不到明文 - ❌ 只信
MiscInfo的字段偏移 —— 它版本间会变,读出来是杂讯 - ❌ 把 dump 文件名里的数字当成 PID 直接下结论 —— 可能是 TID,需交叉验证
- ❌ 认为"改了配置就好了" —— 见 §7 的教训
10. 输出一份可交付的诊断结论
进程:<ImageName> 版本:<x.y.z.w>
崩溃时间:<起> ~ <止> 崩溃次数:<N> 速率:<n> 次/分钟
异常码:0xC0000005 (ACCESS_VIOLATION, 读)
目标地址:0x000001XXXXXXXX(小值堆地址 → 悬垂指针特征)
崩溃偏移:0x977133(75%)、0x92CE09(25%)—— 集中在 2 个固定位置
归属函数:0x...~0x...(.pdata 反查)
结论:确定性逻辑缺陷,位于厂商模块 <name>,本地无法修复
止血:TerminateProcess → 看门狗换新 PID → 崩溃率 <a> → <b> 次/分钟