라즈베리 파이 AP 보안 강화와 트래픽 가시성 (3)

kyhw·2026년 9월 11일

pihole

목록 보기
3/4

들어가며

2편에서는 속도를 재고 모니터링을 붙였다. 이번 편 주제는 보안이다. 방화벽을 다시 짜고 AP 격리와 Tailscale ACL을 건 다음 Suricata IDS까지 올렸다.

하면서 두 가지를 확인했다. 막았다고 생각한 곳이 실제로는 안 막혀 있었다. 그리고 트래픽을 들여다보는 도구보다 DNS 로그가 보여주는 게 더 많았다.

1. 방화벽은 출처를 기준으로

처음 규칙은 iifname "eth0"였다. 기숙사 이더넷 쪽에서 들어오는 DNS만 막는 구조다. Grafana(3000), Prometheus(9090), Node-RED(1880)는 전부 0.0.0.0에서 듣고 있었는데 이 포트들은 막혀 있지 않았다. Prometheus는 인증도 없어서 같은 학교망에 있는 누구든 지표를 볼 수 있었다.

강의실에서는 더 심각해진다. 파이를 들고 가면 wlan0가 AP 대신 eduroam 클라이언트 역할을 맡고 업링크도 eth0에서 wlan0로 바뀐다. Pi-hole은 모든 인터페이스의 질의에 답하도록 설정돼 있었는데 기존 규칙은 eth0만 막았다. 그대로 두면 학교망 전체에 열린 DNS 리졸버가 되는 셈이다.

그래서 판단 기준을 바꿨다. 이제는 어느 인터페이스로 들어왔는지보다 누가 보냈는지를 본다.

table inet pifilter {
    set trusted_ifaces {
        type ifname
        elements = { "lo", "tailscale0" }
    }
    set protected_tcp {
        type inet_service
        elements = { 22, 53, 80, 1880, 3000, 9090 }
    }
    chain input {
        type filter hook input priority filter; policy accept;
        ct state established,related accept
        iifname @trusted_ifaces accept
        ip saddr 10.10.0.0/24 accept
        tcp dport @protected_tcp drop
        udp dport 53 drop
    }
}

Tailscale과 AP 클라이언트(10.10.0.0/24)만 통과시키고 다른 출처에서 관리 포트로 오는 건 전부 버린다. 맨 위 ct state established,related accept는 빼면 안 된다. 파이가 밖으로 보낸 요청의 응답까지 막혀서 파이 자신의 인터넷이 끊긴다.

모드 전환은 pi-mode dorm, pi-mode class, pi-mode status 스크립트로 묶었다. 신경 쓴 건 순서다. 강의실 모드는 Pi-hole부터 내리고 나서 네트워크를 바꾼다. 반대 순서면 잠깐이라도 학교망에 DNS가 열린다. 기숙사 모드는 이더넷 연결을 먼저 확인해서 랜선이 없으면 실행을 거부한다. 강의실에서 잘못 누르면 eduroam이 끊기고 AP만 떠서 파이가 인터넷에서 고립되니까.

2. 카운터가 0이었던 AP 격리

PiLab에 붙은 기기끼리는 서로가 보인다. 하나가 뚫리면 같은 망의 다른 기기로 옮겨가기 쉽다. 지금이야 전부 내 기기지만 IoT 기기를 붙이기 시작하면 얘기가 달라진다.

처음에는 방화벽 forward 체인에 규칙을 넣었다. 들어온 인터페이스도 나갈 인터페이스도 wlan0면 AP 내부 통신으로 보고 버리는 규칙이다.

iifname "wlan0" oifname "wlan0" ip saddr 10.10.0.0/24 ip daddr 10.10.0.0/24 counter drop

노트북에서 폰으로 ping을 보냈더니 응답이 왔다. 카운터는 이랬다.

counter packets 0 bytes 0 drop

규칙에는 문제가 없었다. 패킷이 규칙까지 오지 않았을 뿐이다. 같은 AP에 붙은 기기끼리는 무선 계층(L2)에서 프레임을 바로 주고받는다. IP 포워딩을 거치지 않으니 L3 방화벽에게는 볼 기회조차 없다.

그래서 무선 계층에서 막았다.

sudo nmcli connection modify pi-ap 802-11-wireless.ap-isolation 1
sudo nmcli connection down pi-ap && sudo nmcli connection up pi-ap

재접속하고 다시 ping을 보내자 "대상 호스트에 연결할 수 없습니다"가 떴다. 프레임이 넘어가지 않아 ARP 단계에서 이미 실패한 결과다. 파이 관리 페이지는 그대로 열렸다. 기기들은 이제 게이트웨이하고만 대화하고 서로는 못 본다.

ping을 보내보지 않았다면 막힌 줄 알았을 것이다.

3. Tailscale ACL로 다른 길까지

AP 격리가 막는 건 Wi-Fi 안의 길뿐이다. 내 기기들은 Tailscale로도 서로 이어져 있는데 기본 정책이 전부 허용이다. 이 경로에서는 폰과 노트북이 여전히 서로 닿는다.

기기를 태그로 나눴다. 파이는 tag:server, 노트북과 폰과 아이패드는 tag:client다. 클라이언트는 서버의 정해진 포트로만 가도록 했다.

"grants": [
  {
    "src": ["tag:client"],
    "dst": ["tag:server"],
    "ip":  ["tcp:22", "tcp:80", "tcp:1880", "tcp:3000", "tcp:9090", "udp:53", "tcp:53"]
  }
]

클라이언트끼리 오가는 규칙이 없으니 서로는 막힌다. 정책에 없는 포트도 마찬가지다. 나중에 아이패드로 노트북 원격 데스크톱을 쓸 일이 생겼을 때도 클라이언트 간 3389 한 줄만 추가했다. 필요한 것만 한 줄씩 여는 식이다.

막힌 지점은 순서였다. 태그부터 붙이고 정책은 나중에 저장해야 한다. 순서가 바뀌면 tag:server에 해당하는 기기가 하나도 없어서 접속이 전부 끊긴다. 파이에 태그를 붙일 때는 tailscale up이 거부됐다. 이미 켜둔 옵션까지 전부 다시 적어줘야 통과한다.

sudo tailscale up --advertise-tags=tag:server --ssh --force-reauth

검증에도 함정이 있었다. ACL 적용 전후로 폰에 ping을 보내 비교하려 했는데 적용하기 전부터 응답이 없었다. 아이폰은 원래 ping에 답하지 않는다. 그러니 타임아웃은 차단의 증거가 못 된다. 확인하려면 허용한 포트와 허용하지 않은 포트에 실제로 접속해봐야 한다.

4. 포트를 옮기면 규칙도 옮긴다

서비스가 늘면서 포트 번호 외우기가 번거로워져서 nginx로 진입점을 하나로 묶었다. /에는 포털 페이지를 두고 /admin은 Pi-hole로, /grafana/와 /nodered/는 각 서비스로 넘긴다.

막힌 지점 1. Pi-hole v6는 웹서버가 내장이라 80번을 직접 잡는다. Pi-hole을 8080으로 옮기고 80은 nginx에 넘겼다.

막힌 지점 2. Grafana에서 무한 리다이렉트가 났다. 원인은 proxy_pass 끝의 슬래시 하나였다.

  • proxy_pass http://127.0.0.1:3000/; 는 /grafana/ 접두사를 떼고 넘긴다.
  • proxy_pass http://127.0.0.1:3000; 는 접두사를 그대로 넘긴다.

Grafana는 serve_from_sub_path = true 설정 때문에 자기가 /grafana/ 아래에 있다고 안다. 접두사 없는 요청을 받으면 /grafana/로 되돌려 보내는데 nginx가 그걸 또 떼서 넘긴다. 이게 끝없이 돈다. 슬래시 하나를 빼니 해결됐다.

막힌 지점 3. Pi-hole을 8080으로 옮기고 나서 방화벽을 다시 보니 보호 포트 목록에 8080이 없었다. 학교망에서도 Pi-hole 관리 페이지가 그대로 열리는 상태였다. 목록에 8080을 넣었다. 서비스가 포트를 옮기면 정책도 함께 옮겨야 한다.

5. Suricata의 경고 0건

DNS 로그는 누가 무엇을 물어봤는지만 알려준다. 실제로 오간 트래픽까지 보려고 IDS인 Suricata를 올렸다.

기본 룰셋(ET Open)을 받으니 52,662개가 켜졌다. 몇 분 돌린 뒤 이벤트를 세봤다.

이벤트건수
QUIC157
TLS24
HTTP0
경고0

평문 HTTP는 한 건도 안 보였다. 경고 0건은 안전해서가 아니라 볼 게 없어서 나온 숫자였다. 룰 상당수가 패킷 내용(페이로드)에서 패턴을 찾는데 트래픽이 전부 암호화돼 있으니 매칭될 리가 없다.

메모리도 문제였다. Suricata 혼자 868MB, 2GB 파이 메모리의 45.8%를 차지했다.

결국 룰을 줄였다. 기준은 하나, 이 환경에서 동작하느냐다. 페이로드를 못 봐도 동작하는 룰은 IP 평판(알려진 C2 서버, 악성 IP 목록)과 도메인 기반 룰이다. disable.conf로 전부 끈 다음 enable.conf로 그 그룹만 다시 켰다.

# /etc/suricata/disable.conf
re:.*

# /etc/suricata/enable.conf
group:emerging-botcc.rules
group:emerging-ciarmy.rules
group:emerging-dshield.rules
group:emerging-compromised.rules
group:emerging-coinminer.rules
group:emerging-phishing.rules
group:emerging-mobile_malware.rules
단계룰 수Suricata 메모리여유 메모리
전체 로드52,662868MB388MB
1차 축소31,895724MB595MB
최종4,649187MB1.1GB

룰은 91% 줄었다. 탐지 범위도 줄어든 건 맞다. 하지만 뺀 룰 대부분은 이 네트워크에서 애초에 매칭될 일이 없는 룰이었다. 홈 네트워크에서 IDS가 현실적으로 잡을 만한 건 감염된 기기가 알려진 C2 서버에 붙는 경우 정도다. 남긴 룰이 바로 그 일을 한다.

얼마 뒤 대시보드에 HTTP가 처음 등장했고 암호화 비율은 100%에서 99.2%로 내려갔다. 찾아보니 전부 노트북(윈도우)의 인증서 폐기 목록(CRL) 조회였다. CRL은 보통 평문 HTTP로 배포된다. HTTPS로 받으려면 CRL 서버의 인증서부터 또 검증해야 하니 순환에 빠진다. 암호화되지 않은 1%조차 암호화를 지탱하기 위한 트래픽이었다.

대시보드는 하나로 두고 Pi Hardware, Pi-hole, Suricata 세 Row로 나눴다. 기존 68개 패널 중 실제로 보는 20개만 남겼다.

6. DNS 로그가 보여준 것

Suricata는 암호화 때문에 내용을 못 본다. 반대로 DNS 로그에는 어느 기기가 언제 어디에 가려고 했는지가 그대로 남는다.

먼저 그날 처음 등장한 도메인을 뽑아봤다. 결과는 대부분 유튜브 CDN 서버(rr2---sn-xxxx.googlevideo.com)였다. 영상마다 다른 서버가 배정되니 매번 새 도메인으로 잡힌다. 그래서 등록 도메인 단위로 묶고(tldextract) CDN과 역방향 조회 같은 노이즈를 뺀 뒤, 이미 확인한 도메인은 기록해뒀다가 제외했다. 집계와 필터는 SQL로 DB 안에서 끝내고 라이브러리가 필요한 도메인 파싱만 Python에서 처리했다.

첫날은 전부 처음 보는 도메인이라 기준선으로 기록만 해뒀다. 다음 날에는 원본 726개가 등록 도메인 282개로 묶였고 최종 검토 대상은 21개로 줄었다.

번호만 다른 도메인 8개가 1~2분 간격으로 줄줄이 조회된 기록도 있었다. 얼핏 자동화된 비컨 같지만 앞뒤를 보면 첫 조회는 어떤 웹페이지가 열리고 1초 뒤였다. 페이지가 연쇄로 부른 도메인이다. 도메인 하나만 떼어 보면 무해한 것도 수상해 보인다. 판단하려면 전후 몇 초를 같이 봐야 한다.

오전 8시 36분 기록은 성격이 달랐다. 아이폰 한 대가 13초 동안 도메인 38개를 조회했는데 게임, 쇼핑, 학교와 기숙사 포털, 채용 사이트, 금융 서비스가 섞여 있었다. 게다가 순서가 알파벳순이었다. 사람이 이렇게 브라우징할 수는 없다. 기기에 저장된 계정 목록을 차례로 훑은 것으로 보인다. 어떤 기능이 이런 조회를 하는지까지는 확인하지 못했다.

그래도 결과만큼은 분명하다. DNS 로그만으로 내 계정이 어느 사이트에 있는지가 드러났다. 방문 기록보다 민감한 정보다. 방문은 우연일 수 있어도 저장된 계정은 확실한 관계다. 로그를 밖으로 내보낼 일이 생기면 IP만 가려서는 부족하다. 사람을 가리키는 건 도메인 조합 그 자체다.

이런 일괄 조회가 섞이면 신규 도메인 목록이 오염된다. 그래서 거르는 규칙을 짰다. 한 클라이언트가 짧은 시간창 안에 여러 도메인을 조회했는데 그 순서가 알파벳순과 강하게 일치하면 스캔으로 분류한다.

def ascending_ratio(domains):
    """연속한 쌍 중 알파벳 오름차순인 비율."""
    if len(domains) < 2:
        return 0.0
    ordered = sum(1 for a, b in zip(domains, domains[1:]) if a <= b)
    return ordered / (len(domains) - 1)

돌려보니 두 건이 잡혔다. 하나는 앞의 계정 목록 조회(38개, 일치율 97%)다. 다른 하나는 그 직전에 있었던 애플 인프라 통신(19개, 83%)이다. 스캔으로 분류된 도메인은 신규 후보에서 자동으로 빠진다.

정리

  • 규칙이 있다고 동작하는 건 아니다. 넣었으면 카운터든 ping이든 직접 확인한다.
  • 경로가 여럿이면 하나씩 따로 막는다. Wi-Fi 안은 AP 격리, Tailscale은 ACL, 파이 자신은 nftables가 맡는다.
  • 서비스가 옮겨가면 정책도 같이 옮긴다.
  • 검증 수단도 한 번 더 의심한다. 아이폰은 원래 ping에 답하지 않는다.
  • 암호화된 환경에서 IDS는 내용을 못 본다. 룰은 환경에 맞게 고른다.
  • DNS 로그는 생각보다 많은 걸 보여준다. 그래서 로그 자체가 민감한 데이터다.

다음 편

여기까지가 보고 막는 작업이었다. 다음 편에서는 본 것을 판단하게 만든다. Suricata는 NAT 뒤에서 모든 연결을 파이 주소로 기록하기 때문에 누가 연결했는지 모른다. 그걸 아는 쪽은 Pi-hole 하나다. 먼저 두 로그를 하나로 묶었다. 그 위에 도메인 이름만으로 기계가 만든 이름을 가려내는 분류기를 올렸다. 수상한 게 잡히면 폰으로 알림이 오고 버튼 하나로 허용, 무시, 차단을 고른다.

profile
취업하고싶어요...

0개의 댓글