PHAR Deserialization in the WordPress Ecosystem: From an Innocent Upload to Remote Code Execution
PHP object injection is often described as a vulnerability that requires a direct call to unserialize(). In practice, modern PHP applications expose a second, quieter deserialization surface: the PHAR stream wrapper. When an application performs almost any filesystem operation on a phar:// path, PHP parses the archive metadata and deserializes it. No explicit unserialize() call is needed. This article explains the mechanics, examines the WordPress vulnerability tracked as CVE-2025-2105, and shows how defenders can locate and eliminate the sink.
1. Object Injection in One Paragraph
PHP serialization converts an object into a byte stream that records the class name and its properties. Deserialization reconstructs that object. If an attacker controls the byte stream, they control the class name and every property value. The danger is not the reconstruction itself but the magic methods that PHP invokes automatically: __wakeup(), __destruct(), __toString(), and others. A chain of such methods across legitimate classes — a POP gadget chain — can turn a crafted object graph into file writes, SQL queries, or command execution.
CVE-2025-2105 is a textbook example. It affected the Jupiter X Core plugin for WordPress through version 4.8.11 and was fixed in 4.8.12. The advisory classifies it as unauthenticated PHP object injection (CWE-502) with a CVSS score of 8.1. The important lesson is not the plugin name; it is that an unauthenticated request could reach a deserialization primitive in a component that many site owners never audit.
2. Why PHAR Changes the Threat Model
A PHAR archive is a PHP-specific package format. It contains a stub, a manifest, and file contents. The manifest can store serialized metadata. According to the PHP documentation, the phar:// stream wrapper parses that metadata whenever the archive is opened. The deserialization is a side effect of a normal filesystem call:
// Any of these can trigger PHAR metadata deserialization
file_exists('phar:///var/www/uploads/avatar.jpg');
filesize('phar:///var/www/uploads/avatar.jpg');
getimagesize('phar:///var/www/uploads/avatar.jpg');
file_get_contents('phar:///var/www/uploads/avatar.jpg');
The practical consequence: an application that accepts an upload and later calls a filesystem function on a user-influenced path may be exploitable even if it never calls unserialize(). The uploaded file does not need a .phar extension. It only needs to be a valid PHAR archive that the application can be induced to open through the wrapper.
Polyglot uploads
PHAR archives can be made to look like images. A file may begin with a valid JPEG or GIF header and still contain a PHAR manifest later in the stream. Validation that only checks the first bytes, the MIME type reported by the browser, or the file extension is therefore insufficient. The server must never construct a phar:// path from user-controlled input.
3. Building a Minimal PHAR for a Lab
The following code is for an isolated lab environment only. It demonstrates how metadata is embedded. Creating a PHAR requires phar.readonly=0; production servers should keep the default phar.readonly=1.
<?php
// lab-build-phar.php — run with: php -d phar.readonly=0 lab-build-phar.php
class DemoGadget {
public $cmd = 'id';
public function __destruct() {
// A deliberately trivial sink for demonstration only.
// Real gadget chains abuse existing library methods.
if (is_string($this->cmd)) {
system($this->cmd);
}
}
}
@unlink('/tmp/lab.phar');
$p = new Phar('/tmp/lab.phar');
$p->startBuffering();
$p->addFromString('x.txt', 'lab');
$p->setStub("<?php __HALT_COMPILER(); ?>");
$p->setMetadata(new DemoGadget());
$p->stopBuffering();
echo "PHAR written\n";
On the vulnerable side, a single call is enough to instantiate the object graph:
<?php
// lab-trigger.php
file_exists('phar:///tmp/lab.phar/x.txt');
The output of id appears when the object is destroyed. In a real engagement, the gadget class would be an existing class from the application or a bundled library, and the goal would be a write primitive or command execution without relying on a custom class.
4. The CVE-2025-2105 Pattern
The Jupiter X Core issue illustrates the same architectural mistake at the application layer: untrusted input reached a PHP object injection sink. Three conditions made it serious:
- No authentication requirement. The vulnerable path was reachable before login, which removes the most common precondition for exploitation.
- A large plugin ecosystem. WordPress plugins ship many classes. Even when the vulnerable plugin itself has no useful gadget, a site with dozens of plugins may contain a chain that completes the exploit.
- Silent patch adoption. Users who disabled automatic updates or pinned an older version remained exposed after the fixed release.
The CVE is a reminder that object injection should be treated as a remote-code-execution class, not as a low-severity information-disclosure issue. The CVSS 8.1 rating reflects that distinction.
5. Locating Deserialization Sinks During a Code Review
Start with direct sinks, then expand to filesystem functions that can receive a phar:// path:
# Direct deserialization
grep -rn --include='*.php' -E '\b(unserialize|maybe_unserialize)\s*\(' .
# PHAR-capable filesystem calls
grep -rn --include='*.php' -E '\b(file_exists|filesize|getimagesize|file_get_contents|file_put_contents|is_file|is_dir|stat|md5_file|sha1_file|copy|rename|unlink)\s*\(' .
# Upload handlers and path construction
grep -rn --include='*.php' -E '(move_uploaded_file|wp_handle_upload|\$_FILES)' .
For each hit, trace the argument backward. If any part of the path is user-controlled and the code can be made to prepend phar://, the sink is dangerous. Pay particular attention to image-processing code, avatar handlers, import/export features, and any code that validates a file and then reopens it by path.
Runtime verification
In a lab, enable phar.readonly=1, disable the wrapper only if your test plan requires it, and use a PHP profiler or Xdebug breakpoint on Phar::getMetadata(). If the metadata is read, the object graph has already been instantiated. That is the moment to confirm which classes are available and whether a chain exists.
6. Defensive Controls That Actually Work
- Never build a
phar://path from input. Resolve uploads to a fixed directory and use a server-generated filename. Reject any path containing a stream wrapper scheme. - Validate the file content, not the label. Use
finfoorgetimagesize()on the real temporary file, re-encode images, and strip metadata. A valid image header does not prove the absence of a PHAR manifest. - Keep
phar.readonly=1. This prevents creation, not parsing, so it is a hardening measure rather than a complete fix. - Disable unused stream wrappers. If the application does not need PHAR access, remove the wrapper from the allowed set in the PHP configuration and enforce it at the web-server layer.
- Patch promptly and inventory plugins. CVE-2025-2105 was fixed in Jupiter X Core 4.8.12. Maintain a software bill of materials for WordPress components and remove abandoned plugins.
- Apply least privilege to the web user. A PHP process that cannot write outside its upload directory and cannot spawn shells limits the impact of any gadget chain.
7. Conclusion
PHAR deserialization is a powerful reminder that security boundaries are defined by data flow, not by function names. An application can be vulnerable without ever calling unserialize(); a file that looks like a JPEG can be a serialized object graph. CVE-2025-2105 shows how quickly an unauthenticated object-injection primitive in a widely deployed WordPress plugin becomes a remote-code-execution risk. Defenders should treat every filesystem call on a user-influenced path as a potential deserialization sink, and every upload as untrusted code until it has been re-encoded and stored under a server-controlled name.