pythonw?也可能是木马?

admin 2026-10-02 05:14:04 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

文章总结: 本文分析红队样本中利用pythonw.exe与python314.zip实现恶意代码加载的技术细节。核心机制是CPython在Windows上通过getpath.py计算sys.path时,无条件将python314.zip路径加入搜索列表,且zipimporter注册在path_hooks首位,导致zip内模块可覆盖标准库。攻击者利用此机制在python314.zip中植入恶意encodings模块,实现启动时执行代码。文章详细追踪了从pythonw.exe入口到zip内代码执行的完整调用链,揭示了官方签名文件被滥用的风险。 综合评分: 88 文章分类: 恶意软件,红队,代码审计,安全工具,漏洞分析


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("\n".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>
&nbsp; &nbsp; <ResourceCompile Include="..\PC\pythonw_exe.rc" />
&nbsp; </ItemGroup>
&nbsp; <ItemGroup>
&nbsp; &nbsp; <ClCompile Include="..\PC\WinMain.c" />
&nbsp; </ItemGroup>

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

默认值是 Windows。

<GenerateDebugInformation>true</GenerateDebugInformation>
&nbsp; &nbsp; &nbsp; <ProgramDatabaseFile>$(OutDir)$(TargetName).pdb</ProgramDatabaseFile>
&nbsp; &nbsp; &nbsp; <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 字节码数组。

{
&nbsp; &nbsp; return PyMarshal_ReadObjectFromString(
&nbsp; &nbsp; &nbsp; &nbsp; (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 &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;PC/WinMain.c:9
&nbsp; └─ Py_Main &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;Modules/main.c:874
&nbsp; &nbsp; &nbsp; &nbsp;└─ Py_InitializeFromConfig &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; Modules/main.c:68
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; └─ _PyConfig_InitImportConfig &nbsp; Python/initconfig.c:2678
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;└─ _PyConfig_InitPathConfig &nbsp; Python/initconfig.c:2636
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; → Modules/getpath.c:856
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;└─ PyEval_EvalCode(getpath.py) &nbsp; Modules/getpath.c:962

zip 的文件名

ZIP_LANDMARK 这一行。

BUILDSTDLIB_LANDMARKS = ['Lib\\os.py']
&nbsp; &nbsp; VENV_LANDMARK = 'pyvenv.cfg'
&nbsp; &nbsp; ZIP_LANDMARK = f'python{VERSION_MAJOR}{VERSION_MINOR}{PYDEBUGEXT or ""}.zip'
&nbsp; &nbsp; WINREG_KEY = f'SOFTWARE\\Python\\PythonCore\\{PYWINVER}\\PythonPath'
&nbsp; &nbsp; 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
&nbsp; &nbsp; if os_name == 'nt':
&nbsp; &nbsp; &nbsp; &nbsp; # QUIRK: Windows uses the library directory rather than the prefix
&nbsp; &nbsp; &nbsp; &nbsp; if library:
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; library_dir = dirname(library)
&nbsp; &nbsp; &nbsp; &nbsp; else:
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; library_dir = executable_dir
&nbsp; &nbsp; &nbsp; &nbsp; pythonpath.append(joinpath(library_dir, ZIP_LANDMARK))
&nbsp; &nbsp; elif build_prefix:
&nbsp; &nbsp; &nbsp; &nbsp; # QUIRK: POSIX uses the default prefix when in the build directory
&nbsp; &nbsp; &nbsp; &nbsp; pythonpath.append(joinpath(PREFIX, ZIP_LANDMARK))
&nbsp; &nbsp; else:
&nbsp; &nbsp; &nbsp; &nbsp; pythonpath.append(joinpath(base_prefix, ZIP_LANDMARK))

两个点。

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

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

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

library_dir = dirname(library)
&nbsp; &nbsp; &nbsp; &nbsp; if ZIP_LANDMARK:
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; if os_name == 'nt':
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; # QUIRK: Windows does not search up for ZIP file
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; if isfile(joinpath(library_dir, ZIP_LANDMARK)):
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; prefix = library_dir
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; else:
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; 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 &nbsp; &nbsp;WINAPI &nbsp;DllMain (HANDLE hInst,
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; ULONG ul_reason_for_call,
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; LPVOID lpReserved)
{
&nbsp; &nbsp; switch (ul_reason_for_call)
&nbsp; &nbsp; {
&nbsp; &nbsp; &nbsp; &nbsp; case DLL_PROCESS_ATTACH:
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; PyWin_DLLhModule = hInst;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; 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
&nbsp; &nbsp;instead link against a macOS Framework. */
#if&nbsp;defined(Py_ENABLE_SHARED) || defined(WITH_NEXT_FRAMEWORK)

#ifdef&nbsp;MS_WINDOWS
&nbsp; &nbsp; extern HMODULE PyWin_DLLhModule;
&nbsp; &nbsp; if (PyWin_DLLhModule) {
&nbsp; &nbsp; &nbsp; &nbsp; return winmodule_to_dict(dict, key, PyWin_DLLhModule);
&nbsp; &nbsp; }
#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)
{
&nbsp; &nbsp; PyObject *path_hooks = _PySys_GetRequiredAttrString("path_hooks");
&nbsp; &nbsp; ...
&nbsp; &nbsp; PyObject *zipimporter = PyImport_ImportModuleAttrString("zipimport", "zipimporter");
&nbsp; &nbsp; ...
&nbsp; &nbsp; else {
&nbsp; &nbsp; &nbsp; &nbsp; /* sys.path_hooks.insert(0, zipimporter) */
&nbsp; &nbsp; &nbsp; &nbsp; 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); &nbsp; &nbsp; &nbsp; // ← 1210 装 zipimport 钩子
&nbsp; &nbsp; if (_PyStatus_EXCEPTION(status)) {
&nbsp; &nbsp; &nbsp; &nbsp; return status;
&nbsp; &nbsp; }
&nbsp; &nbsp; ...
&nbsp; &nbsp; status = _PyUnicode_InitEncodings(tstate); &nbsp; &nbsp; // ← 1223 导入 encodings
&nbsp; &nbsp; if (_PyStatus_EXCEPTION(status)) {
&nbsp; &nbsp; &nbsp; &nbsp; return status;

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

导 encodings 的是这里:

_PyCodecRegistry_Init 主动 import encodings。

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

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'):
... &nbsp; &nbsp; s = importlib.util.find_spec(n)
... &nbsp; &nbsp; print(f'{n:12} {type(s.loader).__name__:18} {s.origin}')

encodings &nbsp; &nbsp;SourceFileLoader &nbsp; D:\SomeApp\Lib\encodings\__init__.py &nbsp; ← 不是 frozen
codecs &nbsp; &nbsp; &nbsp; type &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; frozen
site &nbsp; &nbsp; &nbsp; &nbsp; type &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; frozen
zipimport &nbsp; &nbsp;type &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; frozen
os &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; type &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; frozen
abc &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;type &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; 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 &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;100KB 空壳,只有一句 Py_Main
&nbsp; └─ 静态导入 python314.dll
&nbsp; &nbsp; &nbsp; &nbsp;└─ DllMain 存下 HMODULE → PyWin_DLLhModule
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; └─ 初始化时执行冻结的 getpath.py
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;└─ pythonpath.append(dirname(python314.dll) + "python314.zip")
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; ↑ 不检查存在性,无条件进 sys.path[1]
&nbsp; &nbsp; &nbsp; &nbsp;└─ init_zipimport → sys.path_hooks.insert(0, zipimporter)
&nbsp; &nbsp; &nbsp; &nbsp;└─ _PyCodecRegistry_Init → import encodings
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; └─ zip 里的 encodings/__init__.py 被执行
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;└─ ctypes 可用,内存加载

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

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

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

彩蛋


免责声明:

本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。

任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。

本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我。

本文转载自:事件响应回忆录 爱做梦的大米饭 爱做梦的大米饭《pythonw?也可能是木马?》

工具|ASC 网络安全文章

工具|ASC

文章总结: 本文介绍ASC工具,一款用于Android逆向的辅助工具,具备Deflate比特流探针、武器化R8编译器优化、O(1)指令定位原语及内存中动态重建最
评论:0   参与:  0