How We Audited a High-Traffic PHP Enterprise Stack on AWS and Mitigated session hijacking through unencrypted session files storage
Unencrypted Session Files: A Silent Threat in High-Traffic PHP Stacks
During a recent comprehensive security audit of a high-traffic enterprise PHP application hosted on AWS, we uncovered a critical vulnerability: unencrypted session files stored on disk. While seemingly a minor oversight, this configuration presented a significant risk of session hijacking, especially in environments where file system access might be compromised, or during disaster recovery scenarios where backups could be exfiltrated.
The application utilized PHP’s default session handling mechanism, which, by default, writes session data to files on the server’s local filesystem. In a distributed AWS environment, particularly with auto-scaling groups and multiple EC2 instances, this default behavior can lead to several issues:
- Inconsistent Session State: If a user’s session is written to one instance and then their next request is routed to a different instance (due to load balancing), the session data might not be available, leading to unexpected logouts or data loss.
- Security Risk: The primary concern identified was the lack of encryption. Session files, containing sensitive user identifiers and potentially authentication tokens, were stored in plain text. If an attacker gained read access to the filesystem on any of the web servers, they could potentially steal active session IDs and impersonate legitimate users.
- Scalability Challenges: Relying on local filesystem storage for sessions becomes problematic with horizontal scaling. A shared, centralized session store is generally preferred for stateless web applications.
Auditing the Session Storage Configuration
Our audit began by examining the PHP configuration (`php.ini`) across all relevant EC2 instances. The key directive we focused on was `session.save_path`. We used a combination of SSH and AWS Systems Manager (SSM) Run Command to query this setting across our fleet.
First, we identified the `php.ini` file location. This can vary based on the PHP installation method (e.g., package manager, compiled from source, AWS Elastic Beanstalk configuration). Common locations include:
- `/etc/php/[version]/apache2/php.ini`
- `/etc/php/[version]/cli/php.ini`
- `/etc/php.ini`
We then used SSM Run Command to execute a script that would find and parse the `php.ini` file and extract the `session.save_path` value.
Automated Configuration Discovery Script (Bash)
This script iterates through common PHP configuration directories, attempts to locate `php.ini`, and then extracts the `session.save_path` directive. It’s designed to be run via SSM Run Command.
#!/bin/bash
PHP_VERSIONS=("7.4" "8.0" "8.1" "8.2") # Add or remove versions as needed
CONFIG_PATHS=("/etc/php" "/etc")
SESSION_SAVE_PATH=""
find_php_ini() {
local php_version=$1
local base_path=$2
local ini_file=""
if [[ -d "${base_path}/${php_version}/apache2" ]]; then
ini_file="${base_path}/${php_version}/apache2/php.ini"
elif [[ -d "${base_path}/${php_version}/cli" ]]; then
ini_file="${base_path}/${php_version}/cli/php.ini"
elif [[ -f "${base_path}/php.ini" ]]; then
ini_file="${base_path}/php.ini"
fi
if [[ -n "$ini_file" && -f "$ini_file" ]]; then
echo "$ini_file"
return 0
fi
return 1
}
for path in "${CONFIG_PATHS[@]}"; do
for version in "${PHP_VERSIONS[@]}"; do
ini_file=$(find_php_ini "$version" "$path")
if [[ -n "$ini_file" ]]; then
SESSION_SAVE_PATH=$(grep "^session.save_path" "$ini_file" | awk -F '=' '{print $2}' | tr -d '[:space:]')
if [[ -n "$SESSION_SAVE_PATH" ]]; then
echo "Found session.save_path = '$SESSION_SAVE_PATH' in $ini_file"
exit 0
fi
fi
done
done
# Fallback for systems where php.ini might be in a different location or not versioned
if [[ -z "$SESSION_SAVE_PATH" ]]; then
if [[ -f "/etc/php.ini" ]]; then
SESSION_SAVE_PATH=$(grep "^session.save_path" "/etc/php.ini" | awk -F '=' '{print $2}' | tr -d '[:space:]')
if [[ -n "$SESSION_SAVE_PATH" ]]; then
echo "Found session.save_path = '$SESSION_SAVE_PATH' in /etc/php.ini"
exit 0
fi
fi
fi
echo "session.save_path not found or not configured."
exit 1
The output of this script, when run across the fleet, quickly revealed that `session.save_path` was either unset (falling back to PHP’s default, often `/var/lib/php/sessions`) or explicitly set to a local directory on each instance. Crucially, none of these paths were encrypted.
Mitigation Strategy: Centralized and Encrypted Session Storage
To address the security and scalability concerns, we decided on a two-pronged approach:
- Centralize Session Storage: Move away from local filesystem storage to a shared, external service.
- Encrypt Session Data: Ensure that session data is encrypted both in transit and at rest.
For a high-traffic PHP application on AWS, several options exist for centralized session storage:
- Amazon ElastiCache (Redis or Memcached): A managed in-memory data store, ideal for caching and session management due to its low latency.
- Amazon DynamoDB: A NoSQL database that can be used for session storage, offering high scalability and durability.
- External Session Handlers: Custom PHP code or libraries that interface with other storage solutions.
Given the performance requirements of a high-traffic application, ElastiCache with Redis was the most suitable choice. Redis offers excellent performance and supports various data structures, making it a robust session store.
Implementing Redis for Session Handling
The first step is to provision an Amazon ElastiCache for Redis cluster. This involves creating a Redis cluster in the AWS console or via Infrastructure as Code (IaC) tools like CloudFormation or Terraform. Ensure the cluster is placed within the same VPC and subnets as your EC2 instances for optimal network performance and security.
Next, we need to configure PHP to use Redis as its session handler. This is typically done by installing the `phpredis` extension and then modifying `php.ini`.
Installing the phpredis Extension
The installation method depends on your PHP build. For common package managers like `apt` (Debian/Ubuntu) or `yum` (CentOS/RHEL), you might find pre-compiled packages. If not, compiling from source is necessary.
Example using PECL (common for many distributions):
# Ensure PECL is installed sudo apt update && sudo apt install -y php-pear sudo yum update && sudo yum install -y php-pear # Install phpredis extension sudo pecl install redis # Enable the extension by adding it to php.ini or a conf.d file # This command might vary based on your PHP setup echo "extension=redis.so" | sudo tee /etc/php/8.1/mods-available/redis.ini sudo phpenmod redis # For Debian/Ubuntu # For CentOS/RHEL, you might need to manually add it to the correct php.ini or conf.d file # e.g., echo "extension=redis.so" | sudo tee /etc/php.d/20-redis.ini
Configuring PHP to Use Redis
Once the extension is installed and enabled, we modify `php.ini` (or a dedicated configuration file in `conf.d`) to point PHP to the Redis cluster. We also need to enable session serialization and encryption.
Key `php.ini` directives:
; Enable Redis session handler
session.save_handler = redis
; Specify the Redis server connection details
; Format: tcp://[host]:[port] or unix:///path/to/socket
; For ElastiCache, use the primary endpoint.
; Ensure this is accessible from your EC2 instances (e.g., Security Groups).
session.save_path = "tcp://YOUR_ELASTICACHE_REDIS_ENDPOINT:6379"
; Optional: If using Redis Sentinel or Cluster, the format will differ.
; For a single node ElastiCache, the above is sufficient.
; Enable transparent session encryption (PHP 7.2+)
; This uses OpenSSL to encrypt session data before storing it.
; You MUST set a strong, unique encryption key.
session.entropy_file = /dev/urandom
session.entropy_length = 512
session.use_strict_mode = 1
session.cookie_httponly = 1
session.cookie_secure = 1 ; Only send cookies over HTTPS
session.use_cookies = 1
session.use_only_cookies = 1
; --- Encryption Configuration ---
; This requires the OpenSSL extension to be enabled in PHP.
; The key should be stored securely and rotated periodically.
; For production, consider using a secrets management system (e.g., AWS Secrets Manager).
; For simplicity in this example, we'll show a direct setting, but this is NOT recommended for production.
; A better approach is to load this key from an environment variable or a secure file.
; Example using a hardcoded key (NOT RECOMMENDED FOR PRODUCTION)
; openssl_session_save_path = "key=YOUR_VERY_STRONG_AND_SECRET_ENCRYPTION_KEY&cipher=aes-256-cbc&iv_len=16&passphrase=YOUR_PASSPHRASE_IF_NEEDED"
; Recommended approach: Load from environment variable or secrets manager
; In your PHP application code, you would read the key and set it dynamically.
; For example, using a library that supports dynamic key loading or by setting
; the openssl_session_save_path in your application's bootstrap.
;
; If you are using a custom session handler or a library that abstracts this,
; you would configure encryption within that library.
;
; For PHP's built-in Redis session handler with encryption, the openssl_session_save_path
; directive is used. However, managing the key directly in php.ini is insecure.
;
; A more robust solution involves a custom session handler or a library that
; integrates with AWS Secrets Manager or Parameter Store.
;
; For demonstration purposes, let's assume you've set an environment variable
; PHP_SESSION_ENCRYPTION_KEY and PHP_SESSION_ENCRYPTION_CIPHER.
; You would then need a mechanism to inject these into php.ini or use a custom handler.
; --- Alternative: Custom Session Handler for Robust Encryption Management ---
; If direct php.ini configuration for encryption is too complex or insecure,
; a custom session handler is often preferred. This allows full control over
; encryption, key management (e.g., fetching from AWS Secrets Manager), and
; interaction with Redis.
; Example of how you might set a custom handler in your application's bootstrap:
/*
session_set_save_handler(
new MyRedisSessionHandler(
'tcp://YOUR_ELASTICACHE_REDIS_ENDPOINT:6379',
[
'encryption_key' => getenv('SESSION_ENCRYPTION_KEY'), // Fetch from env var
'cipher' => 'aes-256-cbc',
'iv_length' => 16
]
),
true // Register as a session save handler
);
*/
; For this audit's immediate mitigation, we focused on ensuring session.save_handler = redis
; and session.save_path pointed to ElastiCache. Encryption was a subsequent, more complex step
; often handled by application-level libraries or custom handlers for better key management.
; If using PHP's built-in encryption, ensure openssl extension is enabled and
; session.save_path is configured with key/cipher.
;
; Example for PHP's built-in encryption (requires openssl extension):
; openssl_session_save_path = "key=YOUR_VERY_STRONG_AND_SECRET_ENCRYPTION_KEY&cipher=aes-256-cbc&iv_len=16"
; The key management is the critical part here.
Important Considerations for Encryption:
- Key Management: Storing encryption keys directly in `php.ini` is a significant security risk. For production environments, keys should be managed using a secure secrets management system like AWS Secrets Manager or HashiCorp Vault. Your PHP application or a custom session handler would then retrieve the key at runtime.
- OpenSSL Extension: Ensure the OpenSSL extension is enabled in your PHP build.
- Cipher and IV Length: Choose a strong cipher (e.g., `aes-256-cbc`) and ensure the correct Initialization Vector (IV) length is specified.
- Custom Session Handlers: For maximum control over encryption, key rotation, and integration with AWS services, developing a custom PHP session handler is often the most robust solution. This handler would connect to Redis, fetch the encryption key from Secrets Manager, encrypt/decrypt data, and then store/retrieve it from Redis.
Securing ElastiCache and Network Access
Beyond configuring PHP, securing the ElastiCache cluster itself is paramount.
- Security Groups: Configure AWS Security Groups to allow inbound traffic on port 6379 (Redis default) only from the Security Groups associated with your EC2 web servers. Restrict outbound traffic as much as possible.
- Subnet Groups: Ensure your ElastiCache cluster is deployed in private subnets within your VPC, not publicly accessible.
- Encryption in Transit: For ElastiCache for Redis, enable “Encryption in-transit” when provisioning the cluster. This uses TLS to encrypt data between your application and Redis.
- Encryption at Rest: Enable “Encryption at rest” to encrypt the data stored on the Redis nodes.
By enabling both “Encryption in-transit” and “Encryption at rest” on ElastiCache, we ensure that session data is protected at all stages while residing within AWS infrastructure.
Verification and Ongoing Monitoring
After implementing the changes, thorough verification is essential.
- Test Session Persistence: Log in to the application, navigate across different pages, and then simulate a server restart or a new instance launch to ensure sessions are correctly retrieved from ElastiCache.
- Verify Encryption: If possible, inspect the data stored in Redis (e.g., using `redis-cli` with appropriate authentication and TLS if enabled) to confirm it appears encrypted if you’ve configured PHP-level encryption.
- Monitor ElastiCache Metrics: Use Amazon CloudWatch to monitor ElastiCache metrics such as CPU utilization, memory usage, cache hits/misses, and network traffic. Set up alarms for critical thresholds.
- Log Analysis: Monitor application logs for any session-related errors or warnings.
This audit and mitigation process highlights the importance of regularly reviewing default configurations, especially in security-sensitive areas like session management. By moving to a centralized, encrypted session store like ElastiCache for Redis, we significantly enhanced the security posture and scalability of the enterprise PHP application.