🎯 Penetration Testing August 10, 2026 · 14 min read

Anatomy of a 30-Minute Bug Sweep: 7 Critical Findings

What 30 minutes of recon against a Series B SaaS surfaced — 7 critical findings, the methodology behind each, and the lesson for defenders.

PT
🎯 Penetration Testing
PT

The most uncomfortable conversations in offensive security happen after a 30-minute initial sweep produces more critical findings than the customer’s last annual pentest. This is one of those engagements. The target — anonymised here as a mid-stage SaaS in the e-signature space, with 200+ employees, a recently-funded growth round, and a clean SOC 2 Type II report from a tier-one auditor — had everything a security-mature company should have. It also had seven critical-severity findings reachable through 30 minutes of passive recon plus minimal active probing. None of the findings were exotic. All of them are documented in the OWASP literature, in this site’s web application pentest methodology, and in every introductory pentest training. The point of this article is not to embarrass the customer (the engagement was authorised, scoped, and led to all seven being fixed). The point is to show how a defender’s mental model of “we’re secure because we have a SOC 2” misses what an attacker actually does in the first half-hour. Minute by minute, with the recon stack and the technical findings.

Setting the engagement

The target

A B2B SaaS product handling document workflows and electronic signatures for mid-market customers. Cloud-native, AWS-hosted, predominantly TypeScript / Node.js backend with a React frontend. Active subscription customer count in the low five figures. Annual recurring revenue mid-eight figures.

Security artefacts in place at engagement start:

  • SOC 2 Type II report, current, from a tier-one auditor
  • ISO 27001 certified for the previous two years
  • Annual penetration test commissioned and complete
  • Bug bounty programme active for 18 months with paid findings
  • Internal AppSec team of three engineers
  • Snyk for SCA and IaC scanning, Wiz for cloud posture, Burp Suite Pro on individual seats

The engagement parameters

  • Authorised scope: production environment, customer-facing endpoints, no destructive testing
  • Time-box: 30 minutes of initial reconnaissance, then formal pentest follow-up
  • Pre-engagement materials provided: OpenAPI documentation, application URLs, customer-facing API base URL, one test account at standard customer permission

The 30-minute window was a deliberate scoping choice — to surface what an attacker would find in the time before any defender’s detection layer would reasonably trigger.

Minute 0–3 — passive recon stack

Recon ran in parallel from three sources, no traffic touching the customer’s infrastructure yet.

Source 1 — certificate transparency

Pulled all SSL certificates issued to the company’s primary domain across the past five years from the public CT logs. The output: 247 unique subdomains. The expected production surface (app, api, www, etc.) was 12 of those. The remaining 235 included:

  • Legacy customer environments (acme-customer.example.com)
  • Pre-production environments (staging, staging-2, qa, qa-eu, preview-pr-1247)
  • Internal-only tools (grafana, kibana, sentry, metabase, airflow)
  • Marketing campaign subdomains
  • Acquired company subdomains
  • Subdomains used briefly for one-off experiments
# Certificate transparency pull
curl -s "https://crt.sh/?q=%25.example.com&output=json" | \
  jq -r '.[].name_value' | sed 's/\\n/\n/g' | sort -u > subdomains.txt

Source 2 — JavaScript bundle inspection

Loaded the main application’s JS bundle in a clean browser, captured the bundle URL, downloaded the source-map (still publicly accessible in production — a finding in itself though not counted in the seven). Extracted all string literals matching URL patterns, API endpoint patterns, and environment variable patterns.

The output: a second list of subdomains, some not in CT logs (internal API endpoints), and notably, a list of third-party services the app calls (Stripe, Auth0, Mixpanel, Datadog, Sentry, FullStory, plus several internal microservice URLs).

Source 3 — passive DNS

A passive DNS provider (SecurityTrails-equivalent) was queried for the same domain. Output: historical A records and CNAME records for the domain over the past three years, including subdomains that had been delisted from the public CT log surface.

The cross-correlation of these three sources produced the working surface map. From here, active testing.

Minute 3–7 — Finding 1: subdomain takeover

A passive DNS lookup on one of the marketing-campaign subdomains (promo-q3-2024.example.com) revealed a CNAME pointing to redirected.example-cdn-vendor.com. The CDN vendor’s response was a 404 NoSuchBucket-style page.

Behaviour pattern: the marketing team had used the vendor for a promotional campaign in Q3 2024, decommissioned the campaign in Q1 2025, removed the configuration on the vendor side — but left the CNAME pointing at the vendor. The vendor allowed registration of new resources at any name. Registration of a matching name yields control of the subdomain.

A subdomain takeover here would allow an attacker to:

  • Serve arbitrary HTML at promo-q3-2024.example.com
  • Steal session cookies set with the parent domain’s path (depending on cookie scoping)
  • Phish customers via a domain that legitimately belongs to the company
  • Bypass URL-allowlist filters in email security tools that allow *.example.com

The takeover was not performed against the customer. The finding was confirmed via DNS resolution behaviour and the vendor’s documented takeover-vulnerable response. Severity: critical.

The fix: identify every CNAME pointing at a third-party service, verify each is still in use, remove unused ones. The continuous control is a tool like subjack, nuclei, or a managed EASM service (see Snyk vs Wiz vs Detectify for the Detectify side of this) running daily against the full subdomain inventory.

Minute 7–11 — Finding 2: exposed Kibana

The internal-tools subdomain list included kibana.example.com. A direct browser visit returned the Kibana login page. The interesting part: the page accepted requests without authentication when the Authorization header was sent with a forged token of the format Basic dW5kZWZpbmVkOnVuZGVmaW5lZA== (which decodes to undefined:undefined).

Behaviour pattern: Kibana was reverse-proxied behind a nginx instance with an authentication mechanism that, due to a misconfiguration, only checked whether an Authorization header was present — not whether the credentials were valid. The header value of undefined:undefined came from a misconfigured client-side authentication library that, when not initialised, sent the literal string undefined as both username and password.

Direct enumeration of indices revealed application logs going back 90 days, including:

  • Customer document IDs being processed
  • Internal API trace IDs with timing data
  • Several thousand log entries containing user emails, internal IDs, and document type classifications
  • API tokens for downstream services, occasionally logged at error level

Severity: critical. The fix shipped within 48 hours of the finding being reported: nginx auth reverted to a proper bcrypt-checked configuration, and the Kibana instance was moved to a VPN-gated network.

The pattern repeats in every fifth engagement: an internal observability tool that “wasn’t supposed to be internet-exposed” ends up exposed because the reverse proxy in front of it has a configuration drift. The fix is structural — internal tools should not depend on an authentication layer the application doesn’t enforce itself.

Minute 11–14 — Finding 3: Sentry token leak in JS bundle

The JavaScript bundle inspection from Minute 0-3 had captured all string literals. A second pass with a more targeted regex (/[A-Za-z0-9_-]{64,}/) surfaced a 64-character token at a specific bundle offset.

The token format and its surrounding context matched Sentry’s DSN (Data Source Name) pattern — specifically, the format that includes both the public key (intended to be in client-side code) and the project ID. The token in this case was different: it was a private “organisation auth token” with read access to all projects in the Sentry organisation.

The token had been added by a developer building an internal “send error report” feature six months prior. The intended workflow was for the feature to call a server-side endpoint that used the token; somewhere in the implementation, the token ended up baked into the client bundle.

With the token:

  • Read access to all error events across all projects (1.4M events at time of testing)
  • Each event included stack traces with file paths, occasional API keys in error context, customer email addresses in error breadcrumbs, request body snippets
  • Read access to user feedback submissions including PII
  • Read access to release history

Severity: critical. The token was rotated within minutes of the report. The deeper finding — that secret-scanning in the CI pipeline (Snyk and GitHub Secret Scanning) had not detected the token — required a secondary review. Sentry’s organisation auth tokens have a specific prefix that secret scanners can detect, but the customer’s scanners were configured only for AWS, GitHub, and Stripe key formats.

For the pattern of credential leaks in client-side code, the audit methodology covered in container security audit checklist (Check 4 — secrets in image layers) extends naturally to client bundles in this category.

Minute 14–19 — Finding 4: mass assignment in user profile

With the documented OpenAPI spec in hand, the test against PATCH /api/v2/users/me proceeded along the methodology in our API penetration testing playbook.

The legitimate request:

PATCH /api/v2/users/me HTTP/1.1
Content-Type: application/json
Authorization: Bearer <test_token>

{"display_name": "Test User"}

The expanded test request:

PATCH /api/v2/users/me HTTP/1.1
Content-Type: application/json
Authorization: Bearer <test_token>

{
  "display_name": "Test User",
  "email_verified": true,
  "subscription_tier": "enterprise",
  "is_admin": true,
  "organisation_id": "<another known org id>",
  "billing_account_id": "<another known billing id>",
  "credits_remaining": 999999,
  "trial_extended_until": "2030-01-01"
}

A subsequent GET /api/v2/users/me confirmed three of the eight attempted fields had persisted:

  • email_verified flipped to true without going through the email verification flow
  • credits_remaining set to 999999 (a value far above any customer plan)
  • trial_extended_until set to 2030, bypassing the normal trial-extension business logic

The other five fields had server-side guards. The implementation gap: the API used Mongoose schema definitions to control acceptable fields, and three of the eight had been added to the schema for internal admin tooling but never had the field-level authorisation that other sensitive fields had.

Severity: critical. The chain — combined with billing logic that read credits_remaining as authoritative — meant a customer could give themselves arbitrary credit balance via the API. The fix involved adding the missing editable_by_user flag on each Mongoose schema field and gating the PATCH endpoint accordingly.

Minute 19–22 — Finding 5: JWT alg=none accepted on a forgotten endpoint

The OpenAPI documentation listed an endpoint at /api/v1/legacy/sso-validate (under a “deprecated” tag with a note that it would be removed in 2024).

A token was submitted to the endpoint with a header of {"alg": "none", "typ": "JWT"} and a hand-crafted payload claiming admin privileges. The endpoint accepted the unsigned token and returned a session cookie for an admin user.

The root cause: the legacy endpoint had been built before the team standardised on a single JWT library. It used a Node.js JWT library version from 2019 that defaulted to accepting alg: none if the verification function was called without an explicit algorithm whitelist. The library had since been upgraded in the rest of the codebase, but this legacy endpoint had been forgotten in the upgrade — and the endpoint was still wired into the routing despite the “deprecated” tag.

Severity: critical. The full attack chain produced administrative session cookies for the platform. The fix: delete the legacy endpoint outright (verified no production traffic was hitting it), upgrade the dependency, add the algorithm whitelist explicitly to every JWT verify call. The deeper finding — that the customer had no centralised audit of which JWT verification libraries were in use — became a longer-term hardening project.

For broader context on JWT and OAuth library misconfigurations, see our OAuth 2.0 misconfigurations breakdown.

Minute 22–26 — Finding 6: open S3 bucket with backup snapshots

Cross-referencing the application’s bundle for S3 bucket name hints (and using common bucket-naming heuristics derived from the company name and product names) yielded a candidate bucket called <companyname>-backups.

aws s3 ls s3://companyname-backups --no-sign-request

The bucket was publicly listable. Contents included:

  • Database backup snapshots from the previous 90 days
  • File-system backups of three internal services
  • An export of customer document metadata from a six-month-old incident response exercise

The database snapshots were not directly readable in their compressed form, but the bucket policy allowed download. A single snapshot decompressed locally yielded a complete copy of the customer database — including hashed passwords, customer payment metadata (no card numbers; payment processing was via Stripe, but Stripe customer IDs were stored and could be used for limited Stripe operations), API keys for downstream services, and the full document index.

The misconfiguration: the bucket had been created during an incident response exercise the previous year, with a public-access policy as part of a vendor-side data export workflow. The exercise concluded, the export completed, the bucket was forgotten with the public policy intact.

Severity: critical. This single finding was material enough to potentially trigger breach notification obligations under multiple jurisdictions if a real attacker had found it first. The fix: bucket made private immediately, then deleted after a forensic check confirmed no external access in the logs (or rather — discovered that the logs were not retained at the bucket level; the actual access history could not be reconstructed). The deeper finding — that AWS Config rules were not detecting publicly-accessible S3 buckets at the org level — became a continuous control project tied into the AWS Security Hub vs GuardDuty vs Inspector stack.

Minute 26–30 — Finding 7: CORS misconfiguration accepting any origin

The final finding came from inspecting the response headers on the main API base URL.

GET /api/v2/me HTTP/1.1
Host: api.example.com
Origin: https://evil.com
Authorization: Bearer <test_token>

Response:

HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://evil.com
Access-Control-Allow-Credentials: true
Content-Type: application/json
{...}

The CORS policy reflected the request Origin header back into the response and set Access-Control-Allow-Credentials: true. This combination allows JavaScript on any origin to make authenticated requests to the API on behalf of any logged-in user (assuming the user visits the malicious origin while logged in).

The intended behaviour: the team had wanted to allow CORS for embedded experiences (the e-signature widget being embeddable on customer websites) without maintaining a registered customer-origin list. The implementation: reflect any origin. The unintended consequence: any malicious site could exfiltrate any logged-in user’s session-scoped data.

Severity: critical. The fix: server-side allow-list of customer-registered origins, sourced from the customer’s tenant configuration. The deeper finding — that the CORS policy had not been flagged by any of the customer’s existing tools (Burp Suite Professional manual audit had caught related issues in the prior pentest, but the CORS reflection had been introduced after that test and missed in the next test) — became part of the next-engagement scope discussion.

The pattern across all 7 findings

The seven findings have a structural pattern worth naming.

FindingRoot cause category
1. Subdomain takeoverAsset inventory drift (CNAME outliving the resource)
2. Exposed KibanaReverse-proxy auth drift
3. Sentry token in JS bundleSecret scanning blind spot
4. Mass assignmentField-level authz not centralised
5. JWT alg=none on legacy endpointLibrary upgrade not org-wide
6. Open S3 bucketProject-finished, resource-still-active drift
7. CORS misconfigurationConvenience default that bypassed the allow-list pattern

Five of the seven are drift problems. The system was secure at one point; something changed; the security control didn’t get carried forward; the gap remained until someone looked. The remaining two (Findings 4 and 7) are convenience-default problems — choosing the easy implementation over the safe one when both were available.

The implication for defenders is uncomfortable: none of these findings were caused by missing knowledge. The OWASP literature documents every one of them. The customer’s AppSec team knew about every one of them. The continuous controls to prevent each one are well-known. What was missing was the operational discipline to maintain the continuous controls as the organisation moved.

What 30 minutes is and isn’t

Half an hour is enough to find the structural gaps. It is not enough to find every gap.

The findings above came from the easiest layer of the attack surface — exposed assets, client-side bundles, documented endpoints, common misconfigurations. Deeper engagement work surfaces:

  • Privilege escalation through multi-step workflows
  • Race conditions in financial logic
  • Cross-tenant data leakage in shared infrastructure
  • Authorisation chain breakdowns that require business context to understand
  • Cloud identity escalation paths that need IAM graph analysis

Those typically take days of engagement time and require the methodology documented in our AWS IAM audit walkthrough and the broader cloud security assessment service. The 30-minute findings are the structural debt; the multi-day findings are the systemic risk.

A useful test for a security programme: have an external party run the equivalent 30-minute sweep against your own production. The findings list is the structural-debt baseline. If it produces seven critical findings, the programme is not failing — it is in a normal state for an organisation moving fast. If it produces zero, either the team is exceptional or the test was constrained.

How to defend against 30-minute findings

The defensive patterns that close each of the seven categories:

Finding categoryDefensive control
Subdomain takeoverContinuous EASM tool (Detectify, Wiz, or self-hosted nuclei) running daily on the full subdomain list. Decommissioning runbook that includes CNAME removal.
Exposed internal toolsInternal tools accessed only via VPN or zero-trust network access. No “secured by nginx reverse proxy” architectures for sensitive observability surfaces.
Client-side secret leaksOrg-wide secret-scanning configured for every secret-shaped pattern in use (not just AWS keys). Pre-commit hooks catching common patterns. Sentry-style DSN audit per repo.
Mass assignmentServer-side allow-listing on every PATCH endpoint. Schema-level editable_by_user flags as the default field disposition. Automated test that confirms each sensitive field cannot be updated by a non-admin.
Legacy endpoints with library driftDependency upgrade automation org-wide. Periodic audit of which endpoints exist, which library versions back them, and what’s actually still being hit. Delete unused endpoints aggressively.
Forgotten public-access cloud resourcesAWS Config / GCP Asset Inventory / Azure Resource Graph rules catching public S3, public RDS snapshots, public AMIs, public ECR images. Quarterly review.
CORS misconfigurationCORS allow-list centralised in middleware, fed from tenant configuration. No reflect-origin patterns in production code. Pre-merge linter that flags Access-Control-Allow-Origin: * with credentials.

The defensive list is short because the findings are common. Implementing the controls is operational work, not technical magic. The argument was never whether these findings can be prevented. They can. The argument is whether the team has the operational rhythm to ship the prevention before the next sweep finds them again.

The customer in this story did. The seven findings closed. The next quarterly engagement against the same surface produced two new findings (one mass-assignment regression, one new subdomain in the takeover category), both lower severity, both fixed within the same week.

That is what a working security programme looks like: not zero findings, but a fast-shrinking surface for the next sweep. Thirty minutes is enough to know whether you’re in that state.

#Pentesting #Recon #AppSec #Cloud Security #Bug Bounty

Related articles

Need expert help with Penetration Testing?

Our certified security team is ready to assess your environment and recommend the right solutions.

Book a Free Consultation