Java代码审计-RBAC

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 漏洞分析

黑盒

  1. 利用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
  1. 异常信息泄露:一般情况下异常信息泄露会配合4XX5XX等响应码出现

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 ,通过BurpSuiteBApp Store安装

白盒

信息收集

pom.xml

pom.xml统一管理第三方依赖。通过groupIdartifactIdversion三个标识,我们可以了解到使用了哪个依赖库以及版本,从而可以确定是否存在漏洞。

  • groupId: 项目组的标识符。通常,它是一个反向的域名,表示项目所属的组织或公司。
  • artifactId: 项目的名称。它是在仓库中标识此项目的主要名称。
  • version: 项目的版本。这可以用来表示项目的迭代和修改。

部分使用带漏洞版本的组件,但并没有使用存在漏洞的相关函数。比如Fastjson反序列化漏洞,该项目实际并没有使用到parseObject

查看配置文件

数据库默认账号密码:

其他配置信息:

关键字查找

漏洞名称关键词
XSSgetParamter<%=param.
SQL注入Select${order
任意文件下载downloadfileName
任意文件删除DeletedeleteFilefileNamefilePath
文件上传UploadwritefileNamefilePath
命令注入getRuntimeexeccmdshell
XML注入DocumentBuilderXMLStreamReaderSAXBuilderSAXParserSAXReaderXMLReaderSAXSource
反序列化漏洞ObjectInputStream.readObjectObjectInputStream.readUnsharedXMLDecoder.readObjectYaml.load
url跳转sendRedirectsetHeaderforward
不安全组件暴露activityBroadcastReceiverContent

SQL注入挖掘

Mybatis拼接sql有下面两种方式:

  • #{}:用于参数的绑定,可以将参数值安全的插入到 SQL 语句中。它会自动为参数值添加单引号('),所以如果参数是一个字符串,则不需要再为其添加单引号。
  • ${}:用于直接拼接 SQL 语句,会将参数值直接插入到 SQL 语句中,重点关注likeinorder 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

nickName1 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

userName1 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挖掘

开发人员正常不会将所有的参数分别做过滤或转义,一般来说写个过滤/转义XSSFilter

分析后端代码,未发现XSS相关过滤器Filter或拦截器Interceptor,可能存在XSS,然后分析前端查看是否有转义机制,发现前端使用Thymeleaf模板引擎(搜索th:textth:each)和Layui框架(搜索layui.jslayui.csslayui.uselayui.layer)。

  • Thymeleafth:text标签渲染,默认对特殊字符进行转义,不存在XSS漏洞
  • Thymeleafth:utext标签渲染,不会将字符串转义,可能存在XSS漏洞
  • Thymeleafth:value属性,不会对HTML标签进行转义,可能导致产生XSS

XSS漏洞的出现主要分为两点:

  • 后端没有对用户输入的字符进行转义或过滤。
  • 使用的前端框架存在XSS漏洞版本,也没有对返回到前端的参数进行过滤或转义。

未授权访问

security/config/SpringSecurityConfig中,将一些关键资源放行,造成未授权访问

SpringSecurity配置是链式结构,可以同时配置多项过滤

验证码校验

输入验证码抓包,全局搜索/logincaptcha,图片验证码代码位于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);
        // 继续执行过滤器链中的下一个过滤器或目标资源
    }
}

只有在验证码为空时才会置空销毁掉缓存中存储的验证码,就是说不为空的时候使用错误的验证码也可以通过校验,这就是验证码可以重用的原因。

0x02 参考链接

https://www.cnblogs.com/bktown/p/17684124.html
暂无评论

发送评论 编辑评论

|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇