본문 바로가기

파이썬으로 만드는 보안 도구 실습

3-way handshake를 배울 때 "SYN 스캔"과 "TCP Connect 스캔"을 잠깐 언급했습니다. 이번 장에서는 그 두 기법의 차이를 코드로 직접 재현합니다. 스캔 결과는 CSV와 JSON으로 남겨, 다음 단계(배너 그래빙, CVE 매칭, 서브도메인 열거)의 입력으로 이어지도록 설계합니다.

여기에 나오는 모든 도구는 굳이 소켓이 아니라 requests 모듈과 random 모듈 정도만 있어도 가능한 공격입니다. 코드를 어렵게 가져가는 것만큼 효용이 있지 않다면 다시 한 번 고민을 해볼 필요가 있습니다.

1. 스캐너는 무엇을 알아내는가

포트 스캐너는 대상 호스트의 어느 포트가 열려 있는지를 판별하는 도구입니다. 02-1의 1.5에서 말했듯 열린 포트 목록은 그대로 공격 표면(attack surface)이 됩니다. 포트 번호와 그 뒤에 돌고 있을 서비스를 매핑해보면 다음 단계가 자연스럽게 그려집니다.

포트흔한 서비스다음 단계 점검 포인트
22/TCPSSH약한 자격증명, 키 기반 인증 여부, 구버전 OpenSSH
80·443/TCPHTTP(S)디렉터리 브루트포싱, SQLi·XSS, 헤더 누락
3306/TCPMySQL외부 바인딩 여부, 기본 계정, 슬로우 쿼리
445/TCPSMBEternalBlue 계열, 익명 공유
6379/TCPRedisNOAUTH 상태로 열려 있는지, CONFIG SET 악용

법·윤리 주의 — 타인 호스트에 허락 없이 스캐너를 돌리는 행위는 국내 정보통신망법, 미국 CFAA 등에서 명백한 위법입니다. 이 절의 실습은 본인 PC의 127.0.0.1과 nmap 제작자가 명시적 허가를 걸어둔 학습 전용 호스트 scanme.nmap.org에서만 수행합니다. 실제 운영 서비스를 목표로 돌리지 마세요. 학습 용도여도 안됩니다.

2. 3-way handshake로 본 두 가지 스캔 방식

02-1의 1.7에서 본 3-way handshake를 다시 펼쳐놓고, 공격자 관점에서 "어디까지 진행하느냐"로 두 기법을 구분해봅니다.

항목TCP Connect 스캔SYN 스캔 (하프 오픈)
완성되는 연결3-way handshake 완주SYN-ACK만 받고 RST
권한일반 사용자Raw 소켓 생성 권한 필요 (관리자/root)
구현 난이도socket.connect() 한 줄IP·TCP 헤더 직접 조립 (예: scapy)
탐지 난이도애플리케이션 로그에 남음애플리케이션 로그 회피 — 단, IDS/IPS는 쉽게 탐지
이 책의 기본값O개념만 소개

이 책은 TCP Connect 스캔으로 시작합니다. 이유는 두 가지입니다. 첫째, 파이썬 socket만으로 구현되어 OS·권한 이슈가 없습니다. 둘째, 결과 정확도는 거의 같습니다. SYN 스캔의 장점인 "스텔스"는 현대 IDS/IPS 앞에서는 과장된 면이 있고, 교육용으로는 Connect 스캔이 충분합니다.

SYN 스캔을 직접 구현하려면 Raw 소켓을 열고 IP/TCP 헤더를 바이트 단위로 조립해야 합니다. 파이썬에서는 scapy 라이브러리가 이 일을 대신 해주지만, Windows에서는 WinPcap/Npcap 드라이버까지 설치해야 하는 등 진입장벽이 꽤 있습니다.

3. 가장 단순한 스캐너 — socket 한 줄

"연결이 되면 열린 포트, 안 되면 닫힌 포트" 이 한 문장을 그대로 코드로 옮긴 것입니다.

# simple_scan.py — 가장 단순한 TCP Connect 스캐너
import socket

def check_port(host: str, port: int, timeout: float = 1.0) -> bool:
    # AF_INET = IPv4, SOCK_STREAM = TCP
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
        s.settimeout(timeout)
        try:
            s.connect((host, port))
            return True   # 3-way handshake 성공 = 열려 있음
        except (socket.timeout, ConnectionRefusedError, OSError):
            return False  # 타임아웃·거절·기타 = 닫혀 있거나 필터링

if __name__ == "__main__":
    host = "scanme.nmap.org"
    for port in [21, 22, 80, 443, 3306, 8080]:
        state = "open" if check_port(host, port) else "closed"
        print(f"{host}:{port} {state}")

실행해보면 대략 아래와 같은 출력이 나옵니다. scanme.nmap.org는 의도적으로 몇 개의 포트만 열어둔 호스트라, 열린 포트와 닫힌 포트가 뒤섞여 나옵니다.

scanme.nmap.org:21 closed
scanme.nmap.org:22 open
scanme.nmap.org:80 open
scanme.nmap.org:443 closed
scanme.nmap.org:3306 closed
scanme.nmap.org:8080 closed

3.1 왜 timeout을 꼭 줘야 하나

s.settimeout(1.0)을 빼면 어떤 일이 생길까요? 닫혀 있지 않지만 방화벽이 패킷을 조용히 버리는 포트에 걸리면, OS의 기본 TCP 재시도 정책에 따라 수십 초를 기다립니다. 포트 1만 개를 훑는 동안 이런 포트가 100개만 있어도 스캐너는 몇 시간이 지나도 끝나지 않습니다.

앞 장에서 timeout을 안 주면 스캐너가 영원히 멈춘다고 말한 이유가 바로 이것입니다. 네트워크 코드를 쓸 때는 timeout을 잊지 않는 것이 습관입니다.

3.2 closed와 filtered의 차이

위 코드는 실패를 전부 closed로 뭉뚱그립니다. 사실 TCP 스캐닝에서는 세 가지 상태를 구분합니다.

상태의미우리 코드에서 발생하는 예외
openSYN-ACK를 받음예외 없음 (connect 성공)
closedRST를 받음 (호스트는 살아있지만 해당 포트는 닫힘)ConnectionRefusedError
filtered방화벽이 조용히 드롭 (응답 없음)socket.timeout

nmap의 출력에서 보이는 filtered 상태가 바로 이 "타임아웃"에 해당합니다. 스캐너를 키울 때는 이 세 가지를 분리해서 기록해야 "방화벽이 막혀 있다"는 가치 있는 힌트를 놓치지 않습니다.

4. CLI로 다듬기 — 대상과 포트 범위 입력받기

스캐너를 재사용 가능한 도구로 만들려면, 대상 호스트와 포트 범위를 실행 시점에 받아야 합니다. 표준 라이브러리 argparse가 제격입니다.

# scanner_v2.py
import argparse
import socket
from dataclasses import dataclass

@dataclass
class Result:
    host: str
    port: int
    state: str      # "open" | "closed" | "filtered"
    error: str = ""

def probe(host: str, port: int, timeout: float) -> Result:
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
        s.settimeout(timeout)
        try:
            s.connect((host, port))
            return Result(host, port, "open")
        except ConnectionRefusedError:
            return Result(host, port, "closed")
        except socket.timeout:
            return Result(host, port, "filtered")
        except OSError as e:
            return Result(host, port, "error", str(e))

def parse_ports(spec: str) -> list[int]:
    """'22,80,443' 또는 '1-1024' 형식을 파이썬 리스트로."""
    ports: set[int] = set()
    for chunk in spec.split(","):
        chunk = chunk.strip()
        if "-" in chunk:
            start, end = map(int, chunk.split("-"))
            ports.update(range(start, end + 1))
        elif chunk:
            ports.add(int(chunk))
    return sorted(p for p in ports if 0 < p <= 65535)

def main():
    ap = argparse.ArgumentParser(description="Basecamp Security — TCP Connect Scanner")
    ap.add_argument("host", help="대상 호스트 (IP 또는 도메인)")
    ap.add_argument("-p", "--ports", default="1-1024", help="포트 (예: 22,80,443 또는 1-1024)")
    ap.add_argument("-t", "--timeout", type=float, default=1.0)
    args = ap.parse_args()

    for port in parse_ports(args.ports):
        r = probe(args.host, port, args.timeout)
        if r.state == "open":
            print(f"{r.host}:{r.port} open")

if __name__ == "__main__":
    main()

실행 방법.

python scanner_v2.py scanme.nmap.org -p 1-1024 -t 1.0

이제 "스캐너가 하나 있다"는 감각이 들기 시작합니다. 하지만 1024개 포트를 한 줄씩 순회하는 동안 한 번에 한 연결만 시도하기 때문에, 타임아웃 1초짜리 filtered 포트가 몇 개만 있어도 체감 속도가 확 느려집니다.

5. 멀티스레드로 속도 올리기

네트워크 요청은 대부분의 시간을 I/O 대기에 씁니다. CPU는 놀고 소켓은 응답을 기다리지요. 이럴 때 스레드를 여러 개 띄우면 GIL(전역 인터프리터 락)의 영향 없이 거의 선형으로 빨라집니다. 파이썬 표준 라이브러리 concurrent.futures.ThreadPoolExecutor가 이 일을 가장 깔끔하게 해줍니다. 다만 여기서부터는 고급 과정에 해당되므로 이번 장에서 실습하진 않습니다.

과도한 동시 요청은 곧 DoS입니다. 반복 10000번 설정 등은 대상 호스트에 실질적 서비스 거부를 유발할 수 있고, 이는 본인의 의도와 무관하게 공격으로 간주됩니다. 실습이 허가된 호스트라도 부하를 의식하며 돌리세요.

6. 결과 구조화 — CSV와 JSON으로 저장

앞서 말했듯 스캐너의 출력은 다음 도구의 입력입니다. 문자열을 print만 해서는 연계가 어렵습니다. dataclasses.asdict와 표준 라이브러리 csv·json을 조합해 두 가지 포맷을 동시에 저장합니다.

# exporter.py — scanner_v3.py에서 불러 씁니다.
import csv
import json
from dataclasses import asdict
from pathlib import Path

def save_csv(path: Path, results: list) -> None:
    fields = ["host", "port", "state", "error"]
    with path.open("w", newline="", encoding="utf-8-sig") as f:
        # utf-8-sig : Excel에서 한글이 깨지지 않도록 BOM 포함
        w = csv.DictWriter(f, fieldnames=fields)
        w.writeheader()
        for r in results:
            w.writerow(asdict(r))

def save_json(path: Path, results: list) -> None:
    payload = [asdict(r) for r in results]
    path.write_text(
        json.dumps(payload, ensure_ascii=False, indent=2),
        encoding="utf-8",
    )

scanner_v3.py의 main() 끝부분에 이 두 줄만 덧붙이면 됩니다.

from pathlib import Path
from exporter import save_csv, save_json

save_csv(Path(f"{args.host}.csv"), results)
save_json(Path(f"{args.host}.json"), results)

두 포맷을 모두 남기는 이유는 용도가 다르기 때문입니다.

포맷강점약점쓰는 곳
CSVExcel·Google Sheets에서 바로 보기 편함중첩 데이터 표현 못 함사람이 보는 보고서
JSON중첩 구조(배너, 헤더 사전)를 그대로 담음눈으로 훑기 불편다른 도구에 파이프로 넘길 때

CSV의 기본 인코딩을 utf-8-sig로 지정한 이유는 Excel이 BOM 없는 UTF-8을 CP949(Windows 한국어)로 오해해 한글을 깨뜨리기 때문입니다. 이 작은 디테일 하나가 "왜 엑셀에서 한글이 다 깨져 보이지?"라는 질문의 답입니다.

7. 배너 그래빙 — 서비스 정체 확인하기

열린 포트가 확인되면, 다음 단계는 그 포트 뒤에 무엇이 돌고 있는지 알아내는 배너 그래빙(banner grabbing)입니다. 02-1의 1.6에서 잠깐 언급한, CVE 매칭의 출발점이죠. 많은 서비스가 연결되자마자 자기 정체를 스스로 밝혀줍니다.

# banner.py — 열린 포트에 대해서만 시도
import socket

def grab_banner(host: str, port: int, timeout: float = 2.0) -> str:
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
        s.settimeout(timeout)
        try:
            s.connect((host, port))

            # HTTP(S)는 요청을 먼저 보내야 배너가 옴
            if port in (80, 8080, 8000):
                s.sendall(b"HEAD / HTTP/1.0\r\nHost: " + host.encode() + b"\r\n\r\n")

            data = s.recv(1024)
            return data.decode("utf-8", errors="replace").strip()
        except (socket.timeout, OSError):
            return ""

if __name__ == "__main__":
    host = "scanme.nmap.org"
    for port in [22, 80]:
        banner = grab_banner(host, port)
        print(f"--- {host}:{port} ---")
        print(banner if banner else "(응답 없음)")
        print()

실행해보면 SSH 서버는 SSH-2.0-OpenSSH_8.2p1 Ubuntu-4ubuntu0.11처럼 버전까지 그대로 뱉어냅니다. 이 문자열이 CVE 데이터베이스 검색어가 되고, 곧 취약점 매칭으로 이어집니다. 80번은 HEAD 요청을 먼저 보낸 덕에 HTTP/1.1 200 OK 상태 라인과 Server: Apache/... 헤더가 같이 돌아옵니다 — 서버가 먼저 말하는 SSH와 클라이언트가 먼저 말해야 하는 HTTP의 차이를 한 번에 확인할 수 있습니다.

프로토콜마다 "먼저 말하는 쪽"이 다릅니다.

  • 서버가 먼저 — SSH, FTP, SMTP, POP3, MySQL 등은 연결 직후 서버가 인사(배너)를 보냅니다. 그냥 recv만 하면 됩니다.
  • 클라이언트가 먼저 — HTTP(S)는 요청을 보내야 응답이 옵니다. 위 코드에서 HEAD 요청을 먼저 보낸 이유입니다.
  • 아무도 말하지 않음 — 일부 서비스는 정상적인 프로토콜 핸드셰이크를 해야 비로소 응답합니다. 이 경우 nmap의 서비스 감지 스크립트처럼 각 포트에 맞는 프로브 문자열을 보내야 합니다.

즉 스캐너가 한 단계 성장한다는 건 이 "어떻게 말을 걸어야 하는가"의 사전을 조금씩 채워가는 것과 같습니다.

8. Claude와 함께 스캐너 확장하기

이번 장에서는 앞서 배운 내용을 기반으로 "scanme.nmap.org"에 해당하는 포트스캐닝 도구를 만드세요. 성공한 포트에 대해서는 배너 그래빙도 시도해보세요. 성공한 데이터는 csv와 json으로 저장하세요.

9. HTML 파싱으로 공격 표면 훑기 — BeautifulSoup

웹 서비스 점검의 시작은 HTML 들여다보기입니다. 브라우저가 렌더링해주는 예쁜 페이지 뒤에는, 공격자에게 유용한 정보가 평문 그대로 남아 있는 경우가 많습니다.

  • 폼의 <input name="..."> — 서버로 넘어가는 파라미터 이름이 그대로 노출
  • <script src="/api/v2/..."> — 내부 API 경로 자백
  • HTML 주석 (<!-- TODO: /admin/debug -->) — 개발자의 실수로 남은 단서
  • <meta name="generator" content="WordPress 6.3"> — 프레임워크·버전 정보

requests로 HTML을 받아 BeautifulSoup으로 파싱하면 이 정보를 한 번에 훑을 수 있습니다.

pip install beautifulsoup4

9.1 테이블 데이터 추출 — 주식 페이지 실습

paullab.co.kr/stock.html은 표 데이터가 실린 학습용 페이지입니다. 거래량 컬럼만 뽑아 합산해봅니다.

import requests
from bs4 import BeautifulSoup

res = requests.get("https://paullab.co.kr/stock.html")
print(res.status_code)                   # 200
print(res.headers.get("Content-Type"))   # text/html; charset=UTF-8
print(res.text[:200])                    # HTML 첫 200자만 살짝

soup = BeautifulSoup(res.text, "html.parser")

# 거래량(마지막 <td>)을 모두 더합니다
total = 0
for row in soup.select(".table")[0].select("tr")[1:]:   # 헤더 제외
    total += int(row.select("td")[-1].text.replace(",", ""))
print(total)

핵심 문법은 두 줄입니다.

  • soup.select(".table")[0].select("tr")[1:] — 첫 번째 .table 안의 <tr> 중 헤더를 뺀 나머지
  • row.select("td")[-1].text.replace(",", "") — 각 행의 마지막 <td> 텍스트에서 콤마를 떼고 정수화

이 패턴만 익히면 로그인 폼·검색 결과·admin 페이지 어느 것이든 동일하게 파싱됩니다. 공격자가 크롤러로 대량 수집을 하는 단계가 바로 여기이고, 방어자는 로그의 User-Agent·요청 간격으로 자동화 트래픽을 찾아냅니다.

10. REST API CRUD 공격자 관점으로 훑기

앞 장(02-1)의 6.9에서 블로그 API의 조회·작성을 다뤘습니다. 이번에는 모든 메서드를 직접 두드리며 권한 검증의 빈틈을 찾아봅니다.

BASE = https://dev.wenivops.co.kr/services/fastapi-crud/675
메서드URL용도
GET/blog전체 조회
GET/blog/{id}단건 조회
POST/blog작성
PUT/blog/{id}수정
DELETE/blog/{id}삭제

지금 배운 코드로 CRUD를 모두 테스트하면서 권한 없이 접근 가능한 경로가 있는지 찾아봅니다. 찾을 때에도 티가 안 나게 테스트하려고 노력합니다. 예를 들어 내가 작성한 게시물이 수정·삭제되는 것은 괜찮지만, 남이 작성한 게시물을 건드리면 신고당할 가능성이 있습니다. 정보보안 담당자 입장에서는 비정상적인 GET·POST 뒤에 DELETE·PUT이 섞여 오면 그 계정의 이상 징후로 해석할 수 있습니다.

10.1 READ — GET

import requests

BASE = "https://dev.wenivops.co.kr/services/fastapi-crud/675"

# timeout — 이 시간 안에 응답이 없으면 포기하고 에러를 던진다
res = requests.get(f"{BASE}/blog", timeout=5)
blogs = res.json()
print(blogs)

티 안 나게 훑기 — 공격자 관점에서는 한 번에 모든 게시물을 끌어오는 것보다 페이지네이션 파라미터(?page=1&size=10)로 정상 유저처럼 가져오는 편이 안전합니다. 단일 IP가 수천 건 GET을 요청하면 로그·WAF에 바로 걸립니다.

10.2 CREATE — POST

import requests

BASE = "https://dev.wenivops.co.kr/services/fastapi-crud/675"

data = {
    "title": "test",
    "content": "test",
}

res = requests.post(f"{BASE}/blog", json=data, timeout=5)
print(res.json())

생성은 내 흔적이 남는 작업이라 점검 중에는 본인 소유 게시물만 남기도록 주의합니다. 실수로 운영 데이터를 건드리면 돌이키기 어렵습니다.

10.3 UPDATE — PUT

import requests

BASE = "https://dev.wenivops.co.kr/services/fastapi-crud/675"

data = {
    "title": "test put",
    "content": "test put",
}

res = requests.put(f"{BASE}/blog/1", json=data, timeout=5)
print(res.json())

공격자 관점 — 위 코드는 /blog/1에 아무 인증 없이 PUT을 보냅니다. 성공한다면 인증 없이 남의 게시물을 수정할 수 있다는 심각한 취약점이죠. 점검 시에는 본인이 작성한 게시물로만 테스트해야 합니다. 남의 글을 건드리는 순간 민·형사 책임이 따릅니다.

방어자 관점 — "비정상 PUT·DELETE 트래픽"은 공격의 신호입니다. 정상 사용자는 GET이 압도적이고 POST가 간혹 섞이는데, DELETE·PUT이 급증하는 계정은 자동화 공격이거나 탈취된 세션일 가능성이 큽니다. WAF·이상탐지 규칙에 이 패턴을 넣어두면 빠르게 잡을 수 있습니다.

10.4 DELETE

import requests

BASE = "https://dev.wenivops.co.kr/services/fastapi-crud/675"

res = requests.delete(f"{BASE}/blog/1", timeout=5)
print(res.json())

PUT과 마찬가지로, 인증 없이 DELETE가 먹히는 서비스는 단 한 줄로 모든 게시물이 지워집니다. REST API를 만들 때 메서드별 인가 검증을 빼먹지 않는 것이 방어의 첫 단추입니다.

10.5 크롤러 위장 — User-Agent·Referer 헤더

requests의 기본 User-Agent는 python-requests/2.x입니다. 웬만한 WAF·CDN은 이 문자열만 보고 자동화 트래픽으로 분류해 차단합니다. 크롤러·스캐너는 실제 브라우저인 척 위장하는 것이 기본입니다.

import requests

headers = {
    'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 '
                  '(KHTML, like Gecko) Chrome/120.0 Safari/537.36',
    'Accept-Language': 'ko-KR,ko;q=0.9,en-US;q=0.8',
}
params = {'q': 'phone', 'page': 1}
res = requests.get('https://dummyjson.com/products/search', params=params, headers=headers)
print(res.url)       # https://dummyjson.com/products/search?q=phone&page=1
print(res.json())

방어자 관점 — User-Agent 위장이 만능은 아닙니다. 진짜 Chrome은 UA만 아니라 TLS 핑거프린트(JA3)·HTTP/2 SETTINGS·헤더 순서까지 고유한 지문이 있습니다. 이 지문으로 "Chrome이라고 주장하는 python-requests"를 가려내는 탐지 기법이 Cloudflare·Akamai Bot Manager 등에서 이미 널리 쓰입니다.

11. 인증 흐름 점검 — 회원가입·로그인·토큰

11.1 회원가입

import requests

BASE = "https://dev.wenivops.co.kr/services/fastapi-crud/675"

data = {
    "username": "test1",
    "password": "test1234",
}

res = requests.post(f"{BASE}/signup", json=data, timeout=5)
print(res.json())

회원가입 API는 공격자가 대량 계정을 생성해 이상탐지·추천 알고리즘·쿠폰 시스템을 오염시키는 진입점입니다. 방어는 캡차·이메일 인증·레이트 리미터 조합이지만 완벽하지 않습니다(요즘 AI는 이미지 캡차 대부분을 풀어냅니다).

11.2 로그인 — access token 받기

import requests

BASE = "https://dev.wenivops.co.kr/services/fastapi-crud/675"

data = {
    "username": "test1",
    "password": "test1234",
}

res = requests.post(f"{BASE}/login", json=data, timeout=5)
print(res.json())
# {"access_token": "eyJhbGciOi...", "token_type": "bearer"}

성공하면 서버는 JWT(JSON Web Token) 형태의 access_token을 돌려줍니다. 보호된 엔드포인트를 호출할 때는 이 토큰을 Authorization: Bearer <token> 헤더에 실어 보냅니다.

11.3 토큰 유효성 검증

import requests

URL = "https://dev.wenivops.co.kr/services/fastapi-crud/login_confirm"

headers = {
    "Authorization": "Bearer eyJhbGciOi.weniv.h8t7NJKEiWCh7G3",
}

res = requests.post(URL, headers=headers, timeout=5)
print(res.json())

토큰이 털리면 끝입니다. 탈취된 토큰은 짧은 시간 안에 갱신·폐기되지 않으면 그 자체로 유저 권한을 그대로 넘겨주는 키가 됩니다. 방어 측에서는:

  • http-only 쿠키로 저장해 JS에서 읽지 못하게 함
  • 짧은 만료 시간 (access token 15분, refresh token 2주 정도)
  • IP·User-Agent 지문이 바뀌면 재인증 요구

공격 측에서는 XSS로 토큰을 탈취하거나, 네트워크 중간자(MITM)로 가로채거나, 클라이언트 디바이스를 직접 장악하는 등 다양한 경로가 있습니다.

12. 정보보안 담당자 관점의 위협 시나리오

지금까지 만든 코드를 방어자 입장에서 다시 보면 여러 질문이 떠오릅니다. 실습 중 나온 대표적인 질의응답을 정리합니다.

Q. 해커가 서버로 넘어가는 값(파라미터 이름)을 어떻게 알죠? A. HTML을 봅니다. 폼의 <input name>, 클라이언트 JS의 fetch() 호출, 페이지 소스 곳곳에 평문으로 들어있습니다. 그래서 공격자는 크롤러로 HTML을 먼저 수집하고 bs4 같은 도구로 분석합니다.

Q. 해커가 API 명세서는 어떻게 구하나요? A. 여러 경로가 있습니다.

  • 스캐닝 — URL 구조를 수집해놓고 RESTful 규칙으로 유추합니다(/blog/{id} → /user/{id}, /admin/{id}의 실재 여부 점검).
  • 공개 문서 실수 — Swagger/OpenAPI 엔드포인트(/docs, /swagger.json)가 실수로 열려 있는 경우가 많습니다.
  • 사회공학 — 퇴사자·외주 개발자와 접촉해 명세서를 구하는 사례도 있습니다.

Q. Access·Refresh 토큰이 탈취되면 어떻게 되나요? A. "나인 척"할 수 있게 됩니다. 다만 요즘은 http-only·짧은 만료·지문 검증 등 다층 방어로 난이도가 올라갔습니다. 그래도 완전한 방어는 없다는 전제로 토큰 탈취가 일어났을 때의 피해 축소(blast radius 축소)를 핵심으로 설계합니다.

Q. 해커가 회원가입을 수백·수천 개 하면요? A. 방어하기 정말 어려운 공격입니다. DDoS와 비슷한 성질이죠. 레이트 리미터·캡차·이메일 인증이 기본이지만, 요즘 AI는 이미지 캡차를 대부분 풉니다. 캡차를 지나치게 어렵게 만들면 정상 유저까지 떠나는 딜레마가 생깁니다.

Q. 해커가 게시물을 수천 개 올리면요? A. 이상탐지 라이브러리·시스템이 이런 작성 패턴을 감지합니다. 단순 카운트 기반이 아니라 내용의 임베딩 유사도·작성 간격·계정 메타데이터 등을 종합합니다.

Q. 악의적인 콘텐츠(예: 당근 마켓의 마약 거래 글)를 올리면요? A. AI 기반 콘텐츠 분류로 ID를 차단하거나 그 종류의 게시물을 못 올리게 합니다. 단순 문자열 매칭으로는 우회가 쉬워 거의 쓰지 않습니다.

Q. 권한 없는 PUT·DELETE 요청이 들어오면요? A. API 명세와 인가(Authorization) 설계가 엄격해야 합니다. 느슨하게 두면 XSS·CSRF 공격의 발판이 됩니다. 프레임워크의 기본 가드(예: Spring Security, Django REST Framework의 permission classes)를 쓰는 것만으로도 많은 실수를 막을 수 있습니다.

Q. API에 DoS 공격을 하면요? A. 레이트 리미팅·CDN 완충·오토스케일링으로 방어합니다. L7 DoS(애플리케이션 레벨)는 L3/L4 DDoS와 달리 요청 자체가 유효해 보이기 때문에 탐지가 까다롭습니다.

Q. API에 SQL Injection은요? A. ORM·파라미터 바인딩을 쓰면 대부분 막힙니다. 문제는 raw SQL을 직접 쓰는 엣지 케이스·동적 쿼리 빌딩·ORM의 우회 기능(extra, raw)에서 터집니다. 이 부분은 뒤 장의 SQLi 스캐너 편에서 직접 퍼징해봅니다.

13. 과제

# 블로그 API 접근 제어 점검

## 과제 설명
책에서 사용한 블로그 API(`https://dev.wenivops.co.kr/services/fastapi-crud/675`)를 대상으로, 각 CRUD 엔드포인트가 **인증 없이 어디까지 허용되는지** 점검하는 간단한 파이썬 스크립트를 작성하세요.

## 요구 사항
* `requests` 모듈만 사용합니다.
* `GET /blog`, `POST /blog`, `PUT /blog/{id}`, `DELETE /blog/{id}` 네 메서드를 **Authorization 헤더 없이** 호출하세요.
* 모든 요청에 `timeout=5`를 지정하세요.
* 각 요청의 메서드, URL, 상태 코드, 응답 본문 길이를 `api_audit.csv` 파일로 저장하세요.
* POST·PUT·DELETE 테스트는 반드시 **본인이 직접 생성한 게시물**에만 수행하세요.

## 고도화 과제
* User-Agent를 실제 브라우저 문자열로 위장했을 때와 기본값(`python-requests/...`)일 때의 응답 차이를 CSV에 함께 기록해 비교해보세요.