Files
Mark-VIII/tests/test_portals.py
T
c4ch3c4d3 88d7141d45 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
2026-08-23 19:50:41 -06:00

254 lines
10 KiB
Python

import base64
import io
import json
import os
import shutil
import sys
import tempfile
import unittest
import zipfile
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'payload', 'user', 'remote_access', 'pager-webui'))
import server
def setUpModule():
__import__('importlib').reload(server)
def ctx(body=None, args=(), query=None):
return type('C', (), {'body': body or {}, 'args': args,
'query': query or {}})()
def make_zip(files, top_dir=None):
buf = io.BytesIO()
with zipfile.ZipFile(buf, 'w') as zf:
for name, data in files.items():
zf.writestr((top_dir + '/' if top_dir else '') + name, data)
return buf.getvalue()
INDEX_PHP = (b"<?php\n$destination = 'x';\nrequire_once('helper.php');\n?>\n"
b'<html><form method="post" action="/captiveportal/index.php">'
b'<input type="hidden" name="hostname" value="<?=getClientHostName($_SERVER[\'REMOTE_ADDR\']);?>">'
b'<input type="hidden" name="mac" value="<?=getClientMac($_SERVER[\'REMOTE_ADDR\']);?>">'
b'<input type="hidden" name="ip" value="<?=$_SERVER[\'REMOTE_ADDR\'];?>">'
b'<input type="hidden" name="target" value="<?=$destination?>">'
b'<input name="email"></form></html>')
META_EP = json.dumps({'name': 'facebook-login', 'type': 'basic'}).encode()
class PortalsTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.mkdtemp(prefix='mk8-portals-test-')
self.old = (server.PORTALS_DIR, server.PORTAL_ACTIVE_FILE,
server.PORTAL_CAPTURES_FILE)
server.PORTALS_DIR = self.tmp
server.PORTAL_ACTIVE_FILE = os.path.join(self.tmp, '.active')
server.PORTAL_CAPTURES_FILE = os.path.join(self.tmp, 'captures.jsonl')
server._portal_set_active(None)
self.hijacks = []
server._portal_dns_hijack = (
lambda enable: self.hijacks.append(enable))
def tearDown(self):
server.PORTALS_DIR, server.PORTAL_ACTIVE_FILE, \
server.PORTAL_CAPTURES_FILE = self.old
shutil.rmtree(self.tmp, ignore_errors=True)
# ---- import ----
def test_import_flat_zip(self):
status, payload = server.h_portals_import(ctx({
'name': 'my-portal',
'data': base64.b64encode(make_zip({
'index.php': INDEX_PHP, 'assets/style.css': b'body{}'})).decode()}))
self.assertEqual(status, 200)
self.assertEqual(payload['name'], 'my-portal')
root = os.path.join(self.tmp, 'my-portal')
self.assertTrue(os.path.isfile(os.path.join(root, 'index.php')))
self.assertTrue(os.path.isfile(os.path.join(root, 'assets', 'style.css')))
def test_import_nested_top_dir_flattens_and_uses_ep_name(self):
status, payload = server.h_portals_import(ctx({
'data': base64.b64encode(make_zip({
'index.php': INDEX_PHP, 'MyPortal.php': b'<?php ?>',
'facebook-login.ep': META_EP},
top_dir='facebook-login')).decode()}))
self.assertEqual(status, 200)
self.assertEqual(payload['name'], 'facebook-login')
root = os.path.join(self.tmp, 'facebook-login')
self.assertTrue(os.path.isfile(os.path.join(root, 'index.php')))
self.assertFalse(os.path.isdir(os.path.join(root, 'facebook-login')))
def test_import_rejects_missing_index_php(self):
status, payload = server.h_portals_import(ctx({
'name': 'bad', 'data': base64.b64encode(make_zip(
{'only.css': b'body{}'})).decode()}))
self.assertEqual(status, 400)
def test_import_rejects_zip_slip(self):
evil = make_zip({'index.php': INDEX_PHP})
# Hand-build a zip with an unsafe entry.
buf = io.BytesIO()
with zipfile.ZipFile(buf, 'w') as zf:
zf.writestr('index.php', INDEX_PHP)
zf.writestr('../../evil.sh', b'rm -rf /')
status, _ = server.h_portals_import(ctx({
'name': 'evil', 'data': base64.b64encode(buf.getvalue()).decode()}))
self.assertEqual(status, 400)
self.assertFalse(os.path.exists('/tmp/evil.sh'))
self.assertFalse(os.path.exists(evil and '/etc/passwd.mk8test'))
def test_import_rejects_garbage(self):
status, _ = server.h_portals_import(ctx({
'name': 'junk', 'data': base64.b64encode(b'not a zip').decode()}))
self.assertEqual(status, 400)
status, _ = server.h_portals_import(ctx({}))
self.assertEqual(status, 400)
def test_import_overwrites_same_name(self):
data = base64.b64encode(make_zip({
'index.php': INDEX_PHP})).decode()
s1, _ = server.h_portals_import(ctx({'name': 'dup', 'data': data}))
data2 = base64.b64encode(make_zip({
'index.php': INDEX_PHP, 'extra.txt': b'x'})).decode()
s2, _ = server.h_portals_import(ctx({'name': 'dup', 'data': data2}))
self.assertEqual((s1, s2), (200, 200))
self.assertTrue(os.path.isfile(
os.path.join(self.tmp, 'dup', 'extra.txt')))
# ---- list / activate / delete ----
def _import_one(self, name='p1'):
status, payload = server.h_portals_import(ctx({
'name': name, 'data': base64.b64encode(make_zip(
{'index.php': INDEX_PHP})).decode()}))
assert status == 200, payload
return name
def test_list_reports_portals_and_active(self):
self._import_one('alpha')
status, payload = server.h_portals_list(ctx())
self.assertEqual(status, 200)
names = [p['name'] for p in payload['portals']]
self.assertIn('alpha', names)
self.assertIsNone(payload['active'])
def test_activate_starts_dns_hijack_and_persists(self):
name = self._import_one()
status, payload = server.h_portals_activate(ctx(args=(name,)))
self.assertEqual(status, 200)
self.assertEqual(self.hijacks, [True])
with open(server.PORTAL_ACTIVE_FILE) as f:
self.assertEqual(f.read().strip(), name)
status, payload = server.h_portals_list(ctx())
self.assertEqual(payload['active'], name)
def test_activate_unknown_portal_404(self):
status, _ = server.h_portals_activate(ctx(args=('ghost',)))
self.assertEqual(status, 404)
def test_deactivate_stops_hijack(self):
name = self._import_one()
server.h_portals_activate(ctx(args=(name,)))
status, payload = server.h_portals_deactivate(ctx())
self.assertEqual(status, 200)
self.assertEqual(self.hijacks, [True, False])
_, payload = server.h_portals_list(ctx())
self.assertIsNone(payload['active'])
def test_delete_active_portal_deactivates_first(self):
name = self._import_one()
server.h_portals_activate(ctx(args=(name,)))
status, _ = server.h_portals_delete(ctx(args=(name,)))
self.assertEqual(status, 200)
self.assertFalse(os.path.exists(os.path.join(self.tmp, name)))
self.assertEqual(self.hijacks, [True, False])
def test_restore_on_boot_reapplies_hijack(self):
name = self._import_one()
with open(server.PORTAL_ACTIVE_FILE, 'w') as f:
f.write(name + '\n')
server._portal_restore_on_boot()
self.assertEqual(self.hijacks, [True])
self.assertEqual(server._portal_active['name'], name)
# ---- php shim ----
def test_php_shim_substitutes_client_values(self):
old_leases = server._dhcp_leases
server._dhcp_leases = lambda: {'10.0.0.5': ('AA:BB:CC:DD:EE:FF', 'victim-pc')}
try:
out = server._php_shim(INDEX_PHP.decode(), '10.0.0.5',
'http://login.example.com/')
finally:
server._dhcp_leases = old_leases
self.assertNotIn('<?php', out)
self.assertNotIn('<%=', out)
self.assertNotIn('<?=', out)
self.assertIn('value="victim-pc"', out)
self.assertIn('value="AA:BB:CC:DD:EE:FF"', out)
self.assertIn('value="10.0.0.5"', out)
self.assertIn('value="http://login.example.com/"', out)
def test_php_shim_escapes_quotes_in_lease_values(self):
old_leases = server._dhcp_leases
server._dhcp_leases = lambda: {'10.0.0.5': ('AA:BB:CC:DD:EE:FF',
'vic"tim')}
try:
out = server._php_shim(INDEX_PHP.decode(), '10.0.0.5', 'http://x/')
finally:
server._dhcp_leases = old_leases
self.assertIn('value="vic&quot;tim"', out)
# ---- capture ----
def test_capture_writes_logs_and_jsonl(self):
name = self._import_one('credtrap')
server._portal_capture(b'email=a@b.c&password=hunter2&submit=Log+In',
'10.0.0.9', name)
logs_path = os.path.join(self.tmp, 'credtrap', '.logs')
with open(logs_path) as f:
text = f.read()
self.assertIn('email: a@b.c', text)
self.assertIn('password: hunter2', text)
self.assertIn('[', text)
with open(server.PORTAL_CAPTURES_FILE) as f:
entries = [json.loads(line) for line in f if line.strip()]
self.assertEqual(len(entries), 1)
self.assertEqual(entries[0]['fields']['password'], 'hunter2')
self.assertEqual(entries[0]['ip'], '10.0.0.9')
self.assertEqual(entries[0]['portal'], 'credtrap')
def test_captures_endpoint_lists_newest_first_and_clears(self):
name = self._import_one()
server._portal_capture(b'a=1', '10.0.0.1', name)
server._portal_capture(b'a=2', '10.0.0.2', name)
status, payload = server.h_portals_captures(ctx(query={'limit': 200}))
self.assertEqual(status, 200)
self.assertEqual(payload['total'], 2)
self.assertEqual(payload['captures'][0]['fields']['a'], '2')
status, _ = server.h_portals_captures_clear(ctx())
self.assertEqual(status, 200)
_, payload = server.h_portals_captures(ctx(query={}))
self.assertEqual(payload['total'], 0)
def test_logs_download_returns_file(self):
name = self._import_one()
server._portal_capture(b'a=1', '10.0.0.1', name)
status, payload = server.h_portal_logs(ctx(args=(name,)))
self.assertEqual(status, 200)
self.assertEqual(payload.filename, '%s.logs.txt' % name)
self.assertIn(b'a: 1', payload.data)
def test_logs_download_404_when_empty(self):
name = self._import_one()
status, _ = server.h_portal_logs(ctx(args=(name,)))
self.assertEqual(status, 404)
if __name__ == '__main__':
unittest.main()