diff --git a/e2e/run.mjs b/e2e/run.mjs index 4c8f39a..53e25e3 100644 --- a/e2e/run.mjs +++ b/e2e/run.mjs @@ -356,6 +356,14 @@ async function main() { if (!Number.isInteger(lport) || lport <= 0 || lport > 65535) { throw new Error(`Invalid SLIVER_E2E_LPORT value: ${process.env.SLIVER_E2E_LPORT}`); } + const mtlsBindHost = process.env.SLIVER_E2E_MTLS_BIND_HOST || daemonHost; + const mtlsHost = process.env.SLIVER_E2E_MTLS_HOST || operatorHost; + const mtlsPort = process.env.SLIVER_E2E_MTLS_PORT + ? Number.parseInt(process.env.SLIVER_E2E_MTLS_PORT, 10) + : await allocateFreePort(mtlsBindHost); + if (!Number.isInteger(mtlsPort) || mtlsPort <= 0 || mtlsPort > 65535) { + throw new Error(`Invalid SLIVER_E2E_MTLS_PORT value: ${process.env.SLIVER_E2E_MTLS_PORT}`); + } const operatorName = process.env.SLIVER_E2E_OPERATOR || "e2e"; @@ -385,6 +393,8 @@ async function main() { log(`Using isolated test root: ${testRoot}`); log(`Using daemon listener: ${daemonHost}:${lport}`); log(`Using operator config endpoint: ${operatorHost}:${lport}`); + log(`Using mTLS listener bind: ${mtlsBindHost}:${mtlsPort}`); + log(`Using implant mTLS endpoint: ${mtlsHost}:${mtlsPort}`); await ensureSliverServerBuilt(sharedEnv); @@ -456,6 +466,9 @@ async function main() { ...sharedEnv, SLIVER_E2E: "1", SLIVER_CONFIG_FILE: operatorConfigPath, + SLIVER_E2E_MTLS_BIND_HOST: mtlsBindHost, + SLIVER_E2E_MTLS_HOST: mtlsHost, + SLIVER_E2E_MTLS_PORT: String(mtlsPort), }; if (process.env.SLIVER_WAIT_TASKS) { testEnv.SLIVER_WAIT_TASKS = process.env.SLIVER_WAIT_TASKS; diff --git a/e2e/status.ts b/e2e/status.ts index 0ede613..7a11a86 100644 --- a/e2e/status.ts +++ b/e2e/status.ts @@ -1,7 +1,13 @@ 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, rm, writeFile, chmod } from "node:fs/promises"; +import { randomInt } from "node:crypto"; type SliverScriptModule = typeof import(".."); +type SliverClientInstance = InstanceType; +type SessionInfo = Awaited>[number]; function findRepoRoot(startDir: string): string | undefined { let dir = path.resolve(startDir); @@ -36,6 +42,118 @@ function loadLocalSliverScript(repoRoot: string): SliverScriptModule { const REPO_ROOT = guessRepoRoot(); const CONFIG_PATH = process.env.SLIVER_CONFIG_FILE ?? path.join(REPO_ROOT, "localhost.cfg"); const WAIT_TASKS = process.env.SLIVER_WAIT_TASKS === "1"; +const SESSION_TIMEOUT_SECONDS = 180; +const DEFAULT_HTTP_C2_PROFILE = "default"; + +function assert(condition: unknown, message: string): asserts condition { + if (!condition) { + throw new Error(message); + } +} + +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 { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function waitForSession( + client: SliverClientInstance, + timeoutSeconds: number, + predicate: (session: SessionInfo) => boolean, +): Promise { + const deadline = Date.now() + timeoutSeconds * 1000; + while (Date.now() < deadline) { + const sessions = await client.sessions(30); + const match = sessions.find(predicate); + if (match) { + return match; + } + await sleep(1000); + } + throw new Error(`Timed out waiting for session (${timeoutSeconds}s)`); +} + +function waitForChildExit(child: ChildProcess): Promise { + return new Promise((resolve) => { + child.once("exit", (code) => resolve(code)); + }); +} + +async function terminateChildProcess(child: ChildProcess): Promise { + 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 printInfoSummary(session: SessionInfo): void { + console.log("session info", { + id: session.ID, + name: session.Name, + hostname: session.Hostname, + username: session.Username, + os: session.OS, + arch: session.Arch, + transport: session.Transport, + activeC2: session.ActiveC2, + pid: session.PID, + remoteAddress: session.RemoteAddress, + }); +} async function main() { if (!fs.existsSync(CONFIG_PATH)) { @@ -46,6 +164,21 @@ async function main() { 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 = Number.parseInt(process.env.SLIVER_E2E_MTLS_PORT ?? String(config.lport), 10); + + assert(Number.isInteger(mtlsPort) && mtlsPort > 0 && mtlsPort <= 65535, `Invalid mtls port: ${mtlsPort}`); + const c2URL = `mtls://${mtlsHost}:${mtlsPort}`; + const existingSessionIds = new Set(); + + let tempDir: string | undefined; + let implantPath: string | undefined; + let implantProc: ChildProcess | undefined; + let implantOutput: ReturnType | undefined; + let mtlsJobId: number | undefined; await client.connect(); try { @@ -57,10 +190,82 @@ async function main() { const sessions = await client.sessions(); console.log("sessions", sessions.length); + for (const session of sessions) { + existingSessionIds.add(session.ID); + } const beacons = await client.beacons(); console.log("beacons", beacons.length); + const mtlsListener = await client.startMTLSListener(mtlsBindHost, mtlsPort, 60); + mtlsJobId = mtlsListener.JobID; + console.log("mtls listener started", { + bindHost: mtlsBindHost, + host: mtlsHost, + port: mtlsPort, + jobId: mtlsJobId, + }); + + const implantName = `e2e-session-${goos}-${goarch}-${Date.now()}`; + console.log("generate implant", { implantName, goos, goarch, c2URL }); + const implantConfig = sliver.clientpb.ImplantConfig.create({ + GOOS: goos, + GOARCH: goarch, + C2: [{ URL: c2URL }], + HTTPC2ConfigName: DEFAULT_HTTP_C2_PROFILE, + Debug: false, + ObfuscateSymbols: false, + IsBeacon: false, + IncludeMTLS: true, + Format: sliver.clientpb.OutputFormat.EXECUTABLE, + IsSharedLib: false, + IsService: false, + IsShellcode: false, + }); + const generated = await client.generate(implantConfig, 300); + assert(generated !== undefined, "Generate returned no file"); + assert(generated.Data.length > 0, "Generated implant file is empty"); + + tempDir = await mkdtemp(path.join(os.tmpdir(), "sliver-script-implant-")); + 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 implant process"); + console.log("implant started", { path: implantPath, pid: implantPid }); + + const session = await waitForSession( + client, + SESSION_TIMEOUT_SECONDS, + (candidate) => + !existingSessionIds.has(candidate.ID) && + candidate.OS.toLowerCase() === goos && + candidate.Arch.toLowerCase() === goarch, + ); + console.log("session created", { id: session.ID, pid: session.PID, transport: session.Transport }); + + const interactiveSession = client.interactSession(session.ID); + const nonce = randomInt(1, 2_000_000_000); + const ping = await interactiveSession.ping(nonce, 60); + assert(ping.Nonce === nonce, `Session ping nonce mismatch: expected ${nonce}, got ${ping.Nonce}`); + + const info = await waitForSession(client, 30, (candidate) => candidate.ID === session.ID); + assert(info.ID === session.ID, "Session info did not return expected session id"); + assert(info.OS.toLowerCase() === goos, `Session info OS mismatch: expected ${goos}, got ${info.OS}`); + assert(info.Arch.toLowerCase() === goarch, `Session info arch mismatch: expected ${goarch}, got ${info.Arch}`); + assert(info.Transport.toLowerCase().includes("mtls"), `Expected mtls transport, got ${info.Transport}`); + assert(info.ActiveC2.toLowerCase().includes("mtls://"), `Expected mtls c2, got ${info.ActiveC2}`); + assert(info.PID === implantPid, `Session PID mismatch: expected ${implantPid}, got ${info.PID}`); + printInfoSummary(info); + if (beacons.length > 0) { const beacon = client.interactBeacon(beacons[0].ID); const task = await beacon.lsTask("."); @@ -76,6 +281,29 @@ async function main() { } } } finally { + if (mtlsJobId !== undefined) { + try { + await client.killJob(mtlsJobId, 30); + } catch (err) { + console.error(`failed to stop mtls listener job ${mtlsJobId}`, err); + } + } + if (implantProc) { + await terminateChildProcess(implantProc); + if (implantOutput) { + const stdout = implantOutput.stdout().trim(); + const stderr = implantOutput.stderr().trim(); + if (stdout.length > 0) { + console.log("implant stdout tail", stdout.split(/\r?\n/).slice(-10).join("\n")); + } + if (stderr.length > 0) { + console.log("implant stderr tail", stderr.split(/\r?\n/).slice(-10).join("\n")); + } + } + } + if (tempDir) { + await rm(tempDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + } await client.disconnect(); } }