Bringed to u by www.zypherion.tech
Zypherion Research, Windows, May 23, 2026
Takedown Notice
This is my first time reverse engineering a captcha, so some of the technical claims in this article might be wrong. If Cloudflare wants this post taken down, I will take it down without any problems. Just contact me on Discord (@wd6g) or on Telegram (https://t.me/ZypherionTechnologies). All requests will be handled privately and very fast.
Introduction
CFSolver is a small Python file that mints Cloudflare Turnstile tokens. The whole thing is 267 lines. It opens a real Chrome through Patchright, gets a token, and hands it back. There is no cipher port, no JS VM, no WASM, no fingerprint synthesis. The bet is that a real Chrome on a residential IP is the cheapest way to look like a real user, and the token that comes out is a plain string that any HTTP client can replay.
This article walks through how the solver works, mode by mode. If you are looking for a deep Turnstile protocol writeup, this is not it. This is about the solution.
The Public Surface
The class exposes four entry points:
1class CFSolver:
2 def Solve(self, Url, Hidden=False, Timeout=45): ...
3 def SolveSitekey(self, Sk, Url, Hidden=False, Timeout=45): ...
4 def SolveAny(self, Url, Hidden=False, Timeout=45): ...
5 def Replay(self, Tok, Url, Method='POST', Data=None, Headers=None, Impersonate='chrome146'): ...
Solve(Url) opens a page that already has a Turnstile widget on it, waits for the token, returns it. Use this when you control the target URL.
SolveSitekey(Sk, Url) is for when you have a sitekey and a domain but you do not want to actually visit that domain. The solver builds its own widget page locally and tricks Chrome into loading it under the right hostname.
SolveAny(Url) is Solve plus a discovery step. The solver opens the page, watches the network for the Turnstile iframe URL, picks the sitekey out of the path, and then calls SolveSitekey to mint cleanly without going back through the host page.
Replay(Tok, Url, ...) is the after part. Once you have a token, you stuff it into a downstream request with the right TLS fingerprint via curl_cffi.
Use it like this:
1Sol = CFSolver()
2Tok = Sol.SolveAny('https://bypass.city/bypass?bypass=asas')
That is the whole API.
How Solve Works
Solve is the boring path. Spawn a persistent context Chrome, open the URL, attach the token listener, poll until the token shows up or the timeout hits.
1async def _RunVisit(self, Url, Hidden, Timeout):
2 async with async_playwright() as Pw:
3 Ctx = await self._Launch(Pw, Hidden)
4 try:
5 Pg = Ctx.pages[0] if Ctx.pages else await Ctx.new_page()
6 await Ctx.add_init_script(TAP_JS)
7 await Pg.goto(Url, wait_until='domcontentloaded', timeout=30000)
8 return await self._Poll(Pg, Timeout, Click=True)
9 finally:
10 await Ctx.close()
The Click=True flag matters. Some pages embed a force interactive variant of the widget (the 3x...FF sitekey or any production sitekey running in "managed" mode under low trust). On those, the widget will not auto mint, it waits for a click. The polling loop attempts a click after 4 seconds with no token.
Launching Chrome happens in _Launch:
1async def _Launch(self, Pw, Hidden, Extra=None, Synth=False):
2 Args = ['--disable-blink-features=AutomationControlled']
3 if Hidden:
4 Args += ['--start-minimized', '--window-position=-32000,-32000']
5 if Extra:
6 Args += Extra
7 UDir = self.Prof + ('_synth' if Synth else '')
8 return await Pw.chromium.launch_persistent_context(
9 user_data_dir=UDir,
10 channel='chrome', executable_path=self.Chrome,
11 headless=False, no_viewport=True,
12 ignore_https_errors=Synth,
13 args=Args,
14 )
Three things to notice. First, channel='chrome' plus a real executable_path to C:\Program Files\Google\Chrome\Application\chrome.exe. Not chromium. Cloudflare can tell the difference. Second, headless=False. Headless is a dead end (they are just able to detect it i suppose). Third, the Hidden=True path uses --start-minimized --window-position=-32000,-32000, which keeps Chrome off screen at its normal size. Tiny windows like --window-size=10,10 get the widget rejected, because zero rendered widget area is itself a signal.
Tapping The Token
The widget hands the token to its parent window via postMessage. The tap script attaches a listener that watches for any message whose payload looks like a Turnstile token (four dot separated parts, starts with 0.):
1window.addEventListener('message', function(E){
2 try{
3 var D = E.data;
4 if(D && typeof D === 'object'){
5 for(var I = 0; I < 2; I++){
6 var T = D[['response','token'][I]];
7 if(typeof T === 'string' && T.indexOf('0.') === 0 && (T.match(/\./g)||[]).length === 3){
8 window.__CfTok = T;
9 }
10 }
11 }
12 }catch(e){}
13}, true);
This is injected via context.add_init_script(TAP_JS) so it lands before the iframe gets a chance to post anything.
There is also a DOM fallback. When the widget is the official Turnstile tag (not just an iframe embed), the token ends up in a hidden input. The polling step checks both:
1() => {
2 if(window.__CfTok) return window.__CfTok;
3 var Sels = ['[name=cf-turnstile-response]','input[name*=turnstile]', 'input[id^=cf-chl-widget-][id$=_response]'];
4 for(var I=0;I<Sels.length;I++){
5 var El = document.querySelector(Sels[I]);
6 if(El && El.value) return El.value;
7 }
8 return null;
9}
The poller wakes every 400 ms, reads the JS state, returns the token the moment it appears, and gives up at 45 seconds.
How SolveSitekey Works
This is the most interesting part of the solver. The problem: Cloudflare pins each sitekey to its registered domain. If I load 0x4AAAAAAC_aOMpwtdCnD5y5 from 127.0.0.1, the widget refuses to mint because the Referer does not match. So I cannot just host a widget page on localhost and point Chrome at it.
The trick is to host the widget locally but make Chrome believe the local server is the real domain. Three pieces glue this together.
A local HTTPS server that serves a one page widget embed. The HTML is just:
1<div class="cf-turnstile" data-sitekey="__SK__" data-callback="_Cb" data-error-callback="_Er"></div>
2<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
__SK__ gets replaced with the caller's sitekey at request time. The server is a ThreadingHTTPServer wrapped in an SSL context that holds an ephemeral self signed cert generated at solver startup via the cryptography library. It binds to 127.0.0.1 on a random free port.
Chrome host resolver rules to redirect DNS for the target domain:
1Extra = [f'--host-resolver-rules=MAP {Host}:443 127.0.0.1:{Port}',
2 '--ignore-certificate-errors']
Now when Chrome tries to load https://bypass.city/, it actually hits https://127.0.0.1:<port>/, but every Referer header, every document.location, every window.location.host reads as bypass.city. The widget sees the right domain. The cert is self signed, so we also pass --ignore-certificate-errors.
Persistent profile separation. The synthetic flow uses a different user data directory than Solve, suffixed with _synth. This keeps the spoofed host's cookies and storage separate from any persistent state collected by the normal Solve path.
End to end:
1async def _RunSynth(self, Sk, Url, Hidden, Timeout):
2 Host = Url.split('/')[2].split(':')[0]
3 Tgt = f'https://{Host}/'
4 Html = WIDGET_HTML.replace('__SK__', Sk)
5 Srv, Port = self._Serve(Html)
6 try:
7 Extra = [f'--host-resolver-rules=MAP {Host}:443 127.0.0.1:{Port}',
8 '--ignore-certificate-errors']
9 async with async_playwright() as Pw:
10 Ctx = await self._Launch(Pw, Hidden, Extra=Extra, Synth=True)
11 try:
12 Pg = Ctx.pages[0] if Ctx.pages else await Ctx.new_page()
13 await Ctx.add_init_script(TAP_JS)
14 await Pg.goto(Tgt, wait_until='domcontentloaded', timeout=30000)
15 return await self._Poll(Pg, Timeout)
16 finally:
17 await Ctx.close()
18 finally:
19 Srv.shutdown()
The widget loads in roughly 3 to 5 seconds and the token comes through postMessage. The local server shuts down after the solver exits.
How SolveAny Works
If you do not know the sitekey ahead of time, SolveAny finds it for you. Turnstile's iframe URL has a stable shape:
1SITEKEY_RE = re.compile(r'/turnstile/f/ov2/av0/rch/[a-z0-9]{4,6}/(0x[A-Za-z0-9_]{20,30})/')
The path is /turnstile/f/ov2/av0/rch/{wid5}/{sitekey}/.... The solver opens the target URL, registers a request listener that watches every outbound network request, and grabs the first match:
1async def _Discover(self, Url, Timeout=20):
2 Found = {'Sk': None}
3 async with async_playwright() as Pw:
4 Ctx = await self._Launch(Pw, Hidden=True)
5 try:
6 Pg = Ctx.pages[0] if Ctx.pages else await Ctx.new_page()
7 def OnReq(Rq):
8 if Found['Sk']:
9 return
10 M = SITEKEY_RE.search(Rq.url)
11 if M:
12 Found['Sk'] = M.group(1)
13 Pg.on('request', OnReq)
14 await Pg.goto(Url, wait_until='domcontentloaded', timeout=30000)
15 St = time.time()
16 while time.time() - St < Timeout and not Found['Sk']:
17 await asyncio.sleep(0.3)
18 finally:
19 await Ctx.close()
20 return Found['Sk']
Once the sitekey is in hand, SolveAny passes it to SolveSitekey with the host extracted from the original URL. This is the recommended entry point for most use cases. It pays one extra Chrome launch for discovery (so cold path is around 8 to 10 seconds), but every subsequent mint can reuse the synthetic flow without ever revisiting the target site.
I do this two stage thing on purpose. Discovery has to talk to the real site, which means I am a guest in their bot detection scaffolding (Cloudflare WAF rules, anti scraper JS, rate limiters). Mint only needs the sitekey and the host name, so the actual minting happens on my own local server where nothing else is in the way.
Replay
Replay is the easy half. Once the token is minted, downstream calls just need the form field cf-turnstile-response set and a TLS fingerprint that matches a real Chrome. curl_cffi handles the JA3 with impersonate='chrome146':
1def Replay(self, Tok, Url, Method='POST', Data=None, Headers=None, Impersonate='chrome146'):
2 from curl_cffi import requests
3 Data = dict(Data or {})
4 Hdr = dict(Headers or {})
5 Data['cf-turnstile-response'] = Tok
6 if Method.upper() == 'POST':
7 return requests.post(Url, data=Data, headers=Hdr, impersonate=Impersonate)
8 return requests.request(Method, Url, params=Data, headers=Hdr, impersonate=Impersonate)
Cloudflare's siteverify endpoint does not enforce JA3, so plain requests works there. But downstream Bot Fight Mode gated endpoints at the target site do, so the default impersonation matters.
Download
The proof of concept lives on GitHub: PoC.py.
Final Note
As my friend said "Yeah it works but overkill" take this to mind : )
If anything in this article is wrong, please tell me. This is my first captcha RE project. The full source is on GitHub: PoC.py. For anything else, or if Cloudflare wants this post taken down, reach out on Discord (@wd6g) or Telegram (https://t.me/ZypherionTechnologies). Fast and private.