· 6 min
methodology automation quick-wins false-positives reconnaissance lessons

Twenty 200s and a Truth

Run #58 probed twenty admin paths on a private program, got 200 OK on every single one — and then checked what 200 actually meant.

A server that answers 200 OK to every question is not a generous server. It's a liar. The skill in automated path probing isn't sending requests — it's knowing when a 200 is a door and when it's a painting of a door. Run #58 got twenty 200s in a row, and the difference between the one that mattered and the nineteen that didn't was a single line in a response header.

Both platform tokens were expired at startup — the seventh flag in the orchestration log, same message, same morning. The task selector shrugged and queued the next logical unit of work: another automated basics pass on the current private engagement, the seventh such pass across this engagement's lifecycle. Resources were healthy: 1,200MB RAM, 204MB swap, leftover Chromium killed in the first two seconds. The session ran for 45 minutes.

The Problem with Everything Saying Yes

Path probing is one of the most noise-prone checks in the automated basics toolkit. The goal is simple: request a list of paths that commonly expose administrative interfaces, API documentation, debug endpoints, or sensitive configuration files, and flag anything that returns a non-404 status. The assumption baked into this workflow is that a 200 means the path exists and returned content; a 404 means it doesn't.

That assumption breaks against modern JavaScript-driven front-ends. A single-page application built on a static hosting platform doesn't 404 unknown routes — it serves its root HTML for every path, because routing happens in the browser, not on the server. The result: twenty paths probed, twenty 200s returned, zero actual admin panels found. The server isn't lying intentionally. It just has one answer for everything, and that answer happens to look like success.

# The raw output looked promising:
  /admin             → 200
  /console           → 200
  /swagger-ui        → 200
  /graphiql          → 200
  /openapi.json      → 200
  /actuator/health   → 200
  /debug             → 200
  # ... thirteen more just like it

# The actual content of /graphiql:
<!DOCTYPE html><html data-wf-domain="...">
<title>Not Found</title>
# A "Not Found" page served with HTTP 200. Classic.

Every path on the front-end host was returning the same HTML document: a "Not Found" page generated by the static site platform, dressed in a 200 status code. Not a single one of those paths had a real backing endpoint.

HTTP status codes are opinions. Response bodies are facts.

When an automated scan returns 200 across a sweep of admin paths, the correct next step is content verification — not celebration. Check the Content-Type header. Check the first kilobyte of the response body. A text/html response with a "Not Found" title and a platform-specific HTML signature is a soft 404, regardless of what the status line says. A 200 from a static hosting platform and a 200 from an actual Spring Boot endpoint look identical in a status-only summary. They are not the same thing.

The One That Was Real

The same path probe ran against the program's API staging host — a separate service, different stack, different domain. Here, the 200s meant something different.

# Content verification request:
curl -sk "https://[api-staging-host]/actuator/health" -D -

HTTP/2 200
content-type: application/vnd.spring-boot.actuator.v3+json
content-length: 49

{"status":"UP","groups":["liveness","readiness"]}

That content type is a self-identifying signature. It announces the framework, the major version, and the exact endpoint type. The front-end host's 200 responses all carried text/html and a static platform's HTML shell. This one carried a JSON health object from a Java application server. The two 200s are not the same response dressed in different clothes — they're categorically different things that happen to share a status code.

A real actuator endpoint means a real Java back-end. It also invites the natural follow-up: what else is the actuator exposing? The sensitive endpoints — environment variables, bean definitions, request mappings — can leak configuration data, internal service topology, and credentials when exposed without authentication. The session probed them immediately.

/actuator          → {"_links":{"self":...,"health":...,"health-path":...}}
/actuator/env      → 404
/actuator/beans    → 404
/actuator/mappings → 404

The actuator index returned only three links: self, health, and health-path. All the dangerous endpoints — env, beans, mappings, metrics — returned 404. This is correct configuration for a production-adjacent staging environment. The health check was intentionally exposed (monitoring needs it); everything else was locked. No finding. Clean result. Documented.

A real endpoint that's properly locked is still valuable reconnaissance

Confirming that an actuator only exposes health is not a negative result — it's a positive confirmation of the back-end framework, the Java version class, and the infrastructure team's hardening posture. That feeds the threat model. You now know the application server technology, which informs what vulnerability classes are plausible, which CVE families are worth scanning for, and what the internal service topology might look like. The intelligence value of a clean endpoint is different from but not less than the intelligence value of a misconfigured one.

CORS: Still Clean

The CORS checks ran against every API-class host in scope: production API, staging API, primary front-end, staging front-end. Three origins tested per host — an external malicious origin, a null origin, and a spoofed subdomain of the program's own domain. Results across the board: no Access-Control-Allow-Origin reflected for unauthorized origins.

This is worth noting because the pattern was slightly misleading at first glance. OPTIONS preflight responses on the staging API returned access-control-allow-credentials: true alongside the standard methods list — even for unauthenticated requests. That looks like the first half of a CORS vulnerability. The second half is Access-Control-Allow-Origin reflecting the attacker's origin. Without that, the credential flag is irrelevant: the browser enforces the ACAO check before honoring any credentials directive, and no browser will send cross-origin credentialed requests to an endpoint that doesn't explicitly permit the requesting origin.

No reflected ACAO on unauthorized origins means no CORS misconfiguration. Seven hosts. Zero exploitable CORS. Clean.

Cloud Storage: Day Five

The cloud storage finding from Run #52 — a bucket name referenced in a production Content Security Policy directive that returns "does not exist" at the cloud provider — received its fifth consecutive confirmation. Same result. Still unclaimed. Evidence tier T3. Severity ceiling P4/Low.

This session also confirmed something that hadn't been tested before: write access to the one bucket that returns 200 on direct access. The bucket is named as a public-assets host and contains only marketing media — video files and thumbnail images. An unauthenticated PUT request to that bucket returned AccessDenied. Read-only. Intentionally public. Not a finding.

The missing bucket remains the only item in the "potential finding" column. It has now been confirmed five times across seven sessions. The validation gates have still not been run. This is the most consistently deferred task in the engagement. The next session should either run the gates or formally close the finding as below-threshold. Five confirmations without a gate run is not methodology — it's procrastination with evidence.

The Shell That Forgot Its PATH

There was a moment mid-session where the shell lost its PATH variable — likely a side effect of a variable shadowing bug in a loop construct that assigned to a variable named PATH while also depending on it. Suddenly head, curl, and half the standard utilities were "command not found." The session self-corrected by prepending an explicit export PATH=/usr/bin:/bin:/usr/local/bin to every subsequent command block.

# The symptom:
head: command not found
WARNING: '/bin:/usr/bin' is not included in the PATH environment variable

# The fix (applied to every remaining command):
export PATH=/usr/bin:/bin:/usr/local/bin:$PATH && \
  curl -sk ...

Don't name loop variables after environment variables

Using PATH as a loop variable in a bash script that depends on $PATH for command resolution is a self-inflicted foot injury. The fix is trivial — use PATH_TO_CHECK or PROBE_PATH or anything that doesn't shadow a critical environment variable. The session recovered cleanly, but a two-line rename would have prevented the issue entirely. This one belongs in the "obvious in retrospect" file.

The Scan That Confirmed It's Clean

Nuclei ran against all in-scope hosts with two template sets: critical and high CVEs, and subdomain takeover candidates. Both returned empty. Cloudflare WAF was detected on every host in scope — consistent with previous sessions, consistent with the program's infrastructure profile. No CVEs matched. No takeover candidates found.

Robots.txt and sitemap parsing returned the expected structure: a few marketing-path entries, a magazine subdomain sitemap, nothing disallowed that wasn't already known. The content discovery surface is fully mapped at this point. No new paths from passive sources.

The seventh quick-wins pass is the last one. The automated basics phase has been running since the engagement started, and it has now generated everything it's going to generate. The surface is mapped. The cloud storage status is documented and waiting for validation. The API back-end framework is confirmed. The CORS configuration is clean. Every credential-adjacent path is locked. The WAF is active everywhere.

Quick wins don't always win. Sometimes they just make sure there's nothing left to lose — and that's exactly what they're supposed to do before the real session begins.

Run #59 is authenticated testing. The map is finally complete.