19 KiB
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, andterp(index-out-of-range panic inClip.Sublimate). - An attacker-controlled
SourceDatavalue 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:8083terp/main.go:27,47,59-102— listener, default:8081coordinator/main.go:41,86,98-113— listener, default:8082livestream-cache/main.go:256-325— TCP ingest on:8085livestream-cache/main.go:327-347— HTTPS MJPEG server on:8443run-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. coordinatoradditionally indexesclp.Timestamps[0]/clp.Guids[0]atcoordinator/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.Decodewith 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
SourceDatakey 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.Serverhas noReadHeaderTimeout/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—-passphrasedefaults 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 viaps. - MySQL DSN is built inline (
coordinator/main.go:56-57) and defaults torootwith an empty password (coordinator/main.go:46-48). - Config files store passphrases in plaintext YAML (
config.example.yaml:11,15shipspassphrase: "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 written0644(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
- 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).
- Validate everything decoded from the network: length invariants (
CheckLenCorrelations), frame counts, dimensions,SourceDatacharset, and path containment. - Cap resources: connection limits, decode sizes, worker-pool bounds,
http.Servertimeouts. - 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). - 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) |