把一个 Python 桌面程序做得小、启动快、界面好看,已经不容易;如果打包后还被 360 和 VirusTotal 上的一些引擎报毒,体验就会更糟糕。
最近我在一个 Spore 太空阶段修改器上完整经历了一遍这个过程。最终方案使用 Nuitka onefile、MSVC LTO、pyminiwebview、自定义载荷标识,以及带证书表的主程序 DLL。成品约 3.5 MB,本机测试窗口可以在 1 秒内出现,360 不再误报,VirusTotal 上外层 EXE 的报毒数量从 6 个降到 4 个。
更重要的是,界面不是灰扑扑的传统工具窗口:HTML/CSS/JS 负责渐变、卡片、自定义标题栏和动态效果,底层仍是真实的 Windows WebView2。
先说明边界:本文记录的是具体样本上的误报实验,不是通用“免杀”保证。复制证书得到的签名状态是
HashMismatch,并不是真正有效的数字签名。正式发布仍应使用自己合法持有的代码签名证书,并向安全厂商提交误报。
最终结果
最终文件:
SporeTrainer.exe
大小:3,602,944 字节
本次样本的变化:
| 检查对象 | 修改前 | 修改后 |
|---|---|---|
| 主程序 DLL 的 VirusTotal 报毒 | 12 个引擎 | 4 个引擎 |
| 外层 onefile EXE 的 VirusTotal 报毒 | 6 个引擎 | 4 个引擎 |
| 360 对最终组合样本 | 会报毒、释放 DLL 时会被查杀 | 不再误报,程序正常运行 |
最终程序可以从独立目录启动,Spore 修改器窗口正常显示并正常退出。运行后释放的 spore_trainer_web.dll 与打包前保存的证书版参考文件哈希完全一致:
大小:3,492,584 字节
证书主体:Python Software Foundation
签名状态:HashMismatch
为什么使用 pyminiwebview
这个程序的界面部分是围绕 pyminiwebview 搭建的。它的定位不是复制完整 pywebview,而是做一个面向 Windows 自定义宿主窗口的精简 WebView2 原生桥接层。
在这类工具上,它有几个很实际的优势:
- 不需要打包 Qt、Tkinter 或一整套大型 GUI 框架;
- 使用系统已有的 WebView2 Runtime;
- 原生扩展负责 WebView2、JS 与 Python 桥接、UI 线程投递和 HWND 嵌入;
- Python 侧通过
ctypes就能处理无边框窗口、圆角、拖动、最小化和关闭; - 页面直接由 HTML/CSS/JS 编写,现代化界面不需要引入额外前端运行时。
最终 onefile 约 3.5 MB,窗口出现时间在本机可以达到 1 秒以内。对于带真实浏览器界面的 Python 桌面程序,这个体积相当少见。这里不是宣称它解决了 WebView2 冷启动的所有开销,而是说明:精简桥接层、按需裁掉未使用模块,再配合 Nuitka,确实可以做出很小、很快而且界面不简陋的 Windows 工具。
技术方法一:给 Nuitka 载荷换一个标识
Nuitka 的 onefile 启动器会从 C 源码编译。压缩载荷通常以 KAY 开头,其中 KA 是识别标识,Y 表示使用压缩。
这次没有重写释放器,也没有修改 Zstandard 数据,只做了一件很简单的事:把载荷标识改成 SPY。
修改打包后的载荷头
构建插件在 Nuitka 正常完成压缩后,修改载荷的前两个字节:
_original_compressor(**kwargs)
payload = bytearray(payload_filename.read_bytes())
if payload[:3] != b"KAY":
raise RuntimeError("Unexpected Nuitka onefile payload header")
payload[:2] = b"SP"
payload_filename.write_bytes(payload)
这里调用的仍是 Nuitka 原来的压缩器。改变的只是识别标识,压缩数据本身完全不变。
让壳接受 SPY
在 OnefileBootstrap.c 中,原来的检查是:
if (header[0] != 'K' || header[1] != 'A') {
fatalErrorHeaderAttachedData();
}
修改为:
if (header[0] != 'S' || header[1] != 'P') {
fatalErrorHeaderAttachedData();
}
第三个字节 Y 的含义不变,后续仍按原来的 Zstandard 格式解压。因此,更准确地说,这不是一种新的压缩格式,而是:
Nuitka 官方壳 + 自定义载荷魔数。
壳里还加入了应用标识,并将错误信息输出到标准错误:
static char const spore_bootstrap_name[] =
"SporeTrainer custom onefile bootstrap";
static void fatalError(char const *message) {
fprintf(stderr, "%s: %s\n", spore_bootstrap_name, message);
exit(2);
}
释放目录也改成了应用自己的名称:
%TEMP%\SporeTrainer_{PID}_{TIME_US}_{RANDOM}
它通过构建参数设置,不需要修改释放逻辑:
--onefile-tempdir-spec={TEMP}/SporeTrainer_{PID}_{TIME_US}_{RANDOM}
技术方法二:在压缩前给主 DLL 附加证书
单独扫描主程序 DLL 时,附加 python3.dll 的证书后,VirusTotal 报毒从 12 个降到了 4 个。更直观的是,360 不再在 onefile 释放 DLL 时直接查杀它。
因此,这次不是事后再修改临时目录里的文件,而是在 onefile 压缩之前处理 DLL,确保打包进去的本来就是证书版。
读取并复制 PE 证书表
Windows PE 文件的证书位于安全数据目录。下面的辅助函数用于找到证书表,并把供体文件的 WIN_CERTIFICATE 附加到目标文件:
import struct
def cert_entry(data):
pe = struct.unpack_from("<I", data, 0x3C)[0]
optional = pe + 24
magic = struct.unpack_from("<H", data, optional)[0]
# PE32+ 与 PE32 的数据目录起点不同;第 5 项是证书表。
return optional + (112 if magic == 0x20B else 96) + 4 * 8
def copy_certificate(donor, target):
donor_data = donor.read_bytes()
donor_entry = cert_entry(donor_data)
cert_offset, cert_size = struct.unpack_from(
"<II", donor_data, donor_entry
)
cert = donor_data[cert_offset:cert_offset + cert_size]
target_data = target.read_bytes()
result = bytearray(target_data)
struct.pack_into(
"<II", result, cert_entry(result), len(target_data), len(cert)
)
result.extend(cert)
target.write_bytes(result)
这与 SigThief 的核心思路相同。Windows 会识别出证书主体,但由于文件内容已经改变,Get-AuthenticodeSignature 会报告 HashMismatch。它不是 Python Software Foundation 为我们的程序背书,也不是合法发布意义上的有效签名。
在 onefile 压缩前执行
Nuitka 用户插件可以包装 runOnefileCompressor。关键流程如下:
from pathlib import Path
import shutil
from nuitka.freezer import Onefile
_original_compressor = Onefile.runOnefileCompressor
def run_signed_compressor(**kwargs):
dist_dir = Path(kwargs["dist_dir"])
payload_filename = Path(kwargs["onefile_output_filename"])
main_dll = dist_dir / "spore_trainer_web.dll"
copy_certificate(python3_dll, main_dll)
shutil.copyfile(main_dll, "spore_trainer_web-python3-sig.dll")
_original_compressor(**kwargs)
payload = bytearray(payload_filename.read_bytes())
if payload[:3] != b"KAY":
raise RuntimeError("Unexpected payload header")
payload[:2] = b"SP"
payload_filename.write_bytes(payload)
Onefile.runOnefileCompressor = run_signed_compressor
插件的 onGeneratedSourceCode(onefile=True) 钩子再把项目里的 OnefileBootstrap.c 复制到 onefile 构建目录。这样,自定义壳、证书 DLL 和 SPY 载荷就进入了同一个可运行的 EXE。
完整的本地实现见:
signed_payload_plugin.pyOnefileBootstrap.c
MSVC + LTO 构建
最终版本使用 MSVC 14.3,并显式启用 LTO:
python -m nuitka ^
--mode=onefile ^
--user-plugin=spore/experiments/msvc-signed-custom/signed_payload_plugin.py ^
--msvc=14.3 ^
--lto=yes ^
--python-flag=no_docstrings ^
--python-flag=no_asserts ^
--windows-console-mode=disable ^
--windows-icon-from-ico=spore/spore.ico ^
--onefile-tempdir-spec="{TEMP}/SporeTrainer_{PID}_{TIME_US}_{RANDOM}" ^
--include-module=pyminiwebview ^
--output-dir=spore/experiments/msvc-signed-custom ^
--output-filename=SporeTrainer.exe ^
spore/spore_trainer_web.py
实际构建还使用了标准库和未使用模块的排除清单。体积优化应建立在真实启动测试之上,不能为了删模块而破坏程序功能。
如何确认 DLL 确实被签入并释放
我没有只看构建目录,而是实际运行最终 EXE,然后检查 %TEMP%\SporeTrainer_* 中释放出来的文件:
Get-ChildItem "$env:TEMP\SporeTrainer_*\spore_trainer_web.dll"
接着比较其大小、SHA-256 和证书状态。释放文件与压缩前保存的参考 DLL 完全一致,程序窗口正常出现并正常退出,返回码为 0。
这一步很重要:单独扫描旁边的证书 DLL,与证明 onefile 内部携带并能正确释放同一个 DLL,是两回事。
从对照实验中学到了什么
排查过程中还做过几组没有功能价值的实验样本:
- 自定义少量壳文本:报毒基本不变;
- 破坏载荷头、让程序无法释放:报毒不变;
- 保留外壳并清零所有压缩内容:报毒不变;
- 真正移除整个载荷区:外层报毒从 6 个降到 3 个;
- 给外层 EXE 附加同样的证书:报毒没有下降,反而增加;
- 给主 DLL 附加证书:DLL 报毒从 12 个降到 4 个。
这些结果说明,误报不是简单的“程序会释放文件,所以有毒”。很多结果来自静态检测,而且外层 EXE 与释放 DLL 面对的规则并不相同。证书对 DLL 有帮助,不代表对外层 EXE 也有帮助;自定义载荷标识有效,也不意味着所有 Nuitka 程序都能复制同样结果。
写在最后
这次最有价值的收获,不是某个神秘参数,而是把问题拆成了可验证的对照实验:壳、载荷、释放行为、DLL 和证书分别测试,最后再组合成真正可运行的程序。
而对应用本身来说,结果同样让人满意:
- 约 3.5 MB 的单个 EXE;
- 本机 1 秒内出现窗口;
- 真实 WebView2 页面和现代化 HTML/CSS 界面;
- 无边框、圆角、自定义标题栏和 JS ↔ Python 桥接;
- 360 不再误报,VirusTotal 外层报毒由 6 个降到 4 个。
pyminiwebview 在这里展示出的潜力也很明确:它很适合继续发展成 Python 自定义桌面窗口的轻量底座。小体积、原生桥接和漂亮界面,并不一定互相冲突。