— Article — № 053

053 —Operations

Finding the rogue cron filling /tmp: a 12 GB session leak

The alert came in at 3:14 on a Tuesday. /tmp on a small VPS had crossed 12 GB of orphaned PHP session files. Here is how we found the cron behind it.

Overhead photo on bone linen: cron schedule sheet, log printout, paper tags, brass CRON plate, steel ruler, pencil, red wax seal.
Hero · staged still№ 053

The alert came in at 03:14 on a Tuesday. /dev/sda1 on a small VPS hosting four WordPress sites had crossed 92%. Not full yet, but trending. The on-call engineer (us, in this case, because the agency that runs the box has a retainer for exactly this kind of evening) SSH'd in and ran the usual triage.

The culprit was /tmp. 12.4 GB of it. Almost entirely files named sess_a3f9b..., going back eighteen months.

What follows is the walkthrough of how we tracked that pile back to a single line in a single user's crontab, and why the standard advice (raise session.gc_probability, set up systemd-tmpfiles) treats the symptom and never the cause.

What 12 GB of sess_ files actually means

PHP's default session handler writes one file per session, named sess_<ID>, into whatever session.save_path resolves to. On most stock builds that path is /tmp. Each file holds the serialised $_SESSION array for one visitor.

A clean WordPress install doesn't call session_start() at all (wp-login uses cookies, not sessions). But plenty of plugins do: WooCommerce historically, Wordfence in some configurations, contact-form plugins that need a CSRF nonce, any "members area" theme. Drupal 7 sites use sessions natively. So does most custom PHP.

Twelve gigabytes of session files is not a configuration problem. It is a creation problem. At roughly 4 KB per file that's about three million sessions, which no human visitor base on a small WordPress site is going to produce in eighteen months. Something machine-shaped is hammering a page that calls session_start().

The mtime cluster that gave it away

The first useful thing was a histogram of file modification times. Nothing fancy:

find /tmp -maxdepth 1 -name 'sess_*' -printf '%TY-%Tm-%Td %TH\n' \
  | sort | uniq -c | tail -40

The output looked like this:

   1440 2026-05-25 22
   1440 2026-05-25 23
   1440 2026-05-26 00
   1440 2026-05-26 01
   1440 2026-05-26 02

1440 files per hour. Exactly 24 per minute. That is not a Googlebot pattern and it is not a human pattern. That is a cron stepping every minute, running 24 separate curl invocations, each of which creates one fresh session.

From there it took two commands to find the source:

for u in $(cut -d: -f1 /etc/passwd); do
  crontab -u "$u" -l 2>/dev/null | sed "s/^/$u: /"
done
ls -la /etc/cron.d/ /etc/cron.hourly/ /etc/cron.daily/

The hit was in a non-root user's crontab, left there by a former contractor and never reviewed:

* * * * * /usr/bin/curl -s https://example.com/wp-cron.php?doing_wp_cron > /dev/null
* * * * * /usr/bin/curl -s https://example.com/?warm=home > /dev/null
* * * * * /usr/bin/curl -s https://example.com/shop/?warm=1 > /dev/null
# 21 more lines like this, hitting various "cache warm" URLs

The curl-without-cookies pattern

Here is the part worth internalising, because we have now seen this exact failure mode on five different client boxes in the last two years.

When you call curl without -b and -c (the cookie jar flags), every invocation is a brand new client with no cookies. If the URL it hits calls session_start() anywhere in its execution path (a plugin hook, a theme function, a header include), PHP allocates a new session ID and writes a new file to session.save_path. The cron that runs every minute hits 24 different URLs, every one of them triggers session_start(), and at 03:14:00 you get 24 fresh files in /tmp.

Then the second half of the failure. PHP's session garbage collector is supposed to remove old files based on session.gc_maxlifetime. On Debian and Ubuntu, the GC is disabled by default (session.gc_probability = 0) and replaced with a sessionclean cron at /etc/cron.d/php. That cron, however, only cleans the save_path defined in the FPM pool's php.ini. If the site sets session.save_path in a per-directory .user.ini, the files land somewhere sessionclean never reads.

In our case that is exactly what happened. The site had this in its document root:

; .user.ini
session.save_path = "/tmp"
session.gc_maxlifetime = 86400

Set by some plugin installer five years ago and never touched since. The FPM pool's save path was the stock /var/lib/php/sessions. The Debian cleanup script dutifully cleaned that. /tmp grew forever.

Fixing the cause, not the symptom

Three things, in order.

  1. Delete the contractor's crontab entries. The "cache warm" loop was not doing anything useful: the site sat behind a CDN and the origin was already warm. The wp-cron.php line is the standard WordPress workaround, but it should run every fifteen minutes from one line, not every minute from a script that throws away cookies. The Codex has the canonical pattern.
  2. Remove the stray .user.ini override. Either lift the directive into the FPM pool config so sessionclean finds the files, or accept the pool default and let everything live under /var/lib/php/sessions.
  3. Only then, clear /tmp. We use find /tmp -name 'sess_*' -mtime +1 -delete, not rm -rf /tmp/*, because other processes write there too (uploads, ImageMagick scratch, MySQL temp tables) and a blanket delete will take down whatever was mid-upload.

The post-mortem fix on the agency side was a one-page note in their handover doc. When you inherit a legacy site, the first three things to look at are the active user crontabs, every .user.ini and .htaccess in the document tree, and the FPM pool config. The interaction between those three is where the slow leaks live.

What we now do first on any new box

The reason this investigation took us forty minutes and not four hours is that we had done it before. When we built Pier we ran into this exact thing on a client box, and the way we ended up handling it was making the file tree and the database both browseable from one window with version history on every overwrite. The stray .user.ini we deleted is recoverable with one click if it turns out something on the site actually depended on the override.

The smallest thing you can do today: on every legacy site you currently maintain, run find / -name '.user.ini' 2>/dev/null and read each file it returns. Most will be empty. The ones that are not are where your next overnight page is going to come from.

— Questions —

Why doesn't PHP's session garbage collector clean these files up automatically?

On Debian-family distros PHP's GC is disabled (gc_probability=0) and replaced by a sessionclean cron that reads only the FPM pool's save_path, missing per-site .user.ini overrides.

Can I just empty /tmp to recover the space?

Not safely. Uploads, ImageMagick scratch files and MySQL temp tables also live there. Use find /tmp -name 'sess_*' -mtime +1 -delete to remove only stale session files.

How do I stop this from happening again?

Audit every user crontab and every .user.ini and .htaccess in the document root. Any curl loop without -b/-c that hits a page calling session_start() will leak sessions forever.