Nuclei 템플릿을 직접 써서 탐지 로직 만들기
Nuclei란
YAML 템플릿 기반의 오픈소스 취약점 스캐너다. 내가 만든 YAML 탐지 규칙을 읽어서, 서버에 자동으로 HTTP 요청을 보내고, 응답을 분석해 “취약점이 있다/없다”를 판단한다.
| 단계 | Nuclei가 자동으로 하는 것 |
|---|---|
| 요청 전송 | YAML에 정의된 요청 자동 전송 |
| 쿠키 처리 | 받은 쿠키 자동 저장/재사용 |
| 응답 분석 | 정의한 키워드가 있는지 자동 확인 |
| 결과 출력 | 1 matches found 자동 출력 |
강점은 규모다. 수천 개 서버도 템플릿 하나로 한 번에 스캔할 수 있고, 전 세계 보안 연구자들이 만든 템플릿 수만 개를 공유한다. 새 취약점을 발견하면 YAML 템플릿을 만들어 커뮤니티에 올리는 흐름이다.
YAML과 용어 정리
YAML은 데이터를 사람이 읽기 쉽게 표현하는 설정 파일 형식이다.
# JSON 방식
{"이름": "홍길동", "나이": 20}
# YAML 방식 (중괄호/따옴표 없이 깔끔)
이름: 홍길동
나이: 20
규칙은 세 개뿐이다. 키: 값, 들여쓰기(스페이스 2칸)로 계층 표현, - 로 목록 표현.
용어를 헷갈리기 쉬운데 이렇게 갈린다.
| 용어 | 의미 |
|---|---|
| CVE | 취약점에 붙는 번호/식별자 (예: CVE-2015-8562) |
| YAML 템플릿 | 그 취약점을 탐지하는 파일 |
| PoC 코드 | 취약점을 실제로 증명하는 코드 |
CVE는 이름표고, YAML은 탐지 도구다.
템플릿 구조
id: 템플릿 식별자
info:
name: 템플릿 이름
severity: 심각도 (high 등)
description: 설명
tags: 태그
http:
- method: GET # 어떤 방식으로 요청
path: 요청 경로 # 어디로 요청
matchers: 탐지 조건 # 어떤 응답이면 취약?
extractors: 추출 값 # 결과에서 뭘 뽑을지
matchers 는 응답이 조건을 만족하면 “취약점 발견”으로 판단하는 부분이다.
| 타입 | 설명 |
|---|---|
status | 상태코드 확인 (예: 200) |
word | 특정 단어가 응답에 있는지 |
regex | 정규식 패턴 매칭 |
dsl | 복합 조건식 |
extractors 는 응답에서 버전 번호 같은 특정 값을 뽑아낸다.
단일 요청과 다중 요청
여기가 이 실습의 핵심이었다.
| 단일 요청 | 다중 요청 | |
|---|---|---|
| 요청 횟수 | 1번 | 2번 이상 |
| flow 설정 | 없음 | flow: http(1) && http(2) |
| 정확도 | 낮음 (오탐 가능) | 높음 |
| 방식 | 바로 공격 | 확인 후 공격 |
&& = AND (둘 다 성공해야)
|| = OR (하나만 성공해도)
http(1) && http(2)
= 1번 요청 성공 AND 2번 요청 성공
동작 흐름은 이렇다.
1번 요청 실패 → 탐지 안 함 (2번 요청 안 보냄)
1번 요청 성공 → 2번 요청 전송
↓
2번 요청 실패 → 탐지 안 함
2번 요청 성공 → 1 matches found!
다중 요청을 쓰는 이유는 오탐을 줄이기 위해서다.
1번 요청: "이 서버가 진짜 대상 맞아?" 확인
↓ 맞으면
2번 요청: "실제로 취약해?" 공격/확인
↓ 둘 다 성공해야
탐지!
여기서 짚고 넘어갈 게 있다. 서버는 요청을 한 번 받든 두 번 받든 똑같이 취약하다. 정교해지는 건 서버가 아니라 YAML 템플릿의 탐지 조건 쪽이다.
알아둘 옵션
| 옵션 | 의미 |
|---|---|
flow: http(1) && http(2) | 두 요청 모두 성공해야 탐지 |
raw | HTTP 요청을 직접 작성하는 영역 |
cookie-reuse: true | 1차 요청 쿠키를 2차 요청에 자동 재사용 |
internal: true | 결과를 외부 출력 안 하고 내부 확인용으로만 |
req-condition: true | 각 요청 응답을 status_code_1, status_code_2 로 구분 |
redirects: true | 리다이렉트 발생해도 따라감 |
max-redirects: 3 | 최대 3번까지 리다이렉트 허용 |
cookie-reuse 는 필요할 때와 아닐 때가 갈린다.
| 방식 | cookie-reuse |
|---|---|
| 세션 기반 공격 (페이로드 전송) | 필요 |
| 단순 파일/버전 확인 | 불필요 |
| Path Traversal | 불필요 |
필요한 이유는 세션 때문이다.
1번 요청 → 서버가 쿠키(세션) 발급
↓
2번 요청 → 그 쿠키로 세션 읽어야 취약점 트리거
쿠키가 없으면 다른 세션이 되어 페이로드가 실행되지 않는다.
명령어
# 템플릿 문법 검증
nuclei -validate -t 템플릿.yaml
# → All templates validated successfully
# 스캔 실행
nuclei -u http://대상주소 -t 템플릿.yaml
# → 1 matches found
# 템플릿 업데이트
nuclei -update-templates
# 실제 요청/응답 확인
nuclei -u http://대상 -t 템플릿.yaml -debug
| 옵션 | 의미 |
|---|---|
-u | 단일 대상 지정 |
-l | 대상 목록 파일 (여러 개) |
-t | 사용할 템플릿 |
-validate | 문법 검증 |
-debug | 상세 요청/응답 출력 |
-disable-update-check | 업데이트 체크 끄기 |
1 matches found 는 YAML 템플릿의 조건을 만족하는 결과를 1개 찾았다는 뜻, 즉 탐지 성공이다.
실습한 CVE 두 개
CVE-2021-43798 (Grafana Path Traversal) — 단일 요청
URL 경로 조작으로 시스템 파일 읽기
/public/plugins/alertlist/../../../../etc/passwd
↓
응답에 root:x:0:0 있으면 탐지
단순한 텍스트 비교라 Nuclei로 쉽게 잡힌다. curl로 확인할 때는 --path-as-is 옵션을 줘야 ../ 가 정규화되지 않고 그대로 전달된다.
CVE-2015-8562 (Joomla RCE) — 다중 요청
User-Agent에 PHP 객체 주입 → 역직렬화 → RCE
↓
1번 요청: Mozilla/5.0으로 쿠키 받기
2번 요청: 쿠키 재사용 + 페이로드 전송
↓
응답에 "PHP Version" 있으면 phpinfo() 실행됨 = 탐지
유형별 탐지 난이도
| 유형 | 예시 | 난이도 |
|---|---|---|
| Path Traversal | CVE-2021-43798 | 쉬움 |
| 버전 노출 | 헤더/바디 확인 | 쉬움 |
| SQL Injection | 에러 메시지 확인 | 보통 |
| PHP Object Injection | CVE-2015-8562 | 어려움 (null byte 문제) |
| Blind RCE | 응답에 증거 없음 | 매우 어려움 |
응답에 증거가 남지 않을수록 탐지가 어려워진다. 이게 템플릿 설계의 본질적인 한계다.
CVE-2015-8562 는 왜 이렇게 도는가
1. User-Agent에 PHP 직렬화 객체 담아 전송
2. Joomla가 검증 없이 세션에 저장
3. 세션 읽을 때 unserialize() 실행 (역직렬화)
4. PHP Object Injection 발생
5. __destruct() 자동 실행
6. assert("phpinfo()") 실행
7. "PHP Version" 출력 = RCE 가능성 확인
| 용어 | 의미 |
|---|---|
serialize() | 객체 → 문자열 변환 (직렬화) |
unserialize() | 문자열 → 객체 복원 (역직렬화) |
__destruct() | 객체 소멸 시 자동 실행되는 메서드 |
assert() | 문자열을 PHP 코드로 실행하는 함수 |
phpinfo() | PHP 정보 출력 함수 (PoC 증명용) |
원인을 정확히 짚으면 역직렬화 자체가 아니라 입력값을 검증 없이 역직렬화하는 것이 문제다.
안전: 검증 → unserialize()
취약: 검증 없이 바로 unserialize() ← 문제
PHP 직렬화 형식의 기호도 알아두면 페이로드를 읽을 수 있다.
| 기호 | 의미 |
|---|---|
O | Object (객체) |
s | String (문자열) |
a | Array (배열) |
i | Integer (숫자) |
b | Boolean |
phpinfo() 가 실행되면 항상 “PHP Version”, “PHP Extension” 같은 단어를 포함한 페이지가 출력된다. 그래서 이 단어가 응답에 있으면 코드 실행에 성공한 것으로 판단할 수 있다 — 탐지 조건으로 이걸 쓰는 이유다.