{"uuid": "d4183888-a605-4d6b-8b3b-8be60d3de871", "vulnerability_lookup_origin": "1a89b78e-f703-45f3-bb86-59eb712668bd", "author": "9f56dd64-161d-43a6-b9c3-555944290a09", "vulnerability": "GHSA-pfq8-h8c4-mprw", "type": "seen", "source": "https://gist.github.com/CyberKareem/601501c133cced1fae4f94d24c0da5cd", "content": "# DjangoCRM (django-crm): Vertical privilege escalation via the user_transfer view (CWE-862)\n\n- **Affected:** DjangoCRM/django-crm, all versions up to and including the latest `main` branch; no fixed release as of 2026-06-29.\n- **Severity:** 8.1 High \u2014 `CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N`\n- **CWE:** CWE-862 (Missing Authorization)\n- **Reported:** GitHub private vulnerability reporting GHSA-pfq8-h8c4-mprw; closed by the maintainer without remediation; CVE requested via MITRE CNA-LR, 2026-06-29.\n- **Credit:** Abdullah Kareem (cyberkareem) \u2014 https://github.com/CyberKareem\n\n## Summary\n\nThe `user_transfer` view moves a user and their documents between departments. It is registered with the `login_required` decorator only, so any authenticated CRM user can reach it. The POST handler reads the target user id and the destination group id straight from the request and applies them with no role check, no ownership check, and no check that the destination group is actually a department. Because the application derives its runtime authority flags from group names, a low-privileged operator can add their own account to a privileged role group (`superoperators` or `chiefs`) and gain cross-department application-admin access on the next request.\n\n## Root cause\n\nThe view is wrapped in `login_required` only. The neighbouring `select_email_account` path uses `staff_member_required`, which shows the missing gate is not consistent across the file.\n\n`common/urls.py:24-28`\n\n```python\npath(\n    'user-transfer/',\n    login_required(user_transfer),\n    name='user_transfer'\n),\n```\n\n`login_required` confirms the caller is authenticated. It does not check role, staff status, or any object ownership.\n\nThe POST handler trusts both ids from the request body. `common/views/user_transfer.py:61-74`\n\n```python\ndef user_transfer(request):\n    if request.method == \"POST\":\n        owner_id = int(request.POST.get('owner'))\n        owner = USER_MODEL.objects.get(id=owner_id)                  # any user id, attacker-chosen\n        old_department = owner.groups.filter(\n            department__isnull=False\n        ).first()\n        new_department = Group.objects.get(\n            id=int(request.POST.get('department'))                   # any Group, including role groups\n        )\n        owner.groups.remove(old_department)\n        owner.groups.add(new_department)                            # &lt;-- the write that grants the role\n```\n\n`owner` is whatever user id the request supplies, so the handler is not limited to the caller's own account. `new_department` comes from `Group.objects.get(id=...)`, which returns any group by primary key. Nothing constrains it to a department group. The GET branch of the same view only lists department groups in its form (`common/views/user_transfer.py:135-137`), but that is cosmetic; the POST branch never re-checks the id, so the role groups `superoperators`, `chiefs`, and `accountants` are valid targets.\n\nThe escalation persists because the authority flags are recomputed from group names on every request. `common/utils/usermiddleware.py:74-82`\n\n```python\ndef set_user_groups(request: WSGIRequest, groups) -&gt; None:\n    group_names = groups.values_list('name', flat=True)\n    request.user.is_superoperator = 'superoperators' in group_names\n    request.user.is_operator = 'operators' in group_names\n    request.user.is_chief = 'chiefs' in group_names\n```\n\nAfter the m2m write, the attacker's account is a member of `superoperators`, so `is_superoperator` becomes `True` on the next request. The companion `set_user_department` (`common/utils/usermiddleware.py:47-71`) then lets users holding `is_chief`, `is_superoperator`, `is_accountant`, or `is_superuser` set the active department from an arbitrary `?department=` query parameter, which is how the new authority turns into cross-department data access.\n\nThe view is mounted under the CRM URL prefix (`SECRET_CRM_PREFIX = '123/'` in `webcrm/settings.py:199`, included at `webcrm/urls.py:34`). `AdminRedirectMiddleware` only guards the secret admin prefix and the Django `is_superuser` bit, so it does not protect this CRM-prefixed view.\n\n## Proof of concept\n\nA runtime PoC exists. It drives the verbatim sink line (`owner.groups.add(Group.objects.get(id=POST['department']))`) and the verbatim middleware check (`'superoperators' in group_names`) against a real Django ORM, and records the before/after authority flags:\n\n```\n[before] attacker groups: ['operators']                   -&gt; {'is_superoperator': False, 'is_chief': False}\n[after ] attacker groups: ['superoperators', 'operators'] -&gt; {'is_superoperator': True,  'is_chief': False}\n[RESULT] low-priv operator self-escalated to superoperator via user_transfer: CONFIRMED\n```\n\nEnd to end against a live instance:\n\n1. Log in as any operator account.\n2. Find the group id of `superoperators` (group ids are small sequential integers and can be enumerated).\n3. Send `POST //123/user-transfer/` with body `owner=&amp;department=`. Include the CSRF token from any CRM page. CSRF is not a barrier here because the request is a self-action the attacker performs in their own session.\n4. Reload any changelist with `?department=`. The attacker now sees that department's data.\n\nVariant: set `owner=` to rewrite a different user's groups and department and to bulk-reassign that user's documents to a department of the attacker's choosing.\n\n## Impact\n\nAny authenticated operator gains the application `superoperator` or `chief` role. With that role the attacker can read every department's Companies, Contacts, Deals, CrmEmails, and EmailAccounts by switching the active department, and can rewrite any other user's group membership and department and reassign that user's documents. This is a vertical privilege escalation: low-privileged user to application-level administrator.\n\nThe ceiling is application-admin, not host or server compromise. The escalation grants the application's `superoperator`/`chief` authority. It does not set the Django `is_superuser` bit, so the `auth_user` admin and anything else gated on `is_superuser` stays out of reach, and there is no code execution on the server.\n\n## Remediation\n\nThree independent fixes, all of which should be applied:\n\n1. Gate the view with an authorization check, not just authentication. Replace `login_required` with `staff_member_required` plus an explicit check that the caller holds a chief or superuser role.\n2. Enforce that the caller is permitted to act on `owner` (for example require `owner == request.user`, or that the caller manages the target user), instead of trusting the raw `owner` POST id.\n3. Restrict the destination group to real department groups. Use `Group.objects.get(id=..., department__isnull=False)` (or `Group.objects.filter(department__isnull=False)`) so role groups such as `superoperators` and `chiefs` can never be assigned through this endpoint.\n\n## References\n\n- `common/urls.py` (view registered with `login_required` only): https://github.com/DjangoCRM/django-crm/blob/main/common/urls.py#L24-L28\n- `common/views/user_transfer.py` (POST handler and unvalidated group sink): https://github.com/DjangoCRM/django-crm/blob/main/common/views/user_transfer.py#L61-L74\n- `common/views/user_transfer.py` (GET branch lists only department groups): https://github.com/DjangoCRM/django-crm/blob/main/common/views/user_transfer.py#L135-L137\n- `common/utils/usermiddleware.py` (`set_user_groups`, name-based authority flags): https://github.com/DjangoCRM/django-crm/blob/main/common/utils/usermiddleware.py#L74-L82\n- `common/utils/usermiddleware.py` (`set_user_department`, role-gated `?department=`): https://github.com/DjangoCRM/django-crm/blob/main/common/utils/usermiddleware.py#L47-L71\n- `webcrm/settings.py` (`SECRET_CRM_PREFIX = '123/'`): https://github.com/DjangoCRM/django-crm/blob/main/webcrm/settings.py#L199\n- `webcrm/urls.py` (CRM prefix include): https://github.com/DjangoCRM/django-crm/blob/main/webcrm/urls.py#L34\n", "creation_timestamp": "2026-06-29T16:59:42.347591Z"}