Repository navigation
SpyTV v1.0.7 - honest verdicts and a working Exploit button
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/exploitrefuses 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 registeredfamily:"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_keyfailed about half of full-suite runs with
[WinError 10053] An established connection was abortedand passed in
isolation. The stub server answeredPOSTwithout 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.