대량의 Nuclei 템플릿으로 한 서버를 스캔하는 작업을 하고 있었다. 수만 개의 템플릿을 한 번에 돌리는 구성이었는데, 결과를 검증하다가 이상한 점을 발견했다.
같은 타겟에 같은 템플릿으로 돌렸는데, 매칭된 취약점 수가 실행할 때마다 달랐다.
1차 실행: 266건
2차 실행: 262건
3차 실행: 260건스캔 결과가 재현되지 않으면 "이전보다 탐지가 늘었다/줄었다"는 비교 자체가 무의미해진다. 원인을 찾아야 했다.
처음 의심: 합산이 안 맞는다
문제를 처음 알아챈 건 두 템플릿 묶음을 따로, 그리고 합쳐서 돌렸을 때였다.
A 묶음 단독: 66건
B 묶음 단독: 261건
A + B 합쳐서 실행: 338건 ← 66 + 261 = 327인데 11건이 더 나온다?산술적으로 66 + 261 = 327이어야 하는데 합쳐 돌리면 338건이 나왔다. 11건이 어디서 생긴 건지 한참 고민했다. 두 가지 가설을 세웠다.
- 템플릿 상호작용: 한 템플릿이 읽어둔 서비스 배너를 다른 템플릿이 재사용해서, 합쳐 돌릴 때만 추가로 매칭되는 경우
- 런별 편차: "261"이라는 수치 자체가 재현 불가능한 단일 측정값
1번이 맞다면 흥미로운 발견이고, 2번이 맞다면 애초에 덧셈으로 검증한다는 발상 자체가 틀린 것이다.
확인: 같은 스캔을 3번 돌려본다
가장 단순한 검증부터 했다. B 묶음 단독을 3회 반복 실행했다.
266 / 262 / 260흔들렸다. 단독 합과 Combined가 안 맞는 건 템플릿 상호작용 때문이 아니라, 스캔 자체가 비결정적이기 때문이었다. 11건은 "어디서 생긴 추가 탐지"가 아니라, B 단독 런이 운 나쁘게 낮게 찍혔을 뿐이다.
여기서 교훈 하나. 스캔 결과 수는 덧셈으로 합산 검증할 수 없다. "X + Y = Z인가?"를 묻기 전에 X와 Y 각각이 재현되는지부터 확인해야 한다. 단일 측정은 신뢰할 수 없다.
그럼 다음 질문은 "왜 흔들리나"였다.
원인: OpenSSH 템플릿들이 SSH 포트에 동시 핸드셰이크
런마다 결과가 달라지는 매칭들을 추려보니 공통점이 있었다. 전부 OpenSSH 관련 CVE 템플릿이었다.
OpenSSH 취약점 템플릿은 수십 개가 있고, 이들은 모두 같은 일을 한다. 타겟의 22번 포트에 접속해서 SSH 배너(SSH-2.0-OpenSSH_8.0 같은 문자열)를 읽고, 버전을 보고 취약 여부를 판정한다.
Nuclei는 템플릿을 병렬로 실행한다. 기본 동시성이 꽤 높다. 그 결과 수십 개의 OpenSSH 템플릿이 거의 동시에 SSH 포트로 핸드셰이크를 시도한다.
여기서 서버 측 sshd의 MaxStartups 설정에 걸린다.
# sshd 기본값
MaxStartups 10:30:100MaxStartups는 아직 인증되지 않은 동시 연결 수를 제한하는 설정이다. 10:30:100의 의미는:
- 미인증 연결이 10개를 넘으면
- 30% 확률로 새 연결을 거부하기 시작하고
- 100개에 도달하면 100% 거부
스캔은 인증까지 가지 않고 배너만 읽고 끊는다. 즉 전부 "미인증 연결"이다. 수십 개의 OpenSSH 템플릿이 동시에 몰리면 미인증 연결이 순식간에 10개를 넘고, 일부 핸드셰이크가 확률적으로 거부된다.
거부된 템플릿은 배너를 못 받으니 매칭에 실패한다. 런마다 어떤 템플릿이 거부되는지는 타이밍에 따라 달라진다. 그래서 258~275 사이에서 흔들렸던 것이다.
합쳐서 돌릴 때(Combined) 수치가 더 높게 나온 것도 같은 원리로 설명된다. 다른 묶음의 템플릿들이 섞이면서 SSH 집중 요청이 시간축으로 분산돼, 더 많은 OpenSSH 템플릿이 거부를 피한 것이다.
해결: rate-limit이 아니라 concurrency
처음엔 Nuclei의 -rate-limit 옵션을 낮추면 될 거라 생각했다. 안 됐다.
-rate-limit은 클라이언트 측 초당 요청 수 제한이다. 하지만 문제는 "초당 몇 개를 보내냐"가 아니라 "동시에 몇 개의 미인증 연결을 여느냐"다. 이건 -concurrency로 제어해야 한다.
nuclei -rate-limit 20 -bulk-size 5 -concurrency 5 -retries 3 -timeout 15핵심은 -concurrency 5다. 동시 실행 템플릿 수를 기본 25에서 5로 낮춰서, 미인증 SSH 연결이 MaxStartups의 안전 구간(10 미만)에 들어가도록 했다.
이 설정으로 3회 반복했더니:
280 / 280 / 280 ← 편차 0, 100% 재현각 옵션의 튜닝 근거
값을 그냥 찍은 게 아니라 하나씩 근거가 있다.
| 옵션 | 값 | 기본값 | 근거 |
|---|---|---|---|
-concurrency |
5 | 25 | sshd MaxStartups 안전 구간(10 미만)에 들어가도록. 1까지 낮추면 더 안전하지만 스캔 시간이 10분→1시간으로 폭증. 10은 경계라 타이밍 겹치면 drop. 5가 안전 + 납득 가능한 시간의 스위트스팟 |
-bulk-size |
5 | 25 | 한 타겟에 동시 발사되는 템플릿 수 상한. concurrency와 맞춰 버스트 억제 |
-rate-limit |
20 | 150 | 초당 전역 요청 수. concurrency 5 기준 대략 20req/s. 더 낮추면 CPU 유휴, 더 높이면 무의미 |
-retries |
3 | 1 | 1회 재시도로는 ~2% 잔여 drop, 2회는 0.5%, 3회에서 0% 달성. 초과해도 이득 없음 |
-timeout |
15 | 10 | SSH 핸드셰이크 + 서버 지연 여유. 기본 10초에서 간헐 타임아웃, 15초로 해소. 20초는 총 시간만 늘어남 |
가장 중요한 트레이드오프는 concurrency다. 낮출수록 안정성은 올라가고 스캔 시간은 길어진다. 서버 사양·네트워크 환경이 달라지면 다시 튜닝해야 한다.
정리
- 같은 스캔이 런마다 다른 결과를 낸다면, 먼저 3회 이상 반복해서 비결정성 여부를 확인한다. 단일 측정은 믿지 않는다.
- Nuclei로 SSH 관련 템플릿을 대량으로 돌리면, 동시 핸드셰이크가 서버의
sshd MaxStartups에 걸려 일부가 거부된다. 거부된 템플릿은 조용히 매칭에 실패한다. -rate-limit(초당 요청)이 아니라-concurrency(동시 연결)를 낮춰야 해결된다. 미인증 동시 연결 수가 곧MaxStartups가 보는 값이다.- 스캔 결과 수는 덧셈으로 합산 검증할 수 없다. 네트워크 IO와 서버 측 rate-limit이 개입하는 측정값의 본질이다.
비결정적인 수치를 평균 내서 쓰는 것보다, 비결정성의 원인을 제거한 뒤 수렴점을 구하는 것이 맞다. 평균 261건보다 안정화된 280건이 훨씬 신뢰할 수 있는 숫자다.
