Step 149. Juice Shop 2 — 접근 제어와 IDOR
Level 2 — 보안 입문과 공격 스킬 기초 | 난이도 ★★☆☆☆ | 예상 소요 시간 2.5시간
전제: Step 148(Juice Shop 입문)을 마쳤다. 개발자 도구의 Network 탭에서 API 요청을 관찰할 수 있고, Burp Suite Repeater로 요청을 다시 보내 본 적이 있다.
- 준비물: 실행 중인 Juice Shop(
http://localhost:3000), Burp Suite(Community Edition으로 충분), 파이썬 3(로컬 재현용). - ⚠️ 이 챕터의 실습은 내 랩·합법 플랫폼 전용입니다. 허가 없는 시스템에 적용하면 범죄입니다.
- 합법 연습장 안내: OWASP Juice Shop은 OWASP 재단이 "공격 연습용"으로 공식 배포하는 합법 학습 플랫폼(취약 웹앱)이고, 오늘 원리를 재현할 Flask 서버는 여러분의 컴퓨터 안에서만 도는 로컬 랩입니다. 이 두 곳 외에는 오늘의 기술을 쓰지 않습니다.
어제 스코어보드를 찾고 숨겨진 경로를 열어 봤다면, 오늘은 본격적으로 남의 데이터를 건드려 봅니다. 내 장바구니 주소가 /rest/basket/6이라면 /rest/basket/7은 누구의 장바구니일까요? 숫자 하나만 바꿨는데 남의 물건이 보인다면, 그 서버는 "로그인했는가"만 검사하고 "이 데이터의 주인인가"는 검사하지 않은 것입니다.
이 취약점의 이름이 IDOR(안전하지 않은 직접 객체 참조, Insecure Direct Object Reference)입니다. 화려한 페이로드도, 어려운 도구도 필요 없이 주소의 숫자를 바꾸는 것만으로 성립하기에, 실제 버그바운티에서 가장 많이 제보되는 유형이기도 합니다. 오늘은 Flask로 똑같이 취약한 서버를 직접 만들어 "왜 생기는가"를 실측하고, Juice Shop에서 진짜 사례를 찾습니다.
1. 학습 목표
이 챕터를 끝내면 다음을 할 수 있습니다:
- IDOR이 생기는 조건("인증은 하되 인가를 안 한다")을 설명한다
- Flask로 취약한 주문 조회 API를 만들고 번호 바꿔치기로 남의 데이터를 읽는 실험을 재현한다
- 소유자 검사(인가)를 추가해 403으로 막는 방어 코드를 작성한다
- Juice Shop에서 Network 탭과 Burp Repeater로 API의 ID 파라미터를 찾아 조작한다
- "프론트엔드에서 숨김 ≠ 서버에서 차단"을 관리자 페이지 사례로 검증한다
2. 배경 지식 — 오늘의 도구와 개념
오늘의 도구 한눈에 보기
| 구분 | 내용 |
|---|---|
| 언어·환경 | 파이썬 3 + Flask(로컬 랩), Juice Shop + Burp Suite(워게임) |
| 오늘의 명령 | python 서버파일.py, 브라우저 개발자 도구 Network 탭, Burp Repeater의 Send |
| 필요한 개념 | 인증과 인가의 구분, IDOR, 수평 권한 상승, REST API의 경로 파라미터 |
| 오늘의 산출물 | IDOR 취약·방어 서버 실습 코드 + Juice Shop IDOR 성공 기록 1건 |
2-1. 인증과 인가 — 문지방과 방 주인
인증(authentication)은 "당신이 누구인가"를 확인하는 절차입니다. 로그인이 대표적입니다. 인가(authorization)는 "당신이 이 행동을 해도 되는가"를 확인하는 절차입니다. 호텔에 비유하면 인증은 프런트에서 카드키를 받는 것이고, 인가는 그 카드키가 307호만 열리는 것입니다.
IDOR은 인증 장치만 있고 인가 장치가 없는 호텔입니다. 카드키만 있으면 어느 방 번호를 눌러도 문이 열립니다. 코드로 보면 이런 모양입니다:
order = db.get(order_id) # 번호로 바로 꺼낸다
return jsonify(order) # 주인이 누구인지는 묻지 않는다
빠진 한 줄이 전부입니다: if order.owner != 현재_사용자: 거부.
2-2. IDOR — 숫자 하나 바꾸는 공격
웹 서비스의 데이터에는 거의 항상 번호가 붙습니다. 주문 1번, 게시글 42번, 회원 7번. REST API는 이 번호가 주소에 드러나는 경우가 많습니다 — /api/orders/1024, /rest/basket/6처럼요.
서버가 이 번호를 "검증 없이" 데이터베이스 조회 키로 쓰면, 요청자는 번호를 1씩 올리며 전 회원의 데이터를 훑을 수 있습니다. 이것이 IDOR이고, 같은 등급의 사용자끼리 남의 데이터를 보는 것이기에 수평 권한 상승(horizontal privilege escalation)이라고도 부릅니다. 일반 사용자가 관리자 기능을 쓰는 것은 수직 권한 상승으로 구분합니다.
2-3. "숨기기"와 "막기"의 차이
관리자 메뉴가 화면에 안 보인다고 해서 관리자 기능이 보호된 것일까요? 아닙니다. 프론트엔드에서 버튼을 지운 것은 숨기기이고, 서버가 요청을 거부하는 것이 막기입니다. Juice Shop의 /administration 페이지가 바로 이 사례입니다 — 일반 계정의 화면에는 링크가 없지만, 주소를 직접 치면 무슨 일이 벌어지는지는 오늘 직접 확인합니다.
3. 따라 하기
3-1. 취약한 주문 서버 만들기
Juice Shop에 들어가기 전에, 똑같이 취약한 서버를 내 컴퓨터에 만들어 원리를 실측합니다. idor_lab.py를 만드세요.
from flask import Flask, jsonify, request
app = Flask(__name__)
app.json.ensure_ascii = False # 한글이 \uXXXX로 깨지지 않게
ORDERS = {
1: {"id": 1, "owner": "alice", "item": "기계식 키보드", "price": 89000},
2: {"id": 2, "owner": "bob", "item": "27인치 모니터", "price": 320000},
}
@app.route("/order/<int:oid>")
def order(oid):
# 취약 버전: "누구냐"는 보지 않고 번호만으로 내어 준다
o = ORDERS.get(oid)
if not o:
return jsonify({"error": "not found"}), 404
return jsonify(o)
@app.route("/v2/order/<int:oid>")
def order_v2(oid):
# 방어 버전: 요청자(X-User 헤더)가 주인인지 확인한다
o = ORDERS.get(oid)
if not o:
return jsonify({"error": "not found"}), 404
user = request.headers.get("X-User", "")
if not user:
return jsonify({"error": "authentication required"}), 401
if o["owner"] != user:
return jsonify({"error": "forbidden: not your order"}), 403
return jsonify(o)
if __name__ == "__main__":
app.run(port=5491)
입력
python idor_lab.py
Running on http://127.0.0.1:5491이 뜨면 서버가 산 것입니다. 이 터미널은 서버가 쓰고 있으니, 요청은 새 터미널(또는 다른 파이썬 창)에서 보냅니다.
3-2. 번호 바꿔치기 — IDOR 실측
새 터미널에서 파이썬으로 요청을 보냅니다. 로그인 같은 것은 일절 없이, 번호만 바꿉니다.
import urllib.request
urllib.request.urlopen("http://127.0.0.1:5491/order/1").read().decode()
urllib.request.urlopen("http://127.0.0.1:5491/order/2").read().decode()
출력 (2026-09-09 실측):
'{"id":1,"item":"기계식 키보드","owner":"alice","price":89000}\n'
'{"id":2,"item":"27인치 모니터","owner":"bob","price":320000}\n'
읽는 법: 나는 로그인도 안 했는데 bob의 주문이 통째로 나왔습니다. 이것이 IDOR의 실체입니다 — 공격이라고 부르기 민망할 만큼 단순합니다. 주소의 1을 2로 바꿨을 뿐입니다.
왜: 서버의 order() 함수를 다시 보세요. oid를 받아 ORDERS에서 꺼내 그대로 돌려줍니다. "요청자가 이 주문의 주인인가"를 검사하는 코드가 어디에도 없습니다. 취약점이란 대개 이렇게 "없는 코드"입니다.
3-3. 방어 실측 — 소유자 검사 한 줄
같은 서버의 /v2/ 경로는 방어 버전입니다. 요청 헤더 X-User로 "나는 alice입니다"를 선언하게 하고(진짜 서비스라면 로그인 토큰이 이 역할을 합니다), 서버가 주인과 대조합니다.
import urllib.request, urllib.error
def get(path, user=None):
req = urllib.request.Request("http://127.0.0.1:5491" + path)
if user:
req.add_header("X-User", user)
try:
with urllib.request.urlopen(req) as r:
return r.status, r.read().decode()
except urllib.error.HTTPError as e:
return e.code, e.read().decode()
get("/v2/order/2", user="bob") # 주인 본인
get("/v2/order/2", user="alice") # 남의 주문
get("/v2/order/2") # 신원 없음
출력 (2026-09-09 실측):
(200, '{"id":2,"item":"27인치 모니터","owner":"bob","price":320000}\n')
(403, '{"error":"forbidden: not your order"}\n')
(401, '{"error":"authentication required"}\n')
읽는 법: 주인은 200으로 받고, 남은 403으로 막히고, 신원이 없으면 401로 돌려보냅니다. 추가된 것은 if o["owner"] != user 한 줄입니다. 상태 코드의 의미도 함께 외워 두세요 — 401은 "누구신지"(인증 실패), 403은 "알지만 안 됩니다"(인가 실패)입니다.
3-4. Juice Shop에서 장바구니 API 관찰
이제 진짜 연습장으로 갑니다. Juice Shop에 계정을 만들고 로그인한 뒤, 장바구니에 아무 상품이나 담고 장바구니 화면을 엽니다. 개발자 도구(F12)의 Network 탭을 켜고 새로고침하면 이런 요청이 보입니다 (화면 예시 — 플랫폼 화면은 여러분이 직접 확인):
GET /rest/basket/6 200 {"id":6,"Products":[...]}
읽는 법: /rest/basket/뒤의 숫자가 내 장바구니 번호입니다. 이 숫자는 3-2에서 바꿨던 /order/1의 1과 정확히 같은 자리입니다. 여기서 공격 가설이 자동으로 세워집니다 — "6을 5로 바꾸면?"
왜: 정찰의 핵심은 "번호가 주소에 노출된 API"를 찾는 것입니다. 장바구니, 주문 내역, 리뷰, 프로필 — 번호가 보이는 곳마다 IDOR 후보입니다.
3-5. Burp Repeater로 ID 바꿔치기
Network 탭에서 찾은 요청을 Burp로 보냅니다. 브라우저 프록시를 Burp로 돌린 상태에서 장바구니를 새로고침하고, Proxy → HTTP history에서 GET /rest/basket/6 요청을 오른쪽 클릭 → Send to Repeater합니다. Repeater에서 주소를 /rest/basket/5로 고쳐 Send (화면 예시):
HTTP/1.1 200 OK
{"id":5,"Products":[{"name":"Apple Juice", ...}]}
남의 장바구니 내용이 응답으로 오면 IDOR 성공이고, 스코어보드에 해당 챌린지가 체크됩니다. 만약 401이 뜬다면 로그인 토큰이 빠진 것입니다 — Repeater 요청의 헤더에 Authorization: Bearer (내 토큰)이 살아 있는지 확인하세요. 토큰은 Application 탭의 쿠키/스토리지에서 복사할 수 있습니다.
3-6. 관리자 페이지 — 숨김인가 차단인가
일반 계정으로 로그인한 채 주소창에 http://localhost:3000/#/administration을 직접 입력해 봅니다 (화면 예시):
403 Forbidden — "You are not authorized..."
또는 버전에 따라 페이지는 열리지만 데이터 API(/rest/user/...)가 403을 돌려줍니다. 어느 쪽이든 관전 포인트는 같습니다 — 프론트에서 메뉴를 숨긴 것과 서버가 요청을 막는 것은 별개라는 것. 진짜 보호는 항상 서버 쪽 응답(403/401)으로 확인해야 합니다.
3-7. 남의 리뷰 수정 시도
상품 페이지의 리뷰는 PUT 요청으로 수정됩니다. Network 탭에서 리뷰 작성 요청을 잡아 Repeater로 보내고, 본문의 리뷰 ID나 작성자를 다른 사람의 것으로 바꿔 Send해 봅니다. 서버가 작성자 검증 없이 덮어쓰면 또 하나의 접근 제어 챌린지가 해결됩니다 (화면 예시 — 성공 여부와 챌린지명은 직접 확인).
왜: 읽기(GET)만이 IDOR이 아닙니다. 쓰기(PUT/DELETE)에 검증이 없으면 남의 글을 지우고 고칠 수 있어 피해가 더 큽니다. 오늘 이후 번호가 붙은 모든 요청에서 "수정·삭제도 되나?"를 함께 의심하세요.
4. 미션과 연습문제
미션 — 내 랩에서 증명하고 워게임에서 찾기
- 3-1의
idor_lab.py를 완성해 실행하고, 로그인 없이/order/2가 읽히는 화면을 캡처합니다 /v2/방어 버전에서 남의 주문 접근이 403으로 막히는 것을 확인합니다- Juice Shop에서 ID 번호가 드러난 API를 Network 탭으로 2개 이상 찾아 목록을 만듭니다
- 그중 1개에서 번호 바꿔치기로 IDOR을 성공시키고, 요청·응답을 write-up에 기록합니다
- 스코어보드 누적 30개를 달성합니다 (Step 148의 15개에서 15개 추가)
연습문제
문제 1. 인증과 인가의 차이를 호텔 비유 없이 기술 용어로 한 문장씩 설명해 보세요.
문제 2. 3-2 실험에서 로그인도 없이 남의 주문이 읽힌 이유를, order() 함수에 "없는 코드" 관점에서 설명해 보세요.
문제 3. HTTP 401과 403의 차이는 무엇이며, 각각 어떤 검사가 빠졌을 때 나오나요?
문제 4. 프론트엔드에서 관리자 메뉴 버튼을 지우는 것이 왜 보안 대책이 아닌지 설명해 보세요.
5. 모범 답안과 완료 기준
미션 모범 답안
1~2번은 3-2·3-3의 실측 그대로입니다. 핵심 대조 화면:
취약: GET /order/2 → 200 {"owner":"bob", ...} ← 로그인 없이 노출
방어: GET /v2/order/2 → 403 {"error":"forbidden: not your order"}
3번의 목록 예시 (화면 예시 — 버전에 따라 경로는 다를 수 있습니다): /rest/basket/N(장바구니), /api/Feedbacks/N(리뷰), /rest/user/...(회원 정보 계열). 4번의 write-up에는 "원래 요청 / 바꾼 번호 / 응답 상태 / 응답에 들어 있던 남의 데이터 / 방어라면 무엇이 필요했는가"를 적습니다. 5번은 스코어보드(/#/score-board)에서 체크 개수를 확인합니다.
검증하는 법: ① 취약 버전에서 남은 것이 응답에 실제로 담겼는가(200 + 남의 데이터). ② 방어 버전에서 같은 요청이 403인가. ③ Juice Shop 성공 건은 스코어보드에 챌린지 체크가 떴는가.
연습문제 해답
문제 1 해답. 인증은 요청자의 신원을 확인하는 절차(로그인, 토큰 검증)이고, 인가는 확인된 신원이 해당 자원·행동에 대한 권한을 가졌는지 검사하는 절차(소유자 검사, 역할 검사)입니다.
문제 2 해답. order()에는 번호로 데이터를 꺼내는 코드만 있고, 요청자와 데이터 주인을 대조하는 코드가 없기 때문입니다. 취약점은 추가된 나쁜 코드가 아니라 빠진 검사 — "없는 코드"였습니다.
문제 3 해답. 401은 인증 실패(신원이 없거나 토큰이 유효하지 않음), 403은 인가 실패(신원은 확인됐으나 권한이 없음)입니다. 3-3 실측에서 헤더 없는 요청이 401, alice가 남의 주문에 접근한 것이 403으로 정확히 갈렸습니다.
문제 4 해답. 버튼 삭제는 화면(클라이언트)에서만 일어나는 변화라, 주소를 직접 입력하거나 Burp로 요청을 내면 우회됩니다. 보안 결정은 클라이언트가 아니라 서버에서 내려져야 하며, 서버의 403 응답만이 진짜 차단입니다.
완료 기준 체크리스트
- [ ] 인증과 인가의 차이를 한 문장씩 말할 수 있다
- [ ] IDOR을 "빠진 소유자 검사"로 설명할 수 있다
- [ ] 로컬 Flask 서버에서 번호 바꿔치기로 남의 주문을 읽었다
- [ ] 방어 버전에서 401/403이 상황에 맞게 나옴을 확인했다
- [ ] Burp Repeater로 요청의 ID를 고쳐 다시 보낼 수 있다
- [ ] "숨김 ≠ 차단"을 관리자 페이지 사례로 확인했다
- [ ] 미션: IDOR 성공 1건 write-up + 스코어보드 누적 30개
6. 흔한 실수와 해결
벽 1. 서버를 켰는데 요청이 "연결 거부"가 난다
증상 (실측 계열 메시지):
urllib.error.URLError: <urlopen error [WinError 10061] 대상 컴퓨터에서 연결을 거부했으므로 연결하지 못했습니다>
원인: 서버 프로세스가 꺼져 있거나, 켜 둔 터미널을 닫았습니다. Flask 서버는 터미널에 붙어 삽니다.
해결: 서버 터미널에서 Running on http://127.0.0.1:5491이 살아 있는지 확인하고, 요청은 반드시 다른 터미널에서 보내세요.
벽 2. 한글이 \uae30\uc2dd처럼 깨져 나온다
증상 (2026-09-09 실측):
'{"id":1,"item":"\\uae30\\uacc4\\uc2dd ..."}'
원인: Flask의 JSON 응답은 기본값으로 한글을 유니코드 이스케이프로 출력합니다. 깨진 것이 아니라 같은 글자의 다른 표기입니다.
해결: app.json.ensure_ascii = False를 추가하면 한글 그대로 나옵니다(3-1 코드에 포함). 데이터 자체는 어느 쪽이든 동일합니다.
벽 3. Repeater로 보냈더니 401 Unauthorized가 뜬다
원인: 요청에 로그인 토큰(Authorization: Bearer ... 헤더)이 빠졌습니다. Juice Shop의 대부분의 /rest/ API는 로그인이 필요합니다.
해결: 로그인 상태에서 잡은 원본 요청을 Repeater로 내면 헤더가 같이 옵니다. 헤더를 지웠다면 Application 탭에서 토큰을 다시 복사해 넣으세요.
벽 4. 번호를 바꿨는데도 내 것만 보인다
원인: 그 API는 소유자 검사가 있는 것입니다 — 방어가 된 API를 찾은 것이니 실패가 아니라 관찰입니다.
해결: 다른 번호 노출 API로 옮기세요. Juice Shop은 일부러 취약하게 만든 앱이라 검사가 빠진 경로가 반드시 있습니다. 스코어보드의 힌트(💡)가 후보를 좁혀 줍니다.
벽 5. 포트가 이미 쓰인다는 오류가 난다
증상 (실측 계열 메시지):
OSError: [WinError 10048] 각 소켓 주소(프로토콜/네트워크 주소/포트)는 하나만 사용할 수 있습니다
원인: 이전에 켠 idor_lab.py가 아직 살아 있습니다.
해결: 그 터미널에서 Ctrl+C로 끄거나, 코드의 포트를 5492처럼 바꾸세요.
7. 정리
오늘의 개념
| 개념 | 한 줄 설명 |
|---|---|
| 인증(authentication) | "누구인가"를 확인 — 로그인, 토큰 검증 |
| 인가(authorization) | "해도 되는가"를 확인 — 소유자·역할 검사 |
| IDOR | 번호 등 객체 참조를 검증 없이 써서 남의 데이터가 열리는 취약점 |
| 수평 권한 상승 | 같은 등급 사용자의 데이터를 보는 것 (관리자 탈취는 수직) |
| 401 / 403 | 인증 실패 / 인가 실패 |
| 숨김 ≠ 차단 | 프론트에서 지운 버튼은 보안이 아니다 — 서버 응답이 진짜다 |
오늘의 도구·명령
| 도구·명령 | 하는 일 |
|---|---|
python idor_lab.py |
로컬 취약 서버 실행 |
urllib.request.urlopen(...) |
파이썬으로 HTTP 요청 보내기 |
| 개발자 도구 Network 탭 | 번호가 노출된 API 찾기 |
Burp Send to Repeater → Send |
요청의 ID를 고쳐 재전송 |
Authorization: Bearer 헤더 |
로그인 토큰 유지 |
if o["owner"] != user: 403 |
IDOR 방어의 핵심 한 줄 |
명령어보다 중요한 감각
번호가 주소에 보이면, 그 번호는 조작 대상입니다. 오늘부터 API 주소의 숫자가 다르게 보일 것입니다 — /basket/6이 아니라 /basket/{여기를 바꾸면?}으로. 그리고 취약점의 본질을 기억하세요: 대부분의 접근 제어 결함은 "없는 코드", 즉 빠진 검사입니다. 공격자의 눈은 "무엇을 보낼까"가 아니라 "무엇을 검사 안 했을까"를 찾는 눈입니다. 수비로 돌아갈 때도 같은 문장이 방어가 됩니다 — 주인 확인 한 줄.
전부 체크되면 Step 149 완료입니다. 사이드바의 체크박스를 눌러 진도를 저장하세요.