DEV Community

ameobius
ameobius

Posted on

Bypass Yandex SmartCaptcha with CDP Mouse Events

Bypassing Yandex SmartCaptcha with CDP Mouse Events

A deep dive into modeling human mouse kinematics through the Chrome DevTools Protocol — for penetration testing, academic research, and red-team automation.


Why Yandex SmartCaptcha Is Hard

Yandex SmartCaptcha is not your grandfather's slider puzzle. Unlike legacy CAPTCHAs that check a single token or a static set of image tiles, SmartCaptcha continuously profiles the entropy of the input device — acceleration profiles, velocity decay, micro-jitter, bezier curvature of the cursor path, and the distribution of inter-event timing deltas. A bot that moves in straight lines with constant velocity is rejected before it even reaches the challenge surface.

The captcha collects roughly three categories of signal:

  1. Kinematic biometrics — the shape and smoothness of the pointer trajectory. Human curves have predictable jerk (the derivative of acceleration) but unpredictable micro-deviations. Bots tend to produce piecewise-linear paths or overly smooth Bezier curves with no high-frequency noise.
  2. Timing entropy — the distribution of delays between mousedown, mousemove, and mouseup. Humans have a log-normal distribution of reaction times; bots cluster around fixed intervals.
  3. Device fingerprint — screen resolution, devicePixelRatio, hardware concurrency, WebGL renderer string, and the presence or absence of a touch input layer. A headless Chrome with navigator.webdriver === true is dead on arrival.

This article walks through a working approach to the first two: generating synthetic kinematic profiles that pass SmartCaptcha's behavioral filter, dispatched through the Chrome DevTools Protocol so the captcha's own JavaScript sees real DOM events — not synthetic MouseEvent constructors, which are trivially detectable via event.isTrusted.


Why CDP, Not Puppeteer's page.mouse

The key insight is isTrusted. The browser sets event.isTrusted = true only for events generated by the user agent itself in response to a real input device — or, crucially, in response to a CDP Input.dispatchMouseEvent call. Events created with new MouseEvent(...) and dispatched via element.dispatchEvent() always have isTrusted = false, and every modern CAPTCHA checks this flag.

Puppeteer's page.mouse.move() does use CDP under the hood. So does Playwright's mouse.move(). The problem is that their default movement is a fixed 10 intermediate steps with a uniform time delta — a fingerprint so distinctive that it has its own name in the anti-bot literature: "the staircase." SmartCaptcha rejects it.

The solution is to bypass the convenience layer and call Input.dispatchMouseEvent directly, with a trajectory and timing model you control down to the millisecond.


Architecture

┌──────────────────────────────────────┐
│  Python orchestrator                 │
│  ├── Bezier trajectory generator     │
│  ├── Timing jitter model             │
│  └── CDP client (websocket)          │
└──────────────┬───────────────────────┘
               │ Input.dispatchMouseEvent
               ▼
┌──────────────────────────────────────┐
│  Chromium (real, non-headless)       │
│  └── SmartCaptcha iframe             │
└──────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

We use a real (non-headless) Chromium instance launched with --remote-debugging-port=9222. Headless mode (--headless) sets several detectable flags even in the "new" headless mode; running with a virtual framebuffer (Xvfb) on Linux avoids this.


Step 1: Launch Chromium with CDP Enabled

# On Linux with Xvfb
Xvfb :99 -screen 0 1920x1080x24 &
export DISPLAY=:99
chromium-browser \
  --remote-debugging-port=9222 \
  --user-data-dir=/tmp/cdp-profile \
  --no-first-run \
  --disable-blink-features=AutomationControlled \
  --window-size=1920,1080
Enter fullscreen mode Exit fullscreen mode

The --disable-blink-features=AutomationControlled flag removes the navigator.webdriver property. Without it, navigator.webdriver === true and you're flagged instantly.


Step 2: Connect to the CDP Endpoint

Pure Python, zero dependencies beyond the standard library. The CDP protocol is just JSON-RPC over a WebSocket, and the WebSocket handshake is trivially implementable with socket + hashlib.

import json
import socket
import struct
import hashlib
import base64
import os
import time
import urllib.request

class CDPSocket:
    """Minimal CDP WebSocket client — stdlib only."""

    def __init__(self, host: str, port: int):
        self.host = host
        self.port = port
        self.msg_id = 0
        self.sock = None
        self._connect()

    def _connect(self):
        # Get the WebSocket debugger URL from /json/version
        url = f"http://{self.host}:{self.port}/json/version"
        resp = json.loads(urllib.request.urlopen(url).read())
        ws_url = resp["webSocketDebuggerUrl"]

        # Parse host/port from ws URL
        addr = ws_url.replace("ws://", "").split("/")[0]
        host, port = addr.split(":")
        self.sock = socket.create_connection((host, int(port)))

        # WebSocket handshake (RFC 6455)
        key = base64.b64encode(os.urandom(16)).decode()
        handshake = (
            f"GET / HTTP/1.1\r\n"
            f"Host: {host}:{port}\r\n"
            f"Upgrade: websocket\r\n"
            f"Connection: Upgrade\r\n"
            f"Sec-WebSocket-Key: {key}\r\n"
            f"Sec-WebSocket-Version: 13\r\n\r\n"
        )
        self.sock.sendall(handshake.encode())
        resp = self.sock.recv(4096).decode()
        # Verify 101 Switching Protocols
        assert "101 Switching Protocols" in resp, f"Handshake failed: {resp}"

    def _send_frame(self, payload: str):
        data = payload.encode("utf-8")
        header = bytearray()
        header.append(0x81)  # FIN + text frame
        mask_key = os.urandom(4)
        if len(data) < 126:
            header.append(0x80 | len(data))
        elif len(data) < 65536:
            header.append(0x80 | 126)
            header.extend(struct.pack(">H", len(data)))
        else:
            header.append(0x80 | 127)
            header.extend(struct.pack(">Q", len(data)))
        header.extend(mask_key)
        masked = bytearray(b ^ mask_key[i % 4] for i, b in enumerate(data))
        self.sock.sendall(bytes(header) + bytes(masked))

    def _recv_frame(self) -> str:
        header = self._recv_n(2)
        opcode = header[0] & 0x0F
        masked = bool(header[1] & 0x80)
        length = header[1] & 0x7F
        if length == 126:
            length = struct.unpack(">H", self._recv_n(2))[0]
        elif length == 127:
            length = struct.unpack(">Q", self._recv_n(8))[0]
        if masked:
            mask_key = self._recv_n(4)
        data = self._recv_n(length)
        if masked:
            data = bytearray(b ^ mask_key[i % 4] for i, b in enumerate(data))
        return bytes(data).decode("utf-8")

    def _recv_n(self, n: int) -> bytes:
        buf = b""
        while len(buf) < n:
            chunk = self.sock.recv(n - len(buf))
            if not chunk:
                raise ConnectionError("Socket closed")
            buf += chunk
        return buf

    def send(self, method: str, params: dict = None) -> dict:
        self.msg_id += 1
        msg = {"id": self.msg_id, "method": method}
        if params:
            msg["params"] = params
        self._send_frame(json.dumps(msg))
        # Read frames until we get our response (skip events)
        while True:
            frame = json.loads(self._recv_frame())
            if frame.get("id") == self.msg_id:
                return frame

    def recv_event(self) -> dict:
        return json.loads(self._recv_frame())
Enter fullscreen mode Exit fullscreen mode

That's a complete, dependency-free CDP transport. No websockets, no websocket-client, no aiohttp. Just socket.


Step 3: Human-Like Trajectory Generation

This is where most attempts fail. The trajectory must satisfy three constraints simultaneously:

  1. Smoothness — no sharp angles. Curvature must be C²-continuous (continuous second derivative).
  2. Noise — superimposed high-frequency jitter that mimics hand tremor (amplitude 1–4px, frequency 5–12 Hz).
  3. Asymmetry — humans approach a target faster than they decelerate. The velocity profile is right-skewed, not Gaussian.

A cubic Bezier curve with four control points satisfies the smoothness constraint. We add stochastic noise and a minimum-jerk velocity profile for the timing.

import random
import math

def bezier_curve(p0, p1, p2, p3, t):
    """Cubic Bezier at parameter t in [0, 1]."""
    u = 1 - t
    x = u**3 * p0[0] + 3*u**2*t * p1[0] + 3*u*t**2 * p2[0] + t**3 * p3[0]
    y = u**3 * p0[1] + 3*u**2*t * p1[1] + 3*u*t**2 * p2[1] + t**3 * p3[1]
    return (x, y)

def generate_control_points(start, end):
    """Random control points that produce a natural arc."""
    mx, my = (start[0] + end[0]) / 2, (start[1] + end[1]) / 2
    dx, dy = end[0] - start[0], end[1] - start[1]
    dist = math.hypot(dx, dy)
    # Perpendicular offset for the arc — humans curve, they don't go straight
    perp_x, perp_y = -dy / max(dist, 1), dx / max(dist, 1)
    # Random curvature: 0.1 to 0.4 of the distance, random side
    curve_amount = random.uniform(0.1, 0.4) * dist * random.choice([-1, 1])
    offset_x, offset_y = perp_x * curve_amount, perp_y * curve_amount
    # Control points at 1/3 and 2/3 along the line, offset perpendicular
    p1 = (start[0] + dx/3 + offset_x + random.gauss(0, dist*0.05),
          start[1] + dy/3 + offset_y + random.gauss(0, dist*0.05))
    p2 = (start[0] + 2*dx/3 + offset_x + random.gauss(0, dist*0.05),
          start[1] + 2*dy/3 + offset_y + random.gauss(0, dist*0.05))
    return p1, p2

def minimum_jerk_profile(n_steps):
    """
    Minimum-jerk velocity profile (Hogan, 1984).
    Produces a bell-shaped curve: slow start, fast middle, gentle stop.
    """
    profile = []
    for i in range(n_steps):
        t = i / max(n_steps - 1, 1)
        # Position along the path follows: 10t³ − 15t⁴ + 6t⁵
        s = 10 * t**3 - 15 * t**4 + 6 * t**5
        profile.append(s)
    return profile

def generate_trajectory(start, end, n_steps=None):
    """Full trajectory: Bezier path + minimum-jerk timing + tremor noise."""
    dist = math.hypot(end[0] - start[0], end[1] - start[1])
    if n_steps is None:
        # Fitts's Law: time to acquire a target scales with log2(dist/width + 1)
        # We convert this to steps at ~100 Hz polling rate
        n_steps = max(20, int(dist * 0.8 + random.gauss(0, 5)))
    p1, p2 = generate_control_points(start, end)
    profile = minimum_jerk_profile(n_steps)
    points = []
    for i in range(n_steps):
        s = profile[i]
        # Bezier parameter advances per the jerk profile
        x, y = bezier_curve(start, p1, p2, end, s)
        # Hand tremor: 1-3 px oscillation
        tremor_x = random.gauss(0, 1.2)
        tremor_y = random.gauss(0, 1.2)
        # As we approach the target, tremor dampens
        dampening = 1.0 - 0.5 * s
        x += tremor_x * dampening
        y += tremor_y * dampening
        points.append((round(x, 1), round(y, 1)))
    # Ensure we land exactly on target
    points[-1] = (float(end[0]), float(end[1]))
    return points

def generate_timestamps(n_steps, base_interval_ms=10):
    """
    Log-normal distribution of inter-event intervals.
    Human reaction times follow log-normal, not Gaussian.
    """
    timestamps = []
    current = 0.0
    for i in range(n_steps):
        # Log-normal: median ~base_interval, with realistic variance
        delta = random.lognormvariate(
            math.log(base_interval_ms),  # mu
            0.35                           # sigma (variance of log-timing)
        )
        # Clamp to plausible range (2ms to 80ms)
        delta = max(2.0, min(80.0, delta))
        current += delta
        timestamps.append(current)
    return timestamps
Enter fullscreen mode Exit fullscreen mode

The Fitts's Law step count is critical. A 500px movement gets ~400 steps; a 20px micro-adjustment gets ~16. This matches empirical data: human cursor paths sample at roughly 100–125 Hz, and longer movements take proportionally more time.


Step 4: Dispatch Through CDP

Now we feed the trajectory into Input.dispatchMouseEvent. The captcha only sees mousemove, mousedown, and mouseup events with isTrusted = true.

def move_mouse(cdp: CDPSocket, points, timestamps):
    """Dispatch a full mouse trajectory via CDP."""
    cdp.send("Input.dispatchMouseEvent", {
        "type": "mouseMoved",
        "x": points[0][0],
        "y": points[0][1],
    })
    t0 = time.monotonic()
    for (x, y), ts in zip(points[1:], timestamps[1:]):
        # Sleep until the scheduled timestamp
        target = t0 + (ts / 1000.0)
        sleep_for = target - time.monotonic()
        if sleep_for > 0:
            time.sleep(sleep_for)
        cdp.send("Input.dispatchMouseEvent", {
            "type": "mouseMoved",
            "x": x,
            "y": y,
        })

def click_at(cdp: CDPSocket, x, y, button="left"):
    """Press + release with human-like hold duration."""
    hold_ms = random.lognormvariate(math.log(45), 0.2)  # ~45ms median
    cdp.send("Input.dispatchMouseEvent", {
        "type": "mousePressed",
        "x": x, "y": y,
        "button": button,
        "clickCount": 1,
    })
    time.sleep(hold_ms / 1000.0)
    cdp.send("Input.dispatchMouseEvent", {
        "type": "mouseReleased",
        "x": x, "y": y,
        "button": button,
        "clickCount": 1,
    })
Enter fullscreen mode Exit fullscreen mode

Step 5: Navigating to the SmartCaptcha Widget

SmartCaptcha loads inside a cross-origin iframe. CDP dispatches events at page-level coordinates, so we need the iframe's bounding rect. We can get it via DOM.getBoxModel or by evaluating a small script through Runtime.evaluate.

def get_captcha_checkbox_coords(cdp: CDPSocket):
    """
    Locate the SmartCaptcha checkbox.
    The widget renders inside an iframe from smartcaptcha.yandexcloud.com.
    """
    result = cdp.send("Runtime.evaluate", {
        "expression": """
            (function() {
                var iframe = document.querySelector(
                    'iframe[src*="smartcaptcha"]');
                if (!iframe) return null;
                var rect = iframe.getBoundingClientRect();
                // Checkbox is near the top-left of the iframe
                return JSON.stringify({
                    x: rect.left + 30,
                    y: rect.top + rect.height / 2,
                    width: rect.width,
                    height: rect.height
                });
            })()
        """,
        "returnByValue": True,
    })
    coords = json.loads(result["result"]["result"]["value"])
    return coords

def solve_image_challenge(cdp: CDPSocket, target_description: str):
    """
    For image-grid challenges: locate tiles matching the description.
    Uses Runtime.evaluate to read tile positions, then clicks each.
    """
    result = cdp.send("Runtime.evaluate", {
        "expression": """
            (function() {
                var tiles = document.querySelectorAll('.image-grid img');
                return JSON.stringify(Array.from(tiles).map(function(t) {
                    var r = t.getBoundingClientRect();
                    return {x: r.left + r.width/2, y: r.top + r.height/2};
                }));
            })()
        """,
        "returnByValue": True,
    })
    tiles = json.loads(result["result"]["result"]["value"])
    # Move to each tile with a human trajectory and click
    last_pos = (960, 540)  # center screen
    for tile in tiles:
        target = (tile["x"], tile["y"])
        traj = generate_trajectory(last_pos, target)
        ts = generate_timestamps(len(traj))
        move_mouse(cdp, traj, ts)
        click_at(cdp, target[0], target[1])
        last_pos = target
        # Human pause between selections: 200-600ms
        time.sleep(random.lognormvariate(math.log(350), 0.3) / 1000.0)
Enter fullscreen mode Exit fullscreen mode

Step 6: The Full Solve Pipeline

def solve_smartcaptcha(host="127.0.0.1", port=9222):
    cdp = CDPSocket(host, port)

    # Step 1: Locate the checkbox
    checkbox = get_captcha_checkbox_coords(cdp)
    if not checkbox:
        raise RuntimeError("SmartCaptcha iframe not found")

    # Step 2: Approach from a random starting point (edge of viewport)
    start_x = random.randint(100, 400)
    start_y = random.randint(100, 400)
    target = (checkbox["x"], checkbox["y"])

    # Step 3: Generate trajectory and timestamps
    trajectory = generate_trajectory((start_x, start_y), target)
    timestamps = generate_timestamps(len(trajectory))

    # Step 4: Move and click
    move_mouse(cdp, trajectory, timestamps)
    # Small pause before clicking (human reaction time)
    time.sleep(random.lognormvariate(math.log(120), 0.3) / 1000.0)
    click_at(cdp, target[0], target[1])

    # Step 5: Wait for challenge to load
    time.sleep(2.0)

    # Step 6: Check if an image challenge appeared
    # (Some solves pass on behavioral signal alone — "invisible" mode)
    result = cdp.send("Runtime.evaluate", {
        "expression": "!!document.querySelector('.image-grid, .task-image')",
        "returnByValue": True,
    })
    has_image_challenge = result["result"]["result"]["value"]

    if has_image_challenge:
        # Route to the solving logic (OCR / CV model omitted — scope of this article
        # is the kinematic dispatch, not computer vision)
        solve_image_challenge(cdp, "crosswalk")

    print("[+] Captcha dispatched")
Enter fullscreen mode Exit fullscreen mode

Why This Works (and Where It Breaks)

The approach works because SmartCaptcha's behavioral model, like all behavioral captchas, operates on a statistical boundary. It cannot reject every non-human trajectory without also rejecting a meaningful percentage of real users (false positives). The goal is to land inside the "human" cluster of the feature space:

Feature Human range Bot giveaway
Path curvature (L1 deviation from straight line / distance) 0.05–0.30 0.0 (straight) or >0.5 (over-curved)
Velocity at midpoint (% of peak) 85–100% 50% (constant velocity)
Inter-event timing std / mean 0.25–0.45 <0.05 (fixed interval) or >0.8 (random spikes)
Tremor amplitude (px) 0.5–3.0 0 (no noise) or >5 (sensor noise injection)
Total event count for a 200px move 80–250 10 (Puppeteer default)

The model above hits all five ranges by design. The Bezier curve gives curvature; minimum-jerk timing gives the velocity profile; log-normal timestamps give timing entropy; Gaussian tremor gives the noise floor.

Where it breaks:

  • Canvas fingerprinting of the cursor — some implementations draw the cursor path to a hidden canvas and hash it. If the hash matches a known bot signature, the solve is voided retroactively. Randomizing Bezier control points each run mitigates this.
  • Accelerometer / gyroscope on mobile — SmartCaptcha on mobile may request device orientation data. A desktop browser has no accelerometer, and the absence itself is a signal.
  • ML-based classifiers — Yandex operates one of the largest ML anti-bot systems in the world. The kinematic features above are necessary but not sufficient against an adaptive model. You must rotate fingerprint profiles (user-agent, canvas hash, WebGL renderer) across runs.
  • Token replay detection — if you solve once and replay the token across many sessions from different IPs, the token is invalidated. Each solve must come from a fresh browser profile and IP.

Anti-Detection: The Browser Layer

Kinematics alone are not enough if the browser itself is fingerprinted as automated. Here are the minimum patches:

def patch_browser_fingerprint(cdp: CDPSocket):
    """Remove automation signals before navigating to the page."""
    # 1. Remove navigator.webdriver
    cdp.send("Page.addScriptToEvaluateOnNewDocument", {
        "source": """
            Object.defineProperty(navigator, 'webdriver', {get: () => undefined});
            // Overwrite the plugins length (headless has 0, real has 3+)
            Object.defineProperty(navigator, 'plugins', {
                get: () => [1, 2, 3, 4, 5]
            });
            // Overwrite languages (headless sometimes has just ["en-US"])
            Object.defineProperty(navigator, 'languages', {
                get: () => ['en-US', 'en']
            });
            // Chrome runtime (real Chrome has window.chrome)
            window.chrome = { runtime: {} };
            // Permissions API — Notification.permission should not be 'denied' by default
            const originalQuery = window.navigator.permissions.query;
            window.navigator.permissions.query = (parameters) =>
                parameters.name === 'notifications'
                    ? Promise.resolve({ state: Notification.permission })
                    : originalQuery(parameters);
        """
    })
Enter fullscreen mode Exit fullscreen mode

Ethical and Legal Notes

This article is for security researchers, penetration testers, and developers building systems that need to understand the threat model of behavioral CAPTCHAs. Bypassing CAPTCHAs to scrape copyrighted content, create mass accounts, or commit fraud violates the Computer Fraud and Abuse Act (CFAA) in the US, the Computer Misuse Act in the UK, and equivalent statutes in most jurisdictions. Yandex's Terms of Service explicitly prohibit automated access. If you are testing your own integration or conducting authorized red-team work, document your scope and authorization.


Conclusion

Yandex SmartCaptcha represents the current state of the art in behavioral bot detection: it profiles not what you do but how you do it. Defeating it requires modeling the same things a human motor cortex produces unconsciously — curved paths, bell-shaped velocity profiles, log-normal timing, and physiological tremor. The Chrome DevTools Protocol is the right transport because it produces isTrusted events that the captcha's own JavaScript cannot distinguish from real input.

The code in this article is a starting point. Production anti-anti-bot systems add ML-based trajectory optimization, fingerprint rotation pools, residential proxy networks, and computer vision for image challenges. But the foundation — CDP dispatch with human kinematics — is the non-negotiable core.


This article is based on open security research. The author does not endorse using these techniques to violate terms of service or applicable laws.

Top comments (0)