← 아티클 목록

시리즈파이썬으로 만드는 보안 자동화1편 · 공개 8편

파이썬 보안 자동화

connect_ex가 0이 아니라고 그 포트가 닫힌 것은 아니다

직접 띄운 localhost 서비스에 socket 스캐너를 돌려 open·closed 응답, errno, 배너 수집, 타임아웃과 병렬도를 측정했다. filtered는 재현하지 못했으며 Windows 관찰을 일반 규칙으로 넓히지 않도록 정정한 기록.

목차 5

2026-08-09 업데이트 — Windows 로컬 측정을 socket의 일반 동작으로 확대한 설명과 임의 IP를 무응답 대상으로 사용한 오류를 바로잡았다. connect_ex()의 errno 의미와 측정 환경의 범위를 명확히 했다.

스캐너가 「열림」이라고 안 하면 그 포트는 닫힌 걸까

포트 스캐너의 뼈대는 짧다. 대상 포트에 TCP 연결을 시도하고, 되면 열린 것으로 본다. 파이썬의 socket.connect_ex()는 연결에 성공하면 0, 실패하면 예외 대신 errno 값을 돌려준다. 0은 연결 성공으로 바로 읽을 수 있지만, 그 밖의 값은 번호의 뜻을 확인해야 한다.

포트 스캐닝의 개념 자체 — 어떤 포트가 열려 있는지, open·closed·filtered가 무엇인지 — 는 네트워크 강의에서 먼저 배웠다. 이 글은 거기에 ’그 판정을 파이썬 소켓으로 직접 해 보면 무엇이 보이고 무엇이 안 보이는가’를 얹은 실습이다. 개념은 뒤의 측정을 이해할 만큼만 짚고 넘어간다.

문제는 그 반대쪽이다. connect_ex가 0이 아니면 그 포트는 닫힌 것인가? 스캐너를 만들면 대개 ’0이면 열림, 아니면 닫힘’으로 두 갈래를 낸다. 이 글은 그 두 번째 갈래가 실제로 무엇을 담고 있는지 재 본 기록이다.

먼저 확인한 범위부터 적는다. Windows 11과 Python 3.14.6에서 settimeout()을 건 이 스크립트는 열린 loopback 포트에 0, 비어 있는 loopback 포트에 10035를 돌려줬다. 후자는 설정한 타임아웃만큼 기다렸다. 이것은 해당 구현의 관찰이며, 방화벽에 막힌 filtered 상태는 이 실습에서 만들거나 측정하지 못했다.

open·closed·filtered, 그리고 socket이 보는 것

입문 설명에서는 포트 상태를 흔히 셋으로 줄여 말한다.

상태 무슨 일이 일어났나 대상의 반응
open 서비스가 듣고 있다 연결 수락(SYN-ACK)
closed 아무도 안 듣는다 거부(RST)
filtered 필터 때문에 열림 여부를 판단할 수 없다 무응답 또는 특정 ICMP 오류

이 표는 TCP 연결 관점의 축약이다. Nmap 공식 설명은 open, closed, filtered 외에도 unfiltered, open|filtered, closed|filtered를 쓰고, 상태가 포트의 고정 속성이 아니라 특정 위치와 스캔 방식에서 관찰한 결과라고 설명한다. TCP SYN 스캔에서는 RST가 오면 closed로 보지만, filtered는 무응답뿐 아니라 일부 ICMP unreachable 응답으로도 판정될 수 있다.

직접 만든 소켓 스캐너는 운영체제가 정리한 연결 결과를 받는다. Python socket 문서에 따르면 connect_ex의 0이 아닌 값은 단순한 실패 표지가 아니라 errno다. ECONNREFUSED, ETIMEDOUT, ENETUNREACH처럼 원인이 다른 값이 나올 수 있으므로 모두 한 상태로 접으면 안 된다. 반대로 errno 하나만으로 Nmap의 모든 상태를 그대로 재현할 수도 없다.

정답을 아는 대상을 만들어 놓고 스캔했다

판정이 맞는지 보려면 어느 포트가 무슨 서비스인지 미리 알아야 한다. 그래서 외부를 스캔하는 대신 행동이 정해진 서버들을 내 localhost 에 직접 띄웠다.

portlab.py가 127.0.0.1의 빈 포트(49200번대)를 잡아 다섯 자리를 만든다. 넷은 성격이 다른 서버를 올리고 하나는 비워 둔다.

포트 정체 행동
49204 banner 접속하자마자 배너를 보낸다 (SSH·SMTP 방식)
49205 http 요청을 받아야 응답한다 (HTTP)
49206 silent 열려 있지만 아무것도 보내지 않는다
49207 slow 1.2초 뒤에야 배너를 보낸다
49208 closed 아무도 듣지 않는다

전부 127.0.0.1이고 전부 이 스크립트가 띄운 서버다. 바깥으로 나가는 연결은 없다. 스캐너·퍼저를 다루는 글에서 대상은 본인 소유이거나 명시적으로 허가된 것으로만 한정한다 — 이 실습에서는 그 경계를 「내가 방금 띄운 서버」로 그었다.

포트 번호는 실행할 때 비어 있는 것을 잡으므로 매번 다를 수 있다. 아래 숫자는 한 번의 실행에서 나온 것이고, 중요한 것은 번호가 아니라 각 포트에 걸린 행동이다.

1. connect_ex가 0이 아닐 때 무엇을 기록해야 하나

다섯 포트를 타임아웃 0.5초로 한 번씩 스캔하고, 반환값과 걸린 시간을 정답과 나란히 놓았다.

    포트 정답     판정            errno       시간
 49204 열림     open              0     1.0ms
 49205 열림     open              0     0.9ms
 49206 열림     open              0     0.7ms
 49207 열림     open              0    20.0ms
 49208 닫힘     no-response   10035   513.0ms

열린 네 포트는 모두 0이었고 정답표와 일치했다. 연결이 실제로 성립했으므로 이 범위에서 open 판정은 맞다.

비어 있는 loopback 포트에서 나온 10035는 Windows의 WSAEWOULDBLOCK이다. 이 값은 비블로킹 작업이 즉시 끝나지 않았다는 뜻이지, 포트가 닫혔다거나 방화벽이 패킷을 버렸다는 뜻이 아니다. Python의 timeout 모드는 내부적으로 소켓을 비블로킹 상태로 다루므로, 이 번호만 보고 대상 상태를 붙일 수 없다.

Microsoft의 connect 문서는 비블로킹 연결 시작 시 WSAEWOULDBLOCK이 나올 수 있고, 완료 뒤에는 WSAECONNREFUSED, WSAENETUNREACH, WSAETIMEDOUT처럼 구체적인 실패가 구분될 수 있다고 설명한다. 직접 비블로킹 연결을 구현한다면 readiness를 기다린 뒤 SO_ERROR를 확인하는 절차가 필요하다. 당시 스크립트는 그 절차를 구현하지 않았고, raw packet도 수집하지 않았다.

따라서 이 표가 말해 주는 것은 하나다. 이 Windows·Python 조합과 당시 timeout 처리에서는 비어 있는 loopback 포트가 10035로 끝났다. 여기서 “타임아웃을 걸면 closed와 filtered가 항상 같아진다”는 일반 규칙을 만들 수는 없다. filtered 상태는 이 실습에서 재현하지 않았으므로 둘을 비교했다는 과거 설명도 근거에서 제외했다.

반대로 connect_ex의 모든 nonzero 값을 closed로 적는 구현도 잘못이다. 반환된 errno와 걸린 시간을 함께 보존하고, 운영체제와 연결 모드에 맞춰 해석해야 한다. 패킷 필터 여부까지 판단하려면 이 API 바깥의 관찰이나 전문 스캐너의 판정이 더 필요하다.

오해가 번지는 경로는 짧다. connect_ex≠0을 전부 closed로 표기하면 서로 다른 네트워크 오류가 한 칸에 들어간다. 그 결과만 보고 “서비스가 없다”고 결론 내리면 조사 범위를 잘못 줄일 수 있다.

2. 이 구현에서는 닫힌 포트가 타임아웃을 채웠다

위 표에서 비어 있는 loopback 포트가 513ms 걸린 것이 두 번째 관찰로 이어진다. 같은 환경에서 타임아웃을 바꿔 가며 한 포트만 다시 쟀다.

  timeout=0.2s → 닫힌 포트 한 개에  208.2ms 소요
  timeout=0.5s → 닫힌 포트 한 개에  513.6ms 소요
  timeout=1.0s → 닫힌 포트 한 개에 1009.0ms 소요

이 세 번의 측정에서는 대기 시간이 설정값에 비례했다. 다른 운영체제나 연결 방식에서는 거부 오류가 즉시 돌아올 수 있으므로 “닫힌 포트의 비용은 항상 타임아웃”이라고 일반화하지 않는다. 다만 같은 동작이 1000개 중 990개에서 반복된다고 가정하면 단일 스레드 대기만 495초가 되는 계산은 맞다.

이런 대기가 많은 환경에서는 제한된 병렬화가 시간을 줄일 수 있다. 그 효과를 5절의 localhost 범위에서 쟀다.

3. 배너는 접속만으로 나오지 않는다

포트가 열린 것을 알았으면 다음은 ’무슨 서비스인가’다. 흔한 방법은 접속한 뒤 recv()로 서비스가 먼저 보내는 배너를 읽는 것이다. 열린 네 포트에 두 방식을 써 봤다 — 그냥 받기, 그리고 요청을 먼저 한 줄 보낸 뒤 받기.

    포트 정체       그냥 recv                    요청 먼저 보내고 recv
 49204 banner   SSH-2.0-OpenSSH_9.6        SSH-2.0-OpenSSH_9.6
 49205 http     —(타임아웃)                    HTTP/1.0 200 OK ...
 49206 silent   —(타임아웃)                    —(타임아웃)
 49207 slow     —(타임아웃)                    —(타임아웃)

banner 서버는 접속하자마자 배너를 준다. SSH나 SMTP가 이렇게 동작한다. 그런데 HTTP 서버는 그냥 받기로는 아무것도 안 나온다. HTTP는 클라이언트가 먼저 요청해야 응답하는 프로토콜이라, 스캐너가 recv()만 하고 기다리면 서버도 이쪽을 기다리며 타임아웃까지 침묵한다. HEAD / HTTP/1.0을 한 줄 보내자 바로 응답이 나왔다.

여기서 얻은 규칙은 ’배너가 비어 있다’가 ’서비스가 없다’는 아니다는 것이다. 열린 것은 1절에서 이미 0으로 확인됐고, 배너가 안 나오는 것은 그 포트가 요청을 먼저 기다리는 종류이거나(HTTP) 원래 조용한 서비스라는 뜻일 수 있다. silent 서버는 두 방식 모두에서 침묵했는데, 이건 스캐너의 한계가 아니라 그 서비스가 원래 그런 것이다.

같은 표에서 하나 더 — 포트 번호로 서비스를 추측하면 여기서 다 틀린다. 관습표는 80=http, 22=ssh라고 하지만 이 실습의 HTTP는 49205에 있고 배너 서버는 49204에 있다. 번호는 관습이지 근거가 아니다. 비표준 포트에서 도는 서비스는 번호만 봐서는 영영 안 보인다.

4. 「열렸나」와 「무슨 서비스인가」는 다른 시간을 쓴다

slow 서버가 이 둘을 갈라 보여 준다. 1.2초 뒤에야 배너를 보내는 서버에, 연결 판정과 배너 수집을 같은 타임아웃으로 나란히 돌렸다.

  timeout 연결 판정        배너
     0.5s open         —(타임아웃)
     1.0s open         —(타임아웃)
     1.5s open         220 slow-service ready

이 세 설정에서는 연결 판정이 모두 즉시 open으로 끝났다. TCP 핸드셰이크는 커널이 처리하므로 애플리케이션의 배너 전송 시각과는 별개다. 반면 배너는 타임아웃이 응답 지연(1.2초)을 넘어야 나왔다.

그래서 ’포트가 열렸나’와 ’무슨 서비스인가’는 필요한 대기 시간을 따로 정하는 편이 낫다. 이 localhost 실습에서는 연결이 즉시 끝났지만 원격 환경은 지연과 손실을 고려해야 하므로 짧은 값 하나를 정답처럼 쓸 수 없다. 배너는 애플리케이션 응답까지 기다리므로 연결보다 긴 여유가 필요할 수 있다. 당시 스크립트는 두 단계를 나눠 측정했다.

5. 스레드를 늘리면 (localhost에서는)

2절에서 병렬화가 필요하다고 했으니 실제로 얼마나 주는지 쟀다. 다섯 포트를 40번씩 반복해 200번의 연결 시도를 만들고, 스레드 수를 바꿨다.

  workers=   1 →  18277.6ms, 열린 포트 오판 0종
  workers=  50 →    454.1ms, 열린 포트 오판 0종
  workers= 500 →    461.6ms, 열린 포트 오판 0종

단일 스레드 18.3초가 50스레드에서 0.45초로 줄었다. 약 40배다. 200번 중 비어 있는 포트는 40번, 즉 20%였지만 각 시도가 약 0.5초를 써서 전체 시간의 대부분을 차지했다. 그 대기가 병렬로 겹치면서 시간이 줄었다. 50에서 500으로 더 늘려도 이 대상에서는 빨라지지 않았다.

오판은 이 localhost 대상과 500스레드까지의 범위에서 없었다. 그보다 많은 스레드나 원격 대상까지 안전하다는 뜻은 아니다. 원격에서는 지연·손실·로컬 소켓 자원 한계 때문에 결과가 달라질 수 있으며, 그 경우는 이 실습으로 재현하지 못했다.

측정에서 남긴 구현 기준

측정 결과를 구현 기준으로 정리하면 다음과 같다.

무엇을 바꿨나 결과
connect_ex≠0 을 한 상태로 묶을지 errno가 담은 실패 원인이 사라진다
timeout과 연결 완료 확인 방식 같은 대상도 반환 코드와 대기 시간이 달라질 수 있다
recv 전에 요청을 보낼지 HTTP 배너가 나오는지
판정과 배너에 같은 타임아웃을 쓸지 느린 서비스를 놓치거나 불필요하게 기다릴 수 있다
스레드 수 localhost에서는 50개까지 빨라졌고 그 뒤 이득이 없었다

그래서 소켓 스캐너를 만들 때 확인하는 순서를 이렇게 잡았다.

  1. 0이 아닌 값을 버리지 않는다. errno와 경과 시간을 기록하고 운영체제·연결 모드에 맞춰 해석한다.
  2. timeout과 병렬도는 측정 환경에 맞춘다. 이 localhost 결과를 원격 대상의 기본값으로 쓰지 않는다.
  3. 열림 판정과 배너 수집을 나눈다. 요구하는 대기 시간이 다르다.
  4. 배너를 읽을 때 이쪽이 먼저 말해야 하는 프로토콜을 고려한다. 빈 배너를 ’서비스 없음’으로 읽지 않는다.
  5. 포트 번호는 추측이지 근거가 아니다. 실제 응답으로 확인한다.

0은 연결이 성립했다는 확실한 결과다. 그 밖의 값은 하나의 실패 상태가 아니라 오류 코드이므로, 분류 정확도는 번호를 보존하고 연결 완료를 어떻게 확인했는지 남기는 데서 시작한다. 서비스 식별은 그 다음 단계다.

재확인 가능한 범위

기록 당시에는 portlab.py, scanner.py, scan_run.py 세 파일로 측정했다. 그러나 현재 저장소에는 이 파일들이 없고 본문에도 전체 코드가 남아 있지 않다. 따라서 위 수치는 지금 이 글만으로 다시 실행해 검증할 수 없는 당시 관찰값이다. 코드를 복원하고 원시 출력을 함께 남기기 전에는 재현 가능한 실험이라고 부르지 않는다.

다시 실습할 때의 대상은 직접 띄운 127.0.0.1 서비스나 명시적으로 격리한 실습망으로 제한한다. 임의의 사설 주소를 무응답 대상으로 쓰지 않는다. RFC 1918의 10.0.0.0/8은 기업 내부에서 실제 호스트에 할당하는 주소 공간이므로 10.255.255.1도 버려지는 주소라고 보장할 수 없다. filtered를 재현하려면 본인이 소유한 격리 환경에서 방화벽 drop 규칙을 만들고 대상·규칙·패킷 관찰을 함께 기록해야 한다.

측정 환경은 Windows 11 / Python 3.14.6이다. 10035는 Windows 소켓의 errno이며 Linux·macOS에서는 다른 번호가 나온다.

확인하지 못한 것은 세 가지다. 첫째, controlled firewall에서 filtered 상태를 만들지 않았다. 둘째, 스레드 과다가 원격 대상에서 열린 포트를 놓치는지는 localhost에서 재현되지 않았다. 셋째, Nmap을 같은 대상에 돌려 판정을 대조하지 않았다. 이 경계를 넘는 결론은 후속 실습과 재현 코드를 남긴 뒤에 세운다.