fix(portals,capture): round-2 validation fixes, live-verified on Pager

- portals: replace zipfile with struct+zlib ZIP writer in portal download
  (python3-light has no zipfile; GET /api/portals/<name>/download 500ed)
- capture: revive watchdog re-arms the 5 GHz deploy auto-capture if the
  post-deploy radio settle kills it (was: empty pcap, dead tcpdump)
- capture: route GET /api/attacks/capture to status (was unrouted -> 404)

New tests/test_validation_fixes2.py covers each fix (TDD); full suite
(30 modules) green. Live-verified: download CRC-clean via stock zipfile,
capture survived settle window and revived automatically (56 MB pcap),
GET status returns proper JSON.

Round-2 validation report added at docs/validation/ (8/9 attack types
PASS against in-scope networks; enterprise PARTIAL per firmware limits).
This commit is contained in:
c4ch3c4d3
2026-08-24 08:23:26 -06:00
parent d23ea56364
commit 0f31bfe885
3 changed files with 370 additions and 13 deletions
@@ -3812,6 +3812,8 @@ def _deploy_wpa_open(kind, fields):
# loot the 4-ways from a pinned monitor capture instead.
try:
capture = _ensure_attack_capture('wlan1mon')
if capture:
_schedule_capture_revive('wlan1mon')
except Exception:
capture = None
return {'kind': kind, 'ssid': ssid, 'iface': iface, 'band': band,
@@ -4673,6 +4675,47 @@ def _teardown_attack_capture(iface):
return running
CAPTURE_REVIVE_CHECKS = 6
CAPTURE_REVIVE_INTERVAL = 10.0
def _capture_revive_once(iface):
"""Re-arm the auto-capture if it died (e.g. radios dropped during the
post-deploy settle window). No-op while a live capture runs or when the
monitor iface itself is gone."""
pidfile = '/tmp/mk8_capture_%s.pid' % iface
running, _, _ = _capture_state(pidfile, iface)
if running:
return None
if not os.path.exists('/sys/class/net/%s' % iface):
return None
return _ensure_attack_capture(iface)
def _schedule_capture_revive(iface='wlan1mon',
checks=CAPTURE_REVIVE_CHECKS,
interval=CAPTURE_REVIVE_INTERVAL):
"""Watch a freshly armed capture through the deploy settle window.
The 5 GHz attack AP bring-up can recreate monitors / reload wifi, which
kills the just-started tcpdump; poll a few times and restart it once the
radios have converged. Runs in a daemon thread, self-terminates."""
def _revive():
for _ in range(checks):
time.sleep(interval)
try:
result = _capture_revive_once(iface)
except Exception:
result = None
if result is not None or _capture_state(
'/tmp/mk8_capture_%s.pid' % iface, iface)[0]:
# Restarted, or the original capture is alive again.
return
thread = threading.Thread(target=_revive, daemon=True)
thread.start()
return thread
def h_attacks_capture(ctx):
body = ctx.body or {}
action = body.get('action') or 'status'
@@ -4897,6 +4940,41 @@ def _portal_list():
_EOCD_SIG = b'PK\x05\x06'
_CDH_SIG = b'PK\x01\x02'
_LFH_SIG = b'PK\x03\x04'
def _zip_create(files):
"""Minimal ZIP writer built on struct+zlib only.
python3-light has no zipfile, so portal download serializes stored
(uncompressed) entries by hand: local file headers, central directory,
end-of-central-directory. Returns archive bytes; names must be plain
relative paths."""
import struct
try:
import zlib
except ImportError:
zlib = None
out = bytearray()
central = bytearray()
for name in sorted(files):
data = files[name]
nbuf = name.encode('utf-8')
crc = zlib.crc32(data) & 0xFFFFFFFF if zlib is not None else 0
offset = len(out)
out += struct.pack('<4sHHHHHIIIHH', _LFH_SIG, 20, 0, 0, 0, 0,
crc, len(data), len(data), len(nbuf), 0)
out += nbuf
out += data
central += struct.pack('<4sHHHHHHIIIHHHHHII', _CDH_SIG, 20, 20, 0, 0,
0, 0, crc, len(data), len(data), len(nbuf),
0, 0, 0, 0, 0, offset)
central += nbuf
cd_off = len(out)
out += central
out += struct.pack('<4sHHHHIIH', _EOCD_SIG, 0, 0, len(files),
len(files), len(central), cd_off, 0)
return bytes(out)
def _zip_entries(data_bytes):
@@ -5393,23 +5471,23 @@ def h_portal_logs(ctx):
def h_portal_download(ctx):
import zipfile
import io as _io
name = ctx.args[0] if ctx.args else ''
root = _portal_root(name)
if not root or not os.path.isdir(root):
return 404, {'error': 'portal not found'}
buf = _io.BytesIO()
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf:
for dirpath, _, files in os.walk(root):
for fn in files:
full = os.path.join(dirpath, fn)
rel = os.path.relpath(full, root)
try:
zf.write(full, os.path.join(name, rel))
except OSError:
continue
return 200, Download(buf.getvalue(), 'application/zip',
files = {}
for dirpath, _, fnames in os.walk(root):
for fn in fnames:
full = os.path.join(dirpath, fn)
rel = os.path.relpath(full, root)
try:
with open(full, 'rb') as f:
files['%s/%s' % (name, rel.replace(os.sep, '/'))] = f.read()
except OSError:
continue
if not files:
return 404, {'error': 'portal is empty'}
return 200, Download(_zip_create(files), 'application/zip',
'%s.zip' % name)
@@ -7909,6 +7987,7 @@ ROUTER.add('POST', r'/api/attacks/deploy', h_attacks_deploy)
ROUTER.add('POST', r'/api/attacks/stop', h_attacks_stop)
ROUTER.add('GET', r'/api/attacks/status', h_attacks_status)
ROUTER.add('POST', r'/api/attacks/capture', h_attacks_capture)
ROUTER.add('GET', r'/api/attacks/capture', h_attacks_capture)
ROUTER.add('GET', r'/api/attacks/export/hc22000', h_attacks_export_hc22000)
ROUTER.add('GET', r'/api/attacks/export/hc22000/([^/]+)', h_attacks_download_hc22000)
ROUTER.add('POST', r'/api/attacks/deauth', h_attacks_deauth)