JumpServer — SQL Server change_secret Automation: Jinja2-Concatenated Unparameterized T-SQL Injection
| Field | Value |
|---|---|
| Product | JumpServer (open-source Privileged Access Management / bastion host) |
| Vendor repository | https://github.com/jumpserver/jumpserver |
| Vulnerability type | CWE-89 (SQL Injection) — attacker-controlled account fields concatenated into T-SQL and executed unparameterized |
| Component | apps/accounts/automations/change_secret/database/sqlserver/main.yml + apps/libs/ansible/modules/mssql_script.py |
| Affected versions | v3.0.0 (introduced 2023-01-16, commit 56d533c8) through v4.10.16 (latest) |
| Fixed version | Not fixed as of v4.10.16 |
| Privileges required | Low — account/asset management privileges to set an account's username/secret on a SQL Server asset |
| Verification | Verified at the template/string level on jumpserver/jms_all:v4.10.16: the shipped change_secret SQL Server playbook interpolates account.username/account.secret directly into the T-SQL string and executes it unparameterized (cursor.execute(query, None)). Runtime landing requires a live SQL Server target (not stood up here). |
The SQL Server change_secret automation renders a T-SQL script by Jinja2-interpolating the account's username and secret directly into the SQL string, then passes the whole string to mssql_script, which runs cursor.execute(query, sql_params) with sql_params=None (the playbook passes no params:), i.e. no parameter binding. account.secret is only Jinja2-syntax-escaped (escape_jinja2_syntax), never SQL-escaped. A secret value containing a single quote and additional statements breaks out of the quoted literal and appends attacker T-SQL that executes in the same batch under the automation's privileges.
A user with account-management privileges can execute arbitrary T-SQL on any managed SQL Server asset when a change_secret automation runs for an account whose new secret/username they control — create logins, read/write arbitrary tables, and (if xp_cmdshell is enabled) execute OS commands. Impact is on the managed database asset, not JumpServer itself.
https://github.com/jumpserver/jumpserver
v3.0.0 – v4.10.16 (latest). The unparameterized Jinja2-concatenated template is present on the current release.
Base score: 8.0 (High)
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:N
Rationale: network-reachable, low complexity, requires account/asset-management privileges (PR:L), no interaction; arbitrary SQL on the managed database asset crosses the JumpServer↔managed-asset trust boundary (Scope changed), full C/I of that database. Scope limited to the target database rather than JumpServer itself.
- The attacker holds account/asset-management RBAC (e.g.
accounts.change_account/ automation permissions) and can set an account'ssecretwithsecret_strategy=specific. - A SQL Server asset + account is registered and a
change_secretautomation runs against it.
- Create/edit a SQL Server asset account,
secret_strategy=specific,secret= a T-SQL breakout, e.g.:N3wP4ss!', DEFAULT_DATABASE=master; CREATE LOGIN attacker_login WITH PASSWORD='Xx123456!'; -- - Trigger the SQL Server
change_secretautomation for that account. - The injected statements execute in the same batch on the SQL Server target (e.g. an attacker login is created).
apps/libs/ansible/modules/mssql_script.py — unparameterized execution:
script = module.params['script']
sql_params = module.params['params'] # the change_secret playbook passes no params -> None
...
cursor.execute(query, sql_params) # sql_params=None => pymssql sends the query string verbatimThe account secret (and username) fields, Jinja2-interpolated into the T-SQL template apps/accounts/automations/change_secret/database/sqlserver/main.yml:
- name: Change SQLServer password
script: "ALTER LOGIN {{ account.username }} WITH PASSWORD = '{{ account.secret }}',
DEFAULT_DATABASE = {{ jms_asset.spec_info.db_name }}; select @@version"
- name: Add SQLServer user
script: "CREATE LOGIN {{ account.username }} WITH PASSWORD = '{{ account.secret }}', ...;
CREATE USER {{ account.username }} FOR LOGIN {{ account.username }}"account.secret is only escape_jinja2_syntax-processed (Jinja2 escaping), not SQL-escaped.
POST .../change-secret-automations/<id>/execute/
└─ ChangeSecretManager.get_secret() (reads execution.snapshot['secret'], only Jinja2-escaped) apps/accounts/automations/base/manager.py
└─ Ansible renders change_secret/database/sqlserver/main.yml (Jinja2 interpolates account.username/secret into T-SQL)
└─ apps/libs/ansible/modules/mssql_script.py -> cursor.execute(query, None) ← unparameterized T-SQL executes on SQL Server
Official jumpserver/jms_all:v4.10.16 — template/string-level confirmation: the shipped SQL Server change_secret playbook (verbatim from the box) interpolates account.username/account.secret into the T-SQL string, and mssql_script.py runs cursor.execute(query, None) (no binding), so a secret containing a '+; breaks out of the literal and appends attacker T-SQL. Full runtime landing (injected statements executing) requires a live SQL Server asset, which was not stood up here; the injection is confirmed at the render/execute-path level. (Honest scope: this is a Level-1 confirmation of the injectable path, not a live SQL Server exploitation run.)
poc_12_sqlserver_tsql_injection.py — a --live mode runs the full injection against a real SQL Server (see the docker command in the file header); the default mode demonstrates the template render / string breakout without an external service.
- Never interpolate
account.username/account.secretinto the SQL string. Use parameterized queries (cursor.execute(query, params)with placeholders) inmssql_script.py, and pass credentials as bound parameters from the playbook. - Where identifiers (login names, database names) must be dynamic, validate them against a strict identifier grammar and quote via the driver's identifier-quoting, not string concatenation.