Repository navigation
ext/session: Do not GC a session file another request still holds locked - #25002
AsahiSoftWareEngineer wants to merge 5 commits into
Conversation
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>
…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>
|
Quoted below: written with Claude, an LLM, as CONTRIBUTING.md asks us to mark. The builds and test runs behind it are on my machine; the commands to repeat them are under the fold.
|
The previous ordering released the probe lock and closed the fd before unlinking: flock(LOCK_UN); close(fd); VCWD_UNLINK(buf). Between the unlock and the unlink, a request that calls open() on the same path can acquire flock(LOCK_EX) immediately (nothing holds it at that point) and proceed as a normal, fully successful session open, only to have its file unlinked out from under it moments later by this function -- the same data loss this function exists to prevent, just in a much smaller window, as pointed out in review on phpGH-25002. Unlinking while the probe's own lock is still held closes most of that window: a concurrent open() on the same path now blocks in its own flock(LOCK_EX) call for as long as we hold the lock, so it can only acquire it once the unlink has already happened, never while the file is still live. It does not fully close it -- if that request's open() already completed before our unlink(), it is still holding a valid fd on the now-unlinked inode once its flock() unblocks, and whatever it writes afterward is still lost. Closing that requires the open side (ps_files_open()) to detect it was unlinked out from under it after acquiring the lock and retry, which is a larger, separate change; documented in the comment as a known residual limitation instead of silently leaving it unaddressed. Also note in NEWS that a request resuming its own already-expired session during the GC pass that would have collected it now keeps that session instead of losing it, since its own flock() makes the probe fail -- a visible behavior change from the first commit on this branch, not from this one, but previously undocumented. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
Thanks for the detailed verification and the three follow-up points. Adopted the lock-held-unlink reorder: To be precise about what this does and doesn't fix: if that concurrent request's On the two other points:
Pushed as a new commit; |
Description
ps_files_cleanup_dir()(thefilessession save handler's garbage collector) deletes anysess_*file whose mtime is older thangc_maxlifetimewithout checking whether another request currently has that file open and locked viaflock(LOCK_EX)-- a lockps_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 withgc_probability/gc_divisorodds, so it's reachable without any special access).Changes
ext/session/mod_files.c: before deleting an expired candidate, probe it with a non-blockingflock(LOCK_EX | LOCK_NB); skip it if another process still holds it open instead of unlinking it. Only increment the deletion counter onceunlink()actually succeeds (its return value was previously discarded unconditionally).ext/session/tests/session_gc_active_session_race.phpt: creates a session file, backdates its mtime pastgc_maxlifetime, holds it open+locked via a secondfopen()/flock()handle, runssession_gc()from an unrelated session, and asserts the file -- and its data -- survive.Checklist
Closes #25001