目录遍历与目录穿越漏洞详解

文章目录

    • [1. 概述](#1. 概述)
      • [1.1 什么是目录遍历(Directory Traversal)?](#1.1 什么是目录遍历(Directory Traversal)?)
      • [1.2 核心问题](#1.2 核心问题)
      • [1.3 影响范围](#1.3 影响范围)
      • [1.4 CWE 分类](#1.4 CWE 分类)
      • [1.5 OWASP 排名](#1.5 OWASP 排名)
    • [2. 漏洞原理](#2. 漏洞原理)
      • [2.1 正常行为 vs 攻击行为](#2.1 正常行为 vs 攻击行为)
      • [2.2 根本原因](#2.2 根本原因)
      • [2.3 Windows vs Linux 差异](#2.3 Windows vs Linux 差异)
    • [3. 攻击类型](#3. 攻击类型)
      • [3.1 基础目录穿越(Basic Path Traversal)](#3.1 基础目录穿越(Basic Path Traversal))
      • [3.2 目录列举穿越(Directory Listing)](#3.2 目录列举穿越(Directory Listing))
      • [3.3 文件上传 + 目录穿越(Upload with Path Traversal)](#3.3 文件上传 + 目录穿越(Upload with Path Traversal))
      • [3.4 数据库路径穿越(Database Path Traversal)](#3.4 数据库路径穿越(Database Path Traversal))
      • [3.5 绝对路径穿越(Absolute Path Traversal)](#3.5 绝对路径穿越(Absolute Path Traversal))
      • [3.6 压缩包目录穿越(Zip Slip)](#3.6 压缩包目录穿越(Zip Slip))
    • [4. 绕过技术](#4. 绕过技术)
      • [4.1 简单字符串过滤绕过](#4.1 简单字符串过滤绕过)
      • [4.2 URL 编码绕过](#4.2 URL 编码绕过)
      • [4.3 16 进制 / Unicode 绕过](#4.3 16 进制 / Unicode 绕过)
      • [4.4 路径规范化差异绕过](#4.4 路径规范化差异绕过)
      • [4.5 符号链接攻击](#4.5 符号链接攻击)
      • [4.6 Null Byte 注入(已过时)](#4.6 Null Byte 注入(已过时))
    • [5. 防御方案](#5. 防御方案)
    • [6. 代码演示](#6. 代码演示)
      • [6.1 技术栈](#6.1 技术栈)
      • [6.2 项目结构](#6.2 项目结构)
      • [6.3 数据库结构](#6.3 数据库结构)
      • [6.4 演示内容](#6.4 演示内容)
      • [6.5 启动方式](#6.5 启动方式)

面向初学者的交互式学习文档 ------ 理解漏洞原理,掌握防御方法

配套演示项目:DirectoryTraversalDemos(Node.js + Express + PostgreSQL)

https://gitcode.com/lcreek/Security


1. 概述

1.1 什么是目录遍历(Directory Traversal)?

目录遍历 (又称路径穿越目录穿越 ,英文:Directory Traversal / Path Traversal)是一种 Web 安全漏洞,攻击者通过操纵文件路径中的 ../(或 ..\)序列,突破应用程序预期的目录范围,访问服务器文件系统上不应该被访问的文件和目录。

1.2 核心问题

复制代码
用户输入的 "文件名" → 被直接拼接到文件路径中 → 攻击者注入 "../" 跳出限制目录

一句话总结: 程序把用户输入当作"路径"的一部分拼接了,而不是当作"文件名"来处理。

1.3 影响范围

风险等级 危害
高危 读取系统配置文件(如 /etc/passwd)、源代码、数据库凭证
严重 读取到数据库密码后 → 数据库脱库
严重 读取到密钥文件后 → 伪造身份、解密敏感数据
严重 结合文件上传 → 写入 Webshell → 远程代码执行(RCE)

1.4 CWE 分类

  • CWE-22: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')
  • CWE-23: Relative Path Traversal
  • CWE-36: Absolute Path Traversal

1.5 OWASP 排名

连续多年位列 OWASP Top 10 ,在 OWASP Top 10:2021 中属于 A01:2021 -- Broken Access Control(访问控制失效)。


2. 漏洞原理

2.1 正常行为 vs 攻击行为

本演示项目中,服务器将 documents/ 目录作为允许访问的基础目录。

正常场景: 用户请求查看文档 manual.txt,服务器拼接路径:

复制代码
基础目录: D:\Programs\Security\DirectoryTraversalDemos\documents\
用户输入: manual.txt
最终路径: D:\Programs\Security\DirectoryTraversalDemos\documents\manual.txt   安全

攻击场景: 攻击者输入 ../server.js

复制代码
基础目录: D:\Programs\Security\DirectoryTraversalDemos\documents\
用户输入: ../server.js
最终路径: D:\Programs\Security\DirectoryTraversalDemos\documents\..\server.js
        = D:\Programs\Security\DirectoryTraversalDemos\server.js   危险!读取到服务器源代码

更严重的攻击: 输入 ../../package.json 可读取项目配置,输入 ../documents/secret.txt 可读取机密文件中的 API 密钥和数据库密码。

2.2 根本原因

以本演示的"演示1"为例,漏洞版本的路由代码(server.js):

javascript 复制代码
// server.js - 演示1 漏洞版本
app.get("/api/file/vulnerable", (req, res) => {
  const filename = req.query.file || "manual.txt";

  // 【漏洞核心】用户输入直接拼接到路径中,无任何验证
  const filePath = path.join(DOCUMENTS_DIR, filename);

  const content = fs.readFileSync(filePath, "utf-8");
  // ...
});

问题出在:

  1. 用户输入被直接拼接到文件系统路径中 --- path.join(DOCUMENTS_DIR, filename) 中的 filename 来自用户
  2. 没有验证最终的绝对路径是否仍在允许的目录范围内 --- 没有检查解析后的路径是否还在 documents/
  3. 没有过滤路径穿越字符 ../..\

2.3 Windows vs Linux 差异

特性 Linux Windows
路径分隔符 / \
穿越符号 ../ ..\
敏感文件 /etc/passwd C:\Windows\System32\config\SAM
绝对路径 /etc/shadow C:\boot.ini
空字节截断 %00(旧版本PHP) %00(旧版本ASP)

注意: Node.js 的 path 模块会自动将 / 转换为当前系统的分隔符,所以在 Node.js 中 ../ 在 Windows 上同样生效。本演示在 Windows 上运行时,../..\ 效果相同。


3. 攻击类型

3.1 基础目录穿越(Basic Path Traversal)

最简单的攻击形式,直接使用 ../ 序列向上穿越。

本演示对应:演示1 - 基础目录穿越GET /api/file/vulnerable?file=xxx

复制代码
输入: ../server.js
结果: 读取到项目根目录的 server.js 源代码

典型 Payload(可在演示页面中尝试):

复制代码
manual.txt              → 正常读取用户手册
./secret.txt            → 读取 documents 目录下的机密文件(含 API 密钥)
../package.json         → 读取项目配置文件
../server.js            → 读取服务器源代码
../etc/secret.txt       → 穿越到 etc/ 目录读取敏感文件
../documents/config.ini → 穿越后重新读取配置文件
../../SQLInjectionDemos → 读取同级其他项目目录

3.2 目录列举穿越(Directory Listing)

攻击者遍历到父目录后,列出任意目录的文件清单,获取服务器目录结构信息。

本演示对应:演示2 - 目录列举穿越GET /api/list/vulnerable?dir=xxx

复制代码
输入: ..
结果: 列出 D:\Programs\Security\DirectoryTraversalDemos\ 目录下的所有文件
      包括 db/、documents/、etc/、public/、package.json、server.js

典型 Payload:

复制代码
.                          → 列出 documents 目录(默认范围)
..                         → 列出项目根目录(看到 package.json、server.js)
../..                      → 列出上级目录(Security 目录)
../../SQLInjectionDemos    → 列出另一个项目目录

3.3 文件上传 + 目录穿越(Upload with Path Traversal)

在上传文件时,文件名中包含 ../ 序列,将文件写入到预期目录之外。

本演示对应:演示3 - 文件上传穿越POST /api/upload/vulnerable

复制代码
漏洞代码: const maliciousFilename = req.body.filename || req.file.originalname;
         const resolvedPath = path.resolve(path.join(UPLOADS_DIR, maliciousFilename));
         fs.writeFileSync(resolvedPath, fs.readFileSync(req.file.path));
攻击者构造文件名: ../../etc/hack.html
结果: 文件被写入 etc/ 目录而非 uploads/ 目录

重要说明:

浏览器文件选择器会限制文件名不允许包含 / 字符,但这只是前端限制,攻击者可以通过以下方式绕过:

  • 使用 curl 命令直接构造请求:curl -X POST -F "file=@shell.php;filename=../public/shell.php"
  • 使用 API 客户端或移动 App 上传(不受浏览器限制)
  • 通过代理或中间人攻击修改 HTTP 请求中的文件名
  • 上传包含恶意路径的压缩包(Zip Slip 漏洞)
    这是 CVSS 评分最高的场景:目录穿越 + 文件上传 = 远程代码执行(RCE)

3.4 数据库路径穿越(Database Path Traversal)

数据库中存储的文件路径被污染,后端从数据库读取路径后直接拼接读取。

本演示对应:演示4 - 数据库路径穿越GET /api/dbfile/vulnerable/:id

本演示的 demo_files 表中,ID=3 的记录存储了恶意路径:

sql 复制代码
INSERT INTO demo_files (title, filename, file_path, category) VALUES
  ('系统密码文件', 'secret.txt', '../etc/secret.txt', 'private');
复制代码
数据库记录 ID=3: file_path = '../etc/secret.txt'
后端代码: path.join(DOCUMENTS_DIR, row.file_path)
最终路径: documents/../etc/secret.txt = etc/secret.txt

为什么数据库会有恶意路径?

  • 二次注入: 攻击者通过其他漏洞(如 SQL 注入)修改了文件路径字段
  • 数据导入: 从外部数据源导入时包含了恶意路径
  • 批量操作: 管理员批量编辑时不小心写入了恶意路径

3.5 绝对路径穿越(Absolute Path Traversal)

当程序没有检查用户输入是否为绝对路径时,攻击者直接提供绝对路径。

复制代码
输入: C:\Windows\System32\drivers\etc\hosts
结果: 直接读取系统文件(跳过了基础目录拼接)

3.6 压缩包目录穿越(Zip Slip)

解压 ZIP/TAR 等压缩包时,压缩包内的文件名包含 ../ 序列。

复制代码
压缩包内文件名: ../../.ssh/authorized_keys
解压后路径:    /home/user/app/uploads/../../.ssh/authorized_keys
             = /home/user/.ssh/authorized_keys
结果: 覆盖用户的 SSH 授权密钥 → 获得 SSH 访问权限

4. 绕过技术

4.1 简单字符串过滤绕过

场景: 开发者过滤了 ../

javascript 复制代码
// 尝试过滤(不推荐)
filename = filename.replace(/\.\.\//g, "");

绕过:

Payload 过滤后 结果
....// 去掉中间的 ../../ 绕过成功
..././ 替换后 → ../ 绕过成功
..\/ 未被匹配 绕过成功

4.2 URL 编码绕过

攻击者使用 URL 编码来隐藏 ../

编码方式 Payload
单次编码 %2e%2e%2f../
双次编码 %252e%252e%252f%2e%2e%2f../
混合编码 ..%2f%2e%2e/
Unicode ..%c0%af..%c1%9c

4.3 16 进制 / Unicode 绕过

复制代码
%c0%ae%c0%ae%c0%af = ../../
(利用 UTF-8 超长编码)

4.4 路径规范化差异绕过

不同系统的路径规范化行为不同:

复制代码
Payload:  .../.../.../etc/passwd
Windows: unlink 不会移除 ..,导致穿越
Node.js: path.normalize() 可能处理不一致

4.5 符号链接攻击

攻击者在可访问目录中创建符号链接指向目标文件:

bash 复制代码
ln -s /etc/passwd /var/www/app/uploads/hack.txt

然后请求 /uploads/hack.txt,读取到 /etc/passwd

4.6 Null Byte 注入(已过时)

旧版 PHP (< 5.3.4) 中,%00 可以截断字符串:

复制代码
输入: ../../../etc/passwd%00.jpg
PHP处理: ../../../etc/passwd\0.jpg → /etc/passwd

注意: Node.js 不受 Null Byte 注入影响,此方法已基本淘汰。


5. 防御方案

5.1 核心防御函数

本演示项目中,所有安全版本共用一个 validatePath() 函数(定义在 server.js 中):

javascript 复制代码
function validatePath(userInput, baseDir) {
  // 步骤1: 用 path.join 拼接基础目录和用户输入
  const fullPath = path.join(baseDir, userInput);

  // 步骤2: path.resolve 将路径规范化,消除所有 .. 和 .
  const resolvedPath = path.resolve(fullPath);

  // 步骤3: 规范化基础目录(处理符号链接等)
  const resolvedBase = path.resolve(baseDir);

  // 步骤4: 确保规范化后的路径以基础目录 + 分隔符开头
  // 加 path.sep 是为了防止 /var/app/documents-fake 这样的目录绕过
  if (
    !resolvedPath.startsWith(resolvedBase + path.sep) &&
    resolvedPath !== resolvedBase
  ) {
    return {
      valid: false,
      resolvedPath: resolvedPath,
      error: `路径穿越被拦截!目标路径 "${resolvedPath}" 不在允许的目录 "${resolvedBase}" 内`,
    };
  }

  return { valid: true, resolvedPath: resolvedPath, error: null };
}

关键要点:

步骤 代码 作用
1. 拼接 path.join(baseDir, userInput) 将用户输入与基础目录组合
2. 规范化 path.resolve(fullPath) 消除 ../.,得到绝对路径
3. 基准规范化 path.resolve(baseDir) 确保基础目录也是绝对路径
4. 前缀验证 startsWith(resolvedBase + path.sep) 确保最终路径仍在允许的目录下

为什么加 path.sep 防止 documents-fake 这类前缀相同的目录绕过。例如 "/var/app/documents-fake".startsWith("/var/app/documents") 返回 true,但加上 path.sep"/var/app/documents-fake".startsWith("/var/app/documents/") 返回 false

5.2 各演示的漏洞 vs 防御对比

演示1:基础目录穿越

场景:文档阅读器,用户输入文件名,服务器从 documents/ 目录读取文件。

javascript 复制代码
// 漏洞版本 - 直接拼接
app.get("/api/file/vulnerable", (req, res) => {
  const filename = req.query.file || "manual.txt";
  const filePath = path.join(DOCUMENTS_DIR, filename); // 无任何验证
  const content = fs.readFileSync(filePath, "utf-8");
});

// 安全版本 - 路径验证
app.get("/api/file/safe", (req, res) => {
  const filename = req.query.file || "manual.txt";
  const validation = validatePath(filename, DOCUMENTS_DIR); // 路径验证
  if (!validation.valid) {
    return res.json({ error: validation.error });
  }
  const content = fs.readFileSync(validation.resolvedPath, "utf-8");
});

演示效果:

  • 漏洞版本输入 ../server.js → 成功读取服务器源代码
  • 安全版本输入 ../server.js → 返回"路径穿越被拦截"错误
演示2:目录列举穿越

场景:文件浏览器,用户输入目录名,服务器列出该目录下的文件。

javascript 复制代码
// 漏洞版本 - 无限制列举
app.get("/api/list/vulnerable", (req, res) => {
  const dirname = req.query.dir || ".";
  const dirPath = path.join(DOCUMENTS_DIR, dirname); // 直接拼接
  const entries = fs.readdirSync(dirPath, { withFileTypes: true });
});

// 安全版本 - 限定目录
app.get("/api/list/safe", (req, res) => {
  const dirname = req.query.dir || ".";
  const validation = validatePath(dirname, DOCUMENTS_DIR); // 路径验证
  if (!validation.valid) {
    return res.json({ error: validation.error });
  }
  const entries = fs.readdirSync(validation.resolvedPath, {
    withFileTypes: true,
  });
});

演示效果:

  • 漏洞版本输入 .. → 列出项目根目录(看到 package.jsonserver.js 等)
  • 安全版本输入 .. → 返回"路径穿越被拦截"错误
演示3:文件上传穿越

场景:文件上传功能,将用户上传的文件保存到 uploads/ 目录。

javascript 复制代码
// 漏洞版本 - 从请求 body 中获取用户提供的文件名
app.post("/api/upload/vulnerable", multer.single("file"), (req, res) => {
  // 【漏洞核心】从请求 body 中获取文件名,直接拼接路径
  const maliciousFilename = req.body.filename || req.file.originalname;
  const dangerousPath = path.join(UPLOADS_DIR, maliciousFilename);
  const resolvedPath = path.resolve(dangerousPath);
  // 手动将文件写入目标路径(模拟不安全的文件保存方式)
  fs.writeFileSync(resolvedPath, fs.readFileSync(req.file.path));
});

// 安全版本 - UUID 重命名
const safeStorage = multer.diskStorage({
  destination: (req, file, cb) => cb(null, UPLOADS_DIR),
  filename: (req, file, cb) => {
    const ext = path.extname(file.originalname);
    const safeName = crypto.randomUUID() + ext; // 完全忽略原始文件名
    cb(null, safeName);
  },
});

演示效果:

  • 漏洞版本:选择任意文件,输入恶意文件名(如 ../../etc/hack.html)→ 文件被写入到指定路径
  • 安全版本:上传文件 → 文件名被替换为 UUID(如 a1b2c3d4-e5f6-7890-abcd-ef1234567890.txt),路径穿越攻击无效

注意: 浏览器文件选择器不允许文件名包含 / 字符。漏洞版本通过手动输入文件名模拟攻击者构造的恶意文件名,真实攻击可通过 curl、API 客户端或修改 HTTP 请求实现。

演示4:数据库路径穿越

场景:文档管理系统,demo_files 表中存储文件路径,用户通过 ID 读取文件。

javascript 复制代码
// 漏洞版本 - 数据库路径直接拼接
app.get("/api/dbfile/vulnerable/:id", async (req, res) => {
  const result = await pool.query(
    "SELECT id, title, filename, file_path, category FROM demo_files WHERE id = $1",
    [req.params.id],
  );
  const row = result.rows[0];
  const filePath = path.join(DOCUMENTS_DIR, row.file_path); // 直接拼接数据库路径
  const content = fs.readFileSync(filePath, "utf-8");
});

// 安全版本 - 对数据库路径也做验证
app.get("/api/dbfile/safe/:id", async (req, res) => {
  const result = await pool.query(
    "SELECT id, title, filename, file_path, category FROM demo_files WHERE id = $1",
    [req.params.id],
  );
  const row = result.rows[0];
  const validation = validatePath(row.file_path, DOCUMENTS_DIR); // 路径验证
  if (!validation.valid) {
    return res.json({ error: validation.error });
  }
  const content = fs.readFileSync(validation.resolvedPath, "utf-8");
});

演示效果:

  • 漏洞版本输入 ID=5 → 数据库中存储的路径 ../../../etc/passwd 被直接拼接,尝试穿越读取系统文件
  • 安全版本输入 ID=5 → 路径验证拦截穿越攻击,返回"路径穿越被拦截"错误

5.3 多层防御汇总

防御层 方法 作用 本项目使用
路径规范 path.resolve() + startsWith 检查 阻止路径穿越 演示1/2/4
UUID 重命名 crypto.randomUUID() 消除文件名攻击面 演示3
最小权限 应用以低权限用户运行 限制可读取的文件范围 建议部署时
Chroot 限制应用的文件系统可见范围 即使穿越也无法访问系统文件 建议部署时

5.4 Node.js 特定注意事项

javascript 复制代码
// 陷阱1: path.join 对绝对路径的处理
path.join("/safe/dir", "/etc/passwd");
// Windows: \safe\dir\etc\passwd (拼接了)
// Linux:   /safe/dir/etc/passwd  (拼接了)
// 但如果 userInput 本身就是绝对路径,在某些框架中可能被直接使用

// 陷阱2: startsWith 的前缀匹配问题
"/var/app/documents-fake/secret.txt".startsWith("/var/app/documents"); // true!
// 解决方案:拼接 path.sep
"/var/app/documents-fake/secret.txt".startsWith(
  "/var/app/documents" + path.sep,
); // false

// 正确做法
const baseDir = path.resolve("/safe/dir");
const target = path.resolve(path.join(baseDir, userInput));
if (!target.startsWith(baseDir + path.sep) && target !== baseDir) {
  throw new Error("Access denied: path traversal detected");
}

6. 代码演示

本目录下的 DirectoryTraversalDemos 项目提供了完整的可运行演示。

6.1 技术栈

组件 技术 说明
后端框架 Express 4.x Node.js 最流行的 Web 框架
数据库 PostgreSQL 通过 pg 驱动连接(演示4使用)
文件上传 multer 1.x Express 文件上传中间件(演示3使用)
前端 原生 HTML/CSS/JS 深色主题,4个演示面板,每个都有漏洞/安全对比

6.2 项目结构

复制代码
DirectoryTraversalDemos/
├── 目录遍历与目录穿越漏洞详解.md   ← 本文档
├── package.json                    ← 项目配置(express + pg + multer)
├── server.js                       ← 主服务器(含 8 个路由:4 漏洞 + 4 安全)
├── db/
│   ├── pool.js                     ← PostgreSQL 连接池配置
│   └── init.sql                    ← 数据库初始化脚本(建表 + 插入测试数据)
├── documents/                      ← 允许访问的文档目录(基础目录)
│   ├── manual.txt                  ← 用户手册
│   ├── config.ini                  ← 配置文件(含模拟密钥:api_key、admin_password)
│   └── secret.txt                  ← 机密文件(含 API 密钥、数据库密码)
├── etc/                            ← 模拟系统敏感文件目录(用于演示穿越攻击目标)
│   └── secret.txt                  ← 敏感文件(模拟系统级机密)
├── uploads/                        ← 文件上传目标目录(服务器运行时自动创建)
└── public/
    ├── index.html                  ← 演示首页(4 个演示面板 + JavaScript 交互逻辑)
    └── style.css                   ← 深色主题样式表

6.3 数据库结构

sql 复制代码
-- demo_files 表:存储文档元数据和文件路径
CREATE TABLE demo_files (
    id SERIAL PRIMARY KEY,
    title VARCHAR(200) NOT NULL,      -- 文档标题
    filename VARCHAR(500) NOT NULL,   -- 文件名
    file_path VARCHAR(500) NOT NULL,  -- 文件路径(可能包含恶意路径)
    category VARCHAR(50) DEFAULT 'public'  -- 分类:public / private / internal
);

-- 测试数据(ID=5 的路径为恶意数据,用于演示数据库路径穿越)
INSERT INTO demo_files (title, filename, file_path, category) VALUES
  ('系统使用手册', 'manual.txt', 'documents/manual.txt', 'public'),
  ('配置说明', 'config.ini', 'documents/config.ini', 'public'),
  ('内部备忘录', 'memo.txt', 'documents/internal/memo.txt', 'internal'),
  ('数据库连接配置', 'database.properties', '/etc/secret/database.properties', 'private'),
  ('系统密码文件', 'passwd.txt', '../../../etc/passwd', 'private');  -- 恶意数据

6.4 演示内容

编号 演示名称 漏洞路由 安全路由 漏洞核心 防御方法
1 基础目录穿越 GET /api/file/vulnerable?file=xxx GET /api/file/safe?file=xxx path.join() 直接拼接 validatePath() 路径验证
2 目录列举穿越 GET /api/list/vulnerable?dir=xxx GET /api/list/safe?dir=xxx 无限制目录列举 路径前缀验证
3 文件上传穿越 POST /api/upload/vulnerable POST /api/upload/safe 使用 file.originalname crypto.randomUUID() 重命名
4 数据库路径穿越 GET /api/dbfile/vulnerable/:id GET /api/dbfile/safe/:id 数据库路径直接拼接 路径验证 + 权限控制

6.5 启动方式

bash 复制代码
cd DirectoryTraversalDemos

# 1. 创建数据库
psql -U postgres -c "CREATE DATABASE directory_traversal_demos;"

# 2. 初始化数据表
psql -U postgres -d directory_traversal_demos -f db/init.sql

# 3. 安装依赖
npm install

# 4. 启动服务(端口 3001)
npm start

# 5. 访问 http://localhost:3001

演示 1-3 不依赖数据库,可直接使用。演示 4 需要 PostgreSQL 运行中。


最后提醒: 本文档和演示代码仅供安全教学和研究使用。在生产环境中,务必对用户输入进行严格的路径验证和过滤,遵循最小权限原则。