9월 말, 홈랩에 작은 IoT 보안 실험실을 차렸다. 목표는 단순했다. “대충 만든 IoT” 와 “제대로 만든 IoT” 를 같은 기기로 나란히 세워 두고, 차이가 실제로 어디서 나는지 확인하자.

기기는 홈랩의 노트북 노드 한 대다. 2-in-1 노트북이라 가속도계, 힌지 각도, 배터리·어댑터 상태 같은 센서가 달려 있다. 가짜 값이 아니라 실제 센서 데이터로 실험할 수 있다는 게 이 기기를 고른 이유다.

이 글은 그 작업의 설계와 교훈을 정리한다. 결론부터 적으면, “제대로” 의 대부분은 암호 알고리즘이 아니라 신원·권한·업데이트·감시를 어떻게 나눠 맡기느냐에서 나왔다.

0. 구성 — 두 노드, 두 봇, 공개 정보만 오간다

역할 맡은 일
기기 노드 (센서 노트북) 센서를 읽어 발행, 명령·업데이트 수신 (검증만, 실행 안 함)
브로커 노드 (2011년형 맥미니) MQTT 브로커(Eclipse Mosquitto), 자체 인증기관(CA), 수집기, 탐지기, 업데이트 서명기

두 노드에는 각각 상주 AI 봇이 있고, 각자 자기 노드만 손댄다. 둘 사이를 오간 건 인증서 서명 요청(CSR), 서명된 인증서, CA 인증서, 공개키 같은 공개 정보뿐이었다. 이 원칙이 이후의 설계를 대부분 정했다.

1. 대조군 — 익명·평문 브로커

먼저 “대충 만든” 쪽을 세웠다. 익명 접속을 허용하고 암호화하지 않는 브로커다. 설정은 두 줄이면 된다.

결과는 예상대로였다. 접속만 할 수 있으면 누구든 모든 기기의 메시지를 읽고, 기기인 척 값을 쓰고, 명령 토픽에 명령을 보낼 수 있었다. OWASP IoT Top 10 이 꼽는 “Weak, Guessable, or Hardcoded Passwords”, “Insecure Network Services” 를 그대로 재현한 셈이다.1

이 브로커는 실험할 때만 켜고 기본은 꺼 둔다. 대조군이 상시로 떠 있으면 그 자체가 홈랩의 구멍이 된다.

2. 신원 — 개인키는 기기 밖으로 나가지 않는다

“제대로 만든” 쪽의 첫 번째 원칙은 기기마다 고유한 신원이다. ETSI EN 303 645 도 비밀번호를 쓴다면 “all consumer IoT device passwords shall be unique per device or defined by the user” 여야 한다고 정한다.2 우리는 비밀번호 대신 기기별 인증서(mTLS) 를 택했다.

발급 흐름이 중요하다.

기기 노드: 개인키 생성 (P-256, 파일 권한 600) → CSR 생성 (CN=device-xxx)
     │  CSR 만 전달 (공개 정보)
     ▼
브로커 노드: CSR 서명 검증 → CN 규칙 검사 → 기기용 인증서 발급 (클라이언트 인증 전용)
     │  인증서 + CA 지문 전달
     ▼
기기 노드: CA 지문 직접 대조 → 인증서의 공개키 = 내 개인키의 공개키 확인 → 접속
  • 개인키는 한 번도 기기를 떠나지 않는다. 브로커 쪽이 키를 만들어 나눠 주는 방식보다 번거롭지만, 브로커 노드가 털려도 기기 키는 안전하다.
  • 서명 스크립트는 CN 이 device- 로 시작하지 않으면 거부한다. 기기가 ops 같은 관리자 이름으로 인증서를 받아 권한을 올리는 길을 발급 단계에서 막기 위해서다.
  • 기기 쪽 봇은 받은 CA 인증서의 지문을 스스로 계산해 전달받은 값과 대조했다. 채팅으로 오간 값을 그대로 믿지 않는 것도 검증의 일부다.

3. 권한 — 인증서 이름이 곧 신원, 신원이 곧 권한

Mosquitto 의 use_identity_as_username 은 클라이언트 인증서의 CN 을 접근 제어용 사용자명으로 쓴다.3 여기에 ACL 의 패턴을 걸면 “기기는 자기 이름 아래만” 이라는 규칙이 한 줄로 끝난다.

pattern write iot/%u/telemetry/#
pattern write iot/%u/status
pattern write iot/%u/event/#
pattern read  iot/%u/cmd/#
pattern read  iot/%u/ota/#

남의 토픽에 쓰려는 시도는 MQTT 5.0 에서 사유 코드 135(Not authorized) 로 거부된다.4 같은 시도를 MQTT 3.1.1 로 하면 에러 없이 조용히 버려진다. 3.1.1 의 응답에는 사유 코드가 없기 때문이다. 그래서 방어하는 쪽은 브로커 로그를 봐야만 시도가 있었다는 걸 안다. 이 점이 4절의 탐지 설계를 바꿨다.

놓치기 쉬운 설정 하나

테스트하다가 설정 하나가 빠져 있다는 걸 발견했다. Mosquitto 문서는 use_username_as_clientid 를 이렇게 설명한다.3

“Set use_username_as_clientid to true to replace the clientid that a client connected with its username. This allows authentication to be tied to the clientid, which means that it is possible to prevent one client disconnecting another by using the same clientid.”

인증서로 신원을 묶었더라도, MQTT 의 client-id 는 클라이언트가 스스로 정하는 값이다. 이 옵션 없이 쓰면 신원(인증서)과 세션 식별자(client-id)가 따로 놀 수 있다. mTLS 를 쓴다면 이 옵션을 같이 켜는 게 안전하다. 대신 같은 인증서를 쓰는 프로그램 둘이 동시에 붙으면 서로를 끊어 버리므로, 수집기·탐지기처럼 상시로 붙어 있는 클라이언트마다 별도 인증서를 발급해야 한다.

4. 감시 — 연결 상태, 데이터, 브로커 로그를 함께 본다

탐지기는 브로커 노드에서 systemd 서비스로 돌며 텔레그램으로 알린다. 규칙은 세 갈래다.

갈래 규칙 알림
전원 어댑터 분리 / 복구 / 배터리 구동 중 30% 이하 ⚡ 🔌 🪫
움직임 자세 20° 이상 변화, 기기 측 충격·낙하 이벤트 📐 💥 🪂
보안·생존 인증 실패 급증, 권한 거부, 비정상 종료(Last Will), 2분 무응답 🛑 🚫 🔇

권한 거부는 3절에서 말했듯 클라이언트가 모를 수 있으므로 브로커 로그를 직접 읽어서 잡는다. 생존 감시는 Last Will 과 무응답 타이머를 같이 쓴다. 왜 둘 다 필요한지는 실제 30분 끊김 사례를 다룬 글에 따로 정리했다.

5. 업데이트 — 서명 검증만, 실행은 하지 않는다

ETSI EN 303 645 는 제약이 크지 않은 기기라면 “it shall have an update mechanism for the secure installation of updates” 라고 요구한다.2 우리는 이걸 두 단계로 나눴다. 지금은 1단계, “검증만 하고 실행은 안 하는” 단계다.

  • 서명: Ed25519(EdDSA, RFC 8032)5. 기기는 공개키를 파일로 고정(pin)하고, 키 ID 를 직접 계산해 대조한다.
  • 매니페스트: 기기 이름, 버전, 해시, 크기, 발급·만료 시각, 일회용 nonce, 본문.
  • 검증 순서: 봉투 형식 → 키 ID → 서명 → 대상 기기 → 유효 기간 → nonce 재사용 → 버전 역행 → 크기 → 해시. 처음 실패한 사유를 보고한다.
  • nonce 와 현재 버전은 디스크에 기록한다. 메모리에만 두면 재부팅 뒤 같은 매니페스트를 다시 받는 재전송을 막을 수 없다.

실기기로 정상 1건과 공격 6종(재전송, 서명 후 변조, 키 ID 사칭, 만료, 버전 역행, 다른 기기용)을 보냈고 7건 모두 기대대로 판정됐다. 그 과정에서 기기 쪽 봇의 제안으로 두 가지를 고쳤다.

  1. 유효 기간 오차. 처음에는 만료 판정에도 60초 오차를 줬다. 오차는 시계가 느린 기기를 위한 것이니 “미래 발급” 쪽에만 주고, 만료는 0초로 엄격하게 했다.
  2. retain 금지. 업데이트 매니페스트를 retain 으로 보내면 기기가 재접속할 때마다 다시 받아 “재전송” 보고가 반복된다.

명령(cmd) 토픽도 같은 원칙이다. 기기는 구독만 하고 로그만 남긴다. 실행하게 하려면 허용 목록, 명령마다 서명·만료·nonce 가 먼저다.

6. 감지는 어디서 해야 하나 — 10초 샘플의 한계

처음에는 기기가 10초마다 보내는 가속도 값으로 서버에서 움직임을 판정했다. 자세가 바뀐 건 잘 잡혔지만, 책상을 톡 치는 충격은 못 잡았다. 충격은 수 밀리초짜리 사건이라 10초 간격 샘플 사이로 빠져나간다.

해결은 감지를 기기로 옮기는 것이었다. 기기가 가속도를 빠르게 보면서 사건이 생길 때만 event/motion 을 보내고, 서버는 그 이벤트를 알림으로 잇기만 한다. 기기 쪽은 이벤트를 로컬 스풀에 적고, 브로커의 수신 확인(PUBACK)을 받은 뒤에만 다음으로 넘어가서 재접속이나 재시작에도 누락이 없다.

첫 실측에서 나온 오탐

책상을 한 번 쳤더니 첫 이벤트가 “낙하 의심” 으로 분류됐다. 두드리는 순간 가속도가 아주 잠깐 0 근처로 튄 걸 자유낙하로 본 것이다. 해법은 지속 시간 조건이다. 문제는 그 길이다. 자유낙하 시간은

\[t = \sqrt{2h/g}\]

이라, 책상 높이 0.5m 에서 약 0.32초, 0.75m 에서 약 0.39초다. 기기 쪽에서 처음 넣은 조건은 “0.4초 이상” 이었는데, 이러면 책상에서 떨어지는 진짜 낙하를 놓친다. 두드림은 수 밀리초 규모이니 0.12~0.15초 정도면 둘을 가를 수 있다. 오탐을 없애려다 진짜 사건을 놓치지 않도록 물리량으로 임계값을 검산하는 습관이 필요하다.

7. 정리 — “제대로” 를 이루는 것들

층 대충 만든 쪽 제대로 만든 쪽
신원 없음 (익명) 기기별 인증서, 개인키는 기기 안에만
전송 평문 TLS (mTLS)
권한 전부 허용 인증서 CN 기준 기기별 토픽, client-id 도 신원에 묶음
업데이트 없음 / 무검증 Ed25519 서명, 만료·nonce·버전 역행 검사, 상태 영속화
명령 즉시 실행 구독·기록만, 실행은 별도 승인 체계 뒤
감시 없음 브로커 로그 + Last Will + 무응답 타이머 + 기기 측 감지

맺으며 — 비용은 암호가 아니라 운영에 있다

이번 작업에서 암호 알고리즘을 고르는 데 쓴 시간은 거의 없었다. 시간이 든 곳은 누가 무엇을 들고 있어야 하는가(개인키는 기기, 서명키는 브로커 노드), 누가 무엇을 확인해야 하는가(지문을 직접 계산, 공개키 일치 확인), 무엇이 조용히 실패하는가(권한 거부가 3.1.1 에선 보이지 않음, 설정 하나의 누락), 그리고 임계값이 현실과 맞는가(10초 샘플, 0.4초 낙하 조건)였다.

IoT 보안은 결국 이 질문들에 대한 답을 하나씩 문서와 설정으로 고정해 가는 일이다. 다음 단계는 서명된 업데이트를 “검증만” 에서 실제 설치로 넘기는 것이다. 롤백과 이중 슬롯 없이 그 단계로 가지는 않을 생각이다.


References

  1. OWASP, Internet of Things Top 10 (2018). https://wiki.owasp.org/images/1/1c/OWASP-IoT-Top-10-2018-final.pdf ↩

  2. ETSI, EN 303 645 V2.1.1 — Cyber Security for Consumer Internet of Things: Baseline Requirements, Provisions 5.1-1, 5.3-2. https://www.etsi.org/deliver/etsi_en/303600_303699/303645/02.01.01_60/en_303645v020101p.pdf ↩ ↩2

  3. Eclipse Mosquitto, mosquitto.conf man page — require_certificate, use_identity_as_username, use_username_as_clientid. https://mosquitto.org/man/mosquitto-conf-5.html ↩ ↩2

  4. OASIS, MQTT Version 5.0 — Reason Code 0x87 Not authorized. https://docs.oasis-open.org/mqtt/mqtt/v5.0/os/mqtt-v5.0-os.html ↩

  5. S. Josefsson, I. Liusvaara, RFC 8032: Edwards-Curve Digital Signature Algorithm (EdDSA), IRTF, 2017. https://www.rfc-editor.org/rfc/rfc8032 ↩