어떤 수업을 찾고 있나요?

예: PowerShell, 네트워크, XSS, Step 138

전체 커리큘럼 둘러보기 →

네트워크

Step 80. Scapy 입문 — 패킷 수작업 공방

Step 80

Level 1 · 난이도 ★★★★☆ · 학습 계획 약 4시간, 개인별 차이 있음

전제: Step77~79의 Python 소켓·TCP 연결과 Linux 터미널 사용. 준비물: 본인의 Linux 실습 환경, Python3, Scapy2.7.0. 조립·응답 판별 연습은 Windows에서도 가능합니다.

소켓의 connect()는 연결 절차를 운영체제에 맡깁니다. 이번에는 IP와 TCP 헤더를 직접 조립하고, 돌아온 패킷이 어떤 관찰을 뒷받침하는지 읽습니다. 완성할 결과물은 syn_scan.py와 관찰 기록입니다. 송수신 대상은 본인 컴퓨터의 127.0.0.1로 고정합니다.

1. 학습 목표

  • IP/TCP/데이터를 조립하고, 아직 계산되지 않은 필드와 직렬화된 값을 구분한다.
  • SYN에 대한 SYN-ACK·RST-ACK·ICMP·무응답을 구분한다.
  • 응답의 바깥 프로토콜과 주소·포트·ACK를 확인한 뒤 판정한다.
  • 루프백 중복 포착을 요청 개수와 구별한다.
  • 원시 소켓 권한, 출발지 필드 변경, 로그 관찰의 한계를 설명한다.

2. 패킷과 판단 기준

2-1. 계층을 쌓는다는 뜻

Ether()/IP()/TCP()/Raw(b"hello")는 Ethernet 프레임 안에 IP, 그 안에 TCP, 그 안에 데이터를 놓는 표현입니다. Ethernet은 같은 링크의 전달, IP는 주소 지정과 라우팅, TCP는 포트와 순서·확인·재전송을 담당합니다. 모든 링크가 Ethernet인 것은 아닙니다. 특히 루프백 캡처에 보이는 Ethernet 형태를 물리적 케이블로 나간 증거로 해석하지 않습니다.

IP()/TCP()는 IP 계층부터 조립한 패킷입니다. Scapy의 /는 계층 결합이며 일부 상위 프로토콜 필드도 결정합니다. show()의 None은 일부 길이·체크섬이 아직 계산되지 않았다는 뜻입니다. bytes(packet)으로 직렬화하고 다시 IP(...)로 읽어 실제 필드를 비교하세요.

2-2. 연결과 관찰은 어떻게 다른가

연결 요청자          수신 측
    SYN  -------->
         <--------  SYN-ACK
    ACK  -------->             연결 성립

닫힌 포트의 일반적인 응답: SYN에 RST-ACK

이 수업의 SYN 관찰은 마지막 ACK를 보내 완전한 연결을 만드는 도구가 아닙니다. Linux 커널은 자신이 만든 연결이 아닌 SYN-ACK를 보고 RST를 보낼 수 있습니다. 이 수업에서는 그 동작을 막기 위한 방화벽 변경을 하지 않습니다.

관찰 표시와 의미
주소·포트·ACK가 맞는 SYN-ACK OPEN: 이 SYN에 연결을 수락하는 응답을 관찰
같은 조건의 RST-ACK CLOSED: 이 탐침에 리셋 응답을 관찰
ICMP ICMP: type/code를 기록. 그 안에 인용된 TCP 헤더를 서버의 TCP 응답으로 읽지 않음
시간 제한까지 대응 응답 없음 NO_RESPONSE: 원인 미확정. 차단·손실·경로·권한/캡처 설정·대기 시간 등을 따로 조사
잘못된 주소·포트·ACK 또는 다른 플래그 UNMATCHED/OTHER: 열림·닫힘으로 억지 분류하지 않음

무응답만으로 방화벽 차단이나 대상의 종료를 확정할 수 없습니다. SYN 관찰도 방화벽·패킷 캡처·IDS에 보일 수 있습니다. 애플리케이션 접근 로그의 유무는 프로그램의 기록 조건에 달려 있습니다.

3. 직접 실행하기

3-1. 파일과 실행 환경 준비

실습 ZIP을 풀고 그 폴더에서 실행하세요. 실행 코드·자동 재현기·두 언어 안내·환경별 결과·파일 해시가 포함됩니다. 개별 파일: syn_scan.py, packet_lab.py, reproduce.py, 한국어 안내.

python3 -m venv .venv
.venv/bin/python -m pip install -r requirements.txt
.venv/bin/python -c "import scapy; print(scapy.__version__)"
.venv/bin/python packet_lab.py build
.venv/bin/python reproduce.py --offline

이 묶음은 실제 검증한 Scapy2.7.0을 지정합니다. 항상 최신 버전이라는 뜻은 아닙니다. externally-managed-environment는 시스템 Python에 설치하려 할 때 나타날 수 있습니다. 가상환경을 쓰고, venv 모듈이 없으면 배포판의 Python venv 패키지를 설치합니다. sudo pip install로 시스템 환경을 바꾸지 않습니다.

송수신에는 원시 소켓 권한이 필요합니다. 아래 sudo는 이미 준비된 가상환경의 Python으로 이 실습 파일을 실행합니다. 일반 사용자의 대화형 Python에서 그대로 sr1()을 실행하면 권한 오류가 날 수 있습니다. 컨테이너라면 root라는 이름만으로 충분하지 않고 NET_RAW capability도 필요합니다.

Windows에서는 py -m venv .venv, .venv\Scripts\python.exe -m pip install -r requirements.txt 후 그 Python으로 build와 –offline을 실행할 수 있습니다. Windows 송수신에는 Npcap과 적절한 권한·인터페이스 설정이 필요합니다. 특정 인터페이스 오류 하나만으로 Npcap 미설치를 확정하지 마세요. 이 묶음의 실제 송수신 검증은 Linux에서 했으며 Windows·WSL의 송수신 성공을 검증했다고 주장하지 않습니다.

3-2. 조립과 필드 확인

from scapy.all import IP, TCP, Raw
packet = IP(src="127.0.0.1", dst="127.0.0.1")/TCP(sport=40000, dport=9999, flags="S")/Raw(b"hello")
packet.show()
decoded = IP(bytes(packet))
print(decoded.len, decoded[TCP].flags, decoded[Raw].load)

이것은 조립 연습이며 전송하지 않습니다. 제공한 build에서 직렬화 전 IP 길이는 None, 직렬화 후 전체 길이는 45바이트, 데이터는 hello였습니다. IP와 TCP 체크섬도 계산됩니다. FlagValue는 Scapy의 플래그 객체이며 검증한 2.7.0에서는 문자열 비교가 지원됩니다. 실패하지 않은 문자열 비교를 오류 사례로 만들지 않습니다. 참고 구현은 비트 마스크로 SYN/ACK/RST/FIN 조합을 검사하며 충돌하는 SAR를 OPEN으로 오판하지 않습니다.

3-3. 열린 포트와 종료 후 비교

Linux 터미널 A에서 아래 서버를 시작합니다. 같은 환경의 터미널 B에서 스캐너를 실행하세요. WSL의 127.0.0.1과 Windows 호스트의 동작을 자동으로 동일하다고 가정하지 않습니다. 9999가 이미 사용 중이면 소유 프로그램을 확인하고 수동 연습에는 다른 포트와 –ports를 함께 사용하세요.

# 터미널 A
python3 -m http.server 9999 --bind 127.0.0.1

# 터미널 B: 실습 파일 폴더에서
sudo .venv/bin/python syn_scan.py --ports 9999
sudo .venv/bin/python syn_scan.py --ports 21,22,80,443,9999 --json

실측에서는 서버 실행 중 9999 OPEN TCP flags=SA였습니다. 터미널 A에서 Ctrl+C로 서버를 종료한 뒤 같은 스캐너를 실행하면 9999 CLOSED TCP flags=RA였습니다. 나머지 포트는 컴퓨터마다 다릅니다. 특정 번호가 항상 닫혀 있다고 가정하지 마세요.

3-4. ICMP echo 응답 확인

sudo .venv/bin/python packet_lab.py echo

실측에서는 matchingEchoReply가 true였습니다. 코드는 바깥 ICMP의 type=0·code=0, 주소, id, seq를 모두 확인합니다. 패킷이 하나 왔다는 이유만으로 ‘살아 있음’이라고 출력하지 않습니다. echo 실패는 호스트 종료의 확정 증거가 아닙니다.

3-5. sniff의 기록 수와 요청 수

sudo .venv/bin/python packet_lab.py sniff

캡처가 실제로 준비된 뒤 고유한 ICMP id로 요청 3개를 보냅니다. iface는 lo이며 해당 id와 루프백 주소만 고릅니다. 이번 Linux 캡처에서는 요청 3개 → 원시 기록 12개 → (type,id,seq) 조합 6개였습니다. 각 요청과 응답이 두 번 포착됐습니다. 앞의 네 줄이 요청·요청·응답·응답이면 같은 seq인지 먼저 확인해야 합니다.

중복 수는 캡처 방식과 환경에 따라 달라질 수 있습니다. 이 실험의 id·seq별 묶기는 우리가 만든 세 요청을 이해하기 위한 방법이며, 일반 트래픽에서 재전송을 무조건 삭제하는 규칙이 아닙니다. 외부 웹페이지를 여는 것만으로 lo에 ICMP가 생기는 것은 아닙니다.

3-6. 출발지 필드 변경

sudo .venv/bin/python packet_lab.py source

코드는 IP(src="127.0.0.2", dst="127.0.0.1")/ICMP(...)를 실제로 보냅니다. 두 주소 모두 루프백 범위입니다. 이번 환경에서는 응답 목적지가 127.0.0.2였고 matchingEchoReply도 true였습니다. 출발지 필드를 바꾸었다고 응답이 항상 사라지는 것은 아닙니다. 임의 사설 주소를 ‘존재하지 않는 주소’라고 단정할 수도 없습니다. 다른 망에서는 라우팅과 필터링 조건을 별도로 고려해야 합니다.

일반적인 SOCK_STREAM 연결 API와 원시 소켓 API를 구별하세요. Scapy도 운영체제 소켓을 사용하므로 ‘소켓으로는 원천적으로 불가능하다’는 설명은 부정확합니다. 여기서 차이는 보통의 TCP 연결 API에 맡기던 헤더를 직접 구성한다는 점입니다.

3-7. connect와 애플리케이션 로그

서버를 다시 켜고 SYN 스캔, 연결만 하기, HTTP 요청을 차례로 비교합니다.

sudo .venv/bin/python syn_scan.py --ports 9999
.venv/bin/python -c "import socket; s=socket.create_connection(('127.0.0.1',9999)); s.close()"
.venv/bin/python -c "import urllib.request; print(urllib.request.urlopen('http://127.0.0.1:9999/').status)"

재현기의 HTTP 서버에서는 SYN 후 접근 로그 0개, 연결만 한 뒤에도 0개, GET을 보낸 뒤 1개였습니다. TCP 연결 성립과 HTTP 요청 로그를 구분해야 합니다. 이 관찰을 다른 서비스·네트워크 감시 장비에도 로그가 없다는 주장으로 확대하지 않습니다.

4. 미션과 연습문제

참고 구현을 보기 전에 고정 대상·포트 목록·응답 판별·출력을 함수로 나누어 작성하세요. 9999의 실행 전후 결과와 Step79 connect 결과를 비교하고, ICMP·무응답·예상 밖 플래그를 별도로 기록하세요. README에 환경·실측 출력·실패 원인·서버 종료 방법을 남깁니다.

  1. IP()/TCP()/Raw(b”hello”)의 각 층은 무엇이고 언제 길이·체크섬이 계산되는가?
  2. SYN-ACK와 RST-ACK를 읽기 전에 왜 바깥 프로토콜과 ACK를 확인하는가?
  3. 응답이 없으면 filtered라고 확정할 수 있는가? 다음에 확인할 조건 두 가지를 적어 보자.
  4. echo 요청 3개를 보냈는데 12개가 포착되면 무엇을 대조해야 하는가?
  5. 출발지 주소 변경과 응답 유무, 일반 연결 API와 원시 소켓은 어떤 관계인가?
  6. connect가 성공하면 HTTP 접근 로그가 반드시 남는가?

5. 참고 구현과 실측 기록

아래 전체 코드는 다운로드 파일과 같습니다. sr1()의 응답 대응 기능에 더해 직접 TCP 응답의 주소·포트·ACK를 확인합니다. 교육용 IPv4 SYN 관찰기이며 모든 방화벽의 포트 상태를 확정하는 도구는 아닙니다.

"""Step080: a small IPv4 SYN observation exercise, fixed to local loopback."""
import argparse
import json
import secrets
import sys

from scapy.all import IP, TCP, ICMP, conf, sr1

TARGET = "127.0.0.1"


def configure_linux():
    if not sys.platform.startswith("linux"):
        raise RuntimeError("Run live exercises in a Linux lab; offline packet construction works elsewhere.")
    from scapy.supersocket import L3RawSocket
    conf.use_pcap = False
    conf.L3socket = L3RawSocket


def classify(answer, sent):
    """Inspect the OUTER response, never a TCP header quoted inside ICMP."""
    if answer is None:
        return {"state": "NO_RESPONSE", "detail": "No matching reply before timeout; cause undetermined"}
    if not isinstance(answer, IP):
        return {"state": "OTHER", "detail": "Not an IPv4 response"}
    if isinstance(answer.payload, ICMP):
        return {"state": "ICMP", "detail": f"type={answer[ICMP].type} code={answer[ICMP].code}; not a TCP verdict"}
    if not isinstance(answer.payload, TCP):
        return {"state": "OTHER", "detail": "Not a direct TCP response"}
    tcp = answer[TCP]
    if (answer.src, answer.dst, tcp.sport, tcp.dport) != (
        sent.dst, sent.src, sent[TCP].dport, sent[TCP].sport
    ) or not (int(tcp.flags) & 0x10) or tcp.ack != ((sent[TCP].seq + 1) & 0xffffffff):
        return {"state": "UNMATCHED", "detail": "Addresses, ports or SYN acknowledgment do not match"}
    # Ignore ECN flags here, but reject conflicting SYN/RST/FIN combinations.
    control = int(tcp.flags) & 0x17
    state = "OPEN" if control == 0x12 else "CLOSED" if control == 0x14 else "OTHER"
    return {"state": state, "detail": f"TCP flags={tcp.flags}"}


def observe(port, timeout=1.0):
    configure_linux()
    sent = IP(src=TARGET, dst=TARGET) / TCP(
        sport=secrets.randbelow(20000) + 40000,
        dport=port, flags="S", seq=secrets.randbits(32)
    )
    answer = sr1(sent, timeout=timeout, verbose=0)
    return {"port": port, **classify(answer, sent)}


def parse_ports(value):
    try:
        ports = sorted(set(int(p) for p in value.split(",")))
    except ValueError as exc:
        raise argparse.ArgumentTypeError("Use comma-separated integer ports") from exc
    if not ports or len(ports) > 32 or not all(1 <= p <= 65535 for p in ports):
        raise argparse.ArgumentTypeError("Choose 1 to 32 distinct ports, each 1..65535")
    return ports


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--ports", type=parse_ports, default=parse_ports("21,22,80,443,9999"))
    parser.add_argument("--json", action="store_true")
    args = parser.parse_args()
    try:
        result = {"target": TARGET, "observations": [observe(port) for port in args.ports]}
    except (PermissionError, OSError, RuntimeError) as exc:
        print(f"Cannot run packet exercise: {exc}. Use the lab venv Python with raw-socket permission.", file=sys.stderr)
        return 1
    if args.json:
        print(json.dumps(result))
    else:
        print(f"Target: {TARGET}")
        for row in result["observations"]:
            print(f"{row['port']:5} {row['state']:12} {row['detail']}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

조립·echo·캡처·출발지 예제 전체 코드와 재현기 전체 코드도 제공합니다. 수동 서버를 종료해 9999를 비운 뒤 다음을 실행하세요. 재현기는 임시 서버와 소켓을 만들고 종료 시 닫습니다.

sudo .venv/bin/python reproduce.py

2026-09-25, Scapy2.7.0으로 Windows Python3.12.10에서 패킷 조립과 구성한 응답 10경우를 검사했습니다. Linux Python3.14.7에서는 같은 검사에 더해 외부 네트워크 없는 컨테이너의 실제 루프백 SYN/RST, ICMP, 출발지 변경, 캡처, HTTP 로그, 잘못된 입력 6경우를 확인했습니다. 별도 실행에서 NET_RAW를 제거하자 권한 오류로 종료 코드1을 반환했습니다. Windows 오프라인 기록 · Linux 실제 송수신 기록 · 권한 거부 기록.

ICMP 인용 헤더·잘못된 ACK·충돌 플래그 판별은 구성한 패킷으로 시험한 것입니다. 실제 방화벽이 SYN을 버리는 실험은 하지 않았습니다. 실제 무응답 관찰은 로컬 RST가 답을 유발하지 않은 경우로, SYN 차단 증거와 구별합니다. Windows·WSL 송수신과 모든 OS·캡처 환경을 검증한 결과는 아닙니다.

연습문제 해답

  1. IP 안에 TCP와 데이터가 들어갑니다. 미지정 길이·체크섬은 직렬화 과정에서 계산되며 바이트를 재해석해 확인할 수 있습니다.
  2. ICMP 오류는 원래 TCP 헤더를 안에 인용할 수 있습니다. 그것을 서버의 직접 응답으로 착각하면 오판합니다. ACK는 이 SYN의 seq+1인지 확인합니다.
  3. 확정할 수 없습니다. 알려진 로컬 서버, 대기 시간, 권한, 인터페이스·소켓 방식, 경로와 필터를 점검합니다.
  4. type·id·seq를 대조합니다. 이번 결과는 요청과 응답이 각각 두 번 포착된 것이며 요청 12개를 전송한 것이 아닙니다.
  5. 일반 연결 API는 TCP 헤더 처리를 커널에 맡깁니다. 원시 소켓을 쓰는 Scapy에서는 출발지 필드를 지정할 수 있고, 응답 도달은 주소·경로·필터에 달려 있습니다.
  6. 아닙니다. 이 HTTP 서버는 요청을 기록하므로 연결만 한 경우와 GET 요청의 로그 결과가 달랐습니다.

6. 오류가 날 때

  • Operation not permitted: 가상환경 Python 경로와 원시 소켓 권한을 확인합니다. 패킷을 못 보냈다는 오류를 NO_RESPONSE로 감추지 않습니다.
  • ModuleNotFoundError: pip와 실행 Python이 같은 가상환경인지 확인합니다. sudo python 대신 지정한 .venv/bin/python을 사용합니다.
  • 루프백 무응답: 제공 코드는 Linux에서 libpcap 경로 대신 L3RawSocket을 명시합니다. 서버·포트와 실행 환경을 확인하세요. 방화벽 전체를 끄는 해결책으로 시작하지 않습니다.
  • 9999 사용 중: 기존 프로세스를 임의 종료하지 않습니다. 수동 예제는 다른 포트로 맞추고, 자동 재현기는 본인이 켠 서버를 정상 종료한 후 실행합니다.
  • 캡처 0개 또는 개수 차이: lo, 권한, 생성한 id·seq와 캡처 시작 시점을 확인합니다. 루프백 중복 수를 고정된 보편값으로 외우지 않습니다.
  • Windows 인터페이스 오류: 설치·권한·실제 인터페이스 목록을 각각 확인합니다. 이 묶음의 live 명령은 Linux용입니다.

7. 완료 기준

패킷을 조립하고 실제 서버 시작·종료에 따른 결과를 설명하며, echo와 캡처 기록을 연결하고, 무응답·중복·로그의 한계를 설명할 수 있으면 완료입니다. 스캐너 실행 성공만으로 체크하지 말고 여섯 문제의 답을 자신의 관찰과 대조하세요. 실습 서버는 Ctrl+C로 종료하고 본인이 만든 기록을 보관합니다.

공식 참고: Scapy 설치 · 계층 조립과 사용법 · 루프백·소켓·커널 RST 설명. 공식 latest 문서의 버전과 이 묶음의 고정 검증 버전은 다를 수 있습니다.

한 단계 더 가까이

수업을 마쳤나요?

본문의 완료 기준을 확인한 뒤 직접 표시하세요.

화면이나 링크에 문제가 있나요? 알려주세요.
다음 수업