CTF的Web方向里,文件读取类题目多得像海边的贝壳,但绝大多数解题思路都绕不开“路径穿越”这四个字。我见过不少刚开始漏洞学习的朋友,拿到题一看源码:哦,文件名跟目录用os.path.join拼起来了,看上去挺安全。结果别人一个../flag.txt或者/tmp/flag.txt打进去,直接就出目录了。这其实就是标题里说的os.path.join()绝对路径拼接漏洞,一个在CTF出题里出现频率极高、在真实项目里同样坑人的点。
这篇文章不打算堆原理长文,而是围绕一个模拟题,把两件事讲清楚:第一,os.path.join在拼接路径时的真实行为到底是什么;第二,这类漏洞在源码里有哪些典型写法,怎么打、怎么修。适合刚入门漏洞学习、正在刷CTF的选手,也适合写Python文件接口时想把路径校验写严谨的开发者。
1. 先说两个“想当然”,这题就挂在这上面
1.1 想当然之一:join拼接后目录就锁死了
很多新手看到这样的代码,第一反应是“安全”:
path = os.path.join('/tmp/sandbox/', user_filename) open(path).read()理由是:user_filename不管传什么,拼接后前面都固定带着/tmp/sandbox/,文件再怎么跑也跑不出这个目录。这个想法不能说完全错,但它忽略了一个关键前提——os.path.join只是一个字符串拼接函数,它不认识文件系统,不管目录存不存在,也不负责判断拼接结果是否“越界”。
拿个生活例子来说,os.path.join就像快递打包员,它只管把你给的地址贴在快递单上。如果用户给了一个完整的“某市某路某号”,打包员就会把原来写的“某某楼栋”划掉,直接贴新地址。真正负责送件的快递员(操作系统)看到地址后,只会照单全收,根本不知道你原本想限制在哪个楼栋。
在文件系统里,..这个特殊路径段表示“上一级目录”。os.path.join('/tmp/sandbox/', '../flag.txt')得到的是:
/tmp/sandbox/../flag.txt这个字符串仍然以/tmp/sandbox/开头,但它最终指向的却是/tmp/flag.txt。打包员把地址贴上去就完事了,送件员一看“退回上一楼再送”,自然就走出了沙箱。
1.2 想当然之二:过滤了..就等于安全
另一个常见习惯是黑名单过滤:
if '..' in user_filename: raise Exception('no traversal')这种过滤看起来把目录穿越最经典的../拦掉了,但问题在于:黑名单永远需要穷举,而你根本不知道攻击者有多少种表达“上一级目录”的方式。
更直接的是,os.path.join还有一个致命行为:当后续参数以/开头时,它会直接丢掉前面所有拼接内容。比如:
os.path.join('/tmp/sandbox/', '/tmp/flag.txt')结果不是/tmp/sandbox//tmp/flag.txt,而是:
/tmp/flag.txt注意,用户传进来的user_filename是/tmp/flag.txt,里面根本没有..子串,过滤自然拦不住。这就是“绝对路径拼接漏洞”这个叫法的由来——第二个参数里出现了一个绝对路径,前面的整个目录前缀就形同虚设。
..过滤防住了普通穿越,绝对路径顶替却直接把构造好的目录前缀一脚踢开,两道防线之间漏了一条缝。这条缝,在CTF里几乎是每季度都会出现的送分点。
2. 翻开源码看行为:os.path.join到底在拼什么
2.1 核心规则:从哪一段开始,前缀作废
为了把问题说透,我们直接看posixpath.join的核心逻辑。不同Python版本的实现细节略有差异,但主干语义是一致的:
def join(a, *p): path = a for b in p: if b.startswith('/'): path = b elif path == '' or path.endswith('/'): path += b else: path += '/' + b return path那个if b.startswith('/')就是一切的根源。只要某一个待拼接参数以斜杠开头,它就不是“追加”到现有路径后面,而是直接替换成为新的路径起点。
看个最直观的对比:
import os print(os.path.join('/a/b', 'c.txt')) # /a/b/c.txt print(os.path.join('/a/b', '/c.txt')) # /c.txt第一个是普通拼接,第二个就是“顶替”。也就是说,任何用户可控参数,只要有机会出现在join的后续参数位置,并且能传入以/开头的字符串,前面的固定目录就完全失去了约束意义。
这个行为的本意其实是为了代码方便——很多开发者想写“如果传入绝对路径就以传入的为准”,比如配置合并、默认路径覆盖等场景。但放在Web文件读取里,就变成了绕过目录限制的一把钥匙。
2.2 join 不负责“清洗”路径
第二个关键点是os.path.join不会对路径做归一化。所谓归一化,是把..、.、连续斜杠等符号整理成最简路径的过程,Python里对应的是os.path.normpath。
对比一下:
import os raw = os.path.join('/tmp/sandbox/', '../flag.txt') print(raw) # /tmp/sandbox/../flag.txt normalized = os.path.normpath(raw) print(normalized) # /tmp/flag.txtos.path.join拿到什么字符串就拼什么字符串,它原样保留了../。而open()真正打开文件时,操作系统会解析路径语义,把..解释为“上一级目录”。于是出现了经典的错位:
- 代码检查的是字符串:
/tmp/sandbox/../flag.txt看起来还在/tmp/sandbox/范围内。 - 系统执行的是语义:路径解析时先进入
/tmp/sandbox,再退到/tmp,最后读/tmp/flag.txt。
这就是为什么有的题明明写了startswith('/tmp/sandbox/')这种校验,却还是能被../flag.txt打穿。字符串检查防不住语义级穿越,“长得像在范围内”和“实际落在范围内”是两回事。
2.3 平台差异的一句话提醒
如果你在Windows上做题,ntpath.join的规则还要多一层:参数中出现盘符(C:)或反斜杠(\)也会触发类似的顶替行为,而且Windows对大小写不敏感、对/和\都视为分隔符,可玩的花样更多。但很多CTF靶机跑的是Linux,..\、盘符这类payload基本没用。
所以拿题先确认操作系统很有必要:Linux用/和..来试,Windows再考虑反斜杠和盘符,别把一套payload打到所有靶机上。
3. CTF出题人为什么总爱拿它做文章
3.1 很多真实功能就是这么写的
在真实业务里,“固定目录 + 用户文件名”的结构太常见了:附件预览、头像读取、文件下载、导出报表、分享链接……后端往往先配置一个存储根目录,然后把用户传的文件名拼上去读取。开发者图省事,顺手就是一行os.path.join(STORAGE_DIR, filename),这行代码看起来人畜无害,实际上成了整个功能最容易被攻击的位置。
CTF出题人当然不会放过这个题材。他们把一个“看起来有防护、实际上到处都是缝”的功能抽象成题目,既能让新手学到东西,又能让老手在几秒内完成“看代码、找拼接点、构造payload”的标准动作。
3.2 出题人真正想考的东西
这类题目表面考的是os.path.join,实际在考两件事:
- 校验的到底是用户输入,还是最终拼接结果?
- 拼接结果有没有被归一化,校验有没有建立在“语义”层面?
如果只校验用户输入filename,那绝对路径顶替、编码绕过等手法都能钻空子;如果校验拼接后的字符串,但不做normpath/realpath,那..语义级穿越依然能戏耍前缀检查。只有把校验放在归一化之后、且针对最终路径本身,才算真正堵住了这条攻击面。
3.3 一眼识别“可绕”的特征
看到源码里有这类结构,可以条件反射地盯住三件事:
- 用户参数最终有没有进入
os.path.join的参数列表? - 参数里有没有以
/开头的可能性?如果有,绝对路径顶替就有戏。 - 拼接后有没有经过
normpath、realpath或类似处理?如果没有,..穿越大概率能成。
把这三个问题在脑子里过一遍,很多题目在读完代码的那一刻就已经结束了。
4. 完整实战模拟:从空手到读穿题目
4.1 题目背景与初始信息
我拿一个模拟题X来做例子。题面大概是:某小站有个“作品预览”功能,作者可以输入文件名预览自己上传到服务器上的作品。服务端只提供一个接口/preview,flag文件被管理员放在/tmp/flag.txt。你没有任何账号权限,唯一能打的就是这个预览功能。
这个设定在CTF里非常典型:接口越简单越好,漏洞点越靠近代码细节越好,flag放在一个“差一层就能拿到”的地方。
4.2 第一版代码:只过滤输入
先看第一版实现:
from flask import Flask, request, abort import os app = Flask(__name__) UPLOAD_DIR = '/tmp/sandbox/' @app.route('/preview') def preview(): name = request.args.get('file', '') if '..' in name: abort(403) path = os.path.join(UPLOAD_DIR, name) return open(path).read()这版代码的防守思路很清楚:用户传入的文件名里不允许出现..,然后用os.path.join强制拼到/tmp/sandbox/下面。看起来“既过滤了穿越,又固定了目录”。
问题是,它只过滤了用户输入,没想过os.path.join会被绝对路径带跑。直接打:
curl 'http://127.0.0.1:5000/preview?file=/tmp/flag.txt'这里的name是/tmp/flag.txt,字符串里没有..,过滤条件不触发。接下来:
os.path.join('/tmp/sandbox/', '/tmp/flag.txt')由于第二个参数以/开头,前面积累的/tmp/sandbox/被彻底丢弃,path直接等于/tmp/flag.txt。open(path)读到的就是flag。
这个版本的命门就是标题里说的绝对路径拼接漏洞:固定目录前缀在绝对路径面前不过是一张可以随手撕掉的标签。
很多刚入门的朋友会困惑:为什么出题人不把路径检查写成“拼接完再检查”?往下看第二版,他们就明白了,检查了也未必有用。
4.3 第二版代码:只校验拼接结果
第二版换了姿势,改成拼接后再校验:
@app.route('/preview') def preview(): name = request.args.get('file', '') path = os.path.join(UPLOAD_DIR, name) if not path.startswith(UPLOAD_DIR): abort(403) return open(path).read()这版确实把绝对路径顶替堵上了:name=/tmp/flag.txt时,path=/tmp/flag.txt,它不以/tmp/sandbox/开头,403。
但校验的是字符串前缀,不是路径的真实归属。试试相对路径穿越:
curl 'http://127.0.0.1:5000/preview?file=../flag.txt'后台的拼接过程:
os.path.join('/tmp/sandbox/', '../flag.txt') ==> '/tmp/sandbox/../flag.txt'检查path.startswith('/tmp/sandbox/')时,字符串确实是这个前缀开头的,校验通过。但操作系统在open()时解析路径:
/tmp/sandbox/../flag.txt --> 先进入 /tmp/sandbox --> 再回退到上一层 /tmp --> 最终读取 /tmp/flag.txtflag又被读到了。
这版代码的漏洞在于:字符串层面“看起来在目录里”不等于“语义层面真的在目录里”。os.path.join拼接完的结果原样带着../,而open()会按语义解析,检查方和执行方在“路径是什么”这个问题上产生了分歧。
4.4 第三版“双保险”为什么开始难缠
假如出题人把前面两个坑都补上:
@app.route('/preview') def preview(): name = request.args.get('file', '') if '..' in name: abort(403) path = os.path.normpath(os.path.join(UPLOAD_DIR, name)) if not path.startswith(UPLOAD_DIR): abort(403) return open(path).read()这版先过滤了输入里的..,拼接后又用normpath把路径归一化,再校验前缀。前面两种payload到这里基本没戏:
/tmp/flag.txt:normpath后是/tmp/flag.txt,前缀检查不过。../flag.txt:在输入过滤那一关就被拦了。
到这一步,想继续直接绕,就得找别的岔路。我的排查顺序一般是这样的:
- 先试各种URL编码,例如
%2e%2e%2f表示../,%252e%252e%252f表示双重编码后的..。这要看后端、网关、框架一共解码了几次,只有解码链路出现错位时才有空子可钻。 - 再看上传目录里有没有别人留下的符号链接。如果沙箱目录内存在一个指向
/tmp/flag.txt或其他敏感位置的符号链接,利用方式就完全不一样了。 - 最后翻路由表,看看除了
/preview有没有别的接口、静态文件路由、报错信息泄露了其他路径线索。很多时候题目不止一个入口,“最难的绕法”都在别的接口里。
这里也说明一个规律:os.path.join的直接绕过容易堵,但整个应用的攻击面很难堵。真正的护城河不是一行代码,而是整体设计。
5. 防御怎么写:五条硬规矩
5.1 别校验入参,校验最终解析结果
所有针对filename的黑名单过滤都默认了一件事——你能想到所有恶意表达方式。这显然不现实。正确的姿势是:先拼接,再归一化,再校验最终真实路径,校验的对象是“结果”而不是“原料”。
import os def safe_open(base_dir, user_input): base = os.path.realpath(base_dir) target = os.path.realpath(os.path.join(base, user_input)) if os.path.commonpath([base, target]) != base: raise ValueError('path escape') with open(target, 'rb') as f: return f.read()这里用realpath是因为它不仅能清理..和.,还会解析符号链接,把虚拟路径落地成真实路径。配合commonpath比较公共前缀,就能确保目标文件一定在base_dir下面。
5.2 用 normpath 还不够,要求再严格一步
normpath能解决..语义穿越,但它不会解析符号链接。如果沙箱目录里被塞了一个指向外部的 symlink,字符串前缀检查照样能过,open 的时候却会一路跟着链接跑出去。
所以在用户可控文件名的场景里,首选realpath而不只是normpath。realpath等于是让操作系统把路径彻底走一遍,落地之后再判断归属,语义和字符串就一致了。
5.3 前缀检查别用 startswith
startswith('/tmp/sandbox/')有一个容易忽略的坑:如果用户构造出/tmp/sandbox_evil/...这种字符串,它同样以/tmp/sandbox开头,却不在目标目录内。更严谨的做法是上面提到的commonpath,或者通过Path.is_relative_to(Python 3.9+)来判断。
from pathlib import Path base = Path('/tmp/sandbox').resolve() target = Path('/tmp/sandbox').joinpath(user_input).resolve() if not target.is_relative_to(base): raise ValueError('path escape')5.4 存储层用白名单重命名
与其费劲堵各种绕过,不如从一开始就不让用户文件名参与路径拼接。比如上传文件后,用随机的UUID作为存储文件名,原始文件名只存数据库当成展示元数据。读取时通过ID查库,而不是通过用户输入拼路径。这种方式把整个路径穿越面直接消灭了,属于我实际写代码时最推荐的一招。
5.5 把信任边界交给系统层
就算代码写得再严密,文件权限、目录挂载、运行进程权限这些系统层配置也可能给你“背刺”。最小化运行用户、沙箱目录独立挂载、容器内只读根文件系统,这些手段能兜住最后一层。CTF里你可能不需要考虑这些,但真实系统上,纵深防御才是唯一的答案。
另外还有一点容易忽略:检查完之后到open()之间有一个时间窗口,恶意进程可能趁机把文件替换成符号链接,这就是经典的TOCTOU问题。如果对安全性要求极高,用文件描述符操作、打开后校验文件状态会更稳,但那是另外一个更深的主题了。
6. 这类题我怎么排问题:速查表与个人习惯
6.1 常见问题速查
| 现象 | 可能原因 | 排查思路 |
|---|---|---|
过滤了..,但绝对路径能直接读 | 只校验了用户输入,没防join顶替前缀 | 直接试?file=/绝对路径 |
startswith检查了,../还是能穿 | 校验的是字符串前缀,没做路径归一化 | 试?file=../xxx,确认normpath是否存在 |
前缀和..都拦了,某些姿势还能读 | 解码链路、符号链接、其他接口 | 试编码、LS目录看链接、翻路由 |
| 本地测试成功,靶机上不通 | 操作系统或目录结构不同 | 先确认靶机系统,再调整路径深度 |
| 手动可以但脚本跑不通 | 转义、编码、请求头处理不一致 | 用直接发送原始请求的客户端工具重放 |
这张表基本覆盖了我在刷题和帮朋友看代码时遇到的大部分路径穿越问题。做排查时有个习惯值得分享:每次都把“用户输入是什么、拼接结果是什么、校验结果是什么、open解析结果是什么”这四个状态在注释里列一遍,很多绕法就是这四者之间的缝隙。
6.2 我自己的一点习惯
看这类题目和审这类代码,我自己的条件反射是:看到os.path.join立刻问三句——用户可控的部分在哪一侧?拼接后的结果有没有再被校验?校验的对象是字符串还是语义?这三句话在脑子里过完,漏洞在哪个环节基本就有数了。
最后分享一个很实用的小技巧:本地复现时,别只用print看拼接结果,多跑一步os.path.realpath看最终落地路径。很多新手在本地打不通,就是因为只看字符串、不看语义。把“字符串”和“语义”两套坐标系都拉出来对比,os.path.join的绝对路径拼接漏洞,从原理到实战就再也藏不住。