Skip to content

record: selectDevices resolves the screen by a loose "Capture screen" substring, first-match-wins, so a crafted video device enumerating first shadows the real screen in screen.mp4 #70

Description

@REPPL

Summary

selectDevices resolves the screen-capture index (for record -video) by scanning the parsed avfoundation video devices and taking the first whose name contains the substring "Capture screen":

screenIndex = -1
if wantScreen {
    for _, d := range video {
        if strings.Contains(d.name, "Capture screen") {
            screenIndex = d.index
            break
        }
    }
    if screenIndex == -1 {
        return 0, nil, fmt.Errorf("no avfoundation \"Capture screen\" device found; ...")
    }
}

The match is a loose, unanchored substring with first-occurrence-wins (break) and no ambiguity/duplicate handling. parseAVDevices captures the device name with the regex's trailing (.*) and only trims whitespace — no ^Capture screen \d+$ shape check. The resolved screenIndex is fed verbatim to ffmpeg's -i via screenArgs, so a video device whose OS-supplied name merely contains "Capture screen" and enumerates before the genuine screen device is captured into screen.mp4 in its place — silently, with no error and no roster surfaced.

The audio side of the same function is the hardened sibling: the microphone is captured via avfoundation ":default" (micArgs), never by a name match, precisely so a virtual audio driver cannot shadow the real device (the anti-shadowing roster documented at internal/record/record.go:475-478). The video path is the unswept sibling of that hardening.

Wrong implicit assumption

"The only avfoundation video device whose name contains Capture screen is the real OS screen-capture device, and it is the first such device in enumeration order." Both halves are false. On macOS, cameras (including virtual/USB cameras) and screen pseudo-devices share one namespace, device names are OS-supplied strings an attacker can influence (the exact boundary this repo already accepts for the audio side — see #68, "a crafted USB/virtual-audio device can set" device names), and avfoundation lists cameras before screen devices — so a crafted camera gets a lower index and matches first.

Exact trigger (input → observed vs correct)

Operator runs testimony record -video (screen capture is opt-in). An attacker-influenced avfoundation video device is present whose name contains Capture screen — e.g. a virtual camera named Capture screen 0, or a USB camera whose vendor string is Elgato Capture screen HD. The device listing (matching the repo's own fixture ordering) is:

[AVFoundation indev @ 0x…] AVFoundation video devices:
[AVFoundation indev @ 0x…] [0] Elgato Capture screen HD   ← crafted/coincidental camera, lower index
[AVFoundation indev @ 0x…] [1] Capture screen 0           ← the genuine display
  • Observed: selectDevices matches index 0 first, breaks, and returns screenIndex = 0; screenArgs(0, out) emits -i 0; screen.mp4 records the crafted camera's video instead of the participant's screen. Exit 0, no warning — the operator believes the retained file is a faithful screen recording.
  • Correct: an anchored match (^Capture screen \d+$) rejects Elgato Capture screen HD, falls through to index 1, and captures the genuine display.

The repo's own test fixture confirms the decisive enumeration order: internal/record/record_test.go:92-97 lists Studio Display Camera at index [0] and the real Capture screen 0 at index [1] — a camera precedes the screen, so first-match-wins is exploitable rather than self-defeating.

Location

  • Defect: internal/record/recorders.go:130-135 (selectDevices, the loose strings.Contains(d.name, "Capture screen") + break).
  • Unanchored name capture: internal/record/recorders.go:78 (deviceLine regex, trailing (.*)) and :101.
  • Consumption: internal/record/record.go:471 (probeDevices(ffmpeg, wantScreen)) → internal/record/record.go:495 (screenArgs(screenIndex, out)) → internal/record/recorders.go:57-68 (-i strconv.Itoa(screenIndex)) → retained screen.mp4 (internal/record/record.go:9, "opt-in retained evidence").
  • Hardened sibling (audio): internal/record/recorders.go:43-52 (micArgs, avfoundation :default) and the anti-shadowing rationale at internal/record/record.go:475-478.

Scope caveat (for the maintainer)

screen.mp4 is retained evidence but is not yet consumed by any downstream pipeline command (internal/record/record.go:9), so the blast radius is bounded to the integrity of that one video artefact — hence severity:minor. The trigger requires -video and an attacker-influenced (or coincidentally-named) video device on the operator's machine — the same crafted-device precondition the repo already accepted in-scope for the audio sibling (#68). On a stock, non-adversarial macOS roster no device collides with the substring, so the normal happy path is unaffected; this is an anti-shadowing/robustness gap, not a stock-input logic error.

CWE anchor

CWE-807 (Reliance on Untrusted Inputs in a Security Decision) / CWE-345 (Insufficient Verification of Data Authenticity); the loose-substring aspect is also CWE-20 (Improper Input Validation).

Sibling sweep (name-based device selection over avfoundation output)

  • Screen selection in selectDevicesthis defect (the only place that selects a device by a name match over untrusted avfoundation output).
  • Microphone selection — NOT vulnerable: captured via avfoundation :default (micArgs), resolved by ffmpeg at capture time, never by a parsed name; the roster is only logged for visibility (formatAudioRoster, recorders.go:151). This is the deliberately-hardened sibling.
  • strconv.Atoi(m[1]) (recorders.go:97) — NOT a sibling defect: the parsed index is re-emitted via strconv.Itoa, never used as a slice subscript, and an out-of-range value makes Atoi error and the row is skipped.
  • Terminal sinks for device names (formatAudioRosterSafeText, outputTailSafeTextLines) — already hardened; not this class.
  • Distinct from the already-filed parseAVDevices section-header confusion (record: parseAVDevices mistakes a device row for a section header when the device name contains "AVFoundation audio/video devices:", silently dropping and misclassifying inputs #68) — different mechanism (name-based selection, not section detection).

Validator confirmations (both lenses, adversarial, independent)

  • Reachability lens — CONFIRMED: "-video is a real flag flowing to planprobeDevices(wantScreen=true)selectDevices; the parsed video name flows into screenIndex and verbatim into ffmpeg's -i via screenArgs (record.go:495, recorders.go:62), and screen.mp4 is produced and retained. The repo's own fixture (record_test.go:94) puts the real Capture screen 0 at index 1 behind a camera at index 0, so first-match-wins is exploitable; a virtual-camera name is the exact video analogue of the audio boundary record: parseAVDevices mistakes a device row for a section header when the device name contains "AVFoundation audio/video devices:", silently dropping and misclassifying inputs #68 already accepts."
  • Correctness lens — CONFIRMED: "No anchoring, dedup, ambiguity warning, or downstream check exists in parseAVDevices/selectDevices/startRecorders/screenArgs; input {"Elgato Capture screen HD"@0, "Capture screen 0"@1} fixes screenIndex=0 and screen.mp4 records the camera, whereas an anchored ^Capture screen \d+$ would correctly select index 1 — a genuine divergence the code's own audio-side :default hardening shows the authors treat as a real threat."

Fix direction (one sentence, not prescriptive)

Match the screen device against the anchored canonical form (^Capture screen \d+$) rather than a loose substring — and consider surfacing a screen roster / refusing on ambiguity — so a non-screen device whose name merely contains the phrase cannot shadow the genuine display.

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions