Skip to content

ext/session: Do not GC a session file another request still holds locked - #25002

Open
AsahiSoftWareEngineer wants to merge 4 commits into
php:masterfrom
AsahiSoftWareEngineer:fix/session-gc-flock-race
Open

AsahiSoftWareEngineer wants to merge 4 commits into
php:masterfrom
AsahiSoftWareEngineer:fix/session-gc-flock-race

Conversation

@AsahiSoftWareEngineer

Copy link
Copy Markdown

Description

ps_files_cleanup_dir() (the files session save handler's garbage collector) deletes any sess_* file whose mtime is older than gc_maxlifetime without checking whether another request currently has that file open and locked via flock(LOCK_EX) -- a lock ps_files_open() holds for the entire lifetime of a session.

On POSIX, unlink() on an open file succeeds silently: the directory entry disappears, but the holder's already-open file descriptor keeps working against the now-orphaned inode. session_write_close() therefore reports success while the session data is permanently lost, and neither side ever sees an error.

See #25001 for the full writeup, reproduction steps, and reachability analysis (every ordinary session_start() call runs this sweep internally with gc_probability/gc_divisor odds, so it's reachable without any special access).

Changes

  • ext/session/mod_files.c: before deleting an expired candidate, probe it with a non-blocking flock(LOCK_EX | LOCK_NB); skip it if another process still holds it open instead of unlinking it. Only increment the deletion counter once unlink() actually succeeds (its return value was previously discarded unconditionally).
  • Added ext/session/tests/session_gc_active_session_race.phpt: creates a session file, backdates its mtime past gc_maxlifetime, holds it open+locked via a second fopen()/flock() handle, runs session_gc() from an unrelated session, and asserts the file -- and its data -- survive.
  • NEWS entry.

Checklist

  • Regression test included
  • NEWS entry added
  • No existing test coverage needed updating (new behavior is additive: previously-untested concurrent-access case)

Closes #25001

Asahi and others added 2 commits October 28, 2026 19:53
ps_files_open() holds flock(LOCK_EX) on a session's data file for as
long as the owning request is active (e.g. a slow payment callback or
a large file upload with session.upload_progress). ps_files_cleanup_dir(),
the files save handler's garbage collector, never checked for that lock:
it unlinked any sess_* file whose mtime was older than gc_maxlifetime
regardless of whether another process currently had it open.

On POSIX, unlinking a file that is still open succeeds silently: the
directory entry disappears, but the holder's already-open fd keeps
writing into the now-orphaned inode. session_write_close() therefore
returns success while the data vanishes, and the next read under the
same session ID silently starts from an empty session.

This is reachable without any special timing or privilege: session_gc()
runs this sweep immediately and unconditionally (bypassing
gc_probability), requires only the caller's own, unrelated active
session, and session.gc_maxlifetime is PHP_INI_ALL so a caller can lower
it to 0 for their own request regardless of what the administrator
configured, making every session file in save_path other than the one
just written a GC target.

Probe each candidate file with a non-blocking flock() before deleting
it; skip files that are still locked by another process instead of
destroying them, and only count a deletion once unlink() actually
succeeds (its return value was also previously ignored).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The test calls session_start() more than once in a single script and
produces output in between. With session.use_cookies and
session.cache_limiter at their defaults, session_start() refuses to run
once any output has already been sent, and session.gc_maxlifetime cannot
be changed once a session is already active. Disable cookies and the
cache limiter, set gc_maxlifetime before the first session_start(), and
defer all var_dump() output to the end of the script so no session call
runs after output has started.

This only affects the test's own mechanics; the patched
ps_files_cleanup_dir() behavior under test is unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@AsahiSoftWareEngineer
AsahiSoftWareEngineer marked this pull request as ready for review October 28, 2026 15:19
…ace.phpt

session.use_strict_mode defaults to on and rejects a caller-supplied
session ID that has no existing session file, silently replacing it
with a freshly generated one instead. This test relies on
session_id($victimId) / session_id('attackerxxx...') actually pinning
those IDs for brand-new sessions, so the victim and attacker files
were never the ones the test thought they were: $victimFile pointed
at a file that didn't exist, touch() created it empty, and the final
assertion compared against an unrelated empty session instead of the
one holding 'secret' => 'still-in-use'. This made the test fail on
every platform in CI regardless of whether the mod_files.c fix works.

Disabling strict mode for the duration of this test lets the chosen
IDs stick, so the test actually exercises the GC/flock race it is
meant to cover. Verified locally (CLI build) that the test now passes
reliably, and that the rest of ext/session/tests/ is unaffected.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ext/session files handler: GC deletes a session file another request still holds locked

1 participant