排查 site-packages 里会在 Python 启动时执行的代码

受 LiteLLM 1.107.0 投毒事件启发,用 Python 标准库扫描 site-packages 中 .pth 的 import 行、sitecustomize.py 等启动钩子,并比对公开 IoC 文件名与哈希,验证能区分正常路径 .pth、合法但会执行代码的 .pth 和与 IoC 一致的文件。

已验证 Python 3.12.15 耗时 0.1s 运行于 2026-10-21 22:38 UTC
结论

3/3 样例环境分类正确

检查 3/3 通过
  • clean CRITICAL=0 (期望 0) REVIEW=0 (期望 0)
  • tooling CRITICAL=0 (期望 0) REVIEW=1 (期望 1)
  • poisoned CRITICAL=2 (期望 2) REVIEW=2 (期望 2)

以上内容从实际运行输出中提取,完整输出见下方。

python main.py
[PASS] clean     CRITICAL=0 (期望 0)  REVIEW=0 (期望 0)
[PASS] tooling   CRITICAL=0 (期望 0)  REVIEW=1 (期望 1)
           REVIEW   distutils-precedence.pth:1  启动时会执行的 import 行:import os; var = 'SETUPTOOLS_USE_DISTUTILS'; enabled = os.en
[PASS] poisoned  CRITICAL=2 (期望 2)  REVIEW=2 (期望 2)
           CRITICAL cook.pth  哈希与已知恶意文件 cook.pth 一致
           CRITICAL cook.py  文件名与 LiteLLM 投毒事件公开 IoC 一致
           REVIEW   distutils-precedence.pth:1  启动时会执行的 import 行:import os; var = 'SETUPTOOLS_USE_DISTUTILS'; enabled = os.en
           REVIEW   sitecustomize.py  site-packages 中的启动模块,每次启动解释器都会执行

当前解释器 3.12.15 的 site-packages 扫描(仅供参考):
  /tmp/exp-isjria1y/venv/lib/python3.12/site-packages: CRITICAL=0 REVIEW=0

RESULT: 3/3 样例环境分类正确

目的

据 StepSecurity 的分析,LiteLLM 1.107.0 的恶意 wheel 会向 site-packages 写入 cook.pth、cook.py 和 sitecustomize.py。Python 的 site 模块在每次启动时会执行 .pth 文件中以 import 开头的行,也会自动导入 sitecustomize,所以不需要 import litellm,只要启动解释器,窃密代码就会运行。

本实验写一个只用标准库的最小扫描器,演示如何把这类「启动钩子」找出来,并说明为什么不能简单地把所有 import 型 .pth 都当成恶意。

步骤

  1. 在临时目录中生成三个样例 site-packages: - clean:只有路径型 .pth(不执行代码) - tooling:另有一个与 setuptools distutils-precedence.pth 同类的 import 行——合法,但确实会在启动时执行 - poisoned:再加上与公开 IoC 同名的 cook.pth / cook.py 和一个 sitecustomize.py(内容均为无害占位)
  2. 扫描规则: - 与公开 IoC 文件名或 SHA-256 一致 → CRITICAL - sitecustomize.py / usercustomize.py、.pth 中的 import 行 → REVIEW(需要人工确认来源)
  3. 断言三个样例的分类数量符合预期
  4. 附加:扫描当前解释器真实的 site-packages,只打印不断言

运行:

python main.py

程序最后输出 RESULT: ...,任一样例分类错误时以非零状态退出。

预期结果

  • clean:0 条
  • tooling:0 条严重、1 条待复核(说明合法工具也会用 import 型 .pth,规则只能提示复核)
  • poisoned:2 条严重、2 条待复核

注:公开报告中 Linux/macOS 版 cook.pth 的哈希恰好与只含一行 import cook 的文件一致,所以样例中的 cook.pth 会按哈希命中——这也说明恶意 .pth 本身可以非常短,真正的载荷在被导入的模块里。

局限与可以继续尝试

  • 只看文件名、哈希和 import 行,不分析被导入模块的内容;攻击者换个文件名就能绕过 IoC 匹配,REVIEW 项仍需人工判断
  • 没有检查 *.dist-info/RECORD 中文件与包的归属关系,可以扩展为「找出不属于任何已安装包记录的启动钩子」
  • 真正的应急处置还需要:结束后台进程、轮换凭据、封禁相关域名,见原报告

内网复现

  • 无需任何第三方依赖,只用 Python 3.8+ 标准库,无需联网,可直接在内网运行
  • 把 main.py 拷贝到内网机器执行 python main.py;扫描某个虚拟环境时,用该环境的解释器运行即可
  • IoC 列表来自公开报告,如需更新需要外网查阅;也可以在内网把哈希清单维护成单独文件

代码

main.py118 行
"""扫描 site-packages 中会在 Python 启动时自动执行的代码(.pth 的 import 行、sitecustomize 等)。

背景:LiteLLM 1.107.0 的恶意 wheel 向 site-packages 写入 cook.pth / cook.py / sitecustomize.py,
无需 import litellm,每次启动解释器就会运行。本实验在临时目录中构造三个"假"环境,
验证一个只用标准库的扫描器能否区分:
  - 正常的路径型 .pth(不执行代码)
  - 常见工具也会用到的 import 型 .pth(需要人工复核,但不应报为已知恶意)
  - 与公开 IoC 文件名一致的启动钩子(应报为严重)

所有样例文件均由本脚本生成,不包含任何真实恶意代码。
"""
from __future__ import annotations

import hashlib
import site
import sys
import sysconfig
import tempfile
from dataclasses import dataclass
from pathlib import Path

# 公开报告(StepSecurity, 2026-10-21)中的文件名与部分 SHA-256(Linux/macOS 版本)
IOC_FILENAMES = {"cook.pth", "cook.py"}
IOC_SHA256 = {
    "7f191632e7320705d154b85204f1ae546b543acbd7e8861229b76f5042b92bdb": "cook.pth",
    "9dd0a3926f96a37eb86cf1ee2310d9be93139cc4ebef3ef9e2efe9531390be25": "cook.py",
    "34bef04fe88ac9d0aae0475630b1738681d15b94f233ac8aa46eacf5968305fa": "sitecustomize.py",
}
STARTUP_MODULES = {"sitecustomize.py", "usercustomize.py"}


@dataclass
class Finding:
    level: str  # CRITICAL / REVIEW
    path: str
    reason: str


def sha256(p: Path) -> str:
    return hashlib.sha256(p.read_bytes()).hexdigest()


def scan(site_dir: Path) -> list[Finding]:
    out: list[Finding] = []
    for p in sorted(site_dir.iterdir()):
        if not p.is_file():
            continue
        digest = sha256(p)
        if digest in IOC_SHA256:
            out.append(Finding("CRITICAL", p.name, f"哈希与已知恶意文件 {IOC_SHA256[digest]} 一致"))
            continue
        if p.name in IOC_FILENAMES:
            out.append(Finding("CRITICAL", p.name, "文件名与 LiteLLM 投毒事件公开 IoC 一致"))
            continue
        if p.name in STARTUP_MODULES:
            out.append(Finding("REVIEW", p.name, "site-packages 中的启动模块,每次启动解释器都会执行"))
            continue
        if p.suffix == ".pth":
            for n, line in enumerate(p.read_text(encoding="utf-8", errors="replace").splitlines(), 1):
                s = line.strip()
                # site.py 规则:以 "import " 或 "import\t" 开头的行会被 exec
                if s.startswith(("import ", "import\t")):
                    out.append(Finding("REVIEW", f"{p.name}:{n}", "启动时会执行的 import 行:" + s[:60]))
    return out


def make_env(root: Path, kind: str) -> Path:
    d = root / kind
    d.mkdir()
    (d / "mypkg.pth").write_text("/opt/extra/libs\n# comment\n", encoding="utf-8")
    if kind in ("tooling", "poisoned"):
        # 与 setuptools 的 distutils-precedence.pth 同类:合法但会执行代码
        (d / "distutils-precedence.pth").write_text(
            "import os; var = 'SETUPTOOLS_USE_DISTUTILS'; enabled = os.environ.get(var, 'local') == 'local'\n",
            encoding="utf-8",
        )
    if kind == "poisoned":
        # 仅模拟文件名与结构,内容是无害的占位
        (d / "cook.pth").write_text("import cook\n", encoding="utf-8")
        (d / "cook.py").write_text("# harmless placeholder for the experiment\n", encoding="utf-8")
        (d / "sitecustomize.py").write_text("# harmless placeholder\n", encoding="utf-8")
    return d


def main() -> int:
    expected = {"clean": (0, 0), "tooling": (0, 1), "poisoned": (2, 2)}
    ok = True
    with tempfile.TemporaryDirectory() as tmp:
        for kind, (want_crit, want_review) in expected.items():
            env = make_env(Path(tmp), kind)
            fs = scan(env)
            crit = sum(f.level == "CRITICAL" for f in fs)
            rev = sum(f.level == "REVIEW" for f in fs)
            status = "PASS" if (crit, rev) == (want_crit, want_review) else "FAIL"
            ok &= status == "PASS"
            print(f"[{status}] {kind:9s} CRITICAL={crit} (期望 {want_crit})  REVIEW={rev} (期望 {want_review})")
            for f in fs:
                print(f"           {f.level:8s} {f.path}  {f.reason}")

    # 附加:扫描当前解释器真实的 site-packages(只打印,不做断言)
    dirs = {sysconfig.get_paths()["purelib"], *getattr(site, "getsitepackages", lambda: [])()}
    print(f"\n当前解释器 {sys.version.split()[0]} 的 site-packages 扫描(仅供参考):")
    for d in sorted(dirs):
        p = Path(d)
        if not p.is_dir():
            continue
        fs = scan(p)
        crit = [f for f in fs if f.level == "CRITICAL"]
        print(f"  {d}: CRITICAL={len(crit)} REVIEW={len(fs) - len(crit)}")
        for f in fs:
            print(f"    {f.level:8s} {f.path}  {f.reason}")

    print("\nRESULT:", "3/3 样例环境分类正确" if ok else "存在分类错误")
    return 0 if ok else 1


if __name__ == "__main__":
    sys.exit(main())
requirements.txt1 行
# 无第三方依赖,只使用 Python 标准库
Esc
↑ ↓ 选择Enter 打开

键盘快捷键

j / k
下一条 / 上一条
o
展开或收起当前条目
Enter
打开当前条目详情
c
复制当前条目链接
s
收藏 / 取消收藏当前条目
/ 或 ⌘K
搜索
1 2 3
切换密度:标题 / 摘要 / 完整
t
切换主题
?
显示本帮助