跳转至

标准库遮蔽:主流编程语言的模块加载陷阱与 AI 智能体安全危机

文章背景与核心概要

在动态编程语言的发展历史中,许多运行时环境默认会将当前工作目录或脚本所在路径置于模块搜索路径中,这为“模块遮蔽” (Module Shadowing) 攻击埋下了重大隐患——攻击者只需在目录下放置同名文件,就能偷梁换柱劫持原生标准库。尽管 Ruby、Perl、Node.js、Deno 和 Julia 等现代运行时已逐步从设计层面剔除或防范了这种隐式路径查找,但 Python 和 PHP 至今仍默认将工作目录置于搜索路径首位,在解压不可信压缩包或访问外部目录时极易受到供应链攻击。尤其在当今 AI 编程智能体 (AI Agent) 广泛自主解压缩外部文件并即时运行辅助脚本的时代,这一看似古老的语言设计特性正演变为不可忽视的高危远程代码执行隐患。


概要

Summary

在动态编程语言的发展历史中,许多语言在默认情况下都会将当前工作目录或脚本所在路径纳入模块搜索路径。这意味着,本地目录下的任意文件都有可能悄悄覆盖内置的核心库,这种安全隐患被称为“模块遮蔽” (Module Shadowing)。本文将系统梳理各大主流编程语言运行时如何应对这一安全挑战。像 Ruby、Perl、Node.js、Deno 和 Julia 等现代语言,早已从根本上彻底消除或严格限制了这种从上下文环境中自动查找路径的做法,以杜绝恶意脚本被意外执行;然而,像 Python 和 PHP 这样的老牌语言,至今仍默认将当前工作目录排在搜索路径的首位。这就带来了一个严重的安全漏洞:一旦程序解压了不可信的压缩包,或是进入了不受信任的文件目录,攻击者就能轻而易举地通过同名文件掉包标准库,达成恶意代码执行。

Dynamic languages have historically included the current directory or script location in their module search paths by default, allowing local files to override built-in libraries (a vulnerability known as module shadowing). This write-up explores how various runtimes handle this security challenge. While languages like Ruby, Perl, Node.js, Deno, and Julia have eliminated or mitigated ambient path lookups to prevent malicious file execution, languages like Python and PHP still place the working directory first on the path, leaving them vulnerable to attacks where unpacked archives or untrusted folders compromise standard library resolution.


已彻底移除:将当前工作目录踢出默认搜索路径

Removed

早在 2010 年 8 月发布的 1.9.2 版本中,Ruby 就果断从 $LOAD_PATH 中删除了代表当前目录的 .。当时的更新公告极其简短,只有一句话:“$: 不再包含当前目录,请改用 require_relative”。引入的 require_relative 方法能够直接基于当前调用文件所在的磁盘位置进行相对寻址,与进程当前的工作目录完全解耦。这样一来,脚本既能安全地加载与自身同目录的兄弟文件,又能确保整个进程中的其他 require 调用免受外部不可信工作目录的污染。由于当时 Ruby 从 1.8 升级至 1.9 本身就是一次重大破坏性升级,因此这次加载路径的调整直接顺理成章地一同落地,既没有专门申请通用漏洞披露 (Common Vulnerabilities and Exposures, CVE) 编号,也没有提供任何用于回退兼容的“逃生舱”环境变量。

Ruby dropped . from $LOAD_PATH in 1.9.2, released August 2010. The NEWS entry is one line, “$: no longer includes the current directory, use require_relative”. require_relative resolves against the calling file’s location, independent of the process working directory, so a script can load its own siblings while keeping the working directory off the search path for every other require in the process. There was no CVE and no escape-hatch environment variable; 1.9 was already a compatibility break from 1.8 and the load-path change went in alongside everything else.

Perl 则在 2017 年 5 月发布的 5.26.0 版本中,将 . 从模块加载数组 @INC 的末尾彻底移除。与 Ruby 不同,Perl 的这次改动直接关联了一起严重的安全漏洞 CVE-2016-1238,该漏洞最初由 cPanel 团队提交:当一个脚本切换工作目录到公共临时目录 /tmp 并尝试加载可选模块时,就会毫无防备地直接执行本地恶意用户提前在 /tmp/Module.pm 中留下的任意代码。Perl 在传统上一向默认在 @INC 中携带 .,不过污点模式 (Taint Mode, perl -T) 一直会自动剔除它,这说明官方早已知晓其潜在风险。为了平稳过渡,Perl 5.26 新增了环境变量 PERL_USE_UNSAFE_INC=1 供老项目临时回退,下游各大 Linux 发行版也为此维护了长达数年的兼容补丁,直至 CPAN 社区中所有依赖 . 路径的历史模块被逐一修复完毕。

Perl removed . from the end of @INC in 5.26.0, released May 2017. That one did have a CVE, CVE-2016-1238, reported by cPanel: a script that changes directory to /tmp and then loads an optional module runs whatever a local user has left at /tmp/Module.pm. Perl had traditionally shipped with . on @INC, and taint mode (perl -T) had always stripped it, so the risk was documented long before the fix. 5.26 added PERL_USE_UNSAFE_INC=1 to restore the old behaviour for the transition, and downstream distributions carried patches for years while CPAN modules that depended on . being present were fixed one at a time.


架构原生防范:优先内置模块与显式路径设计

Designed Out

在 Node.js 中,require 机制会优先检查内置核心模块名称,其优先级高于磁盘上的 node_modules 或任何本地文件。因此,即便调用脚本的同一目录下存在一个名为 http.js 的文件,执行 require('http') 也只会返回官方原生模块。在 Node.js 中若要加载同目录下的本地文件,历来都必须显式使用相对路径,例如 require('./http')。从 Node.js 14.18.0 开始,官方还引入了 node: 协议前缀,使得 require('node:http') 可以进一步绕过 require 缓存,并且类似 node:test 这样的全新内置模块甚至只能通过该前缀访问。不过需要指出的是,这种防护仅覆盖了核心标准库:当引入第三方库如 require('express') 时,Node.js 仍会从调用方所在目录开始向上逐级检索 node_modules。这意味着,解压后的不可信压缩包里若暗藏 node_modules/express,仍会优先于全局或系统安装的版本被加载。类似地,Java 在运行时通过类路径 (Classpath) 解析类,虽然在未设置 -cpCLASSPATH 环境变量时默认类路径就是 . 当前目录,但其启动类加载器 (Bootstrap Class Loader) 会在应用程序类加载器扫描类路径之前先一步锁定 java.* 等基础核心类,因此同目录下的 ./java/lang/String.class 根本不可能实现偷梁换柱。而 Deno 则更为纯粹,它完全抛弃了上下文环境隐式搜索路径的设计理念:所有模块导入必须是 URL 或明确的相对路径,任何未在导入映射表 (Import Map) 中声明的裸说明符都会直接抛出异常。

Node’s require checks core module names first, before node_modules or anything else on disk, so require('http') returns the built-in even with an http.js in the caller’s directory. Loading a neighbouring file has always taken an explicit relative path, require('./http'). Node 14.18.0 added the node: prefix so require('node:http') bypasses the require cache as well, and some newer built-ins such as node:test are only reachable that way. That protection covers core modules only: require('express') searches upward from the caller’s directory through each node_modules, so a node_modules/express inside an extracted archive is resolved ahead of any installed copy. Java resolves classes at run time from a classpath, and the default classpath when neither -cp nor CLASSPATH is set is ., but the bootstrap class loader finds java.* before the application loader searches the classpath, so a ./java/lang/String.class is unreachable for the same reason a local http.js is in Node. Deno dropped the ambient search path entirely: every import is a URL or a relative path, and a bare specifier outside the import map is an error.

Julia 默认的 LOAD_PATH 变量被设定为 ["@", "@v#.#", "@stdlib"]。这三个符号路径分别对应当前激活的项目环境、带有版本号的用户全局默认环境以及标准库。无论进程当前切换到哪个目录下,@ 符号都会严格锚定当前激活的 Project.toml 清单文件,因此即使在不可信目录下执行脚本,模块导入也只会严格依据配置文件清单进行解析。PowerShell 的 Import-Module 命令则仅检索 $env:PSModulePath 环境变量,默认只涵盖单用户目录、全体用户共享目录以及 $PSHOME 核心目录;此外,PowerShell 在可执行文件寻址上也采用了同样的严密设计:在当前工作目录下运行脚本必须显式添加 .\ 前缀,直接键入裸名称只会搜索系统 $env:PATH,绝不会误执行当前目录下的同名文件。

Julia’s default LOAD_PATH is ["@", "@v#.#", "@stdlib"], three symbolic entries that expand to the active project environment, the user’s versioned default environment, and the standard library. @ resolves to whichever Project.toml is active, regardless of which directory the process is in, so a script run from an untrusted directory looks up imports in a manifest file. PowerShell’s Import-Module searches $env:PSModulePath, which defaults to the per-user, all-users and $PSHOME module directories only, and the shell applies the equivalent rule to executable lookup: running a script in the working directory requires an explicit .\ prefix, so a bare name searches only $env:PATH.


隐患仍存:工作目录优先与被遮蔽的标准库

Still There

与上述语言形成鲜明对比的是,PHP 默认的 include_path 依然将 . 放在第一位。这意味着,执行 include 'config.php' 时,程序会毫不犹豫地优先加载当前工作目录下的 config.php,而不是系统 PEAR 路径中的对应库文件。Lua 默认的 package.path 则以 ./?.lua;./?/init.lua 结尾;当执行 require "json" 且所有已安装路径均未匹配时,搜索机制就会顺延兜底到当前目录下的 ./json.lua。好在 Lua 自身的官方标准库 (如 stringtablemath 等) 在 require 遍历任何磁盘路径之前就已经注册到了 package.loaded 表中,因此本地的 ./string.lua 无法覆盖原生标准库,只有第三方未预载的名称会暴露给同名劫持风险。在这两种语言中,如果开发者想要将工作目录从搜索路径中剥离,通常必须通过 php -d include_path=... 命令行参数或配置 LUA_PATH 环境变量来全量重写整个搜索路径字符串。

PHP’s . is the first entry in the default include_path, so include 'config.php' picks up a config.php in the working directory ahead of one under the PEAR path. Lua’s default package.path ends with ./?.lua;./?/init.lua, so require "json" falls through to ./json.lua after the installed-path entries have all missed. Lua’s own standard libraries, string, table, math and the rest, are registered in package.loaded before require searches any path, so a ./string.lua is unreachable and only third-party names are exposed. In both PHP and Lua, dropping the working-directory entry means overriding the full path string, via php -d include_path=... or the LUA_PATH environment variable.

Python 的机制则更为直接:当直接运行 Python 脚本文件时,解释器会将脚本所在目录置入 sys.path[0];使用 -m 运行模块时,置入的是当前工作目录;而在使用 -c 或进入交互式终端 (REPL) 时,置入的则是一个空字符串,其实际含义同样等同于当前工作目录。早在 Python 3.4 中,官方就新增了隔离模式 (Isolated Mode, -I) ,该模式会在禁用该前置路径的同时,一并屏蔽用户级 site-packages 以及所有的 PYTHON* 环境变量。为了提供更精细的控制,Python 3.11 引入了一个针对性更强的安全开关:-P 参数与 PYTHONSAFEPATH 环境变量,它仅单独剔除 sys.path[0] 中的本地目录,而保留其他环境变量完好无损。该功能背后的问题跟踪单 bpo-13475 早在 2011 年 11 月就已被提出。合入 -P 功能的核心开发者 Victor Stinner 还在其个人仓库中起草了一份 PEP 提案草案,主张以 Perl 5.26 为先例将 safe-path 设为解释器的全局默认行为。然而,Ruby 当年能够从容剔除 .,是因为其同步推出了 require_relative 为同级依赖提供了替代出路;而 Python 的显式相对导入语法仅在正式包结构内部有效。这意味着,一个单独的独立脚本 script.py 若要直接 import helper 加载同级辅助模块,其底层完全依赖 sys.path[0] 指向当前目录;一旦在 -P 模式下运行,就会立即因找不到模块而抛出 ModuleNotFoundError 异常。

Python prepends the script’s directory as sys.path[0] when running a file, the working directory when running -m, and an empty string, meaning the working directory, when running -c or the REPL. Isolated mode, -I, added in 3.4, suppresses that entry along with user site-packages and all PYTHON* environment variables. 3.11 added a narrower switch, -P and PYTHONSAFEPATH, which drops only the sys.path[0] entry and leaves the rest of the environment alone. The tracker issue behind -P, bpo-13475, was opened in November 2011. Victor Stinner, who landed -P, has a draft PEP in his personal repo proposing to make safe-path the default and citing Perl 5.26 as precedent. Ruby could drop . because require_relative shipped in the same release and gave scripts another way to load their siblings. Python’s explicit relative imports only work inside a package, so a standalone script.py importing helper.py from the same directory depends on sys.path[0] being that directory, and running it under -P fails with ModuleNotFoundError.

在历史设计上,Perl、Ruby 和 Lua 都倾向于将 . 放在搜索路径的最末端——排在标准库和第三方库目录之后。因此,工作目录中的文件只有在全系统都找不到该名称时,才会作为“兜底”被加载;这也是为什么 CVE-2016-1238 漏洞需要依赖脚本尝试加载某个“可选模块”才能触发。然而,Python 和 PHP 却反其道而行之,直接将外部环境目录推到了搜索路径的最前端。由于官方标准库在路径列表里排在后面,一旦工作目录下存在一个名为 struct.py 的文件,它就会直接被加载并偷换掉真正的 struct 模块,所有从磁盘读取的标准库模块都无一幸免。虽然直接编译进解释器内核的内置模块以及自 Python 3.11 起被冻结在启动内存中的核心集合 (例如 osabciocodecs 等) 会在 PathFinder 扫描磁盘之前由 sys.meta_path 上的 BuiltinImporterFrozenImporter 优先处理,但像 struct 这样纯粹基于磁盘 .py 包装 C 扩展的标准库模块,仍会交由 PathFinder 检索,从而不可避免地面临被恶意掉包的命运。

Perl, Ruby and Lua all put . last, after the standard-library and site directories, so a file in the working directory could only supply a name that was missing everywhere else, and CVE-2016-1238 accordingly needed a target script that loaded an optional module. Python and PHP put the ambient directory first. The standard library comes after it on the path, struct.py in the working directory is loaded in place of the real struct, and any standard-library module read from disk is exposed the same way. Modules compiled into the interpreter and, since 3.11, the frozen startup set (os, abc, io, codecs among them) are served by BuiltinImporter and FrozenImporter on sys.meta_path before PathFinder touches disk; struct is a plain .py wrapper around a C extension and goes to PathFinder.

语言/运行时 默认路径条目 搜索优先级 标准库可被遮蔽? 移除版本 安全模式开关
Ruby $: 中的 . 末位 (last) 1.9.2 (2010)
Perl @INC 中的 . 末位 (last) 5.26 (2017) 可通过 PERL_USE_UNSAFE_INC 恢复
Node.js 无 (none)
Java 未设置 -cp 时的 . 任意 -cp 均可替换该默认值
Deno 无 (none)
Julia 无 (依据项目清单)
Lua package.path 中的 ./?.lua 末位 (last)
PHP include_path 中的 . 首位 (first)
Python sys.path 中的脚本目录 / 工作目录 首位 (first) -I (3.4) , -P / PYTHONSAFEPATH (3.11)
Entry on default path Position Stdlib shadowable Removed in Safe-mode switch
Ruby . in $: last no 1.9.2 (2010)
Perl . in @INC last no 5.26 (2017) (PERL_USE_UNSAFE_INC restores)
Node.js none no
Java . when -cp unset no any -cp replaces it
Deno none no
Julia none (project manifest) no
Lua ./?.lua in package.path last no
PHP . in include_path first yes
Python script dir / cwd in sys.path first yes -I (3.4), -P / PYTHONSAFEPATH (3.11)

威胁模型:AI 编程智能体时代的新危机

Threat Model

Perl 5.26 的发行说明中曾将这一风险清晰定义为:脚本在“当前目录不受信任 (例如 /tmp) ”时加载可选模块。在官方修复之前的二十年里,人们采取的应对方案纯粹是防御性的操作规程:系统运维脚本在加载任何可选模块前必须先 chdir 切换到一个安全可信的目录,并且用户只应当在自己完全受控的目录中运行工具。显然,这些经验法则全都是为“人类开发者”或“固定工作目录的系统守护进程”量身定制的,因为人类在终端敲下回车之前,清楚地知晓解释器将在哪个目录下启动。

The Perl 5.26 release notes describe the risk as a script loading an optional module “when its current directory is untrusted (such as /tmp)”, and the mitigation for the twenty years before that was procedural: system scripts chdir somewhere safe before loading anything optional, and users run tools only from directories they control, advice written for a person or a fixed-cwd daemon choosing where the interpreter runs.

然而,当当今的 AI 编程智能体自主下载一个压缩包、将其解压、在解压内容旁随手编写一个辅助脚本并立即执行时,这实质上就是换了个目录名称的 /tmp 经典提权攻击场景。不可信的压缩包直接决定了目录中包含哪些文件,而智能体自行生成的脚本正好成为了触发恶意模块导入的扳机。更关键的是,Python 是各类 AI 智能体生成辅助代码时的绝对首选:无论是 OpenAI 的代码解释器 (Code Interpreter) ,还是 Anthropic 的代码执行工具 (Code Execution Tool) ,亦或是本文直接针对的 Claude Code 智能体命令行工具,在运行临时脚本时无一例外都依赖 Python 解释器。在安全研究员 Rehberger 构造的概念验证攻击中,被掉包的遮蔽模块甚至还贴心地将调用转发给了真正的标准库,从而使数据解码器依然能返回完全正确的结果,神不知鬼不觉地完成了恶意载荷注入。这与之前讨论过的子进程可通过环境变量注入的覆盖标志位 (Override Flags) 如出一辙:在由人类开发者审慎指定工作目录或命令行标志的年代,这种设计或许还能勉强容忍;但在 AI 智能体自主解压压缩包并随之将解压目录隐式作为工作目录的新时代,这一历史遗留特性无疑为远程代码执行大开方便之门。

A coding agent that downloads an archive, extracts it, writes a helper next to the contents, and runs the helper is the /tmp case with a different label on the directory. The archive determined the directory contents, and the agent’s own script is what triggers the import. Python is also the language agents default to for generated helpers: the sandboxed runtime for both OpenAI’s Code Interpreter and Anthropic’s code execution tool, and the interpreter Claude Code, the target in the write-up, invokes for ad-hoc scripts. In Rehberger’s version the shadowed module forwards to the real one, so the decoder returns a correct result. It is the same class of default as the override flags a subprocess can set through its environment: tolerable while a person supplied the working directory or the flag, and an agent processing an archive supplies the working directory as a side effect of extracting it.