← all articles

highSecurity Advisory

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.

CWE-697 · Incorrect Comparison · CVSS 7.1 / High · paperless-ngx 2.14.5 to 2.20.10 · GHSA-59xh-5vwx-4c4q

  • : 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