432 lines
16 KiB
Python
432 lines
16 KiB
Python
|
|
"""
|
||
|
|
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
|