Reading Responses: Status Codes, Headers, and Body Forensics
A 403 and a 404 look similar. They mean completely different things.Press enter or click to view ima 2026-5-4 13:6:12 Author: infosecwriteups.com(查看原文) 阅读量:22 收藏

A 403 and a 404 look similar. They mean completely different things.

Roshan Rajbanshi

Press enter or click to view image in full size

Series: curl — The Request Engine You Never Learned Properly Article: 4 of 16

A beginner glances at an HTTP response and moves on. A practitioner interrogates it.

The difference is knowing that every field in an HTTP response is a data point — about the server, the technology stack, the application logic, and sometimes the security posture. Status codes are not just traffic lights. Headers are not just metadata. Response body size and timing are not just performance statistics.

This article teaches you to read a response the way a practitioner does: as a source of intelligence.

The Three Output Flags and When to Use Each

Before reading responses, you need to see them. Three curl flags give you response data, and they are not interchangeable.

-I — Sends a HEAD request, returns headers only

curl -I http://localhost:8080
HTTP/1.0 200 OK
Server: BaseHTTP/0.6 Python/3.8.10
Date: Wed, 22 Apr 2026 03:06:18 GMT
Content-Type: text/plain; charset=utf-8
X-Lab-Server: curl-series-echo-v1
X-Lab-Note: HEAD request - no body returned

-I changes the HTTP method to HEAD. The server returns headers but no body — not because curl discards it, but because HEAD instructs the server not to send one. Use this when you want a quick snapshot of what a server announces about itself without downloading any content. Fast and low noise.

One limitation to keep in mind: some servers return different headers for HEAD than for GET. If a header you are looking for is absent from a -I response, try -i before concluding it does not exist.

-i — Includes response headers in output, keeps GET method

curl -i http://localhost:8080
HTTP/1.0 200 OK
Server: BaseHTTP/0.6 Python/3.8.10
Date: Tue, 21 Apr 2026 12:28:41 GMT
Content-Type: text/plain; charset=utf-8
Content-Length: 422
X-Lab-Server: curl-series-echo-v1
==================================================
curl Lab Echo Server
==================================================
METHOD : GET
PATH : /
FULL URL : /
--- REQUEST HEADERS ---
Host: localhost:8080
User-Agent: curl/7.68.0
Accept: */*
--- QUERY STRING PARAMS ---
(none)
--- RAW BODY ---
(empty)
--- PARSED BODY PARAMS ---
(none)
==================================================
The blank line between headers and body is clearly visible — the key teaching moment for understanding the HTTP separator

That blank line between the headers and the body is the HTTP separator — everything above it is a header, everything below is content. Unlike -I, this flag does not change the request method; it performs a normal GET and includes the response headers in what curl prints. This is the flag you reach for during recon when you want the full picture: what the server says it is, and what it actually returns. The headers and body together often tell a more complete story than either alone.

-v — Verbose, full request and response cycle

curl -v http://localhost:8080
*   Trying 127.0.0.1:8080...
* TCP_NODELAY set
* Connected to localhost (127.0.0.1) port 8080 (#0)
> GET / HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.68.0
> Accept: */*
>
* Mark bundle as not supporting multiuse
* HTTP 1.0, assume close after body
< HTTP/1.0 200 OK
< Server: BaseHTTP/0.6 Python/3.8.10
< Date: Tue, 21 Apr 2026 12:29:13 GMT
< Content-Type: text/plain; charset=utf-8
< Content-Length: 422
< X-Lab-Server: curl-series-echo-v1
<
==================================================
curl Lab Echo Server
==================================================
...
==================================================
* Closing connection 0
Full verbose lifecycle — *, >, < prefix lines all visible in a single clean output, best -v demo in the series

Compare this to -i above. The * lines show the TCP connection being established. The > lines are your outgoing request. The < lines are the server's response headers. More information than -i, more noise than you want for quick recon. Use -v when you need to confirm exactly what curl sent — debugging, flag verification, TLS inspection.

The practical workflow: start with -I for a quick header check, move to -i a full response inspection, escalate to -v when something unexpected happens, and you need to see the raw request you actually sent.

Status Codes as Forensic Artifacts

Status codes tell you what the server decided. They also tell you things the server did not intend to reveal.

200 OK

The endpoint exists, accepted your request, and returned content. For pentesters, a 200 is a starting point, not a conclusion. Note the response size and content type — a 200 returning 0 bytes or a tiny body is worth investigating. A 200 on an endpoint that should require authentication is a finding.

301 Moved Permanently / 302 Found

Redirects. The Location header tells you where. Capture it:

curl -I http://target.com | grep -i location

Note that Location Only appears in redirect responses — on a 200, this grep will return nothing. Redirect destinations reveal infrastructure: internal hostnames, load balancer addresses, CDN URLs, and admin subdomains. A 301 to https:// Also confirms the site is serving unencrypted traffic on port 80 — worth noting.

400 Bad Request

Your request was malformed — or the server thinks it was. In testing contexts, a 400 on a request that should be valid can indicate WAF intervention, an input validation rule, or a parsing issue. Compare a 400 response against a known-good request to identify what the server rejected.

401 Unauthorized

Authentication is required and was not provided, or was provided but is invalid. The distinction from 403 is important: 401 is about missing or invalid credentials, while 403 is about valid credentials that still lack permission. The WWW-Authenticate header tells you the authentication scheme expected: Basic, Digest, Bearer, NTLM. This is your cue to look at Article 6.

403 Forbidden

The server understood your request and chose not to fulfill it. This is different from 401 — the issue is authorization, not authentication. You may be authenticated, or the resource may be public, but access is still denied.

In most cases, a 403 response indicates that the resource exists and is protected. A 404 suggests it does not. A server returning 403 on /admin is likely confirming that /admin is a real path with access controls, which is more useful than a 404. That said, some applications intentionally return 404 for protected resources to hide their existence entirely, so treat a 403 as a strong signal rather than a guarantee.

404 Not Found

A 404 usually means the resource is not there — but verify before moving on. Check whether the application returns genuine 404s or custom error pages with a 200 status code, commonly called soft 404s. A soft 404 often returns a 200 with a generic error page, which is why size comparison matters. Compare the response size against a path that definitely does not exist (/zzznotreal). If they match, the application may be masking its 404s.

405 Method Not Allowed

Your HTTP method is not accepted on this endpoint. A standard 405 response should include a Allow header listing what methods the endpoint does accept. A 405 on a DELETE request that returns Allow: GET, POST tells you exactly what is permitted — useful for method discovery and understanding what operations the endpoint supports.

500 Internal Server Error

Something broke server-side. In a testing context, a 500 triggered by your input is a signal worth capturing. It may indicate that your payload reached the application logic and caused an unhandled exception — a common indicator of injection points. Save the request and response.

502 Bad Gateway / 503 Service Unavailable

Infrastructure responses. A 502 indicates that a reverse proxy or load balancer could not reach its backend. A 503 means the service is temporarily unavailable. These are less interesting for direct exploitation but can indicate infrastructure topology.

What a 403 Tells You That a 404 Does Not

This distinction is worth dwelling on because it directly affects the enumeration strategy.

A 404 usually means the resource is not there — but always compare size and content against a known-bad path to rule out soft 404s or camouflage responses before moving on.

A 403, on the other hand, strongly suggests the resource exists and is protected. This changes your approach:

  • Try different HTTP methods (-X POST, -X OPTIONS)
  • Test path normalization variants (/admin/, /ADMIN, /.//admin)
  • Probe with different headers (X-Forwarded-For: 127.0.0.1)

Article 7 covers 403 bypass techniques in depth. The point here is that your response triage must distinguish between “not found” and “found but blocked.” They look similar at a glance. They require entirely different next steps.

Header Extraction Patterns

The response headers are where servers involuntarily reveal their technology stack. Every header that identifies server software is a data point for vulnerability research.

Run this against a target:

curl -sI http://localhost:8080
HTTP/1.0 200 OK
Server: BaseHTTP/0.6 Python/3.8.10
Date: Wed, 22 Apr 2026 03:06:34 GMT
Content-Type: text/plain; charset=utf-8
X-Lab-Server: curl-series-echo-v1
X-Lab-Note: HEAD request - no body returned

Our lab server intentionally exposes X-Lab-Server — That is exactly what real servers do unintentionally with headers like X-Powered-By and Server. On a real target, this output is where your technology profile starts. Against httpbin.org for comparison:

curl -sI https://httpbin.org
HTTP/2 200
date: Tue, 21 Apr 2026 12:18:37 GMT
content-type: text/html; charset=utf-8
content-length: 9593
server: gunicorn/19.9.0
access-control-allow-origin: *
access-control-allow-credentials: true
-sI against a real public server — server: gunicorn/19.9.0 visible, demonstrating technology fingerprinting from headers in one command

server: gunicorn/19.9.0 — Python WSGI server, version exposed. One command, one data point for your CVE search workflow.

Headers to read and catalogue:

Server — The web server software and often its version. Apache/2.4.49, nginx/1.18.0, Microsoft-IIS/10.0. Version numbers go directly into your CVE search pipeline (Article 9). Some hardened servers omit this header or return a generic value — that absence is itself a data point.

X-Powered-By — The application framework. PHP/7.4.3, ASP.NET, Express. Often reveals more than the Server header because developers pay less attention to it.

X-Generator — CMS identification. WordPress 5.8, Drupal 9, Joomla! 4.0. Direct path to CMS-specific vulnerability research.

X-AspNet-Version — Specific .NET version. Useful for narrowing CVE searches on Windows-hosted applications.

X-Runtime — Rails applications often expose request processing time here. Also confirms Ruby on Rails as the stack.

Get Roshan Rajbanshi’s stories in your inbox

Join Medium for free to get updates from this writer.

Remember me for faster sign in

Set-Cookie — Beyond its authentication role, cookie names reveal frameworks. PHPSESSID = PHP. JSESSIONID = Java. ASP.NET_SessionId = ASP.NET. csrftoken + sessionid = Django.

Location — In redirect responses, the destination. In non-redirect contexts, it can appear in 201 Created responses pointing to newly created resources.

Build a header catalogue as you work through a target. The headers from a single -sI request often give you enough to identify the full stack before you have touched a single application function.

Technology Fingerprinting from a Single Request

A practical demonstration. The response below is representative of what you will see against a common LAMP stack target:

HTTP/1.1 200 OK
Server: Apache/2.4.41 (Ubuntu)
X-Powered-By: PHP/7.4.3
Set-Cookie: PHPSESSID=abc123; path=/
X-Frame-Options: SAMEORIGIN
Content-Type: text/html; charset=UTF-8

From this single response, without touching the application:

  • Web server: The Server header suggests Apache 2.4.41 on Ubuntu — though headers can be spoofed or stripped, so treat this as a strong indicator rather than a confirmed fact. Check CVE-2021-41773 (path traversal affecting 2.4.49, understanding the version range matters)
  • Language: PHP 7.4.3 — PHP 7.4 series, check for known CVEs in that minor version
  • Session management: PHP default session cookie — default configuration, which may indicate other defaults are in place
  • No X-Powered-By stripping — server hardening was likely not a priority

This is a technology profile from one command. Article 9 turns this into a CVE search workflow.

Open Redirect Detection with %{redirect_url}

An open redirect vulnerability exists when an application accepts a user-controlled URL and redirects to it without validation. Not every redirect is a vulnerability — a redirect to a trusted canonical domain is normal behavior. What makes an open redirect exploitable is when the destination is user-controlled. Attackers use these for phishing — the redirect originates from a trusted domain, so victims trust the initial URL.

Detection with curl:

curl -s -o /dev/null \
-w "Redirected to: %{redirect_url}\n" \
"http://httpbin.org/redirect-to?url=https://example.com"
Redirected to: https://example.com/

%{redirect_url} captures the value of the Location header without following it. This is different from -L what follows the redirect chain — here you want to read where it points, not follow it.

If the output shows Redirected to: https://evil.com, the application accepted your URL and is redirecting to it. Open redirect confirmed.

For a more thorough test:

# Test various bypass formats
for url in "https://example.com" "//example.com" "https:example.com"; do
echo -n "Testing $url: "
curl -s -o /dev/null -w "%{redirect_url}\n" \
"http://httpbin.org/redirect-to?url=$url"
done
Testing https://example.com: https://example.com/
Testing //example.com: http://example.com/
Testing https:example.com: https:example.com

Read the output carefully. //example.com resolved to http://example.com/ — The server treated the protocol-relative URL as valid and completed it. https:example.com was passed through as-is without resolution — the application accepted it, but the redirect is malformed. Each response tells you something different about how the server validates URLs. A properly implemented redirect should reject all three formats except the first.

All three formats tested in one terminal block — https://example.com resolved, //example.com completed to http, https:example.com passed through raw — three different server behaviors visible at once

Body Analysis: What the Response Content Reveals

Headers give you the server’s self-report. The body gives you evidence.

Response size as a signal:

curl -s -o /dev/null -w "Status: %{http_code} | Size: %{size_download} bytes\n" http://localhost:8080
curl -s -o /dev/null -w "Status: %{http_code} | Size: %{size_download} bytes\n" http://localhost:8080/zzzfake
Status: 200 | Size: 297 bytes
Status: 404 | Size: 469 bytes

Two different status codes and two different sizes. The 404 response is actually larger than the 200 — that is, because the server is returning a custom error page for paths it does not recognise, which contains more HTML than the directory listing. On a real target, compare a path you suspect exists against a path that definitely does not. A significantly different size on the suspected path confirms it has dedicated handling — even if both return the same status code.

If the 403 response to /admin is significantly larger than the 404 response to a random path, the application is returning a custom page for /admin specifically confirming the path exists with dedicated handling.

If they are the same size, the application may be returning a uniform error page for all non-existent or forbidden paths — less information, but still worth knowing.

Error message leakage:

Application error messages — stack traces, SQL errors, framework exceptions — often appear in the response body even when the status code is 200 or 500. Pipe the body through grep:

curl -s "http://target.com/item?id=1'" | grep -iE "sql|error|exception|warning|mysql|postgresql|syntax"

On a vulnerable target running MySQL, this might return:

You have an error in your SQL syntax; check the manual that corresponds
to your MySQL server version for the right syntax to use near ''1''' at line 1

On the TryHackMe SQL Injection sandbox (/sesqli3/login), client-side validation is bypassed via URL injection and the application returns structured error responses rather than raw database errors — a more hardened pattern. Against a raw MySQL or PostgreSQL application with no error handling, the database error surfaces directly in the response body. That grep output is your injection point confirmation.

SQL errors in response bodies are one of the most reliable indicators of possible SQL injection vulnerabilities. Matching output from that grep is a signal worth investigating — not automatic confirmation, but a strong reason to probe further. Error-based SQLi depends entirely on this leakage being present.

Response Timing as a Fingerprint

The timing variables from --write-out are more useful than most people realize.

curl -s -o /dev/null \
-w "Connect: %{time_connect}s | TTFB: %{time_starttransfer}s | Total: %{time_total}s\n" \
http://localhost:8080
Connect: 0.000696s | TTFB: 0.002201s | Total: 0.002269s

On localhost, the numbers are tiny — sub-millisecond connection, 2ms total. Against a real remote target over VPN, the baseline looks different:

Connect: 0.115304s | TTFB: 0.307021s | Total: 0.307s

That 0.3s TTFB against the TryHackMe lab is the server processing time — the application doing its work before sending the first byte back. This is the number you baseline before probing injection points.

time_connect — Time to establish the TCP connection. Consistent and fast on a healthy server. Spikes indicate network issues or server load.

time_starttransfer (TTFB — Time To First Byte) — Time from the request start to when the first byte of the response arrived. This is the server processing time. Spikes here on specific endpoints indicate server-side computation — database queries, file system operations, or deliberate delays.

Time-based blind injection detection:

# Baseline timing:
curl -s -o /dev/null -w "Baseline: %{time_total}s\n" "http://target.com/item?id=1"
# With sleep payload (MySQL):
curl -s -o /dev/null -w "With payload: %{time_total}s\n" "http://target.com/item?id=1 AND SLEEP(5)-- -"

On a vulnerable MySQL target, the output would look like:

Baseline:  0.312s
With payload: 5.318s

The ~5-second differential is your signal — the database executed the SLEEP(5) call, confirming the input reached the SQL engine unfiltered. You did not need a visible error or a changed response body.

Press enter or click to view image in full size

Baseline: 0.115304s and With payload: 0.0000014s — the timing comparison pattern is the teaching point

A note on database syntax: SLEEP(5) is MySQL syntax. For MSSQL use WAITFOR DELAY '0:0:5'. SQLite has no native sleep function — time-based blind injection on SQLite instead relies on computationally heavy queries. Always identify the database first before constructing timing payloads.

The 3-Step Response Triage Workflow

When you land on any new endpoint for the first time:

Step 1 — Headers: curl -sI http://target.com/endpoint

Read Server, X-Powered-By, X-Generator, and Set-Cookie. Build your technology profile. Check for missing security headers (covered in Article 7).

Step 2 — Size: curl -s -o /dev/null -w "%{http_code} %{size_download}\n" http://target.com/endpoint

Note the status code and size. Baseline this against a known-bad path. Use size differentials to distinguish genuine errors from custom error pages.

Step 3 — Body: curl -s http://target.com/endpoint | head -100

Read the first hundred lines of the body. Look for version strings, framework identifiers, error messages, comments in HTML, JavaScript includes that reveal framework names, and any user-controlled data reflected.

Three commands. Sixty seconds. A target profile you can build a testing plan from.

Quick Reference — Article 4

# Headers only (fast recon)
curl -sI http://target.com
# Headers + body
curl -i http://target.com
# Full request/response cycle
curl -v http://target.com
# Status code + size in one line
curl -s -o /dev/null -w "%{http_code} %{size_download} bytes\n" http://target.com
# Size comparison — real path vs fake path
curl -s -o /dev/null -w "Status: %{http_code} | Size: %{size_download} bytes\n" http://target.com/path
curl -s -o /dev/null -w "Status: %{http_code} | Size: %{size_download} bytes\n" http://target.com/zzzfake# Timing fingerprint
curl -s -o /dev/null \
-w "Connect: %{time_connect}s | TTFB: %{time_starttransfer}s | Total: %{time_total}s\n" \
http://target.com
# Redirect destination without following
curl -s -o /dev/null -w "%{redirect_url}\n" http://target.com/path
# Error leakage check
curl -s "http://target.com/path?id=1'" | grep -iE "sql|error|exception|warning|mysql|postgresql|syntax"
# Time-based blind SQLi (MySQL)
curl -s -o /dev/null -w "%{time_total}s\n" "http://target.com/path?id=1 AND SLEEP(5)-- -"
STATUS CODE QUICK REFERENCE
-----------------------------
200 Exists and returned content — start, not conclusion
301 Permanent redirect — check Location header
302 Temporary redirect — check Location header
400 Bad request — WAF? Input validation? Parsing issue?
401 Auth required — check WWW-Authenticate for scheme
403 Resource likely exists but access denied — probe further
404 Usually not found — verify with size comparison
405 Method not allowed — check Allow header for what works
500 Server error triggered by input — save the request

The habit this article encourages: every response tells you something. Train yourself to read what it says rather than just check whether the request succeeded.

Next: Article 5 — POST, PUT, DELETE: Building Custom Requests from Zero


文章来源: https://infosecwriteups.com/reading-responses-status-codes-headers-and-body-forensics-76c2da93335f?source=rss----7b722bfd1b8d---4
如有侵权请联系:admin#unsafe.sh