✓ Human-authored analysis; AI used for formatting and proofreading.
A 2025 CloudGoat walkthrough by VirajMathpati documents a privilege escalation that no permission-based scanner catches. The principal with a synthetic IAM user named cg-sns-user-cgidxi93qpes3g
has zero permissions on Secrets Manager. Run their identity through IAM Access Analyzer; it confirms no access. Run it through PMapper; same answer. Run it through any policy-based review; the IAM user cannot read any secret in the account.
The user retrieves a secret in seven API calls.
The trick: the user's permissions don't connect to Secrets Manager directly. The connection is a credential-value flow through four services. SNS publishes an API key as a message payload. The user subscribes to the topic and receives the key in their inbox. The key authenticates to an API Gateway. The gateway's Lambda integration reads from Secrets Manager. The secret comes back as the HTTP response body.
No policy connects SNS to Secrets Manager. The connection is the credential value itself. Every step is individually authorized. The compound chain is invisible to any tool that checks permissions service-by-service.
[1] IAM user policy
Allow: sns:Subscribe, sns:Receive, sns:ListTopics, …,
apigateway:GET (Resource: *)
Deny: apigateway:GET on 7 specific paths
[2] SNS topic
Topic ARN: public-topic-cgidxi93qpes3g
Topic policy: NONE (defaults to IAM-only gate)
Publishes: a message containing an API Gateway key
[3] API Gateway
Auth: API key only (no IAM, no Cognito, no Lambda authorizer)
Integration: Lambda
[4] Lambda
Reads: the target secret in Secrets Manager
Each one is fine by per-service checklist criteria. The user's IAM policy is broad but doesn't grant Secrets Manager. The SNS topic has no resource policy — the standard "topic owner only" default. The API Gateway uses key-only auth, which is a documented authentication option. The Lambda accesses Secrets Manager via its execution role, the standard pattern.
The bug is the connection.
The Z3 prover in stave/examples/sns-secrets-compound-chain/z3prove/
runs four queries against the writeup configuration and four against a remediated version.
The user's IAM policy admits sns:Subscribe
on Resource: "*"
. The topic has no resource policy. In AWS, the IAM identity policy is the only gate when no topic policy is configured. The user wins.
--- Finding 1: SNS topic subscribable, publishes credential ---
subscribable + sensitive topics: 1 / 1
verdict: SAT — witness: arn:aws:sns:us-east-1:676206926638:public-topic-cgidxi93qpes3g
(publishes api_key targeting arn:aws:apigateway:us-east-1::/restapis/x93anl9mj7)
The data-flow annotation publishes_credential_type=api_key
on the topic asset makes this a security concern rather than a subscription pattern concern. Stave's existing broad-subscribe control checks the topic's resource policy. This issue lives in the IAM identity policy plus the absence of a topic policy plus the data-flow fact about what the topic publishes.
The user's policy grants apigateway:GET
on Resource: "*"
and denies seven specific patterns:
{
"Effect": "Deny",
"Action": "apigateway:GET",
"Resource": [
"/apikeys",
"/apikeys/*",
"/restapis/*/resources/*/methods/GET",
"/restapis/*/methods/GET",
"/restapis/*/resources/*/integration",
"/restapis/*/integration",
"/restapis/*/resources/*/methods/*/integration"
]
}
Seven patterns. The author was thinking carefully. They identified specific high-sensitivity paths (API keys, method bodies, integration internals) and denied them. By any reasonable security review, this deny is well-considered.
Z3 walks 24 known API Gateway management paths and finds 21 of them reachable:
--- API Gateway Deny coverage analysis ---
OPEN : /restapis
OPEN : /restapis/{id}
OPEN : /restapis/{id}/resources
OPEN : /restapis/{id}/resources/{resource_id}
OPEN : /restapis/{id}/resources/{resource_id}/methods/{method}
BLOCKED : /restapis/{id}/resources/{resource_id}/methods/{method}/integration
OPEN : /restapis/{id}/stages
OPEN : /restapis/{id}/stages/{stage}
OPEN : /restapis/{id}/deployments
OPEN : /restapis/{id}/deployments/{deployment_id}
OPEN : /restapis/{id}/models
OPEN : /restapis/{id}/authorizers
... (more)
The author blocked the API key endpoint. Reasonable. But the principal can enumerate the rest: /restapis
lists every API ID. /restapis/{id}/stages
lists each API's stages. /restapis/{id}/resources
lists the resource paths. With those three calls, the attacker has the full URL. They don't need /methods/GET
(denied) or /integration
(denied). They have the API key from Finding 1. They invoke the URL directly.
The deny was correct for the things the author thought to protect. AWS API Gateway has more management endpoints than any individual reviewer holds in their head. The deny-list approach loses the race to the service surface area, just like the autoscaling iteration.
The most novel modeling in this article. The Z3 program computes five booleans:
hop 1: principal can subscribe to topic true
hop 2: topic publishes credential true
hop 3: API Gateway accepts credential, no IAM auth required true
hop 4: API Gateway integrates with downstream function true
hop 5: function reads Secrets Manager true
Each hop is a fact read from a different service's observation. The conjunction is the data-flow chain. Z3's And
of five booleans tells us the chain is satisfiable.
This is the iteration's distinguishing feature. The five facts come from four different observation assets: the IAM user, the SNS topic, the API Gateway, the Lambda + one cross-asset claim ("function reads Secrets Manager"). No single CEL predicate can express this. The composition is necessarily multi-asset.
--- Finding 3: data-flow credential chain (5 hops) ---
hop 1 (principal can subscribe to topic): true
hop 2 (topic publishes credential): true
hop 3 (API Gateway accepts credential, no IAM auth): true
hop 4 (API Gateway integrates with downstream function): true
hop 5 (function reads Secrets Manager): true
verdict: SAT — full credential-flow chain is reachable
(zero direct Secrets Manager permissions; access via
credential value flowing through SNS → API GW → Lambda)
F1 ∧ F2 ∧ F3. The complete escalation:
sns:ListTopics → discover the topic
sns:Subscribe → subscribe with attacker email
(receive SNS message) → extract the API key
apigateway:GET /restapis → enumerate API IDs
apigateway:GET /restapis/.../stages → enumerate stages
apigateway:GET /restapis/.../resources → enumerate paths
curl https://<api>.execute-api.../user-data
-H "x-api-key: <leaked-key>" → invoke API
→ API GW invokes Lambda
→ Lambda reads Secrets Manager
→ secret returned in HTTP body
Seven calls. Three services touched directly. A fourth (Secrets Manager) reached transitively. Zero direct permissions on the target.
A permission-based reasoner such as IAM Access Analyzer, Zelkova, PMapper or any policy resolver works by walking the policy graph: "principal P has policy that grants action A on resource R." The graph is complete for permission edges. But:
sns:Subscribe
. The key is a string in a message body. Receiving the key isn't a permission grant; it's A permission graph cannot represent "principal P receives credential C via mechanism M, and C authenticates to service S." That isn't a permission relationship. It's a data-flow relationship. The information is in the configuration of what flows where, not in who is allowed to do what.
This iteration extends the model to include data-flow facts as observation annotations. The Z3 program reasons over both permission facts and data-flow facts uniformly. The chain reaches a service the user has no permission on, but the user reaches the service's output.
Three changes, each closing one finding:
// 1. Scope sns:Subscribe to specific topic prefixes.
{
- "Action": "sns:Subscribe",
- "Resource": "*"
+ "Action": "sns:Subscribe",
+ "Resource": "arn:aws:sns:us-east-1:676206926638:ops-alerts-*"
}
// 2. Remove apigateway:GET. The user's data-science
// role doesn't need it. If a future use case
// arises, scope to the specific API ARN.
// 3. Stop publishing credentials through SNS. The
// correct architecture is to put the API key in
// Secrets Manager, grant the consumer's IAM
// role secretsmanager:GetSecretValue on the
// specific secret ARN, and let the consumer
// retrieve it via the SDK at runtime. Messaging
// services are not credential transport.
Plus changing the API Gateway authorization to AWS_IAM
so the resource policy and IAM identity policy both gate access, adding a defense in depth even if a future credential leak occurs.
After remediation, all four Z3 queries return UNSAT. The chain is broken at hop 1 (the user can no longer subscribe to the credential-publishing topic).
The published configuration is a teaching exercise. CloudGoat is intentionally vulnerable. But the shape of the bug is real. Production AWS environments do this: a CI/CD pipeline publishes a deployment notification to SNS. The notification includes a deployment URL. The URL is a presigned S3 URL that grants read access to a build artifact. Anyone subscribed to the topic can fetch the artifact. Credential value flowing through messaging that subscribers harvest.
The architectural rule: messaging services are not credential transport. If a credential needs to flow between services, put it in Secrets Manager (or SSM Parameter Store) and grant the consuming service secretsmanager:GetSecretValue
on the specific secret ARN. The credential never traverses a notification path; the consumer retrieves it via SDK calls authenticated by IAM.
Two reasons:
sns:Subscribe
. Anyone with that permission joins the recipient list.When the credential is in Secrets Manager and the consumer's IAM grants secretsmanager:GetSecretValue
on a specific ARN, the access pattern is:
None of these properties hold for a credential delivered via SNS message. The audit trail shows a generic "publish" event; the principal that received it is a subscriber identifier, not an IAM principal; rotation requires re-publishing.
Run an IAM policy review on cg-sns-user
. The output is "this user has SNS read access and API Gateway read access. No access to Secrets Manager." Correct.
Run a posture scan on the SNS topic. The output is "topic has no resource policy; access governed by IAM." Correct.
Run an API Gateway scan. The output is "API uses key-only auth; consider IAM authorization for defense in depth." Useful but not foundational.
Each per-service tool is correct for what it checks. None of them composes the four answers into "this principal can read your secrets."
The Z3 prover does. The five hops conjunct into one boolean, and the boolean returns SAT.
sns:Subscribe
in IAM identity policies is scoped to specific topic ARN patterns, not Resource: "*"
AWS_IAM
authorization, not API_KEY
onlyThe IAM user has no Secrets Manager permission. The secret comes back anyway. That's not a bug in any single service. It's a bug in the composition. The permission graph is silent. The credential graph talks. Z3 reads both.
A framework benchmark in turbot/steampipe-mod-aws-compliance will correctly report the IAM user as having zero Secrets Manager permissions. That's what the IAM policy says, and the framework controls are accurately reading the IAM API. The verdict isn't wrong; it's just on the wrong question. The compositional finding ("the secret comes back because a Lambda subscribed to an SNS topic the user can publish to") isn't representable in per-resource framework controls because no framework section names "subscribe to a topic whose subscriber holds a permission you don't." That's the class of finding Stave's compound + chain catalog covers. Frameworks catch what the IAM policy permits; Stave catches what the cross-service composition delivers. Both run against the same snapshot; both render in Powerpipe. Comparison:
The example is at stave/examples/sns-secrets-compound-chain/. Two binaries side by side: a CEL evaluation via pkg/stave.Apply (the existing CTL.SNS.POLICY.SUBSCRIBE.BROAD.001 per-topic control reports clean on both fixtures because the risk lives in the IAM identity policy and absence of topic policy, not in a topic-side broad subscribe) and a Z3 SAT prover that runs the four queries from this article and prints the API Gateway management deny coverage table. The Z3 binary lives in a sibling Go module so its libz3 link stays out of Stave's main vendored tree. Stave detects this pattern and 31 other H1-grounded scenarios from local AWS configuration snapshots, without cloud credentials.