3편이 보고 막는 이야기였다면 이번 편은 본 것을 판단하게 만드는 이야기다.
처음부터 하고 싶었던 건 LLM에 도메인을 넣고 이게 뭐냐고 묻는 기능이 아니었다. 이상한 게 잡히면 곧바로 폰에 알림이 오고 그 자리에서 허용, 무시, 차단을 고르는 구조를 원했다. 파이 4에서 LLM을 실시간으로 돌리기는 무리라 탐지는 가벼운 머신러닝 모델에 맡겼다.
먼저 두 로그를 하나로 묶었다. 그 위에서 도메인 이름을 판정하고 판정 결과를 알림과 결정으로 이었다.
Pi-hole은 어느 기기가 무엇을 물었는지 안다. Suricata는 실제로 어디에 연결됐는지 안다. 그런데 Suricata는 NAT를 거친 뒤의 트래픽을 보는 탓에 모든 연결의 출발지가 파이 주소로 찍힌다. IP로는 두 로그를 이을 수 없다.
대신 도메인과 시간으로 이었다. 기기는 DNS를 먼저 묻고 나서 연결한다. Suricata가 기록한 SNI(접속하려는 도메인)를 직전에 물어본 기기를 찾으면 된다. 후보 기기가 하나면 신뢰도를 높음으로 매기고 여러 기기가 비슷한 시각에 같은 도메인을 물었으면 낮춘다. 완벽하게 이을 수 없다는 사실까지 데이터에 남겨두는 방식이다.
QUIC은 까다로웠다. 14,845건 중 SNI가 있는 건 2,440건(16%)뿐이었다. 서버 쪽 응답에는 SNI가 붙지 않아서 조인에는 SNI가 있는 이벤트만 썼다.
| 항목 | 건수 |
|---|---|
| 처리한 연결 | 16,736 |
| 기기 귀속 성공 | 9,462 (높음 9,373 / 중간 89 / 낮음 0) |
| 짝이 되는 DNS 질의 없음 | 7,274 |
귀속된 연결의 99%가 신뢰도 높음이었다. 스크립트는 Suricata 로그를 어디까지 읽었는지 기억해뒀다가 새로 쌓인 부분만 읽고 cron으로 10분마다 돈다.
짝이 없는 7,274건은 대부분 DNS 캐시 때문이었다. 그런데 그 목록에 이상한 이름들이 섞여 있었다.
getvfa.site 111
noblepulse539.com 106
getvfa.store 106
quietsummit393.com 104
같은 이름이 값싼 TLD 두 개로 쌍을 이룬다. 형용사와 명사 뒤에 숫자 세 자리를 붙인 이름도 있다. 횟수도 100건 안팎으로 고르다. 악성코드가 자동 생성하는 도메인(DGA)과 모양새가 닮았다.
시간선을 뽑아보니 정체는 달랐다. 한 스트리밍 사이트를 연 직후 3초 안에 줄줄이 따라온 광고 도메인이었다. 도메인을 수시로 바꿔 차단을 피하는 부류로 보인다. 27만 개짜리 블록리스트에도 없었다.
두 로그를 묶고 나니 이 도메인이 왜 불렸는지를 앞뒤 30초로 설명할 수 있게 됐다.
noblepulse539 같은 이름을 블록리스트 없이 잡는 게 다음 목표였다.
먼저 가릴 대상을 정했다. 차단할 도메인이 아니라 사람이 지은 것 같지 않은 도메인이다. 블록리스트 도메인을 정상 쪽 학습 데이터에 넣은 것도 그래서다. googleads.g.doubleclick.net은 광고지만 이름은 사람이 지었다. 비정상 쪽은 알고리즘으로 만들었다. 무작위형, 사전 단어 조합형, 16진수형, 혼합형을 고르게 생성했다.
첫 모델은 문자 통계(엔트로피, 자음 연속, 모음 비율, 숫자 비율)와 구조(끝자리 숫자, 사전 단어 분해, 값싼 TLD)를 특징으로 썼다. 정확도는 95.4%였다.
실제 로그에 돌리자 문제가 드러났다. 의심으로 뜬 14개 중 13개가 정상 도메인이었다. 그중 10개가 appsflyersdk.com의 서브도메인이다. appsflyersdk는 12자에 자음이 6개 연속이고 모음 비율이 0.17이라 통계로만 보면 무작위 문자열과 구분이 안 된다. 반대로 진짜 표적인 getvfa.site는 0.006점을 받아 정상으로 판정됐다. 특징 중요도 1위는 길이였다. 생성한 가짜 도메인이 8~20자라서 모델은 "길면 의심"을 배웠다.
시험 방식에도 구멍이 있었다. noblepulse539는 0.787로 잡혔지만 사전 조합형을 만들 때 쓴 단어 목록에 noble과 pulse가 들어 있었다. 모델이 이미 아는 단어로 시험을 본 셈이다.
두 가지를 고쳤다.
검증은 학습에 쓰지 않은 실제 사례 14개로 따로 했다.
| 1차 | 2차 | |
|---|---|---|
| 학습 데이터 (정상 / 생성형) | 2,849 / 2,848 | 9,642 / 9,640 |
| 정확도 | 95.4% | 98.3% |
| appsflyersdk.com | 0.943 | 0.049 |
| getvfa.site | 0.006 | 0.143 |
앞서 오탐을 냈던 도메인들이 기준 아래로 내려갔다. 특징 중요도 상위 셋은 전부 n-gram(합계 59%)이었고 길이의 비중은 0.032로 떨어졌다.
getvfa는 여전히 못 잡았다. get은 영어에서 아주 흔한 조합이다. 6글자 중 드문 조합은 3개뿐이어서 평균이 희석된다. 기준을 0.143 아래로 내리면 잡히긴 하겠지만 그만큼 정상 도메인도 같이 걸린다. 짧은 이름은 정보량이 모자라 이름만으로는 한계가 있다. 같은 이름이 값싼 TLD 두 개에 동시에 뜨는 패턴이나 도메인 등록일처럼 이름 밖의 신호가 필요하다.
일주일치 로그에 돌리자 등록 도메인 794개 중 4개가 걸렸다(기준 0.5).
| 도메인 | 점수 | 정체 |
|---|---|---|
| noblepulse539.com | 0.975 | 광고 도메인, 블록리스트에 없음 |
| quietsummit393.com | 0.964 | 같은 광고 네트워크 |
| whoas.xyz | 0.788 | 블록리스트가 이미 막고 있던 도메인 |
| ux5o38vbscdw.com | 0.536 | 아이패드의 네트워크 확인 질의로 보임 |
whoas.xyz는 기기 세 대가 부른 도메인으로, 블록리스트를 키운 뒤부터 차단되고 있었다. 분류기는 목록 없이 같은 결론에 도달했다.
ux5o38vbscdw.com은 성격이 달랐다. 조회 시점은 아이패드가 PiLab에 처음 붙은 순간이었다. 같은 초에 _dns.resolver.arpa(DDR)와 mask.icloud.com(Private Relay) 질의도 있었다. 존재하지 않을 무작위 이름을 물어서 이 네트워크의 DNS가 응답을 조작하는지 확인하는 동작으로 보인다. 분류기 판정 자체는 맞았다. 기계가 만든 이름은 맞으니까. 다만 목적이 공격보다는 방어에 가까웠다.
기계가 만든 이름이라고 다 악성은 아니다. 판정과 해석은 다른 층위의 일이다. 사람의 결정이 필요하고 그 결정을 기억해둘 장치도 필요한 이유다.
알림은 파이에 직접 띄운 ntfy로 보낸다. 공개 서버인 ntfy.sh는 토픽 이름만 알면 누구나 구독할 수 있어서 알림에 담은 도메인 이름이 그대로 노출된다. 3편에서 봤듯 DNS 데이터는 민감하다. 자체 서버라면 Tailscale 안에서만 보인다.
분류기 판정 -> 이미 결정한 도메인 제외 -> ntfy 알림 (허용 / 무시 / 차단)
|
버튼 누름 -> nginx /act/<토큰> -> Flask 처리기
|
decisions.db에 결정 기록 + Pi-hole에 허용 또는 차단 반영
버튼 URL에는 도메인 대신 일회용 토큰만 들어간다. 버튼 링크는 인증 없이 눌리니 주소가 고정이면 알림 기록에 남은 링크가 계속 살아 있게 된다. 토큰은 한 번 쓰면 폐기된다. 같은 알림의 나머지 버튼도 함께 무효가 되고 3일이 지나면 만료된다. 결정은 decisions.db에 남으니 한 번 정한 도메인은 다시 묻지 않는다.
막힌 지점 1. 폰에서 구독하려는데 타임아웃이 났다. nftables에는 ntfy 포트(2586)를 열어놓고 Tailscale ACL에는 빠뜨린 탓이었다. 서비스가 늘면 정책도 따라가야 한다는 3편의 교훈을 여기서 또 한 번 겪었다.
막힌 지점 2. 알림 스크립트가 모델을 불러오지 못했다. 학습 스크립트를 직접 실행해서 모델을 저장했더니 pickle이 n-gram 클래스를 __main__.NgramModel로 기록해버렸다. 다른 스크립트에서 불러오면 그 경로에는 클래스가 없다. __module__을 고쳐 적는 우회도 해봤지만 pickle이 같은 객체인지까지 확인하는 바람에 실패했다. 결국 클래스 객체 대신 순수 자료구조로 바꿔서 저장했다.
모델을 다시 저장하느라 재학습을 돌렸더니 이번엔 점수가 달라졌다. 정상 샘플을 무작위로 뽑는 탓에 n-gram 표가 학습할 때마다 바뀌었고 quietsummit393이 0.642로 기준(0.7) 아래로 내려갔다. 코드는 그대로인데 재학습만으로 탐지 결과가 바뀐다면 운영에 쓸 수 없다. 표본 추출을 고정해서 재학습해도 결과가 흔들리지 않게 했다.
막힌 지점 3. 버튼 라벨이 한글이라 전송 단계에서 터졌다. HTTP 헤더는 latin-1 문자만 받는다. ntfy가 지원하는 RFC 2047 인코딩으로 감싸자 이번엔 400이 떨어졌다. 라벨만 감싸야 했는데 URL까지 통째로 base64 안에 넣는 바람에 액션 문법이 깨졌다. view, 허용 부분만 인코딩하도록 고쳤다.
막힌 지점 4. 그래도 400이었다. 에러 메시지는 term 'view' unknown이었다. 액션 세 개를 쉼표로 이었는데 액션 하나 안에도 쉼표가 들어 있어서 ntfy가 두 번째 액션을 첫 번째 액션의 인자로 읽었다. 액션 사이 구분자는 세미콜론이다. 앞서 curl로 검증할 때는 액션이 하나뿐이라 이 문제가 드러나지 않았다. 한 케이스로만 확인하면 조합에서 터지는 버그를 놓치게 된다.
막힌 지점 5. 스크립트는 3건 모두 보냈다고 하는데 폰에는 아무것도 오지 않았다. iOS는 백그라운드 앱이 자체 서버와 연결을 유지하지 못하게 막는다. 그래서 알림은 반드시 애플 푸시(APNs)를 거쳐야 하고 그 경로를 쥔 건 ntfy.sh다. ntfy 설정에 upstream-base-url을 넣으면 파이 서버가 ntfy.sh에 새 메시지가 있다는 신호만 보낸다. 넘어가는 건 메시지 ID뿐이고 폰은 깨어난 뒤 내용을 Tailscale로 파이에서 직접 가져온다. base-url은 앱에 입력한 서버 주소와 글자 하나까지 같아야 한다.
base-url: "http://<파이 Tailscale 주소>:2586"
upstream-base-url: "https://ntfy.sh"
고치고 나서야 알림이 떴다.
마지막으로 루프 전체를 돌렸다. 알림 3건이 왔고 버튼을 하나씩 다르게 눌렀다. noblepulse539.com은 차단했다. gsspcln.jp는 정체를 확인하지 못해 무시로 남겼다. mxpnl.com은 앱 사용 분석 서비스인 Mixpanel의 도메인이다. 모음이 거의 없는 약어라서 오탐이 났고 허용을 눌렀다.
도메인 453개 → 의심 3개 → 기결정 3개 제외 → 알림 0건
gsspcln.jp|ignore|0.742747607376284|2026-09-10 12:56:52
mxpnl.com|allow|0.766866885909068|2026-09-10 12:57:00
noblepulse539.com|deny|0.9815|2026-09-10 12:57:05
- noblepulse539.com (type: exact deny domain)
다음 판정에서 세 도메인이 빠졌다. 결정은 DB에 남았고 Pi-hole 차단 목록에는 noblepulse539.com이 들어갔다. 알림에서 결정, 반영, 기억까지 한 바퀴를 다 돌았다.
LLM은 붙이지 않았다. 판정은 로컬 모델로 충분했다.
첫 글에서 이 로그를 AWS로 보내 이상 트래픽을 탐지해보겠다고 적었다. 이상 탐지는 파이 안에서 해결했지만 AWS는 아직 손대지 못했다. 다음에는 그 AWS를 연동해서 무언가를 만들어보고 싶다.