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