Skip to content

CloudflareInterceptor: Attempt to solve challenge when interactive - #3858

Open
tigeryu8900 wants to merge 15 commits into
mihonapp:mainfrom
tigeryu8900:cloudflare-solve-interactive
Open

CloudflareInterceptor: Attempt to solve challenge when interactive#3858
tigeryu8900 wants to merge 15 commits into
mihonapp:mainfrom
tigeryu8900:cloudflare-solve-interactive

Conversation

@tigeryu8900

@tigeryu8900 tigeryu8900 commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

When an interactive challenge is detected, attempt to solve the challenge instead of aborting by attaching the WebView to the current view and sending the Tab and Space keys.

I also added a locking mechanism to make sure that only one request per host is solving the challenge at a time.

I took inspiration from unseensnick/Reikai@14d3d54.

This is the overall logic of the solver:

  1. Obtain a read lock and try chain.proceed(request). If the response is successful, release the read lock and return the response. Otherwise, get the cf_clearance cookie before releasing the read lock.
  2. Obtain a write lock and check if Cloudflare is already bypassed in another call. If it is, release the write lock and return chain.proceed(request).
  3. Check if the view group of the current activity is available. If it isn't, inject the fallback JavaScript solver.
  4. Load the page in a WebView.
  5. On an interactiveBegin event, if the fallback solver isn't injected, attach the WebView to the view group of the current activity and simulate Tab and Space keys. If the view group is unavailable or keys fail to send, inject the fallback solver and reload the page. Abort if the complete event doesn't happen within five seconds.
  6. After the solver completes, release the write lock before returning chain.proceed(request).

unseensnick added a commit to unseensnick/Reikai that referenced this pull request Aug 27, 2026
The interactive Cloudflare solver now tabs onto the verification box and
hits space, instead of tapping a point it worked out from the widget's
rectangle. Same speed, and it no longer has to guess where the box is.

- Twenty-nine interactive challenges over six VPN rounds on the Fold
  cleared on keys alone, 2.2 to 3.1 seconds each, with four or five
  hosts in parallel finishing in 2.9 to 3.5 seconds.
- The four rounds run before the tap came out still carried it as a
  fallback and never once reached it.

Under the hood:
- Deletes the checkbox inset, the widget-rect walk in the page probe and
  the viewport scaling. That arithmetic was the part most exposed to
  Cloudflare restyling its widget.
- The keys land even with the soft keyboard up, so the IME does not take
  focus from an attached WebView.

Suggested by tigeryu8900, who ported the solver upstream the same way
(mihonapp/mihon#3858).
unseensnick added a commit to unseensnick/Reikai that referenced this pull request Aug 27, 2026
A challenge Cloudflare has abandoned, and a WebView whose renderer dies,
both used to hold the request for the full thirty-second timeout before
failing. Each now ends the wait as soon as it happens, so a source that
cannot be cleared says so quickly instead of hanging.

- A dead renderer no longer takes the app process down with it either,
  which is what the unhandled case does.
- The abandoned-challenge exit is trusted only while the in-app solver
  is off. Armed, it keeps pressing, because Cloudflare reissues after a
  failed round often enough to be worth the wait.
- The bypass WebView is now pushed off screen to the left rather than
  the right, so it stays hidden if the window grows under it.

Ported from mihonapp/mihon#3858. Fifteen more interactive challenges
cleared on the Fold across three VPN rounds with no change in timing;
neither new exit fired, since neither can be provoked from a normal run.
unseensnick added a commit to unseensnick/Reikai that referenced this pull request Aug 27, 2026
The solver record now says what is actually verified and what upstream is
doing with the same idea, so a future sync does not walk into it blind.

- Breadth is five hosts on one device over one VPN, across forty-four
  solves. Reliable on those, unknown beyond them, which is why the switch
  stays off by default.
- Notes that mihonapp/mihon#3858 carries a version of this upstream, and
  where it will collide on the next sync: the RK island in
  CloudflareInterceptor, and ForegroundActivity.kt at the same path.
unseensnick added a commit to unseensnick/Reikai that referenced this pull request Aug 28, 2026
The interactive Cloudflare solver used to give up whenever the app had no
window on screen, which is exactly when a background library update runs.
It now falls back to a script inside the challenge frame that ticks the
box for itself, so an update no longer stalls on a challenged source.

- A library update behind an interactive challenge now gets through on
  its own, where before it failed and left the source unchecked.
- Nothing changes while you are using the app: that path still uses real
  key presses, which stay about four times faster.
- A fallback that cannot land now fails in twenty seconds instead of
  sitting out the full thirty second wait.

Under the hood:
- TurnstileSolver.attach picks a press mechanism instead of refusing to
  arm; the window setup moved into attachToWindow.
- The in-frame script is borrowed from mihonapp/mihon#3858 at e6de3a7a1,
  verbatim apart from three stripped console.log lines, and is scoped to
  challenges.cloudflare.com so it never runs in the source's own page.
- Arming suppresses the upstream interactive and fail aborts, so the
  fallback owns giving up; onGiveUp restores that.
- The budget timer is a main-looper Handler, not View.postDelayed, which
  never fires on a detached view.

Measured on the Fold: three real update solves at 9.4 to 12.2s, and the
foreground path unchanged at 2.4 to 3.0s across five hosts. The fallback
clears nothing when the app is backgrounded with no foreground service,
which is why ForegroundActivity deliberately still reports a paused
activity. Full record in docs/dev/plans/turnstile-solver.md.
unseensnick added a commit to unseensnick/Reikai that referenced this pull request Aug 28, 2026
Concurrent requests to a challenged host used to queue on the Cloudflare
bypass's own map of pending solves; they now take a per-host read/write
lock in the interceptor base, and a request that queued behind a solve
re-checks the cookie jar instead of retrying blind.

No user-visible change: measured back to back against the old path on the
same five hosts, every challenged request resolved either way, and solve
times were 2.4 to 2.9 seconds before against 2.5 to 3.2 after.

Under the hood:
- WebViewInterceptor owns the lock, getNonce and isBypassed; intercept
  takes a nonce and returns null to mean "solved, retry normally".
- TurnstileSolver.onlyOncePerHost and its future map are gone.
- The base closes the challenge response on the sibling shortcut, since
  that path returns without reaching the subclass that used to close it.
  Without it OkHttp refuses the retry and the source renders an error.

MARKED FOR POSSIBLE REMOVAL. This is taken from mihonapp/mihon#3858,
which is unmerged, and it landed on a benefit that stayed unmeasured:
the advantage is on the failure path, where a sibling would otherwise
retry into a 403, and that case was never reproduced. If the PR is
abandoned or the divergence stops being worth it, remove it by hand
rather than with git revert, since work will have landed on top: delete
locksByHost and the lock wrapping in WebViewInterceptor.intercept, drop
getNonce / isBypassed and the nonce parameter, return Response again, and
restore onlyOncePerHost around the resolveWithWebView call. The same
recipe and the regression above are in docs/dev/plans/turnstile-solver.md.
@AntsyLich

Copy link
Copy Markdown
Member

i'm not sure about the auto solver. it might get us blocked permanently by cloudflare. the other changes seems to be useful.

@tigeryu8900

Copy link
Copy Markdown
Contributor Author

I see. Just to clarify, by the auto solver you mean the simulated key events and the injected JavaScript solver, or just the JavaScript solver?

@AntsyLich

Copy link
Copy Markdown
Member

both

@tigeryu8900
tigeryu8900 force-pushed the cloudflare-solve-interactive branch from 7701841 to 0a1f07d Compare August 30, 2026 09:45
@tigeryu8900

Copy link
Copy Markdown
Contributor Author

Got it. I removed the solver.

unseensnick added a commit to unseensnick/Reikai that referenced this pull request Sep 1, 2026
The half of the interactive Cloudflare solver that runs with no screen
open now has its own switch, off by default, so wanting global search to
work no longer means accepting a script that runs inside the challenge
frame. The borrowed script itself gained strict mode and two fixes.

Solver:
- A new "Solve with the app in the background" switch sits under the
  existing one and starts off. With it off and no screen open the solver
  does not arm at all, so the request behaves the way it did before the
  in-frame path existed.
- Key presses are spaced by a random 70 to 160ms instead of a flat tenth
  of a second, so solves running at once no longer share one cadence.

Under the hood:
- The borrowed in-frame script runs in strict mode, which closes the
  caller-chain read its own author flagged. Upstream added it after
  Reikai took the script (mihonapp/mihon#3858).
- removeEventListener now maps the caller's listener forward to the
  wrapper that was actually registered. It read the reverse map, so
  removal never matched: removed listeners kept firing and the wrappers
  leaked. Measured against a control page.
- The Gecko branch builds its WeakMap with new. Unreachable on Android,
  since Blink always takes the V8 branch, but it would have thrown.
- Dropped the dead Object.definePropery call and recorded why the walk
  must never redefine the prototypes: the proxy registrations are its
  whole product, and redefining anonymises 48 members of MouseEvent,
  UIEvent and Event without changing anything a listener can observe.

Verified on device: key path 2.2s to clear, the gate declining to arm
with no window, and the in-frame path injecting into 4 of 4 interactive
hosts and clearing 3 of them in 12.2 to 13.5 seconds, inside the
pre-hardening band. 1262 app and 75 domain tests pass.
unseensnick added a commit to unseensnick/Reikai that referenced this pull request Sep 1, 2026
The solver asked androidx for an isolated world before checking whether
the WebView had one, and that call throws when it does not. The throw was
caught, logged and then reported as a successful arm, which suppressed the
caller's own aborts while nothing was left to release the wait: every
challenged request on such a WebView cost its whole 30 second timeout.
Invisible on a current WebView, which is why it shipped.

- The isolated world joins the other two features the solver requires, so
  a WebView without one declines cleanly and the switch stays off the
  settings screen rather than offering something that cannot work.
- Arming failure now reports not armed, leaving the caller in charge.
- The page-world fallback branch is deleted rather than made reachable. It
  could never run, and a probe in the page's own world is the measured
  dead end that makes Cloudflare reissue the challenge.
- A failed solve deletes cf_clearance. Cloudflare issues one on a round it
  refused, so leaving it behind made a sibling queued on that host skip
  its own solve and retry into a 403. Taken from mihonapp/mihon#3858.
- The probe is scoped to the page being solved instead of every frame on
  it, including third party ones whose reports were discarded anyway.

Verified on device: four hosts cleared in 2.2 to 3.2 seconds, which is
only reachable if the scoped probe still reports, and re-running without
clearing anything challenged nothing, so a solve keeps its clearance.
1262 app and 75 domain tests pass.
unseensnick added a commit to unseensnick/Reikai that referenced this pull request Sep 1, 2026
The solver used to switch itself off entirely, with the setting greyed
out and no explanation, on any WebView older than a feature that shipped
in March. It now solves there too, on Cloudflare's own events, and the
setting is always available.

- Interactive challenges are solved on older WebViews instead of the
  feature quietly not existing there.

Under the hood:
- The feature it needed is negotiated against the installed WebView, so
  the gate was recency, not Android version. The reason recorded for
  refusing covered the page probe, not the events: a poll in the page's
  own world is what makes Cloudflare reissue, while the two events a
  solve turns on already reach the interceptor's bridge on every
  challenged page. Without a world the solve reads those, adds nothing
  new to the page, and loses only the markup fallback.
- The clearance lands with the navigation after complete, not with the
  event, so a solve with no probe asks again every 250ms for five
  seconds. Without that it spent its whole budget before the post-latch
  jar check rescued it, which is what the first build did.
- The probe no longer ships Cloudflare's other events across the bridge.
  They were collected, serialised and dropped unread; the interceptor's
  own bridge already logs all of them.
- isSupported is gone, since it could only answer true; canWatch says
  whether there is a world to poll from, and the harness asks that.

Borrowed from mihonapp/mihon#3858, which degrades where this declined.
Device-verified on the Fold across three fresh exits: with the branch
forced, three hosts accepted 252 to 261ms after complete; on the normal
path four hosts accepted 165 to 802ms; and a background library update
with no activity solved headless. Tests: 1262 app, 75 domain.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants