Mitigating PHP’s long standing issue with OPCache leaking sensitive data

Want to help support this blog? Try out Oh Dear, the best all-in-one monitoring tool for your entire website, co-founded by me (the guy that wrote this blogpost). Start with a 10-day trial, no strings attached.

We offer uptime monitoring, SSL checks, broken links checking, performance & cronjob monitoring, branded status pages & so much more. Try us out today!

Profile image of Mattias Geniar

Mattias Geniar, February 28, 2017

Follow me on Twitter as @mattiasgeniar

A very old security vulnerability has been fixed in PHP regarding the way it handles its OPCaches in environments where a single master process shares multiple PHP-FPM pools. This is the most common way to run PHP nowadays and might affect you, too.

The vulnerability

PHP has a method to speed up the dynamic nature of its interpreter, called bytecode caching. PHP gets interpreted on every pageload, meaning the PHP gets translated to bytecode which the server understands and can execute. Since most PHP pages don’t change every second, PHP caches that bytecode in memory and can serve that as the response instead of having to compile (or “interpret") the PHP scripts every time.

In a default PHP-FPM setup, the process tree looks like this.

php-fpm: master process (/etc/php-fpm.conf)
\_ php-fpm: pool site1 (uid: 701)
\_ php-fpm: pool site2 (uid: 702)
\_ php-fpm: pool site3 (uid: 703)

There is a single PHP-FPM master process that gets started as the root user. It then spawns additional FPM pools, that can each run as their own user, to serve websites. The PHP OPCache (that bytecode cache) is held in the master process.

So here’s the problem: PHP does not validate the userid when it fetches a script from memory, stored in its bytecode. The concept of a “shared memory” in PHP means everything is stored in the same memory segment, and including a file just checks if a version already exists in bytecode.

If a version of the script exists in bytecode, it’s served without additional check.

If a version of the script does not exist in bytecode, the FPM pool (running as an unprivileged uid) will try to read it from disk, and Linux will prevent reads from files that the process has no access to. You know, like it’s supposed to happen.

This is the one of the primary reasons I’ve advocated running multiple masters instead of multiple pools for years.

The solution: upgrade PHP & set additional configurations

After a way too long period, this bug is now resolved and you can fix it with additional master configurations.

$ cat /etc/php-fpm.conf
opcache.validate_permission (default "0")
       Leads OPcache to check file readability on each access to cached file.
       This directive should be enabled in shared hosting environment, when few
       users (PHP-FPM pools) reuse the common OPcache shared memory.

opcache.validate_root (default "0")
       This directive prevents file name collisions in different "chroot"
       environments. It should be enabled for sites that may serve requests in
       different "chroot" environments.

The introduction of the opcache.validate_permission and opcache.validate_root means you can now force PHP’s OPCache to also check the permissions of the file and force a validation of the root path of the file, to avoid chrooted environments from reading eachothers’ files (more on that in the original bugreport).

The default values, however, are insecure.

I understand why they are like this, to keep compatibility with previous versions and avoid breaking changes in minor versions, but you have to explicitly enable them to prevent this behaviour.

Minimum versions: PHP 5.6.29, 7.0.14, 7.1.0

This fix didn’t get much attention and I only noticed it after someone posted to the oss-security mailing list. To mitigate this properly, you’ll need at least;

Anything lower than 5.6 is already end of life and won’t get this fix, even though OPCache got introduced in PHP 5.5. You’ll need a recent 5.6 or newer to mitigate this.

If you still run PHP 5.5 (which many do) and want to be safe, your best bet is to either run multiple PHP masters (a single master per pool) or disable OPCache entirely.

Indirect local privilege escalation to root

This has been a long standing issue with PHP that’s actually more serious than it looks at first glance.

If you manage to compromise a single website (which for most CMS’s in PHP that don’t get updated, isn’t very hard), this shared memory allows you access to all other websites, if their pages have been cached in the OPCache.

You can effectively use this shared memory buffer as a passage way to read other website’s PHP files, read their configs, get access to their database and steal all their data. To do so, you usually need root access to a server, but PHP’s OPCache gives you a convenient shortcut to accomplish similar things.

All you need is the path to their files, which can either be retrieved via opcache_get_status() (if enabled, which again – it is by default), or you can guess the paths, which on shared hosting environments usually isn’t hard either.

This actually makes this shared memory architecture almost as bad as a local privilege escalation vulnerability, depending on what you want to accomplish. If your goal is to steal data from a server that normally requires root privileges, you got it.

If your goal is to actually get root and run root-only scripts (aka: bind on lower ports etc.), that won’t be possible.

The good part is, there’s now a fix with the new PHP configurations. The bad news is, you need to update to the latest releases to get it and explicitly enable it.

Additional sources

For your reading pleasure:

Want to subscribe to the cron.weekly newsletter?

I write a weekly-ish newsletter on Linux, open source & webdevelopment called cron.weekly.

It features the latest news, guides & tutorials and new open source projects. You can sign up via email below.

No spam. Just some good, practical Linux & open source content.