[php-src] Issue #24544: ext/session: ftruncate failure in files save handler silently leaves stale session data
| From: | AsahiSoftWareEngineer | Date: | Thu, 22 Oct 2026 15:06:11 +0000 |
| Subject: | [php-src] Issue #24544: ext/session: ftruncate failure in files save handler silently leaves stale session data | ||
| Groups: | php.bugs | ||
| Request: | Send a blank email to php-bugs+get-253096@lists.php.net to get a copy of this message | ||
Issue: https://github.com/php/php-src/issues/24544
Author: AsahiSoftWareEngineer
## Summary
ps_files_write() in ext/session/mod_files.c silently ignores a failed
ftruncate() call (line 265), leaving stale bytes from a previous, longer session at the
end of the file. On the next read, PS_READ_FUNC allocates a buffer sized by
fstat() (the actual on-disk size, not the intended size) and reads back the stale tail,
injecting old session variables into the new request.
## Code
```c
// ext/session/mod_files.c:264-268
if (ZSTR_LEN(val) < data->st_size) {
php_ignore_value(ftruncate(data->fd, ZSTR_LEN(val))); // failure silently discarded
}
data->st_size = ZSTR_LEN(val); // updated to intended size regardless of ftruncate result
return SUCCESS;
```
On the next request:
```c
// PS_READ_FUNC: reads actual file size, not the intended size
if (zend_fstat(data->fd, &sbuf)) { return FAILURE; }
data->st_size = sbuf.st_size; // e.g. 100 bytes (old size), not 50 bytes (intended)
*val = zend_string_alloc(sbuf.st_size, false);
n = pread(data->fd, ZSTR_VAL(*val), ZSTR_LEN(*val), 0); // reads all 100 bytes
```
The default (php) session serializer consumes all bytes up to endptr
(val + vallen), so any tail bytes that form a valid key|value entry are
deserialized and added to $_SESSION.
## Conditions
ftruncate() can fail in common deployments:
- NFS mounts (ESTALE, EPERM, EIO) — frequently used for shared session storage behind load
balancers
- Disk quota exhaustion (EDQUOT)
- Read-only filesystem after a mount error (EROFS)
## Concrete impact
A session write that shrinks the data (e.g. session_unset() removing variables followed
by session_write_close()) may silently fail to remove old variables. An example that
illustrates the data flow:
```
Request A writes (100 bytes): role|s:5:"admin";token|s:32:"...";
Request B writes (17 bytes): token|s:32:"...";
→ pwrite(17 bytes at offset 0) succeeds
→ ftruncate(17) fails silently
→ file on disk: [17 bytes new] + [83 bytes old tail]
Request C reads (100 bytes):
→ deserializes both token|... AND role|s:5:"admin";
→ $_SESSION['role'] == "admin" unexpectedly restored
```
Applications that clear session variables to invalidate auth state without calling
session_destroy() (a common pattern) are affected.
## Relation to GH-23653
GH-23653 (c2b02f24dff) fixed the inverse problem: truncating to zero *before* a write
that then fails, causing data loss. The fix moved the truncation to *after* a successful write and
added the data->st_size update. The silent discard of the ftruncate
return value is a separate, remaining issue.
## Expected behavior
When ftruncate() fails, ps_files_write() should either:
1. Return FAILURE (safest — lets the caller surface the problem), or
2. Emit E_WARNING and return FAILURE.
## Suggested fix
```c
if (ZSTR_LEN(val) < data->st_size) {
if (ftruncate(data->fd, ZSTR_LEN(val)) == -1) {
php_error_docref(NULL, E_WARNING,
"ftruncate failed: %s (%d)", strerror(errno), errno);
return FAILURE;
}
}
data->st_size = ZSTR_LEN(val);
return SUCCESS;
```
A longer-term improvement would be an atomic write via a temporary file and rename(),
eliminating both the ftruncate race and partial-write exposure entirely.
## PHP versions
Reproducible on master (after c2b02f24dff) and all maintained branches (8.3, 8.4, 8.5,
8.6).