Files
2026-03-01 19:16:57 -08:00

915 lines
30 KiB
TypeScript

import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { spawn, type ChildProcess } from "node:child_process";
import { mkdtemp, mkdir, readFile, rm, writeFile, chmod } from "node:fs/promises";
import { createHash, randomBytes, randomInt } from "node:crypto";
import { gunzip as gunzipCb } from "node:zlib";
import { promisify } from "node:util";
type SliverScriptModule = typeof import("..");
type SliverClientInstance = InstanceType<SliverScriptModule["SliverClient"]>;
type BeaconInfo = Awaited<ReturnType<SliverClientInstance["beacons"]>>[number];
type InteractiveBeaconInstance = ReturnType<SliverClientInstance["interactBeacon"]>;
type BeaconTransportName = "mtls" | "http" | "wg";
interface BeaconTransportSpec {
name: BeaconTransportName;
c2URL: string;
expectedTransportToken: string;
expectedActiveC2Token: string;
bindHost: string;
host: string;
port: number;
wgNPort?: number;
wgKeyPort?: number;
}
const gunzip = promisify(gunzipCb);
function findRepoRoot(startDir: string): string | undefined {
let dir = path.resolve(startDir);
for (let i = 0; i < 10; i++) {
if (fs.existsSync(path.join(dir, "package.json"))) {
return dir;
}
const parent = path.dirname(dir);
if (parent === dir) {
break;
}
dir = parent;
}
return undefined;
}
function guessRepoRoot(): string {
return findRepoRoot(process.cwd()) ?? findRepoRoot(__dirname) ?? process.cwd();
}
function loadLocalSliverScript(repoRoot: string): SliverScriptModule {
const libDir = path.join(repoRoot, "lib");
const entry = path.join(libDir, "index.js");
if (!fs.existsSync(entry)) {
throw new Error(`Missing built library entrypoint: ${entry}. Run: npm run build`);
}
return require(libDir) as SliverScriptModule;
}
const REPO_ROOT = guessRepoRoot();
const CONFIG_PATH = process.env.SLIVER_CONFIG_FILE ?? path.join(REPO_ROOT, "localhost.cfg");
const DEFAULT_HTTP_C2_PROFILE = "default";
const DEFAULT_BEACON_INTERVAL_SECONDS = 5;
const BEACON_TIMEOUT_SECONDS = 300;
const BEACON_TASK_POLL_RPC_TIMEOUT_SECONDS = 20;
const BEACON_TASK_CONTENT_RPC_TIMEOUT_SECONDS = 45;
const BEACON_DOWNLOAD_RPC_TIMEOUT_SECONDS = 45;
const BEACON_PER_TRANSPORT_OVERALL_TIMEOUT_SECONDS = 25 * 60;
function assert(condition: unknown, message: string): asserts condition {
if (!condition) {
throw new Error(message);
}
}
function parsePort(rawValue: string, name: string): number {
const parsed = Number.parseInt(rawValue, 10);
assert(Number.isInteger(parsed) && parsed > 0 && parsed <= 65535, `Invalid ${name}: ${rawValue}`);
return parsed;
}
function parsePositiveInt(rawValue: string, name: string, max: number): number {
const parsed = Number.parseInt(rawValue, 10);
assert(Number.isInteger(parsed) && parsed > 0 && parsed <= max, `Invalid ${name}: ${rawValue}`);
return parsed;
}
const BEACON_INTERVAL_SECONDS = parsePositiveInt(
process.env.SLIVER_E2E_BEACON_INTERVAL_SECONDS ?? String(DEFAULT_BEACON_INTERVAL_SECONDS),
"SLIVER_E2E_BEACON_INTERVAL_SECONDS",
300,
);
const BEACON_INTERVAL_NS = String(BEACON_INTERVAL_SECONDS * 1_000_000_000);
function nodePlatformToGoOS(platform: NodeJS.Platform): string {
switch (platform) {
case "darwin":
return "darwin";
case "linux":
return "linux";
case "win32":
return "windows";
default:
throw new Error(`Unsupported platform for e2e implant generation: ${platform}`);
}
}
function nodeArchToGoArch(arch: string): string {
switch (arch) {
case "x64":
return "amd64";
case "arm64":
return "arm64";
case "arm":
return "arm";
default:
throw new Error(`Unsupported arch for e2e implant generation: ${arch}`);
}
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function withRpcTimeout<T>(
timeoutSeconds: number,
label: string,
action: (signal: AbortSignal) => Promise<T>,
): Promise<T> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutSeconds * 1000);
try {
return await action(controller.signal);
} catch (err) {
if (controller.signal.aborted) {
throw new Error(`${label} timed out after ${timeoutSeconds}s`);
}
throw err;
} finally {
clearTimeout(timer);
}
}
async function waitForBeacon(
client: SliverClientInstance,
timeoutSeconds: number,
predicate: (beacon: BeaconInfo) => boolean,
): Promise<BeaconInfo> {
const deadline = Date.now() + timeoutSeconds * 1000;
while (Date.now() < deadline) {
const beacons = await client.beacons(30);
const match = beacons.find(predicate);
if (match) {
return match;
}
await sleep(1000);
}
throw new Error(`Timed out waiting for beacon (${timeoutSeconds}s)`);
}
async function waitForBeaconTaskComplete(
client: SliverClientInstance,
beaconID: string,
taskID: string,
timeoutSeconds: number,
): Promise<void> {
const deadline = Date.now() + timeoutSeconds * 1000;
while (Date.now() < deadline) {
let tasks;
try {
tasks = await withRpcTimeout(
BEACON_TASK_POLL_RPC_TIMEOUT_SECONDS,
`getBeaconTasks(${beaconID})`,
(signal) => client.rpc.getBeaconTasks({ ID: beaconID }, { signal }),
);
} catch (err) {
if (Date.now() >= deadline) {
throw err;
}
await sleep(1000);
continue;
}
const task = tasks.Tasks.find((candidate) => candidate.ID === taskID);
if (!task) {
await sleep(1000);
continue;
}
const state = task.State.toLowerCase();
if (state === "completed") {
return;
}
if (state === "failed" || state === "canceled" || state === "cancelled") {
throw new Error(`Beacon task ${taskID} ${state}: ${task.Description}`);
}
await sleep(1000);
}
throw new Error(`Timed out waiting for beacon task completion (${timeoutSeconds}s): ${taskID}`);
}
function getQueuedTaskID(queued: { Response?: { Err?: string; TaskID?: string } }, label: string): string {
const queueErr = queued.Response?.Err?.trim();
assert(!queueErr, `${label} queue error: ${queueErr}`);
const taskID = queued.Response?.TaskID?.trim() ?? "";
assert(taskID.length > 0, `${label} queue response missing task id`);
return taskID;
}
async function decodeBeaconTaskResponse<T extends { Response?: { Err: string } }>(
client: SliverClientInstance,
beaconID: string,
queued: { Response?: { Err?: string; TaskID?: string } },
decoder: (input: Uint8Array) => T,
label: string,
timeoutSeconds: number,
): Promise<T> {
const taskID = getQueuedTaskID(queued, label);
const deadline = Date.now() + timeoutSeconds * 1000;
let lastObservedState: string | undefined;
let lastContentError: unknown;
while (Date.now() < deadline) {
try {
const content = await withRpcTimeout(
BEACON_TASK_CONTENT_RPC_TIMEOUT_SECONDS,
`getBeaconTaskContent(${taskID})`,
(signal) => client.rpc.getBeaconTaskContent({ ID: taskID }, { signal }),
);
if (content.Response.length > 0) {
const decoded = decoder(content.Response);
const err = decoded.Response?.Err?.trim();
assert(!err, `${label} execution error: ${err}`);
return decoded;
}
} catch (err) {
lastContentError = err;
}
try {
const tasks = await withRpcTimeout(
BEACON_TASK_POLL_RPC_TIMEOUT_SECONDS,
`getBeaconTasks(${beaconID})`,
(signal) => client.rpc.getBeaconTasks({ ID: beaconID }, { signal }),
);
const task = tasks.Tasks.find((candidate) => candidate.ID === taskID);
if (task) {
const state = task.State.toLowerCase();
lastObservedState = state;
if (state === "failed" || state === "canceled" || state === "cancelled") {
throw new Error(`Beacon task ${taskID} ${state}: ${task.Description}`);
}
}
} catch (err) {
if (err instanceof Error && err.message.includes(`Beacon task ${taskID}`)) {
throw err;
}
}
await sleep(1000);
}
const debugParts = [];
if (lastObservedState) {
debugParts.push(`lastState=${lastObservedState}`);
}
if (lastContentError instanceof Error && lastContentError.message.trim().length > 0) {
debugParts.push(`lastContentError=${lastContentError.message}`);
}
const debugSuffix = debugParts.length > 0 ? ` (${debugParts.join(", ")})` : "";
throw new Error(`Timed out waiting for beacon task response (${timeoutSeconds}s): ${taskID}${debugSuffix}`);
}
async function decodeDownloadData(download: { Encoder: string; Data: Buffer }): Promise<Buffer> {
if (download.Encoder === "gzip") {
return await gunzip(download.Data);
}
if (download.Encoder === "") {
return download.Data;
}
throw new Error(`Unsupported download encoder: ${download.Encoder}`);
}
function waitForChildExit(child: ChildProcess): Promise<number | null> {
return new Promise((resolve) => {
child.once("exit", (code) => resolve(code));
});
}
async function terminateChildProcess(child: ChildProcess): Promise<void> {
if (child.exitCode !== null) {
return;
}
child.kill("SIGTERM");
const exited = await Promise.race([
waitForChildExit(child).then(() => true),
sleep(4000).then(() => false),
]);
if (!exited && child.exitCode === null) {
child.kill("SIGKILL");
await waitForChildExit(child);
}
}
function collectOutput(child: ChildProcess) {
let stdout = "";
let stderr = "";
child.stdout?.on("data", (chunk: Buffer) => {
stdout += chunk.toString("utf8");
if (stdout.length > 20_000) {
stdout = stdout.slice(-20_000);
}
});
child.stderr?.on("data", (chunk: Buffer) => {
stderr += chunk.toString("utf8");
if (stderr.length > 20_000) {
stderr = stderr.slice(-20_000);
}
});
return {
stdout: () => stdout,
stderr: () => stderr,
};
}
function md5Hex(data: Buffer): string {
return createHash("md5").update(data).digest("hex");
}
function printBeaconInfoSummary(beacon: BeaconInfo): void {
console.log("beacon info", {
id: beacon.ID,
name: beacon.Name,
hostname: beacon.Hostname,
username: beacon.Username,
os: beacon.OS,
arch: beacon.Arch,
transport: beacon.Transport,
activeC2: beacon.ActiveC2,
pid: beacon.PID,
remoteAddress: beacon.RemoteAddress,
interval: beacon.Interval,
nextCheckin: beacon.NextCheckin,
});
}
function createBeaconImplantConfig(
sliver: SliverScriptModule,
goos: string,
goarch: string,
transport: BeaconTransportSpec,
) {
return sliver.clientpb.ImplantConfig.create({
GOOS: goos,
GOARCH: goarch,
C2: [{ URL: transport.c2URL }],
HTTPC2ConfigName: DEFAULT_HTTP_C2_PROFILE,
Debug: false,
ObfuscateSymbols: false,
IsBeacon: true,
BeaconInterval: BEACON_INTERVAL_NS,
BeaconJitter: "0",
IncludeMTLS: transport.name === "mtls",
IncludeHTTP: transport.name === "http",
IncludeWG: transport.name === "wg",
WGKeyExchangePort: transport.wgKeyPort ?? 0,
WGTcpCommsPort: transport.wgNPort ?? 0,
Format: sliver.clientpb.OutputFormat.EXECUTABLE,
IsSharedLib: false,
IsService: false,
IsShellcode: false,
});
}
async function startBeaconTransportListener(
client: SliverClientInstance,
transport: BeaconTransportSpec,
): Promise<number> {
if (transport.name === "mtls") {
const listener = await client.startMTLSListener(transport.bindHost, transport.port, 60);
return listener.JobID;
}
if (transport.name === "http") {
const listener = await client.startHTTPListener(transport.host, transport.bindHost, transport.port, "", false, 60);
return listener.JobID;
}
assert(transport.wgNPort !== undefined, "wg nport is required");
assert(transport.wgKeyPort !== undefined, "wg keyport is required");
const listener = await client.startWGListener(
transport.bindHost,
transport.port,
"100.64.0.1",
transport.wgNPort,
transport.wgKeyPort,
60,
);
return listener.JobID;
}
async function runBeaconTransportChecks(
sliver: SliverScriptModule,
client: SliverClientInstance,
transport: BeaconTransportSpec,
goos: string,
goarch: string,
existingBeaconIDs: Set<string>,
): Promise<void> {
let tempDir: string | undefined;
let implantPath: string | undefined;
let implantProc: ChildProcess | undefined;
let implantOutput: ReturnType<typeof collectOutput> | undefined;
let interactiveBeacon: InteractiveBeaconInstance | undefined;
let remoteTransferDir: string | undefined;
let beaconID: string | undefined;
let listenerJobId: number | undefined;
try {
listenerJobId = await startBeaconTransportListener(client, transport);
console.log("listener started", {
transport: transport.name,
bindHost: transport.bindHost,
host: transport.host,
port: transport.port,
jobId: listenerJobId,
wgNPort: transport.wgNPort,
wgKeyPort: transport.wgKeyPort,
});
const implantName = `e2e-beacon-${transport.name}-${goos}-${goarch}-${Date.now()}`;
console.log("generate beacon", {
transport: transport.name,
implantName,
goos,
goarch,
c2URL: transport.c2URL,
beaconIntervalSeconds: BEACON_INTERVAL_SECONDS,
});
const generated = await client.generate(createBeaconImplantConfig(sliver, goos, goarch, transport), 300);
assert(generated !== undefined, "Generate returned no file");
assert(generated.Data.length > 0, "Generated beacon file is empty");
tempDir = await mkdtemp(path.join(os.tmpdir(), `sliver-script-beacon-${transport.name}-`));
const generatedName = generated.Name.trim() || implantName;
const filename = path.basename(generatedName);
implantPath = path.join(tempDir, filename);
await writeFile(implantPath, generated.Data, { mode: 0o700 });
if (process.platform !== "win32") {
await chmod(implantPath, 0o700);
}
const spawnedImplant = spawn(implantPath, [], { stdio: ["ignore", "pipe", "pipe"] });
implantProc = spawnedImplant;
implantOutput = collectOutput(spawnedImplant);
const implantPid = spawnedImplant.pid;
assert(implantPid !== undefined, "Failed to start beacon process");
console.log("beacon implant started", { transport: transport.name, path: implantPath, pid: implantPid });
const beacon = await waitForBeacon(
client,
BEACON_TIMEOUT_SECONDS,
(candidate) =>
!existingBeaconIDs.has(candidate.ID) &&
candidate.OS.toLowerCase() === goos &&
candidate.Arch.toLowerCase() === goarch &&
candidate.Transport.toLowerCase().includes(transport.expectedTransportToken),
);
beaconID = beacon.ID;
existingBeaconIDs.add(beacon.ID);
interactiveBeacon = client.interactBeacon(beacon.ID);
console.log("beacon created", {
transport: transport.name,
id: beacon.ID,
pid: beacon.PID,
beaconTransport: beacon.Transport,
activeC2: beacon.ActiveC2,
});
const nonce = randomInt(1, 2_000_000_000);
const pingQueued = await interactiveBeacon.ping(nonce, 120);
const ping = await decodeBeaconTaskResponse(
client,
beacon.ID,
pingQueued,
sliver.sliverpb.Ping.decode,
`beacon ping (${transport.name})`,
180,
);
assert(ping.Nonce === nonce, `Beacon ping nonce mismatch: expected ${nonce}, got ${ping.Nonce}`);
const executeNoEnvToken = `BEACON_EXEC_NOENV_${transport.name}_${Date.now()}_${randomInt(10_000, 99_999)}`;
const executeNoEnvQueued = await interactiveBeacon.execute(
"/bin/sh",
["-c", `printf '%s' '${executeNoEnvToken}'`],
true,
120,
);
const executeNoEnv = await decodeBeaconTaskResponse(
client,
beacon.ID,
executeNoEnvQueued,
sliver.sliverpb.Execute.decode,
`beacon execute (no env, ${transport.name})`,
180,
);
assert(executeNoEnv.Status === 0, `beacon execute(no env) non-zero status: ${executeNoEnv.Status}`);
const executeNoEnvStdout = executeNoEnv.Stdout.toString("utf8");
assert(
executeNoEnvStdout === executeNoEnvToken,
`beacon execute(no env) stdout mismatch: expected '${executeNoEnvToken}', got '${executeNoEnvStdout}'`,
);
const executeEnvValue = `BEACON_EXEC_ENV_${transport.name}_${Date.now()}_${randomInt(10_000, 99_999)}`;
const executeWithEnvQueued = await withRpcTimeout(
BEACON_DOWNLOAD_RPC_TIMEOUT_SECONDS,
`beacon execute queue (with env, ${transport.name})`,
(signal) =>
client.rpc.execute(
{
Path: "/bin/sh",
Args: ["-c", 'printf "%s" "$BEACON_E2E_EXEC_ENV"'],
Output: true,
EnvInheritance: false,
Env: {
BEACON_E2E_EXEC_ENV: executeEnvValue,
},
Request: {
Async: true,
Timeout: "180",
BeaconID: beacon.ID,
SessionID: "",
},
},
{ signal },
),
);
const executeWithEnv = await decodeBeaconTaskResponse(
client,
beacon.ID,
executeWithEnvQueued,
sliver.sliverpb.Execute.decode,
`beacon execute (with env, ${transport.name})`,
180,
);
assert(executeWithEnv.Status === 0, `beacon execute(with env) non-zero status: ${executeWithEnv.Status}`);
const executeWithEnvStdout = executeWithEnv.Stdout.toString("utf8");
assert(
executeWithEnvStdout === executeEnvValue,
`beacon execute(with env) stdout mismatch: expected '${executeEnvValue}', got '${executeWithEnvStdout}'`,
);
console.log("beacon execute rpc checks", {
transport: transport.name,
noEnvToken: executeNoEnvStdout,
envToken: executeWithEnvStdout,
});
const pwdQueued = await interactiveBeacon.pwd(120);
const pwd = await decodeBeaconTaskResponse(
client,
beacon.ID,
pwdQueued,
sliver.sliverpb.Pwd.decode,
`beacon pwd (${transport.name})`,
180,
);
assert(pwd.Path.trim().length > 0, "beacon pwd returned an empty path");
const lsQueued = await withRpcTimeout(BEACON_DOWNLOAD_RPC_TIMEOUT_SECONDS, `beacon ls queue (${transport.name})`, (signal) =>
client.rpc.ls(
{
Path: ".",
Request: {
Async: true,
Timeout: "180",
BeaconID: beacon.ID,
SessionID: "",
},
},
{ signal },
),
);
const ls = await decodeBeaconTaskResponse(
client,
beacon.ID,
lsQueued,
sliver.sliverpb.Ls.decode,
`beacon ls (${transport.name})`,
180,
);
assert(ls.Exists, `beacon ls reported missing path: ${ls.Path}`);
assert(ls.Files.length > 0, `beacon ls returned no files for path: ${ls.Path}`);
const psQueued = await interactiveBeacon.ps(false, 120);
const ps = await decodeBeaconTaskResponse(
client,
beacon.ID,
psQueued,
sliver.sliverpb.Ps.decode,
`beacon ps (${transport.name})`,
180,
);
assert(ps.Processes.length > 0, "beacon ps returned no processes");
const selfProcess = ps.Processes.find((proc) => proc.Pid === implantPid);
assert(selfProcess !== undefined, `beacon ps output missing implant process pid ${implantPid}`);
const ifconfigQueued = await interactiveBeacon.ifconfig(120);
const ifconfig = await decodeBeaconTaskResponse(
client,
beacon.ID,
ifconfigQueued,
sliver.sliverpb.Ifconfig.decode,
`beacon ifconfig (${transport.name})`,
180,
);
assert(ifconfig.NetInterfaces.length > 0, "beacon ifconfig returned no interfaces");
const lsFromPwdQueued = await withRpcTimeout(
BEACON_DOWNLOAD_RPC_TIMEOUT_SECONDS,
`beacon ls queue (pwd path, ${transport.name})`,
(signal) =>
client.rpc.ls(
{
Path: pwd.Path,
Request: {
Async: true,
Timeout: "180",
BeaconID: beacon.ID,
SessionID: "",
},
},
{ signal },
),
);
const lsFromPwd = await decodeBeaconTaskResponse(
client,
beacon.ID,
lsFromPwdQueued,
sliver.sliverpb.Ls.decode,
`beacon ls pwd path (${transport.name})`,
180,
);
assert(lsFromPwd.Exists, `beacon ls on pwd path reported missing path: ${lsFromPwd.Path}`);
console.log("beacon command checks", {
transport: transport.name,
pwd: pwd.Path,
lsPath: ls.Path,
lsCount: ls.Files.length,
lsFromPwdPath: lsFromPwd.Path,
lsFromPwdCount: lsFromPwd.Files.length,
psCount: ps.Processes.length,
selfProcess: {
pid: selfProcess.Pid,
executable: selfProcess.Executable,
},
ifaces: ifconfig.NetInterfaces.map((iface) => iface.Name),
});
const transferSourceDir = path.join(tempDir, "transfer-source");
const transferDownloadedDir = path.join(tempDir, "transfer-downloaded");
await Promise.all([
mkdir(transferSourceDir, { recursive: true }),
mkdir(transferDownloadedDir, { recursive: true }),
]);
remoteTransferDir = path.join(
pwd.Path,
`.sliver-e2e-beacon-${transport.name}-transfer-${Date.now()}-${randomInt(10_000, 99_999)}`,
);
const mkdirQueued = await interactiveBeacon.mkdir(remoteTransferDir, 120);
await decodeBeaconTaskResponse(
client,
beacon.ID,
mkdirQueued,
sliver.sliverpb.Mkdir.decode,
`beacon mkdir transfer dir (${transport.name})`,
180,
);
const transferSpecs = [
{ name: "tiny.bin", size: 127 },
{ name: "small.bin", size: 4099 },
{ name: "medium.bin", size: 131072 },
{ name: "large.bin", size: 1048576 },
];
for (const spec of transferSpecs) {
const sourcePath = path.join(transferSourceDir, spec.name);
const downloadedPath = path.join(transferDownloadedDir, spec.name);
const remotePath = path.join(remoteTransferDir, spec.name);
const sourceData = randomBytes(spec.size);
await writeFile(sourcePath, sourceData);
const sourceFileData = await readFile(sourcePath);
const sourceMd5 = md5Hex(sourceFileData);
const uploadQueued = await interactiveBeacon.upload(remotePath, sourceFileData, 180);
await decodeBeaconTaskResponse(
client,
beacon.ID,
uploadQueued,
sliver.sliverpb.Upload.decode,
`beacon upload ${spec.name} (${transport.name})`,
240,
);
const downloadQueued = await withRpcTimeout(
BEACON_DOWNLOAD_RPC_TIMEOUT_SECONDS,
`download queue ${spec.name} (${transport.name})`,
(signal) =>
client.rpc.download(
{
Path: remotePath,
Request: {
Async: true,
Timeout: "180",
BeaconID: beacon.ID,
SessionID: "",
},
},
{ signal },
),
);
const download = await decodeBeaconTaskResponse(
client,
beacon.ID,
downloadQueued,
sliver.sliverpb.Download.decode,
`beacon download ${spec.name} (${transport.name})`,
240,
);
const downloadedData = await decodeDownloadData(download);
const downloadedMd5 = md5Hex(downloadedData);
assert(
downloadedMd5 === sourceMd5,
`beacon md5 mismatch for ${spec.name}: source=${sourceMd5}, downloaded=${downloadedMd5}`,
);
assert(
downloadedData.equals(sourceFileData),
`beacon byte mismatch for ${spec.name}: sourceLength=${sourceFileData.length}, downloadedLength=${downloadedData.length}`,
);
await writeFile(downloadedPath, downloadedData);
const downloadedFileData = await readFile(downloadedPath);
const downloadedFileMd5 = md5Hex(downloadedFileData);
assert(
downloadedFileMd5 === sourceMd5,
`beacon saved downloaded file md5 mismatch for ${spec.name}: source=${sourceMd5}, saved=${downloadedFileMd5}`,
);
console.log("beacon file transfer verified", {
transport: transport.name,
file: spec.name,
size: spec.size,
md5: sourceMd5,
});
}
const info = await waitForBeacon(client, 60, (candidate) => candidate.ID === beacon.ID);
assert(info.ID === beacon.ID, "Beacon info did not return expected beacon id");
assert(info.OS.toLowerCase() === goos, `Beacon info OS mismatch: expected ${goos}, got ${info.OS}`);
assert(info.Arch.toLowerCase() === goarch, `Beacon info arch mismatch: expected ${goarch}, got ${info.Arch}`);
assert(
info.Transport.toLowerCase().includes(transport.expectedTransportToken),
`Expected beacon ${transport.name} transport, got ${info.Transport}`,
);
assert(
info.ActiveC2.toLowerCase().includes(transport.expectedActiveC2Token),
`Expected beacon ${transport.expectedActiveC2Token} c2, got ${info.ActiveC2}`,
);
assert(info.PID === implantPid, `Beacon PID mismatch: expected ${implantPid}, got ${info.PID}`);
printBeaconInfoSummary(info);
} finally {
if (interactiveBeacon && remoteTransferDir && beaconID) {
try {
const rmQueued = await interactiveBeacon.rm(remoteTransferDir, true, true, 120);
await decodeBeaconTaskResponse(
client,
beaconID,
rmQueued,
sliver.sliverpb.Rm.decode,
`beacon rm transfer dir (${transport.name})`,
180,
);
} catch (err) {
console.error(`failed to remove beacon remote transfer dir ${remoteTransferDir}`, err);
}
}
if (listenerJobId !== undefined) {
try {
await client.killJob(listenerJobId, 30);
} catch (err) {
console.error(`failed to stop listener job ${listenerJobId}`, err);
}
}
if (implantProc) {
await terminateChildProcess(implantProc);
if (implantOutput) {
const stdout = implantOutput.stdout().trim();
const stderr = implantOutput.stderr().trim();
if (stdout.length > 0) {
console.log("beacon implant stdout tail", stdout.split(/\r?\n/).slice(-10).join("\n"));
}
if (stderr.length > 0) {
console.log("beacon implant stderr tail", stderr.split(/\r?\n/).slice(-10).join("\n"));
}
}
}
if (tempDir) {
await rm(tempDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
}
}
}
async function main() {
if (!fs.existsSync(CONFIG_PATH)) {
console.error(`Missing config file: ${CONFIG_PATH}`);
process.exit(2);
}
const sliver = loadLocalSliverScript(REPO_ROOT);
const config = await sliver.ParseConfigFile(CONFIG_PATH);
const client = new sliver.SliverClient(config);
const goos = nodePlatformToGoOS(process.platform);
const goarch = nodeArchToGoArch(process.arch);
const mtlsHost = process.env.SLIVER_E2E_MTLS_HOST ?? "localhost";
const mtlsBindHost = process.env.SLIVER_E2E_MTLS_BIND_HOST ?? "127.0.0.1";
const mtlsPort = parsePort(process.env.SLIVER_E2E_MTLS_PORT ?? String(config.lport), "SLIVER_E2E_MTLS_PORT");
const httpHost = process.env.SLIVER_E2E_HTTP_HOST ?? "localhost";
const httpBindHost = process.env.SLIVER_E2E_HTTP_BIND_HOST ?? "127.0.0.1";
const httpPort = parsePort(process.env.SLIVER_E2E_HTTP_PORT ?? String(mtlsPort + 1), "SLIVER_E2E_HTTP_PORT");
const wgHost = process.env.SLIVER_E2E_WG_HOST ?? "localhost";
const wgBindHost = process.env.SLIVER_E2E_WG_BIND_HOST ?? "127.0.0.1";
const wgPort = parsePort(process.env.SLIVER_E2E_WG_PORT ?? String(httpPort + 1), "SLIVER_E2E_WG_PORT");
const wgNPort = parsePort(process.env.SLIVER_E2E_WG_NPORT ?? String(wgPort + 1), "SLIVER_E2E_WG_NPORT");
const wgKeyPort = parsePort(process.env.SLIVER_E2E_WG_KEYPORT ?? String(wgPort + 2), "SLIVER_E2E_WG_KEYPORT");
const uniquePorts = new Set<number>([mtlsPort, httpPort, wgPort, wgNPort, wgKeyPort]);
assert(uniquePorts.size === 5, "Transport listener ports must be unique");
const transportSpecs: BeaconTransportSpec[] = [
{
name: "mtls",
c2URL: `mtls://${mtlsHost}:${mtlsPort}`,
expectedTransportToken: "mtls",
expectedActiveC2Token: "mtls://",
bindHost: mtlsBindHost,
host: mtlsHost,
port: mtlsPort,
},
{
name: "http",
c2URL: `http://${httpHost}:${httpPort}`,
expectedTransportToken: "http",
expectedActiveC2Token: "http://",
bindHost: httpBindHost,
host: httpHost,
port: httpPort,
},
{
name: "wg",
c2URL: `wg://${wgHost}:${wgPort}`,
expectedTransportToken: "wg",
expectedActiveC2Token: "wg://",
bindHost: wgBindHost,
host: wgHost,
port: wgPort,
wgNPort,
wgKeyPort,
},
];
const overallTimeoutSeconds =
BEACON_PER_TRANSPORT_OVERALL_TIMEOUT_SECONDS * transportSpecs.length + 5 * 60;
const overallTimer = setTimeout(() => {
console.error(`Beacon e2e exceeded ${overallTimeoutSeconds}s overall timeout`);
process.exit(1);
}, overallTimeoutSeconds * 1000);
const existingBeaconIDs = new Set<string>();
await client.connect();
try {
const version = await client.getVersion();
console.log("version", `${version.Major}.${version.Minor}.${version.Patch}`, version.Commit);
const operators = await client.operators();
console.log("operators", operators.map((o) => o.Name));
const sessions = await client.sessions();
console.log("sessions", sessions.length);
const beacons = await client.beacons();
console.log("beacons", beacons.length);
for (const beacon of beacons) {
existingBeaconIDs.add(beacon.ID);
}
for (const transport of transportSpecs) {
await runBeaconTransportChecks(sliver, client, transport, goos, goarch, existingBeaconIDs);
}
} finally {
clearTimeout(overallTimer);
await client.disconnect();
}
}
void main().catch((err) => {
console.error(err);
process.exit(1);
});