In October 2023, Atlassian disclosed a flaw in Confluence Data Center and Server that let a completely unauthenticated attacker create a Confluence administrator account. No credentials, no phishing, no chained bugs. It was rated CVSS 10.0 — the maximum — and it was already being exploited in the wild when the advisory went out.
We are writing about it now, three years later, because the specific vulnerability is long patched and entirely uninteresting. What is interesting is the mistake underneath it, which is alive and well. It is the single most common class of serious finding we report on web application tests, and we have found it in applications built last year by teams who had never heard of Confluence.
- CVE
- CVE-2023-22515
- Product
- Atlassian Confluence Data Center and Server (self-hosted). Confluence Cloud was not affected.
- Class
- Broken access control — privilege escalation
- Severity
- CVSS 10.0 (Critical), per Atlassian's advisory
- Disclosed
- 4 October 2023 — already exploited in the wild, and later added to CISA's Known Exploited Vulnerabilities catalogue
- Impact
- Unauthenticated creation of an administrator account, and from there full control of the instance and its content
The root cause, in one sentence
Confluence, like most self-hosted software, has a first-run setup flow: the screens you walk through once to create the very first administrator account. That flow is unauthenticated by necessity — there is nobody to authenticate yet. The security of the whole arrangement therefore rests on one question: has setup already been completed?
If the answer is yes, those screens must be unreachable forever. The vulnerability was that an attacker could influence the application's answer to that question from the outside. The incoming request was able to affect configuration state that should only ever have been read from the server, and the application would then present the setup flow on a long-since-configured instance — and let the caller create an administrator through it.
That is the whole bug. Not a memory corruption, not an injection, not a clever encoding trick. The application asked the client a question only the server was entitled to answer.
We are not publishing the request. The mechanism above is described at the conceptual level on purpose. A working request adds nothing for a defender — you patch by version, not by payload — and we are not in the business of handing a copy-paste exploit to whoever reads our blog. If you run self-hosted Confluence, check your version against Atlassian's security advisories and upgrade to a fixed release for your branch.
Why this isn't an Atlassian story
It would be comfortable to file this under "big vendor had a bad day". But strip out the product and the bug is a general one: a decision about authorisation was made from data the client controls. Once you hold the bug class in your head rather than the CVE, you start seeing it everywhere. Here are the five shapes we find most often.
1. Setup and onboarding flows that stay reachable
This is the direct analogue. An installer route, a /setup wizard, a database migration endpoint, a "create first tenant" page, or a seeding script left behind a route guard that checks a value the request can touch. Self-hosted products and on-prem deployments of SaaS apps are the usual hosts, but we have also found first-run routes live on production SaaS because they were only ever disabled by an environment variable that had not been set in that environment.
2. The role arrives in the request
A hidden form field, a JSON key, or a header carries role, is_admin, account_type or permissions, and the server stores or trusts it. The client was never supposed to be able to change it — in the UI, it is not even rendered — so nobody considered what happens when it does. Testing this takes one modified request.
3. The tenant arrives in the request
The multi-tenant SaaS version, and the one we are asked about most. An endpoint takes a tenant_id, org_id, company or account parameter and uses it as the lookup key, when it should derive the tenant from the session and use the parameter only as a filter within it. Change the value, read someone else's data. The authorisation check, if there is one, usually proves you are logged in — not that you are logged in to that tenant.
4. Mass assignment on registration and profile update
A permissive object binder maps the whole request body onto a model, so a signup or profile-edit endpoint accepts fields the form never sent. The classic result is a self-service account that comes out of registration with a privileged role attached. Modern frameworks make this easy to do accidentally and easy to prevent deliberately — with an explicit allowlist of bindable fields.
5. Gating that only exists in the front end
The admin area is hidden from the navigation, the button is not rendered, the route is guarded inside the single-page app. All of which a browser developer console or a direct request to the API disregards entirely. Hiding a control is a usability decision; it is not an access control. The check has to be on the server, on every request, for every object.
Automated scanners find almost none of this. A scanner has no model of who should be allowed to do what, so a 200 OK containing another tenant's invoices looks exactly like a 200 OK containing your own. This is why broken access control sits at the top of the OWASP Top 10 and still ships to production: the tools in most pipelines are structurally incapable of catching it.
How we test for it
Finding this class of bug is not clever, but it is systematic, and that is precisely why it gets skipped. Our method on an authenticated application test:
- Enumerate every role, then get credentials for all of them. Platform admin, tenant admin, standard user, read-only, auditor, guest, API-only service account — plus at least two accounts in two different tenants. This is the one thing we ask clients for up front, because without a second tenant nobody can prove isolation holds; they can only assume it.
- Walk the application fully as the most privileged account while proxying everything, which produces a complete inventory of requests — including the ones the UI only issues in rare states.
- Replay every privileged request as every lower-privileged account, and again with no session at all. This is the access control matrix, and it is where the findings come from. Three outcomes matter: a redirect to login or a clean
403is correct; a200with real data is a vertical escalation; a200with another tenant's data is a horizontal one. - Swap identifiers rather than only removing them. Tenant and object identifiers get substituted between the two tenants, in the path, the query string, the body, and in nested JSON — including arrays, where checks are frequently applied to the first element only.
- Test writes, not just reads. Read-only cross-tenant access is serious; cross-tenant
POST,PUTandDELETEis worse and is tested far less often. We check it with the client's permission and on data we have agreed we may touch. - Probe the state transitions. Invitation and onboarding flows, role changes, trial-to-paid upgrades, account deletion and re-registration. Authorisation logic is usually correct in the steady state and wrong in the middle of a transition.
Every finding then gets a reproduction that a developer can run themselves — the request, the account it was sent from, and the response proving what came back. That is the difference between a report that gets fixed and a list of maybes; we have written separately about what a professional test report contains.
Six things to check in your own application this week
None of these need a tester. They need an afternoon and someone willing to be wrong.
- Grep your routes for setup, install, migration, seed and first-run handlers. Confirm each one is unreachable in production — and that the thing making it unreachable is server-side state, not a request parameter or an unset environment variable.
- List every endpoint that accepts a tenant or organisation identifier. For each, confirm the tenant is derived from the session and that the parameter cannot widen the query beyond it.
- Find your object binders. Replace implicit whole-body binding with an explicit allowlist of fields on anything that creates or updates a user, a membership or a role.
- Open two accounts in two different tenants in two browser profiles and swap one identifier between them. If data comes back, you have your first finding and you found it for free.
- Check that authorisation is enforced in one place that every request passes through, rather than re-implemented per controller. Per-controller checks are where the one that is missing lives.
- Make a deny-by-default route the framework behaviour, so a new endpoint that nobody annotated is closed rather than open.
If you want this done properly, with a second pair of eyes and a report you can hand to an enterprise customer or an auditor, that is the work we do: fixed-price manual web application testing, access control and business logic first, retest included.
References
- NVD — CVE-2023-22515 detail
- Atlassian — Security advisories (authoritative affected and fixed versions)
- CISA — Known Exploited Vulnerabilities catalogue
- OWASP — A01: Broken Access Control
- OWASP — Web Security Testing Guide: Authorization Testing