비코닝과 C2 탐지¶
한 줄 요약
임플란트는 집에 전화를 걸어야 하고, 그 방식은 사람과 통계적으로 다릅니다: 일정한 타이밍, 일정한 크기, 오랫동안 하나의 목적지, 이상한 TLS/HTTP 지문, 아무도 방문하지 않는 목적지. 이 페이지는 Zeek/Splunk에서 그것을 찾는 체크리스트와 쿼리, 그리고 최신 C2가 숨으려고 하는 방법을 다룹니다.
신호¶
| 신호 | 생기는 이유 | 측정할 곳 |
|---|---|---|
| 일정한 간격 (지터 유무와 상관없이) | 임플란트가 체크인 사이에 N초 잠듦 | (orig_h, resp_h, resp_p)별 conn.log ts 차이 |
| 대기 중 일정한 페이로드 크기 | "할 일 없음"은 매번 같은 메시지 | orig_bytes / resp_bytes 분산; http.log response_body_len |
| 오래 지속되는 관계 | 같은 쌍이 몇 시간/며칠 통신 | 쌍별 하루 연결 수; 처음 본 날짜 |
| 드문 목적지 | 감염된 호스트만 C2를 앎 | resp_h / SNI / 도메인별 dc(id.orig_h) |
dns.log에 거의 없음 또는 새 도메인 |
하드코딩된 IP, 또는 막 등록된 도메인 | 앞선 DNS 없는 연결; 도메인 나이 |
| 지문 불일치 | 임플란트의 TLS/HTTP 스택 ≠ 브라우저 | JA3/JA4 희소성, ALPN 없음, 이상한 UA, 헤더 순서 |
| 인증서 이상 | 자체 서명 / 기본값 / 막 생성됨 | ssl.log validation_status, x509.log not_valid_before |
| 업무 시간 외 활동 | 기계는 자지 않지만 사람은 잠 | 사용자가 자고 있을 03:00에 워크스테이션의 연결 |
| 바이트가 적은 긴 연결 | 계속 살아 있는 대화형 셸 / 리버스 터널 | conn.log duration > 몇 시간, 작은 orig_bytes |
| 나가는 바이트 ≫ 들어오는 바이트 | 유출 | 쌍별 sum(orig_bytes); 호스트의 SRUM |
간격 분석 — 핵심 기법¶
연결이 충분한(예: 하루 20개 이상) (id.orig_h, id.resp_h, id.resp_p)마다 연속된 연결 사이의 시간을 계산하고 분포를 보세요:
- 완벽한 비콘: 모든 차이 ≈ 60초 → 표준편차가 아주 작고 히스토그램에 뾰족한 막대 하나.
- 지터가 있는 비콘 (Cobalt Strike
sleep 60 jitter 30): 차이가 42–60초에 고르게 퍼짐 → 상자 모양, 여전히 범위가 정해져 있고 사람 트래픽과는 전혀 다름. - 사람 / 브라우저: 밀리초부터 몇 시간까지, 꼬리가 길고, 업무 시간에 몰림.
- 정상 백그라운드: NTP(완전히 규칙적, 123번 포트, 아주 작음), Windows Update, AV 업데이트, OneDrive, Teams 접속 상태 — 규칙적이면서 많은 호스트가 쓰는 잘 알려진 목적지. 드문 목적지인지가 이들을 가릅니다.
index=zeek sourcetype=zeek:conn local_orig=true local_resp=false
| sort 0 id.orig_h, id.resp_h, id.resp_p, _time
| streamstats current=f last(_time) as prev_time by id.orig_h, id.resp_h, id.resp_p
| eval delta = _time - prev_time
| where isnotnull(delta) AND delta > 0
| stats count avg(delta) as avg_gap stdev(delta) as sd_gap avg(orig_bytes) as avg_up stdev(orig_bytes) as sd_up dc(uid) as conns min(_time) as first max(_time) as last by id.orig_h, id.resp_h, id.resp_p
| where count >= 20
| eval jitter_pct = round(100 * sd_gap / avg_gap, 1), size_var_pct = round(100 * sd_up / (avg_up+1), 1)
| eval span_hours = round((last-first)/3600, 1)
| where jitter_pct < 30 AND span_hours > 2
| convert ctime(first) ctime(last)
| sort jitter_pct
한 줄씩: sort 0 …, _time은 모든 연결을 쌍과 시간 순으로 정렬합니다(0 = 행 수 제한 없음). streamstats current=f last(_time) as prev_time by …는 정렬된 목록을 내려가면서 각 행마다 같은 쌍의 이전 행 타임스탬프를 가져옵니다 — current=f는 "현재 행은 포함하지 마라"는 뜻이라 이전 행이 나옵니다. eval delta = _time - prev_time이 초 단위 간격입니다. 그다음 stats … by 쌍이 쌍마다 요약합니다: 간격 개수, 평균과 표준편차, 업로드 크기의 평균/표준편차, 처음/마지막 시각. jitter_pct는 표준편차를 평균으로 나눈 백분율입니다 — 완벽한 비콘은 ~0%, 지터 30%인 Cobalt Strike는 15–20% 정도, 웹 브라우징은 100%를 훨씬 넘습니다. where jitter_pct < 30 AND span_hours > 2는 규칙적이고 오래 지속된 쌍만 남깁니다. 맨 위에 보이는 결과 예: 워크스테이션 → VPS IP 하나:443, 연결 1,400개, 평균 간격 61초, 지터 3%, 24시간에 걸침.
같은 쿼리에 희소성을 더하기:
eventstats는 stats와 비슷하지만 원래 행을 유지하고 결과를 새 필드로 붙입니다 — 여기서는 그 목적지와 통신하는 내부 호스트가 몇 대인지. 1–3대만 접속하는 목적지로의 비콘부터 여세요.
# deltas for one pair
zeek-cut ts id.orig_h id.resp_h id.resp_p < conn.log \
| awk '$2=="10.0.0.25" && $3=="203.0.113.7" && $4==443 {print $1}' | sort -n \
| awk 'NR>1{printf "%.0f\n", $1-prev} {prev=$1}' | sort -n | uniq -c | sort -rn | head
# → "1372 60" (1,372 gaps of exactly 60 s) is a beacon; a browser gives a long messy list.
# pairs by connection count, then eyeball the frequent ones
zeek-cut id.orig_h id.resp_h id.resp_p < conn.log | sort | uniq -c | sort -rn | awk '$1>100' | head -30
# Import Zeek logs, then ask for beacons / long connections / DNS oddities
rita import /data/zeek/logs/2026-09-16 case01
rita show-beacons case01 | head -20 # score, src, dst, connections, avg bytes, interval range
rita show-long-connections case01
rita show-exploded-dns case01 # subdomain explosion = tunnelling
rita show-strobes case01 # extremely high connection counts
RITA(Active Countermeasures)는 바로 이 분석을 0–1 비콘 점수로 구현합니다. AC-Hunter는 상용 UI입니다.
HTTP/TLS 수준 단서¶
- 같은 URI, 작은 응답, 끝없이: 매분
http.logGET /pixel.gif,response_body_len0–100, 결과를 돌려보낼 때request_body_len> 0인POST /submit.php?id=…. - 1대만 쓰는 User-Agent; HTTP/1.0 또는
Accept-Language/Referer없음;Host헤더가 IP 그대로이거나 비어 있음. - 그 호스트만 쓰는 JA3/JA4 (ssl.log 참고); ALPN 없음; SNI 비어 있음; 자체 서명 / 막 만든 인증서;
sni_matches_cert = F(프론팅). - 인증서 재사용: 몇 주에 걸쳐 여러 IP에서 같은
x509지문. - Cobalt Strike / Sliver / Mythic / Havoc 기본값이 있지만(기본 UA 문자열, 기본 인증서
Major Cobalt Strike, 기본 URI/ca,/dpixel,/__utm.gif,/activity), 유능한 운영자는 바꿉니다 — 행동에 기대고, 기본값은 덤으로 쓰세요.
DNS C2와 터널링¶
- 한 도메인으로의 아주 높은 쿼리 빈도, 길고 엔트로피가 높은 서브도메인, 대부분
TXT/NULL/CNAME/MX; 호스트별 전체 DNS 바이트가 다른 호스트보다 훨씬 큼; 주소처럼 보이지 않는 응답. (dns.log) - 회사 것이 아닌 리졸버로, 또는 TCP 53으로 DNS를 쓰는 클라이언트.
rita show-exploded-dns; Splunk: 호스트별·등록 도메인별dc(query).
그 밖의 채널¶
| 채널 | 보이는 모습 |
|---|---|
| ICMP 터널 | orig_bytes가 크고, 패킷이 많고, 양방향인 conn.log proto=icmp; Wireshark icmp && data.len > 64 |
| 오래 유지되는 리버스 셸 / SOCKS (chisel, ngrok, ssh -R) | 몇 시간짜리 conn.log duration, 작은 데이터가 계속 오가는 history ShADad…, 22번이 아닌 포트의 service=ssh 또는 터널링 제공자(*.ngrok.io, *.trycloudflare.com, *.serveo.net)로의 ssl |
| 클라우드 서비스 C2 (Slack, Discord, Telegram, Dropbox, GitHub, Google Drive API) | 목적지는 정상처럼 보임; 일정한 간격 + 호스트 하나 + API 엔드포인트(api.telegram.org, discord.com/api/webhooks, 일정 주기의 raw.githubusercontent.com)와 호스트의 어떤 프로세스인지(Sysmon 3 — Discord와 통신하는 rundll32.exe는 Discord가 아님)로 구분 |
| 도메인 프론팅 / CDN | SNI = 큰 CDN, 한 호스트에서 일정한 간격, sni_matches_cert=F |
| DoH | dns.log에서 사라진 호스트가 DoH 제공자로 TLS 연결 |
| WebSockets | http.log status_code=101 후 하나의 긴 연결 |
| QUIC/HTTP3 | service=quic인 UDP 443 — Zeek 6+는 SNI가 있는 quic.log를 기록 |
| P2P / SMB 네임드 파이프 C2 (Cobalt Strike SMB 비콘) | 피해 호스트에서 인터넷 트래픽이 없음 — 피벗 호스트로의 내부 445에서 smb_files/dce_rpc가 네임드 파이프(\\pipe\msagent_*, \\pipe\postex_*, \\pipe\MSSE-*)를 보여 줌; 밖으로 비코닝하는 것은 피벗 호스트 |
잡음 걸러 내기¶
허용 목록은 포트가 아니라 목적지 + 행동으로 만드세요. 정상적으로 비코닝하는 것들: NTP(123), Windows Update / 배달 최적화(*.windowsupdate.com, *.delivery.mp.microsoft.com), Defender/AV 클라우드(*.wdcp.microsoft.com), Office/Teams 접속 상태(*.office.com, *.teams.microsoft.com), OneDrive/Dropbox/Google Drive 동기화, Chrome/Edge 업데이트, Slack, Zoom, Intune/SCCM/Jamf, EDR 에이전트(자사 에이전트의 목적지를 알아 두세요), 프린터, UPS/IoT가 자기 클라우드로, 모니터링 에이전트(Zabbix, Nagios, 9997번으로 인덱서에 가는 Splunk UF), 리졸버로의 DNS. 이 목록의 모든 것은 많은 호스트가 쓰므로 dc(id.orig_h) <= 3이 대부분을 자동으로 제거합니다. 나머지는 SNI/도메인으로 한 번 허용 목록에 넣고 문서화하세요.
하나를 찾았다면¶
- 사실을 고정하세요: 쌍, 포트, 처음 본 시각, 간격, 들어오고 나간 바이트, SNI/URI/JA3, 인증서 지문.
- 이름과 그것이 해석된 모든 IP는
dns.log로; 지문은http.log/ssl.log로; 그uid로 다운로드된 것은files.log로 피벗하세요. - 전사에서 같은 목적지, 같은 JA3, 같은 인증서, 같은 URI 패턴을 헌팅하세요 — 임플란트는 무리 지어 다닙니다.
- 호스트로 가세요: 프로세스는 Sysmon 3 / EDR; 바이너리는 Prefetch / Amcache; 앱별 바이트는 SRUM; 지속성 키는 (레지스트리 ASEP).
- 결정하세요: 수집을 마친 뒤에 출구에서 차단하세요. 그렇지 않으면 비콘을 잃고 운영자가 눈치챕니다.