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를 시도하면 대답은 셋 중 하나입니다.
- 연결 성공 → 그 포트에는 서버가 살아 있습니다 (열림, open)
- 즉시 거절(Connection refused) → 문은 있지만 아무도 기다리지 않습니다 (닫힘, closed)
- 아무 대답 없음(시간 초과) → 방화벽이 두드림을 못 들은 척합니다 (걸러짐, filtered)
파이썬에는 이 실험을 위한 함수 connect_ex가 있습니다. connect는 실패 시 예외를 던지지만, connect_ex는 결과 코드 숫자를 돌려줍니다. 0이면 성공(열림), 그 외는 실패입니다. 스캐너에는 이쪽이 훨씬 편합니다.
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):
s = socket.socket()
s.settimeout(1)
result = s.connect_ex((ip, port))
s.close()
return result == 0 # True면 열림
print(check_port("127.0.0.1", 9999)) # 서버가 켜져 있다면 True
print(check_port("127.0.0.1", 65000)) # 거의 확실히 False
입력: 먼저 터미널 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:
s = socket.socket()
s.settimeout(2)
s.connect((ip, port))
banner = s.recv(1024).decode(errors="ignore").strip()
s.close()
return banner
except Exception:
return ""
입력 (__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 배너: (없음)
읽는 법: 22번의 배너 한 줄에 규약(SSH-2.0), 프로그램 이름(OpenSSH), 버전(9.5), 심지어 계열(Windows)까지 전부 들어 있습니다. 서비스가 자발적으로 내어 주는 명함입니다. 반면 135, 445번은 침묵 — 접속만으로는 아무 말도 안 하는 서비스입니다. errors="ignore"는 글자로 못 푸는 바이트가 섞여도 죽지 않게 하는 안전핀입니다.
왜: 이 명함 한 줄이 왜 민감한 정보인지 생각해 보세요. "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)
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의 server.py를 다시 켜 두고(이번에는 9990~10010 범위만 조여서 스캔하도록 scan(target, 9990, 10010)으로 바꿔 봅니다):
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 같은 번호가 있으면 각각 무슨 서비스인지 주석을 답니다
- 오늘 스캔한 결과를 "내 컴퓨터의 정상 상태 목록"으로 보관하고, 이 목록이 왜 필요한지 세 줄로 적습니다
- 스캐너 파일 맨 위에 "이 도구는 내 랩 전용이다"라는 주석을 스스로 적습니다
연습문제
문제 1. connect 스캔의 세 가지 결과(열림/닫힘/걸러짐)와 각각의 의미를 말해 보세요.
문제 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에 날짜와 대상이 함께 쌓이는가. ③ 각 열린 포트의 서비스명 주석이 있는가. ④ 스캐너 첫 줄에 랩 전용 주석이 있는가. 넷이 전부 ‘예’이면 완성입니다.
연습문제 해답
문제 1 해답. 연결 성공은 열림(서버가 기다리고 있음), 즉시 거절은 닫힘(포트는 있지만 서비스 없음), 무응답/시간 초과는 걸러짐(방화벽이 응답을 삼킴)입니다. 셋을 구분하는 것이 스캔 결과 해석의 시작입니다.
문제 2 해답. connect는 실패를 예외로 던져서 포트마다 try/except를 써야 하지만, connect_ex는 실패를 결과 코드(숫자)로 돌려줍니다. "0이면 열림, 아니면 다음 포트로"라는 평범한 if문으로 스캔 루프를 쓸 수 있어 스캐너에는 이쪽이 편합니다.
문제 3 해답. 순차 스캔은 한 포트의 기다림(거절이나 타임아웃)이 끝나야 다음 포트로 가므로, 기다림이 그대로 합산됩니다 — 실측에서 포트 100개에 30.9초. 스레드 풀은 여러 포트의 기다림을 서로 다른 직원이 동시에 기다리게 해 겹치게 만들므로, 전체 시간이 "가장 느린 한 건" 수준으로 줄어듭니다 — 같은 환경에서 1024개에 3.4초.
문제 4 해답. SSH처럼 "먼저 인사하는" 서비스와 달리, HTTP처럼 "손님이 먼저 말해야" 대답하는 서비스는 접속만으로는 침묵합니다. 그런 서비스에는 먼저 규약에 맞는 한 마디를 보내야 합니다 — 예를 들어 HTTP에는 GET / HTTP/1.0\r\n\r\n를 보내면 서버 정보가 담긴 응답이 옵니다. "먼저 말하는 서비스 vs 기다리는 서비스"의 구분 자체가 지식입니다.
완료 기준 체크리스트
- [ ] connect 스캔의 세 가지 결과(열림/닫힘/걸러짐)를 설명할 수 있다
- [ ] 타임아웃이 필요한 이유를 설명할 수 있다
- [ ] 스레드 풀로 스캔 속도를 높이고 시간을 기록했다
- [ ] 배너 그래빙으로 서비스 이름과 버전을 읽었다
- [ ] 일부러 연 포트(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 완료입니다. 사이드바의 체크박스를 눌러 진도를 저장하세요.