⚠️ 免责声明:本项目仅用于安全学习与教育目的,严禁用于非法用途。如因使用本项目造成的任何法律后果,使用者自行承担全部责任。
一、项目背景 本项目是基于 SpringBoot + Vue 的图书管理系统,实现了图书查询、借阅、借阅记录查看、用户登录注册等功能。在学习 Web 安全知识后,我对项目进行了主动的代码审计,发现两处高危漏洞:SQL 注入和水平越权。
项目源码:GitHub - library-security-lab
二、SQL 注入漏洞 2.1 漏洞原理 ${} 和 #{} 的区别: @Select("SELECT * FROM books WHERE book_name LIKE '%${bookName}%' AND author LIKE '%${author}%'") 这个是危险的写法 ,${} 是字符串替换,直接把参数拼接到 SQL 里。
举例:
'1'='1' 永远都是真的,逻辑就被篡改了。
@Select("SELECT * FROM books WHERE book_name LIKE #{bookName} AND author LIKE #{author}") 这个是安全的写法 ,#{} 是预编译参数,这些参数会被当做纯文本来处理。
举例:
用户输入:' OR '1'='1' -- - SQL:SELECT * FROM books WHERE book_name LIKE ?(? 是占位符) 数据库收到以后就是普通的字符串,不是 SQL 代码。
2.2 漏洞代码位置 BookMapper.java
@Select("SELECT * FROM books WHERE book_name LIKE '%${bookName}%' AND author LIKE '%${author}%'") BookService.java
return bookMapper.searchBoooksVuln(
nameEmpty ? "%" : bookName,
authorEmpty ? "%" : author
); 问题: 直接把前端的数据喂给后端,没有做任何的处理。
2.3 复现步骤 步骤 1:验证漏洞存在 Payload:
bookName=' OR '1'='1' -- - 解释:
':闭合前面的单引号'1'='1':添加了一个永远都是真的条件-- -:注释掉后面的 AND author LIKE '%...%'(-- 是 SQL 注释符,后面必须跟一个空格)在 Burp 里操作就是:
bookName=%27%20OR%20%271%27%3D%271%27%20--%20- %27 → ',%20 → 空格
可以看到图片里返回了 33 本书,这是正确的。
步骤 2:判断列数(ORDER BY) 理由: 后面的 UNION SELECT 要让左右列数一样,不然会报错。
Payload:
ORDER BY 5 -- - 这里我是知道我自己的页数,在不知道的情况下需要一个个试。
如果正常返回:列数 >= 5 报错或者空:列数 < 5 5√ 6 × → 列数就是 5
这一步我们就确定了列数是 5。
步骤 3:找显示位 UNION 可以把两个查询结果拼接到一起,构造一个"假查询",数据就会显示在页面上。
Payload:
bookName=' UNION SELECT 1,2,3,4,5 -- - 右边的就是我们构造的假查询。
我这个有分页,而且没有排序,所以他会显示在最后一页,可以看到这个图片里显示了数字,这些就是我们找到的显示位。
步骤 4:爆数据库版本 主要是确认一下环境,找到这个 MySQL 的版本号,这里的显示位我用的是第 2 列。
Payload:
bookName=' UNION SELECT 0,version(),3,4,5 -- - 0:id=0version():数据库内置函数,返回数据库版本
8.0.45 就是我们提取出来的版本号。
步骤 5:爆表名(获得数据库结构) 主要查看数据库里有哪些表格。
Payload:
bookName=' UNION SELECT 1,table_name,3,4,5 FROM information_schema.tables WHERE table_schema=database() -- - information_schema.tables 是 MySQL 的系统表,存储了所有表的信息。
table_schema=database() 表示查询当前数据库的表。
还是放在第 2 列显示。
出现这个问题不要慌,查一下是不是格式有问题,根据上面的步骤统一一下编码,或者刷新一下重新抓包。
这四个表格就是我的数据库里的。
步骤 6:爆字段名(找账号和密码) 查 user 表里有哪些列(id, username, password 等)。
还是一样的查 information_schema,但这里是 columns。
Payload:
bookName=' UNION SELECT 0,column_name,3,4,5 FROM information_schema.columns WHERE table_name='user' -- -
确认有 username 和 password 就可以。
步骤 7:拖库(偷 username 和 password) 用 CONCAT 函数把这两个字段连接起来。
Payload:
bookName=' UNION SELECT 0,CONCAT(username,':',password),4,5 FROM user -- - 注: 一定要注意符号,容易漏打。
我这里有两个用户,可以看到完整的显示了。
这个就是全过程。
2.4 修复方案 修复代码: // 【修复代码】使用 #{} 预编译参数,防止 SQL 注入
@Select("SELECT * FROM books WHERE book_name LIKE #{bookName} AND author LIKE #{author}")
List<Book> searchBooksSafe(@Param("bookName") String bookName,
@Param("author") String author); // 在 Java 层拼接通配符,SQL 使用 #{} 预编译
return bookMapper.searchBooksSafe(
nameEmpty ? "%" : "%" + bookName + "%",
authorEmpty ? "%" : "%" + author + "%"
); 换成 #{} 后,不能再在 SQL 里写 % 了,因为 #{} 会把整个参数(包括 %)当作一个整体去匹配,所以要放到 Java 里去拼接。
如果用户没输入书名(nameEmpty 是 true),就传 "%"(匹配所有) 如果用户输入了 Java,就拼接成 "%Java%",然后传给 Mapper 这样既能模糊搜索,又不会有 SQL 注入风险。
注意: 若需严格避免用户输入 % 或 _ 被当作 LIKE 通配符,可对用户输入进行转义(如将 % 替换为 \%),或改用全文索引方案。本系统为演示场景,暂不处理。
三、水平越权漏洞 3.1 漏洞描述 通俗理解就是,A 和 B 等级权限相同,但是 A 可以获取 B 的信息,就像取快递,A 报 B 的取件码,把 B 的快递取走。
本系统中,用户 A 可以通过修改 URL 中的 userId 参数,直接查看用户 B 的借阅记录。
3.2 漏洞代码位置 BorrowRecordController.java
try {
// 直接用的前端传来的,没有判断用户,直接改 URL 里的 userId=2 就能看别人记录
List<BorrowRecord> records = borrowRecordService.getBorrowRecordsByUserId(userId);
return Result.success(records);
} 问题: 这里的逻辑就是,前端想查就查,后端太信任,要什么给什么,没有查询是否是当前登录的用户。
3.3 复现步骤 步骤 1:准备两个账号 在网页里注册两个账号,这里我已经注册好了。
前面的截图里可以看到。
步骤 2:登录其中一个账号,正常抓包
直接把这里的 userId=1 改成 2。
旁边就是 user2 的借阅记录,user 就可以查看任何人的记录。
3.4 修复方案 修复代码: @GetMapping("/records")
public Result getBorrowRecords(HttpServletRequest request) {
// 修复:从 Session 中获取当前登录用户的真实 ID
HttpSession session = request.getSession();
User currentUser = (User) session.getAttribute("loginUser");
if (currentUser == null) {
return Result.error("请先登录");
}
// 使用 Session 里的真实 ID,完全忽略前端传来的参数
List<BorrowRecord> records = borrowRecordService.getBorrowRecordsByUserId(currentUser.getId());
return Result.success(records);
} // 修复:登录成功后,将用户信息存入 Session
HttpSession session = request.getSession();
session.setAttribute("loginUser", loginUser);
// 从服务器内存里把刚才登录时存进去的 User 对象取出来。
loginUser.setPassword(null); // 脱敏处理
return Result.success(loginUser); 修复原理: 水平越权产生的根本原因是后端过度信任前端参数 。
修复的核心思想是身份强校验 。
我在登录接口中引入了 HttpSession ,用户登录成功后将 User 对象存入服务器 Session 中。在查询借阅记录的接口中,不再接收前端传来的 userId,而是直接从 Session 中解析出当前登录用户的真实 ID 进行查询。
这样,即使用户通过抓包工具篡改了请求参数,后端依然只返回属于他本人的数据,彻底阻断了越权路径。
Session 是服务器端的一块内存空间。每个用户登录后,服务器都会给他分配一个唯一的 Session ID,存在浏览器的 Cookie 里,只要带着这个请求,服务器就知道是刚才登录的人。 四、总结与反思 通过本次对图书管理系统的代码审计,我获得了以下收获:
安全编码意识 :永远不要信任前端传来的数据。SQL 注入和越权的根源都是后端过度信任用户输入。防御的黄金法则 :防 SQL 注入用预编译(#{}),防越权用服务端 Session 校验,这两条是 Web 安全的基本功。攻击视角的价值 :亲自走一遍 UNION 注入的完整流程,比只看理论更能理解漏洞的危害性。文档沉淀的重要性 :将审计过程写成博客,既是自我复盘,也是向他人展示能力的有效方式。GitHub 项目地址:1d9K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6Y4K9i4c8Z5N6h3u0Q4x3X3g2U0L8$3#2Q4x3V1j5I4y4r3k6J5k6h3g2Q4x3V1k6D9K9h3u0J5j5i4u0&6i4K6u0V1M7$3g2U0N6i4u0A6N6s2W2Q4x3X3c8D9j5h3t1`.
欢迎 Star 和交流!
[培训]《冰与火的战歌:Windows内核攻防实战》!从零到实战,融合AI与Windows内核攻防全技术栈,打造具备自动化能力的内核开发高手。