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 "";
  }
}