把一个 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.py
  • OnefileBootstrap.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 自定义桌面窗口的轻量底座。小体积、原生桥接和漂亮界面,并不一定互相冲突。