A pure-Go Arkose Labs / FunCaptcha token solver. It builds a real-browser BDA fingerprint,
encrypts it with the site's RSA-OAEP + AES-GCM envelope, and posts it to
/fc/gt2/public_key/{pk} to obtain a suppressed (sup=1) token — no headless browser, no
Node sandbox, no PoW step. One HTTP round-trip, ~0.5s per token.
Works with any Arkose site: you provide the site key, the RSA public key, and the verify host, and the solver does the rest.
GET /token -> {"token":"98718c6b...|r=eu-west-1|...|sup=1|...","suppressed":true,"elapsed_ms":482}
sup=1suppressed tokens — the trusted, no-challenge token, produced directly.- dataExchange auto-fetch — the single biggest reliability lever: point
WithDataExchangeURLat the embedding page and every solve pulls a freshdata[blob]on the same session. Measured in practice: with blob → reliablysup=1; without → mostly unsuppressed. (See below.) - Ground-truth BDA fingerprint — every field validated byte-for-byte against a live Chrome-150 mint (the universal hashes are pinned to their real values, not fabricated).
- A new, unique device every solve — each solve synthesizes a fresh, internally-coherent Windows-Chrome machine (GPU from a ~900-device pool + matching RAM/cores/screen, random per-GPU WebGL hash), so no two tokens share a fingerprint and there's no fleet-constant tell.
- Fresh session per solve — each
Solve()uses its own cookie jar so a spent, low-trust session can't poison the next token (inject your own client withSetHTTPClientto share one). - Correct crypto envelope — RSA-2048-OAEP-wrapped AES-256-GCM (
iv|tag|rsa|ct), matching the browser byte-for-byte. Legacy AES-CBC path kept for oldcapiversions. - Configurable per site — site key, RSA key, verify host, origin, UA, language, proxy.
- TLS impersonation — Chrome fingerprint via
bogdanfinn/tls-client. - Per-request proxy override — send a
Proxy:header to the server, orWithProxy(...). - Library + CLI server — import the package, or run the tiny HTTP server.
This project began as a faithful Go port of a Python Arkose solver. That port never produced
sup=1 — it was chasing the wrong two things. v1 fixes both, verified against a live mint.
| Area | Old (CBC port) | v1 (this repo) |
|---|---|---|
c envelope |
AES-256-CBC, emitted as {"ct","s","iv"} JSON |
RSA-OAEP + AES-256-GCM hybrid b64(iv12)+b64(tag16)+b64(rsa256)+b64(ct) |
| Server verdict | token returned, never sup=1 |
sup=1 suppressed token |
| RSA public key | assumed a "red herring", regex-scraped from api.js (always empty — key is VM-computed) | required & configurable (WithRSAPublicKey); captured once per site |
f (fp hash) |
md5("") — hash of empty |
real hash from a genuine feature enumeration |
fe (features) |
[] (empty) |
populated real Chrome fe (DNT/L/S/AS/JSF/P/… with correct comma-separated, sorted formats) |
| Top-level BDA | …jsbd, vsadsa, basfas, lfasdgs (extras misplaced at top level) |
…jsbd, c (matches real; extras live only inside enhanced_fp) |
enhanced_fp |
90 keys, missing z87b89t5; several wrong value formats |
91 keys, exact structure; value formats corrected |
| Value tells | L:en-us, ODB:true, empty document__referrer, telemetry blobs |
L:en-US, ODB:false, real referrer, minimal telemetry (Ow==) |
| PoW | stub → hard error on non-sup=1 |
irrelevant — sup=1 means no challenge is demanded |
| Shape | single package main binary, hardcoded to one site + a checker |
reusable library + CLI server, site-agnostic |
The key insight: sup=1 requires both the RSA+GCM envelope and a real fingerprint.
CBC gets a token but never sup=1; a good fingerprint under CBC still never sup=1. Together they do.
go get github.com/buggerlogger/arkose-solver/arkoseOr build the server:
git clone https://github.com/buggerlogger/arkose-solver
cd arkose-solver
go build -ldflags="-s -w" -o arkose-server ./cmd/arkose-serverRequires Go 1.24+.
arkose-server \
-surl https://verify.example.com \
-pk 00000000-0000-0000-0000-000000000000 \
-rsa MIIBIjAN...your-captured-key... \
-site https://www.example.com \
-proxy http://user:pass@host:portThen request tokens:
curl http://127.0.0.1:8100/token
# {"token":"...|sup=1|...","suppressed":true,"elapsed_ms":482}- Per-request proxy:
curl -H "Proxy: host:port:user:pass" http://127.0.0.1:8100/token - With a dataExchange blob:
http://127.0.0.1:8100/token?blob=<data-adx value>
| Flag | Meaning | Default |
|---|---|---|
-addr |
listen address | 127.0.0.1:8100 |
-surl |
Arkose verify host (required) | — |
-pk |
Arkose site key / public_key UUID (required) |
— |
-rsa |
RSA public key, SPKI base64 (required for sup=1) |
— |
-site |
origin the widget runs on | — |
-proxy |
default egress proxy (any form) | none |
-ua |
override User-Agent | Chrome 150 Win64 |
-lang |
language | en-US |
-mode |
lightbox or inline |
lightbox |
package main
import (
"fmt"
"log"
"github.com/buggerlogger/arkose-solver/arkose"
)
func main() {
s, err := arkose.New(
arkose.WithSurl("https://verify.example.com"),
arkose.WithPublicKey("00000000-0000-0000-0000-000000000000"),
arkose.WithRSAPublicKey("MIIBIjAN...your-captured-key..."),
arkose.WithSite("https://www.example.com"),
arkose.WithProxy("http://user:pass@host:port"), // optional
)
if err != nil {
log.Fatal(err)
}
res, err := s.Solve()
if err != nil {
log.Fatal(err)
}
fmt.Println(res.Token, "suppressed:", res.Suppressed)
}| Option | Purpose |
|---|---|
WithSurl(host) |
Arkose verify host, e.g. https://verify.example.com (required) |
WithPublicKey(uuid) |
Arkose site key (required) |
WithRSAPublicKey(spki) |
RSA SPKI base64 (required for sup=1) |
WithSite(url) |
origin the widget runs on (derives Origin/Referer) |
WithProxy(p) |
egress proxy — http(s)://, socks5://, host:port:user:pass, user:pass@host:port |
WithUserAgent(ua) |
override the UA (also reported in the BDA) |
WithLanguage(l) |
language (default en-US) |
WithCapiMode(m) |
lightbox (default) or inline |
WithReferer(r) / WithOrigin(o) |
override headers explicitly |
WithSitedataLocationHref(u) |
set the page URL reported in the BDA |
WithTitle(t) |
document.title of the embedding page (BDA jsbd DT) |
WithDataExchangeURL(u) |
auto-fetch a fresh data[blob] per solve from this page URL — the biggest sup=1 lever (see below) |
WithDataExchangeRegex(re) |
override the blob extractor (default data-adx="([^"]+)") |
Many Arkose deployments embed a per-page-load dataExchange blob — a short-lived, server-minted
token — in the host page, and forward it as the data[blob] mint field. Supplying a fresh,
same-session blob is the single largest driver of suppression (back-to-back, same IP: with blob →
reliably sup=1; without → mostly unsuppressed).
WithDataExchangeURL makes this autonomous: before each solve the library GETs that page on the
same TLS client (which also warms the session cookies), extracts the blob, and forwards it — no
browser needed.
s, _ := arkose.New(
arkose.WithSurl("https://verify.example.com"),
arkose.WithPublicKey("00000000-0000-0000-0000-000000000000"),
arkose.WithRSAPublicKey(rsaKey),
arkose.WithSite("https://www.example.com"),
arkose.WithTitle("Sign in - Example"),
arkose.WithDataExchangeURL("https://www.example.com/sign-in"),
)
res, _ := s.Solve() // fetches a fresh blob, then mintsNotes:
- The blob is single-use: it's re-fetched every
Solve(). - The page GET uses a full browser header set on purpose — a minimal request makes some origins
serve a blob-less page variant. If the default
data-adxextractor doesn't fit your site, passWithDataExchangeRegex. - Or supply a blob yourself per solve with
Solver.SetDataBlob(...)(e.g. from adata-adx/data-exchangeattribute you already have). - IP reputation is a separate axis: even a perfect blob won't yield
sup=1from an IP that Arkose has throttled after a burst. Spread solves out and/or rotate egress proxies for volume.
Solve() returns *SolveResult{ Token, Suppressed, Timings }. Suppressed == true means the
token carries sup=1 and is accepted with no challenge.
Three values are site-specific. Two are trivial; one you capture once.
public_key(site key) — a UUID. It's in the target page's Arkose script URL:…/v2/<PUBLIC_KEY>/api.js, or in adata-pkey/data-apkattribute.surl(verify host) — the host serving thatapi.js, e.g.https://verify.example.com.- RSA public key — required for
sup=1. It is not a static string inapi.js(the client reconstructs it at runtime inside an obfuscated VM), so you capture it once from a real browser mint. It's stable per site key for weeks.
A one-command tool that launches Chrome, injects the hook into every frame before page scripts, forces the Arkose mint, catches the RSA key, writes it to a file, and closes Chrome:
go build -o capture-key ./cmd/capture-key
capture-key -url https://www.example.com/sign-in -out rsa_key.txtOutput:
Chrome open on https://www.example.com/sign-in
Waiting for the Arkose mint (auto-triggered)…
[OK] captured RSA key (392 chars) -> rsa_key.txt
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB...
Then use it: -rsa "$(cat rsa_key.txt)" or WithRSAPublicKey(...).
Flags: -url (page that uses Arkose, required), -out (default rsa_key.txt),
-timeout (default 90s), -headless (default off — leave off if a site is picky),
-keep (leave Chrome open for debugging). Chrome must be installed.
Why a plain console paste often fails: Arkose runs its crypto inside a cross-origin iframe, and on suppressed sites the mint never fires on its own — it must be triggered. The tool handles both: it hooks all frames at document-start and calls the enforcement's
initSession()to force the mint. That's why the manual snippet printed nothing.
Switch the DevTools console context to the Arkose iframe (verify.<site>.com / *.arkoselabs.com
in the context dropdown), paste the hook below, then trigger the captcha. Or run it as a Tampermonkey
userscript with @run-at document-start + @all-frames true:
const _i = crypto.subtle.importKey.bind(crypto.subtle);
crypto.subtle.importKey = function (fmt, kd) {
try {
if (fmt === 'spki' && kd && kd.byteLength)
console.log('RSA_SPKI =', btoa(String.fromCharCode(...new Uint8Array(kd))));
} catch (e) {}
return _i.apply(this, arguments);
};
console.log('[hooked — now trigger the captcha]');Copy the printed RSA_SPKI = MIIBIjAN… into WithRSAPublicKey(...) / -rsa.
Without the RSA key the solver falls back to AES-CBC: the server still returns a token, but it will not be
sup=1. The solver logs a warning in that case.
GET /v2/{pk}/api.js— read the currentcapi_versionandark-build-id. 1a. (optional) fetch the dataExchange blob — ifWithDataExchangeURLis set, GET that page on the same client and extract a freshdata[blob](also warms session cookies). See above.- Build the BDA — a real Chrome fingerprint:
api_type, f, n, wh, enhanced_fp (91 fields), fe, ife_hash, jsbd, c. WebGL/GPU, canvas, audio, codecs, and math fields rotate per request. - Encrypt — random AES-256 key + 12-byte IV; AES-GCM the BDA; RSA-OAEP(SHA-256) wrap the AES
key with your site's RSA public key; concat
b64(iv)+b64(tag)+b64(rsa)+b64(ct)intoc=. POST /fc/gt2/public_key/{pk}— form fieldsc, public_key, site, userbrowser, capi_version, capi_mode, style_theme, rnd, language[, data[blob]], correct header order.- Return the token — if
sup=1, done. Otherwise one ARID-warmup retry.
arkose-solver/
├── arkose/ # the library (import this)
│ ├── config.go # Config + functional options + defaults
│ ├── solver.go # New(), Solve(), transport, payload/headers
│ ├── bda.go # BDA + enhanced_fp + fe/f (ground-truth Chrome)
│ ├── encryption.go # RSA-OAEP+AES-GCM (Encrypt) and AES-CBC (EncryptAES)
│ ├── rotators.go # per-request rotating pools (chrome version, audio, langs, speech…)
│ ├── profiles.go # coherent device profiles (real GPU+screen+RAM as one unit)
│ ├── gpu.go / gpu_data.go# WebGL vendor/renderer/hash pool (930 GPUs)
│ ├── logger.go # phase logging
│ └── pow.go # (unused — sup=1 means no PoW)
├── cmd/arkose-server/ # tiny HTTP server (GET /token, /health)
│ └── ...
├── cmd/capture-key/ # auto RSA-key capture: opens Chrome, hooks, saves to file, closes
└── examples/basic/ # minimal library example
For research and authorized testing only. You are responsible for complying with the terms of any site you interact with and with applicable law. No affiliation with Arkose Labs.
MIT — see LICENSE.