신성혁
개인2026.08.31 (하루)

MeshHawk — 멀티채널 IEEE 802.15.4 메시 스니퍼

채널마다 Wireshark 창을 따로 띄워야 했던 스마트미터(AMI) 무선망 분석을, 7개 동글을 한 창에서 동시에 캡처하고 하나의 시간순 패킷 리스트로 병합·분석하는 단일 PyQt6 GUI 앱으로 통합했습니다.

7개 (CH 26~32)
동시 캡처 채널
5단 (MAC→DLMS)
프로토콜 계층
251개
테스트
106/106 프레임
복호화 성공
단일 exe
배포 산출물

개요

스마트미터 무선망은 채널당 동글 1대씩 총 7대(채널 26~32)를 물려 관측하는데, 기존 도구는 채널마다 Wireshark 창을 하나씩 띄워 7개 창을 동시에 봐야 했고, 자동화 스크립트는 시리얼 스트림 하나를 7개 파이프에 그대로 복사해 모든 창이 동일한 패킷을 보여주는 데다 채널 지정(CH) 명령조차 보내지 않는 상태였습니다. 이를 단일 창으로 통합하면서 캡처 계층과 GUI를 완전히 분리했습니다. 각 동글은 별도의 시리얼 수신 스레드(SerialSource)가 독립적으로 바이트를 프레이밍해 Packet 객체를 공용 큐에 넣고, 세션 관리자(CaptureSession)가 100ms 주기의 Qt 타이머로 큐를 비워 도착 시각 기준으로 하나의 채널 태그가 붙은 리스트에 병합합니다. 캡처 계층은 Qt에 전혀 의존하지 않아 실제 캡처 파일을 리플레이하는 방식으로 GUI 없이도 단위 테스트가 가능하도록 설계했습니다.

가장 까다로웠던 부분은 Wireshark 없이 프레이밍을 직접 파싱하는 것이었습니다. 장비는 2바이트 빅엔디안 PHR 뒤에 그 길이만큼의 MAC 프레임을 붙여 보내는데 싱크 워드가 없어, 길이 접두 방식 스트림에서 UART 바이트가 하나만 유실돼도 이후 프레임 경계가 영구히 어긋납니다(기존 파서는 세션 끝까지 어긋난 채 동작). 재동기화는 PHR 유효성(상위 예약 비트가 0인지, 11비트 길이가 범위 내인지)을 먼저 검사하고, 헤더가 깨지면 바이트 단위로 이동하며 후보를 FCF 타당성과 '다음 2개 프레임이 유효한 체인을 이루는지'로 채점하도록 설계했으며, 장비가 항상 켜두는 Data Whitening 등 PHR 플래그 패턴을 학습·고정해 다른 플래그가 오면 손상으로 간주합니다. 여기에 2015 Enhanced Beacon이 걸림돌이었는데, 버전2 비콘은 구형의 superframe/GTS/pending-address 본문이 아니라 Information Element를 실어 보내기 때문에 이를 구형 비콘으로 파싱하면 프레임 끝을 넘어가 14개가 truncated로 깨졌고, Header IE(7비트 길이)와 Payload IE(11비트 길이) 파서를 추가해 해결했습니다. 복호화는 암호화 프레임이 ENC-MIC-32(MIC 4바이트)에 key_id_mode 0(암묵적 키)이라 키가 전파로 오지 않으므로, 실제 키 없이 표준 체크값과 합성 키 프레임으로 nonce/AAD 구성을 검증했습니다.

복호화한 평문은 그 자체로 계기 데이터가 아니라 또 하나의 프로토콜 스택이라, MAC → AES-CCM* → HDLC(IEC 62056-46) → LLC → DLMS/COSEM까지 계층별 디코더를 이어 붙였습니다. 여기서 가장 오래 붙잡은 문제가 '키가 분명히 맞는데 복호화가 100% 실패'하는 현상이었는데, IEEE 802.15.4는 확장주소를 전파에 LSB 우선으로 실어 보내는 반면 CCM* nonce는 정규 EUI-64(MSB 우선)를 요구한다는 불일치가 원인이었고, 바이트 순서를 뒤집자 실캡처 106프레임이 전부 복호화됐습니다. 계층을 다 벗겨낸 뒤에는 DLMS APDU가 glo-* 계열로 한 번 더 암호화돼 있다는 사실이 드러났는데, 이건 DLMS 키가 따로 있어야 풀리는 영역이라 그럴듯한 값을 지어내지 않고 'ciphered, DLMS 키 필요'로 표기하도록 했습니다. 장비가 FCS를 떼고 보내는 것을 확인한 뒤 CRC 칸을 채우지 않고 비워 둔 것도 같은 원칙입니다 — 확인된 것만 단정하고 나머지는 정직하게 남깁니다.

패킷을 나열하는 데 그치지 않도록 계기별 세션·링크 품질 분석을 얹었습니다. 미터마다 HDLC 세션(SNRM → 폴링 → DISC)을 추적하고 N(S) 시퀀스 갭으로 손실을, 재전송과 RTT로 링크 품질을, 프레임카운터 재사용으로 리플레이 의심을 표시합니다. 이 기능을 실데이터로 검증하는 과정에서 합성 테스트로는 드러나지 않던 결함 3건을 잡았습니다 — LLC 방향 판정이 부분문자열 매칭이라 "client -> server"에 'server'가 포함돼 방향을 뒤집던 문제, mod-8 시퀀스가 7에서 0으로 도는 정상 wrap을 재전송으로 오탐하던 문제, 요청과 응답이 서로 독립된 카운터를 쓰는데 하나로 비교하던 문제입니다. 네트워크 토폴로지도 키 없이 헤더와 트래픽 패턴만으로 추론하는데, 코디네이터는 비콘 송신·가입 응답 당사자·서로 다른 계기 2대 이상이 유니캐스트를 보내는 '허브' 중 하나로 판정하고, 계기 소속은 가장 최근 유니캐스트 교환을 1차 근거로 삼습니다. 미터 ID도 주소에서 제조사·타입·일련번호를 유도하되 접두사를 하드코딩하지 않고 관측된 비콘에서 학습하며, 관측값과 유도값을 기호로 구분해 표시합니다.

테스트 251개는 계층별로 배분했습니다 — CRC/프레이밍/디코더 단위 테스트, 실캡처 회귀 픽스처, AES-CCM* 왕복, HDLC·DLMS 계층 파싱, 세션 분석, 테마 색의 WCAG 명암비 검증, 그리고 실제 Qt 위젯을 오프스크린으로 구동하는 엔드투엔드 테스트까지 포함합니다. 합성 데이터와 실캡처 픽스처를 병행하는 방식은 앞의 nonce 버그에서 얻은 교훈인데, 합성 테스트는 같은 순서로 nonce를 만들어 암호화하고 복호화했기 때문에 자기일관적이어서 그 버그를 구조적으로 잡을 수 없었습니다. UI 쪽에서도 라이트 테마 글씨가 다크 OS 환경에서 보이지 않는 문제를 QSS와 QPalette의 폴백 차이로 규명해 고쳤고, 패킷 상세 클릭 시 트레이스백 없이 앱이 죽던 하드 크래시는 헥스 뷰 하이라이트가 PyQt6에 없는 QPlainTextEdit.ExtraSelection을 참조하다 슬롯 내부 예외가 강제 종료로 이어진 것으로 특정해 QTextEdit.ExtraSelection으로 고치고 전 프레임을 순회하는 회귀 테스트로 방어했습니다. 패키징은 PyInstaller onefile로 버전 리소스와 아이콘을 심어 단일 exe를 만들고, 프로즌 환경에서는 설정·캡처 파일을 exe 옆에 쓰도록 경로 헬퍼를 두었으며, serial과 cryptography.hazmat의 동적 임포트를 collect_submodules로 포함했습니다. 검증은 녹화한 원본 스트림(.bin) 리플레이와 실제 동글 7대 구동으로 진행했습니다.

실행 화면

MeshHawk 메인 화면. 채널 28·30·32에서 캡처한 189개 프레임이 하나의 시간순 리스트로 병합돼 있고, 오른쪽에 네트워크 토폴로지 트리가 있다.
동글 3대(CH 28·30·32)를 재생해 189프레임을 한 리스트로 병합한 상태. 오른쪽 트리는 헤더와 트래픽 패턴만으로 추론한 토폴로지 — 코디네이터 1대, 소속 3대, 스캐닝 1대.
200바이트 데이터 프레임을 선택한 상세 화면. 802.15.4g PHR과 MAC 헤더 필드가 트리로 펼쳐져 있고 오른쪽에 헥스 덤프가 있다.
200바이트 Data 프레임 상세. 802.15.4g PHR의 플래그(Mode Switch·FCS Type·Data Whitening)와 MAC 헤더를 계층별로 펼친 화면.
같은 화면의 라이트 테마 버전.
라이트 테마. 테마 색은 WCAG 명암비를 테스트로 검증합니다.

원인 — 올바른 키인데 복호화가 전부 실패한 이유

사전공유키가 분명히 맞는데도 실캡처 106프레임이 MIC 검증에 100% 실패했습니다. 원인은 IEEE 802.15.4가 확장주소를 전파에 LSB 우선으로 실어 보내는 반면 CCM* nonce는 정규 EUI-64(MSB 우선)를 요구한다는 바이트 순서 불일치였고, 순서를 뒤집자 106프레임이 전부 복호화됐습니다. 더 뼈아팠던 건 합성 테스트가 이걸 구조적으로 잡을 수 없었다는 점입니다 — 테스트가 같은 순서로 nonce를 만들어 암호화하고 복호화했기 때문에 틀린 순서끼리도 자기일관적이었습니다. 이후 실캡처 회귀 픽스처를 합성 테스트와 반드시 병행하는 쪽으로 검증 방식을 바꿨습니다.

핵심

  • 올바른 키로도 전부 실패하던 복호화를, on-air LSB-first 확장주소와 CCM* nonce가 요구하는 정규 EUI-64(MSB-first)의 불일치로 특정해 106프레임 전량 복호화 성공
  • 5단 계층 프로토콜 스택 자체 구현(Wireshark 비의존) — MAC → AES-CCM* → HDLC(IEC 62056-46) → LLC → DLMS/COSEM, 이중 암호화된 APDU는 추측 대신 'ciphered, DLMS 키 필요'로 명시
  • 계기별 세션·링크 품질 분석 — HDLC 세션(SNRM→폴링→DISC), N(S) 갭 기반 손실, 재전송, RTT, 프레임카운터 재사용 추적으로 망 진단 도구화
  • IEEE 802.15.4g(SUN) PHR 직접 파싱 — 상위 플래그(Mode Switch/FCS Type/Data Whitening)와 11비트 길이 필드 해석, 바이트 유실 시 FCF 타당성 + 다음 2프레임 체인 검증으로 약 4바이트 내 재동기화(프레임 1개만 손실)
  • 7개 CP210x 동글 동시 캡처 — 동글별 시리얼 수신 스레드 → 공용 큐 → 100ms 타이머로 GUI 스레드에서 시간순 병합, USB 시리얼번호 기반 채널 매핑으로 COM 재번호에도 유지
  • 헤더/트래픽 패턴 기반 수동 토폴로지 추론(가입·유니캐스트·허브), 테스트 251개 + PyInstaller 단일 exe 패키징

기술 스택

Python 3.13PyQt6pyserialcryptography (AES-CCM*)DLMS/COSEMHDLC (IEC 62056-46)PyInstallerpytest