Files
2026-09-09 21:44:05 -05:00

19 KiB
Raw Permalink Blame History

Security Audit — oko livestream/camera pipeline

Audited scope: all Go modules (frame, runCam, mofin, terp, coordinator, livestream-cache, oko-run), shell/launcher scripts, YAML configs, and everything tracked in the git repository.

Date: 2026-08-28 Method: full source review + targeted exploit verification (path traversal, malformed-clip crash, committed-secret history scan).


Executive summary

The oko pipeline moves live/captured camera frames over plaintext TCP between never-authenticated daemons. Anyone who can reach any of the default :808x/:8443 listeners can inject video clips, poison live feeds, crash daemons, or read live footage. The most severe problems are not crypto weaknesses but architectural ones: no authentication anywhere, no input validation on anything received from the network, no resource limits, and secrets/footage committed to git.

Verified during this audit:

  • A single crafted TCP clip crashes coordinator, mofin, and terp (index-out-of-range panic in Clip.Sublimate).
  • An attacker-controlled SourceData value lets a clip escape the storage directory (path traversal to arbitrary file writes).
  • TLS private key + real surveillance footage are present in git history.

Findings (most → least serious)

1. CRITICAL — No authentication on any network endpoint; all listeners bind 0.0.0.0

Location:

  • mofin/main.go:28,41,54-73 — listener, default :8083
  • terp/main.go:27,47,59-102 — listener, default :8081
  • coordinator/main.go:41,86,98-113 — listener, default :8082
  • livestream-cache/main.go:256-325 — TCP ingest on :8085
  • livestream-cache/main.go:327-347 — HTTPS MJPEG server on :8443
  • run-stack.sh:58-87 — launches everything bound to all interfaces

Description: Every daemon listens on :port (all interfaces) and accepts any TCP connection. There is no token, no shared secret handshake, no TLS client-auth, no IP allowlist — nothing. The config comments ("over Tailscale") imply the intended boundary, but nothing enforces it; the ports are also reachable on any other interface (LAN, Docker bridges, etc.).

Impact:

  • Remote clip injection: arbitrary video can be routed into the motion-detection / classifier / storage pipeline.
  • Live-feed poisoning: spoofed frames overwrite legitimate cameras in the livestream cache (livestream-cache/main.go:299-321), so a viewer sees attacker-chosen images on a real camera stream.
  • DoS: malicious peers can crash daemons and exhaust resources (findings #2, #3, #5).
  • Data theft: if any listener is exposed, an attacker gets fuller access than the camera operator intends.

Recommendation: Require authentication on every ingest endpoint (HMAC-authenticated messages or an API token), bind to loopback/Tailscale interface explicitly (configurable per service), and consider TLS with client certificates for inter-node traffic.


2. CRITICAL — Remotely-triggerable crash: malformed Clip causes index-out-of-range panic in Clip.Sublimate

Location: frame/frame.go:128-148 (Sublimate, indexing clp.Guids[i] at line 142 and clp.Timestamps[i] at line 143). CheckLenCorrelations exists at frame/frame.go:151-166 but is never called on any receive path.

  • Consumers that crash: coordinator/main.go:172-180, mofin/main.go:100-108, terp/main.go:117-126,138.
  • coordinator additionally indexes clp.Timestamps[0] / clp.Guids[0] at coordinator/main.go:193,217,327.

Description: A Clip with a non-empty PixelMats but an empty/short Guids or Timestamps slice causes Sublimate to panic. These structs are decoded straight from the network via gob with zero validation.

Impact: One small crafted packet kills any ingest daemon (coordinator, mofin, terp). No authentication means any reachable host can take the entire recording pipeline down, repeatedly. This is a remote crash / availability vulnerability.

Verified: unit test reproduced runtime error: index out of range [0] with length 0.

Recommendation: Validate Clip length invariants (CheckLenCorrelations) immediately after decode on every receiver, bound the number of frames per clip, and recover from panics in connection handlers (defer recover()).


3. CRITICAL — Path traversal in coordinator → arbitrary file write outside storage

Location: coordinator/main.go:191-196:

user, camera := splitSource(clp.SourceData)      // line 191
...
clipDir := filepath.Join(storageDir, user, camera, dateShard, dirName)
if err := os.MkdirAll(clipDir, 0755); err != nil { ... }

splitSource (coordinator/main.go:360-365) does no sanitization; SourceData comes verbatim from the network.

Description: filepath.Clean preserves .. elements that escape the root. A SourceData of e.g. ../../../../tmp/pwn--cam resolves clipDir to something outside storageDir and the coordinator then MkdirAlls it and writes clip.mp4 / clip.json inside.

Impact: Remote arbitrary file creation/overwrite anywhere the coordinator process can write. At minimum: fill the disk, clobber config/log files, plant files for operators to open, or stage payloads. Because the process runs as the service user (often root on Pis), this can become full host compromise.

Verified: filepath.Join("storage", "../../../../tmp/pwn", "cam", ...) resolves to /home/tmp/pwn/cam/..., comfortably outside storage.

Recommendation: Sanitize/whitelist SourceData (allow only [a-zA-Z0-9_-/]), strip .. segments, and resolve+verify the final path is within storageDir (EvalSymlinks / prefix check) before any MkdirAll.


4. CRITICAL — TLS private key for the livestream HTTPS server is committed to git

Location: livestream-cache/key.pem and livestream-cache/cert.pem (tracked; present in every commit since added). Used at livestream-cache/main.go:344 (ListenAndServeTLS).

Description: The key.pem is a live private key checked into the repository (mode 0644).

Impact: Anyone with repo access (or the public history) can operate the server identity and MITM the live camera streams served over :8443; combined with finding #1, the HTTPS layer provides no actual security for this deployment. Self-signed, so browsers already show warnings.

Recommendation: Remove both files from the repo and history (git filter-repo), rotate the key immediately, generate certs at deploy time or store in a secrets manager, and chmod 600 the key file.


5. CRITICAL — Real surveillance footage is committed to the git repository

Location: coordinator/storage/oko/front/0/2026-06-30/*/clip.mp4 + clip.json (tracked). The JSON exposes "source_data": "oko--front/0", timestamps, GUIDs, detections.

Description: Actual camera recordings of a camera labeled front are in the repo and history.

Impact: Permanent privacy/legal exposure of security-camera footage of people and property. Anyone who ever gets the repo gets the footage. This cannot be fixed by deleting the files — history must be rewritten.

Recommendation: Purge storage output from git history (git filter-repo), add coordinator/storage/** (and logs/) to .gitignore, and ensure no future clip data is committed.


6. HIGH — Unbounded resource consumption / DoS: no limits on decode size, connections, or goroutines

Location:

  • Unbounded connection + goroutine per accept: coordinator/main.go:98-113, mofin/main.go:52-73, terp/main.go:59-102, livestream-cache/main.go:269-279.
  • Unbounded gob.Decode with no stream/field size caps: coordinator/main.go:172, mofin/main.go:67, terp/main.go:74, livestream-cache/main.go:288.
  • No read/write deadlines on any socket (slow-loris friendly).
  • Per-clip processing goroutines with no bound on frames-per-clip: terp/main.go:141-151 (one goroutine per frame × one per classifier), mofin/main.go:79-88.
  • Livestream cache map grows per untrusted SourceData key until the TTL janitor runs (livestream-cache/main.go:66-73,235-254) — attacker can flood with millions of synthetic IDs in 30 s.
  • http.Server has no ReadHeaderTimeout/MaxHeaderBytes/connection limits (livestream-cache/main.go:332-336).

Impact: Memory exhaustion (gob-decode bombs, large PixelMats, giant maps), CPU exhaustion (frame/classifier goroutines), and connection exhaustion from a handful of sockets. All remotely triggerable without auth.

Recommendation: Enforce max clip size / frame count / dimension limits, io.LimitReader on connections, per-connection deadlines, semaphore-bounded worker pools instead of unbounded goroutines, and sane http.Server timeouts/limits.


7. HIGH — Live camera streams (/stream/...) served over HTTPS with no authentication

Location: livestream-cache/main.go:354-422 (handleMJPEGStream).

Description: Any unauthenticated client that can reach :8443 can fetch live frames:

  • GET /stream/oko--front/0 → live MJPEG stream.
  • Valid camera IDs are trivially enumerable (200 vs 404, main.go:361-364).
  • No auth headers, cookies, or originating-IP restrictions.
  • Because it's a plain <img>-loadable resource, any web page the victim visits can embed the stream (no CORS framing/canvas interplay blocks images), leaking the feed to third parties.

Impact: Anyone on the network can watch the cameras live — the very thing a home/office security system must prevent.

Recommendation: Require authentication (session cookie or per-camera token), serve on a Tailscale-only listener, hide whether cameras exist (uniform responses), and add X-Frame-Options/CSP framing controls.


8. HIGH — Encryption defaults off: clips are plaintext on the wire and at rest

Location:

  • runCam/main.go:39-passphrase defaults to "" (no encryption).
  • mofin/main.go:33, terp/main.go:31, coordinator/main.go:43 — decrypt enabled only when passphrase set.
  • All provided configs ship passphrase: "" or omit it (config.yaml, cam-only.yaml:12-13, livestream-cache/config.yaml:5, run-stack.sh).
  • No TLS anywhere in the pipeline; transport is raw TCP + gob (frame/frame.go:239-252, frame/network.go:24-70).

Description: The AES-256-GCM layer exists but is opt-in and off in every shipped config. Frames/clips traverse runCam→mofin→terp→coordinator as plaintext and are stored unencrypted. Even when enabled, there is no protection against replay.

Impact: On-path sniffers can reconstruct surveillance footage; anyone with storage read access reads plaintext footage; clips can be modified in transit without detection (GCM only protects when the passphrase is actually configured end-to-end).

Recommendation: Require encryption to be on (fail-closed), propagate the passphrase via environment/secret store rather than CLI flags, or use mutually-authenticated TLS. Add per-clip anti-replay (e.g., monotonic nonce/counter, unique clip nonce).


9. HIGH — Live-feed poisoning via spoofed SourceData on TCP ingest

Location: livestream-cache/main.go:299-321 (handleTCPConnection). passphrase := cameraPassphrases[sourceData]; with an empty passphrase the frame is accepted and stored under the spoofed key, overwriting a real camera's cached frames.

Description: The ingest server trusts clip.SourceData from the wire. Because there is no sender authentication (#1) and encryption is off (#8), an attacker connected to :8085 can impersonate any configured camera and overwrite what viewers see on /stream/....

Impact: Viewers are shown attacker-chosen images (denial of the real feed, social engineering, hiding the attacker's presence from the livestream while clips still may record differently).

Recommendation: Authenticate senders and validate SourceData against the configured camera list; reject unknown sources and never allow overwriting another camera's cache entry except from authenticated senders.


10. MEDIUM — Secrets in plaintext / visible in process listings

Location:

  • Passphrases and DB password passed as CLI flags: runCam/main.go:39, coordinator/main.go:43,47 (-passphrase, -db-password), mofin/main.go:33, terp/main.go:31. Visible to any local user via ps.
  • MySQL DSN is built inline (coordinator/main.go:56-57) and defaults to root with an empty password (coordinator/main.go:46-48).
  • Config files store passphrases in plaintext YAML (config.example.yaml:11,15 ships passphrase: "change-me" — a universally-known key if left in place).

Impact: Local privilege escalation/privacy loss on the host; a "well-known" default key if example config is lifted wholesale; database exposed if root/empty password ever connects to a reachable endpoint.

Recommendation: Pass secrets via environment variables or a secrets file with 0600 perms; stop defaulting to root/empty; never ship a default key.


11. MEDIUM — Compiled binaries committed to the repo

Location: livestream-cache/livestream-cache, terp/terp, mofin/mofin (tracked; terp/terp additionally shows uncommitted modifications in the working tree). The .gitignore lists them (runCam/runCam, etc.) but they were added anyway.

Description: Binaries cannot be audited or attributed; a modified, untracked terp/terp means the deployed binary may not match the source.

Impact: Supply-chain / integrity risk if such a repo is shared — someone could execute a trojaned binary with full pipeline privileges and never notice.

Recommendation: git rm --cached all compiled artifacts, keep .gitignore enforced (use git check-ignore/pre-commit), and always build from source (run-stack.sh / oko-run already build-if-missing).


12. MEDIUM — Live-stream endpoint is a CPU DoS by design

Location: livestream-cache/main.go:377-420.

Description: Every connected client triggers a full frameToImage + JPEG encodeJPEG every 100 ms regardless of whether the underlying frame changed (cache re-encode is per client, not per-frame). There is no client limit or rate limit.

Impact: A modest number of unauthenticated clients saturates CPU and starves the feed for everyone; no auth means trivially scriptable by an attacker.

Recommendation: Encode once per new frame and share the JPEG across clients (broadcast model), cap concurrent stream consumers globally and per camera, and add per-IP rate limits.


13. MEDIUM — No slow-loris / timeouts protection (all TCP services)

Location: accept handlers in coordinator/main.go:98-113, mofin/main.go:52-73, terp/main.go:59-102, livestream-cache/main.go:269-279; no SetReadDeadline/SetWriteDeadline anywhere; http.Server without ReadHeaderTimeout (livestream-cache/main.go:332-336).

Impact: Idle connections can be held open indefinitely by any local network peer, exhausting file descriptors and goroutines.

Recommendation: Set connection deadlines (e.g., 10-30 s idle timeout) and server timeouts; consider a small max-connections gate.


14. MEDIUM — World-readable storage and logs

Location:

  • coordinator: MkdirAll(..., 0755) dirs and clip JSON written 0644 (coordinator/main.go:196,316).
  • livestream.log (955 KB) committed to the repo, containing operational details of the livestream backend.

Impact: Any local account can read surveillance footage and logs; perms + repo history mean footage persists beyond the operator's control.

Recommendation: Use restrictive perms (e.g., 0700 dirs, 0600 files), and keep logs out of git (.gitignore logs/).


15. LOW — Camera enumeration & metadata leakage in responses

Location: livestream-cache/main.go:361-364 (404 vs 200 reveals configured camera IDs); stream/client IPs logged (main.go:380).

Impact: Enumerating which cameras exist over an unauthenticated endpoint; minor privacy exposure of viewer IPs in logs.

Recommendation: Return a uniform response for unknown vs known cameras (with auth in place), and consider not logging client IPs (or logging to an authenticated-only log).


16. LOW — mofin -threshold is truncated through uint8

Location: mofin/main.go:32,111 — flag is int (default 30) but processClip casts to uint8; shipped configs use threshold: 5000 (config.yaml, run-stack.sh:76, bots_readme.md), which silently becomes 5000 & 0xFF = 136.

Impact: Not a security break, but indicates config values are misinterpreted; behavior is not what operators think, which can cause unexpected motion-gating results.

Recommendation: Clamp or validate threshold to 0-255, or document it as percent-based math.


17. LOW — Replay of previously captured clips

Location: frame/frame.go:172-237 (AES-GCM without an anti-replay counter).

Description: GCM protects integrity/confidentiality but the protocol has no nonce-uniqueness enforcement across clips or sequence numbers, so a captured encrypted clip can be replayed into storage/livestream.

Impact/Recommendation: Low for a home system; add a unique per-clip nonce/preimage (e.g., GUID-derived) and monotonic counter if replay resistance matters.


18. INFO — genGuid panics on entropy failure; CfgData.PassHash is a misnomer

Location: frame/cameraFunctions.go:59-67 (panic on crypto/rand failure — turns a rare-but-recoverable condition into a crash), and frame/cameraFunctions.go:12-15,37,55 (PassHash holds the raw passphrase bytes, not a hash; SourceData embeds it as user--pass).

Impact/Recommendation: Entropy panic is a crash-under-adversarial-CSPRNG issue. The PassHash naming invites future misuse (embedding real secrets in SourceData); rename and ensure the actual passphrase is never serialized into SourceData (the current runCam path no longer does this — keep it that way).


Cross-cutting recommendations

  1. Zero-trust the wire: authenticate every peer and message (token or client-cert TLS), set explicit bind interfaces, and make encryption non-optional (fail closed).
  2. Validate everything decoded from the network: length invariants (CheckLenCorrelations), frame counts, dimensions, SourceData charset, and path containment.
  3. Cap resources: connection limits, decode sizes, worker-pool bounds, http.Server timeouts.
  4. Purge the repo: re-key TLS, remove footage from history (git filter-repo), drop compiled binaries and logs, harden .gitignore + pre-commit hooks (block *key*, *.pem, storage/log output).
  5. Secrets hygiene: environment-based secrets, no default root/empty DB creds, no examples with real-looking keys.

Repo hygiene summary (what's tracked in git that shouldn't be)

Item Risk
livestream-cache/key.pem (private key) TLS compromise → MITM of live streams
coordinator/storage/**/clip.mp4, clip.json Real surveillance footage in history
livestream-cache/livestream.log Operational data
livestream-cache/livestream-cache, terp/terp, mofin/mofin Unverifiable compiled binaries (one modified)