在C++程序开发过程中,当我们需要处理用户传入的文件路径时,恶意路径遍历是一个常见且危险的安全问题。攻击者可能会构造类似../../../etc/passwd这样的路径,尝试突破程序预设的目录限制,读取系统上的敏感文件,进而导致严重的安全事故。不少开发者会选择使用C++17标准引入的filesystem库中的lexically_normal函数对路径做规范化处理,期望通过这种方式过滤掉路径中的遍历字符,但实际使用中这个函数存在不少容易忽略的坑,处理不当无法完全实现防护效果。

路径遍历攻击的基本原理
路径遍历攻击的核心是利用文件路径中的相对路径标识符,最常见的是../,这个符号的作用是跳转到当前目录的上一级目录。如果程序没有对用户输入的路径做严格校验,直接拼接基础目录和用户传入的路径去读取文件,攻击者就可以通过构造多层../,跳出程序允许访问的目录范围。
比如程序预设的文件读取根目录是/home/app/upload/,正常用户传入的路径是user_avatar.jpg,拼接后的完整路径是/home/app/upload/user_avatar.jpg,属于安全范围。但如果攻击者传入的路径是../../../etc/passwd,拼接后的完整路径就会变成/home/app/upload/../../../etc/passwd,经过系统解析后实际访问的是/etc/passwd,这就造成了敏感文件泄露。
lexically_normal函数的特性与常见误区
C++17的filesystem库提供了lexically_normal函数,作用是返回路径的规范化形式,会移除路径中多余的目录分隔符、处理.和..等相对路径符号,但这个函数仅做词法层面的处理,不会检查路径对应的实际文件系统状态,这也是很多开发者容易踩坑的地方。
lexically_normal的基本用法
我们可以通过下面的代码查看lexically_normal的处理效果:
#include <iostream>
#include <filesystem>
namespace fs = std::filesystem;
int main() {
// 测试包含../的路径
fs::path test_path = "/home/app/upload/../../../etc/passwd";
fs::path normalized_path = test_path.lexically_normal();
std::cout << "原始路径: " << test_path << std::endl;
std::cout << "规范化后路径: " << normalized_path << std::endl;
return 0;
}
这段代码的输出结果是:
原始路径: "/home/app/upload/../../../etc/passwd" 规范化后路径: "/etc/passwd"
可以看到lexically_normal确实把路径中的../做了词法解析,得到了最终的相对路径结果,但这里就出现了第一个误区:很多开发者认为只要对用户输入的路径调用lexically_normal,再拼接基础目录就安全了,这是完全错误的。
lexically_normal的局限性
lexically_normal仅做词法层面的规范化,不会验证路径是否真实存在,也不会判断路径是否在预设的安全目录范围内。如果我们先拼接基础目录和用户路径,再调用lexically_normal,依然可能被绕过。
比如下面的错误用法示例:
#include <iostream>
#include <filesystem>
#include <string>
namespace fs = std::filesystem;
// 错误的校验方式
bool unsafe_check(const std::string& user_input) {
fs::path base_dir = "/home/app/upload";
fs::path full_path = base_dir / user_input;
// 先拼接再调用lexically_normal
fs::path normalized = full_path.lexically_normal();
// 仅判断规范化后的路径是否以base_dir开头
return normalized.string().find(base_dir.string()) == 0;
}
int main() {
std::string attack_input = "../../../etc/passwd";
std::cout << "是否通过校验: " << (unsafe_check(attack_input) ? "是" : "否") << std::endl;
return 0;
}
这段代码的输出结果是是否通过校验: 否吗?实际上不是,因为拼接后的路径是/home/app/upload/../../../etc/passwd,调用lexically_normal之后得到的是/etc/passwd,这个路径的字符串开头并不是/home/app/upload,所以看起来好像能拦截?但如果攻击者构造的路径是/home/app/upload/../../home/app/upload/../../../etc/passwd,规范化后依然是/etc/passwd,还是会被拦截。但这里的根本问题是,我们不应该依赖lexically_normal之后的路径字符串匹配,因为lexically_normal已经把路径中的../处理掉了,我们失去了判断是否原本存在遍历字符的依据。
另一个更危险的场景是,如果基础目录本身是可变的,或者用户输入的路径是绝对路径,lexically_normal的处理结果会更不可控。比如用户输入的是/etc/passwd绝对路径,拼接基础目录后full_path是/home/app/upload/etc/passwd,lexically_normal之后还是这个路径,看起来在基础目录下,但实际攻击者想要访问的是/etc/passwd,这种情况下如果程序逻辑有问题,依然可能出错。
正确的路径安全校验方案
要有效防止路径遍历攻击,不能只依赖lexically_normal,需要结合多步校验逻辑,下面是推荐的安全校验流程:
第一步:校验用户输入路径是否为绝对路径
如果程序只允许访问基础目录下的相对路径文件,那么首先要拒绝用户传入绝对路径,避免攻击者直接指定系统任意路径。
#include <filesystem>
namespace fs = std::filesystem;
bool is_absolute_path(const std::string& user_input) {
fs::path input_path(user_input);
return input_path.is_absolute();
}
第二步:先规范化用户输入路径,再拼接基础目录
不要先拼接再规范化,而是先对用户输入的路径单独做lexically_normal处理,这样可以先去掉用户输入中的.和多余的目录分隔符,再和基础目录拼接,避免拼接后的路径被lexically_normal处理掉关键的遍历标识。
fs::path user_path = fs::path(user_input).lexically_normal();
第三步:拼接基础目录后再次规范化,验证是否在安全范围内
拼接基础目录和规范化后的用户路径,再对完整路径做lexically_normal处理,然后判断处理后的完整路径是否以基础目录为前缀,同时还要注意基础目录的路径需要是规范化后的形式。
#include <iostream>
#include <filesystem>
#include <string>
namespace fs = std::filesystem;
// 安全的路径校验函数
bool safe_path_check(const std::string& user_input, const std::string& base_dir_str) {
// 1. 拒绝绝对路径输入
fs::path input_path(user_input);
if (input_path.is_absolute()) {
return false;
}
// 2. 规范化基础目录
fs::path base_dir = fs::path(base_dir_str).lexically_normal();
// 3. 先规范化用户输入路径
fs::path normalized_user_input = input_path.lexically_normal();
// 4. 拼接完整路径并再次规范化
fs::path full_path = base_dir / normalized_user_input;
fs::path final_path = full_path.lexically_normal();
// 5. 判断最终路径是否以基础目录为前缀
// 注意要转换为字符串比较,避免路径分隔符的差异问题
std::string final_str = final_path.string();
std::string base_str = base_dir.string();
// 确保基础目录路径以目录分隔符结尾,避免类似/home/app/upload匹配到/home/app/upload2的情况
if (base_str.back() != fs::path::preferred_separator) {
base_str += fs::path::preferred_separator;
}
if (final_str.back() != fs::path::preferred_separator) {
final_str += fs::path::preferred_separator;
}
return final_str.find(base_str) == 0;
}
int main() {
std::string base_dir = "/home/app/upload";
// 测试用例
std::cout << "测试正常路径: " << (safe_path_check("user_avatar.jpg", base_dir) ? "通过" : "拒绝") << std::endl;
std::cout << "测试遍历路径: " << (safe_path_check("../../../etc/passwd", base_dir) ? "通过" : "拒绝") << std::endl;
std::cout << "测试绝对路径: " << (safe_path_check("/etc/passwd", base_dir) ? "通过" : "拒绝") << std::endl;
std::cout << "测试带.的路径: " << (safe_path_check("./test/../avatar.jpg", base_dir) ? "通过" : "拒绝") << std::endl;
return 0;
}
这段代码的输出结果如下:
测试正常路径: 通过 测试遍历路径: 拒绝 测试绝对路径: 拒绝 测试带.的路径: 通过
第四步:可选的文件存在性校验
如果程序只需要读取已经存在的文件,可以在路径校验通过之后,再调用fs::exists判断文件是否存在,同时注意fs::exists可能会抛出异常,需要做异常处理。
#include <filesystem>
#include <iostream>
namespace fs = std::filesystem;
bool check_file_exists(const fs::path& file_path) {
try {
return fs::exists(file_path);
} catch (const fs::filesystem_error& e) {
std::cerr << "检查文件存在性出错: " << e.what() << std::endl;
return false;
}
}
避坑总结
使用lexically_normal做路径校验时,需要注意以下几个核心坑点:
- 不要先拼接基础目录和用户路径再调用lexically_normal,这样会丢失路径遍历的判断依据
- lexically_normal仅做词法规范化,不会校验路径的实际存在性和访问权限,不能单独作为安全校验的唯一手段
- 路径前缀匹配时要注意目录分隔符和路径结尾的问题,避免出现短路径匹配的长路径的漏洞
- 绝对路径输入要直接拒绝,避免攻击者绕过基础目录限制
按照先校验绝对路径、再单独规范化用户输入、拼接后再次规范化、最后前缀匹配的步骤处理,就能有效利用lexically_normal的特性,避免路径遍历攻击的风险。
C++文件安全路径遍历防护lexically_normal路径校验文件读取安全修改时间:2026-06-23 07:57:27