Skip to content

Commit 37119b3

Browse files
committed
Optimize arbitrary file module
1 parent ad10c6a commit 37119b3

10 files changed

Lines changed: 207 additions & 74 deletions

File tree

docs/instructions.md

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,6 @@
9393
| postMessage XSS | 页面HTML5特性/PostMessage XSS | `<img src=x onerror=alert(1)>` | 接收窗口未校验origin且用`innerHTML`写入消息 |
9494

9595

96-
9796
## SQL注入
9897

9998
SQL注入模块适合作为综合性Java靶场的核心模块:当前覆盖了JDBC原生拼接、伪预编译拼接、JdbcTemplate拼接、参数化查询、MyBatis动态SQL、Hibernate HQL/原生SQL、JPA JPQL/动态排序等常见开发栈。
@@ -194,5 +193,73 @@ SQL注入模块适合作为综合性Java靶场的核心模块:当前覆盖了J
194193
| JPA排序白名单 | `GET /sqli/jpa/safe-order?orderBy=username` | 合法字段 | 正常排序 |
195194
| JPA排序白名单拦截 | `GET /sqli/jpa/safe-order?orderBy=username desc` | 非白名单字段 | 返回排序字段不合法 |
196195

196+
## 任意文件操作
197+
198+
任意文件操作模块适合作为综合性Java靶场的基础模块:当前覆盖了任意文件上传、任意文件读取、任意文件下载、任意文件删除四类常见风险,能串联“上传恶意文件 -> 通过静态映射访问 -> 读取/下载敏感文件 -> 删除业务文件”的典型文件安全链路。
199+
200+
本次已将页面描述统一为:任意文件类漏洞的本质是用户可控的文件名、路径、内容或文件元数据进入文件系统操作后,应用没有正确限制目录边界、文件类型、访问方式和业务权限。修复时不要只依赖字符串替换、黑名单或前端限制,应使用后端白名单、服务端生成文件名、路径标准化、真实路径校验、目录隔离、权限校验和审计日志。
201+
202+
已覆盖类型
203+
204+
| 分类 | 已有场景 | 结论 |
205+
| -------- | ----------------------------------------------- | ------------------------------------------------------------ |
206+
| 文件上传 | 任意类型上传、图片后缀白名单、图片内容校验 | 覆盖上传入口、后缀校验误区和上传后访问方式的影响 |
207+
| 文件读取 | 绝对路径/目录穿越读取、上传目录限制 | 覆盖敏感文件读取和安全目录边界校验 |
208+
| 文件下载 | 绝对路径/目录穿越下载、文件名校验、上传目录限制 | 覆盖附件下载类接口的常见风险 |
209+
| 文件删除 | 任意路径删除、上传目录限制 | 覆盖破坏性文件操作风险,并强调只使用临时文件测试 |
210+
| 静态映射 | `/file/**` 映射到上传目录 | 说明上传文件可被访问,需关注脚本解析、内容类型和独立域名隔离 |
211+
212+
模块覆盖符合综合性靶场定位。后续如需增强,可补充“Zip Slip压缩包目录穿越”“文件覆盖/路径可控文件名”“软链接绕过专项”“MIME/魔数绕过”“大文件/压缩炸弹DoS”“日志文件读取/下载”等场景。当前 XSS 模块已包含 HTML/SVG/PDF/XML 上传导致 XSS 的内容安全案例,任意文件模块保留为文件系统操作主线即可。
213+
214+
### 文件上传测试
215+
216+
页面:`/file/upload`
217+
218+
| 场景 | 请求/入口 | 测试输入 | 预期结果 |
219+
| --------------------- | --------------------------- | ------------------------------------ | -------------------------------------------------- |
220+
| 任意文件上传 | `POST /file/upload/vul` | `test.jsp` 或任意扩展名文件 | 返回“上传文件成功”及 `/file/<文件名>` 访问路径 |
221+
| 上传后访问 | `GET /file/<文件名>` | 上一步返回文件名 | 文件可被静态映射访问;Spring Boot 默认不会解析 JSP |
222+
| 安全上传-图片 | `POST /file/upload/safe` | 真实 `png/jpg/gif/jpeg/bmp/ico` 图片 | 后缀白名单和图片内容校验均通过,上传成功 |
223+
| 安全上传-脚本拦截 | `POST /file/upload/safe` | `jsp/php/html` | 返回“只能上传图片哦!” |
224+
| 安全上传-伪造后缀拦截 | `POST /file/upload/safe` | 内容不是图片的 `test.png` | 返回“文件内容与图片类型不匹配!” |
225+
| 流量包/示例Payload | 页面右上角 Payload/流量分析 | `test.jsp``upload.pcapng` | 可下载对应测试文件 |
226+
227+
### 文件读取测试
228+
229+
页面:`/file/read`
230+
231+
| 场景 | 请求 | 测试输入 | 预期结果 |
232+
| --------------------- | --------------------------------------------------- | ------------------ | ------------------------------------------ |
233+
| 绝对路径读取 | `GET /file/read/vul?fileName=/etc/hosts` | `/etc/hosts` | 返回文件内容 |
234+
| 目录穿越读取 | `GET /file/read/vul?fileName=../../../../etc/hosts` | `../` payload | 如果路径解析到真实文件,返回文件内容 |
235+
| 安全读取-越权拦截 | `GET /file/read/safe?fileName=/etc/hosts` | 绝对路径 | 返回“访问被拒绝:文件路径不合法”或不可访问 |
236+
| 安全读取-目录内文件 | `GET /file/read/safe?fileName=<上传目录内文件名>` | 上传目录内普通文件 | 返回文件内容 |
237+
| 安全读取-符号链接绕过 | `GET /file/read/safe?fileName=<指向外部的软链接>` | 上传目录内软链接 | 返回“文件真实路径不合法” |
238+
239+
### 文件下载测试
240+
241+
页面:`/file/download`
242+
243+
| 场景 | 请求 | 测试输入 | 预期结果 |
244+
| --------------------- | ------------------------------------------------------- | ------------------ | -------------------------------- |
245+
| 绝对路径下载 | `GET /file/download/vul?fileName=/etc/passwd` | `/etc/passwd` | 以附件形式返回文件,存在则可下载 |
246+
| 目录穿越下载 | `GET /file/download/vul?fileName=../../../../etc/hosts` | `../` payload | 如果路径解析到真实文件,返回附件 |
247+
| 安全下载-非法文件名 | `GET /file/download/safe?fileName=/etc/hosts` | 绝对路径 | 返回 400 或 404,不允许下载 |
248+
| 安全下载-目录内文件 | `GET /file/download/safe?fileName=<上传目录内文件名>` | 上传目录内普通文件 | 正常下载 |
249+
| 安全下载-符号链接绕过 | `GET /file/download/safe?fileName=<指向外部的软链接>` | 上传目录内软链接 | 返回 403 “文件真实路径不合法” |
250+
251+
### 文件删除测试
252+
253+
页面:`/file/delete`
254+
255+
| 场景 | 请求 | 测试输入 | 预期结果 |
256+
| --------------------- | ------------------------------------------------------------ | ------------------ | -------------------------------- |
257+
| 任意路径删除 | `GET /file/delete/vul?filePath=./src/main/resources/static/upload/demo.txt` | 临时测试文件 | 文件存在时返回删除成功 |
258+
| 目录穿越删除 | `GET /file/delete/vul?filePath=../../tmp/demo.txt` | 仅限临时文件 | 如果路径存在且有权限,会尝试删除 |
259+
| 安全删除-越权拦截 | `GET /file/delete/safe?fileName=../test` | `../` payload | 返回“访问被拒绝:文件路径不合法” |
260+
| 安全删除-目录内文件 | `GET /file/delete/safe?fileName=<上传目录内文件名>` | 上传目录内普通文件 | 文件存在时删除成功 |
261+
| 安全删除-符号链接绕过 | `GET /file/delete/safe?fileName=<指向外部的软链接>` | 上传目录内软链接 | 返回“文件真实路径不合法” |
262+
263+
###
197264

198265
# Java 专题

src/main/java/top/whgojp/modules/file/controller/DeleteController.java

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,11 @@
1010
import org.springframework.web.bind.annotation.*;
1111
import org.springframework.web.multipart.MultipartFile;
1212
import top.whgojp.common.constant.SysConstant;
13-
import top.whgojp.common.utils.CheckUserInput;
14-
import top.whgojp.common.utils.R;
15-
import top.whgojp.common.utils.UploadUtil;
1613

1714
import java.io.File;
15+
import java.nio.file.Files;
16+
import java.nio.file.Path;
17+
import java.nio.file.Paths;
1818

1919
/**
2020
* @description 任意文件类-文件删除
@@ -59,11 +59,19 @@ public String vul(@RequestParam("filePath") String filePath) {
5959
@ResponseBody
6060
@SneakyThrows
6161
public String safe(@RequestParam("fileName") String fileName) {
62-
String baseDir = sysConstant.getUploadFolder(); // 限制删除文件所在目录为 /static/upload/下
63-
File file = new File(baseDir, fileName);
62+
String baseDir = sysConstant.getUploadFolder();
63+
Path basePath = Paths.get(baseDir).toRealPath();
64+
Path filePath = basePath.resolve(fileName).normalize();
65+
if (!filePath.startsWith(basePath)) {
66+
return "访问被拒绝:文件路径不合法";
67+
}
6468
boolean deleted = false;
65-
if (file.exists() && file.getCanonicalPath().startsWith(new File(baseDir).getCanonicalPath())) {
66-
deleted = file.delete();
69+
if (Files.isRegularFile(filePath)) {
70+
Path realFilePath = filePath.toRealPath();
71+
if (!realFilePath.startsWith(basePath)) {
72+
return "访问被拒绝:文件真实路径不合法";
73+
}
74+
deleted = Files.deleteIfExists(filePath);
6775
}
6876
if (deleted) {
6977
return "文件删除成功: " + fileName;

src/main/java/top/whgojp/modules/file/controller/DownloadController.java

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
import javax.servlet.http.HttpServletResponse;
1414
import java.io.*;
15+
import java.nio.file.Files;
1516
import java.nio.file.Path;
1617
import java.nio.file.Paths;
1718

@@ -63,16 +64,20 @@ public void safe(@RequestParam("fileName") String fileName, HttpServletResponse
6364
response.sendError(HttpServletResponse.SC_BAD_REQUEST, "非法文件名:" + fileName);
6465
return;
6566
}
66-
File file = new File(baseDir, fileName);
67-
if (file.exists() && file.isFile() && file.getCanonicalPath().startsWith(new File(baseDir).getCanonicalPath())) {
67+
Path basePath = Paths.get(baseDir).toRealPath();
68+
Path filePath = basePath.resolve(fileName).normalize();
69+
if (filePath.startsWith(basePath) && Files.isRegularFile(filePath)) {
70+
Path realFilePath = filePath.toRealPath();
71+
if (!realFilePath.startsWith(basePath)) {
72+
response.sendError(HttpServletResponse.SC_FORBIDDEN, "文件真实路径不合法:" + fileName);
73+
return;
74+
}
6875
response.setContentType("application/octet-stream");
69-
response.setHeader("Content-Disposition", "attachment; filename=\"" + file.getName() + "\"");
70-
try (FileInputStream fis = new FileInputStream(file);
76+
response.setHeader("Content-Disposition", "attachment; filename=\"" + realFilePath.getFileName().toString() + "\"");
77+
try (InputStream fis = Files.newInputStream(realFilePath);
7178
OutputStream os = response.getOutputStream()) {
7279
StreamUtils.copy(fis, os);
7380
os.flush();
74-
} catch (FileNotFoundException e) {
75-
throw new RuntimeException(e);
7681
} catch (IOException e) {
7782
throw new RuntimeException(e);
7883
}

src/main/java/top/whgojp/modules/file/controller/ReadController.java

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,23 +4,18 @@
44
import io.swagger.annotations.ApiOperation;
55
import lombok.SneakyThrows;
66
import lombok.extern.slf4j.Slf4j;
7-
import lombok.var;
8-
import org.apache.commons.io.FilenameUtils;
97
import org.springframework.beans.factory.annotation.Autowired;
108
import org.springframework.stereotype.Controller;
119
import org.springframework.web.bind.annotation.*;
12-
import org.springframework.web.multipart.MultipartFile;
1310
import top.whgojp.common.constant.SysConstant;
14-
import top.whgojp.common.utils.CheckUserInput;
15-
import top.whgojp.common.utils.R;
16-
import top.whgojp.common.utils.UploadUtil;
1711

1812
import java.io.File;
1913
import java.io.IOException;
2014
import java.nio.file.Files;
2115
import java.nio.file.Path;
2216
import java.nio.file.Paths;
2317
import java.util.stream.Collectors;
18+
import java.util.stream.Stream;
2419

2520
/**
2621
* @description 任意文件类-文件读取
@@ -50,7 +45,7 @@ public String vul(@RequestParam("fileName") String fileName) throws IOException
5045
if (file.exists() && file.isFile()) {
5146
Path filePath = file.toPath();
5247
// 使用 BufferedReader 和流 API 逐行读取文件
53-
try (var lines = Files.lines(filePath)) {
48+
try (Stream<String> lines = Files.lines(filePath)) {
5449
return lines
5550
.map(line -> line + "<br/>")
5651
.collect(Collectors.joining());
@@ -68,14 +63,18 @@ public String vul(@RequestParam("fileName") String fileName) throws IOException
6863
@ResponseBody
6964
public String safe(@RequestParam("fileName") String fileName) throws IOException {
7065
String baseDir = sysConstant.getUploadFolder();
71-
Path filePath = Paths.get(baseDir, fileName).normalize();
72-
// 确保文件路径在允许的目录中
73-
if (!filePath.startsWith(Paths.get(baseDir))) {
66+
Path basePath = Paths.get(baseDir).toRealPath();
67+
Path filePath = basePath.resolve(fileName).normalize();
68+
// 先标准化路径,再确认目标文件仍位于允许目录内。
69+
if (!filePath.startsWith(basePath)) {
7470
return "访问被拒绝:文件路径不合法";
7571
}
76-
File file = filePath.toFile();
77-
if (file.exists() && file.isFile()) {
78-
return new String(Files.readAllBytes(file.toPath()));
72+
if (Files.isRegularFile(filePath)) {
73+
Path realFilePath = filePath.toRealPath();
74+
if (!realFilePath.startsWith(basePath)) {
75+
return "访问被拒绝:文件真实路径不合法";
76+
}
77+
return new String(Files.readAllBytes(realFilePath));
7978
} else {
8079
return "文件不存在或路径不正确:" + fileName;
8180
}

src/main/java/top/whgojp/modules/file/controller/UploadController.java

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,12 @@
1313
import top.whgojp.common.utils.R;
1414
import top.whgojp.common.utils.UploadUtil;
1515

16+
import javax.imageio.ImageIO;
1617
import javax.servlet.http.HttpServletRequest;
18+
import java.awt.image.BufferedImage;
19+
import java.io.IOException;
20+
import java.io.InputStream;
21+
import java.util.Locale;
1722

1823
/**
1924
* @description 任意文件类-文件上传
@@ -60,11 +65,34 @@ public R safe(@RequestParam("file") MultipartFile file, HttpServletRequest reque
6065
if (!checkUserInput.checkFileSuffixWhiteList(suffix)){
6166
return R.error("只能上传图片哦!");
6267
}
68+
if (!isAllowedImageContent(file, suffix)) {
69+
return R.error("文件内容与图片类型不匹配!");
70+
}
6371
String path = request.getScheme() + "://" + request.getServerName() + ":" + request.getServerPort() + "/file/";
6472
res = uploadUtil.uploadFile(file, suffix, path);
6573
return R.ok(res);
6674
}
6775

76+
private boolean isAllowedImageContent(MultipartFile file, String suffix) throws IOException {
77+
String normalizedSuffix = suffix.toLowerCase(Locale.ROOT);
78+
if ("ico".equals(normalizedSuffix)) {
79+
try (InputStream inputStream = file.getInputStream()) {
80+
byte[] header = new byte[4];
81+
if (inputStream.read(header) != header.length) {
82+
return false;
83+
}
84+
return header[0] == 0 && header[1] == 0 && header[2] == 1 && header[3] == 0;
85+
}
86+
}
87+
try (InputStream inputStream = file.getInputStream()) {
88+
BufferedImage image = ImageIO.read(inputStream);
89+
return image != null;
90+
} catch (IOException e) {
91+
log.warn("图片内容校验失败:{}", e.getMessage());
92+
return false;
93+
}
94+
}
95+
6896

6997
// 返回JSP视图
7098
@GetMapping("/jsp")

0 commit comments

Comments
 (0)