네트워크 패킷 분석과 스니핑
지금까지 우리는 HTTP라는 한 계층만 다뤘습니다. requests로 요청을 보내고, FastAPI로 응답을 만들고, 헤더와 쿠키를 꺼내 보았죠. 이번 회차는 한 계층 더 내려갑니다. HTTP 한 줄을 보내면 실제로 선 위에 어떤 비트들이 흘러가는지, 그리고 그 비트들을 가로채고 분석하는 방법을 다룹니다. 도구는 단 하나, Scapy입니다.
법·윤리 주의: 이번 회차의 모든 캡처·스니핑 실습은 본인 PC의 루프백 인터페이스(127.0.0.1/Loopback)와 본인 소유의 LAN에서만 진행합니다. 카페·학교·회사 같은 공용 네트워크에서 다른 사용자의 트래픽을 캡처하는 행위는 정보통신망법상 명백한 불법입니다. ARP 스푸핑 절은 원리만 다루고, 실습은 자기 자신에게 위조 응답을 보내는 형태로만 제한합니다.
1. 왜 직접 패킷을 만지는가
requests로 충분해 보이는 일을 굳이 한 단계 더 내려가서 다루는 이유는 세 가지입니다.
- HTTP 위에서는 보이지 않는 신호: 포트 스캔의 SYN/RST, 연결 끊김의 RST, ICMP
Destination Unreachable같은 네트워크의 "표정"은 패킷 레벨에서만 보입니다. - 방어 도구의 입력은 패킷: 침입 탐지 시스템(IDS), 방화벽 로그, 와이어샤크 캡처 모두 패킷 단위로 사고합니다. 공격을 만들든 방어를 만들든 같은 단위를 다뤄야 합니다.
- 취약점이 있는 곳은 종종 HTTP 아래: TLS 다운그레이드, ARP 스푸핑, DNS 캐시 포이즈닝 같은 공격은 HTTP 메시지를 만들기 전 단계를 노립니다.
02-1에서 4계층 캡슐화를 그림으로 봤다면, 이번 회차는 그 그림의 각 칸을 파이썬 객체로 직접 들고 만지는 시간입니다.
2. Scapy 설치와 환경 준비
Scapy는 파이썬으로 패킷을 만들고, 보내고, 받고, 분석하는 라이브러리입니다. 와이어샤크가 GUI 위에서 패킷을 분석하는 도구라면, Scapy는 REPL과 스크립트 위에서 패킷을 다루는 도구입니다.
pip install scapy
캡처는 운영체제의 로우 소켓을 사용하기 때문에 추가 권한이 필요합니다.
| OS | 추가 설치 / 권한 |
|---|---|
| Windows | Npcap 설치 (WinPcap API-compatible 옵션 체크) → 관리자 권한 터미널 |
| macOS | 기본 BPF 디바이스 사용. 터미널을 sudo로 실행하거나 /dev/bpf*에 권한 부여 |
| Linux | sudo 또는 setcap cap_net_raw,cap_net_admin=eip $(which python) |

설치를 하실 때 체크표시는 모두 체크하고 진행하시는 것을 권장드립니다.

설치가 끝났으면 가장 짧은 동작 확인 코드를 돌려봅니다.
# scapy_hello.py
from scapy.all import IP, ICMP, sr1
# 자기 자신에게 ping 한 번
pkt = IP(dst="127.0.0.1") / ICMP()
reply = sr1(pkt, timeout=2, verbose=False)
reply.show() if reply else print("응답 없음")
sr1(send-receive-1)은 패킷을 한 번 보내고 첫 응답을 기다립니다. reply.show()는 받은 패킷을 계층별로 펼쳐서 출력합니다. 출력에 IP / ICMP 두 계층이 모두 보인다면 환경 준비가 끝난 것입니다.
Windows에서 첫 실패 패턴: Npcap을 깔지 않은 채 sr1을 호출하면 OSError: [WinError 10013] 또는 "No libpcap provider available" 메시지가 납니다. Npcap 설치 마법사에서 두 옵션을 꼭 체크하세요. "WinPcap API-compatible Mode"(Scapy가 인터페이스를 인식하기 위해 필요)와 "Support loopback traffic (Npcap Loopback Adapter)"(이번 회차의 자기 자신 캡처 실습에 필요). 둘 중 하나라도 빠지면 뒤의 sniff 예제들이 0개를 출력합니다. 이미 다른 옵션으로 설치했다면 Npcap을 제거하고 재설치해야 적용됩니다.
3. 패킷이라는 파이썬 객체
Scapy의 첫 번째 핵심은 패킷 = 계층의 슬래시 결합이라는 표기입니다.
from scapy.all import Ether, IP, TCP, Raw
pkt = Ether() / IP(dst="127.0.0.1") / TCP(dport=80, flags="S") / Raw(b"hello")
이 한 줄은 02-1의 캡슐화 그림을 그대로 코드로 옮긴 것입니다. 왼쪽이 바깥 계층, 오른쪽이 안쪽 페이로드입니다.
패킷 객체는 파이썬 딕셔너리처럼 들춰볼 수 있습니다.
pkt[IP].dst # '127.0.0.1'
pkt[TCP].dport # 80
pkt[TCP].flags # <Flag 2 (S)>
bytes(pkt) # 와이어로 나갈 실제 바이트열
pkt.summary() # 'Ether / IP / TCP 0.0.0.0:ftp_data > 127.0.0.1:http S / Raw'
pkt.show() # 모든 필드를 트리 형태로 출력
여기서 한 줄로 끝낼 수 있는 일이 두 가지입니다.
pkt.show(). 사람이 읽기 좋게 모든 필드를 출력bytes(pkt). 실제로 NIC에서 흘러갈 비트열
이 두 기능만 손에 익혀도 "내가 보낸 게 무엇인지", "받은 게 정확히 어떤 모양인지"를 즉시 확인할 수 있습니다.
3.1 헤더 필드를 직접 확인하기
pkt.show()를 출력하면 IP 헤더의 version, ihl, tos, len, id, flags, frag, ttl, proto, chksum 같은 칸들이 한눈에 들어옵니다. 02-1에서 표로만 봤던 필드들이 이제 실제 객체의 속성으로 보이는 셈입니다.
자동으로 채워지는 필드: IP() 생성자에 src, chksum, len을 지정하지 않아도 Scapy가 보낼 때 알아서 계산해 채워줍니다. 일부러 잘못된 체크섬을 넣고 싶으면 IP(chksum=0xdead)처럼 직접 지정하세요. 이 트릭은 다음 회차에서 다룰 잘못된 패킷에 서버가 어떻게 반응하는지를 관찰할 때 씁니다.
4. 패킷 보내기: sr1, send, sendp
세 함수의 차이를 한 표로 정리하면 다음과 같습니다.
| 함수 | 어느 계층까지 알아서 채워주나 | 응답을 기다리나 |
|---|---|---|
send() | L3 (IP)부터 직접. L2는 OS가 채움 | 아니오 |
sendp() | L2 (Ether)부터 직접 | 아니오 |
sr1() | L3부터 직접 | 첫 응답 1개 |
sr() | L3부터 직접 | 모든 응답 |
srp1() | L2부터 직접 | 첫 응답 1개 |
대부분의 학습 시나리오에서는 sr1만 알아도 충분합니다. 응답을 받지 않는 일방향 전송이라면 send를 씁니다.
4.1 SYN 한 번으로 포트 살피기
3장에서 사용했던 vuln_lab.py 가 떠 있다면(uvicorn vuln_lab:app --port 8000), 그 8000번 포트로 SYN 패킷 한 장만 직접 던져 봅니다.
# scapy_syn_probe.py: 가장 작은 포트 스캐너
from scapy.all import IP, TCP, sr1
target = "127.0.0.1"
port = 8000
pkt = IP(dst=target) / TCP(dport=port, flags="S")
reply = sr1(pkt, timeout=1, verbose=False)
if reply is None:
print(f"{port} filtered (응답 없음)")
elif reply.haslayer(TCP):
flags = reply[TCP].flags
if flags == "SA":
print(f"{port} open (SYN+ACK 수신)")
elif flags == "RA":
print(f"{port} closed (RST 수신)")
else:
print(f"{port} unknown flags={flags}")
이 한 페이지짜리 스크립트가 사실은 SYN 스캔의 본질입니다. 응답이 SA(SYN+ACK)면 포트가 열린 것이고, RA(RST+ACK)면 닫힌 것이고, 무응답이면 방화벽으로 막혀 있다는 신호입니다. nmap이 하는 일도 같은 판정의 반복일 뿐입니다.
왜 ACK를 안 보내는가: sr1은 응답을 받자마자 함수가 끝나기 때문에 3-way handshake의 마지막 ACK를 우리가 보내지 않습니다. 결국 OS 커널은 "내가 모르는 SYN+ACK가 도착했네"라고 판단해 자동으로 RST를 쏘아 연결을 끊습니다. 이 부수효과 덕분에 SYN 스캔은 서버 애플리케이션 로그에 연결이 남지 않는 스텔스 스캔으로 동작합니다. 단, 커널 레벨 방화벽 로그에는 남습니다.
5. 패킷 캡처: sniff
캡처는 sniff 한 함수로 끝납니다. 원하는 만큼 받아서 리스트로 돌려주거나, 패킷이 도착할 때마다 콜백을 호출하게 만들 수 있습니다.
# scapy_sniff_basic.py
from scapy.all import sniff
pkts = sniff(count=10, timeout=10)
pkts.summary()
이 코드를 실행하면 NIC를 지나가는 처음 10개 패킷(또는 10초 안에 도착한 모든 패킷)을 잡아 요약을 출력합니다.
NIC(Network Interface Card)란? 컴퓨터가 네트워크에 연결할 때 쓰는 하드웨어입니다. 유선 랜 카드, Wi-Fi 어댑터, 가상 머신의 가상 NIC 등이 모두 여기에 해당합니다. sniff는 지정한 인터페이스를 지나가는 패킷을 잡아옵니다.
이때 WiFi 패킷을 캡쳐하기 위해서는 모니터 모드로 인터페이스를 설정해야 합니다. 모니터 모드는 무선 신호를 원시 패킷 형태로 캡처할 수 있게 해주는 기능입니다. 일반적으로 WireShark에서 무선 인터페이스를 모니터 모드로 설정하면 공중에 떠다니는 WiFi 패킷을 볼 수 있게 됩니다. 남의 패킷을 캡처하는 것은 불법입니다.
자기 자신(127.0.0.1)을 캡처할 때는 루프백 인터페이스 명시: 127.0.0.1 사이의 트래픽은 NIC를 거치지 않고 커널 안에서만 돌기 때문에 기본 인터페이스로는 잡히지 않습니다. iface를 안 주고 위 코드를 돌리면 Scapy는 WiFi/이더넷 어댑터를 보기 때문에, 다른 터미널에서 curl http://127.0.0.1:8000/...을 아무리 쳐도 0개가 나옵니다. 다음 절부터 등장하는 자기 자신 캡처 예제에서는 OS별 루프백 인터페이스를 반드시 같이 지정하세요.
| OS | 루프백 인터페이스 |
|---|---|
| Windows | iface=r"\Device\NPF_Loopback" (Npcap 설치 시 Support loopback traffic 옵션 필요) |
| macOS | iface="lo0" |
| Linux | iface="lo" |
인터페이스 이름이 정확히 무엇인지 확인하고 싶다면 from scapy.all import get_if_list; print(get_if_list())로 목록을 뽑아볼 수 있습니다.
5.1 BPF 필터로 좁히기
전체를 캡처하면 출력이 금방 노이즈로 가득 찹니다. BPF(Berkeley Packet Filter) 표현식으로 필요한 것만 골라냅니다.
# 80번 포트 트래픽만, 무한정 (외부 트래픽: 기본 인터페이스 사용)
sniff(filter="tcp port 80", prn=lambda p: p.summary())
# 자기 자신(127.0.0.1) 8000번 포트 통신만 (루프백 인터페이스 명시)
sniff(iface=r"\Device\NPF_Loopback", filter="host 127.0.0.1 and tcp port 8000", count=20)
# DNS 질의만 (외부 트래픽: 기본 인터페이스 사용)
sniff(filter="udp port 53", count=5).summary()
prn은 각 패킷마다 호출되는 콜백입니다. 패킷을 모은 다음 한 번에 처리하지 않고 실시간으로 흘리며 분석할 수 있어, 바로 다음 절의 평문 HTTP 추출 같은 작업이 자연스러워집니다.
5.2 자기 자신의 HTTP 캡처해 보기
새 파이썬 파일에 캡처 코드를 띄우고, 다른 터미널에서 03-1의 vuln_lab.py 같은 평문 HTTP 서버에 요청을 보내면, 그 사이의 트래픽을 글자 그대로 받아볼 수 있습니다.
# capture_local_http.py
from scapy.all import sniff, TCP, Raw
# 자신의 OS에 맞게 한 줄만 살리세요
LOOPBACK = r"\Device\NPF_Loopback" # Windows
# LOOPBACK = "lo0" # macOS
# LOOPBACK = "lo" # Linux
def show_http(p):
if p.haslayer(Raw):
body = bytes(p[Raw]).decode(errors="replace")
if body.startswith(("GET ", "POST ", "HTTP/")):
print("=" * 60)
print(body[:400])
sniff(iface=LOOPBACK, filter="tcp port 8000", prn=show_http, store=False, count=20)
다른 터미널에서 curl http://127.0.0.1:8000/sqli?id=1을 한 번 치면, 위 스크립트의 출력에 요청 라인과 헤더가 그대로 찍힙니다. HTTP가 평문이라는 말의 의미를 눈으로 확인하는 순간입니다. 출력이 비어 있다면 (1) Npcap 설치 시 Support loopback traffic 옵션이 켜져 있는지, (2) 관리자 권한 터미널에서 실행 중인지, (3) LOOPBACK이 자신의 OS에 맞게 설정됐는지를 차례대로 확인하세요.
HTTPS는 왜 안 보이는가: 같은 코드를 curl https://example.com에 대고 돌리면 Raw에는 알아볼 수 없는 바이트열만 잡힙니다. TLS가 애플리케이션 페이로드를 암호화하기 때문입니다. TLS 핸드셰이크의 ServerHello, Certificate 같은 메타데이터는 평문으로 보이지만, 그 뒤의 HTTP 본문은 키 없이는 풀 수 없습니다. 그래서 모든 보안 컨퍼런스가 마지막에 똑같은 결론을 내립니다. "HTTPS를 켜라."
5.3 POST 본문까지 캡처하기
5.2 코드를 그대로 돌려보면 GET 요청은 잘 찍히는데 폼 제출(POST)의 본문(name=...&message=...)은 잘 안 보일 겁니다. 이유가 두 가지 겹쳐 있습니다.
- 헤더만으로도 400바이트가 넘는다: 브라우저가 보내는 POST 요청에는
User-Agent,Cookie,sec-ch-ua,Accept-Language같은 헤더가 풍성하게 붙습니다. 5.2의body[:400]은 보통 헤더 끝에 도달하기도 전에 잘려서 본문에 닿지 못합니다. - TCP가 한 메시지를 여러 segment로 쪼갤 수 있다: 요청이 크면 TCP 계층이 두세 패킷으로 나눠 보냅니다. 두 번째 패킷은
POST로 시작하지 않으니 5.2의body.startswith(("GET ", "POST ", "HTTP/"))필터에 걸려 통째로 버려집니다.
해결은 흐름(flow) 단위로 버퍼링하는 것입니다. (src, sport, dst, dport) 4튜플이 같은 패킷들의 페이로드를 하나의 버퍼에 이어 붙이고, 헤더 끝(\r\n\r\n)이 보이고 Content-Length만큼 본문이 모두 도착하면 그때 한꺼번에 출력합니다.
# capture_post_body.py
from collections import defaultdict
from scapy.all import sniff, IP, TCP, Raw
LOOPBACK = r"\Device\NPF_Loopback" # macOS: "lo0", Linux: "lo"
flows = defaultdict(bytearray)
def show_request(p):
if not (p.haslayer(IP) and p.haslayer(TCP) and p.haslayer(Raw)):
return
key = (p[IP].src, p[TCP].sport, p[IP].dst, p[TCP].dport)
flows[key].extend(bytes(p[Raw]))
buf = flows[key]
# 헤더가 아직 다 안 왔으면 더 기다리기
if b"\r\n\r\n" not in buf:
return
head_bytes, _, body_bytes = buf.partition(b"\r\n\r\n")
head = head_bytes.decode("latin-1", errors="replace")
# 응답(HTTP/1.1 200 ...)이나 무관한 흐름은 흘려보내기
if not head.startswith(("GET ", "POST ")):
flows.pop(key, None)
return
# Content-Length 만큼 본문이 모두 도착했는지 확인
content_length = 0
for line in head.split("\r\n"):
if line.lower().startswith("content-length:"):
content_length = int(line.split(":", 1)[1].strip())
break
if len(body_bytes) < content_length:
return # 본문이 더 와야 함. 다음 패킷 대기
print("=" * 60)
print(head)
if content_length:
print()
print(body_bytes[:content_length].decode("latin-1", errors="replace"))
flows.pop(key, None)
sniff(iface=LOOPBACK, filter="tcp port 8000",
prn=show_request, store=False, count=40)
이 스크립트를 띄워두고, 브라우저에서 http://127.0.0.1:8000/xss/stored 방명록 페이지에 들어가 이름과 메시지를 채우고 작성 버튼을 누르면 출력에 다음과 같은 모양이 찍힙니다.
============================================================
POST /xss/stored HTTP/1.1
Host: 127.0.0.1:8000
Content-Length: 38
Content-Type: application/x-www-form-urlencoded
User-Agent: Mozilla/5.0 ...
...
name=hojun&message=%3Cscript%3Ealert%281%29%3C%2Fscript%3E
여기서 한 가지가 분명해집니다. name과 message 값은 와이어 위에서 평문입니다. 브라우저가 한 일은 폼 인코딩(application/x-www-form-urlencoded)으로 <를 %3C로 바꾸는 정도의 단순 변환뿐이고, 암호화는 어디에도 끼어 있지 않습니다. 같은 LAN에서 패킷을 잡을 수 있는 사람이라면 누구든 이름·메시지·비밀번호·세션 쿠키를 그대로 읽을 수 있다는 뜻입니다.
페이로드와 비밀번호는 같은 자리에 흐른다: 03-2의 Stored XSS 실습에서 본 <script>...</script> 페이로드도, 어떤 사이트의 로그인 폼에 친 비밀번호도, 같은 형식(폼 인코딩)으로 같은 자리(요청 본문)에 흘러갑니다. 평문 HTTP 위에서는 공격 페이로드든 사용자 비밀번호든 모두 똑같이 캡처됩니다. 다음 회차에서 비밀번호 해시·솔트를 다루는 이유가 여기 있고, 더 근본적으로는 03-7의 HTTPS 강제로 이어집니다.
6. pcap 파일로 저장하고 다시 읽기
캡처한 패킷은 pcap 파일로 저장해 두면 와이어샤크에서도 열 수 있고, 다음 분석 스크립트의 입력으로도 쓸 수 있습니다.
# pcap_io.py
from scapy.all import sniff, wrpcap, rdpcap
LOOPBACK = r"\Device\NPF_Loopback" # macOS: "lo0", Linux: "lo"
# 30초 동안 또는 100개까지
pkts = sniff(iface=LOOPBACK, filter="tcp port 8000", count=100, timeout=30)
wrpcap("session_8000.pcap", pkts)
print(f"saved {len(pkts)} packets")
# 나중에 다시 열기
loaded = rdpcap("session_8000.pcap")
loaded.summary()
saved 0 packets와 unknown LL type for NoneType 경고: 두 메시지가 같이 나왔다면 원인은 하나, sniff가 0개를 잡은 것입니다. 5.2에서 본 루프백 인터페이스 문제와 동일합니다. iface=를 빼먹었거나 다른 NIC를 보고 있어서 tcp port 8000 트래픽이 한 건도 안 잡혔고, wrpcap은 빈 리스트에서 링크 계층 타입을 추론할 수 없어 fallback으로 Ethernet(LL type 1)을 쓴다는 경고를 던진 뒤 빈 파일을 만든 것입니다. iface=LOOPBACK을 추가하고 다른 터미널에서 curl http://127.0.0.1:8000/...을 한두 번 친 뒤 다시 돌려보면 saved N packets(N≥1)로 바뀌고 경고도 사라집니다.
loaded.summary()는 패킷 헤더 한 줄짜리만 보여줍니다. 실제로 무엇이 오갔는지 보려면 5.3의 흐름 버퍼링 로직을 그대로 가져다 쓰면 됩니다. sniff의 콜백이 rdpcap이 돌려준 리스트 순회로 바뀌는 것 외에는 동일합니다.
# pcap_extract_http.py: 저장된 pcap에서 HTTP 요청을 본문까지 복원
from collections import defaultdict
from scapy.all import rdpcap, IP, TCP, Raw
pkts = rdpcap("session_8000.pcap")
flows = defaultdict(bytearray)
# 1단계: 흐름별로 페이로드 모으기
for p in pkts:
if IP in p and TCP in p and p.haslayer(Raw):
key = (p[IP].src, p[TCP].sport, p[IP].dst, p[TCP].dport)
flows[key].extend(bytes(p[Raw]))
# 2단계: 각 흐름에서 요청 헤더 + 본문 출력
for key, buf in flows.items():
if b"\r\n\r\n" not in buf:
continue
head_bytes, _, body_bytes = buf.partition(b"\r\n\r\n")
head = head_bytes.decode("latin-1", errors="replace")
if not head.startswith(("GET ", "POST ")):
continue
content_length = 0
for line in head.split("\r\n"):
if line.lower().startswith("content-length:"):
content_length = int(line.split(":", 1)[1].strip())
break
print("=" * 60)
print(f"[{key[0]}:{key[1]} → {key[2]}:{key[3]}]")
print(head)
if content_length:
print()
print(body_bytes[:content_length].decode("latin-1", errors="replace"))
5.3과의 차이는 두 가지뿐입니다. (1) 입력이 NIC가 아니라 이미 저장된 파일이고, (2) 모든 패킷이 한꺼번에 들어와 있으니 콜백 안에서 부분 도착을 기다릴 필요가 없다는 것입니다. 그래서 1단계에서 모든 흐름의 페이로드를 다 모은 뒤, 2단계에서 한 흐름씩 꺼내 처리합니다. 같은 pcap 안에 여러 요청이 섞여 있어도 4튜플 키가 다르면 자동으로 분리됩니다.
저장해둔 pcap을 가지고 간단한 통계도 뽑아봅니다. "어떤 IP가 누구와 가장 많이 떠들었는가"는 트래픽 분석의 가장 첫 질문입니다.
# pcap_top_talkers.py
from collections import Counter
from scapy.all import rdpcap, IP
pkts = rdpcap("session_8000.pcap")
pairs = Counter()
for p in pkts:
if IP in p:
pairs[(p[IP].src, p[IP].dst)] += 1
for (src, dst), n in pairs.most_common(10):
print(f"{src:>15} → {dst:<15} {n:>5} packets")
이 스크립트는 사람이 보고 한 줄을 읽으면 끝납니다. 공격 트래픽을 분석할 때 가장 처음 던지는 질문이 바로 "Top Talker가 누구냐"입니다. 한 IP가 짧은 시간에 같은 대상에게 수만 개의 패킷을 보냈다면 스캔이거나 DoS일 가능성이 높습니다.
7. 종합 실습: 취약 서비스를 1분간 모니터링하고 자동 분석 보고서까지
지금까지 흩어져 있던 조각들 — 4계층 캡슐화, sniff, 흐름 버퍼링, pcap 저장 — 을 한 덩어리로 묶어 봅니다. 흐름은 세 단계입니다.
- 1단계. 취약점 1~2개가 박혀 있는 작은 게시판 서비스를 FastAPI로 띄운다.
- 2단계. 그 서비스를 약 1분간 모니터링하면서, 다른 터미널·브라우저로 사용자처럼 클릭한다.
- 3단계. 캡처한 pcap을 자동 분석해 마크다운 보고서를 만든다.
이 실습이 중요한 이유는 코드 한 줄 한 줄보다 자동 분석 패턴을 만드는 사고방식에 있습니다. 실무의 보안 관제는 결국 "어떤 신호를 어떤 룰로 골라내고, 사람이 봐야 할 것만 보고서로 끌어올리는가"의 반복입니다. 7.3에서 만들 detect() 함수는 그 룰을 한곳에 모아두는 자리이고, 학생이 룰을 더 추가할수록 보고서가 그만큼 풍부해집니다.
7.1 1단계: 작은 취약 서비스 띄우기
vuln_board.py 한 파일에 로그인 + 게시판이 들어 있는 미니 사이트를 만듭니다. 03-3의 weniboard.py와 달리 이번 서비스의 목적은 공격이 아니라 패킷 위에서 보이는 신호를 만드는 것이라, 의도적인 취약점은 두 가지로 좁혔습니다.
- 평문 비밀번호 전송: 로그인 폼이 폼 인코딩으로
password=...을 그대로 보냅니다. 5.3에서 본 그 자리입니다. - 서명 없는 평문 세션 쿠키: 로그인 성공 시
Set-Cookie: session=<사용자명>을 그대로 박아 줍니다. 쿠키만 보면 누가 로그인했는지가 그대로 보입니다.
# vuln_board.py: 패킷 캡처 실습용 미니 게시판 (로그인 + 검색 + 글쓰기)
from fastapi import FastAPI, Form, Cookie
from fastapi.responses import HTMLResponse, RedirectResponse
app = FastAPI()
# 의도적 취약점 1: 비밀번호를 해시 없이 평문으로 보관 (와이어 위에서도 평문)
USERS = {"admin": "admin1234", "hojun": "qwerty1!"}
POSTS = []
PAGE = """<!doctype html><html><head><meta charset="utf-8"><title>{title}</title></head>
<body style="font-family:sans-serif;max-width:560px;margin:2em auto">{body}</body></html>"""
@app.get("/", response_class=HTMLResponse)
def home(session: str | None = Cookie(default=None)):
who = f"<p>안녕하세요, <b>{session}</b>님</p>" if session else ""
body = f"""<h1>아주 작은 게시판</h1>{who}
<a href="/login">로그인</a> · <a href="/board">게시판</a>"""
return PAGE.format(title="홈", body=body)
@app.get("/login", response_class=HTMLResponse)
def login_form():
body = """<h1>로그인</h1>
<form method="post" action="/login">
<p>아이디: <input name="username"></p>
<p>비밀번호: <input name="password" type="password"></p>
<button>로그인</button>
</form>"""
return PAGE.format(title="로그인", body=body)
@app.post("/login")
def login(username: str = Form(...), password: str = Form(...)):
if USERS.get(username) == password:
# 의도적 취약점 2: 세션 쿠키가 사용자명 그대로 (서명도, 만료도 없음)
resp = RedirectResponse("/", status_code=303)
resp.set_cookie("session", username)
return resp
return HTMLResponse("<p>로그인 실패. <a href='/login'>다시</a></p>", status_code=401)
@app.get("/board", response_class=HTMLResponse)
def board(q: str = ""):
found = [p for p in POSTS if q in p["title"] or q in p["content"]] if q else POSTS
items = "".join(
f"<li><b>{p['title']}</b> — {p['author']}<br>{p['content']}</li>"
for p in found
) or "<li>(글이 없습니다)</li>"
body = f"""<h1>게시판</h1>
<form method="get"><input name="q" value="{q}" placeholder="검색어"><button>검색</button></form>
<ul>{items}</ul>
<h2>새 글</h2>
<form method="post" action="/board">
<p>작성자: <input name="author"></p>
<p>제목: <input name="title"></p>
<p>내용: <textarea name="content"></textarea></p>
<button>작성</button>
</form>"""
return PAGE.format(title="게시판", body=body)
@app.post("/board")
def write_post(author: str = Form(...), title: str = Form(...), content: str = Form(...)):
POSTS.append({"author": author, "title": title, "content": content})
return RedirectResponse("/board", status_code=303)
uvicorn vuln_board:app --port 8000
브라우저로 http://127.0.0.1:8000에 들어가 보면 홈 → 로그인 → 게시판이 도는 평범한 사이트가 떠 있습니다. 겉으로는 정상적인 사이트가 와이어 위에서는 어떻게 보이는가가 이 실습의 관전 포인트입니다.
7.2 2단계: 1분간 트래픽 모니터링하기
이제 캡처를 띄우고, 사용자처럼 사이트를 한 번 둘러봅니다. 60초 타이머가 있는 만큼 너무 길게 고민하지 말고 로그인 → 글쓰기 → 검색 정도의 시나리오를 두세 번 반복하면 충분합니다.
# monitor_60s.py: 8000번 포트 트래픽을 약 1분간 캡처해 pcap으로 저장
from datetime import datetime
from scapy.all import sniff, wrpcap
LOOPBACK = r"\Device\NPF_Loopback" # macOS: "lo0", Linux: "lo"
DURATION = 60
OUT = f"capture_{datetime.now():%H%M%S}.pcap"
print(f"[{datetime.now():%H:%M:%S}] 캡처 시작 — {DURATION}초 동안 tcp port 8000")
print(" 이 시간 동안 브라우저로 vuln_board를 사용해 보세요.")
print(" 추천 시나리오: 로그인 → 글쓰기 → 검색(?q=test) → 잘못된 비밀번호 1회")
pkts = sniff(iface=LOOPBACK, filter="tcp port 8000", timeout=DURATION, store=True)
wrpcap(OUT, pkts)
print(f"[{datetime.now():%H:%M:%S}] 종료 — {len(pkts)} packets → {OUT}")
왜 1분인가: 학습용으로 60초가 절묘한 길이입니다. 너무 짧으면 시나리오가 안 끝나고, 너무 길면 보고서에 노이즈가 가득 차 자동 분석의 효과가 흐려집니다. 실무에서는 보통 "사고가 의심된 시점 ± 5분"처럼 사고 중심으로 캡처를 자릅니다. 본 실습에서는 60초 동안 의도적으로 풍부한 시나리오를 만들어 한 보고서에 다양한 신호가 잡히게 하는 게 목적입니다.
캡처가 끝나면 같은 디렉터리에 capture_HHMMSS.pcap 파일이 생깁니다. 0 packets이라면 5.2의 점검 목록(루프백 인터페이스, Npcap 옵션, 관리자 권한)을 다시 확인하세요.
7.3 3단계: 자동 분석 + 마크다운 보고서 만들기 (Claude와 함께)
여기서부터는 코드를 직접 받지 않고 Claude와 함께 짜 보는 자리입니다. 이 단계에서 만들 스크립트가 곧 "내 작은 IDS"가 되며, 한 번 짜고 나면 룰을 한 줄씩 늘려가는 식으로 계속 키워나가게 됩니다.
analyze_to_report.py가 해야 할 일은 머릿속에서 다섯 단계로 정리됩니다.
rdpcap()으로 pcap을 읽고, 6절처럼(src, sport, dst, dport)4튜플을 키로 흐름별로 페이로드를 모은다- 각 흐름의 버퍼에서 5.3의 방식으로 HTTP 요청을 헤더+본문 단위로 복원한다 (
Content-Length헤더까지 확인) - 요청 한 건마다
detect()함수를 돌려 의심 신호를 룰 매칭한다 — 이 함수가 이번 실습의 본체입니다 - 메서드 분포, 통신 쌍 Top 5 같은 간단한 통계를 뽑는다
- 통계 + 신호를 묶어 마크다운 보고서(
capture_..._report.md)로 떨어뜨린다
detect()에 처음부터 들어가야 할 룰은 7.1의 두 취약점에서 그대로 나옵니다. 본문에 password=가 보이면 HIGH, 쿠키에 session=<서명 없는 사용자명>이 보이면 MEDIUM. 여기에 디코드한 입력 안에 <script나 onerror= 같은 XSS 흔적이 보이면 MEDIUM, union select·or 1=1·-- 같은 SQLi 흔적이 보이면 HIGH 정도가 시드 룰로 충분합니다.
이 설계를 그대로 Claude에 던져 초안을 받습니다. 이때 요청을 충분히 구체적으로 적는 것이 핵심입니다. 입력·출력의 모양, 사용해야 할 라이브러리, 룰의 심각도 표기까지 명시해야 한 번에 돌아가는 코드가 나옵니다.
너는 파이썬과 Scapy에 익숙한 보안 엔지니어야.
목표: pcap 파일을 입력으로 받아 마크다운 보고서를 만드는 analyze_to_report.py 한 파일을 짜줘.
요구사항
- 입력: 명령행 인자로 받은 pcap 경로 (없으면 "capture.pcap")
- 사용 라이브러리: scapy(rdpcap, IP, TCP, Raw), 표준 라이브러리만
- 처리 흐름은 다음 5단계
1. 흐름별(4튜플)로 TCP Raw 페이로드를 모은다
2. 각 흐름에서 HTTP 요청(GET/POST)을 헤더+본문으로 복원
(헤더는 \r\n\r\n로 분리, 본문은 Content-Length만큼 자르기)
3. detect(request) 함수로 룰 매칭. 시드 룰 4개:
- HIGH "Plaintext Password": 본문에 password=... 가 있으면
- MEDIUM "Unsigned Session Cookie": Cookie 헤더에 session=값 이고 값에 점(.)이 없으면
- MEDIUM "XSS Payload": 디코드한 본문/요청라인에 <script, onerror=, javascript:
- HIGH "SQL Injection": union select, or 1=1, --, '; 패턴
4. 메서드 분포(GET/POST 카운트)와 통신 쌍 Top 5를 Counter로 집계
5. 마크다운 보고서를 capture_..._report.md 로 저장
- 보고서 절: 메타정보, 메서드 분포, 통신 쌍 Top 5(표), 자동 탐지된 신호(레벨/종류/근거),
요청 목록(최대 30개)
- 코드는 100줄 이내, 주석은 단계 표시 정도만
먼저 전체 코드를 한 블록으로 출력하고, 그 다음에 각 단계를 1~2줄로 설명해줘.
받은 코드를 그대로 돌려보면서, 7.1의 사이트를 시나리오대로 사용했을 때 보고서에 HIGH: Plaintext Password 한 건과 MEDIUM: Unsigned Session Cookie 한 건이 보이면 1차 통과입니다. 글쓰기 본문에 <script>alert(1)</script>을, 검색창에 ' OR 1=1 --을 넣어 한 번 더 캡처를 돌리면 나머지 두 룰도 발화하는 것을 확인할 수 있습니다.
detect()가 이 실습의 본체: 1·2·4·5단계는 한 번 짜면 잘 바뀌지 않습니다. 실무에서 매일 손대는 자리는 오직 3단계 — 새로 알게 된 공격 표면이 생기면 룰이 한 줄 늘어나고, 오탐이 많은 룰은 조건이 다듬어집니다. 보안 관제 자동화의 본질은 이 한 함수가 점점 똑똑해지는 과정이라고 봐도 무리가 없습니다.
7.4 보고서를 한 단계 더 끌어올리기
7.3까지 만든 보고서는 사람이 안 봐도 될 만큼 충분히 쓸만합니다. 그래서 Claude를 한 번 더 끼울 자리는 두 군데 정도면 됩니다.
첫째는 룰 보강. 자기가 짠 detect() 함수를 통째로 붙이고, 빠진 패턴 5개와 오탐을 줄일 수정점을 함께 받습니다. 받은 룰을 한 줄씩 추가하면서 같은 pcap을 재분석해보면, 룰이 늘어날 때마다 보고서가 어떻게 풍성해지는지 직접 눈으로 확인할 수 있습니다.
둘째는 사람이 읽을 요약 절. 7.3에서 만든 capture_..._report.md를 그대로 붙여 한 줄 요약·분석가 메모·권장 대응 세 절을 받아 보고서 맨 위에 얹습니다. 여기서 핵심은, 원시 pcap이 아니라 이미 요약된 마크다운을 본다는 점입니다. AI는 사람이 읽을 단위로 한 번 압축한 뒤에야 빛을 발합니다 — 다음 회차의 패스워드 분석, 그 뒤의 종합 실습에서도 똑같은 순서가 반복됩니다.
직접 한 번 해 보시고, 룰을 몇 개까지 늘렸는지·보고서가 처음과 어떻게 달라졌는지 비교해보면 이 실습의 맛을 가장 잘 느낄 수 있습니다.