05-路径穿越缺陷
2025/1/1大约 5 分钟安全开发安全开发应用安全
路径穿越缺陷 - 腾讯安全代码审计实战系列05
在操作系统中".."代表的是向上级目录跳转,如果程序在处理到诸如用 ../../../../../etc/passwd 的文件名时没有进行防护,则会跳转出当前工作目录,跳转到到其他目录中;从而返回系统敏感文件给用户。其危害为泄漏源码、泄漏系统敏感文件。
修复建议
- 对用户输入的文件路径进行严格验证,确保其不包含路径穿越序列(例如 '..')。检查并拒绝包含相对路径移动的请求。
- 使用文件路径白名单,只允许访问指定的安全目录和文件。
- 对用户输入的文件后缀进行白名单控制,仅允许安全的、预定义的文件类型。
- 实施路径规范化,通过函数如 Python 的 os.path.normpath() 或 Go 的 filepath.Clean() 来移除路径中的冗余序列,并验证最终文件路径是否位于预期的安全目录内。
示例代码
Java代码示例:
//bad:直接从请求中获取文件路径并执行删除操作,存在高风险
@RequestMapping("/path/delete")
public void delete(HttpServletRequest request, HttpServletResponse response) {
String filePath = request.getParameter("path");
File file = new File(filePath); // 文件全路径由客户端传入,禁止
file.delete();
}
//good:通过验证文件路径中是否包含非法字符来预防路径穿越
@RequestMapping("/path/download")
public void download(HttpServletRequest request, HttpServletResponse response) {
String fileName = request.getParameter("name");
String DIR = "/data/file/upload/"; //文件服务器映射目录,非web目录
if(fileName.contains("..")) {
return; // 如果检测到路径穿越尝试,不执行任何操作
}
File file = new File(DIR + fileName);
try {
InputStream inputStream = new FileInputStream(file);
OutputStream out = response.getOutputStream();
byte[] b = new byte[100];
int len;
while ((len = inputStream.read(b)) > 0) {
out.write(b, 0, len);
}
inputStream.close();
} catch (IOException e) {
e.printStackTrace();
}
}
//good:使用文件的规范路径来防止路径穿越,并确认文件位于指定目录
@RequestMapping("/path/upload")
public void safe_upload(@RequestParam(value="file") MultipartFile file) throws IOException {
String fileName = file.getOriginalFilename();
String DIR = "/data/file/upload/"; //文件服务器映射目录,非web目录
String filePath = DIR + fileName;
File tmpFile = new File(filePath);
if(!tmpFile.getCanonicalPath().startsWith(DIR)) {
throw new IOException(); // 如果文件路径不是以指定目录开始,抛出异常
}
try {
InputStream in = file.getInputStream();
FileUtils.copyInputStreamToFile(in, tmpFile);
} catch (IOException e) {
e.printStackTrace();
}
}
//good:结合路径检查和文件类型白名单来增强安全性
@RequestMapping("/path/delete")
public void safe_delete(HttpServletRequest request) {
String webRootPath = request.getSession().getServletContext().getRealPath("/");
String fileName = request.getParameter("name");
if(fileName.contains("..")) {
return; // 检查到路径穿越尝试,不执行操作
}
int pos = fileName.lastIndexOf(".");
String ext = fileName.substring(pos);
String whiteExt = ".jpg.jpeg.png.gif.bmp"; // 文件类型白名单,根据具体情况而定
if(whiteExt.contains(ext)) {
new File(webRootPath + fileName).delete();
}
}
//good:在上传文件时使用随机文件名并进行文件类型验证
@RequestMapping("/path/upload")
public void safe_upload(@RequestParam(value="file") MultipartFile file) throws IOException {
String fileName = file.getOriginalFilename();
String ext = fileName.substring(fileName.lastIndexOf("."));
String DIR = "/data/file/upload/"; //文件服务器映射目录,非web目录
String randomFileName = UUID.randomUUID().toString(); // 文件名为随机字符串
String filePath = DIR + randomFileName + ext;
File tmpFile = new File(filePath);
try {
InputStream in = file.getInputStream();
FileUtils.copyInputStreamToFile(in, tmpFile);
} catch (IOException e) {
e.printStackTrace();
}
}
Go代码示例:
// bad: 任意文件读取
func handler(w http.ResponseWriter, r *http.Request) {
path := r.URL.Query()["path"][0]
// 未过滤文件路径,可能导致任意文件读取
data, _ := ioutil.ReadFile(path)
w.Write(data)
// 对外部传入的文件名变量,还需要验证是否存在../等路径穿越的文件名
data, _ = ioutil.ReadFile(filepath.Join("/home/user/", path))
w.Write(data)
}
// bad: 任意文件写入
func unzip(f string) {
r, _ := zip.OpenReader(f)
for _, f := range r.File {
p, _ := filepath.Abs(f.Name)
// 未验证压缩文件名,可能导致../等路径穿越,任意文件路径写入
ioutil.WriteFile(p, []byte("present"), 0640)
}
}
// good: 检查压缩的文件名是否包含..路径穿越特征字符,防止任意写入
func unzipGood(f string) bool {
r, err := zip.OpenReader(f)
if err != nil {
fmt.Println("read zip file fail")
return false
}
for _, f := range r.File {
if !strings.Contains(f.Name, "..") {
p, _ := filepath.Abs(f.Name)
ioutil.WriteFile(p, []byte("present"), 0640)
} else {
return false
}
}
return true
}
PHP代码示例:
// bad:未检查文件名/路径
if (isset($_GET['filename'])) {
$path = "/var/www/html/" . $_GET['filename'];
echo file_get_contents($path); // 危险的直接文件访问,可能导致路径穿越
}
// good:检查了文件名/路径,是否包含路径穿越字符
if (isset($_GET['filename'])) {
$path = "/var/www/html/" . $_GET['filename'];
if (strpos($path, '..') === false) {
echo file_get_contents($path);
} else {
echo "filename is not valid";
}
}
Python代码示例:
# bad: 直接使用用户输入进行文件操作,未进行路径规范化
def unsafe_file_access(file_path):
with open(file_path, 'r') as file:
data = file.read()
return data
# good: 使用os.path.normpath()和白名单检查文件类型
import os
ALLOWED_EXTENSIONS = ['txt', 'jpg', 'png']
def allowed_file(filename):
# 检查文件扩展名是否合法
if ('.' in filename and
'..' not in filename and
os.path.splitext(filename)[1][1:].lower() in ALLOWED_EXTENSIONS):
return filename
return None
upload_dir = '/tmp/upload/' # 预期的上传目录
file_name = '../../etc/hosts' # 用户传入的文件名
absolute_path = os.path.join(upload_dir, file_name) # 结合基础路径
normalized_path = os.path.normpath(absolute_path) # 规范化路径
if not normalized_path.startswith(upload_dir): # 检查最终路径是否在预期的上传目录中
raise IOError()
# good: 严格控制用户可以访问的文件
def secure_file_access(file_path, base_path='/var/www/data/'):
# 规范化并绝对化路径
normalized_path = os.path.normpath(os.path.join(base_path, file_path))
# 确保路径位于基础路径下
if not normalized_path.startswith(base_path):
raise ValueError("Invalid file path")
with open(normalized_path, 'r') as file:
return file.read()
JavaScript代码示例:
const fs = require("fs");
const path = require("path");
// bad:直接拼接用户输入路径,没有进行校验
let root = '/data/ufile';
fs.readFile(root + req.query.ufile, (err, data) => {
if (err) {
return console.error(err);
}
console.log(`异步读取: ${data.toString()}`);
});
// bad:尽管使用了path.join,但没有完全避免路径穿越问题
let filename = path.join(root, req.query.ufile);
if (filename.indexOf("..") < 0) {
fs.readFile(filename, (err, data) => {
if (err) {
return console.error(err);
}
console.log(`File Read: ${data.toString()}`);
});
};
// good:进行路径规范化并检查最终路径是否在预期目录
filename = path.resolve(root, req.query.ufile);
if (filename.startsWith(root)) {
fs.readFile(filename, (err, data) => {
if (err) {
return console.error(err);
}
console.log(`Secure File Read: ${data.toString()}`);
});
} else {
console.error('Attempted path traversal');
}
WebGoat-main/src/main/java/org/owasp/webgoat/lessons/xxe/Ping.java
/*
Ping.java:44 username是污点来源
Ping.java:50 路径穿越类型风险触发,由入参username导致
*/
@Slf4j
public class Ping {
@Value("${webgoat.user.directory}")
private String webGoatHomeDirectory;
@GetMapping
@ResponseBody
public String logRequest(
@RequestHeader("User-Agent") String userAgent,
@RequestParam(required = false) String text,
@CurrentUsername String username) {
String logLine = String.format("%s %s %s", "GET", userAgent, text);
log.debug(logLine);
File logFile = new File(webGoatHomeDirectory, "/XXE/log" + username + ".txt");
try {
try (PrintWriter pw = new PrintWriter(logFile)) {
pw.println(logLine);
}
} catch (FileNotFoundException e) {
log.error("Error occurred while writing the logfile", e);
}
return "";
}
}