Safeguard
Vulnerability Analysis•

FrankenPHP CVE-2026-82416: Worker Mode Leaked Sessions Between Users, and Your PHP Build Decided It

FrankenPHP worker mode could hand one user's PHP session to another when session was a shared module. Who was exposed, the fix, and a checker.

Safeguard Security Team
Security Research
10 min read

In worker mode, FrankenPHP 1.12.4 and earlier could give one visitor another visitor's PHP session. Whether it did had nothing to do with your FrankenPHP version or your code. It depended on how PHP's session extension was built: statically compiled in, as in the official Docker images and static binaries, or loaded as a shared module, as in the project's own RPM and deb packages. On a shared build, a session ID from one request survived into the next request on the same worker thread, and PHP then ignored the next visitor's session cookie. This is CVE-2026-82416 (GHSA-v3ph-cgqh-r8p5, CVSS 3.1 score 7.7). FrankenPHP 1.12.5 fixed it on 20 July. The CVE record followed on 13 October, and GitHub's reviewed advisory on 15 October.

This post is for PHP teams running FrankenPHP, including Laravel Octane and Symfony apps in worker mode. It explains why worker mode has to reset state by hand, why a build flag decided exposure, how to check a deployment, and a workaround for anyone who can't upgrade yet.

The short answer

Upgrade FrankenPHP to 1.12.5 or later. The current release is 1.13.0. You were exposed if all three were true: FrankenPHP 1.12.4 or earlier, worker mode, and a PHP build that loads session as a shared module (extension=session in an ini file), which includes the RPM and deb packages. The official Docker images and static binaries were not affected. If you can't upgrade, call session_id('') after session_write_close() in the worker loop, with session.use_strict_mode=1. Treat sessions on affected deployments as possibly exposed: rotate them and invalidate long-lived logins.

Why worker mode has to reset state by hand

Classic PHP builds and tears down a fresh environment for every request. Nothing from one request survives to the next, so per-user state like $_SESSION and the session ID can't leak.

Worker mode trades that away for speed. FrankenPHP boots your application once, keeps it in memory, and hands it request after request through frankenphp_handle_request(). Everything PHP would normally throw away at the end of a request now has to be reset by FrankenPHP itself, piece by piece. Miss one piece of global state and it survives into the next request, which may belong to someone else.

This is the second session leak of that kind this year. In February, FrankenPHP 1.11.2 fixed CVE-2026-24894, where $_SESSION itself wasn't cleared between worker requests, so code that read it before calling session_start() saw the previous visitor's data.

How the build decided exposure

FrankenPHP's reset for the session ID lives in frankenphp_reset_session_state(), which releases PHP's internal PS(id) so the next session_start() reads the cookie again. The advisory explains that the whole function is wrapped in #ifdef HAVE_PHP_SESSION. That macro is only defined when the session extension's headers are visible when FrankenPHP is compiled, which means session is statically compiled into PHP.

When session is a shared module, loaded at runtime from an ini file, the macro is undefined and the reset is compiled out. The session ID from the last request stays set.

PHP's own logic does the rest. In ext/session/session.c, session_start() only reads the session cookie if no ID is already set. With a stale ID in place, it skips the cookie and resumes the previous request's session. The next visitor on that worker thread gets someone else's session, or an empty or stale one, while their own cookie is ignored.

The advisory's reproduction used two workers, session.use_strict_mode=1 and 600 concurrent requests alternating between two session cookies. Before the fix, 519 of 600 responses carried the wrong session. After it, none did. Statically linked builds stayed at zero throughout.

FrankenPHP's maintainers point out that the session reset code didn't change across 1.11.x and 1.12.x, so this was never a regression between versions. If one host seemed fine on 1.12.2 and leaked on 1.12.3, that was two differently built packages, not a code change.

The fix

The fix commit adds session to the list of PHP modules FrankenPHP fully reloads between worker requests, but only when HAVE_PHP_SESSION is undefined. On those builds, the session module's own shutdown and startup run every request and clear the ID. Statically linked builds keep using the existing reset function.

There's a trade-off noted in the code comment. A full module shutdown also frees session save handlers registered once in the worker bootstrap. If your app sets a custom handler, for example Redis, before the request loop, test it on a shared build after upgrading.

The same release, 1.12.5, fixed a second issue: the official Docker images served phpinfo() as the default index.php. Images built the documented way overwrite it with their own index.php, but a derived image that didn't ship one exposed environment variables, which often hold secrets. That's CVE-2026-85733, rated medium.

Your version number doesn't tell you enough

This is a case where software composition analysis can only get you halfway. A scanner that sees FrankenPHP 1.12.4 will flag CVE-2026-82416 on every deployment, including official Docker images that were never exposed. And it can't tell you which affected hosts actually leaked, because that depends on how PHP was built and how you run it. The three facts that decide it live in different places: the FrankenPHP version, the PHP build, and the server configuration.

Check a deployment

This script combines all three. Give it the FrankenPHP version, the output of frankenphp php-cli -i, and your Caddyfile or the env file that sets FRANKENPHP_CONFIG. It reads the ini files that phpinfo lists, looking for session loaded as an extension, and finds worker directives. Run it on the host or inside the container, since it opens those ini files. It uses only the Python standard library.

#!/usr/bin/env python3
"""Check a FrankenPHP deployment for CVE-2026-82416 (session id leak between
worker requests when the session extension is a shared module).

  frankenphp php-cli -i > phpinfo.txt
  python3 frankenphp_session_check.py --version 1.12.4 --phpinfo phpinfo.txt \
      [--caddyfile /etc/frankenphp/Caddyfile] [--env-file .env]

Affected = FrankenPHP 1.12.4 or earlier AND worker mode AND `session` loaded as
a shared module. Reads the ini files phpinfo lists, so run it on the host or in
the container. Standard library only. Exits 1 if the deployment looks exposed.
"""
import argparse, os, re, sys

FIXED = (1, 12, 5)
SHARED_INI = re.compile(r'^\s*extension\s*=\s*"?(?:[\w/.-]*/)?session(?:\.so)?"?\s*(?:;.*)?$', re.M)


def parse_phpinfo(text):
    info, lines = {}, text.splitlines()
    for key in ("Configure Command", "Loaded Configuration File", "Additional .ini files parsed"):
        value = ""
        for i, line in enumerate(lines):
            if line.startswith(key + " => "):
                parts = [line.split(" => ", 1)[1]]
                # PHP joins scanned ini files with ",\n", so the value continues
                # on following lines while each one ends with a comma.
                while parts[-1].rstrip().endswith(",") and i + 1 < len(lines):
                    i += 1
                    parts.append(lines[i])
                value = " ".join(p.strip() for p in parts)
                break
        info[key] = value
    info["session_loaded"] = "Session Support => enabled" in text
    return info


def ini_files(info):
    files = []
    if info["Loaded Configuration File"] not in ("", "(none)"):
        files.append(info["Loaded Configuration File"])
    files += [f.strip() for f in info["Additional .ini files parsed"].split(",") if f.strip() and f.strip() != "(none)"]
    return files


def worker_mode(caddyfile, env_file):
    hits = []
    if caddyfile:
        for n, line in enumerate(open(caddyfile), 1):
            if re.match(r"\s*worker\b", line) and not line.strip().startswith("#"):
                hits.append(f"{caddyfile}:{n}: {line.strip()}")
    sources = [("environment", os.environ.get("FRANKENPHP_CONFIG", ""))]
    if env_file:
        for line in open(env_file):
            if line.startswith("FRANKENPHP_CONFIG="):
                sources.append((env_file, line.split("=", 1)[1]))
    for where, value in sources:
        if re.search(r"\bworker\b", value):
            hits.append(f"{where}: FRANKENPHP_CONFIG={value.strip()}")
    return hits


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--version", required=True, help="FrankenPHP version, e.g. 1.12.4")
    ap.add_argument("--phpinfo", required=True, help="output of `frankenphp php-cli -i`")
    ap.add_argument("--caddyfile")
    ap.add_argument("--env-file")
    a = ap.parse_args()

    version = tuple(int(x) for x in re.match(r"v?(\d+)\.(\d+)\.(\d+)", a.version).groups())
    info = parse_phpinfo(open(a.phpinfo).read())
    shared = []
    if "session=shared" in info["Configure Command"]:
        shared.append("configure command has --enable-session=shared")
    for f in ini_files(info):
        try:
            if SHARED_INI.search(open(f).read()):
                shared.append(f"{f} loads session as an extension")
        except OSError as e:
            print(f"[?]     can't read {f}: {e.strerror}")
    workers = worker_mode(a.caddyfile, a.env_file)

    old = version < FIXED
    print(f"[{'VULN' if old else 'ok'}]{' ' if old else '   '}  FrankenPHP {a.version}: {'before 1.12.5' if old else 'includes the fix'}")
    if not info["session_loaded"]:
        print("[ok]    session extension not loaded")
    elif shared:
        for s in shared:
            print(f"[SHARED] {s}")
    else:
        print("[ok]    session looks statically compiled (no shared-module evidence)")
    for w in workers:
        print(f"[WORKER] {w}")
    if not workers:
        print("[ok]    no worker mode found in the files given")

    exposed = old and info["session_loaded"] and bool(shared) and bool(workers)
    print("\nEXPOSED: upgrade to 1.12.5+, or call session_id('') after session_write_close() in the worker loop"
          if exposed else "\nNot exposed on the evidence given")
    sys.exit(1 if exposed else 0)


if __name__ == "__main__":
    main()

We ran it on 16 October against three fixtures: a package-style install with session in conf.d, a static build, and the package install again after upgrading.

Package install, 1.12.4, worker mode:

$ python3 frankenphp_session_check.py --version 1.12.4 --phpinfo phpinfo.txt --caddyfile Caddyfile
[VULN]   FrankenPHP 1.12.4: before 1.12.5
[SHARED] /etc/php-zts/conf.d/20-session.ini loads session as an extension
[WORKER] Caddyfile:9: worker index.php 8

EXPOSED: upgrade to 1.12.5+, or call session_id('') after session_write_close() in the worker loop

Static build, 1.12.4, worker mode set through FRANKENPHP_CONFIG:

$ python3 frankenphp_session_check.py --version 1.12.4 --phpinfo phpinfo.txt --env-file .env
[VULN]   FrankenPHP 1.12.4: before 1.12.5
[ok]    session looks statically compiled (no shared-module evidence)
[WORKER] .env: FRANKENPHP_CONFIG=worker /app/public/index.php

Not exposed on the evidence given

Package install after upgrading to 1.13.0:

$ python3 frankenphp_session_check.py --version 1.13.0 --phpinfo phpinfo.txt --caddyfile Caddyfile
[ok]     FrankenPHP 1.13.0: includes the fix
[SHARED] /etc/php-zts/conf.d/20-session.ini loads session as an extension
[WORKER] Caddyfile:9: worker index.php 8

Not exposed on the evidence given

The second case is the one a version-only scanner gets wrong. It's on a vulnerable version in worker mode, but the reset was compiled in, so sessions never leaked.

What to do

  1. Upgrade to FrankenPHP 1.12.5 or later, through the same channel you installed from: the RPM or deb repository, the Docker image, or the static binary.
  2. If you can't upgrade yet, add session_id(''); after session_write_close() inside the worker's request handler, and set session.use_strict_mode=1, as the advisory recommends.
  3. Rotate sessions on any deployment the script marks as exposed. Delete server-side session storage, or change the session cookie name, so every user logs in again. Revoke remember-me tokens if your app issues them.
  4. Review logs for the affected period if you can. Users reporting random logouts, or seeing someone else's account, are the visible symptom the advisory describes.
  5. Check derived Docker images for the phpinfo() default page too, and rotate any secrets passed through environment variables if one was served.

The fair view

This needed a specific combination: worker mode, a shared session module and concurrent traffic. Many FrankenPHP users run the official Docker images, which were never affected. FrankenPHP's maintainers released the fix two days after publishing the advisory, explained it clearly, and the 1.12.5 release notes told shared-module users plainly to upgrade.

But the gap between fix and record matters. The repository advisory was published on 18 July. Most scanners only learned about it when the CVE was published on 13 October and GitHub reviewed it two days later, almost three months after the fix. Long-running PHP runtimes are fast because they skip PHP's per-request teardown. Every piece of state that teardown used to handle is now FrankenPHP's job, and how PHP was built can quietly change which pieces get reset. If you ship images built on FrankenPHP, this is also what VEX statements are for: recording that a flagged version isn't exploitable in your build.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.

Self-healing security runs on Safeguard.

Your first fix PR is minutes away.

No sales call required, even your agent can complete the purchase over MCP.