pythonw?也可能是木马?"

admin 2026-10-16 06:38:43 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

pythonw?也可能是木马?

原创

爱做梦的大米饭
爱做梦的大米饭

事件响应回忆录

2026年9月30日 08:31
湖南

在小说阅读器读本章

去阅读

在公众号小说中沉浸阅读

引子

今年经手的红队样本里,有一类我印象比较深:外层是 pythonw.exe,最终在内存里跑的是 shellcode。

从签名上看这套东西挑不出毛病。pythonw.exe、python314.dll,全是 Python 官方的签名文件,按照常规分析来看这似乎没毛病。但样本目录里总会多出一个东西:

D:\\SomeApp\\
    ├─ pythonw.exe          ← 官方签名
    ├─ python314.dll        ← 官方签名
    ├─ python314.zip        ← 恶意代码在这里面
    └─ ...

去看这个 python314.zip,它不在 PYTHONPATH 里,也没有 .pth 文件或者 sitecustomize.py 指向它。但如果我让 pythonw.exe 把 sys.path 打到文件里:

# probe.py
import sys
open(r\"D:\\out.txt\", \"w\").write(\"\
\".join(sys.path))
PS> .\\pythonw.exe probe.py
PS> type D:\\out.txt
D:\\SomeApp
D:\\SomeApp\\python314.zip
D:\\SomeApp\\DLLs
D:\\SomeApp\\Lib
D:\\SomeApp

它就在里面,而且排在 DLLs 和 Lib 前面。位置靠前意味着 zip 里的模块会盖掉标准库里的同名模块。

跟 pythonw.exe 打交道得习惯这一手——它默认没有控制台,print 是打不出来的,所有探测结果都得往文件里落地。

所以问题是:这个 zip 凭什么会被自动加载?

我把 CPython 的代码拉下来跟了一遍,从 pythonw.exe 的入口一直跟到 zip 里的代码被执行。下面是完整链路。


一、pythonw.exe 本身不干活

分析样本时习惯先看主程序在干什么,但在 pythonw.exe 上这一步会落空。

整个 exe 的源码就这些,函数体里只有一句 Py_Main。

int WINAPI wWinMain(
    HINSTANCE hInstance,
    HINSTANCE hPrevInstance,
    LPWSTR lpCmdLine,
    int nCmdShow
)
{
    return Py_Main(__argc, __wargv);
}

pythonw.exe 的源码不到 20 行,没有参数解析、没有路径计算、没有模块加载。它只是个壳。

工程文件能印证这一点:

源文件只有 WinMain.c 和 pythonw_exe.rc。

<ItemGroup>
    <ResourceCompile Include=\"..\\PC\\pythonw_exe.rc\" />
  </ItemGroup>
  <ItemGroup>
    <ClCompile Include=\"..\\PC\\WinMain.c\" />
  </ItemGroup>

入口为什么是 wWinMain 而不是 main?因为这个工程没写 <SubSystem>,继承的是公共属性表里的默认值:

默认值是 Windows。

<GenerateDebugInformation>true</GenerateDebugInformation>
      <ProgramDatabaseFile>$(OutDir)$(TargetName).pdb</ProgramDatabaseFile>
      <SubSystem>Windows</SubSystem>

子系统是 Windows,链接器就去找 wWinMain。这也是 pythonw.exe 不弹黑窗口的原因。


二、sys.path 是一段 Python 代码算出来的

接下来这段是整条链里最容易被忽略的地方。

Windows 上的 Python 启动路径是怎么确定的?直觉上应该是一堆 C 代码在拼字符串。实际上从 3.11 开始,CPython 把整套路径计算逻辑改写成了一个 Python 脚本,放在 Modules/getpath.py,预编译成字节码之后冻结进 python314.dll,初始化的时候直接执行。

_Py_M__getpath 就是 getpath.py 的 marshal 字节码数组。

{
    return PyMarshal_ReadObjectFromString(
        (const char*)_Py_M__getpath, sizeof(_Py_M__getpath));
}

把这段字节码当普通 Python 代码 eval 掉。

PyObject *r = PyEval_EvalCode(co, dict, dict);

C 侧把编译期常量、环境变量、可执行文件路径这些东西塞进一个 dict,然后跑一遍 getpath.py,算出来的 sys.path 再从这个 dict 里取回去。

调用链大致是这样:

pythonw.exe!wWinMain                        PC/WinMain.c:9
  └─ Py_Main                                Modules/main.c:874
       └─ Py_InitializeFromConfig           Modules/main.c:68
            └─ _PyConfig_InitImportConfig   Python/initconfig.c:2678
                 └─ _PyConfig_InitPathConfig   Python/initconfig.c:2636
                                                  → Modules/getpath.c:856
                       └─ PyEval_EvalCode(getpath.py)   Modules/getpath.c:962

zip 的文件名

ZIP_LANDMARK 这一行。

BUILDSTDLIB_LANDMARKS = ['Lib\\\\os.py']
    VENV_LANDMARK = 'pyvenv.cfg'
    ZIP_LANDMARK = f'python{VERSION_MAJOR}{VERSION_MINOR}{PYDEBUGEXT or \"\"}.zip'
    WINREG_KEY = f'SOFTWARE\\\\Python\\\\PythonCore\\\\{PYWINVER}\\\\PythonPath'
    DELIM = ';'

VERSION_MAJOR 和 VERSION_MINOR 由 C 侧注入,PYDEBUGEXT 只在 Debug 构建下有值。

Release 版 3.14 出来就是 python314.zip。Debug 构建是 python314_d.zip,自由线程构建是 python314t.zip。

样本里那个\"python 版本号.zip\"就是从这个模板来的,名字错一个字都不会被加载。

从哪个目录找

library_dir 的来源,以及最后那句没有做任何检查的 append。

# Then add the default zip file
    if os_name == 'nt':
        # QUIRK: Windows uses the library directory rather than the prefix
        if library:
            library_dir = dirname(library)
        else:
            library_dir = executable_dir
        pythonpath.append(joinpath(library_dir, ZIP_LANDMARK))
    elif build_prefix:
        # QUIRK: POSIX uses the default prefix when in the build directory
        pythonpath.append(joinpath(PREFIX, ZIP_LANDMARK))
    else:
        pythonpath.append(joinpath(base_prefix, ZIP_LANDMARK))

两个点。

一是 Windows 用的是 library_dir,不是 prefix。CPython 自己在注释里标了 QUIRK,因为其他平台都是拼 prefix。

二是这句 append 没做存在性检查,是直接塞进去的。同一个文件里另一处用到 zip 的地方就判了:

用 zip 反推 prefix 的时候,isfile 是在的。

library_dir = dirname(library)
        if ZIP_LANDMARK:
            if os_name == 'nt':
                # QUIRK: Windows does not search up for ZIP file
                if isfile(joinpath(library_dir, ZIP_LANDMARK)):
                    prefix = library_dir
            else:
                prefix = search_up(library_dir, ZIP_LANDMARK)

也就是说,不管 zip 存不存在,这个路径字符串都会进 sys.path。官方安装包根本不生成这个文件,但 sys.path 里照样有,就是这么来的。

library 是什么

library 是当前进程实际加载的那个 python314.dll 的完整路径。它靠 DllMain 的一个副作用拿到:

DLL 被加载时把自己的 HMODULE 存进全局变量。

// Python Globals
HMODULE PyWin_DLLhModule = NULL;
const char *PyWin_DLLVersionString = MS_DLL_ID;

BOOL    WINAPI  DllMain (HANDLE hInst,
                                                ULONG ul_reason_for_call,
                                                LPVOID lpReserved)
{
    switch (ul_reason_for_call)
    {
        case DLL_PROCESS_ATTACH:
            PyWin_DLLhModule = hInst;
            break;

然后转成路径塞进 dict:

library_to_dict 取 PyWin_DLLhModule,内部走 GetModuleFileNameW。

/* Add the runtime library's path to the dict */
static int
library_to_dict(PyObject *dict, const char *key)
{
/* macOS framework builds do not link against a libpython dynamic library, but
   instead link against a macOS Framework. */
#if defined(Py_ENABLE_SHARED) || defined(WITH_NEXT_FRAMEWORK)

#ifdef MS_WINDOWS
    extern HMODULE PyWin_DLLhModule;
    if (PyWin_DLLhModule) {
        return winmodule_to_dict(dict, key, PyWin_DLLhModule);
    }
#endif

注意这里取的是 DLL 的位置,跟启动的 exe 没关系。

对攻击者来说这构成一个硬约束:python314.zip 必须放在 python314.dll 所在的目录,一般是安装根目录。放 DLLs\\ 下面、或者放别的地方,都不生效。


三、zip 进 sys.path 之后,还得有人能读它

sys.path 里有这个字符串只是一半。zip 不是目录,FileFinder 读不了,得靠 zipimport 注册成 path hook 才能被 import。

PyList_Insert(path_hooks, 0, ...),插在列表最前面。

static int
init_zipimport(PyThreadState *tstate, int verbose)
{
    PyObject *path_hooks = _PySys_GetRequiredAttrString(\"path_hooks\");
    ...
    PyObject *zipimporter = PyImport_ImportModuleAttrString(\"zipimport\", \"zipimporter\");
    ...
    else {
        /* sys.path_hooks.insert(0, zipimporter) */
        int err = PyList_Insert(path_hooks, 0, zipimporter);

插在 index 0,之后每个 sys.path 条目进来,导入系统会依次试 sys.path_hooks,zipimporter 排第一个。


四、为什么是 encodings

zip 里的代码什么时候执行?

在任何用户代码之前,在解释器启动过程里。而且这个入口是 CPython 自己送上来的。

看启动顺序:

1210 行装 zipimport 钩子,1223 行才去导 encodings。

status = _PyImport_InitExternal(tstate);       // ← 1210 装 zipimport 钩子
    if (_PyStatus_EXCEPTION(status)) {
        return status;
    }
    ...
    status = _PyUnicode_InitEncodings(tstate);     // ← 1223 导入 encodings
    if (_PyStatus_EXCEPTION(status)) {
        return status;

装钩子在前,导 encodings 在后,sys.path[1] 那个 zip 此时已经能搜到。

导 encodings 的是这里:

_PyCodecRegistry_Init 主动 import encodings。

// Importing `encodings' will call back into this module to register codec
    // search functions, so this is done after everything else is initialized.
    PyObject *mod = PyImport_ImportModule(\"encodings\");
    if (mod == NULL) {
        PyThreadState *tstate = _PyThreadState_GET();
        _Py_DumpPathConfig(tstate);
        return PyStatus_Error(\"Failed to import encodings module\");
    }

Python 要处理字符串和 I/O 就得有编码系统,所以启动阶段必然要 import 它。

那为什么不是 os 或者 codecs?

因为这些是冻结的。

encodings 是 SourceFileLoader,其余全是 frozen;sys.meta_path 里 FrozenImporter 在 PathFinder 前面。

>>> import importlib.util, sys
>>> for n in ('encodings', 'codecs', 'site', 'zipimport', 'os', 'abc'):
...     s = importlib.util.find_spec(n)
...     print(f'{n:12} {type(s.loader).__name__:18} {s.origin}')

encodings    SourceFileLoader   D:\\SomeApp\\Lib\\encodings\\__init__.py   ← 不是 frozen
codecs       type               frozen
site         type               frozen
zipimport    type               frozen
os           type               frozen
abc          type               frozen

>>> sys.meta_path
[DistutilsMetaFinder, BuiltinImporter, FrozenImporter, PathFinder]

FrozenImporter 在 PathFinder 前面。冻结模块是内嵌在 python314.dll 里的字节码,在 sys.path 被搜索之前就解析完了。往 zip 里塞同名的 codecs.py 或者 os.py,加载的时候根本不会看。

encodings 不是 frozen,走 SourceFileLoader,老老实实从 sys.path 上找。

于是 zip 在 sys.path[1],Lib 在 sys.path[3],zip 里的 encodings/__init__.py 会盖掉标准库那份。而这个文件每次启动都会被导入。

样本选 encodings 不是随手挑的,启动链上非 frozen 的模块里,就它一个。

五、能遮蔽的不止 encodings

zip 在 sys.path[1],先于 DLLs 和 Lib 被搜索。encodings 只是\"保证每次启动都执行\"的最优解,真正能盖的是所有非 frozen 的纯 Python 模块。

如果目标是某个业务应用,zip 里放一个 requests/__init__.py 就够。那个时间点 builtins 已经完整,载荷写起来没有限制。

所以这类样本的形态可以调:

  • 要每次启动必跑,盖 encodings
  • 只针对某个应用,盖它依赖的第三方库
  • 要体积小,一个 zip 塞几个模块

这不是 encodings 的问题,是 zip 优先级带来的通用遮蔽面。


六、写在最后

链路串起来是这样:

pythonw.exe            100KB 空壳,只有一句 Py_Main
  └─ 静态导入 python314.dll
       └─ DllMain 存下 HMODULE → PyWin_DLLhModule
            └─ 初始化时执行冻结的 getpath.py
                 └─ pythonpath.append(dirname(python314.dll) + \"python314.zip\")
                      ↑ 不检查存在性,无条件进 sys.path[1]
       └─ init_zipimport → sys.path_hooks.insert(0, zipimporter)
       └─ _PyCodecRegistry_Init → import encodings
            └─ zip 里的 encodings/__init__.py 被执行
                 └─ ctypes 可用,内存加载

这里面没有一处是漏洞。python314.zip 是给嵌入式分发用的标准机制,getpath.py 无条件把 zip 加进 sys.path 是为了让\"zip 可选存在\"这个设计成立,encodings 早导入是为了让解释器能处理编码。三件事单独看都合理。

叠在一起就成了一条稳定的执行路径,全程不碰签名。

排查的时候,看解释器目录有没有多出 pythonXY.zip,看 encodings.__file__ 指向哪里,这两条基本够用。

彩蛋


pythonw?也可能是木马?" 网络安全文章

pythonw?也可能是木马?"

本文分析红队样本中利用pythonw.exe与python314.zip实现恶意代码加载的技术细节。核心机制是CPython在Windows上通过getpath.py计算sys.path时,无条件将python314.zip路径加入搜索列表,且zipimporter注册在path\\_hooks首位,导致zip内模块可覆盖标准库。攻击者利用此机制在python314.zip中植入恶意encodings模块,实现启动时执行代码。文章详细追踪了从pythonw.exe入口到zip内代码执行的完整调用链,揭
谁是卧底" 网络安全文章

谁是卧底"

文档内容严重不足,仅包含标题’谁是卧底’、来源’Khan安全团队’、日期2026年9月30日及地点广东等元信息,无正文或实质技术内容,无法进行有效总结或分析。
评论:0   参与:  0