Zeek dns.log¶
한 줄 요약
DNS 쿼리마다 한 줄(보였다면 응답 포함)입니다 — 호스트가 어디에 닿으려 했는지 알아내고, DGA와 터널링 도메인을 잡고, conn.log의 IP를 악성코드가 요청한 이름으로 되짚어 가는 가장 저렴한 방법입니다.
필드¶
| 필드 | 의미 | 분석 메모 |
|---|---|---|
ts, uid, id.* |
conn.log와 같음 |
여러 쿼리가 UDP uid 하나를 공유할 수 있음 |
proto |
udp / tcp |
클라이언트의 TCP DNS는 드묾 — 큰 응답, 존 전송, 터널링 |
trans_id |
16비트 트랜잭션 ID | 쿼리 ↔ 응답 연결 |
rtt |
왕복 시간 | |
query |
요청한 이름 | 세기 전에 소문자로 바꿀 것 |
qclass / qclass_name |
보통 1 / C_INTERNET |
|
qtype / qtype_name |
레코드 타입: A(1), AAAA(28), CNAME(5), MX(15), TXT(16), NS(2), PTR(12), SRV(33), ANY(255), NULL(10) |
한 호스트에서 TXT, NULL, CNAME이 많음 → 터널링; ANY → 증폭 / 정찰 |
rcode / rcode_name |
응답 코드: NOERROR(0), NXDOMAIN(3), SERVFAIL(2), REFUSED(5) |
NXDOMAIN이 몰려 나오면 = DGA, 타이포스쿼팅, 싱크홀된 C2 |
AA, TC, RD, RA |
권한 있는 응답 / 잘림 / 재귀 요청 / 재귀 가능 | TC=T → 클라이언트가 TCP로 재시도 |
Z |
예약 비트 | 0이 아니면 = 이상한 클라이언트/도구 |
answers |
응답 벡터 (IP, CNAME 대상, TXT 문자열) | TSV에서는 쉼표로 구분 |
TTLs |
answers에 대응하는 TTL 벡터 |
수상한 도메인의 아주 낮은 TTL(≤ 60초) = fast-flux; 극단적인 값 = 이상 |
rejected |
서버가 쿼리를 거부함 | |
saw_query / saw_reply (선택) |
Zeek이 각 방향을 봤는지 |
빠른 쿼리¶
# Most queried domains (strip to registered domain roughly: last two labels)
zeek-cut query < dns.log | tr 'A-Z' 'a-z' | awk -F. 'NF>=2{print $(NF-1)"."$NF}' | sort | uniq -c | sort -rn | head -30
# Rare domains — queried by exactly one host, once or twice (long tail is where C2 hides)
zeek-cut id.orig_h query < dns.log | sort -u | awk '{print $2}' | sort | uniq -c | awk '$1==1' | head -50
# NXDOMAIN storm per host (DGA)
zeek-cut id.orig_h rcode_name < dns.log | awk '$2=="NXDOMAIN"' | sort | uniq -c | sort -rn | head
# Long / high-entropy subdomains (tunnelling: dnscat2, iodine, Cobalt Strike DNS beacon)
zeek-cut -d ts id.orig_h query qtype_name < dns.log | awk 'length($3)>60'
zeek-cut query < dns.log | awk -F. '{ if (length($1)>30) print }' | sort | uniq -c | sort -rn | head
# TXT / NULL / unusual types by host
zeek-cut id.orig_h qtype_name < dns.log | awk '$2=="TXT"||$2=="NULL"||$2=="ANY"' | sort | uniq -c | sort -rn
# Which name resolved to a given IP? (pivot from conn.log)
grep -F '203.0.113.7' dns.log | zeek-cut -d ts id.orig_h query answers
# Hosts using a resolver other than the corporate one (10.0.0.53)
zeek-cut id.orig_h id.resp_h < dns.log | awk '$2!="10.0.0.53"' | sort | uniq -c | sort -rn
# Volume of DNS *bytes* per host — tunnels move data, not just names
zeek-cut id.orig_h orig_bytes < conn.log | awk '{s[$1]+=$2} END{for(h in s) print s[h], h}' | sort -rn | head
# (run that on conn.log filtered to id.resp_p==53)
index=zeek sourcetype=zeek:dns
| eval qlen=len(query), labels=mvcount(split(query,"."))
| eval sub=mvindex(split(query,"."),0), sublen=len(sub)
| where sublen > 30 OR qtype_name IN ("TXT","NULL")
| stats count dc(query) as uniq_names sum(qlen) as total_chars by id.orig_h, id.resp_h
| sort - uniq_names
len()은 문자열 길이, split(query,".")는 이름을 레이블의 다중값 목록으로 쪼개고, mvcount()는 그 개수를 세며, mvindex(...,0)은 첫 번째 레이블(가장 왼쪽 서브도메인)을 고릅니다. 같은 리졸버로 30자 이상의 고유한 첫 레이블을 수천 개 보내는 호스트는 DNS로 데이터를 밖으로 터널링하고 있는 것입니다.
index=zeek sourcetype=zeek:dns rcode_name=NXDOMAIN
| bin _time span=10m
| stats count dc(query) as uniq by _time, id.orig_h
| where uniq > 50
bin _time span=10m은 각 이벤트 시각을 10분 단위 구간으로 내림해서 stats가 호스트별·10분별로 셀 수 있게 합니다. 10분 동안 존재하지 않는 고유 이름이 50개 넘게 나오면 C2를 찾고 있는 도메인 생성 알고리즘(DGA)입니다.
분석 팁¶
DNS는 범행 직전의 로그입니다
악성코드는 C2로 가는 conn.log 행이 나타나기 전에 이름을 조회합니다. 첫 수상한 conn.log 시각 주변으로 dns.log를 정렬하세요 — 50 ms 전의 쿼리가 C2 도메인이고, 그 answers가 차단할 IP 전체 목록입니다.
- 모든 DNS가 53번 포트에 있지는 않습니다. DoH(
dns.google,cloudflare-dns.com,1.1.1.1로 가는https)와 DoT(853)는dns.log를 우회합니다. 알려진 DoH 제공자를ssl.logSNI에서,conn.log에서id.resp_p=853을 찾으세요. 여전히 웹을 쓰는데dns.log에서 갑자기 사라진 호스트는 DoH로 바꾼 것입니다. - 리졸버 기준선을 잡으세요. 클라이언트는 회사 리졸버와만 통신해야 합니다. 워크스테이션에서 인터넷으로 직접 53번을 쓰면 정책 위반이거나 리졸버가 하드코딩된 악성코드입니다(
8.8.8.8은 Cobalt Strike DNS 프로필에 흔함). - 터널 징후: 한 도메인으로의 아주 높은 쿼리 빈도, 대부분
TXT/NULL/CNAME/MX, base32/base64처럼 보이는 긴 레이블, 아주 작은 TTL, 쓰레기처럼 보이는answers가 달린NOERROR응답, 그리고 — 결정적으로 — 다른 호스트보다 훨씬 많은 전체 DNS 바이트. - DGA 징후:
NXDOMAIN다수, 길이가 일정한 무작위처럼 보이는 레이블,.top .xyz .info .ru .ccTLD가 많음, 그리고 마침내 살아 있는 C2를 찾았을 때NOERROR하나. - Fast-flux / CDN 혼동: 낮은 TTL만으로는 CDN(Akamai, Cloudflare)에서 정상입니다. 표시하기 전에 도메인 나이 / 희소성과 함께 보세요.
- 한 호스트가 많은 내부 IP에 대해 PTR 조회 = 정찰 (nmap
-sL, BloodHound)._ldap._tcp,_kerberos._tcp에 대한 SRV = 정상 도메인 참가; 이상한 호스트의_msrcp,_vlmcs등 = 열거. answers는 IOC를 공짜로 줍니다 — 수상한 도메인이 해석된 모든 IP를 뽑아서, 조회한 적 없는 다른 호스트가 그 IP로 직접 연결했는지conn.log에서 찾아보세요(하드코딩된 IP 대체 경로).- 싱크홀 / RPZ: 리졸버가 나쁜 도메인을
0.0.0.0/싱크홀 IP로 바꿔 준다면,dns.log에는 감염된 호스트의 요청이,conn.log에는 실패한 연결이 보입니다. 둘 다 경보입니다.
상관분석¶
| 질문 | 다음에 볼 곳 |
|---|---|
| 해석된 곳으로 실제로 연결했나? | ts 후 몇 초 안에 id.resp_h ∈ answers인 conn.log |
| 무엇을 가져왔나? | 그 연결의 uid → http.log / ssl.log (SNI가 query와 일치해야 함) |
| 어떤 프로세스가 물었나? | 호스트의 Sysmon 22 (QueryName, QueryResults, Image); Windows DNS 클라이언트 캐시 (ipconfig /displaydns, 메모리) |
| 도메인 인텔리전스 | 패시브 DNS, WHOIS/생성일, VirusTotal, 위협 피드; 도메인 간 ssl.log 인증서 재사용 |
| 서버 쪽 관점 | 센서가 못 보는 호스트는 DNS 서버 로그 (Windows DNS 디버그 로그 / 분석 ETW, BIND 쿼리 로그) |