← 返回文章列表

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→35KB
7a3c54f fix: 关于对话框恢复图标,GitHub 链接改为纯文本 URL
11267c8 test: 补充 track_config/theme/update_checker/resources 测试
f7fb398 refactor: 移除 _show_about 方法内重复的模块导入
4c5ff9e chore: 清理测试文件中未使用的 import
f153772 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 误报。如果是这个问题,重建一次换一个缓存版本可能就好了。

Terminal window
# 清空 Nuitka 缓存后重建
rm -rf ~/AppData/Local/Nuitka
uv 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 从资源目录移出去重建:

Terminal window
# 只移出 splash.jpg,代码不动
mv chestnut_studio/resources/splash.jpg /tmp/
uv run python scripts/build_release.py

Defender 正常。把文件放回去重建,又触发了。锁定问题:这张图片本身

第三步:二分法精确定位

确定了是图片文件的问题后,开始缩小范围。图片的变更有两个属性:格式和内容。

测试 A:换一张完全不同内容的图片

把 splash.jpg 替换为另一张从网上下载的 JPEG 图片(风景照),重建。

结果:Defender 仍然报毒。排除”图片内容”这个变量——不是这张图本身有什么特殊。

测试 B:同一张图,不同格式

这里有个关键线索。最初的 splash.png 是 118KB 的 PNG 文件。为了减小体积,我用 Pillow 把它转成了 JPEG(Q85,35KB)。那 PNG 版本会触发 Defender 吗?

把图片存回 PNG 格式(splash.png,118KB),重建。

结果:Defender 不报毒

再用同一张图分别测试:

格式文件大小Defender
PNG118 KB✅ 正常
JPEG Q8535 KB❌ 报毒
JPEG Q7528 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%)。

main.py
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 误报,可以按以下顺序排查:

  1. 重建一次(排除 Nuitka 缓存污染)
  2. 注释掉最近添加的代码(排除代码逻辑)
  3. 移除最近添加的资源文件(排除文件嵌入)
  4. 检查文件格式:尝试把 JPEG 换成 PNG
  5. 检查是否有打包的 PDF/docx 等富格式文件(这些也常被用于隐写)
  6. 以上都不行 → 提交 Defender 误报申诉,或考虑代码签名