시리즈AWS 실습 계정에서 흔적을 남기고 되짚기2편 · 공개 4편
최소 권한을 정책 길이가 아니라 거부 테스트로 확인했다
IAM 정책을 좁혔다는 것을 무엇으로 증명하는지 실습 계정에서 확인했다. 정책 시뮬레이터로 Negative 테스트를 돌리다가, 입력이 틀려도 「거부됨」이 똑같이 나오는 경로를 네 개 만났다. 거부가 왜 증거가 되지 못하는지에 대한 기록이다.
목차 8
2026-08-09 정정 —
EvalResourceName자리표시자를 ARN 템플릿으로 바로잡고, 액션별 ARN 유형과 Negative 테스트의 유효 조건을 명확히 했다.s3:PutBucketPolicy예시는 버킷 ARN을 사용하고, canary 객체는 허용 범위 밖에서 검사하도록 고쳤다.
2026-09-25 정정 — 유효한 자격증명과 요청의 권한 허용을 구분하고, 서비스 역할을 통한 권한 확대도 권한 상승에 포함됨을 바로잡았다. 당시 실험 결과는 그대로 두었다.
최소 권한으로 좁혔다는 것을 무엇으로 증명하는가
정책이 짧아진 것은 눈에 보인다. 그런데 그 정책으로 무엇이 막히는지는 정책 파일 안에 적혀 있지 않다. 나중에 누가 Action 하나를 되돌려 넣어도 아무 일도 일어나지 않는다.
그래서 증명이 될 만한 형태를 찾아 실습 계정에서 확인했다. 결론부터 적으면, 「거부됨」은 증명이 되지 못한다. 실습 한 번에 입력이 틀렸는데도 거부가 나오는 경로를 네 개 만났고, 화면상으로는 정상적인 거부와 구분되지 않았다.
클라우드 API를 호출할 때도 권한 평가를 거친다
S3, Lambda, RDS 같은 클라우드 서비스를 관리할 때는 API를 호출한다. 유효한 자격증명으로 인증받아도, 요청한 작업과 리소스에 대한 권한 평가를 통과해야 허용된다. 네트워크 출처도 이 평가에 참여할 수 있다. 예를 들어 정책의 aws:SourceIp나 aws:SourceVpce 조건으로 허용할 출처를 제한할 수 있으므로, 클라우드에서도 네트워크 경계와 권한을 함께 봐야 한다.
IAM user의 access key는 CLI·SDK·API 호출에 쓰는 장기 자격 증명이다. 자동으로 만료되지 않는다는 것이 편의이자 문제다. 임시 자격증명은 시간이 지나면 만료되지만 장기 access key는 비활성화하거나 삭제하기 전까지 사용할 수 있다. 노출되면 공격자도 그 키로 인증을 시도할 수 있고, 적용되는 정책과 조건이 허용하는 범위에서 API를 호출할 수 있다.
root user에 대해 권장되는 원칙(일상 작업에 쓰지 않기, MFA 적용, access key를 만들지 않기)이 강한 이유가 여기 있다. root user는 IAM 정책으로는 제한되지 않는다. AWS Organizations의 멤버 계정 root는 SCP로 제한할 수 있지만, SCP는 관리 계정의 사용자·역할과 root에는 적용되지 않는다. 조직에 속하지 않은 단일 계정에도 이 조직 수준 통제는 없다.
1. 넓은 정책과 좁힌 정책은 시뮬레이터에서 어떻게 갈리는가
실습용 IAM 사용자 하나를 만들고, 인라인 정책 하나를 붙였다 바꿔 가며 평가했다. 시뮬레이터는 실제 호출을 하지 않으므로 버킷과 객체에 영향이 없다.
먼저 넓은 정책이다.
{ "Effect": "Allow", "Action": "s3:*", "Resource": "*" }aws iam simulate-principal-policy \
--policy-source-arn <실습 사용자 ARN> \
--action-names s3:ListBucket s3:GetObject s3:DeleteObject s3:PutBucketPolicy \
--resource-arns arn:aws:s3:::example-bucket \
arn:aws:s3:::example-bucket/canary.txt \
--query 'EvaluationResults[].[EvalActionName,EvalResourceName,EvalDecision]' \
--output table| s3:ListBucket | arn:aws:s3:::${BucketName}/${KeyName} | allowed |
| s3:GetObject | arn:aws:s3:::${BucketName}/${KeyName} | allowed |
| s3:DeleteObject | arn:aws:s3:::${BucketName}/${KeyName} | allowed |
| s3:PutBucketPolicy | arn:aws:s3:::${BucketName}/${KeyName} | allowed |네 줄 모두 allowed인 것은 예상대로다. 예상하지 못한 것은 EvalResourceName이다. 내가 넘긴 ARN이 아니라 ${BucketName}/${KeyName}이라는 자리표시자가 돌아왔다. 처음에는 정책의 Resource가 *여서 생긴 값이라고 해석했지만, 아래의 좁힌 정책에서도 똑같이 나왔다. 그 해석은 틀렸다.
AWS CLI 문서에서 최상위 EvalResourceName은 사용자가 입력한 ARN이 아니라 해당 리소스 유형의 ARN 템플릿이다. 입력한 ARN별 결과는 ResourceSpecificResults에서 봐야 한다. 따라서 자리표시자 자체는 정책이 넓다는 증거도, 리소스를 특정하지 못했다는 증거도 아니다. s3:*와 Resource: "*"가 넓다는 판단은 정책 문장 자체와, 테스트한 모든 액션·리소스 조합이 허용된 결과에서 나온다.
같은 대상을 좁혀서 적으면 이렇게 된다.
{
"Version": "2012-10-17",
"Statement": [
{ "Effect": "Allow", "Action": ["s3:ListBucket"],
"Resource": "arn:aws:s3:::example-bucket" },
{ "Effect": "Allow", "Action": ["s3:GetObject"],
"Resource": "arn:aws:s3:::example-bucket/*" }
]
}버킷 수준 액션(ListBucket)과 객체 수준 액션(GetObject)의 Resource가 다르다. 앞의 것은 버킷 ARN, 뒤의 것은 버킷 ARN 뒤에 /*가 붙는다. 이 구분이 뒤에서 문제를 만든다.
2. 거부는 왜 한 가지가 아닌가
정책을 배포하기 전에 두 방향을 확인한다.
| 구분 | 확인하는 것 |
|---|---|
| Positive | 업무가 되는가 |
| Negative | 막혔어야 할 것이 막히는가 |
Positive만으로는 최소 권한을 검증할 수 없다. 넓은 정책도 Positive 테스트는 전부 통과하기 때문이다. 그래서 검증되는 쪽은 Negative라고 생각했는데, 돌려 보니 Negative 쪽이 더 잘 속는다.
좁힌 정책을 붙이고 같은 명령을 돌렸다.
| s3:ListBucket | arn:aws:s3:::${BucketName}/${KeyName} | implicitDeny |
| s3:GetObject | arn:aws:s3:::${BucketName}/${KeyName} | implicitDeny |
| s3:DeleteObject | arn:aws:s3:::${BucketName}/${KeyName} | implicitDeny |
| s3:PutBucketPolicy | arn:aws:s3:::${BucketName}/${KeyName} | implicitDeny |최상위 네 줄은 전부 거부다. 하지만 이것만으로 리소스별 판정을 읽을 수는 없다.
--resource-arns에 ARN을 두 개 넘겼는데, 최상위 EvalDecision은 리소스에 묶이지 않은 집계값이다. 실제 판정은 ResourceSpecificResults 안에 있다.
aws iam simulate-principal-policy \
--policy-source-arn <실습 사용자 ARN> \
--action-names s3:ListBucket s3:GetObject s3:DeleteObject s3:PutBucketPolicy \
--resource-arns arn:aws:s3:::example-bucket \
arn:aws:s3:::example-bucket/canary.txt \
--query 'EvaluationResults[].{Action:EvalActionName,
Results:ResourceSpecificResults[].{Resource:EvalResourceName,
Decision:EvalResourceDecision}}' \
--output json| | 버킷 ARN | 객체 ARN |
| s3:ListBucket | allowed | implicitDeny |
| s3:GetObject | implicitDeny | allowed |
| s3:DeleteObject | implicitDeny | implicitDeny |
| s3:PutBucketPolicy | implicitDeny | implicitDeny |당시 결과를 ARN과 함께 다시 배열하면 위 표와 같다. ListBucket은 버킷에서, GetObject는 객체에서 통과하고 나머지는 막힌다. 배열의 0번·1번을 곧바로 버킷·객체라고 가정하지 않고 각 항목의 EvalResourceName으로 대응시켜야 한다. 최상위 값만 읽었다면 「전부 막혔다」는 잘못된 통과 보고를 쓸 뻔했다.
AWS CLI 명령 참조도 이 구조를 명시한다. 응답은 액션당 하나의 EvaluationResult를 돌려주고, 최상위 EvalDecision은 요청한 리소스 전체의 집계 결정이다. 최상위 EvalResourceName은 ARN 템플릿이며, 실제 ARN별 상세는 ResourceSpecificResults에 담긴다.
콘솔 시뮬레이터에서 같은 조건으로 돌리면 이 함정이 없다. 작업마다 리소스 유형을 bucket·object로 나눠 받기 때문에 애초에 짝이 어긋나지 않는다.

GetObject와 ListBucket이 허용됨, 나머지가 거부됨으로 CLI의 리소스별 결과와 일치한다. 결과 세부 정보 열이 이유까지 문장으로 준다 — 「1개의 문에서 명시적으로 허용됨」, 「일치하는 문이 없기 때문에 암시적으로 거부됨」.
거부가 나오는 네 가지 경로
여기까지 오는 동안 「거부됨」을 봤지만 원인이 정책이 아니었던 경우가 네 번 있었다.
| # | 틀린 곳 | 무엇이 일어났나 | 화면 결과 |
|---|---|---|---|
| 1 | 리소스 이름 | 버킷 이름 자리에 예시 문자열이 그대로 남아, 존재하지 않는 버킷을 평가했다 | 거부됨 |
| 2 | 액션 이름 | 문자열 치환이 s3:ListBucket의 Bucket까지 바꿔, 없는 액션이 됐다 |
거부됨 |
| 3 | 리소스 이름 | 객체 ARN의 버킷 이름 끝이 잘려, 정책이 가리키는 버킷과 달라졌다 | 거부됨 |
| 4 | 리소스 범위 | 리소스를 지정하지 않고 *로 뒀다 |
거부됨 |
1번은 더 나갔다. 정책이 가리키는 버킷과 조회하는 ARN이 둘 다 존재하지 않는 이름이면 허용됨이 나온다. CLI 문서는 계정에 존재하지 않는 리소스도 시뮬레이션할 수 있다고 명시한다. 시뮬레이터는 실제 S3 요청을 보내 리소스 존재 여부를 확인하는 도구가 아니다.
4번은 정책이 켜져 있는데도 전부 거부가 된다.

*로 두었을 때 — 정책은 그대로인데 네 줄 모두 거부됨
네 경우 모두 결과 세부 정보가 같은 문장이다. 「일치하는 문이 없기 때문에 암시적으로 거부됨」. 정책이 좁아서 막힌 것과 입력이 틀려서 아무것도 안 걸린 것이 같은 값, 같은 설명으로 표시된다.
암시적 거부는 적용되는 Allow 문장이 없다는 뜻이고, 명시적 거부는 Deny 문장이 일치했다는 뜻이다. 둘 다 최종 권한 평가는 거부지만, Deny 문장이 의도대로 맞았다는 증거는 명시적 거부에만 있다. CLI에서는 암시적 거부의 MatchedStatements가 빈 배열인 것으로 이 차이가 보인다.
현행 응답 구조에서 객체 ARN 한 건의 암시적 거부를 펼치면 리소스별 필드는 ResourceSpecificResults 안에 있다.
{
"EvalActionName": "s3:PutBucketPolicy",
"EvalDecision": "implicitDeny",
"ResourceSpecificResults": [
{
"EvalResourceName": "arn:aws:s3:::example-bucket/canary.txt",
"EvalResourceDecision": "implicitDeny",
"MatchedStatements": [],
"MissingContextValues": []
}
]
}그래서 Negative만으로는 부족하다
처음에는 같은 실행 안에 allowed가 한 줄이라도 있으면 나머지 Negative 결과를 믿을 수 있다고 정리했다. 이것도 너무 넓은 결론이었다. ListBucket의 allowed는 그 액션·그 버킷 ARN·그 시뮬레이션 입력이 Allow 문장과 맞았다는 것만 보여 준다. 같은 표의 DeleteObject 액션 이름이나 객체 ARN, PutBucketPolicy의 버킷 ARN까지 검증하지 않는다.
Positive 대조군은 주체나 정책을 통째로 잘못 고른 실수를 찾는 데 유용하다. 그러나 Negative 한 줄을 유효하게 만들려면 그 줄의 액션 이름과 적용 가능한 리소스 유형을 별도로 확인하고, 해당 ARN의 ResourceSpecificResults를 읽어야 한다. 최상위 집계값만 전부 거부로 나온 결과는 통과가 아니라 판정 불가로 보고, 허용 한 줄만으로 나머지를 자동 통과시키지 않는다.
같은 조건에서 정책만 체크 해제하고 다시 돌려 봤다.

lab 정책만 제외했을 때
허용됨이던 GetObject와 ListBucket이 거부로 바뀌었다. 이 관찰로 확인한 범위는, 당시 입력한 두 액션·ARN 조합의 허용이 선택한 lab 정책에 의존했다는 데까지다.
그리고 이 화면은 앞의 「입력이 틀렸을 때」 화면들과 구분되지 않는다. 정책을 통째로 떼어낸 상태와 ARN 한 글자가 틀린 상태가 같은 모양으로 보인다.
여기서 canary 객체는 아직 유효한 GetObject Negative가 아니었다. 위에서 실제로 시험한 좁힌 정책도 arn:aws:s3:::example-bucket/*를 허용하므로 canary.txt가 그 범위 안에 들어가고, 표에서도 GetObject가 allowed였다. 회귀 테스트로 쓰려면 업무 객체를 training/*처럼 더 좁혀 허용한 뒤, 그 밖의 canary.txt에 대한 GetObject가 거부되는지를 별도 항목으로 검사해야 한다. 아래 재검증 절차는 그 조건을 전제로 한다.
s3:PutBucketPolicy를 Negative 목록에 넣은 것도 의도가 있다. 데이터를 읽는 권한과 접근 규칙 자체를 바꾸는 권한은 등급이 다르다. 명시적 Deny나 SCP 같은 상위 제한이 없다면, 후자는 버킷 정책을 바꿔 데이터 접근을 스스로 부여하는 경로가 될 수 있다. 다만 KMS로 암호화된 객체처럼 별도 권한이 필요한 경우까지 이 권한 하나로 모두 우회되는 것은 아니다.
3. 한 줄로는 무해한 권한
iam:PassRole은 역할을 AWS 서비스에 전달할 수 있게 하는 권한이다. 서비스 생성·실행 권한과 대상 역할의 신뢰 정책 등 필요한 조건이 함께 맞으면, 자기보다 강한 역할을 새 함수에 붙이고 실행해 원래 할 수 없던 작업을 할 수 있다. 사용자에게 붙은 정책을 직접 바꾸지 않더라도, 서비스 역할을 통해 실효 권한이 커지는 권한 상승 경로다. AWS의 PassRole 설명도 지나치게 넓은 역할 전달 권한이 이런 결과를 낳을 수 있다고 경고한다.
이 조합은 실행하지 않았다. 실제로 함수를 만들면 권한 확대를 만드는 셈이라, 시뮬레이터로 평가만 했다.
aws iam simulate-principal-policy \
--policy-source-arn <실습 사용자 ARN> \
--action-names iam:PassRole lambda:CreateFunction \
--query 'EvaluationResults[].[EvalActionName,EvalDecision]' --output table| iam:PassRole | implicitDeny |
| lambda:CreateFunction | implicitDeny |실습 사용자에게는 S3 두 줄만 있으니 당시 시뮬레이션에서는 둘 다 거부였다. 여기서 직접 관찰한 것은 입력한 정책에 두 액션을 허용하는 문장이 없었다는 사실뿐이다. 실제 권한 상승 경로를 검증한 것은 아니다. 대상 역할 ARN, Lambda 생성에 필요한 다른 권한과 조건까지 넣어 각각 평가해야 하고, 최종 판단은 실제 구성 검토가 필요하다.
같은 구조로 보는 권한들이다.
sts:AssumeRole— 다른 역할로 갈아타는 경로CreatePolicyVersion,SetDefaultPolicyVersion— 정책 내용을 바꾸는 경로PutUserPolicy,PutRolePolicy— 자기 자신에게 권한을 붙이는 경로
「데이터에 접근하는 권한」과 「권한을 바꾸는 권한」을 같은 눈으로 보면 안 된다. 후자는 다른 제한을 무효화할 수 있어서, 개수를 세는 것이 아니라 누가 갖고 있는지를 세어야 한다.
계정을 점검할 때 보는 순서
되돌릴 수 없는 것부터 본다.
- root user에 access key가 존재하는가. 있으면 다른 어떤 항목보다 먼저 처리한다
- root user와 관리자 주체에 MFA가 걸려 있는가. root user 로그인 이벤트를 알림으로 받고 있는가
- 장기 access key가 코드·Git 히스토리·설정 파일·로그에 남아 있지 않은가
- 접근 규칙을 바꿀 수 있는 권한(
PutBucketPolicy, 정책 버전 변경,PutRolePolicy)을 가진 주체가 몇 명인가 iam:PassRole이 서비스 생성 권한과 함께 붙어 있는 주체가 있는가- 정책에
Action: "*"또는Resource: "*"가 남아 있는가. 남았다면 왜 필요한지가 적혀 있는가 - 쓰지 않는 IAM user·role·access key를 지웠는가
1번과 6번을 순서 없이 나란히 두면 6번부터 손대게 된다. 항목 수가 많아 보이고 고치기 쉽기 때문이다. 순서를 고정한 이유다.
다시 확인하는 방법
정책을 바꿀 때 적용할 순서다. 아래 명령은 이번 정정에서 다시 실행한 결과가 아니라, 앞의 오류를 반영해 고친 재검증 절차다.
canary Negative를 쓰려면 policy.json의 s3:GetObject 허용 리소스가 arn:aws:s3:::example-bucket/training/*처럼 업무 prefix로 좁혀져 있어야 한다. 앞에서 관찰한 example-bucket/* 정책을 그대로 쓴다면 canary도 정상적으로 허용되므로, 그 결과를 실패로 판정하면 안 된다.
# 0. 정책 문서의 문법·액션 이름·ARN 형식 확인
aws accessanalyzer validate-policy \
--policy-document file://policy.json \
--policy-type IDENTITY_POLICY
# 공통 입력
principalArn='<실습 사용자 ARN>'
bucketArn='arn:aws:s3:::example-bucket'
canaryArn='arn:aws:s3:::example-bucket/canary.txt'
# 1-a. 객체 액션 Negative — 정책의 GetObject 허용 범위가 training/*라고 가정
aws iam simulate-principal-policy \
--policy-source-arn "$principalArn" \
--action-names s3:GetObject s3:DeleteObject \
--resource-arns "$canaryArn" \
--query 'EvaluationResults[].{Action:EvalActionName,Results:ResourceSpecificResults[].{Resource:EvalResourceName,Decision:EvalResourceDecision}}'
# 1-b. 버킷 정책 변경 Negative — 반드시 버킷 ARN
aws iam simulate-principal-policy \
--policy-source-arn "$principalArn" \
--action-names s3:PutBucketPolicy \
--resource-arns "$bucketArn" \
--query 'EvaluationResults[].{Action:EvalActionName,Results:ResourceSpecificResults[].{Resource:EvalResourceName,Decision:EvalResourceDecision}}'
# 2. Positive — 허용해야 하는 액션과 해당 리소스 유형을 한 쌍씩 확인
aws iam simulate-principal-policy \
--policy-source-arn "$principalArn" \
--action-names s3:GetObject \
--resource-arns arn:aws:s3:::example-bucket/training/sample.txt \
--query 'EvaluationResults[].ResourceSpecificResults[]'
aws iam simulate-principal-policy \
--policy-source-arn "$principalArn" \
--action-names s3:ListBucket \
--resource-arns "$bucketArn" \
--query 'EvaluationResults[].ResourceSpecificResults[]'PutBucketPolicy는 버킷에 정책을 적용하는 버킷 작업이므로 버킷 ARN을 써야 한다. 객체 ARN을 넣고 받은 implicitDeny는 의도한 권한 제한이 아니라 액션과 리소스 유형을 잘못 짝지어 만든 거짓 거부다. S3 공식 문서는 PutBucketPolicy·ListBucket 같은 버킷 작업에는 arn:aws:s3:::bucket, GetObject·DeleteObject 같은 객체 작업에는 arn:aws:s3:::bucket/key 형식을 요구한다.
Access Analyzer 정책 검증은 정책 문서의 문법, ARN 형식, 액션 이름을 확인한다. 그러나 시뮬레이터 명령줄에 별도로 입력한 --action-names와 --resource-arns가 의도한 값인지는 대신 증명하지 않는다. 정책 검증에서 Finding이 없다는 것도 최소 권한의 증거가 아니다. 「이 업무에 이 권한이 필요한가」는 업무 요구사항과 대조해야만 나온다.
여기서 직접 확인한 것은 당시 시뮬레이터의 화면과 CLI 출력이다. 자리표시자와 집계 필드의 의미, S3 액션별 ARN 유형, 정책 검증 범위는 이번 정정에서 AWS 공식 문서로 대조했다. Negative 결과만으로 최소 권한을 확정할 수 없다는 것은 두 근거에서 내린 판단이다. 실제 S3 호출은 하지 않았고, 교차 계정에서 권한 정책·신뢰 정책·리소스 정책이 함께 평가되는 경우와 Permissions Boundary·SCP가 겹쳤을 때의 순서도 확인하지 못했다. 이 사용자와 정책을 만들고 바꾼 호출이 로그에 어떻게 남는지는 다음 글에서 본다.
참고 자료
- IAM에서의 보안 모범 사례 — AWS IAM User Guide
- 정책 평가 논리 — 여러 정책이 함께 평가되는 순서
- IAM 정책 시뮬레이터 — Principal 모드와 Custom 모드
simulate-principal-policy명령 참조 — 최상위 ARN 템플릿·집계 결정과ResourceSpecificResults- IAM 정책 검증 — 정책 문법·ARN 형식·액션 이름 검사 범위
- Amazon S3가 IAM과 함께 동작하는 방식 — 버킷 작업과 객체 작업의 ARN 형식
- 서비스 제어 정책(SCP) — 멤버 계정 root와 관리 계정에 대한 적용 범위
- 사용자에게 역할 전달 권한 부여(PassRole) — AWS IAM User Guide
출력의 계정 ID·버킷 이름은 예시 값으로 치환했고, 화면의 리소스 ARN 열은 가렸다.