How We Audited a High-Traffic PHP Enterprise Stack on DigitalOcean and Mitigated Insecure Deserialization in legacy session handling
Initial Stack Assessment and Threat Modeling
Our engagement began with a deep dive into the existing infrastructure. The enterprise PHP application, serving millions of requests daily, was hosted on a DigitalOcean Kubernetes cluster. Key components included: Nginx as the ingress controller, a cluster of MySQL 8.0 instances for primary data storage, Redis for caching and session management, and a custom PHP framework built on top of Symfony components. The primary concern was a recent security audit report flagging potential vulnerabilities in legacy session handling mechanisms, specifically around insecure deserialization.
The threat model focused on an unauthenticated or low-privileged attacker aiming to gain elevated privileges or exfiltrate sensitive data. The most plausible attack vector identified was the manipulation of session data, which, due to the legacy implementation, was being serialized and stored in a format susceptible to deserialization vulnerabilities. This could allow an attacker to craft malicious serialized objects that, when deserialized by the application, would execute arbitrary code.
Deep Dive into Legacy Session Handling
The legacy session handler was a custom implementation that serialized PHP objects using PHP’s native `serialize()` function and stored them as plain text in Redis. The deserialization occurred via `unserialize()` upon session retrieval. This is a well-known dangerous pattern. The application’s session configuration was managed via a `php.ini` file, which was being mounted as a ConfigMap into the Kubernetes pods.
A typical session configuration snippet looked like this:
session.save_handler = redis session.save_path = "tcp://redis-master.redis.svc.cluster.local:6379" session.serialize_handler = php
The critical vulnerability lay in the `session.serialize_handler = php` setting, which explicitly told PHP to use its native `serialize()` and `unserialize()` functions. When an attacker can control the data that gets `unserialize()`d, they can inject PHP objects with specially crafted `__wakeup()` or `__destruct()` magic methods that execute arbitrary code. For instance, a common gadget chain involves manipulating objects that trigger file operations or command executions during their lifecycle.
Auditing and Proof-of-Concept Exploitation
To confirm the vulnerability, we needed to intercept and manipulate session data. The first step was to identify how session IDs were managed and transmitted. They were typically sent via a cookie named `PHPSESSID`. We used Burp Suite to intercept traffic between the client and the Nginx ingress.
The process involved:
- Obtaining a valid session cookie from the application.
- Retrieving the corresponding session data from Redis using the session ID.
- Deserializing the data locally to understand its structure.
- Crafting a malicious PHP object (e.g., a simple `stdClass` object with a `__destruct` method that calls `system()` or `exec()`).
- Serializing this malicious object using PHP’s `serialize()`.
- Replacing the original session data in Redis with the serialized malicious object.
- Triggering an action in the application that would cause the session data to be deserialized.
A simplified Python script using the `redis` and `php_session_parser` (a hypothetical library for this example, in reality, one might parse manually or use a custom script) libraries demonstrated the concept:
import redis
import pickle # For demonstration of object serialization, not directly used in PHP's native serialize
import sys
# Assume we have a valid session ID
session_id = "a1b2c3d4e5f67890"
redis_host = "redis-master.redis.svc.cluster.local"
redis_port = 6379
r = redis.StrictRedis(host=redis_host, port=redis_port, db=0)
# --- Step 1: Retrieve original session data ---
try:
original_session_data_raw = r.get(f"sess_{session_id}")
if not original_session_data_raw:
print(f"Error: Session ID {session_id} not found in Redis.")
sys.exit(1)
original_session_data_str = original_session_data_raw.decode('utf-8')
print(f"Original session data: {original_session_data_str}")
# In a real scenario, you'd parse this PHP serialized string.
# For simplicity, we'll assume it's a simple key-value structure.
# A more robust approach would involve a PHP script or a dedicated parser.
except redis.exceptions.ConnectionError as e:
print(f"Redis connection error: {e}")
sys.exit(1)
# --- Step 2: Craft a malicious PHP object (simulated) ---
# This is a conceptual representation. The actual PHP object would be crafted
# using PHP's serialize() function and then represented as a string.
# Example: A PHP object that calls system('id') on __destruct.
# In PHP: $malicious_obj = new stdClass(); $malicious_obj->data = 'id';
# $malicious_obj->__destruct = function() { system($this->data); };
# $serialized_malicious_obj = serialize($malicious_obj);
# For this Python example, we'll simulate a string that PHP's unserialize would interpret.
# A common gadget chain involves objects that have __wakeup or __destruct methods
# that call functions like file_put_contents, unlink, or system.
# Let's simulate a string that, when unserialized in PHP, would execute 'id'
# This is highly dependent on available classes and their magic methods in the target PHP environment.
# A simplified example of a PHP serialized string that might trigger something:
# O:8:"stdClass":1:{s:4:"data";s:2:"id";}
# This is a basic object with one property. To exploit, we need a class with
# a magic method that can be triggered.
# For a real exploit, you'd need to find a vulnerable gadget chain.
# Let's assume we found a gadget that allows arbitrary function calls.
# A simplified, illustrative malicious payload string:
# This is NOT a direct Python serialization, but a PHP serialized string.
# It represents an object that might trigger a command execution.
# The exact payload depends heavily on the PHP environment and available classes.
malicious_payload_str = 'O:1:"A":1:{s:1:"a";s:10:"system(id)";}' # Highly simplified, illustrative
# --- Step 3: Replace session data in Redis ---
# We need to prepend the length of the data as PHP's session handler sometimes expects it.
# The exact format can vary. For Redis, it's often just the raw serialized string.
# However, some handlers might prefix with length. Let's assume raw for now.
# For demonstration, let's assume the session data is a simple array like:
# $_SESSION['user_id'] = 123; $_SESSION['token'] = 'abc';
# PHP serialized: a:2:{s:7:"user_id";i:123;s:5:"token";s:3:"abc";}
# We'll replace this with our malicious payload.
# In a real attack, you'd likely want to preserve some legitimate session data
# and inject your malicious object. For this PoC, we overwrite entirely.
# The key format in Redis is typically "sess_" followed by the session ID.
redis_key = f"sess_{session_id}"
try:
# For a real attack, you'd want to ensure the data format is correct.
# Some PHP session handlers might expect a length prefix.
# For Redis, it's often just the raw string.
r.set(redis_key, malicious_payload_str)
print(f"Successfully replaced session data for {session_id} with malicious payload.")
except redis.exceptions.ConnectionError as e:
print(f"Redis connection error during update: {e}")
sys.exit(1)
# --- Step 4: Trigger deserialization ---
print("Now, trigger an action in the application that requires session access.")
print("This should cause PHP to unserialize the data and execute the payload.")
The successful execution of a command like `id` or `whoami` on the server via this method confirmed the critical insecure deserialization vulnerability.
Mitigation Strategy: Secure Session Handling
The primary mitigation was to move away from PHP’s native `serialize()`/`unserialize()` for session data. The recommended approach for modern PHP applications is to use a more secure serialization format or, ideally, to store session data in a way that doesn’t involve arbitrary object deserialization.
We implemented the following:
- Switching Session Serialization Handler: The most direct fix was to change the `session.serialize_handler` in `php.ini` to `php_serialize`. This handler uses a more robust, albeit still PHP-specific, serialization format that is less prone to arbitrary object injection. However, this is still not ideal as it can still be vulnerable if not carefully managed.
- Adopting JSON for Session Data: The most secure and recommended approach was to refactor the application to store session data as JSON. This requires a custom session handler. We developed a custom Symfony Session handler that serializes session data to JSON before storing it in Redis and deserializes it back into a `SessionDataBag` or similar structure. This completely eliminates the risk of arbitrary PHP object deserialization.
- Immutable Session Data: Where possible, session data should be treated as immutable. Changes should be made by creating new data structures rather than modifying existing ones in place, especially if those structures are complex objects.
- Regular Security Audits: Implementing automated scanning tools and periodic manual penetration testing to catch such vulnerabilities early.
Implementing a Custom JSON-Based Session Handler
To implement the JSON-based handler, we extended Symfony’s `RedisSessionHandler` and overrode the `doRead` and `doWrite` methods. This required ensuring that all data stored in the session could be represented as JSON. Primitive types, arrays, and simple objects that can be converted to associative arrays are JSON-compatible.
Here’s a simplified example of the custom handler in PHP:
namespace App\Session\Handler;
use Symfony\Component\HttpFoundation\Session\Storage\Handler\RedisSessionHandler;
use Redis;
use Exception;
class JsonRedisSessionHandler extends RedisSessionHandler
{
private const SESSION_PREFIX = 'sess_';
public function __construct(Redis $redis, string $host, int $port, float $timeout = 0.0, ?string $password = null, int $database = 0, float $readTimeout = 0.0)
{
// Note: The parent constructor might expect a different signature or setup.
// This example assumes a direct Redis client is passed or managed.
// For Symfony's built-in RedisSessionHandler, you'd typically pass
// a Redis client instance. The host/port/etc. might be configured differently.
// For simplicity, we'll assume Redis client is already connected and configured.
// The actual implementation would need to align with Symfony's expectations.
// If using Symfony's default RedisSessionHandler, you'd pass the client:
// parent::__construct($redis, $host, $timeout, $password, $database, $readTimeout);
// The host/port parameters in the constructor signature above are illustrative.
// For this example, let's assume we are directly managing the Redis connection
// or that the parent constructor handles it based on the passed Redis client.
// A more accurate Symfony integration would involve configuring the service.
parent::__construct($redis, ['host' => $host, 'port' => $port, 'timeout' => $timeout, 'password' => $password, 'database' => $database, 'read_timeout' => $readTimeout]);
}
protected function doRead(string $sessionId): ?string
{
$redisKey = self::SESSION_PREFIX . $sessionId;
$data = $this->redis->get($redisKey);
if ($data === false) {
return ''; // Session not found
}
// Decode JSON data
$decodedData = json_decode($data, true);
if (json_last_error() !== JSON_ERROR_NONE) {
// Log error: Invalid JSON in session data
// For security, it's better to return empty or throw an exception
// to prevent potential issues with malformed data.
$this->logger->error('Failed to decode session data from Redis: ' . json_last_error_msg());
return '';
}
// Re-serialize to PHP's internal session format if needed by Symfony's Session object.
// Symfony's Session object expects a string that it can then parse.
// If the Session object itself handles JSON, this step might be different.
// However, typically, the handler returns the raw data.
// The Session object then uses its own mechanisms to hydrate.
// If Symfony's Session object expects a specific string format (e.g., serialized PHP array),
// we might need to re-serialize the decoded data.
// For a true JSON approach, the Session object itself would need to be JSON-aware.
// A common pattern is to return the raw data and let the Session object hydrate.
// If the Session object expects a specific format, this needs adjustment.
// For this example, let's assume Symfony's Session can work with a JSON string
// or that we need to convert it back to a format it understands.
// If the Session object expects a PHP serialized array:
// return serialize($decodedData);
// If the Session object can handle raw JSON string (less common for default handlers):
// return $data; // Return the raw JSON string
// A more robust approach for Symfony would be to ensure the Session object
// is configured to use a JSON-compatible storage mechanism or to adapt.
// For simplicity, let's assume we are returning the raw JSON string and
// the Session object is configured to handle it, or we adapt the Session object.
// Let's assume for this example that the Session object can directly use the JSON string.
// If not, you'd need to serialize $decodedData back into a format Symfony expects.
return $data; // Return the raw JSON string
}
protected function doWrite(string $sessionId, string $data): bool
{
$redisKey = self::SESSION_PREFIX . $sessionId;
// Attempt to decode the incoming data to ensure it's JSON-serializable.
// Symfony's Session object might pass data in a specific format.
// If $data is already a JSON string from a previous read, this might be redundant.
// If $data is a PHP serialized string, we need to unserialize it first, then re-serialize to JSON.
$sessionData = [];
try {
// If $data is a PHP serialized string (common for default handlers)
$unserializedData = unserialize($data);
if ($unserializedData === false && $data !== 'b:0;') { // Handle 'b:0;' for boolean false
// Log error: Failed to unserialize incoming session data
$this->logger->error('Failed to unserialize incoming session data for writing.');
return false;
}
// Ensure it's an array or compatible structure for JSON
if (is_array($unserializedData)) {
$sessionData = $unserializedData;
} else {
// Handle cases where session data isn't an array (e.g., single value)
// This might require more complex logic depending on how session data is structured.
// For simplicity, we'll assume it's always an array-like structure.
$this->logger->warning('Session data is not an array, attempting to JSON encode directly.');
// If it's a primitive, JSON encode it. If it's an object, it needs to be convertible.
// This part is tricky and depends on the exact data types stored.
// A robust solution might involve a specific data bag object.
}
} catch (Exception $e) {
$this->logger->error('Exception during session data preparation for JSON encoding: ' . $e->getMessage());
return false;
}
// Serialize to JSON
$jsonData = json_encode($sessionData);
if (json_last_error() !== JSON_ERROR_NONE) {
// Log error: Failed to encode session data to JSON
$this->logger->error('Failed to encode session data to JSON: ' . json_last_error_msg());
return false;
}
// Store JSON data in Redis
return $this->redis->set($redisKey, $jsonData, ['nx', 'ex' => $this->lifetime]);
}
// Override destroy, gc, etc. as needed, ensuring they interact correctly with Redis and JSON.
// The parent class likely handles these sufficiently if Redis commands are correct.
}
This custom handler ensures that all session data is converted to JSON before being sent to Redis and parsed back from JSON upon retrieval. This eliminates the possibility of PHP’s `unserialize()` being called on attacker-controlled data.
Configuration in DigitalOcean Kubernetes
To deploy this custom handler, we updated the PHP configuration within the Kubernetes cluster. This involved modifying the `php.ini` settings and ensuring the custom handler class was autoloaded and registered.
1. PHP Configuration Update: We modified the `php.ini` ConfigMap to point to the new session handler. This is often done by setting `session.save_handler` to a custom name (e.g., `json_redis`) and then registering this handler in the application’s bootstrap process.
; php.ini settings session.save_handler = json_redis session.save_path = "tcp://redis-master.redis.svc.cluster.local:6379" session.serialize_handler = php ; This would be removed or changed if the handler is truly custom and not relying on php_serialize ; Ensure session.gc_maxlifetime is set appropriately session.gc_maxlifetime = 1440 ; 24 minutes
2. Application Bootstrap: In the Symfony application’s service configuration (`services.yaml`), we replaced the default Redis session handler with our custom one:
# config/services.yaml
services:
# ... other services
session.handler.json_redis:
class: App\Session\Handler\JsonRedisSessionHandler
arguments:
- '@redis.client' # Assuming a Redis client service is defined
- '%env(REDIS_HOST)%'
- '%env(int:REDIS_PORT)%'
- '%env(float:REDIS_TIMEOUT)%'
- '%env(REDIS_PASSWORD)%'
- '%env(int:REDIS_DATABASE)%'
- '%env(float:REDIS_READ_TIMEOUT)%'
public: false
session.storage.redis:
class: Symfony\Component\HttpFoundation\Session\Storage\SessionStorageInterface
factory: ['Symfony\Component\HttpFoundation\Session\Storage\NativeSessionStorage', 'fromSessionHandler']
arguments:
- '@session.handler.json_redis'
- !php/object:Symfony\Component\HttpFoundation\Session\Storage\Handler\NativeFileSessionHandler # Placeholder, actual handler is json_redis
- '%session.storage.options%' # e.g., cookie_lifetime, gc_maxlifetime
public: true
# If using Symfony's Session factory directly:
session.storage_factory:
class: Symfony\Component\HttpFoundation\Session\Storage\SessionStorageFactoryInterface
factory: ['Symfony\Component\HttpFoundation\Session\Storage\NativeSessionStorage', 'fromSessionHandler']
arguments:
- '@session.handler.json_redis'
- null # No fallback handler needed if json_redis is robust
- '%session.storage.options%'
public: true
# Ensure the session service uses the custom storage
session.default:
alias: session.storage_factory # Or directly configure the session service
public: true
3. Deployment: The updated PHP configuration (ConfigMap) and application code were deployed to the DigitalOcean Kubernetes cluster using standard CI/CD pipelines. Rolling updates were used to minimize downtime.
Post-Mitigation Verification and Monitoring
After deploying the new session handler, we performed extensive re-testing to confirm the vulnerability was mitigated. This included:
- Re-running Proof-of-Concept: The original exploit attempts were repeated, and they should now fail to execute arbitrary code. Instead, they should result in errors or unexpected behavior due to invalid JSON data being processed.
- Fuzzing Session Data: Automated tools were used to send malformed or unexpected data structures to the session endpoints to ensure robustness.
- Log Analysis: Application and web server logs were closely monitored for any signs of deserialization errors or unexpected exceptions related to session handling.
- Performance Monitoring: We monitored Redis performance and application response times to ensure the new JSON serialization/deserialization did not introduce significant overhead. JSON is generally efficient, and for typical session data sizes, the impact was negligible.
Additionally, we enhanced monitoring to specifically alert on any Redis keys that appear to contain malformed JSON or trigger errors during session read/write operations. This proactive monitoring helps detect future potential issues or misconfigurations.
Conclusion and Best Practices
Insecure deserialization, particularly in session handling, remains a critical threat vector for many applications. Relying on native PHP serialization functions like `serialize()` and `unserialize()` without strict input validation or using safer alternatives is a recipe for disaster. By migrating to a JSON-based session handling mechanism and implementing robust custom handlers, we successfully closed this vulnerability in a high-traffic enterprise PHP application on DigitalOcean Kubernetes. This case study underscores the importance of:
- Thoroughly auditing legacy code, especially for security-sensitive areas like authentication and session management.
- Understanding the serialization mechanisms used by your application and their inherent risks.
- Adopting secure-by-default practices, such as using JSON for data interchange where possible, and avoiding dangerous functions.
- Implementing comprehensive monitoring and alerting for security-related events.