pingcap / pingcap/tidb

RENAME USER bypasses SEM RESTRICTED_USER_ADMIN protection on protected accounts

Open
#70,663 5 comments 0 reactions 1 assignee Claimed by @YangKeao View on GitHub
affects-25.10 affects-26.3 affects-7.5 affects-8.1 affects-8.5 contribution first-time-contributor severity/major sig/sql-infra type/bug
Dominant language
Go
Stars
40.5k
Forks
6.2k
PR merge metrics
PR metrics pending

Description

reported via email on 18 July 2026:

TiDB's Security Enhanced Mode (SEM) added a `RESTRICTED_USER_ADMIN` protection in PR #64297
(merged to master 2025-11-17, backported to release-6.5/7.1/7.5/8.1/8.5 in March 2026) so that an
account holding `SUPER` cannot delete, alter, set the password of, grant to, or revoke from an
account that itself holds `RESTRICTED_USER_ADMIN`. That fix touched `DROP USER`, `ALTER USER`,
`SET PASSWORD`, `GRANT`, `GRANT ROLE`, `REVOKE`, and `REVOKE ROLE` in
`pkg/planner/core/planbuilder.go`, each calling the helper `appendVisitInfoIsRestrictedUser()`.
`RENAME USER` (`ast.RenameUserStmt`), handled a few lines above in the same switch statement, was
not given the same call, and its executor (`executeRenameUser` in `pkg/executor/simple.go`) has no
independent check either. It only requires the ordinary `CREATE USER` static privilege.

Because `executeRenameUser` implements the rename as in-place `UPDATE ... SET User=?, Host=?`
statements against `mysql.user` and every related privilege table (`mysql.global_priv`,
`mysql.db`, `mysql.tables_priv`, `mysql.columns_priv`, `mysql.role_edges`, `mysql.default_roles`,
`mysql.password_history`, `mysql.global_grants`), all of the target's privileges travel with the
rename, including `RESTRICTED_USER_ADMIN` and `SUPER` themselves. The account is not deleted or
weakened in the mysql.* tables, it simply stops existing under its original, protected username.

I confirmed this live against the official `pingcap/tidb:v8.5.7` image (the current stable
release, released 2026-07-09), run standalone with `--store=unistore` and
`security.enable-sem=true`, and confirmed the same code path is unchanged on current master.

Setup (SEM temporarily off, to let root grant RESTRICTED_USER_ADMIN once):

docker run -d --name tidb-setup -p 4000:4000 -v "$PWD/data:/tmp/tidb" \
pingcap/tidb:v8.5.7 --store=unistore --path=/tmp/tidb

mysql -h127.0.0.1 -P4000 -uroot -e "
CREATE USER 'restricted_admin'@'%' IDENTIFIED BY 'AdminPass123!';
GRANT SUPER ON *.* TO 'restricted_admin'@'%';
GRANT RESTRICTED_USER_ADMIN ON *.* TO 'restricted_admin'@'%';
CREATE USER 'operator'@'%' IDENTIFIED BY 'OperatorPass123!';
GRANT CREATE USER ON *.* TO 'operator'@'%';
"
docker rm -f tidb-setup

Restart the same data directory with SEM enabled (tidb.toml contains `[security]\nenable-sem =
true`), as it would run in a SEM-enabled deployment:

docker run -d --name tidb-sem -p 4000:4000 -v "$PWD/data:/tmp/tidb" \
-v "$PWD/tidb.toml:/tidb.toml" \
pingcap/tidb:v8.5.7 --store=unistore --path=/tmp/tidb --config=/tidb.toml

As `operator` (holds only `CREATE USER`, no `SUPER`, no `RESTRICTED_USER_ADMIN`):

$ mysql -h127.0.0.1 -P4000 -uoperator -pOperatorPass123!

mysql> DROP USER 'restricted_admin'@'%';
ERROR 1227 (42000): Access denied; you need (at least one of) the RESTRICTED_USER_ADMIN privilege(s) for this operation

mysql> ALTER USER 'restricted_admin'@'%' IDENTIFIED BY 'Hacked123!';
ERROR 1227 (42000): Access denied; you need (at least one of) the SYSTEM_USER or SUPER privilege(s) for this operation

mysql> RENAME USER 'restricted_admin'@'%' TO 'restricted_admin_pwned'@'%';
Query OK, 0 rows affected

Verified as root that the rename succeeded and all privileges, including `SUPER` and
`RESTRICTED_USER_ADMIN`, moved with it:

mysql> SELECT user, host FROM mysql.user WHERE user LIKE 'restricted_admin%';
+------------------------+------+
| user | host |
+------------------------+------+
| restricted_admin_pwned | % |
+------------------------+------+

mysql> SHOW GRANTS FOR 'restricted_admin_pwned'@'%';
+----------------------------------------------------------------------+
| GRANT SUPER ON *.* TO 'restricted_admin_pwned'@'%' |
| GRANT RESTRICTED_USER_ADMIN ON *.* TO 'restricted_admin_pwned'@'%' |
+----------------------------------------------------------------------+

Impact: an account holding only `CREATE USER` under SEM (the exact class of "trusted operator with
elevated but bounded privilege" SEM is designed to constrain) can rename away a
`RESTRICTED_USER_ADMIN`-protected account, immediately breaking anything that authenticates under
its known username (availability), and can then `CREATE USER` a new account under the vacated,
trusted name (integrity/identity-squatting), while the moment before, the same caller was
correctly denied on `DROP USER`, `ALTER USER`, and `SET PASSWORD` against the identical target.
This defeats the specific operator/customer trust boundary PR #64297 introduced, for the one
statement type its own test coverage does not exercise.

Suggested fix: add the same `appendVisitInfoIsRestrictedUser(b.visitInfo, b.ctx, user, "RESTRICTED_USER_ADMIN")`
call (for both the old and new user identity) to the `case *ast.RenameUserStmt` branch in
`pkg/planner/core/planbuilder.go`, matching the pattern already used for `DropUserStmt`,
`SetPwdStmt`, and the `GrantStmt`/`RevokeStmt` user lists.

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.