文章总结: 本文分析红队样本中利用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>
<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?也可能是木马?》
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。









评论