Skip to content

Releases

v4.5.3

Choose a tag to compare

@ProjectZeroDays ProjectZeroDays released this 02 Nov 11:14

Bugfix release: working buttons, auto-filled CIDR, bulk exploit/patch generation, 9443 default port (8443 reserved on Windows), file:// API fallback, fingerprinting improvements.

SpyTV v4.5.1 — TV Security Assessment Platform

Choose a tag to compare

@ProjectZeroDays ProjectZeroDays released this 27 Oct 19:40

Release v4.5.1

Phase 1 — Error-correct & refactor

  • Fixed Kotlin MainActivity: removed uninitialized lateinit vars (webView/errorView/buttons) and all dead references
  • Replaced WebView loading with system browser intent for connect-remote mode
  • Removed unused WebView/WebViewClient imports

Phase 2 — Repo cleanup

  • Removed pycache directories and android/app/build from disk
  • Added .opencode/ to .gitignore
  • Confirmed no secrets tracked (.env, *.key, *.pem all ignored-only)

Phase 3 — file_structure.txt

  • Regenerated from git ls-files (186 tracked files, indented tree with root folder comments)

Phase 4 — Vulnerability sweep

  • pip-audit: 0 Python vulnerabilities
  • npm audit fix: resolved 3 Node vulnerabilities (electron, fast-uri, undici) → 0 remaining
  • GitHub Dependabot: all alerts in 'fixed' state (0 open)
  • No hardcoded credentials or dead URLs found

Phase 5 — Test all code

  • 53 pytest tests pass
  • compileall: all Python files compile
  • node --check: tvreaper.js and c2_dashboard.html script block OK
  • All YAML configs parse correctly

Phase 6 — Debug

  • All threading.Lock usages use context managers (no deadlocks)
  • No stale Gradle processes; port 8443 clear

Phase 7 — Release assets

  • Built SpyTV.apk (6,435,918 bytes) via Gradle assembleDebug
  • Verified: zip integrity, apksigner signature, aapt badging (package com.authenticated.spytv, launcher MainActivity)
  • Generated SHA256SUMS.txt for SpyTV.apk and SpyTV-C2-standalone.exe

Phase 8-9 — Push & merge

  • All changes committed and pushed to main
  • main is the default branch

Assets

  • SpyTV.apk — signed Android APK (debug)
  • SpyTV-C2-standalone.exe — standalone C2 executable
  • SHA256SUMS.txt — checksums for all assets

SpyTV v1.2.1 - TV header icon, free AI models, electron build config

Choose a tag to compare

@ProjectZeroDays ProjectZeroDays released this 08 Oct 06:20

SpyTV v1.2.1

Summary

Bug-fix and feature release over v1.2.0: TV header icon, 13 new free AI provider models, Electron build config improvements, missing python-nmap dependency fix. Ships verified APK + standalone EXE.

Changes

File Change
c2_dashboard.html Added TV header icon (SVG) with .header-tv-icon styles
c2_cve_feed.py Added 13 free OpenRouter models (Nemotron 3 family, Laguna, Inkling, Dots3, Ling)
android/.../C2Server.kt Added matching model entries for the Android standalone C2
electron/package.json Version bump 1.0.7 -> 1.1.0; extraResources bundle; darwin dmg target; asar false
electron/main.js Removed unused DASHBOARD_URL; hardened frameless window startMove call
electron/preload.js App version 1.0.2 -> 1.1.0
requirements.txt Added missing python-nmap>=0.6.1 (required by core/scanner.py; was breaking pytest collection)
SHA256SUMS.txt Regenerated for rebuilt assets

Release Artifacts

Asset Size
SpyTV.apk ~6.1 MB
SpyTV-C2-standalone.exe ~25 MB

SHA256 checksums are in the SHA256SUMS.txt file included in the repository.

Security

  • pip-audit: No known vulnerabilities in Python dependencies.
  • npm audit: 8 moderate vulnerabilities from sprintf-js (used transitively by electron-builder). All versions vulnerable, no patched release published. Fix via --force requires downgrading electron-builder (breaking change, forbidden by policy). Accepted risk, dev-only build tooling.
  • GitHub Dependabot: 1 open moderate alert (#55, sprintf-js transitive). Same accepted rationale.
  • Hardcoded credentials: None found in tracked files.

Pipeline Summary (Phases 1-10)

Phase Description Status
1 Error-correct (nmap dep, JS duplicates verified clean) Completed
2 Repo cleanup (temp scripts, __pycache__, electron/node_modules, stale port-8443 process killed) Completed
3 file_structure.txt regenerated Completed (prior run)
4 Vulnerability sweep (pip-audit clean, npm 8 accepted, dependabot 1 accepted) Completed
5 pytest tests/ — 116 passed; compileall clean; tvreaper.js + electron JS syntax OK; YAML valid Completed
6 Debug (port 8443 freed, no Gradle races, PowerShell ; separators) Completed
7 APK rebuilt + verified (apksigner, aapt), EXE rebuilt, SHA256SUMS regenerated Completed
8 Committed (deaf176) Completed
9 Pushed to main (only branch, already default) Completed
10 Tagged v1.2.1 Completed

SpyTV v1.2.0 — Payload Generator Target IP Dropdown + Custom IP

Choose a tag to compare

@ProjectZeroDays ProjectZeroDays released this 08 Oct 03:34

Payload Generator UX Improvements

Payload Generator Tab

  • Target IP dropdown — the Target IP field is now a <select> dropdown (was a free-text input)
    that auto-populates from scan results and connected devices marked vulnerable: true
  • Exploit button moved left — Generate and Exploit now sit on the left of the form row
  • Custom IP button — new button on the far right prompts for an IPv4 address, validates it,
    and adds it to the Target IP dropdown

Build Artifacts

File Size
SpyTV.apk 6,430,665 bytes
SpyTV-C2-standalone.exe 37,672,041 bytes
SHA256SUMS.txt Checksum manifest

Both artifacts rebuilt with the latest dashboard HTML. The APK runs as a full standalone C2 server
on Android (default mode) or as a console connecting to a remote C2. The EXE is a PyInstaller
one-shot bundle that runs the C2 server without Python installed.

Verification

  • 116 pytest tests pass (all green)
  • Dashboard UI tests pass (Node harness, 67 assertions)
  • APK verified: package com.authenticated.spytv, launcher MainActivity, apksigner verify passed

SpyTV v1.1.1 - Dashboard deduplication, f-string fixes

Choose a tag to compare

@ProjectZeroDays ProjectZeroDays released this 08 Oct 03:18

SpyTV v1.1.1

Summary

Bug-fix release over v1.1.0: removes duplicate dashboard code, fixes f-string inconsistencies, and ships verified release artifacts.

Changes

File Change
c2_dashboard.html Removed duplicate tab-exploits div, duplicate nav item, duplicate JS function definitions (second set of loadProviderModels, generateExploitForCVE, generatePatchForCVE, loadExploits, updateCVEsFromFeed), duplicate switchTab override
c2_dashboard.html Fixed typo: "Regate Exploit" -> "Regenerate Exploit"
c2_cve_feed.py Fixed unnecessary f-string (f"CVE not found in database" -> "CVE not found in database")
c2_cve_feed.py Fixed redundant cve.get("tv_type", cve.get("tv_type", "smart-tv")) -> cve.get("tv_type", "smart-tv")
c2_server.py get_gateway_and_subnet() consistently returns dotted subnet mask
android/app/src/main/assets/c2_dashboard.html Synced with root dashboard

Release Artifacts

Asset Size
SpyTV.apk ~6.1 MB
SpyTV-C2-standalone.exe ~17.2 MB

SHA256 checksums are in the SHA256SUMS.txt file included in the repository.

Security

  • npm audit: 8 moderate vulnerabilities from sprintf-js (used transitively by electron-builder). All versions of sprintf-js are vulnerable with no patched release. The only fix via npm audit fix --force requires downgrading electron-builder, which is a breaking change and is explicitly forbidden by the project's security policy. These are documented as accepted risks in this release.
  • pip-audit: No known vulnerabilities in Python dependencies (colorama, pyyaml, requests, urllib3, reportlab).
  • GitHub Dependabot: All alerts on default branch resolved (10 alerts, all fixed).
  • Hardcoded credentials: None found in tracked files.

Pipeline Summary (Phases 1-10)

Phase Description Status
1 Error-correction & refactor Completed
2 Repo cleanup (remove junk dirs, extend .gitignore) Completed
3 Regenerate file_structure.txt Completed
4 Vulnerability sweep (pip-audit, npm audit, dependabot) Completed
5 Test suite (pytest tests/) All passed
6 Debug (port 8443 free, no Gradle races) Verified
7 Build release assets (APK, EXE, SHA256SUMS) Completed
8 Commit changes Committed (2ead0a6)
9 Push to remote Pushed to main
10 Tag & release Tagged v1.1.1

v1.1.0 — TV CVE additions from Quantum CLI

Choose a tag to compare

Release highlights:

TV CVE Catalog Expansion (11 new CVEs from Quantum CLI)

Dashboard & Exploit Catalog

  • Exploit dashboard now shows all 92 built-in exploits per CVE with per-row View Whitepaper button
  • Composite row IDs eliminate duplicate CVE-ID collisions across families
  • runExploitRow appends res.detail to success log
  • 39 tests passing (test_autonomous.py + test_audit_flow.py)

Android Parity

  • C2Server.kt TV_DATABASE expanded 1:1 with 11 new CVEs per family
  • Kotlin compile EXIT=0
  • Android asset c2_dashboard.html synced (contract test passes)

Screenshots

  • docs/screenshots/: dashboard-web.png, exe-running.png, apk-running.png

Whitepapers

  • 11 new whitepapers in docs/whitepapers/ (NVD CVSS 3.1 primary scores)

Excluded

Release Assets

  • SpyTV-C2-standalone.exe (37.6 MB)
  • SpyTV.apk (5.3 MB release APK)
  • SHA256SUMS.txt

SpyTV v1.1.0 — TV Security Assessment Platform

Choose a tag to compare

@ProjectZeroDays ProjectZeroDays released this 08 Oct 02:42

SpyTV v1.1.0 — Dashboard Enhancements, Network Info, Exploits Tab

New Features

  • New CVE Feed panel with Update button (refreshes from NVD in-place)
  • Network info in C2 Server Info tab: Gateway IPv4 + Subnet Mask
  • Provider/Model dropdown — auto-populated from /api/providers with all configured AI providers
  • Generate Exploit / Add Patch buttons on each CVE feed item with status badges
  • Exploits dashboard tab — lists all generated exploit modules with success rates
  • Fixed test generation HTTP 400 error — model validation now prevents mismatched provider/model combos

Android APK

  • APK can run as standalone full C2 server (same capabilities as Web/EXE)
  • Default to Self-Hosted C2 mode with mode selection dialog on app open
  • Fixed intToInetAddress/unresolved reference — manual intToIpString() conversion
  • Fixed DhcpInfo.ipMask -> netmask field name

Security

  • urllib3>=2.8.0, requests>=2.32.0 (pip-audit clean)
  • .pytest_cache/ and .mypy_cache/ added to .gitignore

Assets

  • SpyTV.apk — Android APK (standalone C2 server + Console mode)
  • SpyTV-C2-standalone.exe — Windows standalone EXE (full C2 server)
  • SHA256SUMS.txt — checksums for both assets

SpyTV v1.0.9 - Default Self-Hosted C2 + Mode Selection

Choose a tag to compare

@ProjectZeroDays ProjectZeroDays released this 07 Oct 23:03

SpyTV v1.0.9 - Default Self-Hosted C2 + Mode Selection

All platforms now default to Self-Hosted C2 mode with a clear mode selection dialog when the app opens:

  1. Self-Hosted: This device runs the full C2 server. Dashboard at http://localhost:8443/ (or http://:8443/ on Android). This is the default.
  2. Connect-Remote: Connect this device to an existing C2 server on another machine by entering its URL.

What changed

  • APK: First-run dialog shows Setup C2 Server with Self-Hosted | Connect-Remote buttons. Self-Hosted is now the default mode. Mode toggle in settings (top-right switch).
  • EXE: run_c2.bat shows mode selection menu. c2_server.py adds interactive prompt.
  • Web: python c2_server.py shows mode selection prompt.

Security fixes

  • Fixed moderate vulnerability: urllib3 pinned to >=2.8.0 (was 2.7.0)
  • requests pinned to >=2.32.0
  • Added .github/dependabot.yml for automated dependency updates
  • Replaced os.system shell calls with shutil.which() in core/exploit_dispatcher.py, families/webos/install.py, families/tizen/install.py

Release assets

Asset Description
SpyTV.apk Android APK (Self-Hosted + Connect-Remote modes)
SpyTV-C2-standalone.exe Windows EXE (bundled Python, Self-Hosted C2)
SHA256SUMS.txt Checksum manifest

Full Changelog

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

Choose a tag to compare

@ProjectZeroDays ProjectZeroDays released this 05 Oct 00:11

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.

SpyTV v1.0.6 - C2 startup crash, silent dashboard buttons

Choose a tag to compare

@ProjectZeroDays ProjectZeroDays released this 04 Oct 22:42

SpyTV v1.0.6

Three of the four things that made the console feel broken came down to code
that failed without saying so. This release fixes the C2's own startup
crash and makes every button report what happened.

Fixed

1. The C2 could exit before it served a single request (critical)

main() prints a startup hint containing ⚙. On Windows, a piped or
redirected
stdout is encoded with the ANSI code page (cp1252), which has no
such character — print() raised UnicodeEncodeError.

Because the order in main() is bind the port → print the banner → print the
hint → serve_forever(),
the exception fired after the port was bound but
before the server started serving
. The process died during startup: no
dashboard, no API, and the only visible effect was a terminal window that
opened with a traceback — which is what "the C2 keeps restarting, opening a
terminal window again" looks like from the operator's side.

An interactive console doesn't hit this (Python writes through the Unicode
console API), so it only surfaced whenever output was captured or redirected.

Fix: _harden_console_output() runs first in main() and reconfigures
sys.stdout / sys.stderr with errors="replace". Each stream keeps its own
encoding — interactive consoles still render ⚙ correctly — and substitutes a
placeholder for anything it cannot represent. No print() can be fatal again.

2. The Scan button failed without a message

startScan() had no error handling. Any failure — non-200, non-JSON body,
missing results, network error — died as an unhandled rejection, so the
results panel stayed exactly as it was and the log stopped at
"Starting network scan...". A dead button.

It now disables and relabels the button (Scanning...), writes a progress
note into the results panel itself, validates that results is a list, and
on failure shows a warning panel naming the reason while keeping the previous
rows. The button is restored in finally, so every path re-enables it.

Evidence from your own C2's history: 8 scans between 13:06 and 13:13, one
every 40–80s, each returning the same single host — with no spinner, no
disabled button and no in-panel feedback, a minute of silence looks like
nothing happened.

3. Client-details submit could hang with the button stuck

api() was await fetch(...); return r.json() — no timeout, no status
check, no non-JSON handling
. fetch has no default timeout, so a C2 that
never answered left the promise pending forever: submitClientForm()'s
finally never ran, the Continue button stayed disabled and the modal never
closed.

api() now arms an AbortController per call — 30s by default, 600s
for /api/cve/patch (it stacks a 45s-per-command cap across several commands)
and 300s for scan / exploit / patch / cve — tolerates empty and non-JSON
replies, and rethrows timeouts and network failures as messages you can act on
("check that the C2 is running"). The
submit button also shows Submitting... while in flight.

4. Error bodies no longer blank the CVE tab or the device grid

renderCVEs() tested !cves || cves.length === 0. A failed /api/cves reply
is an object, so length was undefined, the empty-check passed, and
cves.map threw — the tab went blank with no message. The loaders
(loadDevices / loadClips / loadLocalIp) now only adopt a well-formed
reply, so a 500 or timeout can no longer overwrite the last good state.

5. The Exploit Payload Generator's Generate button did nothing

generatePayload() was the only generate path with no error handling: no
try/catch, no res.error check, no button state. It validated the form with
alert() — a suppressed, backgrounded or missed dialog leaves no trace, so an
empty Target IP looked exactly like a broken button. It also never wrote to the
card's own output container, so every failure was invisible.

The server was healthy the whole time — POST /api/payload measured on your
live C2 returned 200 in 0.07s for tizen-dev, webos-dev and dlna, the
route exists, and every element id the function touches is present — which is
what pointed at the client.

Now: inline validation on the card (never alert()), a Generating...
disabled button, a surfaced res.error, a reply without a payload reported
instead of opening a preview full of undefined, and the preview title reset
(it is shared with the AI generator, which retitles it to the CVE id).
deployPayload() got the same treatment.

Verification

Check Result
Full pytest suite 107 passed (+1 startup crash, +2 dashboard)
Startup crash test fails with the fix reverted: UnicodeEncodeError … '\u2699'
Dashboard script parse node --check clean (before and after)
Dashboard behaviour (Node, stub DOM) 67 assertions, 0 failures (13 sections)
Live scan against a new-code C2 200 in 38.1s, found=2, results is a list
Live authorize (client details) 200 in 1.02s, PDF preview 0.02s
Live authorize while a scan runs 200 in 0.43s — did not block

The dashboard suite (tests/test_dashboard_ui.py) executes the whole inline
script in Node against a stub DOM: happy path, server rejection, unreachable
C2, malformed reply, the renderScanResults(undefined) guard, both
client-details outcomes, the bounded hang, the per-endpoint budgets and the new
list guards. It skips when node is absent (node is already required for the
Electron build).

Upgrading

A running C2 executes the code it was started with — restart it to pick up
this release:

python c2_server.py

No Android or desktop-shell source changed, so the app you already have
loads the fixed dashboard from the restarted C2.