Skip to content

SpyTV v1.0.7 - honest verdicts and a working Exploit button

Choose a tag to compare

@ProjectZeroDays ProjectZeroDays released this 05 Oct 00:11
· 54 commits to main since this release

SpyTV v1.0.7

Three reports, and all three turned out to be the same shape of bug: a value
that was decided before it was proven. A host was called vulnerable
because a port was open; a device was sent to DLNA because a lookup missed;
a television was identified by whichever ports happened to survive a burst of
64 simultaneous connections.

Fixed

1. A device showed as vulnerable with no device type and no CVEs

c2_ops.scan_network() set vulnerable: len(open_ports) > 0. The flag meant
"this host exists". perform_scan() then only ever raised it — after a
CVE list matched, after a family signature matched — and never lowered it, so
a host that matched nothing kept discovery's guess:

10.1.10.35   ports=80   device_type="Unknown"   vulnerable=true   (no cves)

It also contradicted the control next to it. /api/check had always computed
vulnerable: bool(finding) and bool(cves), so Check answered "not
confirmed" for a device Scan had just called vulnerable
— which is why the
report sounded impossible.

Fix. Discovery no longer decides. scan_network() records
vulnerable: False, detected: True, and one function — finalize_findings() —
sets the verdict: vulnerable only if a CVE list matched or a family
signature was recognised, otherwise merely detected. severity is now
computed for both sources (a device found only by its signature port got
cve_ids but no severity), and device_type takes the matched family's name.

The table agrees: an unmatched host now renders as Unidentified device
with a neutral Not confirmed badge instead of an amber Vulnerable sitting
over an empty CVE column that read as Safe.

Measured on the real pipeline, then again over POST /api/scan:

host open ports device_type vulnerable CVEs
10.2.0.1 (TV) 16 Samsung Tizen TV true 5
10.2.0.2 (this machine, :80) 1 Unidentified false 0

Findings claiming vulnerable with zero CVEs: 0 (was 1).

2. The row "Exploit" button never ran anything — and always chose DLNA

exploitTarget() looked the device up in a map keyed 'Samsung Tizen',
'LG webOS', 'Roku'… while the row passes r.name || r.device_type, i.e.
'Samsung Tizen TV', 'LG webOS TV', 'Roku TV'. No key ever matched.
The || 'dlna' fallback won for every device (only chromecast was rescued by
a special case). Driving the real script in Node:

exploitTarget('10.1.10.41', 'LG webOS TV')  ->  payload-family: "dlna"

DLNA has no entry in TV_CVE_DATABASE or CVE_EXPLOITS, so /api/exploit
could only answer 404 no exploit module. And even that was never shown: the
function only pre-filled a form and switched tabs, writing nothing to the
card. A click produced no visible effect at all — "the exploit button doesn't
work"
.

Fix. The family now resolves from the row's own tv_type through a new
TVTYPE_TO_FAMILY table (keys taken from TV_CVE_DATABASE), and the button
does what its label says:

  • recognised family and confirmed CVEs → runs the module and reports the
    real outcome on the card;
  • recognised family, no confirmed CVE → loads the target, does not run, and
    says why (Check to verify, or Exploit to run anyway);
  • no identifiable family → refuses and leaves the selector alone rather
    than silently claiming DLNA.

Two more gaps found while auditing this: select.value only sticks when the
option exists, so Hisense and TCL were unreachable — both are in the CVE
database, the exploit registry and both family maps, but had no <option>.
Added. And deployPayload() posted tv_type: null, which arrived server-side
as the literal string 'None' (truthy) and skipped family lookup; both ends
now normalise null to "".

3. Scan and Check disagreed about which television a host was

Seconds apart on the same host: /api/scan said LG webOS (4 CVEs),
/api/check said Samsung Tizen (5 CVEs). Two different CVE sets and two
different exploit modules for one device.

detect_tv_type() is first-match over database order, so the winner depends
entirely on which ports the caller saw — and the scan gets its ports from the
pooled probe while Check probes sequentially. Measured against a device that
accepts every connection (16 signature ports, sequential reference 16/16):

workers=64 -> 3/16      workers=8 -> 13/16
workers=16 -> 8/16      workers=4 -> 16/16   (2 and 1 also 16/16)

64 concurrent SYNs to one device drop most of them, and the ports that
survive decide the family. Here all four Tizen ports were lost and only 3000
survived — hence "LG webOS".

Fix. probe_many() now caps in-flight probes per address
(PER_HOST_WORKERS = 4) and builds its queue port-major, so parallelism
spreads across hosts instead of piling onto the first few addresses. Results
are still returned in job order, which every caller maps back by index.
Re-measured: 16/16, scan_network() reports samsung-tizen, in 1.4s —
no slowdown, and each device now sees a fraction of the previous fan-out.

4. Controls audited: family / CVE / TV type / connection type

Control Samsung LG Android/Fire Roku Vizio Chromecast Hisense TCL DLNA
CVE database ✅ ✅ ✅ ✅ ✅ ✅ ✅ ✅ —
Exploit module ✅ ✅ ✅ ✅ ✅ ✅ ✅ ✅ ❌ none
Family map (server + dashboard) ✅ ✅ ✅ ✅ ✅ ✅ ✅ ✅ null
Payload selector ✅ ✅ ✅ ✅ ✅ ✅ added added ✅ payload only
Row Check / Exploit / Patch ✅ ✅ ✅ ✅ ✅ ✅ ✅ ✅ refused, honestly
Devices-tab CVE chips + Patch ✅ ✅ ✅ ✅ ✅ ✅ ✅ ✅ n/a
  • DLNA has no exploit module. /api/exploit refuses it with
    404 {error, attempted:false, known_families:[…]} — verified live. Payload
    generation for DLNA works (generic connect-back), so it stays selectable;
    the refusal now happens where you can see it.
  • generate_payload()'s fallback was labelled "Generic DLNA Exploit Payload"
    and registered family:"Unknown" for every family without a dedicated
    install vector — which is what left connected Hisense/TCL/Chromecast devices
    with no device type and no CVE profile. It now names and registers the
    family actually requested.
  • Patch only wrote to the activity log, and it could not use the scan
    status helper (that targets the results table itself). It now reports into a
    dedicated slot under the table.

Also

  • A flaky test that made the suite untrustworthy.
    test_generate_exploit_without_key failed about half of full-suite runs with
    [WinError 10053] An established connection was aborted and passed in
    isolation. The stub server answered POST without ever reading the request
    body
    , then closed — leaving the client mid-write. Windows reports that on
    the client. It now drains the body before responding.

Verification

Check Result
Full pytest suite 114 passed, 3 consecutive runs
New probe-pool tests fail unless the per-host cap, the cross-host spread and job ordering hold
New verdict tests with the old logic restored: {'ip': '10.1.10.35', 'open_ports': [80], 'device_type': 'Unknown', 'vulnerable': True} — the exact report
Dashboard script parse node --check clean
Dashboard behaviour (Node, stub DOM) 90 assertions, 0 failures (14 sections)
Live POST /api/scan 2 findings, 0 claiming vulnerable with no CVE
Live POST /api/check agrees with Scan on the same hosts
Live POST /api/exploit (DLNA) 404 attempted:false + known_families — nothing ran
Devices tab, all implant families 9/9 cards, CVE chips + Patch present; Samsung(Tizen), LG(webOS), AndroidTV, RokuTV, Vizio, chromecast-cast, hisense, tcl, Fire TV all resolve; Unknown/unknown correctly resolve to no profile
Pooled vs sequential probe accuracy 3/16 → 16/16

The dashboard suite executes the whole inline script in Node against a stub DOM
and skips when node is absent (node is already required for the Electron
build). It also covers the inline handlers themselves: an apostrophe in a
device name is escaped with the new jsAttr(), because escapeHtml() leaves
apostrophes alone (correct for HTML text) and one inside a single-quoted
onclick argument would make the handler a SyntaxError — a button that does
nothing, which is precisely what this release is about.

Upgrading

A running C2 executes the code it was started with — restart it:

python c2_server.py

No Android source changed. The desktop shell (electron/*.js, its index.html
and offline.html) did not change either; only its version string was bumped,
so the shipped exe is rebuilt solely to match this tag.

Artifacts

File Size SHA-256
SpyTV.apk 6,294,138 B 6395ff97891500c8b53067b28a3a4f4574af2dce4f405c13f24c3daba1deca1f
SpyTV-C2-1.0.7-portable.exe 100,109,261 B 061eb2d3fbb5307336982f004e320b537f2667b2417afaecacd3fd81ce826e5b
SHA256SUMS.txt 172 B matches both entries above

SpyTV.apk is byte-identical to v1.0.5 and v1.0.6 — no Android source changed,
so it is re-attached unchanged rather than rebuilt. The exe is rebuilt only
because electron/package.json carries the version; its build.files is
main.js, preload.js, index.html, offline.html and none of those
changed.