I was reading Johann Rehberger’s write-up of an attack on a coding agent last week. The payload is a file called struct.py inside a zip archive. The agent unpacks the archive, writes its own small Python decoder in the extracted directory, and runs it. The decoder does import base64, base64 internally does import struct, and Python resolves that to the attacker’s file because the script’s directory is the first entry on sys.path. Module-level code runs on import. The poisoned module re-exports the real _struct API so decoding still produces valid output and execution continues, and the payload relaunches itself with python3 -I so the second interpreter starts with a clean module path.

That -I is the tell that this is a well-understood Python behaviour. Putting an ambient directory on the module search path is a default most dynamic-language runtimes shipped with early on, and several have since removed it, so I went through the ones I could find to see where each ended up. The problem is specific to resolving module lookups at run time against filesystem locations, so languages that bind everything at build time are mostly absent.

Removed

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 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’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’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’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 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 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.

  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)

Threat model

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.

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.