remove infra.md.example, infra.md is the source of truth
This commit is contained in:
317
ayn-antivirus/ayn_antivirus/detectors/cryptominer_detector.py
Normal file
317
ayn-antivirus/ayn_antivirus/detectors/cryptominer_detector.py
Normal file
@@ -0,0 +1,317 @@
|
||||
"""Crypto-miner detector for AYN Antivirus.
|
||||
|
||||
Combines file-content analysis, process inspection, and network connection
|
||||
checks to detect cryptocurrency mining activity on the host.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
import psutil
|
||||
|
||||
from ayn_antivirus.constants import (
|
||||
CRYPTO_MINER_PROCESS_NAMES,
|
||||
CRYPTO_POOL_DOMAINS,
|
||||
HIGH_CPU_THRESHOLD,
|
||||
SUSPICIOUS_PORTS,
|
||||
)
|
||||
from ayn_antivirus.detectors.base import BaseDetector, DetectionResult
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# File-content patterns
|
||||
# ---------------------------------------------------------------------------
|
||||
_RE_STRATUM = re.compile(rb"stratum\+(?:tcp|ssl|tls)://[^\s\"']+", re.IGNORECASE)
|
||||
_RE_POOL_DOMAIN = re.compile(
|
||||
rb"(?:" + b"|".join(re.escape(d.encode()) for d in CRYPTO_POOL_DOMAINS) + rb")",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_RE_ALGO_REF = re.compile(
|
||||
rb"\b(?:cryptonight|randomx|ethash|kawpow|equihash|scrypt|sha256d|x11|x13|lyra2rev2|blake2s)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_RE_MINING_CONFIG = re.compile(
|
||||
rb"""["'](?:algo|pool|wallet|worker|pass|coin|url|user)["']\s*:\s*["']""",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# Wallet address patterns (broad but useful).
|
||||
_RE_BTC_ADDR = re.compile(rb"\b(?:1|3|bc1)[A-HJ-NP-Za-km-z1-9]{25,62}\b")
|
||||
_RE_ETH_ADDR = re.compile(rb"\b0x[0-9a-fA-F]{40}\b")
|
||||
_RE_XMR_ADDR = re.compile(rb"\b4[0-9AB][1-9A-HJ-NP-Za-km-z]{93}\b")
|
||||
|
||||
|
||||
class CryptominerDetector(BaseDetector):
|
||||
"""Detect cryptocurrency mining activity via files, processes, and network."""
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# BaseDetector interface
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "cryptominer_detector"
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return "Detects crypto-mining binaries, configs, processes, and network traffic"
|
||||
|
||||
def detect(
|
||||
self,
|
||||
file_path: str | Path,
|
||||
file_content: Optional[bytes] = None,
|
||||
file_hash: Optional[str] = None,
|
||||
) -> List[DetectionResult]:
|
||||
"""Analyse a file for mining indicators.
|
||||
|
||||
Also checks running processes and network connections for live mining
|
||||
activity (these are host-wide and not specific to *file_path*, but
|
||||
are included for a comprehensive picture).
|
||||
"""
|
||||
file_path = Path(file_path)
|
||||
results: List[DetectionResult] = []
|
||||
|
||||
try:
|
||||
content = self._read_content(file_path, file_content)
|
||||
except OSError as exc:
|
||||
self._warn("Cannot read %s: %s", file_path, exc)
|
||||
return results
|
||||
|
||||
# --- File-content checks ---
|
||||
results.extend(self._check_stratum_urls(file_path, content))
|
||||
results.extend(self._check_pool_domains(file_path, content))
|
||||
results.extend(self._check_algo_references(file_path, content))
|
||||
results.extend(self._check_mining_config(file_path, content))
|
||||
results.extend(self._check_wallet_addresses(file_path, content))
|
||||
|
||||
return results
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# File-content checks
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _check_stratum_urls(
|
||||
self, file_path: Path, content: bytes
|
||||
) -> List[DetectionResult]:
|
||||
results: List[DetectionResult] = []
|
||||
matches = _RE_STRATUM.findall(content)
|
||||
if matches:
|
||||
urls = [m.decode(errors="replace") for m in matches[:5]]
|
||||
results.append(DetectionResult(
|
||||
threat_name="Miner.Stratum.URL",
|
||||
threat_type="MINER",
|
||||
severity="CRITICAL",
|
||||
confidence=95,
|
||||
details=f"Stratum mining URL(s) found: {', '.join(urls)}",
|
||||
detector_name=self.name,
|
||||
))
|
||||
return results
|
||||
|
||||
def _check_pool_domains(
|
||||
self, file_path: Path, content: bytes
|
||||
) -> List[DetectionResult]:
|
||||
results: List[DetectionResult] = []
|
||||
matches = _RE_POOL_DOMAIN.findall(content)
|
||||
if matches:
|
||||
domains = sorted(set(m.decode(errors="replace") for m in matches))
|
||||
results.append(DetectionResult(
|
||||
threat_name="Miner.PoolDomain",
|
||||
threat_type="MINER",
|
||||
severity="HIGH",
|
||||
confidence=90,
|
||||
details=f"Mining pool domain(s) referenced: {', '.join(domains[:5])}",
|
||||
detector_name=self.name,
|
||||
))
|
||||
return results
|
||||
|
||||
def _check_algo_references(
|
||||
self, file_path: Path, content: bytes
|
||||
) -> List[DetectionResult]:
|
||||
results: List[DetectionResult] = []
|
||||
matches = _RE_ALGO_REF.findall(content)
|
||||
if matches:
|
||||
algos = sorted(set(m.decode(errors="replace").lower() for m in matches))
|
||||
results.append(DetectionResult(
|
||||
threat_name="Miner.AlgorithmReference",
|
||||
threat_type="MINER",
|
||||
severity="MEDIUM",
|
||||
confidence=60,
|
||||
details=f"Mining algorithm reference(s): {', '.join(algos)}",
|
||||
detector_name=self.name,
|
||||
))
|
||||
return results
|
||||
|
||||
def _check_mining_config(
|
||||
self, file_path: Path, content: bytes
|
||||
) -> List[DetectionResult]:
|
||||
results: List[DetectionResult] = []
|
||||
matches = _RE_MINING_CONFIG.findall(content)
|
||||
if len(matches) >= 2:
|
||||
results.append(DetectionResult(
|
||||
threat_name="Miner.ConfigFile",
|
||||
threat_type="MINER",
|
||||
severity="HIGH",
|
||||
confidence=85,
|
||||
details=(
|
||||
f"File resembles a mining configuration "
|
||||
f"({len(matches)} config key(s) detected)"
|
||||
),
|
||||
detector_name=self.name,
|
||||
))
|
||||
return results
|
||||
|
||||
def _check_wallet_addresses(
|
||||
self, file_path: Path, content: bytes
|
||||
) -> List[DetectionResult]:
|
||||
results: List[DetectionResult] = []
|
||||
wallets: List[str] = []
|
||||
|
||||
for label, regex in [
|
||||
("BTC", _RE_BTC_ADDR),
|
||||
("ETH", _RE_ETH_ADDR),
|
||||
("XMR", _RE_XMR_ADDR),
|
||||
]:
|
||||
matches = regex.findall(content)
|
||||
for m in matches[:3]:
|
||||
wallets.append(f"{label}:{m.decode(errors='replace')[:20]}…")
|
||||
|
||||
if wallets:
|
||||
results.append(DetectionResult(
|
||||
threat_name="Miner.WalletAddress",
|
||||
threat_type="MINER",
|
||||
severity="HIGH",
|
||||
confidence=70,
|
||||
details=f"Cryptocurrency wallet address(es): {', '.join(wallets[:5])}",
|
||||
detector_name=self.name,
|
||||
))
|
||||
return results
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Process-based detection (host-wide, not file-specific)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def find_miner_processes() -> List[DetectionResult]:
|
||||
"""Scan running processes for known miner names.
|
||||
|
||||
This is a host-wide check and should be called independently from
|
||||
the per-file ``detect()`` method.
|
||||
"""
|
||||
results: List[DetectionResult] = []
|
||||
for proc in psutil.process_iter(["pid", "name", "cmdline", "cpu_percent"]):
|
||||
try:
|
||||
info = proc.info
|
||||
pname = (info.get("name") or "").lower()
|
||||
cmdline = " ".join(info.get("cmdline") or []).lower()
|
||||
|
||||
for miner in CRYPTO_MINER_PROCESS_NAMES:
|
||||
if miner in pname or miner in cmdline:
|
||||
results.append(DetectionResult(
|
||||
threat_name=f"Miner.Process.{miner}",
|
||||
threat_type="MINER",
|
||||
severity="CRITICAL",
|
||||
confidence=95,
|
||||
details=(
|
||||
f"Known miner process running: {info.get('name')} "
|
||||
f"(PID {info['pid']}, CPU {info.get('cpu_percent', 0):.1f}%)"
|
||||
),
|
||||
detector_name="cryptominer_detector",
|
||||
))
|
||||
break
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
|
||||
continue
|
||||
return results
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# CPU analysis (host-wide)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def find_high_cpu_processes(
|
||||
threshold: float = HIGH_CPU_THRESHOLD,
|
||||
) -> List[DetectionResult]:
|
||||
"""Flag processes consuming CPU above *threshold* percent."""
|
||||
results: List[DetectionResult] = []
|
||||
for proc in psutil.process_iter(["pid", "name", "cpu_percent"]):
|
||||
try:
|
||||
info = proc.info
|
||||
cpu = info.get("cpu_percent") or 0.0
|
||||
if cpu > threshold:
|
||||
results.append(DetectionResult(
|
||||
threat_name="Miner.HighCPU",
|
||||
threat_type="MINER",
|
||||
severity="HIGH",
|
||||
confidence=55,
|
||||
details=(
|
||||
f"Process {info.get('name')} (PID {info['pid']}) "
|
||||
f"using {cpu:.1f}% CPU"
|
||||
),
|
||||
detector_name="cryptominer_detector",
|
||||
))
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
|
||||
continue
|
||||
return results
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Network detection (host-wide)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def find_mining_connections() -> List[DetectionResult]:
|
||||
"""Check active network connections for mining pool traffic."""
|
||||
results: List[DetectionResult] = []
|
||||
try:
|
||||
connections = psutil.net_connections(kind="inet")
|
||||
except psutil.AccessDenied:
|
||||
logger.warning("Insufficient permissions to read network connections")
|
||||
return results
|
||||
|
||||
for conn in connections:
|
||||
raddr = conn.raddr
|
||||
if not raddr:
|
||||
continue
|
||||
|
||||
remote_ip = raddr.ip
|
||||
remote_port = raddr.port
|
||||
|
||||
proc_name = ""
|
||||
if conn.pid:
|
||||
try:
|
||||
proc_name = psutil.Process(conn.pid).name()
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||||
proc_name = "?"
|
||||
|
||||
if remote_port in SUSPICIOUS_PORTS:
|
||||
results.append(DetectionResult(
|
||||
threat_name="Miner.Network.SuspiciousPort",
|
||||
threat_type="MINER",
|
||||
severity="HIGH",
|
||||
confidence=75,
|
||||
details=(
|
||||
f"Connection to port {remote_port} "
|
||||
f"({remote_ip}, process={proc_name}, PID={conn.pid})"
|
||||
),
|
||||
detector_name="cryptominer_detector",
|
||||
))
|
||||
|
||||
for domain in CRYPTO_POOL_DOMAINS:
|
||||
if domain in remote_ip:
|
||||
results.append(DetectionResult(
|
||||
threat_name="Miner.Network.PoolConnection",
|
||||
threat_type="MINER",
|
||||
severity="CRITICAL",
|
||||
confidence=95,
|
||||
details=(
|
||||
f"Active connection to mining pool {domain} "
|
||||
f"({remote_ip}:{remote_port}, process={proc_name})"
|
||||
),
|
||||
detector_name="cryptominer_detector",
|
||||
))
|
||||
break
|
||||
|
||||
return results
|
||||
Reference in New Issue
Block a user