방어선 0: 탐지보다 먼저, 코드에서 막기

아프로·2026년 10월 30일

방어선 0 탐지보다 먼저 코드에서 막기 표지

"IDOR을 실시간으로 탐지하려면 무엇이 필요한가요?"라는 질문에 가장 먼저 해야 할 답은 "탐지하기 전에 막고 있나요?"입니다. 주문 조회에 소유권 검사가 없다면, 탐지가 몇 초 만에 울려도 그 몇 초 동안 남의 주문이 나갑니다. 3부 실험에서 소유권 검사를 뺀 앱에 열거 공격 네 가지를 흘렸더니 남의 주문 657건이 응답으로 나갔습니다. 검사를 넣은 앱에서는 0건이었습니다.

1. 객체마다 인가 검사

IDOR(BOLA)의 해결은 하나입니다. 클라이언트가 보낸 ID로 레코드를 꺼내는 모든 곳에서, 지금 사용자가 그 레코드에 접근할 수 있는지 확인합니다. OWASP API Security Top 10의 BOLA 항목도 사용자 정책과 계층에 기반한 인가 장치를 두고, 클라이언트 입력으로 레코드에 접근하는 모든 함수에서 쓰라고 권합니다.

가장 빠지기 어려운 방법은 조회 자체에 주인 조건을 넣는 것입니다.

# 나쁜 예: 꺼낸 뒤에 확인 (확인을 빠뜨리기 쉽다)
order = db.get(Order, order_id)

# 좋은 예: 주인 조건이 조회에 들어 있다
order = db.scalar(
    select(Order).where(Order.id == order_id, Order.owner_id == user.id)
)
if order is None:
    raise HTTPException(status_code=404)   # "없음"과 "남의 것"을 같은 응답으로

두 가지가 중요합니다.

  • "남의 것"과 "없음"을 같은 404로 답합니다. 403과 404를 나눠 답하면 공격자는 응답만 보고 "이 번호는 존재한다"를 알게 됩니다.
  • 응답은 같아도 내부 기록은 다르게 남깁니다. 남의 것이라 거절했다면 authz_fail, 정말 없었다면 object_not_found. 다음 편에서 이 둘이 가장 정확한 탐지 신호가 됩니다.

관리자 기능, 다른 테넌트, 공유 링크처럼 규칙이 복잡하면 인가를 한곳(정책 함수나 정책 엔진)에 모으고, 핸들러는 그 함수만 부르게 합니다. "한곳"이 있어야 다음 편의 보안 이벤트도 한곳에서 낼 수 있습니다.

2. 정해진 모양만 통과시키는 스키마 검증

퍼징은 앱이 예상하지 못한 입력에 어떻게 반응하는지 봅니다. 그러니 예상한 입력만 받으면 됩니다. OpenAPI 스키마나 Pydantic 모델로 파라미터의 이름, 형식, 범위, 길이를 정합니다.

from pydantic import BaseModel, ConfigDict, Field
from typing import Literal

class ProductQuery(BaseModel):
    model_config = ConfigDict(extra="forbid")      # 처음 보는 파라미터 이름은 거절
    q: str | None = Field(default=None, max_length=50)
    page: int = Field(default=1, ge=1, le=10000)
    size: int = Field(default=20, ge=1, le=100)
    sort: Literal["price", "new", "popular"] = "price"

FastAPI는 쿼리 파라미터 중 모르는 이름을 기본적으로 조용히 무시합니다. 무시하면 막히기는 하지만 신호가 남지 않습니다. 실험실 앱은 모르는 이름이면 400, 형식이 틀리면 422로 답하고 둘 다 input_validation_fail 이벤트를 남깁니다.

조심할 점이 하나 있습니다. 오래된 앱 버전은 지금 스키마에 없는 파라미터(예: 광고 추적용 이름)를 붙여 보낼 수 있습니다. 거절할지, 허용 목록에 넣을지, 무시하고 기록만 할지 정해 두세요. 실험실의 정상 트래픽에도 이런 요청이 6건 있습니다.

3. 추측하기 어려운 ID

주문 번호가 1씩 늘어나면 열거는 덧셈 한 번입니다. 외부에 보이는 ID를 추측하기 어려운 값으로 바꾸면 열거 자체가 힘들어집니다. OWASP BOLA 항목도 레코드 ID로 무작위 GUID를 쓰는 것을 권합니다.

  • UUIDv4: 거의 전부 무작위입니다.
  • UUIDv7: 앞부분이 생성 시각이라 정렬과 색인에 유리합니다. 뒤쪽은 무작위라 열거는 어렵지만, 생성 시각이 드러난다는 점은 알고 쓰세요.
  • 내부 자동 증가 키는 그대로 두고, 외부에는 별도의 불투명 ID를 내보내는 방법도 있습니다.

다만 추측하기 어려운 ID는 인가 검사를 대신하지 않습니다. ID는 링크 공유, 로그, 브라우저 기록, 다른 API 응답으로 새어 나갑니다. 순서는 언제나 인가 검사가 먼저, ID는 보탬입니다.

4. 로그인 보호

로그인은 실제 사고가 가장 많이 나는 곳입니다. 코드에서 할 수 있는 기본기는 이렇습니다.

  • 계정 단위 실패 제한. NIST SP 800-63B-4(2025년 7월 최종판)는 한 인증 수단의 연속 실패를 100회 이하로 제한하라고 요구합니다(SHALL). 100은 상한이고 더 낮게 잡아도 됩니다. 실패가 쌓일수록 대기 시간을 늘리는 방법, 인증 전에 봇 확인을 거치는 방법도 선택지로 듭니다.
  • IP 단위 제한은 보조로. 크리덴셜 스터핑은 계정 하나에 한두 번만 시도하고 IP를 바꿉니다. 계정 단위와 IP 단위를 함께 봐야 합니다(8편).
  • 유출 비밀번호 검사. 가입과 비밀번호 변경 때 알려진 유출 목록과 대조합니다.
  • MFA. 비밀번호가 맞아도 끝나지 않게 합니다.

로그인 공격의 실시간 탐지는 이 시리즈 실험실에서 따로 재지 않았습니다. 계정별 실패율 윈도우는 4편의 슬라이딩 윈도우와 같은 구조로 만들 수 있습니다.

막는 코드가 신호를 만든다

객체 인가, 스키마 검증, 불투명 ID, 로그인 보호가 각각 어떤 보안 이벤트를 남기는지 정리한 그림

방어선 0을 제대로 하면 공격은 대부분 실패합니다. 그리고 실패한 흔적이 남습니다.

예방공격이 부딪히면 남는 것
객체마다 인가 검사authz_fail(남의 객체), object_not_found(없는 객체)
스키마 검증input_validation_fail(모르는 이름, 틀린 값)
로그인 제한authn_login_fail, 계정 잠금

이 이름들은 OWASP Logging Vocabulary의 이름을 따랐습니다. 다음 편에서 이 이벤트를 어떻게 남기는지, 그 다음 편에서 어떻게 세는지 봅니다. 예방이 없으면 이런 신호도 없습니다. 13편 실험에서 소유권 검사를 뺀 앱은 인가 거부 이벤트가 0건이라, 거부 기반 규칙이 아무것도 잡지 못했습니다.

정리

  • IDOR의 해결은 탐지가 아니라 모든 객체 접근의 인가 검사입니다. 조회에 주인 조건을 넣고, 남의 것과 없는 것을 같은 404로 답하세요.
  • 스키마 검증으로 정해진 모양만 받고, 모르는 파라미터는 조용히 무시하지 말고 기록하세요.
  • 추측하기 어려운 ID는 열거를 어렵게 하지만 인가를 대신하지 않습니다.
  • 막는 코드가 곧 가장 정확한 탐지 신호의 출처입니다.

출처와 확인 날짜

모든 링크는 2026-10-30에 확인했습니다.


이전 글 공격은 어디서 보이나: IDOR·열거·퍼징·로그인 공격·봇
다음 글 방어선 1-①: 인가 거부를 보안 이벤트로 남기기
시리즈 전체 목록 해킹 시도를 몇 초 안에 잡는 서버 전체 목록

profile
고인물 개발자(OG)

0개의 댓글