본문으로 건너뛰기
고급약 40분

인증·인가 우회 시나리오 — 권한 상승·IDOR·세션 탈취를 테스트 케이스로 설계하기

손상된 접근 제어는 매번 취약점 목록의 맨 위에 올라오지만, 스캐너로는 거의 잡히지 않는다. 우리 서비스의 권한 규칙을 도구가 모르기 때문이다. 계정·역할·객체 매트릭스로 테스트 조합을 만들고, 수직·수평 권한 상승과 IDOR을 구분해 케이스화하며, 세션 생명주기와 토큰 검증을 절차로 확인하고, 응답 코드만으로 판정하지 않는 기준을 세운다.

  • #인가
  • #권한상승
  • #IDOR
  • #세션관리
  • #접근제어
  • #테스트설계

개념

왜 접근 제어는 자동으로 못 찾는가

모듈 16(레슨 5)에서 손상된 접근 제어(Broken Access Control)를 봤고, 레슨 14에서 세션 관리 실습을 했다. 이 레슨은 그 내용을 테스트 설계 문제 로 다시 다룬다.

DAST 스캐너는 /orders/124에 접근해서 200이 돌아오는 것을 본다. 하지만 그게 취약점인지 아닌지는 124번 주문이 지금 로그인한 계정의 것인지 에 달려 있고, 스캐너는 그걸 모른다. SAST도 마찬가지다. 코드에 findById(orderId)가 있는 것은 보이지만 그 앞에 권한 검사가 있어야 하는지는 도메인 지식이다.

즉 접근 제어 결함은 "기대 결과를 아는 사람만 판정할 수 있는 결함" 이다. 그리고 기대 결과를 아는 사람은 QA다. 이 영역에서 QA가 보안 전문가보다 유리한 경우가 실제로 많다.

인증과 인가를 먼저 분리한다

용어를 섞으면 케이스가 섞인다.

인증(Authentication)인가(Authorization)
묻는 것당신은 누구인가당신은 이것을 해도 되는가
실패 응답401 Unauthorized403 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일반AA소유조회200A의 데이터정상 기준선
AZ-02일반AB소유조회404없음존재 여부까지 감춘다
AZ-03비로그인A소유조회401없음인증 먼저
AZ-04일반AB소유수정404변경 없음수정 후 B 데이터 원본 확인 필수
AZ-05일반A없는ID조회404없음AZ-02와 응답이 같아야 한다
AZ-06탈퇴AA소유조회401없음토큰 즉시 무효화
AZ-07매니저다른테넌트목록2000건빈 목록, 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에 한 줄 추가하는 습관 을 만든다는 데 있다. 줄이 추가되지 않은 엔드포인트는 인가 테스트가 없다는 뜻이고, 그것 자체가 리뷰 지적 사항이 된다. 다만 상태 코드만 보는 것은 앞에서 말한 대로 부족하므로, 쓰기 동작은 호출 후 소유자 계정으로 재조회해 원본이 그대로인지 확인하는 단계를 더한다.

실습 과제

과제 1 — 엔드포인트 인벤토리와 3축 매트릭스 (25분)

담당 서비스의 API 명세(OpenAPI 문서, 없으면 개발자 도구 네트워크 탭 기록)에서 엔드포인트 12개를 뽑는다. 화면에서 직접 호출하지 않는 것 — 내보내기, 일괄 처리, 웹훅, 내부 관리 API — 을 최소 3개 포함시킨다. 각 엔드포인트에 대해 주체 4종(비로그인·소유자·타인·관리자)의 기대 응답을 채운다. 채우지 못하는 칸 이 나오면 그것이 곧 스펙의 공백이다. 목록으로 정리해 기획에 질문한다.

과제 2 — IDOR 케이스 완성하기 (20분)

과제 1에서 식별자를 경로나 파라미터로 받는 엔드포인트 하나를 골라 아래 6개 케이스를 완성한다. 각 케이스의 판정 기준을 상태 코드·응답 본문·부작용 세 줄로 적는다.

ID-01 타인 소유 ID로 조회
ID-02 존재하지 않는 ID로 조회        (ID-01과 응답이 동일해야 한다)
ID-03 삭제된 리소스 ID로 조회
ID-04 타인 소유 ID로 수정 (수정 후 원본 재확인 포함)
ID-05 타인 소유 ID로 삭제 (삭제 후 원본 재확인 포함)
ID-06 목록 API에 타인 소유 ID를 필터로 전달

과제 3 — 세션 생명주기 점검표 (20분)

본문의 생명주기 표 7행을 우리 서비스 기준으로 다시 쓴다. 각 행에 현재 구현이 어떻게 되어 있는지 확인한 근거 (코드, 설정, 직접 확인)를 적고, 확인하지 못한 행은 "미확인"으로 남긴다. 미확인이 3개 이상이면 그것만으로 다음 스프린트 보안 작업 목록이 만들어진다.

자가 체크리스트

  • 인증과 인가를 구분하고 각각의 실패 응답 코드를 말할 수 있다
  • 수평·수직 권한 상승과 속성 수준 인가 누락을 예로 구분할 수 있다
  • IDOR에서 식별자를 UUID로 바꾸는 것이 근본 수정이 아닌 이유를 설명할 수 있다
  • 주체·객체·동작 3축으로 인가 테스트 매트릭스를 만들 수 있다
  • 403과 404 중 무엇을 기대 결과로 삼을지 팀 규칙을 정하고 일관성을 테스트할 수 있다
  • 상태 코드만이 아니라 응답 본문과 부작용까지 판정 기준에 넣는다
  • 세션·토큰의 생명주기 7시점을 점검 케이스로 옮길 수 있다
  • refresh 토큰 재사용 탐지, 권한 변경 시 기존 토큰 처리 같은 놓치기 쉬운 항목을 안다
  • 발견한 인가 결함의 전제 조건·영향·규모를 실측해 심각도를 방어할 수 있다

흔한 실수

  • 화면에서만 인가를 확인한다. 버튼이 안 보이는 것은 통제가 아니다. 화면이 호출하지 않는 엔드포인트에 구멍이 남는다.
  • 상태 코드만 보고 판정한다. 403인데 실제로 삭제된 경우, 200인데 빈 응답인 경우가 모두 있다. 본문과 부작용을 함께 본다.
  • UUID 도입을 IDOR 수정으로 승인한다. 추측 난이도만 올라갔을 뿐 소유권 검사는 여전히 없다. 다른 경로로 ID가 새면 그대로 뚫린다.
  • 관리자 계정으로만 테스트한다. 가장 흔한 실수다. 인가 결함은 정의상 권한이 낮은 계정에서만 드러난다. 최소 권한 계정이 기본 테스트 계정이어야 한다.
  • 엔드포인트별로 403/404 규칙이 다른 것을 방치한다. 응답 차이 자체가 리소스 존재를 알려주는 정보 노출이다.
  • 한 건 확인하고 심각도를 정한다. 규모를 재지 않으면 "이론상 가능"과 "전량 수집 가능"이 같은 등급으로 처리된다.

참고 자료

낯선 말 풀이

이 레슨에 나온 약어입니다. 본문에서도 처음 나올 때 점선으로 표시해 두었습니다 (제목과 링크 글자는 그대로 둡니다).

DAST — 동적 보안 분석
실제로 돌아가는 서비스에 요청을 보내 보며 취약점을 찾는 검사.
SAST — 정적 보안 분석
프로그램을 돌리지 않고 코드 자체를 읽어 위험한 부분을 찾는 검사.
QA — 품질보증
만든 것이 쓸 만한지 확인하고, 애초에 덜 망가지게 만드는 방법을 정하는 일. "테스트"는 그 일의 일부다.
ID — 식별자
무엇 하나를 다른 것과 구별하기 위해 붙인 이름이나 번호.
테넌트 — 입주자
한 시스템을 여러 고객사가 나눠 쓸 때, 그 고객사 하나하나를 가리키는 말. 건물의 입주자에 해당한다.
API — 응용 프로그램 인터페이스
프로그램끼리 주고받는 창구. 사람이 화면 버튼을 누르는 대신, 프로그램이 정해진 주소로 요청을 보내 답을 받는다.
DB — 데이터베이스
데이터를 넣어 두고 꺼내 쓰는 저장소. 보통 표 여러 개가 서로 연결된 형태다.
IP — 아이피 주소
인터넷에 연결된 기기마다 붙는 번호.
OpenAPI — 오픈에이피아이 명세
창구가 어떤 주소로 무엇을 받고 무엇을 돌려주는지 정해진 형식으로 적어 둔 문서.
OWASP — 오왁스프
웹과 앱 보안 자료를 공개로 만드는 비영리 단체. 취약점 목록과 점검 기준이 유명하다.
XSS — 크로스 사이트 스크립팅
입력창에 넣은 글자가 화면에서 명령으로 실행되게 만드는 공격.
CSRF — 크로스 사이트 요청 위조
로그인한 사람의 권한을 몰래 빌려 원치 않는 요청을 보내게 만드는 공격.

용어집 전체 보기