# Solving Anubis's SHA-256 Proof-of-Work Challenge

> Source: <https://gist.github.com/RomySaputraSihananda/3a2be0ba02b37c98c58efc9fcdd62f4c>
> Published: 2026-10-09 11:47:34+00:00

Anubis (`TecharoHQ/anubis`) is an open-source reverse-proxy anti-bot gate an increasing number of sites put in front of themselves (Startpage among them). When it decides to challenge a client, it serves a page embedding a PoW puzzle that the browser's JS solves in a Web Worker, then replays the answer as a query string against a fixed API path. Replicating that server-side unlocks the real page instead of the "Just a moment..." wait screen — no headless browser needed.

Everything below is derived directly from Anubis's own public source (`lib/challenge/proofofwork/proofofwork.go` for the server-side validator, `web/js/worker/sha256.ts` and `wasm/pow/sha256/src/lib.rs` for the two reference client implementations, which agree exactly) — not reverse-engineered from obfuscated code.

```
HTTP 200
Content-Type: text/html

<!doctype html>
<html lang="en">
  <head>
    <script id="anubis_version" type="application/json">"v1.26.4"</script>
    <script id="anubis_challenge" type="application/json">{"rules":{"algorithm":"fast","difficulty":6},"challenge":{"issuedAt":"2026-10-09T10:58:18.167975774Z","metadata":{"User-Agent":"...","X-Real-Ip":"..."},"id":"01a12050-55b7-7ed8-ac01-8ca29fcefe62","method":"fast","randomData":"da5eb566735ccae8db7d506e96d18dbfb6790c4bcde8600cf0cd21e9e5c71d7ccd74745509dbfb71c97a74d33f5e4bab60ee58dd704ce318322516afbe9d7272","policyRuleHash":"ac980f49c4d35fab","difficulty":6,"spent":false}}</script>
    <script id="anubis_base_prefix" type="application/json">""</script>
    <script id="anubis_public_url" type="application/json">""</script>
    ...
  </head>
  ...
</html>
```

The response also sets a `Set-Cookie` header — a verification cookie whose name is deployment-specific (Anubis's own default differs from what a site may configure; Startpage uses `spchal-cookie-verification=<uuid>`). **This cookie must be captured and replayed on the solve request**, or the server rejects it outright.

The `#anubis_challenge` element's text content, parsed as JSON:

```
{
  "rules": { "algorithm": "fast", "difficulty": 6 },
  "challenge": {
    "id": "<uuid>",
    "randomData": "<hex string, ~128 chars>",
    "difficulty": 6
  }
}
```

The task: find the smallest non-negative integer `nonce` such that

```
SHA256(randomData_as_utf8_bytes + nonce.to_string().as_bytes())
```

— hex-encoded — has `difficulty` leading `'0'` hex digits. That's `difficulty / 2` leading zero bytes, plus one more leading-zero nibble if `difficulty` is odd (checking raw digest bytes rather than the hex string avoids re-encoding on every attempt). At `difficulty = 6`, the winning `nonce` typically lands under 1000 — the whole search takes low single-digit milliseconds.

A plain `GET` (not a POST — the browser does `window.location.replace(...)`, so query params alone are enough) to a fixed path:

```
GET {origin}/.within.website/x/cmd/anubis/api/pass-challenge
    ?id=<challenge.id>
    &response=<hex_hash>
    &nonce=<nonce>
    &redir=<original request URL, url-encoded>
    &elapsedTime=<ms spent solving>
```

— with the challenge page's `Set-Cookie` attached. The response is either the real page directly or a redirect to it; `base_prefix`/` public_url` are empty in practice, so `redir` is just the exact original URL and the path above is used unprefixed off the origin's root.

```
use sha2::{Digest, Sha256};

/// Returns (nonce, hex_hash).
fn solve_anubis_pow(random_data: &str, difficulty: usize) -> (u64, String) {
    let zero_bytes = difficulty / 2;
    let odd_nibble = difficulty % 2 != 0;
    let mut nonce: u64 = 0;
    loop {
        let mut hasher = Sha256::new();
        hasher.update(random_data.as_bytes());
        hasher.update(nonce.to_string().as_bytes());
        let digest = hasher.finalize();

        let leading_zero_bytes_ok = digest[..zero_bytes].iter().all(|&b| b == 0);
        let next_nibble_ok = !odd_nibble || (digest[zero_bytes] >> 4) == 0;

        if leading_zero_bytes_ok && next_nibble_ok {
            let hex_hash = digest.iter().map(|b| format!("{b:02x}")).collect::<String>();
            return (nonce, hex_hash);
        }
        nonce += 1;
    }
}
```

Submission, with any HTTP client carrying a cookie jar (`reqwest`/` rquest` with `.cookie_store(true)`):

```
let (nonce, response_hash) = solve_anubis_pow(&challenge.random_data, rules.difficulty);

let pass_response = client
    .get("https://TARGET/.within.website/x/cmd/anubis/api/pass-challenge")
    .query(&[
        ("id", challenge.id.as_str()),
        ("response", response_hash.as_str()),
        ("nonce", &nonce.to_string()),
        ("redir", original_request_url.as_str()),
        ("elapsedTime", &elapsed_ms.to_string()),
    ])
    .send()
    .await?;
```

**Gotcha**: if this runs inside a shared async codebase, don't hold an HTML-parser `Document`/` Selection` in scope across the `.await` on the solve-submit request. Some HTML parsers (e.g. `nipper`, built on `tendril`) aren't `Send` — a value merely left in scope at an `.await` point, even unused, poisons the whole generated future's `Send`-ness. Scope the parsing step in a block that ends before the await.
