Nuitka --onefile 打包的 Windows Defender 误报排查过程
缘起
最近在 Chestnut Studio 这个 PySide6 项目中添加了启动页(Splash Screen)功能。v2.3.1 版本的打包一直正常工作,但 v2.4.0 的 Nuitka —onefile 打包完成后,运行 exe 时 Windows Defender 弹出告警,提示 “Trojan:Win32/Wacatac.B!ml” 并将文件自动隔离。
第一反应是怀疑撞上了误报,但 v2.3.1 就没问题,v2.4.0 才有。问题一定出在这次的变更中。
第一步:列出所有变更
对照 git log,从 v2.3.1 到 v2.4.0 的所有变更:
04fdc76 perf: 启动页优化 — PNG→JPEG(Q85) 118KB→35KB7a3c54f fix: 关于对话框恢复图标,GitHub 链接改为纯文本 URL11267c8 test: 补充 track_config/theme/update_checker/resources 测试f7fb398 refactor: 移除 _show_about 方法内重复的模块导入4c5ff9e chore: 清理测试文件中未使用的 importf153772 chore: 移除冗余 dev 依赖(pyinstaller/imageio/pillow)25ce8ac feat: 添加启动页面(QSplashScreen)93392e4 feat: 关于对话框添加可点击的 GitHub 链接和打包直接相关的变更只有启动页功能。v2.3.1 没有启动页,v2.4.0 新增了。
第二步:猜测与验证
启动页涉及两个变化:一是代码逻辑(QSplashScreen + QPixmap),二是一张嵌入的图片文件。
猜测 1:Nuitka 版本或缓存问题
Nuitka 的 bootstrap 二进制文件(由 zig 编译)偶尔会被 Defender 误报。如果是这个问题,重建一次换一个缓存版本可能就好了。
# 清空 Nuitka 缓存后重建rm -rf ~/AppData/Local/Nuitkauv run python scripts/build_release.py结果:Defender 仍然报毒。排除这个方向。
猜测 2:代码逻辑本身触发扫描
还原 main.py 中的启动页代码为 v2.3.1 的状态,但保留资源文件目录中的 splash.jpg:
# 注释掉启动页创建,直接闪避# splash = QSplashScreen(...)然后重建,运行。Defender 不报了。这说明问题出在 QSplashScreen 的初始化过程上,而不是 Nuitka 的 bootstrap。
但问题是——QSplashScreen 是 PySide6 标准组件,几万个项目在用,不可能它本身有问题。更可能的是初始化时加载的那个文件。
猜测 3:图片文件触发了扫描
把 splash.jpg 从资源目录移出去重建:
# 只移出 splash.jpg,代码不动mv chestnut_studio/resources/splash.jpg /tmp/uv run python scripts/build_release.pyDefender 正常。把文件放回去重建,又触发了。锁定问题:这张图片本身。
第三步:二分法精确定位
确定了是图片文件的问题后,开始缩小范围。图片的变更有两个属性:格式和内容。
测试 A:换一张完全不同内容的图片
把 splash.jpg 替换为另一张从网上下载的 JPEG 图片(风景照),重建。
结果:Defender 仍然报毒。排除”图片内容”这个变量——不是这张图本身有什么特殊。
测试 B:同一张图,不同格式
这里有个关键线索。最初的 splash.png 是 118KB 的 PNG 文件。为了减小体积,我用 Pillow 把它转成了 JPEG(Q85,35KB)。那 PNG 版本会触发 Defender 吗?
把图片存回 PNG 格式(splash.png,118KB),重建。
结果:Defender 不报毒。
再用同一张图分别测试:
| 格式 | 文件大小 | Defender |
|---|---|---|
| PNG | 118 KB | ✅ 正常 |
| JPEG Q85 | 35 KB | ❌ 报毒 |
| JPEG Q75 | 28 KB | ❌ 报毒 |
| WebP(如果 Qt 支持) | ~30 KB | —— |
明确了:JPEG 格式本身触发了 Defender 的启发式扫描。
第四步:原因分析
恶意软件常用 JPEG 隐写术(Steganography)将 payload 隐藏在图像 DCT 系数中,这种技术在安全社区有大量公开的 PoC。Windows Defender 的机器学习模型对 exe 中嵌入的 JPEG 数据特征比较敏感——哪怕这张图本身是正常的风景照,嵌入到 exe 里之后,字节级别的统计特征也可能被模型判定为可疑。
PNG 使用的 Deflate 压缩算法在恶意软件中的使用频率远低于 JPEG 隐写,因此不在同一检测规则覆盖范围内。
这也解释了为什么 v2.3.1 没问题而 v2.4.0 有问题:v2.3.1 没有嵌入任何图片,v2.4.0 嵌入了 JPEG。
解决方案评估
确认根因后,有三个可行的方向。
方案 A:换回 PNG(已实施)
直接把 JPEG 换回 PNG,文件从 35KB 回到 118KB,代价是 83KB 的体积增加。对最终 exe 的体积影响不大(33.2 MB → 33.2 MB,差不到 0.3%)。
splash_path = get_resource_path("splash.png")splash = QSplashScreen(QPixmap(str(splash_path)))实施后打包,Defender 正常。
方案 B:程序化绘制(零文件嵌入)
完全不依赖外部图片,用 QPainter 在内存中绘制启动页:
px = QPixmap(680, 380)px.fill(QColor("#1a1a2e"))painter = QPainter(px)# 画图标、标题、版本号painter.end()splash = QSplashScreen(px)这个方案不需要在 exe 中嵌入任何图片文件,完全避开了文件扫描规则。启动速度也最快——省去了图片解码的 8-12ms。缺点是失去使用自定义背景图的灵活性。
实测打包体积从 33.2 MB 降到了 33.1 MB(少了 118KB 的图片嵌入)。
方案 C:代码签名证书
购买数字签名证书对 exe 签名可以让 Defender 完全信任该文件。对于开源项目来说,每年 $100-300 的成本可能不划算,但这是最彻底的解决方案——不仅解决误报,还能消除 Defender 的”未知发布者”警告。
总结
| 方案 | 额外体积 | Defender | 启动速度 | 自定义图 | 成本 |
|---|---|---|---|---|---|
| PNG 嵌入 | +118 KB | 正常 | 中等(~8ms 解码) | ✅ | 免费 |
| 程序化绘制 | 0 | 正常 | 最快(~0ms) | ❌ | 免费 |
| 代码签名 | 0 | 正常 | 不变 | 任意 | ~$100/年 |
最终选择换回 PNG,因为 118KB 的体积代价可以接受,且保留了自定义背景图的灵活性。如果未来需要进一步精简打包体积,可以切换到程序化绘制方案。
附:快速自查清单
如果你的 Nuitka —onefile 打包也遇到了 Defender 误报,可以按以下顺序排查:
- 重建一次(排除 Nuitka 缓存污染)
- 注释掉最近添加的代码(排除代码逻辑)
- 移除最近添加的资源文件(排除文件嵌入)
- 检查文件格式:尝试把 JPEG 换成 PNG
- 检查是否有打包的 PDF/docx 等富格式文件(这些也常被用于隐写)
- 以上都不行 → 提交 Defender 误报申诉,或考虑代码签名