Tuần này HN có bài viết rất hot về chuyện reverse engineering máy lọc không khí để bỏ cloud. Đọc xong mình nhớ lại cái máy hút ẩm ở nhà: app thì chậm, mỗi lần bật tắt phải chờ request đi một vòng qua server bên Trung Quốc rồi mới quay về thiết bị cách mình 3 mét. Chưa kể có hôm server của hãng sập, cả thiết bị thành cục gạch, nút vật lý thì chẳng bao giờ ai dùng. Mình đã ngồi mất hai cuối tuần để đưa nó về chạy local hoàn toàn. Bài này chia sẻ quy trình mình hay làm với thiết bị IoT giá rẻ: quan sát traffic, hiểu protocol, rồi dựng một "fake cloud" chạy ngay trong LAN.
Lưu ý: chỉ làm với thiết bị bạn sở hữu, trên mạng của bạn. Việc này có thể làm mất bảo hành, và đừng public firmware hay key của hãng.
1. Hiểu kiến trúc trước khi đụng vào
Đa số thiết bị IoT consumer đi theo một pattern khá giống nhau: thiết bị giữ một kết nối dài hạn (thường là MQTT qua TLS, hoặc WebSocket, đôi khi là TCP tự chế) tới cloud của hãng. App điện thoại không nói chuyện trực tiếp với thiết bị mà gọi REST API lên cloud, cloud đẩy lệnh xuống.
flowchart LR App[App điện thoại] -->|HTTPS REST| Cloud[Cloud của hãng] Cloud -->|MQTT/TLS push lệnh| Device[Thiết bị IoT] Device -->|Telemetry định kỳ| Cloud Device -->|DNS lookup| DNS[DNS router] Điểm yếu nằm ở mũi tên cuối: thiết bị phải resolve domain của cloud trước khi kết nối. Nếu mình kiểm soát DNS trong LAN, mình kiểm soát luôn thiết bị kết nối tới đâu. Đây là kỹ thuật cốt lõi của toàn bộ bài.
2. Quan sát: thiết bị đang nói chuyện với ai?
Bước đầu tiên luôn là sniff traffic. Mình dùng một con Raspberry Pi 4 làm access point riêng cho thiết bị (hoặc đơn giản là port mirroring trên switch nếu có). Sau đó dùng tcpdump và nmap:
# Tìm IP của thiết bị qua MAC vendor sudo nmap -sn 192.168.1.0/24 | grep -B2 -i 'espressif\|tuya\|realtek' # Scan port đang mở trên thiết bị (nhiều con để lộ port debug) sudo nmap -sV -p- 192.168.1.57 # Bắt toàn bộ DNS query và kết nối ra ngoài của thiết bị sudo tcpdump -i wlan0 -nn host 192.168.1.57 and \(port 53 or tcp\) -w device.pcap # Xem nhanh các domain nó resolve tshark -r device.pcap -Y dns.qry.name -T fields -e dns.qry.name | sort -u Với máy hút ẩm của mình, kết quả trả về chỉ có 2 domain: một cho NTP, một kiểu mqtt-ap.vendor-iot.com trên port 8883. Port 8883 là MQTT over TLS, rất phổ biến với chip ESP32/ESP8266.
Nhiều thiết bị dùng chip Espressif không verify certificate chặt chẽ (hoặc tệ hơn, kết nối port 1883 plaintext luôn). Nếu thấy port 1883, xin chúc mừng, bạn gần xong rồi. Nếu là TLS, ta cần bước tiếp theo.
3. MITM để đọc payload
Để đọc nội dung, mình dùng mitmproxy 11 ở chế độ transparent và trỏ DNS domain cloud về máy chạy mitmproxy. Với dnsmasq trên Pi, chỉ cần một dòng:
# /etc/dnsmasq.d/iot-override.conf address=/mqtt-ap.vendor-iot.com/192.168.1.10 sudo systemctl restart dnsmasq # Chạy mitmproxy ở chế độ reverse tới cloud thật, giữ nguyên TLS phía upstream mitmdump --mode reverse:tls://mqtt-ap.vendor-iot.com:8883@8883 \ --set tcp_hosts='.*' -s log_mqtt.py Nếu thiết bị chấp nhận cert tự ký của mitmproxy (chuyện này xảy ra nhiều hơn bạn nghĩ), bạn sẽ thấy raw bytes. Script addon nhỏ dưới đây decode MQTT packet và log ra topic + payload:
# log_mqtt.py - mitmproxy addon, test với mitmproxy 11.x from mitmproxy import tcp, ctx def decode_publish(data: bytes): # Chỉ xử lý packet PUBLISH (type 3), đủ dùng để quan sát if not data or (data[0] >> 4) != 3: return None # Remaining length dạng variable-length (tối đa 4 byte) mult, length, i = 1, 0, 1 while True: b = data[i] length += (b & 0x7F) * mult mult *= 128 i += 1 if not b & 0x80: break topic_len = int.from_bytes(data[i:i + 2], 'big') topic = data[i + 2:i + 2 + topic_len].decode(errors='replace') qos = (data[0] >> 1) & 0x03 start = i + 2 + topic_len + (2 if qos else 0) payload = data[start:i + length] return topic, payload def tcp_message(flow: tcp.TCPFlow): msg = flow.messages[-1] result = decode_publish(msg.content) if result: direction = 'DEV->CLOUD' if msg.from_client else 'CLOUD->DEV' topic, payload = result ctx.log.info(f'[{direction}] {topic}: {payload[:200]!r}') Sau đó mở app, bấm lần lượt từng nút: bật, tắt, đổi mode, đổi tốc độ quạt. Mỗi lần bấm, ghi lại payload tương ứng. Sau 30 phút bạn sẽ có một bảng mapping kiểu:
| Hành động | Topic | Payload |
|---|---|---|
| Bật | dev/AB12/cmd | {"power":1} |
| Fan speed 3 | dev/AB12/cmd | {"fan":3} |
| Telemetry | dev/AB12/state | {"hum":62,"tank":0} |
Mẹo: nếu payload là binary chứ không phải JSON, hãy bấm cùng một nút nhiều lần và diff các packet. Byte nào thay đổi theo thời gian thường là counter hoặc checksum (hay gặp CRC16 hoặc sum đơn giản modulo 256).
Nếu thiết bị pin certificate và từ chối mitmproxy, lúc đó phải tính đến dump firmware qua UART (esptool.py read_flash) để tìm key. Đó là chủ đề cho một bài khác.
4. Dựng fake cloud và tích hợp Home Assistant
Khi đã hiểu protocol, ta thay cloud thật bằng Mosquitto 2.0 chạy local. Giữ nguyên DNS override, nhưng giờ trỏ về broker của mình. Nếu thiết bị không verify cert, bạn chỉ cần một cert tự ký với CN đúng domain.
sequenceDiagram participant D as Thiết bị participant DNS as dnsmasq participant M as Mosquitto local participant B as Bridge Python participant HA as Home Assistant D->>DNS: resolve mqtt-ap.vendor-iot.com DNS-->>D: 192.168.1.10 D->>M: CONNECT + publish dev/AB12/state M->>B: forward state B->>HA: homeassistant/sensor/... (MQTT discovery) HA->>B: lệnh bật/tắt B->>M: publish dev/AB12/cmd Bridge nhỏ dùng paho-mqtt 2.x để dịch giữa format của hãng và MQTT discovery của Home Assistant:
# bridge.py - pip install paho-mqtt==2.1.0 import json import paho.mqtt.client as mqtt DEV = 'AB12' VENDOR_STATE = f'dev/{DEV}/state' VENDOR_CMD = f'dev/{DEV}/cmd' HA_PREFIX = 'homeassistant' def publish_discovery(client): client.publish(f'{HA_PREFIX}/sensor/{DEV}_hum/config', json.dumps({ 'name': 'Dehumidifier Humidity', 'state_topic': f'local/{DEV}/humidity', 'unit_of_measurement': '%', 'device_class': 'humidity', 'unique_id': f'{DEV}_hum', }), retain=True) client.publish(f'{HA_PREFIX}/switch/{DEV}_power/config', json.dumps({ 'name': 'Dehumidifier Power', 'command_topic': f'local/{DEV}/power/set', 'payload_on': 'ON', 'payload_off': 'OFF', 'unique_id': f'{DEV}_power', }), retain=True) def on_connect(client, userdata, flags, reason_code, properties): client.subscribe([(VENDOR_STATE, 0), (f'local/{DEV}/power/set', 0)]) publish_discovery(client) def on_message(client, userdata, msg): if msg.topic == VENDOR_STATE: state = json.loads(msg.payload) client.publish(f'local/{DEV}/humidity', state.get('hum')) elif msg.topic.endswith('/power/set'): value = 1 if msg.payload == b'ON' else 0 client.publish(VENDOR_CMD, json.dumps({'power': value})) client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2) client.on_connect = on_connect client.on_message = on_message client.connect('127.0.0.1', 1883) client.loop_forever() Chạy nền bằng nohup python3 bridge.py > bridge.log 2>&1 & hoặc đóng gói thành systemd service cho chuẩn. Sau đó block luôn domain cloud ở firewall router để chắc chắn thiết bị không "phone home" ngầm:
# Chặn thiết bị ra Internet, chỉ cho phép nói chuyện trong LAN sudo iptables -I FORWARD -s 192.168.1.57 ! -d 192.168.1.0/24 -j DROP Kết quả: bấm nút trên Home Assistant là thiết bị phản hồi gần như ngay lập tức, thay vì 1 đến 2 giây như trước. Mất Internet vẫn chạy bình thường, và automation kiểu "độ ẩm trên 65% thì bật" chạy hoàn toàn local.
Kết luận
Bỏ cloud cho thiết bị IoT không cần kỹ năng hacker gì ghê gớm, phần lớn là kiên nhẫn quan sát. Mấy điểm rút ra từ kinh nghiệm của mình:
- Luôn bắt đầu bằng DNS: chạy
tcpdumpxem thiết bị resolve domain nào. Kiểm soát được DNS là đã thắng một nửa. - Kiểm tra port 1883 trước: nhiều thiết bị rẻ vẫn chạy MQTT plaintext, không cần MITM gì cả.
- Thử mitmproxy trước khi dump firmware: rất nhiều firmware không verify cert, tiết kiệm được cả ngày mò UART.
- Ghi lại mapping lệnh một cách có hệ thống: mỗi lần bấm một nút, diff payload, và lưu bảng mapping vào repo.
- Cách ly thiết bị IoT bằng VLAN hoặc firewall sau khi đã chạy local, vừa an toàn hơn vừa ngăn firmware tự update làm hỏng setup.
- Kiểm tra cộng đồng trước: các project như Tasmota, ESPHome hay các integration của Home Assistant có thể đã có người reverse sẵn thiết bị của bạn rồi.
Cuối cùng, đừng quên thiết bị là của bạn, dữ liệu trong nhà bạn cũng là của bạn. Mỗi thiết bị được đưa về local là thêm một thứ không bị chết theo server của hãng.
