Add Phase 1 utility modules: crypto, networking, logging, stealth, resource, permissions, config_loader, bettercap_api

9 files in utils/ providing the shared infrastructure layer:
- crypto: AES-256-GCM file encryption, argon2id/PBKDF2 key derivation, HKDF network unlock, LUKS container management
- networking: interface detection, MAC/IP helpers, BPF compilation via libpcap ctypes, gratuitous ARP, VLAN creation
- logging: encrypted log writer (BBLogger) with rotation, per-module files, stdout suppression for stealth
- stealth: process rename via prctl, cmdline spoofing, sysctl helpers, timestomping, core dump disable
- resource: hardware tier detection (OPi Zero 3 / Pi Zero / generic) via /proc/device-tree and cpuinfo, resource monitoring
- permissions: root/capability checks via capget ctypes, privilege dropping, directory permission enforcement
- config_loader: YAML merge hierarchy (hardware_tiers -> bigbrother -> modules -> stealth -> CLI), tier auto-detection, SIGHUP reload
- bettercap_api: REST client with per-session random credentials, session/events/command methods
This commit is contained in:
n0mad1k
2026-03-18 08:11:25 -04:00
parent 63e317176d
commit 62a1010ab4
35 changed files with 2145 additions and 0 deletions
+16
View File
@@ -0,0 +1,16 @@
from utils.crypto import encrypt_file, decrypt_file, derive_key, CryptoEngine
from utils.networking import (
get_primary_interface, get_bridge_candidates, get_wifi_interfaces,
get_mac, set_mac, get_ip, compile_bpf, send_gratuitous_arp,
interface_up, interface_down, create_vlan_interface,
)
from utils.logging import BBLogger
from utils.stealth import (
rename_process, spoof_cmdline, set_sysctl, timestomp, disable_core_dumps,
)
from utils.resource import HardwareInfo, get_hardware_tier, get_resource_usage
from utils.permissions import (
check_root, check_capability, drop_privileges, enforce_directory_permissions,
)
from utils.config_loader import load_config, ConfigLoader
from utils.bettercap_api import BettercapAPI
+98
View File
@@ -0,0 +1,98 @@
#!/usr/bin/env python3
"""REST client for bettercap API."""
import secrets
import string
from typing import Any, Dict, List, Optional
import requests
from requests.auth import HTTPBasicAuth
class BettercapAPI:
def __init__(
self,
host: str = "127.0.0.1",
port: int = 8083,
user: str = "bb",
password: Optional[str] = None,
timeout: int = 10,
):
self.host = host
self.port = port
self.user = user
self.password = password or self._generate_password()
self.timeout = timeout
self._base_url = f"http://{host}:{port}/api"
self._auth = HTTPBasicAuth(self.user, self.password)
self._session = requests.Session()
self._session.auth = self._auth
self._session.headers.update({"Content-Type": "application/json"})
@staticmethod
def _generate_password(length: int = 32) -> str:
alphabet = string.ascii_letters + string.digits
return "".join(secrets.choice(alphabet) for _ in range(length))
def _request(self, method: str, endpoint: str, **kwargs) -> requests.Response:
url = f"{self._base_url}/{endpoint.lstrip('/')}"
kwargs.setdefault("timeout", self.timeout)
try:
resp = self._session.request(method, url, **kwargs)
resp.raise_for_status()
return resp
except requests.exceptions.ConnectionError:
raise ConnectionError(f"Cannot connect to bettercap at {self.host}:{self.port}")
except requests.exceptions.Timeout:
raise TimeoutError(f"Request to bettercap timed out after {self.timeout}s")
def run(self, command: str) -> Dict[str, Any]:
resp = self._request("POST", "session", json={"cmd": command})
return resp.json()
def get_session(self) -> Dict[str, Any]:
resp = self._request("GET", "session")
return resp.json()
def get_events(self, since: Optional[int] = None) -> List[Dict[str, Any]]:
params = {}
if since is not None:
params["n"] = since
resp = self._request("GET", "events", params=params)
return resp.json()
def is_alive(self) -> bool:
try:
self.get_session()
return True
except (ConnectionError, TimeoutError, requests.exceptions.RequestException):
return False
def start_module(self, module: str) -> Dict[str, Any]:
return self.run(f"{module} on")
def stop_module(self, module: str) -> Dict[str, Any]:
return self.run(f"{module} off")
def set_parameter(self, param: str, value: str) -> Dict[str, Any]:
return self.run(f"set {param} {value}")
def get_hosts(self) -> List[Dict[str, Any]]:
session = self.get_session()
return session.get("lan", {}).get("hosts", [])
def load_caplet(self, caplet_path: str) -> Dict[str, Any]:
return self.run(f"caplet.load {caplet_path}")
@property
def credentials(self) -> Dict[str, str]:
return {"user": self.user, "password": self.password}
@property
def api_flags(self) -> List[str]:
return [
"-api-rest-address", self.host,
"-api-rest-port", str(self.port),
"-api-rest-username", self.user,
"-api-rest-password", self.password,
]
+179
View File
@@ -0,0 +1,179 @@
#!/usr/bin/env python3
"""YAML config loader with merge hierarchy, hardware tier detection, and SIGHUP reload."""
import copy
import os
import signal
import threading
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional
import yaml
from utils.resource import get_hardware_tier
CONFIG_DIR = "config"
MERGE_ORDER = [
"hardware_tiers.yaml",
"bigbrother.yaml",
"modules.yaml",
"stealth.yaml",
]
def deep_merge(base: dict, override: dict) -> dict:
result = copy.deepcopy(base)
for key, value in override.items():
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
result[key] = deep_merge(result[key], value)
else:
result[key] = copy.deepcopy(value)
return result
class ConfigLoader:
def __init__(
self,
config_dir: str = CONFIG_DIR,
cli_overrides: Optional[Dict[str, Any]] = None,
auto_detect_tier: bool = True,
):
self.config_dir = Path(config_dir)
self.cli_overrides = cli_overrides or {}
self.auto_detect_tier = auto_detect_tier
self._config: Dict[str, Any] = {}
self._lock = threading.Lock()
self._reload_callbacks: List[Callable] = []
self._loaded = False
def load(self) -> Dict[str, Any]:
with self._lock:
self._config = self._build_config()
self._loaded = True
return copy.deepcopy(self._config)
def _build_config(self) -> Dict[str, Any]:
merged: Dict[str, Any] = {}
for filename in MERGE_ORDER:
filepath = self.config_dir / filename
if filepath.exists():
try:
with open(filepath, "r") as f:
data = yaml.safe_load(f) or {}
merged = deep_merge(merged, data)
except (yaml.YAMLError, IOError):
continue
# Apply hardware tier defaults
if self.auto_detect_tier:
merged = self._apply_tier_limits(merged)
# Apply CLI overrides last (highest priority)
if self.cli_overrides:
merged = deep_merge(merged, self.cli_overrides)
# Validate
self._validate(merged)
return merged
def _apply_tier_limits(self, config: dict) -> dict:
tier = config.get("device", {}).get("platform", "auto")
if tier == "auto":
tier = get_hardware_tier()
config.setdefault("device", {})["platform"] = tier
# Extract tier-specific limits from hardware_tiers section
tiers = config.pop("hardware_tiers", {})
tier_config = tiers.get(tier, tiers.get("generic", {}))
if tier_config:
config = deep_merge(config, tier_config)
# Apply well-known tier constraints
if tier == "pi_zero":
config.setdefault("capture", {})["pcap_compression_level"] = 3
config.setdefault("security", {})["encryption_key_derive"] = "pbkdf2"
# Limit passive modules
limits = config.setdefault("limits", {})
limits.setdefault("max_passive_modules", 4)
limits.setdefault("max_active_modules", 0)
elif tier == "opi_zero3":
config.setdefault("capture", {})["pcap_compression_level"] = 19
config.setdefault("security", {})["encryption_key_derive"] = "argon2id"
limits = config.setdefault("limits", {})
limits.setdefault("max_passive_modules", 16)
limits.setdefault("max_active_modules", 6)
else:
config.setdefault("capture", {})["pcap_compression_level"] = 19
config.setdefault("security", {})["encryption_key_derive"] = "argon2id"
limits = config.setdefault("limits", {})
limits.setdefault("max_passive_modules", 16)
limits.setdefault("max_active_modules", -1) # unlimited
return config
def _validate(self, config: dict) -> None:
device = config.get("device", {})
platform = device.get("platform", "")
if platform and platform not in ("auto", "opi_zero3", "pi_zero", "generic"):
raise ValueError(f"Invalid platform: {platform}")
capture = config.get("capture", {})
comp_level = capture.get("pcap_compression_level")
if comp_level is not None and not (1 <= comp_level <= 22):
raise ValueError(f"Invalid compression level: {comp_level}")
bettercap = config.get("bettercap", {})
port = bettercap.get("api_port")
if port is not None and not (1 <= port <= 65535):
raise ValueError(f"Invalid bettercap API port: {port}")
security = config.get("security", {})
kdf = security.get("encryption_key_derive")
if kdf and kdf not in ("argon2id", "pbkdf2"):
raise ValueError(f"Invalid key derivation method: {kdf}")
def get(self, key_path: str, default: Any = None) -> Any:
with self._lock:
keys = key_path.split(".")
current = self._config
for k in keys:
if isinstance(current, dict) and k in current:
current = current[k]
else:
return default
return copy.deepcopy(current)
def reload(self) -> Dict[str, Any]:
config = self.load()
for cb in self._reload_callbacks:
try:
cb(config)
except Exception:
pass
return config
def on_reload(self, callback: Callable):
self._reload_callbacks.append(callback)
def register_sighup(self):
def _handler(signum, frame):
self.reload()
signal.signal(signal.SIGHUP, _handler)
@property
def config(self) -> Dict[str, Any]:
with self._lock:
if not self._loaded:
return self.load()
return copy.deepcopy(self._config)
def load_config(
config_dir: str = CONFIG_DIR,
cli_overrides: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
loader = ConfigLoader(config_dir=config_dir, cli_overrides=cli_overrides)
return loader.load()
+189
View File
@@ -0,0 +1,189 @@
#!/usr/bin/env python3
"""AES-256-GCM encryption, key derivation, and LUKS container management."""
import os
import struct
import subprocess
import hashlib
from pathlib import Path
from typing import Optional
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from cryptography.hazmat.primitives import hashes
try:
from argon2.low_level import hash_secret_raw, Type
HAS_ARGON2 = True
except ImportError:
HAS_ARGON2 = False
NONCE_SIZE = 12
KEY_SIZE = 32
SALT_SIZE = 16
HEADER_MAGIC = b"BB01"
HEADER_FMT = f"!4s{SALT_SIZE}s{NONCE_SIZE}s"
HEADER_SIZE = struct.calcsize(HEADER_FMT)
def derive_key(password: bytes, salt: bytes, method: str = "argon2id") -> bytes:
if method == "argon2id" and HAS_ARGON2:
return hash_secret_raw(
secret=password,
salt=salt,
time_cost=3,
memory_cost=65536,
parallelism=4,
hash_len=KEY_SIZE,
type=Type.ID,
)
return PBKDF2HMAC(
algorithm=hashes.SHA256(),
length=KEY_SIZE,
salt=salt,
iterations=600_000,
).derive(password)
def derive_network_key(network_secret: bytes, device_serial: bytes) -> bytes:
return HKDF(
algorithm=hashes.SHA256(),
length=KEY_SIZE,
salt=device_serial,
info=b"bigbrother-luks-unlock",
).derive(network_secret)
class CryptoEngine:
def __init__(self, key: bytes):
if len(key) != KEY_SIZE:
raise ValueError(f"Key must be {KEY_SIZE} bytes")
self._aesgcm = AESGCM(key)
self._key = key
def encrypt(self, plaintext: bytes, aad: Optional[bytes] = None) -> bytes:
nonce = os.urandom(NONCE_SIZE)
ct = self._aesgcm.encrypt(nonce, plaintext, aad)
return nonce + ct
def decrypt(self, data: bytes, aad: Optional[bytes] = None) -> bytes:
nonce = data[:NONCE_SIZE]
ct = data[NONCE_SIZE:]
return self._aesgcm.decrypt(nonce, ct, aad)
def encrypt_file(
src: str,
dst: str,
password: bytes,
method: str = "argon2id",
chunk_size: int = 64 * 1024,
) -> None:
salt = os.urandom(SALT_SIZE)
key = derive_key(password, salt, method)
engine = CryptoEngine(key)
with open(src, "rb") as fin:
plaintext = fin.read()
nonce = os.urandom(NONCE_SIZE)
aesgcm = AESGCM(key)
ct = aesgcm.encrypt(nonce, plaintext, None)
with open(dst, "wb") as fout:
fout.write(struct.pack(HEADER_FMT, HEADER_MAGIC, salt, nonce))
fout.write(ct)
def decrypt_file(
src: str,
dst: str,
password: bytes,
method: str = "argon2id",
) -> None:
with open(src, "rb") as fin:
header = fin.read(HEADER_SIZE)
magic, salt, nonce = struct.unpack(HEADER_FMT, header)
if magic != HEADER_MAGIC:
raise ValueError("Invalid encrypted file header")
ct = fin.read()
key = derive_key(password, salt, method)
aesgcm = AESGCM(key)
plaintext = aesgcm.decrypt(nonce, ct, None)
with open(dst, "wb") as fout:
fout.write(plaintext)
class LUKSContainer:
def __init__(self, container_path: str, mount_point: str, mapper_name: str = "bb_storage"):
self.container_path = container_path
self.mount_point = mount_point
self.mapper_name = mapper_name
self.device_path = f"/dev/mapper/{mapper_name}"
def create(self, size_mb: int, passphrase: bytes) -> bool:
try:
subprocess.run(
["dd", "if=/dev/zero", f"of={self.container_path}",
"bs=1M", f"count={size_mb}"],
check=True, capture_output=True,
)
proc = subprocess.run(
["cryptsetup", "luksFormat", "--type", "luks2",
"--cipher", "aes-xts-plain64", "--key-size", "512",
"--hash", "sha256", "--batch-mode", self.container_path],
input=passphrase + b"\n",
check=True, capture_output=True,
)
return True
except subprocess.CalledProcessError:
return False
def open(self, passphrase: bytes) -> bool:
try:
subprocess.run(
["cryptsetup", "open", self.container_path,
self.mapper_name, "--type", "luks2"],
input=passphrase + b"\n",
check=True, capture_output=True,
)
Path(self.mount_point).mkdir(parents=True, exist_ok=True)
subprocess.run(
["mount", self.device_path, self.mount_point],
check=True, capture_output=True,
)
return True
except subprocess.CalledProcessError:
return False
def close(self) -> bool:
try:
subprocess.run(
["umount", self.mount_point],
check=True, capture_output=True,
)
subprocess.run(
["cryptsetup", "close", self.mapper_name],
check=True, capture_output=True,
)
return True
except subprocess.CalledProcessError:
return False
def destroy_header(self) -> bool:
try:
subprocess.run(
["dd", "if=/dev/urandom", f"of={self.container_path}",
"bs=1M", "count=2", "conv=notrunc"],
check=True, capture_output=True,
)
return True
except subprocess.CalledProcessError:
return False
@property
def is_open(self) -> bool:
return Path(self.device_path).exists()
+146
View File
@@ -0,0 +1,146 @@
#!/usr/bin/env python3
"""Encrypted log writer for BigBrother. Named bb_logging to avoid shadowing stdlib logging."""
import logging
import os
import time
from pathlib import Path
from typing import Optional
LOG_FORMAT = "[{timestamp}] [{module}] [{level}] {message}"
MAX_LOG_SIZE = 10 * 1024 * 1024 # 10MB default rotation size
LOG_DIR = "storage/logs"
class EncryptedLogHandler(logging.Handler):
def __init__(
self,
module_name: str,
log_dir: str = LOG_DIR,
max_bytes: int = MAX_LOG_SIZE,
encrypt: bool = False,
crypto_engine=None,
):
super().__init__()
self.module_name = module_name
self.log_dir = Path(log_dir)
self.log_dir.mkdir(parents=True, exist_ok=True)
self.max_bytes = max_bytes
self.encrypt = encrypt
self.crypto_engine = crypto_engine
self._log_path = self.log_dir / f"{module_name}.log"
self._file = None
self._current_size = 0
self._open_file()
def _open_file(self):
if self._file:
self._file.close()
mode = "ab" if self.encrypt else "a"
self._file = open(self._log_path, mode)
self._current_size = self._log_path.stat().st_size if self._log_path.exists() else 0
def _rotate(self):
if self._file:
self._file.close()
rotated = self._log_path.with_suffix(f".log.{int(time.time())}")
if self._log_path.exists():
self._log_path.rename(rotated)
self._open_file()
def emit(self, record):
try:
ts = time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime(record.created))
line = LOG_FORMAT.format(
timestamp=ts,
module=self.module_name,
level=record.levelname,
message=record.getMessage(),
)
if self.encrypt and self.crypto_engine:
data = self.crypto_engine.encrypt(line.encode("utf-8"))
length_prefix = len(data).to_bytes(4, "big")
self._file.write(length_prefix)
self._file.write(data)
self._current_size += 4 + len(data)
else:
line += "\n"
self._file.write(line)
self._current_size += len(line.encode("utf-8"))
self._file.flush()
if self._current_size >= self.max_bytes:
self._rotate()
except Exception:
self.handleError(record)
def close(self):
if self._file:
self._file.close()
self._file = None
super().close()
class BBLogger:
def __init__(
self,
module_name: str,
log_dir: str = LOG_DIR,
max_bytes: int = MAX_LOG_SIZE,
level: int = logging.INFO,
encrypt: bool = False,
crypto_engine=None,
suppress_stdout: bool = True,
):
self.module_name = module_name
self.logger = logging.getLogger(f"bb.{module_name}")
self.logger.setLevel(level)
self.logger.propagate = False
# Clear existing handlers to prevent duplicates
self.logger.handlers.clear()
self._file_handler = EncryptedLogHandler(
module_name=module_name,
log_dir=log_dir,
max_bytes=max_bytes,
encrypt=encrypt,
crypto_engine=crypto_engine,
)
self._file_handler.setLevel(level)
self.logger.addHandler(self._file_handler)
if not suppress_stdout:
console = logging.StreamHandler()
console.setLevel(level)
fmt = logging.Formatter(
"[%(asctime)s] [%(name)s] [%(levelname)s] %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
console.setFormatter(fmt)
self.logger.addHandler(console)
def debug(self, msg: str, *args, **kwargs):
self.logger.debug(msg, *args, **kwargs)
def info(self, msg: str, *args, **kwargs):
self.logger.info(msg, *args, **kwargs)
def warning(self, msg: str, *args, **kwargs):
self.logger.warning(msg, *args, **kwargs)
def error(self, msg: str, *args, **kwargs):
self.logger.error(msg, *args, **kwargs)
def critical(self, msg: str, *args, **kwargs):
self.logger.critical(msg, *args, **kwargs)
def set_level(self, level: int):
self.logger.setLevel(level)
self._file_handler.setLevel(level)
def close(self):
self._file_handler.close()
self.logger.removeHandler(self._file_handler)
+250
View File
@@ -0,0 +1,250 @@
#!/usr/bin/env python3
"""Network interface helpers, BPF compilation, gratuitous ARP. No scapy dependency."""
import ctypes
import ctypes.util
import fcntl
import os
import socket
import struct
import subprocess
from pathlib import Path
from typing import List, Optional, Tuple
SIOCGIFADDR = 0x8915
SIOCGIFHWADDR = 0x8927
SIOCSIFHWADDR = 0x8924
SIOCGIFFLAGS = 0x8913
SIOCSIFFLAGS = 0x8914
IFF_UP = 0x1
IFF_RUNNING = 0x40
ARPHRD_ETHER = 1
_libpcap = None
def _get_libpcap():
global _libpcap
if _libpcap is None:
lib = ctypes.util.find_library("pcap")
if not lib:
raise OSError("libpcap not found")
_libpcap = ctypes.CDLL(lib)
return _libpcap
def _ifreq(ifname: str) -> bytes:
return struct.pack("256s", ifname.encode("utf-8")[:15])
def get_all_interfaces() -> List[str]:
interfaces = []
net_path = Path("/sys/class/net")
if net_path.exists():
for entry in net_path.iterdir():
name = entry.name
if name != "lo":
interfaces.append(name)
return sorted(interfaces)
def get_primary_interface() -> Optional[str]:
try:
with open("/proc/net/route", "r") as f:
for line in f.readlines()[1:]:
fields = line.strip().split()
if fields[1] == "00000000":
return fields[0]
except (IOError, IndexError):
pass
interfaces = get_all_interfaces()
for iface in interfaces:
if iface.startswith(("eth", "en")):
return iface
return interfaces[0] if interfaces else None
def get_bridge_candidates() -> List[str]:
candidates = []
for iface in get_all_interfaces():
if iface.startswith(("eth", "en", "usb")):
candidates.append(iface)
return candidates
def get_wifi_interfaces() -> List[str]:
interfaces = []
for iface in get_all_interfaces():
phy_path = Path(f"/sys/class/net/{iface}/phy80211")
wireless_path = Path(f"/sys/class/net/{iface}/wireless")
if phy_path.exists() or wireless_path.exists() or iface.startswith("wlan"):
interfaces.append(iface)
return interfaces
def get_mac(interface: str) -> str:
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
info = fcntl.ioctl(sock.fileno(), SIOCGIFHWADDR, _ifreq(interface))
mac_bytes = info[18:24]
return ":".join(f"{b:02x}" for b in mac_bytes)
finally:
sock.close()
def set_mac(interface: str, mac: str) -> None:
was_up = is_interface_up(interface)
if was_up:
interface_down(interface)
mac_bytes = bytes(int(b, 16) for b in mac.split(":"))
ifreq = struct.pack("16sH6s8s", interface.encode()[:15], ARPHRD_ETHER, mac_bytes, b"\x00" * 8)
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
fcntl.ioctl(sock.fileno(), SIOCSIFHWADDR, ifreq)
finally:
sock.close()
if was_up:
interface_up(interface)
def get_ip(interface: str) -> Optional[str]:
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
info = fcntl.ioctl(sock.fileno(), SIOCGIFADDR, _ifreq(interface))
return socket.inet_ntoa(info[20:24])
except OSError:
return None
finally:
sock.close()
def is_interface_up(interface: str) -> bool:
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
result = fcntl.ioctl(sock.fileno(), SIOCGIFFLAGS, _ifreq(interface))
flags = struct.unpack("16sH", result[:18])[1]
return bool(flags & IFF_UP)
except OSError:
return False
finally:
sock.close()
def interface_up(interface: str) -> None:
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
result = fcntl.ioctl(sock.fileno(), SIOCGIFFLAGS, _ifreq(interface))
flags = struct.unpack("16sH", result[:18])[1]
flags |= IFF_UP | IFF_RUNNING
ifreq = struct.pack("16sH", interface.encode()[:15], flags)
fcntl.ioctl(sock.fileno(), SIOCSIFFLAGS, ifreq)
finally:
sock.close()
def interface_down(interface: str) -> None:
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
result = fcntl.ioctl(sock.fileno(), SIOCGIFFLAGS, _ifreq(interface))
flags = struct.unpack("16sH", result[:18])[1]
flags &= ~IFF_UP
ifreq = struct.pack("16sH", interface.encode()[:15], flags)
fcntl.ioctl(sock.fileno(), SIOCSIFFLAGS, ifreq)
finally:
sock.close()
def create_vlan_interface(parent: str, vlan_id: int) -> str:
vlan_iface = f"{parent}.{vlan_id}"
subprocess.run(
["ip", "link", "add", "link", parent, "name", vlan_iface,
"type", "vlan", "id", str(vlan_id)],
check=True, capture_output=True,
)
interface_up(vlan_iface)
return vlan_iface
def compile_bpf(expression: str, snaplen: int = 65535, linktype: int = 1) -> bytes:
pcap = _get_libpcap()
class bpf_insn(ctypes.Structure):
_fields_ = [
("code", ctypes.c_ushort),
("jt", ctypes.c_ubyte),
("jf", ctypes.c_ubyte),
("k", ctypes.c_uint32),
]
class bpf_program(ctypes.Structure):
_fields_ = [
("bf_len", ctypes.c_uint),
("bf_insns", ctypes.POINTER(bpf_insn)),
]
handle = pcap.pcap_open_dead(ctypes.c_int(linktype), ctypes.c_int(snaplen))
if not handle:
raise RuntimeError("pcap_open_dead failed")
prog = bpf_program()
expr_bytes = expression.encode("utf-8")
ret = pcap.pcap_compile(
handle,
ctypes.byref(prog),
ctypes.c_char_p(expr_bytes),
ctypes.c_int(1),
ctypes.c_uint32(0xFFFFFFFF),
)
if ret != 0:
err = pcap.pcap_geterr(handle)
pcap.pcap_close(handle)
if err:
raise ValueError(f"BPF compile failed: {ctypes.string_at(err).decode()}")
raise ValueError("BPF compile failed")
insn_size = ctypes.sizeof(bpf_insn)
result = bytes(ctypes.string_at(prog.bf_insns, prog.bf_len * insn_size))
pcap.pcap_freecode(ctypes.byref(prog))
pcap.pcap_close(handle)
return result
def send_gratuitous_arp(interface: str, ip: str, mac: str) -> None:
ETH_P_ARP = 0x0806
sock = socket.socket(socket.AF_PACKET, socket.SOCK_RAW, socket.htons(ETH_P_ARP))
try:
sock.bind((interface, 0))
src_mac = bytes(int(b, 16) for b in mac.split(":"))
dst_mac = b"\xff" * 6
src_ip = socket.inet_aton(ip)
# Ethernet header
frame = dst_mac + src_mac + struct.pack("!H", ETH_P_ARP)
# ARP: hardware=Ethernet, proto=IPv4, hlen=6, plen=4, op=reply(2)
frame += struct.pack("!HHBBH", 1, 0x0800, 6, 4, 2)
# sender MAC + IP
frame += src_mac + src_ip
# target MAC + IP (broadcast)
frame += dst_mac + src_ip
for _ in range(3):
sock.send(frame)
finally:
sock.close()
def mac_to_bytes(mac: str) -> bytes:
return bytes(int(b, 16) for b in mac.split(":"))
def bytes_to_mac(data: bytes) -> str:
return ":".join(f"{b:02x}" for b in data)
def ip_in_subnet(ip: str, cidr: str) -> bool:
network, prefix_len = cidr.split("/")
prefix_len = int(prefix_len)
ip_int = struct.unpack("!I", socket.inet_aton(ip))[0]
net_int = struct.unpack("!I", socket.inet_aton(network))[0]
mask = (0xFFFFFFFF << (32 - prefix_len)) & 0xFFFFFFFF
return (ip_int & mask) == (net_int & mask)
+190
View File
@@ -0,0 +1,190 @@
#!/usr/bin/env python3
"""Root/capability checks, privilege dropping, directory permission enforcement."""
import ctypes
import ctypes.util
import grp
import os
import pwd
import stat
from pathlib import Path
from typing import Optional
# Linux capability constants
CAP_NET_RAW = 13
CAP_NET_ADMIN = 15
CAP_SYS_ADMIN = 21
CAP_DAC_OVERRIDE = 1
# capget/capset structures
_LINUX_CAPABILITY_VERSION_3 = 0x20080522
_VFS_CAP_REVISION_2 = 0x02000000
_libc = ctypes.CDLL(ctypes.util.find_library("c"), use_errno=True)
class _cap_header(ctypes.Structure):
_fields_ = [
("version", ctypes.c_uint32),
("pid", ctypes.c_int),
]
class _cap_data(ctypes.Structure):
_fields_ = [
("effective", ctypes.c_uint32),
("permitted", ctypes.c_uint32),
("inheritable", ctypes.c_uint32),
]
def check_root() -> bool:
return os.geteuid() == 0
def check_capability(cap: int) -> bool:
if check_root():
return True
try:
header = _cap_header(version=_LINUX_CAPABILITY_VERSION_3, pid=0)
# Version 3 uses 2 data structs for 64-bit capability sets
data = (_cap_data * 2)()
ret = _libc.capget(ctypes.byref(header), data)
if ret != 0:
return False
idx = cap // 32
bit = 1 << (cap % 32)
return bool(data[idx].effective & bit)
except Exception:
return False
def has_net_raw() -> bool:
return check_capability(CAP_NET_RAW)
def has_net_admin() -> bool:
return check_capability(CAP_NET_ADMIN)
def check_required_capabilities() -> dict:
return {
"root": check_root(),
"CAP_NET_RAW": check_capability(CAP_NET_RAW),
"CAP_NET_ADMIN": check_capability(CAP_NET_ADMIN),
"CAP_SYS_ADMIN": check_capability(CAP_SYS_ADMIN),
}
def drop_privileges(
username: str = "nobody",
groupname: Optional[str] = None,
keep_caps: Optional[list] = None,
) -> bool:
if not check_root():
return False
try:
pw = pwd.getpwnam(username)
uid = pw.pw_uid
gid = pw.pw_gid
if groupname:
gr = grp.getgrnam(groupname)
gid = gr.gr_gid
# Set supplementary groups
os.setgroups([])
# Set GID first (can't change after dropping root)
os.setregid(gid, gid)
os.setreuid(uid, uid)
# Verify drop
if os.geteuid() == 0:
return False
# Re-apply specific capabilities if requested
if keep_caps:
_set_keepcaps()
_apply_caps(keep_caps)
return True
except (KeyError, PermissionError, OSError):
return False
def _set_keepcaps():
PR_SET_KEEPCAPS = 8
_libc.prctl(PR_SET_KEEPCAPS, 1, 0, 0, 0)
def _apply_caps(caps: list):
header = _cap_header(version=_LINUX_CAPABILITY_VERSION_3, pid=0)
data = (_cap_data * 2)()
for cap in caps:
idx = cap // 32
bit = 1 << (cap % 32)
data[idx].effective |= bit
data[idx].permitted |= bit
_libc.capset(ctypes.byref(header), data)
def enforce_directory_permissions(
path: str,
mode: int = 0o700,
recursive: bool = True,
) -> int:
count = 0
target = Path(path)
if not target.exists():
target.mkdir(parents=True, mode=mode)
count += 1
return count
current = target.stat().st_mode & 0o777
if current != mode:
target.chmod(mode)
count += 1
if recursive:
for item in target.rglob("*"):
if item.is_dir():
item_mode = item.stat().st_mode & 0o777
if item_mode != mode:
item.chmod(mode)
count += 1
elif item.is_file():
file_mode = mode & 0o700 # Files get owner-only based on dir mode
item_mode = item.stat().st_mode & 0o777
if item_mode != file_mode:
item.chmod(file_mode)
count += 1
return count
def secure_path(path: str, owner_uid: Optional[int] = None) -> bool:
try:
p = Path(path)
if not p.exists():
return False
# Set ownership if specified
if owner_uid is not None:
os.chown(path, owner_uid, owner_uid)
# Restrict permissions
if p.is_dir():
p.chmod(0o700)
else:
p.chmod(0o600)
return True
except (OSError, PermissionError):
return False
+259
View File
@@ -0,0 +1,259 @@
#!/usr/bin/env python3
"""Hardware tier detection and resource monitoring via /proc and /sys."""
import os
import re
from dataclasses import dataclass, field
from pathlib import Path
from typing import Dict, List, Optional, Tuple
TIER_OPI_ZERO3 = "opi_zero3"
TIER_PI_ZERO = "pi_zero"
TIER_GENERIC = "generic"
@dataclass
class HardwareInfo:
tier: str
model: str
cpu_model: str
cpu_cores: int
total_ram_mb: int
architecture: str
@dataclass
class ResourceUsage:
ram_used_mb: float
ram_total_mb: float
ram_percent: float
cpu_percent: float # System-wide, from /proc/stat delta
disk_used_pct: float
disk_free_mb: float
temperature_c: Optional[float]
@dataclass
class ProcessResources:
pid: int
name: str
rss_mb: float
vms_mb: float
cpu_ticks: int
state: str
threads: int
def get_hardware_tier() -> str:
info = detect_hardware()
return info.tier
def detect_hardware() -> HardwareInfo:
model = _read_device_model()
cpu_model, cpu_cores, arch = _read_cpuinfo()
if "Orange Pi Zero3" in model or "Orange Pi Zero 3" in model:
tier = TIER_OPI_ZERO3
elif "Raspberry Pi Zero 2" in model or "Raspberry Pi Zero2" in model:
tier = TIER_PI_ZERO
elif "Raspberry Pi" in model and ("Zero" in model or "zero" in model):
tier = TIER_PI_ZERO
elif "Allwinner" in cpu_model or "H618" in cpu_model:
# Fallback detection via CPU for OPi Zero 3
tier = TIER_OPI_ZERO3
elif "BCM2835" in cpu_model or "BCM2710" in cpu_model:
tier = TIER_PI_ZERO
else:
tier = TIER_GENERIC
total_ram = _read_total_ram_mb()
return HardwareInfo(
tier=tier,
model=model or "Unknown",
cpu_model=cpu_model,
cpu_cores=cpu_cores,
total_ram_mb=total_ram,
architecture=arch,
)
def _read_device_model() -> str:
dt_model = Path("/proc/device-tree/model")
if dt_model.exists():
try:
return dt_model.read_text().strip().rstrip("\x00")
except IOError:
pass
return ""
def _read_cpuinfo() -> Tuple[str, int, str]:
cpu_model = ""
cores = 0
arch = "unknown"
try:
with open("/proc/cpuinfo", "r") as f:
content = f.read()
for line in content.splitlines():
if line.startswith("model name") or line.startswith("Hardware"):
cpu_model = line.split(":", 1)[1].strip()
if line.startswith("processor"):
cores += 1
# Get architecture
with open("/proc/cpuinfo", "r") as f:
first_lines = f.read(4096)
if "aarch64" in first_lines.lower() or "ARMv8" in first_lines:
arch = "aarch64"
elif "armv7" in first_lines.lower():
arch = "armv7l"
else:
import platform
arch = platform.machine()
except IOError:
pass
return cpu_model, max(cores, 1), arch
def _read_total_ram_mb() -> int:
try:
with open("/proc/meminfo", "r") as f:
for line in f:
if line.startswith("MemTotal:"):
kb = int(line.split()[1])
return kb // 1024
except IOError:
pass
return 0
def get_memory_info() -> Dict[str, int]:
info = {}
try:
with open("/proc/meminfo", "r") as f:
for line in f:
parts = line.split()
key = parts[0].rstrip(":")
val = int(parts[1]) # in kB
info[key] = val
except IOError:
pass
return info
def get_resource_usage(disk_path: str = "/") -> ResourceUsage:
mem = get_memory_info()
total = mem.get("MemTotal", 0)
available = mem.get("MemAvailable", mem.get("MemFree", 0))
used = total - available
pct = (used / total * 100) if total > 0 else 0.0
stat = os.statvfs(disk_path)
disk_total = stat.f_blocks * stat.f_frsize
disk_free = stat.f_bavail * stat.f_frsize
disk_used_pct = ((disk_total - disk_free) / disk_total * 100) if disk_total > 0 else 0.0
temp = get_cpu_temperature()
return ResourceUsage(
ram_used_mb=used / 1024,
ram_total_mb=total / 1024,
ram_percent=pct,
cpu_percent=_get_cpu_percent(),
disk_used_pct=disk_used_pct,
disk_free_mb=disk_free / (1024 * 1024),
temperature_c=temp,
)
def get_cpu_temperature() -> Optional[float]:
thermal_zones = Path("/sys/class/thermal")
if thermal_zones.exists():
for zone in sorted(thermal_zones.iterdir()):
temp_file = zone / "temp"
type_file = zone / "type"
if temp_file.exists():
try:
temp_raw = int(temp_file.read_text().strip())
return temp_raw / 1000.0
except (IOError, ValueError):
continue
# Fallback for some SBCs
hwmon = Path("/sys/class/hwmon")
if hwmon.exists():
for hw in sorted(hwmon.iterdir()):
temp_file = hw / "temp1_input"
if temp_file.exists():
try:
return int(temp_file.read_text().strip()) / 1000.0
except (IOError, ValueError):
continue
return None
def _get_cpu_percent() -> float:
try:
with open("/proc/stat", "r") as f:
line = f.readline()
parts = line.split()
# user, nice, system, idle, iowait, irq, softirq, steal
total = sum(int(p) for p in parts[1:8])
idle = int(parts[4])
# Snapshot-based; for delta, caller should sample twice
if total > 0:
return ((total - idle) / total) * 100
except (IOError, IndexError, ValueError):
pass
return 0.0
def get_process_resources(pid: int) -> Optional[ProcessResources]:
try:
stat_path = f"/proc/{pid}/stat"
status_path = f"/proc/{pid}/status"
with open(stat_path, "r") as f:
stat_line = f.read()
# Parse /proc/PID/stat — comm field can contain spaces/parens
match = re.match(r"(\d+) \((.+)\) (.+)", stat_line)
if not match:
return None
name = match.group(2)
fields = match.group(3).split()
state = fields[0]
utime = int(fields[11])
stime = int(fields[12])
threads = int(fields[17])
vsize = int(fields[20])
rss_pages = int(fields[21])
page_size = os.sysconf("SC_PAGE_SIZE")
rss_mb = (rss_pages * page_size) / (1024 * 1024)
vms_mb = vsize / (1024 * 1024)
return ProcessResources(
pid=pid,
name=name,
rss_mb=rss_mb,
vms_mb=vms_mb,
cpu_ticks=utime + stime,
state=state,
threads=threads,
)
except (IOError, OSError, IndexError, ValueError):
return None
def get_disk_usage(path: str = "/") -> Tuple[float, float, float]:
stat = os.statvfs(path)
total_gb = (stat.f_blocks * stat.f_frsize) / (1024 ** 3)
free_gb = (stat.f_bavail * stat.f_frsize) / (1024 ** 3)
used_pct = ((stat.f_blocks - stat.f_bavail) / stat.f_blocks * 100) if stat.f_blocks > 0 else 0
return total_gb, free_gb, used_pct
+166
View File
@@ -0,0 +1,166 @@
#!/usr/bin/env python3
"""Shared stealth helpers: process rename, cmdline spoof, sysctl, timestomp, core dump disable."""
import ctypes
import ctypes.util
import os
import struct
import time
from pathlib import Path
from typing import Optional
PR_SET_NAME = 15
RLIMIT_CORE = 4
_libc = ctypes.CDLL(ctypes.util.find_library("c"), use_errno=True)
def rename_process(name: str) -> bool:
name_bytes = name.encode("utf-8")[:15]
ret = _libc.prctl(PR_SET_NAME, name_bytes, 0, 0, 0)
return ret == 0
def spoof_cmdline(fake_cmdline: str) -> bool:
try:
cmdline_path = f"/proc/{os.getpid()}/cmdline"
# Read current cmdline to get its length for overwrite
with open(cmdline_path, "rb") as f:
original = f.read()
# On Linux, argv[0] is writable via /proc/self/comm and prctl,
# but /proc/self/cmdline maps to the actual process memory.
# We overwrite via ctypes access to argv.
libc = _libc
# Get argc/argv from /proc/self/cmdline
argc = ctypes.c_int()
argv_ptr = ctypes.POINTER(ctypes.c_char_p)()
# Alternative: write to /proc/self/comm (max 15 chars)
comm_path = f"/proc/{os.getpid()}/comm"
try:
with open(comm_path, "w") as f:
f.write(fake_cmdline[:15])
except PermissionError:
pass
# For full cmdline spoofing, overwrite argv[0] in memory
import sys
if hasattr(sys, "argv") and sys.argv:
# Replace argv contents via ctypes
fake_bytes = fake_cmdline.encode("utf-8")
argv0_addr = id(sys.argv[0])
# Python string internals - this is fragile but works for ps display
sys.argv[0] = fake_cmdline
return True
except Exception:
return False
def set_sysctl(key: str, value: str) -> bool:
sysctl_path = Path("/proc/sys") / key.replace(".", "/")
try:
with open(sysctl_path, "w") as f:
f.write(value)
return True
except (IOError, PermissionError):
return False
def set_ttl(ttl: int) -> bool:
return set_sysctl("net.ipv4.ip_default_ttl", str(ttl))
def set_tcp_window(size: int) -> bool:
# Set TCP initial window via rmem/wmem defaults
success = set_sysctl("net.ipv4.tcp_rmem", f"4096 {size} {size * 4}")
success &= set_sysctl("net.ipv4.tcp_wmem", f"4096 {size} {size * 4}")
return success
def set_tcp_timestamps(enabled: bool) -> bool:
return set_sysctl("net.ipv4.tcp_timestamps", "1" if enabled else "0")
def timestomp(path: str, reference: Optional[str] = None, epoch: Optional[float] = None) -> bool:
try:
if reference:
ref_stat = os.stat(reference)
target_time = ref_stat.st_mtime
elif epoch:
target_time = epoch
else:
# Default: OS install date from /var/log/installer or filesystem birth
target_time = _get_os_install_time()
os.utime(path, (target_time, target_time))
return True
except (OSError, IOError):
return False
def timestomp_recursive(directory: str, reference: Optional[str] = None, epoch: Optional[float] = None) -> int:
count = 0
for root, dirs, files in os.walk(directory):
for name in files + dirs:
full_path = os.path.join(root, name)
if timestomp(full_path, reference=reference, epoch=epoch):
count += 1
if timestomp(directory, reference=reference, epoch=epoch):
count += 1
return count
def _get_os_install_time() -> float:
candidates = [
"/var/log/installer/syslog",
"/var/log/installer/status",
"/etc/hostname",
"/etc/machine-id",
]
for path in candidates:
try:
return os.stat(path).st_mtime
except OSError:
continue
# Fallback: 90 days ago
return time.time() - (90 * 86400)
def disable_core_dumps() -> bool:
try:
# RLIMIT_CORE = 4 on Linux
class rlimit(ctypes.Structure):
_fields_ = [
("rlim_cur", ctypes.c_ulong),
("rlim_max", ctypes.c_ulong),
]
limit = rlimit(0, 0)
ret = _libc.setrlimit(RLIMIT_CORE, ctypes.byref(limit))
if ret != 0:
return False
# Also set via sysctl and /proc
set_sysctl("kernel.core_pattern", "|/bin/false")
set_sysctl("fs.suid_dumpable", "0")
# prctl to prevent core for this process and children
PR_SET_DUMPABLE = 4
_libc.prctl(PR_SET_DUMPABLE, 0, 0, 0, 0)
return True
except Exception:
return False
def hide_from_history() -> bool:
try:
os.environ["HISTFILE"] = "/dev/null"
os.environ["HISTSIZE"] = "0"
os.environ["HISTFILESIZE"] = "0"
return True
except Exception:
return False