# LingGuo-Drama: broken access control gives any registered user full cross-account takeover and project IDOR
, admin API
**Affected:** default branch `main` at commit `8a6924adf8142678919b4d9a08b9d60c49dcfbed` (no release tag exists)
**Class:** CWE-639 IDOR, CWE-862 Missing Authorization, CWE-620 Unverified Password Change, CWE-200 Sensitive Information Exposure
LingGuo-Drama is a multi-user AI short-drama generation platform. Account creation is open through `POST /admin/v1/auth/register`, and every account is a row in the `admins` table that receives a JWT. The project list endpoint proves the intended data boundary: it forces the owner predicate `admin_id = current user` so each user sees only their own short-drama projects. The per-id handlers do not apply that predicate. `ProjectsController.Show`, `Update`, and `Delete` load the record with `projects.Get(id)`, which runs `WHERE id = ?` only, so any authenticated user can read, overwrite, or delete any other user's project by iterating the numeric id. A second and more severe break is the account-management surface at `/admin/v1/admins`, which is gated only by a JWT presence check with no role check. Any self-registered user can list every account including its stored bcrypt password hash, overwrite any account's password and privilege level, and delete any account. This reaches full takeover of the bootstrap administrator account from an anonymous starting point. Every finding is reachable under the default configuration in the pinned source.
### 1. Open registration mints full accounts
`routes/admin.go` registers the auth group with no authentication, including a public register route.
authGroup.POST("/login/using-phone", lgc.LoginByPassword)
authGroup.POST("/register", lgc.Register)
`LoginController.Register` validates only field format and uniqueness, then creates the account with no approval step (`app/http/controllers/admin/v1/auth/login_controller.go:70-96`).
func (lc *LoginController) Register(c *gin.Context) {
request := adminRequest.RegisterRequest{}
if ok := requests.Validate(c, &request, adminRequest.Register); !ok {
passwordHash := hash.BcryptHash(request.Password)
The validator enforces format and uniqueness only (`app/requests/admin/register_request.go`). Any visitor can obtain a valid token.
### 2. The owner boundary is real and is enforced only in list
`ProjectsController.Index` builds its filter from `buildSearchConditions`, which forces the owner column (`app/http/controllers/admin/v1/projects_controller.go:76-98`).
func (ctrl *ProjectsController) buildSearchConditions(c *gin.Context) map[string]interface{} {
This proves that `admin_id` is the owner/tenant column and that cross-user project reads are not an intended feature.
### 3. The per-id project handlers drop the owner predicate (IDOR)
`projects.Get` resolves by primary key only, with no owner scope (`app/models/projects/projects_utils.go:9-12`).
func Get(idStr string) (projects Projects) {
database.DB.Where("id", idStr).First(&projects)
`Show` returns whatever that id resolves to, regardless of owner (`projects_controller.go` Show).
func (ctrl *ProjectsController) Show(c *gin.Context) {
projectsModel := projects.Get(c.Param("id"))
response.JSON(c, gin.H{"code": 0, "data": projectsModel, "message": "success"})
`Update` loads by id with no owner check and then forces ownership to account id 1 on every write (`projects_controller.go` Update).
existingProjects := projects.Get(id) // no admin_id check
updateProjects.AdminId = &adminId // ownership reassigned to id 1
result := database.DB.Save(updateProjects)
`Delete` also loads by id with no owner check and deletes (`projects_controller.go` Delete). The same ownerless `Get(c.Param("id"))` to `Save`/`Delete` pattern repeats across the scripts, scenes, characters, props, shots, source, and shot-media controllers, and `ScriptsController.GetProjectsSelectList` returns `projects.All()` with no filter, which lists every user's projects.
### 4. The account-management group has no role gate (missing authorization)
The identity middleware only resolves the caller and sets context values. It makes no authorization decision (`app/http/middlewares/auth_jwt.go`).
if adminModel.ID == 0 { response.Unauthorized(c, "找不到对应用户,用户可能已删除"); return }
c.Set("current_admin_id", adminModel.GetStringID())
c.Set("current_admin_name", adminModel.Username)
The `/admins` group is attached to this middleware alone, with no further check (`routes/admin.go` adminsGroup). Every CRUD route on the account table is reachable by any token holder.
### 5. The account model leaks the password hash
The `Password` field is serialized to JSON (`app/models/admins/admins.go:12`).
Password *string `json:"password" form:"password" gorm:"column:password; :密码;size:64;"`
The login handler nulls it before returning (`admin.Password = nil`), but `AdminsController.Index` and `Show` do not. They call `admins.Paginate` and `admins.Get` and return the raw struct (`app/http/controllers/admin/v1/admins_controller.go` Index/Show). So any token holder reading the account list receives the stored bcrypt hash of every account.
### 6. The account update allows unverified password and privilege change
`AdminsController.Update` loads any account by id, then writes attacker-supplied `password`, `email`, `mobile`, `username`, and `authority_id` with no current-password check and no ownership or role check (`admins_controller.go` Update).
existingAdmins := admins.Get(id) // any id, no owner/role check
updateAdmins.Password = &request.Password // overwrite target password
updateAdmins.AuthorityId = &request.AuthorityId
The request password is stored through the account save hook, so a chosen password for account id 1 becomes a working login for the bootstrap administrator. `DELETE /admin/v1/admins/:id` deletes any account the same way.
A->>P: request victim project by numeric id
P->>DB: select where id only, no owner filter
A->>M: list accounts then update account id 1 password
M->>DB: select all accounts then save new password
DB-->>M: account id 1 password overwritten
M-->>A: success, attacker can now log in as account id 1
The requests below are illustrative and were not executed against any deployment. They are derived from the pinned source routes and handlers.
- Request: `POST /admin/v1/auth/register` with JSON `username`, `mobile` (11 digits), `email`, `password` (min 6).
- Source behavior: `LoginController.Register` validates format and uniqueness, bcrypt-hashes the password, and inserts a row into `admins`.
- State change: a new account row exists.
- Request: `POST /admin/v1/auth/login/using-phone` with the attacker username or mobile and password.
- Source behavior: `AdminAttempt` checks the bcrypt hash, `IssueAdminToken` returns a JWT.
- State change: the attacker holds a valid bearer token.
3. Read a victim project by id (IDOR read).
- Request: `GET /admin/v1/projects/ ` with the attacker token, iterating `victim_id` over small integers.
- Source behavior: `ProjectsController.Show` calls `projects.Get(id)` which is `WHERE id = ?` only, so a row owned by another `admin_id` is returned.
- State change: none, the victim project body is disclosed.
4. Overwrite or delete the victim project (IDOR write and delete).
- Request: `PUT /admin/v1/projects/ ` with a new title and settings, or `DELETE /admin/v1/projects/ `.
- Source behavior: `Update` saves with `admin_id` forced to 1, `Delete` removes the row, neither checks the caller against the owner.
- State change: the victim project content is changed and its ownership is reassigned to account id 1, or the project is deleted.
5. Harvest every account and its password hash.
- Request: `GET /admin/v1/admins` with the attacker token.
- Source behavior: `AdminsController.Index` returns the raw `Admins` struct for every account, and `Password` is tagged `json:"password"` and is not nulled here.
- State change: none, every account's username, mobile, email, authority id, and bcrypt hash are disclosed.
6. Take over the bootstrap administrator.
- Request: `PUT /admin/v1/admins/1` with a chosen `password` and a raised `authority_id`, then `POST /admin/v1/auth/login/using-phone` with account id 1 credentials and the chosen password.
- Source behavior: `AdminsController.Update` saves the new password for account id 1 with no current-password check, and login then succeeds against the new hash.
- State change: the attacker controls account id 1 and any privilege keyed on `authority_id`.
This finding was confirmed by static source review at commit `8a6924adf8142678919b4d9a08b9d60c49dcfbed`. Runtime validation was not performed. No request was sent to any deployment, no account or data was used, and no PoC was executed. The chain was traced through these pinned files and lines:
- Open registration: `server/routes/admin.go` auth group, `server/app/http/controllers/admin/v1/auth/login_controller.go:70-96`.
- Owner boundary enforced in list only: `server/app/http/controllers/admin/v1/projects_controller.go:76-98` (`where["admin_id"] = adminInfo.ID`).
- IDOR resolver: `server/app/models/projects/projects_utils.go:9-12` (`Where("id", idStr).First`).
- IDOR handlers and ownership reassignment to id 1: `server/app/http/controllers/admin/v1/projects_controller.go` Show, Update, Delete.
- Missing role gate: `server/app/http/middlewares/auth_jwt.go` and `server/routes/admin.go` adminsGroup.
- Password hash serialization: `server/app/models/admins/admins.go:12`.
- Unverified password and privilege write: `server/app/http/controllers/admin/v1/admins_controller.go` Update and Delete.
Not established from source: exact runtime HTTP status codes, whether a specific deployment disables registration at a front proxy, and the offline cost of cracking a leaked bcrypt hash.
Scenario self-hosted multi-user deployment with registration reachable: critical. An anonymous visitor registers, then reads, overwrites, and deletes any other user's short-drama projects and related assets by numeric id, lists every account with its bcrypt password hash, overwrites any account's password and privilege level, and deletes any account. This reaches full takeover of the bootstrap administrator account id 1 and any role keyed on `authority_id`.
Scenario registration disabled at the edge but an attacker already holds any low-privilege token: still critical. The IDOR and the account-management group are reachable by any token holder, so one low-privilege account escalates to administrator and reads every other user's data.
What is NOT established: no remote code execution is claimed, the password leak is a stored bcrypt hash and not plaintext, and runtime HTTP behavior was not captured. Deployment-level mitigations outside this repository, such as a proxy that blocks the register route, are not visible in source and would reduce the anonymous entry point but not the token-holder escalation.
**Where exactly:** `server/app/models/projects/projects_utils.go:9-12`, `server/app/http/controllers/admin/v1/projects_controller.go` (Show/Update/Delete), `server/app/http/controllers/admin/v1/admins_controller.go` (Index/Show/Update/Delete), `server/app/models/admins/admins.go:12`, `server/routes/admin.go` (adminsGroup).
Scope every id resolver by the caller's owner column and reject a mismatch.
-func (ctrl *ProjectsController) Show(c *gin.Context) {
- projectsModel := projects.Get(c.Param("id"))
+func (ctrl *ProjectsController) Show(c *gin.Context) {
+ projectsModel := projects.Get(c.Param("id"))
+ if projectsModel.ID == 0 || projectsModel.AdminId == nil || *projectsModel.AdminId != adminInfo.ID {
response.JSON(c, gin.H{"code": 404, "message": "数据不存在", "data": nil})
Stop reassigning ownership on update and keep the real owner.
+ updateProjects.AdminId = existingProjects.AdminId
- Password *string `json:"password" form:"password" gorm:"column:password; :密码;size:64;"`
+ Password *string `json:"-" form:"password" gorm:"column:password; :密码;size:64;"`
Gate the account-management group behind an authorization check and require the current password on a self password change.
-adminsGroup := v1.Group("/admins").Use(middlewares.AuthAdminJWT())
+adminsGroup := v1.Group("/admins").Use(middlewares.AuthAdminJWT(), middlewares.RequireAuthority("admin"))
Short-term: add the owner check to every per-id project, script, scene, character, prop, shot, source, and shot-media handler, and set `json:"-"` on `Password`. Medium-term: add a role middleware that reads `authority_id` and restrict `/admins` to a privileged role, and require the current password for a self password change while forbidding a user from changing another account. Defense-in-depth: scope all `Get(id)` queries through a helper that always takes the owner id, add a server-side default that registration does not grant administrative authority, and add regression tests that one user cannot read or modify another user's records.
Recommended fix: enforce owner scope at every id resolver, stop reassigning ownership to id 1, set `json:"-"` on the password field, and put the account-management routes behind a real authority check with current-password verification.
- No public advisory at time of writing. The repository has zero GitHub Security Advisories, zero CVEs, and zero release tags.
-
-
-
-
-
-
-
Reported by hieuPenguinnn, [github.com/hieuPenguinnn](
The full story
This article is one source in a clustered incident — the cluster page carries the summary, timeline and every other outlet covering it.
