BusyWork: Replacing Sleep with Real Work to Break Behavioral Detection
This post covers BusyWork, a Rust library that replaces sleep() calls with real, varied work. Malware and game cheats commonly use sleep loops to pace their activity, but sleeping is a behavioral signal that EDR products and anti-cheat engines detect. A thread that allocates memory, calls a few APIs, then sleeps for exactly 5 seconds, 10 times in a row, stands out in telemetry. BusyWork replaces the sleep with randomized task execution across seven categories, so each “pause” looks like genuine application activity: hashing data, enumerating files, querying the registry, making DNS lookups, or allocating and sorting memory.
The Problem with Sleep
When an implant or cheat needs to wait between actions (polling for commands, pacing injection attempts, throttling exfiltration), the standard approach is Sleep() or std::thread::sleep(). This creates two detection opportunities:
-
Timing primitives in the binary. Static analysis can flag the presence of
Duration,Instant,SystemTime, or calls toSleep/WaitForSingleObjectwith constant intervals. Sandbox detonation engines specifically look for long sleeps as an evasion indicator [1]. -
Behavioral patterns. A thread that repeatedly sleeps for similar intervals creates a distinctive cadence. EDR products that track thread state transitions will see a periodic sleep/wake pattern with little work in between [2]. Anti-cheat engines like EasyAntiCheat and BattlEye monitor thread behavior for exactly these patterns.
BusyWork eliminates both: no timing primitives appear in the compiled binary, and each “pause” executes genuinely different code that produces real API calls, memory operations, and I/O.
One-Line API, Randomized Internals
The public API is two functions and a builder:
// From: src/lib.rs
pub fn busywork(intensity: Intensity) {
BusyWork::new(intensity).run();
}
pub fn busywork_with(intensity: Intensity, categories: Categories) {
BusyWork::new(intensity).allow(categories).run();
}
A call to busywork(Intensity::Medium) selects ~5 random tasks from the 76 available, jitters their parameters by ±30%, and executes them. The next call picks different tasks with different parameters. No two invocations produce the same API call sequence, memory allocation pattern, or execution duration.
The builder provides finer control:
// From: src/builder.rs
pub struct BusyWork {
intensity: Intensity,
allow: Categories,
deny: Categories,
jitter: bool,
}
impl BusyWork {
pub fn new(intensity: Intensity) -> Self {
Self {
intensity,
allow: Categories::all(),
deny: Categories::empty(),
jitter: true,
}
}
pub fn allow(mut self, cats: Categories) -> Self {
self.allow = cats;
self
}
pub fn deny(mut self, cats: Categories) -> Self {
self.deny = cats;
self
}
pub fn jitter(mut self, enabled: bool) -> Self {
self.jitter = enabled;
self
}
pub fn run(&self) {
let effective = (self.allow & Categories::available()) & !self.deny;
dispatch::execute(self.intensity, effective, self.jitter);
}
}
The deny method is useful for excluding categories that might cause side effects in a specific context. Denying NETWORK prevents DNS lookups and HTTP requests; denying REGISTRY avoids Windows registry access. The Categories::available() method returns only the categories that were compiled in via Cargo features, so the binary only contains the code it actually uses.
Intensity Levels and Parameter Scaling
Each intensity level defines base parameters for task count, iteration count, buffer size, and call depth:
// From: src/intensity.rs
impl Intensity {
pub(crate) fn base_params(&self) -> IntensityParams {
match self {
Intensity::Low => IntensityParams {
task_count: 2,
iteration_count: 50,
buffer_size: 1024,
call_depth: 2,
},
Intensity::Medium => IntensityParams {
task_count: 5,
iteration_count: 500,
buffer_size: 16384,
call_depth: 4,
},
Intensity::High => IntensityParams {
task_count: 10,
iteration_count: 5000,
buffer_size: 262144,
call_depth: 8,
},
Intensity::Ultra => IntensityParams {
task_count: 20,
iteration_count: 50000,
buffer_size: 1048576,
call_depth: 16,
},
}
}
}
task_count controls how many tasks are selected per invocation. iteration_count is the inner loop count passed to each task. buffer_size determines allocation sizes. call_depth controls how many times compound operations (compress/decompress cycles, file reads, registry queries) repeat. The values scale exponentially: Ultra uses 10x the iterations and 64x the buffer size of Low.
Jitter: ±30% on Every Parameter
Every parameter is jittered before use. The jitter function applies a random factor between 0.7 and 1.3:
// From: src/jitter.rs
pub fn apply(base: usize, rng: &mut impl Rng) -> usize {
let factor: f64 = rng.gen_range(0.7..=1.3);
(base as f64 * factor).round().max(1.0) as usize
}
This means a Medium-intensity call with task_count: 5 might execute 4 or 7 tasks. A buffer size of 16384 might become 11469 or 21299. The jitter is applied independently to every parameter of every task, so even when the same task is selected twice in a row, it runs with different sizes and iteration counts.
The Dispatch Loop
The dispatcher ties together task selection, jitter, and execution:
// From: src/dispatch.rs
pub fn execute(intensity: Intensity, effective: Categories, jitter_enabled: bool) {
let mut rng = rand::thread_rng();
let all = tasks::all_tasks();
let eligible: Vec<_> = all
.iter()
.filter(|t| effective.contains(t.category))
.collect();
if eligible.is_empty() {
return;
}
let base = intensity.base_params();
let count = if jitter_enabled {
jitter::apply(base.task_count, &mut rng)
} else {
base.task_count
};
for _ in 0..count {
let task = eligible.choose(&mut rng).unwrap();
let params = TaskParams {
iterations: if jitter_enabled {
jitter::apply(base.iteration_count, &mut rng)
} else {
base.iteration_count
},
buffer_size: if jitter_enabled {
jitter::apply(base.buffer_size, &mut rng)
} else {
base.buffer_size
},
call_depth: if jitter_enabled {
jitter::apply(base.call_depth, &mut rng)
} else {
base.call_depth
},
};
(task.func)(¶ms, &mut rng);
}
}
Tasks are selected uniformly at random from the eligible pool. The function pointer dispatch ((task.func)(¶ms, &mut rng)) means the compiler cannot inline or devirtualize the calls, which prevents static analysis from predicting the execution path.
The Task Registry: 76 Tasks Across Seven Categories
Each task is a TaskDescriptor with a name, category, and function pointer:
// From: src/tasks/mod.rs
pub type TaskFn = fn(&TaskParams, &mut ThreadRng);
pub struct TaskDescriptor {
pub name: &'static str,
pub category: Categories,
pub func: TaskFn,
}
The seven categories are defined as bitflags, each gated behind a Cargo feature:
// From: src/categories.rs
bitflags! {
pub struct Categories: u32 {
const COMPUTE = 0b000_0001;
const MEMORY = 0b000_0010;
const FILESYSTEM = 0b000_0100;
const REGISTRY = 0b000_1000;
const WINAPI = 0b001_0000;
const NETWORK = 0b010_0000;
const CRYPTO = 0b100_0000;
}
}
All categories are enabled by default. Disabling a feature at compile time strips all code for that category from the binary, including its dependencies (windows crate, sha2, flate2, etc.).
Compute (14 tasks)
Pure CPU work: SHA-256 hash chains, MD5 hash chains, prime sieves, matrix multiplication, quicksort/bubblesort of random arrays, deflate compress/decompress cycles, Fibonacci sequences, XOR ciphers, Collatz sequences, string operations, bitwise operations, pi approximation via Leibniz series, and Heap’s permutation algorithm.
Every compute task uses std::hint::black_box to prevent the compiler from optimizing away the results:
// From: src/tasks/compute.rs
fn hash_sha256_loop(params: &TaskParams, rng: &mut ThreadRng) {
let mut data = vec![0u8; 64];
rng.fill_bytes(&mut data);
for _ in 0..params.iterations {
let mut hasher = Sha256::new();
hasher.update(&data);
let result = hasher.finalize();
data[..32].copy_from_slice(&result);
}
black_box(&data);
}
Memory (10 tasks)
Allocation and data manipulation: alloc/touch/free cycles (touching every page), memcpy chains, sort-in-place, pattern fill and verify, heap fragmentation (allocate many small buffers, drop 50% at random, reallocate to fill gaps), ring buffer operations, repeated binary search, buffer reversal, interleave two buffers, and scatter/gather with random indices.
The heap fragmentation task is particularly useful for breaking memory allocation patterns:
// From: src/tasks/memory.rs
fn heap_fragmentation(params: &TaskParams, rng: &mut ThreadRng) {
let count = params.iterations.min(500);
let mut buffers: Vec<Option<Vec<u8>>> = Vec::with_capacity(count);
for _ in 0..count {
let size = rng.gen_range(16..=4096);
let mut buf = vec![0u8; size];
rng.fill_bytes(&mut buf);
buffers.push(Some(buf));
}
for slot in buffers.iter_mut() {
if rng.gen_bool(0.5) {
*slot = None;
}
}
let mut total_bytes = 0usize;
for slot in buffers.iter_mut() {
if slot.is_none() {
let size = rng.gen_range(16..=4096);
let mut buf = vec![0u8; size];
rng.fill_bytes(&mut buf);
total_bytes += size;
*slot = Some(buf);
} else {
total_bytes += slot.as_ref().unwrap().len();
}
}
black_box(total_bytes);
}
Filesystem (12 tasks)
Read-only directory enumeration and file reads: C:\Windows\System32, temp directory, Program Files, C:\Windows\Fonts, C:\Windows\System32\drivers, C:\Windows\Prefetch, C:\Windows\Logs (with one level of subdirectory recursion), user profile directory. File reads target hosts, services, protocol, win.ini, system.ini. Stat operations target common DLLs (kernel32.dll, ntdll.dll, user32.dll, etc., 20 in total).
All filesystem operations are read-only. No files are created, modified, or deleted.
Registry (10 tasks)
Read-only registry queries via the windows crate: installed software enumeration (HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall), system info (CurrentVersion), services enumeration, timezone info, environment variables, network configuration (TCP/IP parameters and interfaces), hardware info (CPU processor name, frequency, vendor), font list, startup programs (Run keys in both HKLM and HKCU), and file associations from HKEY_CLASSES_ROOT.
Registry paths are encoded as compile-time const UTF-16 arrays to avoid runtime string conversion:
// From: src/tasks/registry.rs
const PATH: &[u16] = &{
let mut arr = [0u16; 60];
let bytes = b"SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\0";
let mut i = 0;
while i < bytes.len() {
arr[i] = bytes[i] as u16;
i += 1;
}
arr
};
Windows API (16 tasks)
Calls through the windows crate: window enumeration via EnumWindows, process enumeration via CreateToolhelp32Snapshot, system info via GetSystemInfo and GlobalMemoryStatusEx, clipboard read, system metrics (GetSystemMetrics with 10 different metric indices), foreground window info, cursor position, desktop window dimensions, logical drives and drive types, volume information, disk free space, file find patterns (C:\Windows\System32\*.dll), module handle resolution for 12 common DLLs, virtual memory region enumeration via VirtualQuery, system/Windows directory queries, and process/thread ID queries.
The memory walk via VirtualQuery iterates through the process’s entire address space, reading region metadata:
// From: src/tasks/winapi_tasks.rs
fn virtual_query_memory(params: &TaskParams, _rng: &mut ThreadRng) {
unsafe {
let mut addr: usize = 0;
let info_size = std::mem::size_of::<MEMORY_BASIC_INFORMATION>();
for _ in 0..params.iterations {
let mut info = MEMORY_BASIC_INFORMATION::default();
let result = VirtualQuery(
Some(addr as *const std::ffi::c_void),
&mut info,
info_size,
);
if result == 0 {
break;
}
black_box(info.BaseAddress);
black_box(info.RegionSize);
black_box(info.State);
black_box(info.Type);
let region_size = if info.RegionSize == 0 { 4096 } else { info.RegionSize };
addr = match addr.checked_add(region_size) {
Some(a) => a,
None => break,
};
}
}
}
Network (7 tasks)
DNS lookups against 24 common domains, HTTP GET requests to public endpoints (httpbin.org, ip-api.com, ifconfig.me, etc.), NTP queries to 7 time servers, HTTP HEAD requests, TCP connect probes (handshake only, immediate close), DNS resolution with varied port numbers, and HTTP POST/PUT/PATCH requests to httpbin.org with random hex payloads.
All network operations use 3-second socket timeouts set via raw setsockopt calls to avoid hanging on unreachable hosts:
// From: src/tasks/network.rs
fn set_socket_timeouts(socket: &impl AsRawSocket, ms: u32) {
let raw = socket.as_raw_socket() as usize;
let val = ms.to_ne_bytes();
unsafe {
setsockopt(raw, SOL_SOCKET, SO_RCVTIMEO, val.as_ptr(), 4);
setsockopt(raw, SOL_SOCKET, SO_SNDTIMEO, val.as_ptr(), 4);
}
}
Crypto (7 tasks)
Windows BCrypt API operations: random byte generation via BCryptGenRandom with the system-preferred RNG, SHA-256 hashing via BCryptCreateHash/BCryptHashData/BCryptFinishHash, SHA-512, MD5, SHA-1, AES-256 encryption with random keys, and random number generation using multiple algorithm providers (RNG, FIPS186DSARNG, DUALECRNG).
Feature Flags and Binary Size
Each category is a Cargo feature. The Cargo.toml defines optional dependencies per category:
# From: Cargo.toml
[features]
default = [
"cat-compute",
"cat-memory",
"cat-filesystem",
"cat-registry",
"cat-winapi",
"cat-network",
"cat-crypto",
]
cat-compute = ["dep:sha2", "dep:md-5", "dep:flate2"]
cat-memory = []
cat-filesystem = []
cat-registry = ["dep:windows", "windows/Win32_System_Registry", "windows/Win32_Foundation"]
cat-winapi = ["dep:windows", "windows/Win32_UI_WindowsAndMessaging", ...]
cat-network = []
cat-crypto = ["dep:windows", "windows/Win32_Security_Cryptography", "windows/Win32_Foundation"]
A build with only cat-compute and cat-memory produces a binary with no Windows crate dependency at all. The cat-filesystem and cat-network categories use only Rust’s standard library. This modularity lets the user balance task variety against binary size and dependency footprint.
Why This Works Against Behavioral Detection
The combination of techniques targets multiple layers of detection:
No timing signature. There are no Duration, Instant, SystemTime, or Sleep calls in the library. The execution time is a natural consequence of the work performed, not a configured interval. A thread profiler will see CPU-bound computation, I/O operations, and API calls rather than a sleep state.
Different code path every invocation. With 76 tasks, even at Medium intensity (5 tasks per call), there are over 2.5 million possible task combinations. Each task also runs with jittered parameters, so the same task produces different iteration counts, buffer sizes, and allocation patterns.
Real API calls to real targets. The filesystem tasks read actual system directories. The registry tasks query actual registry keys. The network tasks perform real DNS lookups and HTTP requests. These are the same API calls that legitimate applications make constantly. An EDR that flags ReadDirectoryChangesW or RegOpenKeyExW would produce overwhelming false positives on every Windows system.
Function pointer dispatch. Tasks are called through function pointers stored in a Vec<TaskDescriptor>. The compiler cannot see through the indirection to predict which task will run, which prevents both devirtualization (a compiler optimization) and static control-flow analysis (a reverse engineering technique).
black_box against dead code elimination. Every task result is passed through std::hint::black_box, which is a compiler intrinsic that prevents optimization of the computation [3]. Without it, the compiler could determine that the SHA-256 hash chain result is never used and eliminate the entire loop.
Limitations and Detection Surface
Network traffic is distinctive. The HTTP requests to httpbin.org, ip-api.com, and NTP servers are legitimate, but a process that normally does not make network requests suddenly issuing DNS lookups and HTTP GETs is anomalous. Restricting categories to COMPUTE | MEMORY avoids this but reduces task variety.
Registry and filesystem access is read-only but observable. Enumerating HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall or walking C:\Windows\System32 is normal for many applications, but an EDR that correlates these reads with the process identity might flag a game overlay or injected DLL performing these operations.
Execution duration is non-deterministic. Unlike sleep(5000), BusyWork cannot guarantee a specific pause duration. Low intensity averages ~0.19ms, Medium ~4.5ms, High ~1.5 seconds, Ultra ~58 seconds. The actual duration depends on hardware, system load, and random task selection. Callers that need a minimum pause duration must measure elapsed time and loop.
The rand crate is a dependency. The randomization relies on rand::thread_rng(), which is a cryptographically secure RNG on most platforms. The rand crate is widely used and not suspicious by itself, but its presence in a binary alongside Windows API calls and no apparent UI is a soft indicator.
Drafted with LLM assistance from the BusyWork source code, reviewed and verified against the actual implementation.
References
[1] MITRE ATT&CK, “T1497.003: Virtualization/Sandbox Evasion: Time Based Evasion”, attack.mitre.org
[2] Microsoft, “Event Tracing for Windows (ETW): Thread State”, learn.microsoft.com
[3] Rust documentation, “std::hint::black_box”, doc.rust-lang.org