Bali, Indonesia
Insights

Why HTTP 200 in Access Logs Does Not Mean an Exploit Succeeded

October 16, 2026
0:00
When reviewing access logs during a suspected website compromise, seeing an exploit URL return an HTTP status of 200 often triggers immediate alarms. A vulnerability scanner or automated bot sends a request for a known remote code execution or file upload vulnerability, and the web server records a status code that universally stands for "OK." In high-pressure incident triage, support engineers and site owners frequently mistake this entry for definitive proof that the attacker succeeded and the server is compromised. In reality, modern web architecture decouples the HTTP status code from the underlying file system. Front controllers, single-page application routing, reverse proxies, and custom error handlers routinely return an HTTP 200 header even when the requested file does not exist or the underlying payload completely failed to execute. Relying on the status code alone leads to false positives and misdirected investigative efforts.
It feels logical that requesting /wp-content/plugins/vulnerable-plugin/upload.php and getting a 200 response means upload.php was present on disk and processed the request. In traditional, flat static hosting environments where Apache mapped every URI directly to a physical file, a missing file almost always generated an immediate 404 Not Found generated by the web server daemon. Modern content management systems and application frameworks do not work this way. Under Nginx, Apache, or LiteSpeed, web servers almost universally route unmatched requests through a front controller—typically index.php in platforms like WordPress, Drupal, or Laravel. A directive like try_files uriuri/ /index.php?$args; tells the web server to check if the requested file exists, and if it does not, hand the entire request over to PHP. When the PHP application receives an unknown URI, its internal router evaluates the route. Depending on the theme, plugins, or rewrite rules configured in the application, the CMS might not return a 404 header. It may redirect internally to the homepage, render a "search results" page for terms parsed from the URL slug, or serve a custom error page configured with a soft 404 that omits the appropriate status header. In every one of those cases, the web server writes an HTTP 200 status into the access log, even though the exploit script never existed on the filesystem.
When an attacker sends a POST request containing an encoded payload, seeing an HTTP 200 in the log feels far more dangerous than a simple GET scan. The common assumption is that a POST request sends data into an endpoint, and an HTTP 200 confirms that the endpoint accepted, parsed, and executed the payload. What actually happens is that many web applications discard unauthenticated or invalid POST requests without altering the HTTP response code. If a bot sends an automated exploit payload to /wp-login.php or /xmlrpc.php, the application may process the request, determine that the credentials or parameters are invalid, and render the standard login HTML form again. Because the form was rendered successfully, the HTTP status is 200. Similarly, generic API endpoints often respond with an HTTP 200 status code at the transport level while conveying an application-level rejection inside the response body, such as {"status": "error", "message": "invalid nonce"}. The web server access log only tracks the transport-layer response header. Unless your logging pipeline captures the response body or you inspect the application-level debug log, a rejected exploit looks identical in the access log to an accepted one.
Because status codes are standardized numeric values, log analysis often begins and ends with filtering for codes starting with 2 or 3. It is tempting to run a command that filters for 200 alongside known malicious keywords and treat every matching line as a valid compromise indicator. The response size in bytes—recorded right after the HTTP status code in standard Combined Log Format—is often the real differentiator between a triggered exploit and a caught fallback. When a CMS serves its standard homepage or custom fallback page, the byte size of the response remains relatively consistent across different probes. If an automated scanner fires twenty distinct exploit strings at twenty different paths, and every single entry logs an HTTP 200 with an identical size of 42,150 bytes, the server is serving the same default template every time. Conversely, a successful exploit rarely matches the footprint of your standard application layout. Remote code execution scripts, web shells, and unauthenticated option dumps usually output either very little data (such as raw text or blank output from an eval() call) or an unformatted dump of database credentials. If nineteen probes returned 42,150 bytes and one returned 142 bytes with an HTTP 200, that anomaly warrants immediate inspection. You can inspect this relationship directly using standard Linux text processing tools. In standard Combined Log Format, field 9 represents the HTTP status code and field 10 represents the bytes sent to the client:
BASH
awk '{print 9,10, $7}' /var/log/nginx/access.log | grep '^200' | sort | uniq -c | head -n 20
In this output, you will typically see large clusters of requests with identical byte counts across varying URIs. Those clusters represent your application's standard template responses, not individual exploit executions.
Engineers often assume that if a Web Application Firewall (WAF) or security plugin had intercepted the attack, the log entry would unmistakably read 403 Forbidden or 406 Not Acceptable. Consequently, if a known malicious request shows a 200 instead of a 403, teams assume the WAF failed or let the malicious payload pass through unchecked. While traditional perimeter firewalls prefer returning 403 or dropping packets entirely, many application-level security tools deliberately respond with an HTTP 200. Security solutions sometimes use honeypot mechanisms, challenge pages, or silent drops to confuse automated exploit tools. An automated scanner checking for an exposed environment variable may look for a specific string; if a security plugin intercepts the request and responds with a 200 OK containing a mock HTML splash page or a CAPTCHA challenge, the scanner's payload was neutralized even though the log registers a successful HTTP transaction. Furthermore, rate-limiting or anti-scraping modules often rewrite requests to an internal challenge URI rather than terminating the connection. The access log records the completion of that challenge page delivery, resulting in a false impression that the target endpoint handled the malicious request.
Confirming an intrusion requires verifying multiple layers of evidence rather than stopping at the access log. If an access log shows an HTTP 200 on a suspicious request, check the local filesystem first to see if the requested file actually exists in the directory tree. If the file is not on disk, verify your web server rewrite rules to see which file handled the fallback. Next, correlate the access log entry with the web server error log and PHP-FPM error logs for the exact same second. A script executing unexpected input often triggers PHP notices, fatal errors, or database syntax failures. Finally, compare the response byte size against normal traffic for that endpoint. If the byte size deviates sharply from standard responses, or if the file exists and has a recent modification timestamp matching the request, escalate the incident to deeper forensic isolation.