Abstract. Page Optimize, a WordPress performance plugin from Automattic with more than 200,000 active installations, derives its cache key from the raw request URI instead of from the resolved, validated file list. An unauthenticated remote visitor can exploit that mismatch to make the server write an unbounded number of duplicate cache files, exhausting disk space and inodes (CWE-770). This write-up covers the vulnerable code, the conceptual path to the sink, the availability impact, remediation, and detection guidance. Under coordinated disclosure, the vendor has been notified and the working proof of concept stays private until a patch ships.
Keywords: Page Optimize, WordPress, CWE-770, uncontrolled resource consumption, denial of service, cache key, disk exhaustion, inode exhaustion.
1. Introduction
Page Optimize concatenates a site's JavaScript and CSS and serves them from a dedicated /_static/ endpoint. With more than 200,000 active installations, its request handling sits on a meaningful slice of the WordPress attack surface.
Caching code runs early, often, and for every visitor, which is exactly why it rewards scrutiny. A layer that creates files on demand has to decide carefully what identifies a cache entry. Choose that identity loosely and a feature meant to save work turns into one that manufactures work without limit. Page Optimize picks the wrong identity, and an anonymous visitor can push its cache into uncontrolled resource consumption (CWE-770). What follows is written so developers can recognise the pattern and site owners can detect and contain it.
2. Affected component
| Plugin | Page Optimize (Automattic) — slug page-optimize |
| Tested version | 0.6.3 (current/latest at time of testing) |
| Active installations | 200,000+ |
| Vulnerability type | Uncontrolled Resource Consumption / Denial of Service |
| CWE | CWE-770 — Allocation of Resources Without Limits or Throttling (also CWE-400) |
| Required privilege | None — unauthenticated, remote |
| User interaction | None |
| CVSS 3.1 | 5.3 (Medium) — AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L (rises toward 7.5 / A:H under sustained flooding that fully fills the disk) |
| Status | Fresh (0-day), confirmed in code and reproduced on a localhost lab on the current version; unpatched |
3. Technical analysis
3.1 The endpoint answers everyone
The plugin registers a dispatcher at global scope. On every front-end request — logged-out visitors included — it reads the request URI and hands control to the static service whenever the path starts with /_static/. No nonce, capability, or authentication check guards it:
// page-optimize.php (lines 28-31)
if ( isset( $_SERVER['REQUEST_URI'] ) && '/_static/' === substr( $_SERVER['REQUEST_URI'], 0, 9 ) ) {
require_once __DIR__ . '/service.php';
exit;
}Serving concatenated assets to anonymous visitors is intentional. The trouble starts on a cache miss.
3.2 The cache key is the raw request URI
On a miss, the service builds the concatenated output and writes two files — a payload file and a metadata file — both named from an md5 of the raw request URI, query string and all:
// service.php (lines 27-30, 73-76)
$request_uri_hash = md5( $_SERVER['REQUEST_URI'] ); // <-- key is the raw URI, attacker-controlled
$cache_file = PAGE_OPTIMIZE_CACHE_DIR . "/page-optimize-cache-$request_uri_hash";
$cache_file_meta = PAGE_OPTIMIZE_CACHE_DIR . "/page-optimize-cache-meta-$request_uri_hash";
...
file_put_contents( $cache_file, $content );
file_put_contents( $cache_file_meta, json_encode( array( 'headers' => $headers ) ) );3.3 The key carries bytes the output ignores
Here is the crux. Before the service decides which files to concatenate, it strips everything from the trailing ?version marker onward. That version suffix never touches the resolved file list or the output content, yet it still feeds the md5 cache key from §3.2:
// service.php (lines 117-121) — version suffix stripped BEFORE file resolution
$version_string_pos = strpos( $args, '?' );
if ( false !== $version_string_pos ) {
$args = substr( $args, 0, $version_string_pos );
}Why that breaks. The key and the value have drifted apart. The value is a pure function of the resolved, validated file list; the key is a function of the raw URI, padded with bytes the attacker controls and the output discards. Two requests that produce byte-for-byte identical output still land under different keys. A correct cache keys an entry on exactly the inputs that determine its value, and nothing else.
Two details make the defect stick rather than pass:
- Caching is on by default:
PAGE_OPTIMIZE_CACHE_DIRis always defined (wp-content/cache/page_optimize). - Cleanup (
page_optimize_cache_cleanup) runs only on a daily WP-cron and deletes only files older than 24 hours; WP-cron itself fires only on organic traffic. Freshly created files therefore survive for at least a day.
4. Reaching the flaw (conceptual)
Reaching the sink takes no privilege, because the /_static/ dispatcher answers anonymous GET requests by design. An attacker needs one public fact: a single valid asset path the site genuinely serves. The framework assets shipped with WordPress are effectively universal.
The rest follows from §3.3. Because the key includes bytes the output ignores, the same underlying concatenation can be addressed through an effectively unlimited set of keys; every variation of the ignored suffix is a fresh md5, and so a fresh pair of on-disk files holding identical content. Nothing caps per-IP requests, entry counts, total cache size, or endpoint rate. Every request the server reads as a miss performs new writes.
The result is a volume denial of service. The server can be pushed to write far more data, and far more individual files, than the one real resource would ever justify. Each entry burns two directory entries (two inodes) and lingers for at least a day, so both the free-space budget and the inode table are in play. We deliberately hold back the request sequence, the suffix values, and any replay commands; the mechanism above is enough to understand the issue and defend against it, and the operational detail waits for a fix.
5. Impact assessment
An unauthenticated remote actor can drive persistent, unbounded growth of the plugin's cache directory. Two exhaustion paths exist:
- Free-space exhaustion — written bytes grow roughly in step with the resource served; amplification is close to 1:1, so a real outage needs sustained request volume.
- Inode exhaustion — each entry is a pair of small files, so inode consumption climbs fast and can empty a filesystem's inode table long before raw bytes fill the disk, especially on small VPS hosts.
Exhaust either budget and every WordPress operation that needs to write — database temporary files, media uploads, session data, further caching — starts to fail, and the site goes down. That is why the base score sits at 5.3 (Medium) for a partial availability impact (A:L) and climbs toward 7.5 / A:H under a sustained flood that fills the disk or inode table outright. There is no confidentiality or integrity impact; availability is the whole story.
For scope, record what the bug is not. File resolution is hardened — .. and null-byte traversal are blocked, and non-existent paths return 404 with no write — so this is not a path-traversal or arbitrary-write-location bug. The optional gzuncompress() branch is not a practical decompression-bomb vector. The admin settings use the core Settings API behind a nonce and manage_options. What remains is a genuine resource-exhaustion flaw, rated Medium rather than dressed up as a high-amplification reflector.
6. Remediation
The decisive fix realigns the cache key with the value it stores:
// Key the cache on the RESOLVED, VALIDATED file list — not the raw request URI.
// After stripping the version suffix and resolving/validating paths:
$resolved = page_optimize_resolve_and_validate( $args ); // ordered, validated file list
$request_uri_hash = md5( implode( "\0", $resolved ) ); // key == exactly the inputs that
// determine the outputKeying on the resolved, validated file list folds every junk-suffix variant of a request onto a single entry and kills the multiplication at its source. For defence in depth, the plugin should also:
- Cap the number of cache entries and/or the total cache-directory size, evicting on a bound rather than only on a 24-hour age.
- Rate-limit the
/_static/endpoint per client. - Trigger cleanup on write pressure, not only on a daily cron that depends on organic traffic.
Until an official fixed release is available, site owners should apply the mitigations in §7.
7. Detection and hardening for site owners
Everything here derives from the mechanics above; nothing invents a product feature.
- Watch the cache directory. Monitor
wp-content/cache/page_optimizefor abnormal growth in file count, not just size. A fast-climbing number ofpage-optimize-cache-*/page-optimize-cache-meta-*pairs with identical payload content is the clearest indicator. - Monitor inodes, not just disk. On small hosts the inode table can exhaust first. Alert on inode utilisation alongside free space (
df -ion Linux). - Review access logs for
/_static/patterns. Watch for many requests to the same underlying asset distinguished only by a changing trailing suffix, especially from a single source or a narrow set of them. - Apply least exposure. If the concatenation feature is not needed, disable or remove the plugin. If it is needed, consider restricting or disabling on-disk caching where your stack allows.
- Virtual patching / WAF. A WAF or edge rule can rate-limit
/_static/and reject requests whose query carries a high-cardinality, output-irrelevant suffix. Treat this as a stopgap, not a substitute for the cache-key fix. - Scheduled cleanup. Because the built-in cleanup depends on WP-cron firing, back it with a real system cron so stale cache files actually get removed.
8. The broader lesson
The principle is simple and travels well: a cache key must be a function of exactly the inputs that determine the cached value, no more and no less. Include attacker-controllable bytes the value ignores and the cache can be made to store unbounded duplicates of one payload; omit bytes the value depends on and you get cache confusion or poisoning. Either drift is a defect. The same discipline governs memoisation, deduplication tables, rate-limiter buckets, and any on-demand resource keyed by request data. Pair correct keying with explicit resource ceilings — entry counts, total size, per-client rate limits — because a cache without a bound is an allocator an anonymous visitor can drive.
9. Responsible disclosure
This research was conducted under coordinated (responsible) disclosure. The vendor has been notified privately, and the full operational detail — the exact request sequence, suffix values, and reproduction commands — is deliberately withheld from this public write-up until a fix is available. Publishing now is a defensive move: it lets site owners recognise, detect, and contain the bug class while the fix is prepared.
All testing ran exclusively on an isolated, localhost-bound Docker WordPress lab running the public GPL plugin. No third-party or production systems were accessed, and the lab and its cache directory were removed after testing. Noroxi LLC conducts authorized security research only and does not test systems it is not permitted to test.
References
- CWE-770: Allocation of Resources Without Limits or Throttling — https://cwe.mitre.org/data/definitions/770.html
- CWE-400: Uncontrolled Resource Consumption — https://cwe.mitre.org/data/definitions/400.html
- FIRST CVSS v3.1 Specification & Calculator — https://www.first.org/cvss/calculator/3.1
- OWASP: Denial of Service — https://owasp.org/www-community/attacks/Denial_of_Service
- WordPress Plugin Developer Handbook — Nonces — https://developer.wordpress.org/plugins/security/nonces/
- WordPress Plugin Developer Handbook — Checking User Capabilities — https://developer.wordpress.org/plugins/security/checking-user-capabilities/
Published for defensive and educational purposes. Noroxi LLC conducts authorized security research only; operational exploit detail is withheld until a fix is available.
Exploited this week · every Monday
New KEV entries, fresh PoCs, WordPress triage and what to do this week. No account needed.