개념
왜 접근 제어는 자동으로 못 찾는가
모듈 16(레슨 5)에서 손상된 접근 제어(Broken Access Control)를 봤고, 레슨 14에서 세션 관리 실습을 했다. 이 레슨은 그 내용을 테스트 설계 문제 로 다시 다룬다.
DAST 스캐너는 /orders/124에 접근해서 200이 돌아오는 것을 본다. 하지만 그게 취약점인지 아닌지는 124번 주문이 지금 로그인한 계정의 것인지 에 달려 있고, 스캐너는 그걸 모른다.
SAST도 마찬가지다. 코드에 findById(orderId)가 있는 것은 보이지만 그 앞에 권한 검사가 있어야 하는지는 도메인 지식이다.
즉 접근 제어 결함은 "기대 결과를 아는 사람만 판정할 수 있는 결함" 이다. 그리고 기대 결과를 아는 사람은 QA다. 이 영역에서 QA가 보안 전문가보다 유리한 경우가 실제로 많다.
인증과 인가를 먼저 분리한다
용어를 섞으면 케이스가 섞인다.
| 인증(Authentication) | 인가(Authorization) | |
|---|---|---|
| 묻는 것 | 당신은 누구인가 | 당신은 이것을 해도 되는가 |
| 실패 응답 | 401 Unauthorized | 403 Forbidden |
| 대표 결함 | 토큰 검증 누락, 세션 고정, 무제한 시도 | IDOR, 수직/수평 권한 상승, 기능 노출 |
| 확인 지점 | 로그인·토큰 발급·갱신·로그아웃 | 모든 요청의 리소스 접근 시점 |
인증은 한 번 일어나고 인가는 매 요청마다 일어난다. 그래서 인증 케이스는 수가 적고 인가 케이스는 폭발한다. 테스트 설계의 요령은 인가 쪽에 있다.
인가 결함의 세 유형
같은 종류의 객체를 다른 주인의 것으로?
│
┌─────── YES ────────┐
│ │
수평 권한 상승 NO → 더 높은 역할의 기능을?
(IDOR 포함) │
┌────── YES ─────┐
│ │
수직 권한 상승 객체의 특정 속성만?
(BFLA) (속성 수준 인가)| 유형 | 정의 | 전형적 예 |
|---|---|---|
| 수평 권한 상승 | 같은 역할의 다른 사용자 자원에 접근 | A 회원이 B 회원의 주문·영수증·프로필 조회 |
| 수직 권한 상승 | 더 높은 역할의 기능 실행 | 일반 회원이 /admin/users/{id}/role 호출 성공 |
| 속성 수준 인가 누락 | 객체는 내 것이지만 수정하면 안 되는 필드를 수정 | 내 프로필 PATCH에 "role":"ADMIN", "point":999999 포함 |
IDOR(Insecure Direct Object Reference) 은 수평 권한 상승이 일어나는 방식 을 가리킨다. 식별자를 직접 노출하고 그 식별자로 바로 조회하면서 소유권 검사를 빠뜨린 형태다. 식별자를 UUID로 바꾸는 것은 완화일 뿐 수정이 아니다 — 추측이 어려워질 뿐 소유권 검사는 여전히 없기 때문이다. 이 구분을 못 하면 "UUID로 바꿨으니 조치 완료"라는 티켓을 승인하게 된다.
테스트 설계의 뼈대 — 3축 매트릭스
인가 테스트는 조합 문제다. 세 축을 정하면 케이스가 기계적으로 생성된다.
축 1. 주체(계정) : 비로그인 / 일반A / 일반B / 매니저 / 관리자 / 탈퇴A / 정지A
축 2. 객체(리소스) : A소유 / B소유 / 없는ID / 삭제된ID / 다른테넌트소유
축 3. 동작(엔드포인트): 조회 / 목록 / 생성 / 수정 / 삭제 / 내보내기 / 상태변경전수 조합은 너무 크다. 실무에서는 "기대 결과가 바뀌는 지점"만 뽑는다 — 모듈 3에서 배운 동등 분할과 같은 논리다. 결과는 이런 표가 된다.
| # | 주체 | 객체 | 동작 | 기대 응답 | 기대 본문 | 판정 근거 |
|---|---|---|---|---|---|---|
| AZ-01 | 일반A | A소유 | 조회 | 200 | A의 데이터 | 정상 기준선 |
| AZ-02 | 일반A | B소유 | 조회 | 404 | 없음 | 존재 여부까지 감춘다 |
| AZ-03 | 비로그인 | A소유 | 조회 | 401 | 없음 | 인증 먼저 |
| AZ-04 | 일반A | B소유 | 수정 | 404 | 변경 없음 | 수정 후 B 데이터 원본 확인 필수 |
| AZ-05 | 일반A | 없는ID | 조회 | 404 | 없음 | AZ-02와 응답이 같아야 한다 |
| AZ-06 | 탈퇴A | A소유 | 조회 | 401 | 없음 | 토큰 즉시 무효화 |
| AZ-07 | 매니저 | 다른테넌트 | 목록 | 200 | 0건 | 빈 목록, 403 아님(정보 노출) |
| AZ-08 | 일반A | — | 역할변경 | 403 | 변경 없음 | 수직 상승 차단 |
403과 404 — 무엇을 기대 결과로 쓸 것인가
여기서 팀마다 논쟁이 생긴다. 남의 리소스에 접근하면 403을 줘야 하나 404를 줘야 하나.
403을 주면 → "그 ID는 존재한다"는 정보가 새어 나간다 (열거 공격의 재료)
404를 주면 → 존재 여부가 감춰진다. 다만 개발자 디버깅이 불편하다정답은 하나가 아니다. 중요한 것은 팀이 규칙을 정하고, 그 규칙이 모든 엔드포인트에서 일관되게 지켜지는지 테스트하는 것 이다. 가장 나쁜 상태는 엔드포인트마다 다른 경우다. 어떤 API는 403, 어떤 API는 404, 어떤 API는 500을 준다면 그 자체로 정보가 새어 나간다. 그래서 AZ-02와 AZ-05를 나란히 두고 응답이 서로 같은지 를 본다. 응답 시간도 함께 본다. 존재하는 ID일 때만 20ms 더 걸린다면 그것도 열거 단서다.
응답 코드만 보면 틀린다
인가 테스트에서 가장 흔한 오판이 여기서 나온다.
❌ 403이 왔으니 안전하다
→ 실제로는 DELETE가 수행된 뒤 응답만 403인 경우가 있다 (트랜잭션 순서 문제)
❌ 200이 왔으니 취약하다
→ 응답 본문이 빈 배열이거나 마스킹된 데이터일 수 있다
✅ 판정 기준 세 줄
(1) 상태 코드
(2) 응답 본문에 타인의 식별 가능한 데이터가 실제로 있는가
(3) 쓰기 동작이면 요청 후 원본이 바뀌었는가 (DB 또는 다른 계정으로 재조회)세 번째 줄이 QA가 보안 담당자보다 잘하는 부분이다. 모듈 43의 리뷰 체크리스트에서 "화면 밖 확인(API·DB·로그)"을 강조했던 그 습관이 여기서 그대로 쓰인다.
인증 쪽 — 세션과 토큰의 생명주기
인증 케이스는 수가 적으니 전부 나열할 수 있다. 세션/토큰이 언제 생기고, 언제 바뀌고, 언제 죽는가 를 축으로 삼는다.
| 시점 | 기대 동작 | 확인 절차 |
|---|---|---|
| 로그인 성공 | 세션 식별자 재발급 | 로그인 전 쿠키와 후 쿠키 값이 달라야 한다(세션 고정 방지) |
| 로그인 실패 반복 | 지연·잠금·추가 인증 | 같은 계정 N회, 같은 IP 다수 계정(크리덴셜 스터핑) 둘 다 확인 |
| 권한 변경 | 기존 토큰의 권한도 갱신 또는 무효 | 관리자 권한 회수 직후 예전 토큰으로 관리자 API 호출 |
| 비밀번호 변경 | 다른 기기의 세션 종료 | 기기 A에서 변경 → 기기 B 세션이 죽는지 |
| 로그아웃 | 서버 측 무효화 | 로그아웃 후 같은 토큰 재사용 → 401 |
| 만료 | exp 이후 거부 | 시계를 당기거나 만료 토큰 보관 후 재사용 |
| 갱신(refresh) | 1회용, 재사용 탐지 | 같은 refresh 토큰 2회 사용 → 전체 세션 무효화되는가 |
마지막 행이 자주 빠진다. refresh 토큰이 재사용 탐지 없이 계속 통하면, 한 번 유출된 토큰으로 무한히 세션을 이어 갈 수 있다.
토큰 자체에 대한 검증
JWT를 쓰는 서비스라면 다음 항목이 케이스가 된다. 여기서 우리는 토큰을 깨뜨리는 법 이 아니라 서버가 거부하는지 확인하는 법 을 다룬다.
TK-01 서명을 한 글자 바꾼 토큰 → 401 이어야 한다
TK-02 alg 헤더를 none 으로 바꾼 토큰 → 401 (서명 없이 통과되면 심각)
TK-03 다른 환경(스테이지)에서 발급된 토큰 → 401 (iss/aud 검증)
TK-04 exp 를 과거로 둔 토큰 → 401
TK-05 payload 의 userId 만 남의 것으로 → 401 (서명 불일치로 거부)
TK-06 유효한 토큰의 스코프 밖 API 호출 → 403
TK-07 토큰을 URL 쿼리에 넣어 호출 → 거부 또는 최소한 로그에 남지 않아야 한다TK-07은 놓치기 쉬운데, 토큰이 쿼리스트링에 들어가면 웹서버 액세스 로그·프록시 로그·리퍼러 헤더에 그대로 남는다. 이건 취약점 스캐너가 잡아주지 않는다.
판정 — 이것은 실제 위험인가
케이스가 실패했다고 다 같은 무게가 아니다. 세 가지를 묻는다.
| 질문 | 낮춤 요인 | 높임 요인 |
|---|---|---|
| 전제 조건이 무엇인가 | 내부망 접근 필요, 관리자 계정 필요 | 로그인만 하면 됨, 비로그인도 가능 |
| 무엇이 드러나거나 바뀌는가 | 비식별 통계값 | 개인정보, 금액, 권한 |
| 규모는 어떤가 | 1건씩만, 속도 제한 있음 | 순번 ID로 전량 수집 가능 |
세 번째가 등급을 가장 크게 움직인다. 한 건 조회되는 IDOR과 순번 ID로 42만 건을 긁을 수 있는 IDOR은 같은 결함이 아니다. 보고서에는 "회원 ID가 1부터 순번이며 1,000건 샘플 조회에 모두 성공, 전량 수집에 이론상 약 7시간 소요"처럼 규모를 실측해서 적는다. 이 한 줄이 우선순위를 바꾼다.
실무에서 왜 필요한가
접근 제어 결함은 국내에서 실제 개인정보 유출 사고의 단골 원인이다. 그리고 사고 후 원인 분석을 보면 "특정 화면은 권한 검사가 있는데 그 화면이 호출하는 내보내기 API에는 없었다" 같은 부분 누락 이 대부분이다. 기능 테스트는 화면을 따라가기 때문에 화면이 호출하지 않는 경로를 보지 않는다. 그래서 인가 테스트는 화면이 아니라 엔드포인트 목록 에서 출발해야 한다.
또 하나. 권한 체계는 시간이 지나며 반드시 복잡해진다. 역할이 3개에서 9개가 되고, 조직·테넌트·위임 권한이 붙는다. 그때마다 수동으로 전수 확인하는 것은 불가능하므로, 3축 매트릭스를 자동화된 회귀 스위트 로 만들어 두는 것이 유일한 현실적 해법이다. 계정별 토큰을 픽스처로 두고 같은 요청을 역할 수만큼 반복하는 구조면 충분하다.
# 인가 회귀의 최소 골격 — 실제 기대값은 팀 규칙에 맞춰 조정한다
import itertools
import requests
BASE = "https://staging.example.com"
TOKENS = {"anon": None, "userA": "...", "userB": "...", "admin": "..."}
# (메서드, 경로템플릿, 소유자) → 주체별 기대 상태코드
CASES = [
("GET", "/api/orders/{id}", "userA", {"anon": 401, "userA": 200, "userB": 404, "admin": 200}),
("PATCH", "/api/orders/{id}", "userA", {"anon": 401, "userA": 200, "userB": 404, "admin": 200}),
("GET", "/api/orders/export", None, {"anon": 401, "userA": 200, "userB": 200, "admin": 200}),
("POST", "/api/admin/users/{id}/role", None, {"anon": 401, "userA": 403, "userB": 403, "admin": 200}),
]
def call(method, path, token):
headers = {"Authorization": f"Bearer {token}"} if token else {}
return requests.request(method, BASE + path, headers=headers, timeout=10)
def test_authorization_matrix(resource_ids):
failures = []
for method, tmpl, owner, expected in CASES:
path = tmpl.format(id=resource_ids.get(owner, "1"))
for subject, want in expected.items():
got = call(method, path, TOKENS[subject]).status_code
if got != want:
failures.append(f"{method} {path} as {subject}: expected {want}, got {got}")
assert not failures, "\n".join(failures)이 골격의 가치는 새 엔드포인트가 생길 때마다 CASES에 한 줄 추가하는 습관 을 만든다는 데 있다. 줄이 추가되지 않은 엔드포인트는 인가 테스트가 없다는 뜻이고, 그것 자체가 리뷰 지적 사항이 된다. 다만 상태 코드만 보는 것은 앞에서 말한 대로 부족하므로, 쓰기 동작은 호출 후 소유자 계정으로 재조회해 원본이 그대로인지 확인하는 단계를 더한다.