reverse-skills-max: initial public release (restored, redacted, hardened)
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
esp_bootkit_hunter.py — Baseline and hunt malicious EFI binaries on the EFI System Partition.
|
||||
|
||||
Mounts (or reads an already-mounted) ESP, inventories EFI/PE boot binaries, computes
|
||||
SHA-256 hashes, verifies Secure Boot signatures with sbverify, flags files outside the
|
||||
canonical EFI/ directory, diffs against a golden baseline, and optionally YARA-scans.
|
||||
|
||||
Real tooling used: sbverify (sbsigntool), yara, sha256 hashing. No placeholders.
|
||||
|
||||
Usage:
|
||||
sudo python3 esp_bootkit_hunter.py --esp /mnt/esp --baseline baseline.sha256
|
||||
sudo python3 esp_bootkit_hunter.py --device /dev/sda1 --mount --yara bootkit.yar
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
EFI_EXTENSIONS = (".efi", ".sys", ".dll")
|
||||
|
||||
|
||||
def run(cmd):
|
||||
"""Run a command, return (returncode, stdout, stderr)."""
|
||||
try:
|
||||
p = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
|
||||
return p.returncode, p.stdout, p.stderr
|
||||
except FileNotFoundError:
|
||||
return 127, "", f"binary not found: {cmd[0]}"
|
||||
except subprocess.TimeoutExpired:
|
||||
return 124, "", "timeout"
|
||||
|
||||
|
||||
def mount_esp(device, mountpoint):
|
||||
os.makedirs(mountpoint, exist_ok=True)
|
||||
rc, _, err = run(["mount", "-o", "ro,umask=077", device, mountpoint])
|
||||
if rc != 0:
|
||||
sys.exit(f"[!] Failed to mount {device}: {err.strip()}")
|
||||
print(f"[+] Mounted {device} read-only at {mountpoint}")
|
||||
|
||||
|
||||
def sha256_file(path):
|
||||
h = hashlib.sha256()
|
||||
with open(path, "rb") as f:
|
||||
for chunk in iter(lambda: f.read(65536), b""):
|
||||
h.update(chunk)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def find_efi_binaries(esp_root):
|
||||
found = []
|
||||
for dirpath, _, files in os.walk(esp_root):
|
||||
for name in files:
|
||||
if name.lower().endswith(EFI_EXTENSIONS):
|
||||
found.append(os.path.join(dirpath, name))
|
||||
return found
|
||||
|
||||
|
||||
def detect_anomalous_root(esp_root):
|
||||
"""Per Velociraptor/Rapid7: EFI/ should be the only top-level dir on the ESP."""
|
||||
allowed = {"efi", "system volume information"}
|
||||
anomalies = []
|
||||
for entry in os.listdir(esp_root):
|
||||
if entry.lower() not in allowed:
|
||||
anomalies.append(os.path.join(esp_root, entry))
|
||||
return anomalies
|
||||
|
||||
|
||||
def verify_signature(path):
|
||||
"""Use sbverify --list to confirm the binary carries a Secure Boot signature."""
|
||||
if not shutil.which("sbverify"):
|
||||
return "sbverify-missing"
|
||||
rc, out, _ = run(["sbverify", "--list", path])
|
||||
if rc != 0:
|
||||
return "UNSIGNED-or-invalid"
|
||||
# sbverify --list prints 'signature N' lines for each embedded signature
|
||||
return "signed" if "signature" in out.lower() else "UNSIGNED"
|
||||
|
||||
|
||||
def load_baseline(path):
|
||||
baseline = set()
|
||||
if not path or not os.path.exists(path):
|
||||
return baseline
|
||||
with open(path) as f:
|
||||
for line in f:
|
||||
tok = line.strip().split()
|
||||
if tok:
|
||||
baseline.add(tok[0].lower())
|
||||
return baseline
|
||||
|
||||
|
||||
def yara_scan(rules, esp_root):
|
||||
if not shutil.which("yara"):
|
||||
return "yara-missing"
|
||||
rc, out, err = run(["yara", "-r", "-w", rules, esp_root])
|
||||
return out.strip() or ("(no matches)" if rc == 0 else err.strip())
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="Hunt bootkits on the EFI System Partition.")
|
||||
ap.add_argument("--esp", default="/mnt/esp", help="ESP mountpoint")
|
||||
ap.add_argument("--device", help="ESP block device to mount (e.g. /dev/sda1)")
|
||||
ap.add_argument("--mount", action="store_true", help="Mount --device read-only first")
|
||||
ap.add_argument("--baseline", help="Golden baseline sha256 file to diff against")
|
||||
ap.add_argument("--yara", help="YARA ruleset to scan the ESP with")
|
||||
ap.add_argument("--json", help="Write findings to this JSON path")
|
||||
args = ap.parse_args()
|
||||
|
||||
if os.geteuid() != 0:
|
||||
print("[!] Warning: ESP access typically requires root.", file=sys.stderr)
|
||||
|
||||
if args.mount:
|
||||
if not args.device:
|
||||
sys.exit("--mount requires --device")
|
||||
mount_esp(args.device, args.esp)
|
||||
|
||||
if not os.path.isdir(args.esp):
|
||||
sys.exit(f"[!] ESP path not found: {args.esp}")
|
||||
|
||||
report = {"esp": args.esp, "binaries": [], "root_anomalies": [],
|
||||
"baseline_new": [], "yara": None}
|
||||
|
||||
# Step 2: anomalous root entries
|
||||
report["root_anomalies"] = detect_anomalous_root(args.esp)
|
||||
for a in report["root_anomalies"]:
|
||||
print(f"[!] ANOMALY: non-EFI entry in ESP root -> {a}")
|
||||
|
||||
# Steps 3-4: inventory, hash, verify signatures
|
||||
baseline = load_baseline(args.baseline)
|
||||
for path in find_efi_binaries(args.esp):
|
||||
try:
|
||||
digest = sha256_file(path)
|
||||
except OSError as e:
|
||||
print(f"[!] Cannot read {path}: {e}", file=sys.stderr)
|
||||
continue
|
||||
sig = verify_signature(path)
|
||||
new = bool(baseline) and digest.lower() not in baseline
|
||||
entry = {"path": path, "sha256": digest, "signature": sig, "new_vs_baseline": new}
|
||||
report["binaries"].append(entry)
|
||||
flag = ""
|
||||
if sig.startswith("UNSIGNED"):
|
||||
flag += " [UNSIGNED]"
|
||||
if new:
|
||||
flag += " [NOT-IN-BASELINE]"
|
||||
report["baseline_new"].append(digest)
|
||||
print(f"[+] {digest} {sig:18s} {path}{flag}")
|
||||
|
||||
# Step 6: YARA
|
||||
if args.yara:
|
||||
report["yara"] = yara_scan(args.yara, args.esp)
|
||||
print(f"[+] YARA results:\n{report['yara']}")
|
||||
|
||||
# Summary
|
||||
n_unsigned = sum(1 for b in report["binaries"] if b["signature"].startswith("UNSIGNED"))
|
||||
print(f"\n[=] {len(report['binaries'])} EFI binaries | "
|
||||
f"{n_unsigned} unsigned | {len(report['baseline_new'])} new vs baseline | "
|
||||
f"{len(report['root_anomalies'])} root anomalies")
|
||||
|
||||
if args.json:
|
||||
with open(args.json, "w") as f:
|
||||
json.dump(report, f, indent=2)
|
||||
print(f"[+] Wrote findings to {args.json}")
|
||||
|
||||
# Non-zero exit if anything suspicious was found (CI/hunt automation friendly)
|
||||
if n_unsigned or report["baseline_new"] or report["root_anomalies"]:
|
||||
sys.exit(2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user