The Target & Threat Context
This particular engagement was a red team exercise for a client in the FinTech space – let's call them "SecurePay." SecurePay handled millions of daily transactions, processing sensitive financial data, and their infrastructure was almost entirely serverless on AWS. My team at TheDevDude was brought in to stress-test their defenses, specifically focusing on their core payment processing pipeline. The stakes couldn't have been higher; a breach here meant not just financial loss but catastrophic reputational damage and regulatory fines.
The specific target that caught our eye was a critical AWS Lambda function, let's call it TransactionProcessorLambda. This function was the heart of their real-time transaction validation and routing system. It was written in Python, triggered by an API Gateway endpoint, and interacted heavily with DynamoDB for transaction records, S3 for audit logs, and an internal Kafka cluster for asynchronous processing. The tech stack was pretty standard for a modern serverless application: AWS Lambda, API Gateway, DynamoDB, S3, KMS, and a smattering of other services orchestrated via AWS SAM (Serverless Application Model).
The business context was crucial: this Lambda function was responsible for validating incoming payment requests, applying business logic, and then securely forwarding them to various banking partners. Any disruption or compromise of this function meant transactions would halt, or worse, could be manipulated. It was a high-throughput, low-latency component, designed for resilience and speed. The developers had focused heavily on performance and functional correctness, as is often the case, sometimes overlooking the subtle security implications of certain design choices. I remember thinking, "This reminds me of some of the early challenges we faced at Website Factory when we were trying to balance rapid deployment with robust security for our client's e-commerce platforms." The pressure to deliver features often overshadows the meticulous review of every configuration detail, especially when it comes to environment variables, which are often seen as 'just configuration'.
Our goal was to achieve remote code execution (RCE) within this critical function, demonstrating the ability to exfiltrate data, manipulate transactions, or pivot further into their AWS environment. The initial reconnaissance revealed a complex web of IAM roles and permissions, but one particular detail in the Lambda's configuration caught our attention during an enumeration phase: a seemingly benign environment variable.
Corrected Code / Config
Here's how the Lambda code and environment variables should be configured:
# transaction_processor_hardened.py
import os
import subprocess
import json
import shlex # For safe splitting of shell-like strings
def lambda_handler(event, context):
# Retrieve the script path and arguments separately
# No longer a single 'command_prefix' that can be injected
script_path = os.environ.get("VALIDATION_SCRIPT", "/usr/bin/python")
script_args_str = os.environ.get("VALIDATION_ARGS", "/opt/validation_logic.py") # Default arguments
# Assume 'event' contains transaction data that needs validation
transaction_data = json.loads(event['body'])
transaction_id = transaction_data.get('transaction_id', 'UNKNOWN')
# Safely parse arguments using shlex.split() if they are expected to be shell-like
# For truly fixed arguments, a simple list is better.
# Here, we assume VALIDATION_ARGS might contain multiple arguments.
try:
script_args = shlex.split(script_args_str)
except ValueError as e:
print(f"Error parsing VALIDATION_ARGS: {e}. Using default.")
script_args = ["/opt/validation_logic.py"] # Fallback to a safe default
# Construct the full command as a list of arguments
# This is crucial: subprocess.run with a list does NOT invoke a shell.
command_list = [script_path] + script_args + ["--transaction-id", transaction_id]
print(f"Executing validation command: {' '.join(command_list)}")
try:
# SAFE: shell=False (default) when passing a list of arguments
result = subprocess.run(command_list, capture_output=True, text=True, check=True)
print(f"Validation output: {result.stdout}")
return {
'statusCode': 200,
'body': json.dumps({'message': 'Transaction validated', 'details': result.stdout})
}
except subprocess.CalledProcessError as e:
print(f"Validation failed for transaction {transaction_id}: {e.stderr}")
return {
'statusCode': 500,
'body': json.dumps({'message': 'Transaction validation failed', 'error': e.stderr})
}
except Exception as e:
print(f"An unexpected error occurred: {e}")
return {
'statusCode': 500,
'body': json.dumps({'message': 'Internal server error', 'error': str(e)})
}
And the corresponding environment variables:
# Hardened Environment Variables
VALIDATION_SCRIPT="/usr/bin/python"
VALIDATION_ARGS="/opt/validation_logic.py" # Arguments for the script
This approach ensures that the script path and its arguments are treated as distinct elements, preventing shell metacharacter injection. The shlex.split() function is used to safely parse the arguments string into a list, but even better is to provide arguments as separate environment variables if possible, or hardcode them if they are truly static.