네트워크
Step 79. 포트 스캐너 — 열린 문을 찾아내는 정찰기
Level 1 — 프로그래밍과 컴퓨터 내부 | 난이도 ★★★★☆ | 예상 소요 시간 4시간
전제: Step 77~78을 마쳤다. 소켓 연결의 원리와 스레드를 안다. connect가 거절되는 현상(Connection refused)의 의미를 안다.
- 준비물: 파이썬, 그리고 스캔 대상이 될 내 컴퓨터(127.0.0.1). Step 77의 server.py를 “스캔 당할 서버”로 다시 씁니다.
- 주의: ⚠️ 이 챕터의 실습은 내 랩·합법 플랫폼 전용입니다. 허가 없는 시스템에 적용하면 범죄입니다.
Step 77에서 우리는 서버를 열었고, 클라이언트가 그 문을 두드렸습니다. 그런데 생각해 보세요 — 문을 두드리기만 하면 “열림/닫힘”을 알 수 있다면, 문을 하나씩 다 두드려 보면 그 건물의 지도가 그려지지 않을까요? 그것이 포트 스캔(port scan)입니다. 침투 테스트의 첫 단계인 정찰 행위이고, 공격자는 열린 문에서 약한 서비스를 찾고 방어자는 열릴 이유 없는 문을 닫습니다. 오늘은 이 스캐너를 직접 만듭니다. 시판 도구(nmap)를 쓰면 한 줄이지만, 직접 만들어 본 사람만이 그 도구의 출력을 진짜로 읽습니다.
1. 학습 목표
이 챕터를 끝내면 다음을 할 수 있습니다:
- 포트 스캔의 원리가 “접속 시도(connect)”임을 직접 만든 도구로 설명한다
- connect_ex와 settimeout으로 포트 하나를 검사하는 함수를 쓴다
- ThreadPoolExecutor로 스캔을 병렬화하고 속도 차이를 측정한다
- 배너 그래빙으로 열린 포트의 서비스 이름과 버전을 읽는다
- 스캔의 합법적 경계(내 랩, 허가된 대상)를 스스로 설명한다
2. 배경 지식 — 오늘의 도구와 개념
오늘의 도구 한눈에 보기
| 구분 | 내용 |
|---|---|
| 언어·환경 | 파이썬 3 표준 라이브러리 socket + concurrent.futures (설치 불필요) |
| 오늘의 함수 | connect_ex(), settimeout(), ThreadPoolExecutor, pool.map(), recv() |
| 필요한 개념 | 포트 열림/닫힘/걸러짐, 타임아웃, 배너 그래빙, 스레드 풀 |
| 오늘의 산출물 | scanner.py + scan_result.txt — 내 랩 전용 정찰 도구 |
2-1. 스캔의 원리 — 세 가지 대답
포트 하나에 connect를 시도하면 대답은 셋 중 하나입니다.
- 연결 성공: TCP 연결 수락을 관찰했습니다.
- 즉시 거부: 거부 응답을 관찰했으며 응답자와 필터 정책을 확인합니다.
- 시간 초과: 제한 시간 내 연결을 완료하지 못했으며 원인은 미확정입니다.
connect_ex는 성공 시 0, 여러 실패에서는 오류 번호를 반환하지만 예외도 가능합니다. 아래의 단순 불리언 함수는 연결 성공 여부만 기록하며 False가 closed나 filtered의 진단은 아닙니다.
2-2. 타임아웃 — 무한정 기다리지 않기
Step 77에서 배웠듯 연결 시도는 상대가 대답할 때까지 기다립니다. 대답 없는 포트에서 무작정 기다리면 스캔이 영원히 끝나지 않습니다. 그래서 settimeout(1)로 “1초만 기다리고 포기”를 정합니다. 스캐너의 속도는 사실 이 기다림의 관리에서 결정됩니다 — 3-2에서 몸으로 확인합니다.
2-3. 배너 그래빙 — 문 뒤의 목소리 듣기
포트가 열려 있다는 것만으로는 부족합니다. “무슨 프로그램이 기다리는가”를 알아야 합니다. 다행히 많은 서비스는 접속하자마자 자기 소개(배너, banner)를 먼저 보냅니다. SSH 서버는 접속 즉시 SSH-2.0-... 같은 문자열을 뱉습니다. 이 첫 인사를 받아 읽는 기술을 배너 그래빙(banner grabbing)이라 합니다. 서비스의 이름과 버전은 곧 취약점 검색의 실마리입니다.
2-4. 스레드 풀 — 정원이 정해진 직원 무리
포트 천 개를 하나씩 두드리면 기다림이 그대로 누적됩니다. 지난 시간의 스레드를 여기에 씁니다. 다만 스레드를 무한정 만드는 대신, ThreadPoolExecutor라는 “정원이 정해진 직원 풀”을 씁니다. 일거리(포트 번호)를 던져 주면 풀 안의 직원들이 나눠서 처리합니다.
3. 따라 하기
3-1. 단일 포트 검사 함수 — 스캐너의 심장
scanner.py를 만드세요.
입력 (scanner.py)
import socket
def check_port(ip, port):
try:
with socket.socket() as s:
s.settimeout(1)
return s.connect_ex((ip, port)) == 0
except OSError:
return False
if __name__ == "__main__":
print(check_port("127.0.0.1", 9999))
# Check that 65000 is unused before this comparison.
print(check_port("127.0.0.1", 65000))
입력: 먼저 터미널 1에서 Step 77의 server.py를 켜 두고(python server.py), 터미널 2에서 실행합니다.
python scanner.py
출력 (2026-09-09 실측):
True
False
읽는 법: echo 서버가 기다리는 9999번은 True, 아무도 없는 65000번은 False입니다. 여러분의 서버가 스캐너의 첫 포획물이 됐습니다. (참고: 우리 echo 서버는 한 손님을 받으면 끝나는 설계라, 이 검사 한 번에 접속 하나를 소비하고 종료합니다. 서버 창을 확인해 보세요.)
왜: 스캔의 원리가 “접속해 보고 결과 코드 보기” 한 줄임을 확인하는 단계입니다. 거창한 기술이 아니라, 지난 챕터의 connect 그 자체입니다.
3-2. 예측해 보기 — 100개를 순서대로 두드리면 걸리는 시간
예측입니다. 1번부터 100번까지 순서대로 검사하면 몇 초 걸릴까요? 대부분의 포트는 닫혀 있습니다. 닫힌 포트의 대답이 얼마나 빨리 오는지가 관건입니다. 예측을 적고 재 보세요.
입력 (scanner.py의 아래쪽에 추가)
import time
if __name__ == "__main__":
start = time.time()
open_ports = []
for port in range(1, 101):
if check_port("127.0.0.1", port):
open_ports.append(port)
print("순차 스캔(1~100) 열린 포트:", open_ports)
print("순차 걸린 시간:", round(time.time() - start, 1), "초")
출력 (2026-09-09 실측):
순차 스캔(1~100) 열린 포트: [22]
순차 걸린 시간: 30.9 초
읽는 법: 두 가지가 보입니다. ① 22번 포트가 열려 있었습니다 — 이 컴퓨터에는 SSH 서버가 살고 있었습니다. ② 포트 100개에 30.9초. 즉 닫힌 포트 하나당 약 0.3초씩 기다린 셈입니다. (실측 환경에서는 닫힌 포트의 거절이 즉시 오지 않고 타임아웃까지 버텼습니다 — 보안 소프트웨어가 연결 시도를 붙잡아 두는 환경에서 흔한 일입니다. 리눅스 랩이라면 거절이 즉각적이라 훨씬 빠릅니다.) 어느 환경이든 결론은 같습니다: 순차 검사는 기다림이 그대로 누적됩니다.
왜: “왜 병렬화가 필요한가”를 시간으로 체감하는 실험입니다. 예측과 얼마나 달랐나요?
3-3. 스레드 풀로 병렬화 — 기다림을 겹치기
같은 일을 스레드 풀에 맡깁니다. scanner.py에 함수를 추가하고 __main__을 고칩니다.
입력 (scanner.py에 추가)
from concurrent.futures import ThreadPoolExecutor
def scan(ip, start=1, end=1024, workers=100):
ports = range(start, end + 1)
with ThreadPoolExecutor(max_workers=workers) as pool:
results = pool.map(lambda p: (p, check_port(ip, p)), ports)
return [p for p, ok in results if ok]
입력 (__main__ 부분)
if __name__ == "__main__":
start = time.time()
print("스레드 풀(1~1024) 열린 포트:", scan("127.0.0.1"))
print("스레드 풀 걸린 시간:", round(time.time() - start, 1), "초")
출력 (2026-09-09 실측):
스레드 풀(1~1024) 열린 포트: [22, 135, 445]
스레드 풀 걸린 시간: 3.4 초
읽는 법: 범위는 10배 넓어졌는데(100→1024) 시간은 10분의 1로 줄었습니다(30.9→3.4초). 직원 100명이 각자 따로 기다리니 기다림이 겹친 것입니다. 그리고 새로 잡힌 135, 445번 — 윈도우의 기본 서비스(RPC, 파일 공유) 포트입니다. 내 컴퓨터인데도 “이게 왜 열려 있지?”라는 질문이 생겼다면, 그것이 방어자의 눈이 열리는 순간입니다.
왜: 지난 시간의 스레드가 “서비스의 동시성”이었다면, 오늘의 스레드 풀은 “일처리의 동시성”입니다. 같은 기술, 다른 얼굴입니다. 속도 비교 숫자를 노트에 기록해 두세요.
3-4. 배너 그래빙 — 문 뒤의 목소리
열린 포트를 찾았으면 정체를 물어 봅니다. check_port의 사촌 함수를 만듭니다.
입력 (scanner.py에 추가)
def grab_banner(ip, port):
try:
with socket.create_connection((ip, port), timeout=2) as conn:
try:
banner = conn.recv(1024)
except socket.timeout:
return "CONNECTED; no banner before read timeout"
return "CONNECTED; " + repr(banner)
except OSError as exc:
return "CONNECTION FAILED: " + type(exc).__name__
입력 (__main__에 이어서)
for p in scan("127.0.0.1"):
print(f"포트 {p} 배너: {grab_banner('127.0.0.1', p) or '(없음)'}")
출력 (2026-09-09 실측):
포트 22 배너: SSH-2.0-OpenSSH_for_Windows_9.5
포트 135 배너: (없음)
포트 445 배너: (없음)
교정 배너 읽기는 디코딩 오류를 버리지 않고 repr로 받은 바이트를 보존합니다. 배너는 상대가 제공한 근거이며 검증된 서비스 정체가 아닙니다. 빈 바이트나 timeout은 추가 조사가 필요합니다.
왜: 이 명함 한 줄이 왜 민감한 정보인지 생각해 보세요. “OpenSSH 9.5의 알려진 취약점”을 검색할 수 있는 사람에게 배너는 공격의 목차이고, 방어자에게는 업데이트 체크리스트입니다. 같은 정보를 어느 편에서 읽느냐의 차이일 뿐입니다.
3-5. 완성 — 대상 확인과 결과 저장
조각들을 모아 완성품으로 만듭니다. scanner.py의 마지막 부분입니다.
입력 (scanner.py의 __main__ 전체)
import sys
from datetime import datetime
if __name__ == "__main__":
if len(sys.argv) < 2:
print("사용법: python scanner.py <내 랩의 IP>")
sys.exit(1)
target = sys.argv[1]
print(f"대상: {target} — 내 랩 장비인지 다시 확인하세요!")
open_ports = scan(target, start=9999, end=9999)
with open("scan_result.txt", "a", encoding="utf-8") as fp:
fp.write(f"=== {datetime.now()} | 대상 {target} ===\n")
for p in open_ports:
banner = grab_banner(target, p)
line = f"포트 {p} 열림 배너: {banner or '(없음)'}"
print(line)
fp.write(line + "\n")
fp.write("\n")
최종 예제는 Step 77 모범 답안의 지속 실행 로그 에코 서버를 실행하고 127.0.0.1:9999만 검사하세요. 연결 검사와 배너 시도는 별도 연결을 사용하므로 초반의 단일 클라이언트 서버는 첫 연결 뒤 종료됩니다. 에코 서버는 입력을 기다리므로 빈 배너가 예상됩니다.
python scanner.py 127.0.0.1
출력 (2026-09-09 실측):
대상: 127.0.0.1 — 내 랩 장비인지 다시 확인하세요!
포트 9999 열림 배너: (없음)
읽는 법: 실행할 때 대상을 인자로 받고, 확인 경고 문구가 먼저 뜨는 것은 실수 방지용입니다. 일부러 열어 둔 우리 서버가 정확히 잡혔습니다. 우리 echo 서버는 인사를 안 하는 설계라 배너는 비어 있습니다. (이 실측에서는 9990~10010 범위 스캔에 6.2초가 걸렸습니다.)
왜: 인자 받기, 확인 문구, 결과 누적 — 도구의 “품격”은 이런 디테일에서 나옵니다. 여러분의 첫 정찰 도구 완성입니다.
4. 미션과 연습문제
미션 — 스캐너 완성과 정상 목록 만들기
- 순차 스캔과 스레드 풀 스캔의 시간을 같은 범위에서 각각 재어 비교표를 만듭니다
- 127.0.0.1 전체 범위(1~1024)를 스캔해 열린 포트와 배너를 scan_result.txt에 쌓습니다
- 결과에 22, 80, 443, 135, 445 같은 번호가 있으면 각각 무슨 서비스인지 주석을 답니다
- 오늘 스캔한 결과를 “내 컴퓨터의 정상 상태 목록”으로 보관하고, 이 목록이 왜 필요한지 세 줄로 적습니다
- 스캐너 파일 맨 위에 “이 도구는 내 랩 전용이다”라는 주석을 스스로 적습니다
연습문제
Q1. 연결 성공·거부·시간 초과를 구분하세요. 어떤 원인이 미확정이며 불리언 check_port 함수는 어떤 정보를 버리나요?
문제 2. connect 대신 connect_ex를 쓰면 스캐너 만들기가 왜 편해지나요?
문제 3. 3-2에서 순차 스캔이 느렸던 이유를 “기다림의 누적”이라는 관점으로 설명하고, 스레드 풀이 이를 어떻게 해결하는지 말해 보세요.
문제 4. 배너 그래빙에서 빈 문자열이 나오는 경우는 어떤 서비스인지, 그런 서비스의 정체를 알려면 어떻게 해야 하는지 말해 보세요.
해설과 완료 기준 · 펼치기/접기
5. 모범 답안과 완료 기준
미션 모범 답안
속도 비교표의 예 (2026-09-09 실측 — 환경에 따라 다릅니다):
| 방식 | 범위 | 시간 |
|---|---|---|
| 순차 | 1~100 | 30.9초 |
| 스레드 풀(100) | 1~1024 | 3.4초 |
잘 알려진 포트 주석의 예: 22 SSH(원격 접속), 80 HTTP(웹), 443 HTTPS(암호화 웹), 135 윈도우 RPC, 445 윈도우 파일 공유(SMB).
4번의 답 예시: “어느 포트가 원래 열려 있었는지를 알아야, 나중에 ‘원래 없던 포트가 열렸다’는 이상 징후를 알아챌 수 있다. 침해사고 조사에서 ‘평소와 다른 열린 포트’는 최우선 단서다. 정상을 아는 것이 이상을 아는 것의 시작이다.”
검증하는 법: ① 두 방식의 시간이 표로 기록됐는가. ② scan_result.txt에 날짜와 대상이 함께 쌓이는가. ③ 각 열린 포트의 서비스명 주석이 있는가. ④ 스캐너 첫 줄에 랩 전용 주석이 있는가. 넷이 전부 ‘예’이면 완성입니다.
연습문제 해답
Q1: TCP 연결 성공은 해당 주소·포트에서 연결을 받아들였다는 증거입니다. 연결 거부는 호스트나 거부 응답을 보내는 필터에서 올 수 있습니다. timeout만으로는 필터링, 라우팅, 손실, 느린 끝점을 구분하지 못합니다. 모든 timeout을 filtered로 바꾸어 표시하지 마세요.
Q2: connect_ex는 성공 시 0, 여러 연결 실패에서는 오류 번호를 반환하지만 주소 해석이나 인자 문제 등에서는 예외가 발생할 수도 있습니다. 대상을 검증하고 컨텍스트 매니저와 timeout을 사용하며 실패 종류를 구분해 기록하세요.
Q3: 제한된 작업자 풀은 대기 시간을 겹칩니다. N개의 동일 시간 작업과 W명의 작업자라면 대략 ceil(N/W) 곱하기 작업 하나의 시간에 오버헤드를 더한 값으로 생각할 수 있습니다. 응답 시간, 제한, 스케줄링에 따라 달라집니다. 과거 측정값은 해당 환경의 관찰이지 성능 보장이 아닙니다.
Q4: 먼저 인사하는 프로토콜도 있고 요청을 기다리는 것도 있습니다. 배너 부재는 시간·필터·연결 종료·다른 실패 때문일 수도 있습니다. 소유자가 HTTP라고 확인했다면 최소 요청으로 응답을 볼 수 있지만 침묵만으로 프로토콜을 단정하지 마세요.
완료 기준 체크리스트
- [ ] 연결 수락·거부·시간 초과를 구분하고 원인을 확정하지 않는다.
- [ ] 타임아웃이 필요한 이유를 설명할 수 있다
- [ ] 스레드 풀로 스캔 속도를 높이고 시간을 기록했다
- [ ] 배너 그래빙으로 서비스 이름과 버전을 읽었다
- [ ] 일부러 연 포트(9999)가 스캔에 잡히는 것을 확인했다
- [ ] 내 랩 전용 스캐너와 결과 파일, 정상 목록을 완성했다
6. 흔한 실수와 해결
벽 1. 스캔이 너무 느리다
증상 (2026-09-09 실측): 순차로 포트 100개를 두드렸더니 30.9초가 걸립니다.
원인: 닫힌 포트의 응답이 즉시 오지 않는 환경(보안 소프트웨어가 연결을 붙잡아 두는 경우 등)에서는 매번 타임아웃을 전부 씁니다. 기다림이 그대로 누적됩니다.
해결: settimeout을 확인하고 ThreadPoolExecutor로 기다림을 겹치세요. 그래도 느리면 범위를 좁혀(1~100) 먼저 테스트합니다.
벽 2. connect_ex인데 예외가 난다
증상: connect_ex를 썼는데 예외가 튀어나옵니다.
원인: 주소 자체가 잘못됐을 때(형식 오류, 이름 해석 실패)는 connect_ex도 예외를 던집니다. “결과 코드로 돌려준다”는 연결 시도의 성패에만 해당합니다.
해결: try/except로 감싸서 실패를 False로 처리하세요. 스캐너는 어떤 입력에도 죽지 않아야 합니다.
벽 3. 배너가 전부 빈 문자열이다
증상 (2026-09-09 실측): 135, 445처럼 열린 포트인데 배너가 안 잡힙니다.
원인: HTTP, SMB처럼 “손님이 먼저 말해야” 대답하는 서비스가 많습니다. 접속만으로는 침묵합니다.
해결: 정상입니다. 그런 서비스에는 규약에 맞는 첫 마디를 먼저 보내야 합니다. 빈 배너 자체가 “이 서비스는 먼저 말하지 않는 타입”이라는 정보입니다.
벽 4. 스레드를 너무 많이 만들었다
증상: workers를 수천으로 올렸더니 오히려 느려지거나 오류가 납니다.
원인: 스레드도 자원입니다. 운영체제와 네트워크 장비가 감당하는 동시 연결 수에는 한계가 있습니다.
해결: 100 내외면 충분합니다. 빠르게 하려면 스레드 수보다 타임아웃과 범위를 조정하는 쪽이 효과적입니다.
벽 5. 어느 대상까지 스캔해도 되는지 헷갈린다
증상: 공유기나 학교 컴퓨터도 “궁금해서” 스캔하고 싶어집니다.
원인: 호기심은 좋지만, 허락받지 않은 스캔은 지역에 따라 법에 걸립니다. 궁금함과 허락은 별개입니다.
해결: 기준은 단 하나 — 내가 소유하고 설정한 장비인가, 아니면 연습 플랫폼이 명시적으로 허락한 대상인가. 둘 다 아니면 하지 않습니다. 궁금하면 내 랩에 그 서비스를 직접 설치해서 스캔하세요.
7. 정리
오늘의 개념
| 개념 | 한 줄 설명 |
|---|---|
| 포트 스캔 | 포트에 접속을 시도해 열림/닫힘을 조사하는 정찰 행위 |
| 열림/닫힘/걸러짐 | 연결 성공 / 즉시 거절 / 무응답 — 스캔의 세 가지 대답 |
| 타임아웃 | 기다림의 상한 — 스캐너 속도를 결정하는 값 |
| 배너 그래빙 | 접속 즉시 오는 서비스의 자기 소개(이름·버전)를 받는 기술 |
| 스레드 풀 | 정원이 정해진 일꾼 무리 — 기다림을 겹쳐 속도를 냄 |
| 정상 목록 | 평소의 열린 포트 기록 — 이상 징후 탐지의 기준선 |
오늘의 함수
| 함수 | 하는 일 |
|---|---|
connect_ex((ip, port)) |
접속을 시도하고 결과를 코드로 돌려받기 (0=열림) |
settimeout(초) |
기다림의 상한 정하기 |
ThreadPoolExecutor(max_workers=n) |
정원 n명의 스레드 풀 만들기 |
pool.map(함수, 목록) |
일거리를 풀의 직원들에게 자동 배분 |
recv(1024) (배너용) |
접속 직후 오는 첫 인사 받기 |
명령어보다 중요한 감각
오늘 만든 스캐너는 침투 테스트의 표준 첫 단계인 “정찰”의 도구입니다. 공격자는 이 결과로 공격 순서를 정하고, 방어자는 같은 결과로 닫을 문을 정합니다. 스캐너는 도둑의 손전등이 될 수도, 경비원의 손전등이 될 수도 있습니다 — 구분은 손전등이 아니라 여러분에게 달려 있습니다. 그리고 여러분이 스캔하면 상대의 로그에는 “한 주소에서 많은 포트로 짧은 접속”이라는 선명한 발자국이 남습니다. 침입 탐지 시스템이 바로 그 패턴을 감시합니다. 스캔이 어떻게 보이는지 아는 것이 탐지 규칙을 이해하는 첫걸음입니다.
마지막으로, 오늘의 방식은 “connect 스캔”이라 부르며 정확하지만 연결을 끝까지 완성해 기록을 많이 남깁니다. 실전 도구들은 악수의 첫 단계만 보고 판별하는 SYN 스캔 같은 변종을 씁니다 — 그 원리를 다음 챕터에서 패킷을 손으로 조립하며 만나게 됩니다. 도구를 직접 만들어 본 사람이 도구의 진짜 주인입니다.
전부 체크되면 Step 79 완료입니다.
한 단계 더 가까이
수업을 마쳤나요?
본문의 완료 기준을 확인한 뒤 직접 표시하세요.