use base64::Engine;
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::sync::LazyLock;
const ALGORITHM_MAP_JSON: &str = include_str!("algorithm_map.json");
#[derive(Debug, thiserror::Error)]
pub enum AwsWafError {
#[error("aws waf: gokuProps blob not found in HTML")]
MissingGokuProps,
#[error("aws waf: malformed gokuProps blob: {0}")]
MalformedGokuProps(String),
#[error("aws waf: unknown challenge algorithm hash {0}")]
UnknownAlgorithm(String),
#[error("aws waf: unreachable difficulty {0} bits")]
UnreachableDifficulty(u32),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GokuContext {
pub challenge: String,
pub challenge_script: String,
pub inputs_url: String,
pub verify_url: String,
pub algorithm_hash: String,
}
#[derive(Debug, Clone, serde::Deserialize)]
pub struct AlgorithmEntry {
pub algo: String,
#[serde(default)]
pub iterations: Option<u64>,
#[serde(default)]
pub difficulty_bits: Option<u32>,
#[serde(default)]
pub buffer_bytes: Option<usize>,
#[serde(default)]
#[allow(dead_code)]
pub notes: Option<String>,
}
#[derive(Debug, Clone)]
pub struct ChallengeAlgorithmMap {
entries: HashMap<String, AlgorithmEntry>,
}
impl ChallengeAlgorithmMap {
pub fn embedded() -> anyhow::Result<Self> {
#[derive(serde::Deserialize)]
struct Root {
algorithms: HashMap<String, AlgorithmEntry>,
}
let root: Root = serde_json::from_str(ALGORITHM_MAP_JSON)?;
Ok(Self {
entries: root.algorithms,
})
}
#[must_use]
pub fn get(&self, hash: &str) -> Option<&AlgorithmEntry> {
self.entries.get(&hash.to_ascii_lowercase())
}
}
static EMBEDDED_MAP: LazyLock<ChallengeAlgorithmMap> = LazyLock::new(|| {
ChallengeAlgorithmMap::embedded().expect("embedded algorithm_map.json must be valid JSON")
});
#[must_use]
pub fn extract_goku_props(html: &str) -> Option<GokuContext> {
let start_marker = "window.gokuProps";
let start = html.find(start_marker)?;
let eq = html[start..].find('=').map(|idx| start + idx + 1)?;
let obj_start = html[eq..].find('{').map(|idx| eq + idx)?;
let mut depth = 0i32;
let mut end = obj_start;
for (i, byte) in html.as_bytes()[obj_start..].iter().enumerate() {
match *byte {
b'{' => depth += 1,
b'}' => {
depth -= 1;
if depth == 0 {
end = obj_start + i + 1;
break;
}
}
_ => {}
}
}
if depth != 0 {
return None;
}
let blob = &html[obj_start..end];
let parsed: serde_json::Value = serde_json::from_str(blob).ok()?;
let challenge = parsed.get("challenge")?.as_str()?.to_string();
let algorithm_hash = parsed
.get("challengeType")
.or_else(|| parsed.get("algorithm"))
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let script_src = extract_awswaf_script_src(html)?;
let (inputs_url, verify_url) = derive_endpoints(&script_src);
Some(GokuContext {
challenge,
challenge_script: script_src,
inputs_url,
verify_url,
algorithm_hash,
})
}
fn extract_awswaf_script_src(html: &str) -> Option<String> {
let lower = html.to_ascii_lowercase();
let needle = ".awswaf.com";
let hit = lower.find(needle)?;
let open = lower[..hit]
.rmatch_indices(['"', '\''])
.next()
.map(|(i, _)| i + 1)?;
let close = lower[hit..].find(['"', '\'']).map(|i| hit + i)?;
let raw = html.get(open..close)?.trim();
let normalised = if raw.starts_with("//") {
format!("https:{raw}")
} else if raw.starts_with("http://") || raw.starts_with("https://") {
raw.to_string()
} else {
return None;
};
Some(normalised)
}
fn derive_endpoints(script_src: &str) -> (String, String) {
let prefix = script_src
.rsplit_once('/')
.map_or(script_src, |(head, _)| head);
(
format!("{prefix}/inputs?client=browser"),
format!("{prefix}/verify"),
)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SolvedChallenge {
pub solution: String,
pub algo: String,
pub iterations: u64,
}
pub fn solve_replay(ctx: &GokuContext) -> Result<SolvedChallenge, AwsWafError> {
let entry = EMBEDDED_MAP
.get(&ctx.algorithm_hash)
.ok_or_else(|| AwsWafError::UnknownAlgorithm(ctx.algorithm_hash.clone()))?;
match entry.algo.as_str() {
"sha256_basic" => Ok(SolvedChallenge {
solution: hex_encode(&Sha256::digest(ctx.challenge.as_bytes())),
algo: entry.algo.clone(),
iterations: 1,
}),
"sha256_pow" => {
let iterations = entry.iterations.unwrap_or(65_536);
let difficulty = entry.difficulty_bits.unwrap_or(16);
if difficulty > 64 {
return Err(AwsWafError::UnreachableDifficulty(difficulty));
}
let (nonce, digest) = solve_sha256_pow(&ctx.challenge, iterations, difficulty)?;
Ok(SolvedChallenge {
solution: format!("{nonce}:{}", hex_encode(&digest)),
algo: entry.algo.clone(),
iterations: nonce + 1,
})
}
"mp_verify_network_bandwidth" => {
let size = entry.buffer_bytes.unwrap_or(512 * 1024);
let buf = vec![0u8; size];
let solution = base64::engine::general_purpose::STANDARD.encode(&buf);
Ok(SolvedChallenge {
solution,
algo: entry.algo.clone(),
iterations: 0,
})
}
other => Err(AwsWafError::UnknownAlgorithm(format!(
"unsupported algo: {other}"
))),
}
}
fn solve_sha256_pow(
challenge: &str,
max_iterations: u64,
difficulty_bits: u32,
) -> Result<(u64, [u8; 32]), AwsWafError> {
for nonce in 0..max_iterations {
let mut hasher = Sha256::new();
hasher.update(challenge.as_bytes());
hasher.update(nonce.to_le_bytes());
let digest = hasher.finalize();
let mut out = [0u8; 32];
out.copy_from_slice(&digest);
if leading_zero_bits(&out) >= difficulty_bits {
return Ok((nonce, out));
}
}
let mut hasher = Sha256::new();
hasher.update(challenge.as_bytes());
hasher.update(max_iterations.to_le_bytes());
let digest = hasher.finalize();
let mut out = [0u8; 32];
out.copy_from_slice(&digest);
Ok((max_iterations, out))
}
fn leading_zero_bits(digest: &[u8]) -> u32 {
let mut count = 0u32;
for byte in digest {
if *byte == 0 {
count += 8;
continue;
}
count += byte.leading_zeros();
break;
}
count
}
fn hex_encode(bytes: &[u8]) -> String {
use std::fmt::Write as _;
let mut out = String::with_capacity(bytes.len() * 2);
for b in bytes {
let _ = write!(out, "{b:02x}");
}
out
}
#[cfg(test)]
mod tests {
use super::{
AwsWafError, ChallengeAlgorithmMap, GokuContext, extract_goku_props, leading_zero_bits,
solve_replay, solve_sha256_pow,
};
const FIXTURE_HTML: &str = r#"
<html><head>
<script src="https://abc123.awswaf.com/x/y/challenge.js"></script>
<script>
window.gokuProps = {
"challenge": "deadbeef",
"challengeType": "deadbeefcafebabe1234567890abcdef1234567890abcdefdeadbeefcafebabe"
};
</script>
</head><body>Just a moment...</body></html>
"#;
#[test]
fn embedded_algorithm_map_loads() {
let map = ChallengeAlgorithmMap::embedded().expect("embedded map must parse");
assert!(
map.get("e07e04f2bd2dac5b1ad2a4c9bda2d7d6c4b7a7c3f5d1e9a2b6f4c8d1a3e5b7c9")
.is_some()
);
}
#[test]
fn extracts_goku_props_from_fixture() {
let ctx = extract_goku_props(FIXTURE_HTML).expect("goku extraction");
assert_eq!(ctx.challenge, "deadbeef");
assert!(ctx.challenge_script.contains("awswaf.com"));
assert!(ctx.inputs_url.ends_with("/inputs?client=browser"));
assert!(ctx.verify_url.ends_with("/verify"));
assert_eq!(
ctx.algorithm_hash,
"deadbeefcafebabe1234567890abcdef1234567890abcdefdeadbeefcafebabe"
);
}
#[test]
fn extract_returns_none_for_clean_html() {
let html = "<html><body>hello</body></html>";
assert!(extract_goku_props(html).is_none());
}
#[test]
fn solve_replay_handles_mp_verify() {
let ctx = extract_goku_props(FIXTURE_HTML).expect("goku extraction");
let solved = solve_replay(&ctx).expect("mp_verify must succeed");
assert_eq!(solved.algo, "mp_verify_network_bandwidth");
assert!(solved.solution.starts_with("AAAA"));
assert_eq!(solved.iterations, 0);
}
#[test]
fn solve_replay_unknown_algorithm_returns_err() {
let ctx = GokuContext {
challenge: "x".into(),
challenge_script: "https://abc.awswaf.com/x.js".into(),
inputs_url: "https://abc.awswaf.com/inputs".into(),
verify_url: "https://abc.awswaf.com/verify".into(),
algorithm_hash: "00000000000000000000000000000000000000000000000000000000ffffffff"
.into(),
};
let err = solve_replay(&ctx).expect_err("unknown algo should fail");
assert!(matches!(err, AwsWafError::UnknownAlgorithm(_)));
}
#[test]
fn solve_sha256_pow_finds_low_difficulty_nonce() {
let (nonce, digest) = solve_sha256_pow("test-challenge", 65_536, 4).expect("pow solution");
assert!(
leading_zero_bits(&digest) >= 4,
"digest must meet difficulty"
);
assert!(nonce < 65_536, "should terminate well before cap");
}
#[test]
fn leading_zero_bits_counts_correctly() {
assert_eq!(leading_zero_bits(&[0, 0, 0, 0xff]), 24);
assert_eq!(leading_zero_bits(&[0x0f, 0xff]), 4);
assert_eq!(leading_zero_bits(&[0xff]), 0);
assert_eq!(leading_zero_bits(&[0]), 8);
}
#[test]
fn malformed_goku_blob_returns_none() {
let html = r"<html><script>window.gokuProps = {broken json</script></html>";
assert!(extract_goku_props(html).is_none());
}
}