Code Auditing Guidelines: Detecting and Fixing Insecure Deserialization in legacy session handling in Your PHP Monolith
Identifying Insecure Session Handling in Legacy PHP Monoliths
Many legacy PHP monoliths rely on the default session handling mechanisms, often storing serialized PHP objects directly in files or databases. This practice, while convenient, presents a significant security vulnerability: insecure deserialization. An attacker can craft malicious serialized objects that, when deserialized by the application, can lead to arbitrary code execution, denial-of-service, or other catastrophic system compromises. This post outlines a systematic approach to auditing and mitigating these risks.
Locating Session Data Storage
The first step is to pinpoint where your application stores session data. This is typically configured via the session.save_path directive in php.ini or dynamically set using session_save_path(). Common locations include:
- The default system temporary directory (e.g.,
/tmpon Linux). - A custom directory specified in your application’s configuration.
- A database table, often managed by a custom session handler.
If your application uses a custom session handler, you’ll need to trace the implementation. Look for functions like session_set_save_handler(). The handler’s methods (open, read, write, close, destroy, gc) will reveal how session data is persisted.
Analyzing Session Data Format
Once the storage location is identified, examine the format of the stored session data. For file-based storage, you’ll find files named sess_SESSIONID. For database storage, inspect the relevant table schema.
The critical observation is whether the data is simply a string or if it’s a serialized PHP structure. The presence of O: (object), s: (string), i: (integer), a: (array), or b: (boolean) prefixes within the session data strongly indicates PHP serialization. For example, a session file might contain:
O:8:"stdClass":1:{s:5:"user";s:10:"admin_user";}s:10:"last_login";i:1678886400;
This snippet represents a serialized PHP object and an integer. If your application directly deserializes such data without strict validation, it’s vulnerable.
Identifying Vulnerable Deserialization Points
The primary function to watch for is unserialize(). Search your codebase for all occurrences of this function. Pay close attention to where the input to unserialize() originates. If the input comes directly from user-controllable sources (e.g., HTTP headers, POST/GET parameters, cookies) without sanitization or validation, you have a critical vulnerability.
Consider a typical scenario where session data is read and then used:
<?php
// In a custom session handler's read method or application logic
function read_session_data($session_id) {
$data = file_get_contents("/path/to/sessions/sess_" . $session_id);
if ($data === false) {
return '';
}
// VULNERABLE: Direct unserialization of potentially untrusted data
return unserialize($data);
}
// Later in the application flow
$session_data = read_session_data($_COOKIE['PHPSESSID']);
if (is_array($session_data) && isset($session_data['user_object'])) {
// Potentially dangerous operations with $session_data['user_object']
// If $session_data['user_object'] was crafted by an attacker,
// and it's an object with a __wakeup() or __destruct() method,
// code execution can occur here.
}
?>
Exploitation Vectors: The Power of Magic Methods
The danger of unserialize() lies in PHP’s “magic methods” (e.g., __construct, __destruct, __wakeup, __toString, __call, __get, __set, __isset, __unset). When an object is unserialized, certain magic methods are automatically invoked. An attacker can craft a serialized object that, upon deserialization, triggers one of these methods to perform malicious actions.
For instance, if your application has a class with a __destruct method that performs file operations or executes system commands, an attacker could provide a serialized payload that instantiates this class and triggers the destructor:
<?php
class MaliciousAction {
public $command;
public function __construct($cmd) {
$this->command = $cmd;
}
public function __destruct() {
// This method is called automatically when the object is destroyed
// or after unserialize() if __wakeup() is not defined.
if ($this->command) {
system($this->command); // Arbitrary command execution!
}
}
}
// Attacker crafts this payload
$payload = 'O:13:"MaliciousAction":1:{s:7:"command";s:15:"id >> /tmp/pwned";}';
// If application does this:
$data = unserialize($payload);
// The __destruct() method will be called, executing 'id > /tmp/pwned'
?>
Mitigation Strategies: From Prevention to Remediation
1. Avoid `unserialize()` on User-Controlled Input
This is the most effective defense. If possible, refactor your session handling to store simple, primitive data types (strings, integers, booleans, simple arrays of primitives) instead of complex objects. If you must store objects, ensure they are not directly deserialized from untrusted sources. Consider using a secure serialization format like JSON for data that doesn’t require object instantiation.
2. Use Secure Serialization Formats (JSON)
For data that doesn’t inherently need to be a PHP object, use JSON. JSON is a data interchange format and does not execute code upon parsing. You can serialize data to JSON using json_encode() and deserialize it using json_decode(). Note that json_decode() returns associative arrays by default, which is safer than objects.
<?php
// Instead of:
// $session_data = unserialize($raw_data);
// Use JSON:
$session_data_array = json_decode($raw_data, true); // true for associative array
if ($session_data_array === null && json_last_error() !== JSON_ERROR_NONE) {
// Handle JSON decoding error
error_log("JSON decode error: " . json_last_error_msg());
// Potentially clear session or use default data
$session_data_array = [];
}
// Access data safely
if (isset($session_data_array['user_id'])) {
$userId = $session_data_array['user_id'];
// ... use $userId
}
?>
3. Implement a Custom, Secure Session Handler
If you absolutely must store complex objects, create a custom session handler that serializes objects into a more controlled format (e.g., JSON) and deserializes them only after rigorous validation. This handler should also implement strict access controls and potentially encryption.
A basic example of a custom handler that uses JSON and avoids direct object deserialization:
<?php
class SecureSessionHandler implements SessionHandlerInterface {
private $savePath;
public function open($savePath, $sessionName) {
$this->savePath = $savePath;
if (!is_dir($this->savePath)) {
mkdir($this->savePath, 0700, true);
}
return true;
}
public function close() {
return true;
}
public function read($sessionId) {
$filePath = $this->savePath . '/sess_' . $sessionId;
if (!file_exists($filePath)) {
return '';
}
$data = file_get_contents($filePath);
if ($data === false) {
return '';
}
// Decode as JSON, return empty array on failure
$decoded_data = json_decode($data, true);
if ($decoded_data === null && json_last_error() !== JSON_ERROR_NONE) {
error_log("SecureSessionHandler: Failed to decode session data for ID " . $sessionId . ": " . json_last_error_msg());
return ''; // Return empty to prevent processing invalid data
}
// Return data as a string for PHP's session mechanism to handle
// (PHP will then serialize this string if it's an array/object)
// Or, if you manage the entire session array yourself, return $decoded_data
return serialize($decoded_data); // PHP expects a string
}
public function write($sessionId, $data) {
// Data here is already serialized by PHP's session manager
// We need to unserialize it to get our original PHP array/object
$unserialized_data = unserialize($data);
if ($unserialized_data === false && $data !== 'b:0;') { // Handle unserialize failure, allow boolean false
error_log("SecureSessionHandler: Failed to unserialize session data for ID " . $sessionId);
return false;
}
// Encode to JSON for storage
$jsonData = json_encode($unserialized_data);
if ($jsonData === false) {
error_log("SecureSessionHandler: Failed to encode session data to JSON for ID " . $sessionId . ": " . json_last_error_msg());
return false;
}
$filePath = $this->savePath . '/sess_' . $sessionId;
return file_put_contents($filePath, $jsonData) !== false;
}
public function destroy($sessionId) {
$filePath = $this->savePath . '/sess_' . $sessionId;
if (file_exists($filePath)) {
unlink($filePath);
}
return true;
}
public function gc($lifetime) {
$now = time();
foreach (glob($this->savePath . '/sess_*') as $file) {
if ($now - filemtime($file) > $lifetime) {
unlink($file);
}
}
return true;
}
}
// Usage:
// session_set_save_handler(new SecureSessionHandler('/path/to/secure/sessions'));
// session_start();
?>
4. Input Validation and Whitelisting
If you cannot eliminate unserialize() entirely, implement strict validation on the data *before* it’s passed to the function. This involves checking the expected structure, data types, and values. Whitelisting allowed classes and properties is crucial. However, this is a complex and error-prone approach, best avoided if possible.
5. Disable Dangerous Functions (PHP.ini)
While not a complete solution for session handling, disabling functions like system(), exec(), shell_exec(), passthru(), popen(), proc_open() in php.ini can mitigate some post-deserialization exploitation vectors. This should be part of a broader security hardening strategy.
[PHP] disable_functions = system, exec, shell_exec, passthru, popen, proc_open, unserialize
Note: Disabling unserialize() itself is often not feasible if it’s used legitimately elsewhere. The focus must be on the *source* of the data being unserialized.
Auditing Workflow Summary
- Identify Storage: Locate
session.save_pathand custom session handlers. - Analyze Format: Determine if session data is serialized PHP objects/arrays.
- Find `unserialize()` Calls: Search for all instances of
unserialize(). - Trace Data Sources: For each
unserialize()call, identify the origin of its input. Prioritize user-controlled sources. - Assess Risk: Evaluate the likelihood and impact of exploitation based on data source and application logic.
- Implement Fixes: Refactor to JSON, use secure custom handlers, or implement strict validation.
- Test Thoroughly: Verify that the fixes do not break application functionality and effectively prevent exploitation.
By systematically auditing your legacy PHP monolith for insecure deserialization in session handling, you can significantly reduce your attack surface and protect your application from critical security vulnerabilities.