
"IDOR을 실시간으로 탐지하려면 무엇이 필요한가요?"라는 질문에 가장 먼저 해야 할 답은 "탐지하기 전에 막고 있나요?"입니다. 주문 조회에 소유권 검사가 없다면, 탐지가 몇 초 만에 울려도 그 몇 초 동안 남의 주문이 나갑니다. 3부 실험에서 소유권 검사를 뺀 앱에 열거 공격 네 가지를 흘렸더니 남의 주문 657건이 응답으로 나갔습니다. 검사를 넣은 앱에서는 0건이었습니다.
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) # "없음"과 "남의 것"을 같은 응답으로
두 가지가 중요합니다.
authz_fail, 정말 없었다면 object_not_found. 다음 편에서 이 둘이 가장 정확한 탐지 신호가 됩니다.관리자 기능, 다른 테넌트, 공유 링크처럼 규칙이 복잡하면 인가를 한곳(정책 함수나 정책 엔진)에 모으고, 핸들러는 그 함수만 부르게 합니다. "한곳"이 있어야 다음 편의 보안 이벤트도 한곳에서 낼 수 있습니다.
퍼징은 앱이 예상하지 못한 입력에 어떻게 반응하는지 봅니다. 그러니 예상한 입력만 받으면 됩니다. 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건 있습니다.
주문 번호가 1씩 늘어나면 열거는 덧셈 한 번입니다. 외부에 보이는 ID를 추측하기 어려운 값으로 바꾸면 열거 자체가 힘들어집니다. OWASP BOLA 항목도 레코드 ID로 무작위 GUID를 쓰는 것을 권합니다.
다만 추측하기 어려운 ID는 인가 검사를 대신하지 않습니다. ID는 링크 공유, 로그, 브라우저 기록, 다른 API 응답으로 새어 나갑니다. 순서는 언제나 인가 검사가 먼저, ID는 보탬입니다.
로그인은 실제 사고가 가장 많이 나는 곳입니다. 코드에서 할 수 있는 기본기는 이렇습니다.
로그인 공격의 실시간 탐지는 이 시리즈 실험실에서 따로 재지 않았습니다. 계정별 실패율 윈도우는 4편의 슬라이딩 윈도우와 같은 구조로 만들 수 있습니다.

방어선 0을 제대로 하면 공격은 대부분 실패합니다. 그리고 실패한 흔적이 남습니다.
| 예방 | 공격이 부딪히면 남는 것 |
|---|---|
| 객체마다 인가 검사 | authz_fail(남의 객체), object_not_found(없는 객체) |
| 스키마 검증 | input_validation_fail(모르는 이름, 틀린 값) |
| 로그인 제한 | authn_login_fail, 계정 잠금 |
이 이름들은 OWASP Logging Vocabulary의 이름을 따랐습니다. 다음 편에서 이 이벤트를 어떻게 남기는지, 그 다음 편에서 어떻게 세는지 봅니다. 예방이 없으면 이런 신호도 없습니다. 13편 실험에서 소유권 검사를 뺀 앱은 인가 거부 이벤트가 0건이라, 거부 기반 규칙이 아무것도 잡지 못했습니다.
모든 링크는 2026-10-30에 확인했습니다.
lab/results/run-vuln-shadow/leak.json, run-secure-enforce/leak.json(2026-10-30 실행, 13편)이전 글 공격은 어디서 보이나: IDOR·열거·퍼징·로그인 공격·봇
다음 글 방어선 1-①: 인가 거부를 보안 이벤트로 남기기
시리즈 전체 목록 해킹 시도를 몇 초 안에 잡는 서버 전체 목록