Stratir

Stratir threat intelligence research

Tor .onion Captcha Solver

Postmortem & Technical Reference

All guides

Status: ✅ First successful bypass achieved on 2026-04-13

Target: drughub666py6fgnml5kmxa7fva5noppkf6wkai4fwwvzwt4rz645aqd.onion

Iterations to success: ~15 deploy cycles

Winning strategy: Phase 3 (Full-screenshot AI Vision via Gemini Flash)

5

Research parts

28

Documented sections

12

Captured samples

28

Bugs catalogued

Abstract

A full postmortem on automated Tor .onion captcha bypass for threat intelligence collection: three-phase vision waterfall, post-captcha HTML rendering pipeline, adaptive multi-captcha detection, and marketplace-specific solvers for DrugHub, Black Ops, AWazon, WeTheNorth, and Omega — including coordinate systems, bug registry, and validated master rules.

Captured samples

Live captcha evidence from solver debug runs

12 screenshots preserved from Tor renderer debug sessions across DrugHub, AWazon, Black Ops, WeTheNorth, Omega. Element crops, full-page captures, and per-character OCR frames.

DrugHub broken-circle captcha with cartoon cat and colored ring overlays

DrugHub

broken circle

Broken-circle element capture — cat photo, multiple complete rings plus one gap target.

DrugHub broken-circle captcha with winter window scene

DrugHub

broken circle

Holiday photo background — Phase 1 algo false positives from saturated candle pixels.

DrugHub broken-circle captcha with portrait art

DrugHub

broken circle

Grayscale portrait variant — normalized 0–999 Gemini coords solved this class.

AWazon DDOS protection page with broken-circle captcha and timeout bar

AWazon

broken circle

Elementless broken-circle page — viewport-coordinate fallback required (no cap_response element).

Black Ops captcha with mirror URL asterisks and odd-one-out icon grid

Black Ops

blackops mirror

Round 1 — mirror OCR + Binance icon odd-one-out click challenge.

Black Ops captcha round 4 with purple orb odd-one-out grid

Black Ops

blackops mirror

Round 4 — string diff recovered truncated Gemini URL; element.click({ position }) submission.

WeTheNorth distorted hex text captcha on orange background

WeTheNorth

text captcha

drawcaptcha4.php entry gate — 6-char hex OCR with honeypot form filtering.

WeTheNorth distorted hex text captcha on pink background

WeTheNorth

text captcha

Alternate colorway — same OCR pipeline, 100% Round 1 solve rate in testing.

Omega marketplace sequential character captcha with six input boxes

Omega

omega char

Full DDOS Protection page — JS-required image swap per input box (c1–c6).

Omega captcha single distorted character in circular frame — position 1

Omega

omega char

Per-box circle crop — character 1 OCR at temperature 0.1.

Omega captcha single distorted character in circular frame — position 3

Omega

omega char

Character 3 — heavy distortion; sequential solve retries up to 3 rounds.

Omega captcha single distorted character in circular frame — position 5

Omega

omega char

Character 5 — circular crop sent to Gemini Flash with maxOutputTokens 512.

Part 1

DrugHub Broken-Circle Solver

Status: ✅ First successful bypass achieved on 2026-04-13

Target: drughub666py6fgnml5kmxa7fva5noppkf6wkai4fwwvzwt4rz645aqd.onion

Iterations to success: ~15 deploy cycles

Winning strategy: Phase 3 (Full-screenshot AI Vision via Gemini Flash)

01

Captcha Format

DrugHub broken-circle captcha with cartoon cat and colored ring overlays

DrugHub

broken circle

Broken-circle element capture — cat photo, multiple complete rings plus one gap target.

DrugHub broken-circle captcha with winter window scene

DrugHub

broken circle

Holiday photo background — Phase 1 algo false positives from saturated candle pixels.

The target site uses a broken-circle captcha served as an <input type="image" name="cap_response"> form element.

How it works:

  • A photograph (random image — cats, people, Christmas scenes, etc.) is displayed
  • Multiple colored circle outlines are overlaid on the photo (8–15 circles per captcha)
  • Most circles are complete (full ring, like the letter O)
  • Exactly one circle has a visible gap in its outline (like the letter C)
  • The user must click the center of the broken circle
  • The <input type="image"> submits the (x, y) click coordinates relative to the image's natural dimensions
  • The server validates the coordinates — if wrong, it returns "Invalid response"

Timing:

  • Initial page load → 12–15s meta-refresh redirect (anti-bot delay)
  • Captcha page appears → 60-second solve window
  • On success → "Captcha solved" intermediate page → 30s wait → final redirect to marketplace

Key HTML structure:

<form method="post" action="/captcha">
  <input name="cap_response" type="image" src="/captcha_image.png">
</form>

02

Infrastructure

ComponentDetail
ServerHetzner VPS at 178.156.250.12
Servicetor-renderer.service (systemd)
RuntimeBun
BrowserPlaywright-core (Chromium)
Tor proxySOCKS5 via local Tor daemon
Source filetor-proxy/index.ts (Stratir Tor renderer)
API key envGOOGLE_API_KEY (for Gemini Vision)
JS disabledYes — .onion pages run with javaScriptEnabled: false

Deploy command:

scp tor-proxy/index.ts root@178.156.250.12:/opt/tor-renderer/index.ts
ssh root@178.156.250.12 'pkill -9 bun; sleep 2; systemctl restart tor-renderer'

Test command:

curl -X POST http://localhost:3002/render \
  -H "Content-Type: application/json" \
  -H "X-API-Key: <key>" \
  -d '{"url": "http://<onion>.onion", "timeout": 180000}'

03

Solver Architecture — Three-Phase Waterfall

DrugHub broken-circle captcha with portrait art

DrugHub

broken circle

Grayscale portrait variant — normalized 0–999 Gemini coords solved this class.

Phase 1: Algorithmic Pixel Analysis (0 cost, instant)
  ↓ fails →
Phase 2: Element-Targeted AI Vision (Gemini, pixel coords)
  ↓ fails →
Phase 3: Full-Screenshot AI Vision (Gemini, normalized 0-999 coords)
  ↓ fails →
All phases exhausted — captcha not solved

Phase 1: Algorithmic Pixel Analysis

How it works:

  • Screenshots the #cap_response element directly (not the full page)
  • Opens an isolated Playwright browser context with JS enabled
  • Renders the screenshot into a <canvas> element
  • Scans for saturated colored pixels (S > 35%, L between 25–80%)
  • Clusters pixels by hue + spatial proximity
  • Fits circles via bounding box, samples circumference in 72 sectors (5° each)
  • Identifies the circle with the largest consecutive gap in sector coverage
  • Returns the center coordinates of that circle

Current reliability: ~20% — the photo background produces false colored-pixel clusters that confuse the gap detection.

Phase 2: Element-Targeted AI Vision

How it works:

  • Screenshots just the #cap_response element
  • Sends to Gemini with prompt asking for pixel coordinates relative to the element image
  • Prompt includes the element dimensions (e.g., "bottom-right is 420,420")
  • Uses maxOutputTokens: 64 to force terse responses
  • Clicks at the element-relative position via box.x + clickX, box.y + clickY

Current reliability: ~30% — Gemini sometimes returns empty responses, possibly due to RLHF safety filters on the image content.

Phase 3: Full-Screenshot AI Vision (THE WINNER)

How it works:

  • Screenshots the #cap_response element
  • Sends to Gemini with prompt asking for normalized 0–999 coordinates
  • Converts: clickX = (x / 1000) * box.width, clickY = (y / 1000) * box.height
  • Clicks at absolute viewport position: box.x + clickX, box.y + clickY
  • Uses maxOutputTokens: 512 (needs more room for the normalized format)

Current reliability: ~60–70% — this is what achieved the first successful bypass.


04

Bugs Found & Fixes Applied

Bug 1: Vision JSON Responses Silently Dropped

Symptom: Every single vision API call showed Could not extract coordinates — for weeks.

Root cause: Gemini wraps JSON responses in markdown code fences:

{"x": 493, "y": 282}

Our regex /{[\s\S]*?}/ would match the JSON, but the surrounding backticks caused JSON.parse() to fail silently (caught by an empty catch (e) {}).

Fix: Strip markdown fences before parsing:

rawText = rawText.replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/i, '').trim();

CAUTION: Empty catch {} blocks are debugging poison. Always log parse errors.


Bug 2: Vision Responses Truncated at 128 Tokens

Symptom: Raw responses showed {"x": 83 or {"x": 6 — cut off mid-number.

Root cause: maxOutputTokens: 128 is only ~30 words. If the model adds *any* preamble ("The broken circle is..."), the JSON gets truncated.

Fix: Bumped to maxOutputTokens: 512 for the normalized-coordinate prompt, and 64 for the pixel-coordinate prompt (which uses "Do NOT include any other text" instruction).


Bug 3: Algorithm Picked Noise as "Broken Circle"

Symptom: The algo consistently reported confidence in circles with r=20, arc=29% — tiny fragments that weren't real circles.

Root cause: No minimum radius filter. Pixel sampling at 2px intervals found small clusters of saturated background pixels (e.g., a cat's orange eye patch) and classified them as "broken circles" because their arc coverage was very low.

Fix:

// Before: if (radius < 10) continue;
if (radius < 25) continue;  // Minimum 25px radius

// Filter by arc coverage — real circles must cover at least 40% of circumference
const realCircles = candidates.filter(c => c.arcScore >= 0.40);

The debug image was essential here — we could finally see what the algorithm was looking at.


Bug 4: Coordinate Reference Frame Mismatch

Symptom: The algo found the correct broken circle in screenshot pixel coordinates, but the click landed in the wrong place on the page.

Root cause: The algorithm ran against a full-page screenshot (1920×1080), but the captcha image element was only ~420×420 pixels positioned at an offset within the viewport. The pixel coordinates were relative to the full screenshot, not the element.

Fix: Screenshot the element itself instead of the full page. Coordinates from the algorithm are then element-relative and can be mapped to viewport position via bounding box:

const captchaEl = page.locator('#cap_response');
const screenshotBuffer = await captchaEl.screenshot({ type: "png" });
// ...algo returns { x, y } relative to the element image...
const box = await captchaEl.boundingBox();
const absX = box.x + algoResult.x;
const absY = box.y + algoResult.y;

Bug 5: Crop-to-Element Coordinate Mapping Was Broken

Symptom: The cropped vision strategy got valid coordinates from Gemini but clicked in completely wrong locations.

Root cause: The cropped screenshot was a viewport clip (page.screenshot({ clip: ... })), and the coordinates from Gemini were relative to that crop. Converting crop-relative → element-relative required subtracting the element's offset within the crop — a fragile calculation that was incorrect.

Fix: Eliminated the viewport crop entirely. Both Phase 2 and Phase 3 now screenshot the #cap_response element directly. Coordinates from Gemini map trivially to the element.


Bug 6: Navigation After Successful Click Crashed the Handler

Symptom: "error": "Execution context was destroyed, most likely because of a navigation" — the entire render request returned an error even though the captcha was solved.

Root cause: When the captcha click succeeds, the page navigates away. The polling loop called page.evaluate() on the next iteration, which threw because the old execution context was destroyed.

Fix: Wrap the polling loop in try/catch. Treat navigation/context-destruction errors as success:

try {
  bodyText = await page.evaluate(() => document.body.innerText || '');
} catch (evalErr: any) {
  if (evalErr.message?.includes('navigation') || evalErr.message?.includes('destroyed')) {
    console.log('[CAPTCHA] ✅ Navigation detected — Captcha likely SOLVED!');
    captchaSolved = true;
    break;
  }
}

Bug 7: Bot Detection via Instantaneous Mouse Clicks

Symptom: Early versions used page.mouse.click(x, y) which moves the cursor and clicks in 0ms — a detectable bot signature.

Fix: Human-like mouse movement sequence:

await page.mouse.move(targetX, targetY, { steps: 45 });   // 45-step bezier curve
await page.waitForTimeout(300 + Math.random() * 200);     // 300-500ms hover
await page.mouse.down();
await page.waitForTimeout(50 + Math.random() * 150);      // 50-200ms press
await page.mouse.up();

05

Coordinate Systems Reference

ContextOriginUnitsUsed by
Element-relativeTop-left of #cap_responsePixelsPhase 1 algo, Phase 2 vision
NormalizedTop-left of element0–999 scalePhase 3 vision
Viewport-absoluteTop-left of browser viewportPixelspage.mouse.move()
Form submissionTop-left of <input type="image">Pixels (natural size)Server validation

Converting normalized → element pixels → viewport absolute:

const clickX = Math.round((normalized_x / 1000) * box.width);
const clickY = Math.round((normalized_y / 1000) * box.height);
const absX = box.x + clickX;    // viewport-absolute X
const absY = box.y + clickY;    // viewport-absolute Y

06

Debug Image Saving

Every captcha attempt saves the element screenshot to /tmp/ on the server:

const fs = require('fs');
fs.writeFileSync(`/tmp/captcha_debug_${Date.now()}.png`, screenshotBuffer);

To download and inspect:

scp root@178.156.250.12:/tmp/captcha_debug_*.png ./

TIP: Always visually inspect the debug images when the algorithm is wrong. Without seeing the captcha, you're flying blind — ~8 iterations were wasted before adding this.


07

Lessons Learned (DrugHub)

  • Always log parse errors. An empty catch {} silently swallowed weeks of valid Gemini responses.
  • Screenshot the element, not the page. This eliminates all coordinate mapping headaches.
  • Treat navigation as success. In captcha-solving, if the page navigates after your click, you probably won.
  • Debug images are mandatory. Adding /tmp/captcha_debug_*.png saving was the single most impactful change.
  • LLM vision APIs need room to breathe. maxOutputTokens: 128 + a model that loves preamble = truncated JSON. Default to 512+.
  • The algorithmic approach has a ceiling. Heuristic pixel analysis works for simple cases but struggles with photographic backgrounds. AI vision is more reliable for this captcha type.
  • Anti-bot detection is subtle. Even after getting the right coordinates, instantaneous mouse movement can fail silently. Human-like movement (45-step bezier, random delays) was necessary.

08

Future Improvements

  • [ ] Retry loop at the top level — if Phase 1 fails, reload the page for a fresh captcha before trying Phase 2/3
  • [ ] Use Gemini Pro instead of Flash for harder captchas — may have better spatial reasoning
  • [ ] Local CV with Sharp/OpenCV — detect circle contours directly without sending images to an API (faster, no safety filter risk)
  • [ ] Rate limiting awareness — the captcha server may rate-limit after too many wrong clicks from the same Tor circuit


Part 2

Post-Captcha Rendering Pipeline

Status: ✅ Fully working as of 2026-04-13

Result: Styled marketplace page with sidebar, product listings, vendor info, and images — no popups, no unstyled HTML

IMPORTANT: The key insight: opening a new page in the same browser shares the SOCKS5 proxy and session cookies. A new context or new browser would not have the proxy configured.

TIP: When you can't execute JavaScript on a page, modify the HTML directly with regex. CSS-only toggles are especially easy to defeat since they depend on HTML attributes (checked, open, etc.).

09

Problem: Unstyled HTML

Symptom

The captured HTML rendered as raw, unstyled text — no colors, no layout, no sidebar.

Root Cause

The .onion page references an external stylesheet:

<link href="/static/style.css" rel="stylesheet">

Our renderer captured the raw HTML but didn't fetch or inline the CSS. The non-onion path had CSS inlining via page.evaluate() + fetch(), but that requires JavaScript — which was disabled for .onion pages.

Failed Attempt

We tried creating a new browser context with javaScriptEnabled: true to fetch the CSS, but that context didn't have the Tor SOCKS5 proxy configured, so it couldn't reach the .onion server.

Working Fix

Open a new page in the SAME browser context (which shares the Tor SOCKS5 proxy), navigate to each stylesheet URL, and capture the response text:

const linkMatches = [...rawHtml.matchAll(/<link[^>]*href="([^"]*)"[^>]*rel="stylesheet"/gi)];

const cssPage = await page.context().newPage();

for (const match of linkMatches) {
  const href = match[1];
  const cssUrl = baseOrigin + href;
  const cssResponse = await cssPage.goto(cssUrl, { timeout: 30000, waitUntil: 'commit' });
  if (cssResponse?.ok()) {
    const cssText = await cssResponse.text();
    fetchedCSS.push({ href, cssText });
  }
}
await cssPage.close();

// Replace <link> tags with inline <style> blocks
for (const { href, cssText } of fetchedCSS) {
  html = html.replace(
    new RegExp(`<link[^>]*href=["']${escapeRegex(href)}["'][^>]*>`, 'gi'),
    `<style>/* Inlined from ${href} */\n${cssText}\n</style>`
  );
}

10

Problem: "News and Announcements" Popup Overlay

Symptom

A dark modal overlay covered the entire page with "News and announcements" content.

Root Cause

The popup uses a CSS-only checkbox toggle (no JavaScript required):

<style>
  #checkbox_show_announcements:checked ~ #modal_show_announcements {
    display: block;
  }
</style>
<input type="checkbox" id="checkbox_show_announcements" checked="">
<div class="modal" id="modal_show_announcements">...</div>

Working Fix

Strip the checked attribute from the HTML using regex, and add a CSS safety net:

html = html.replace(
  /(<input[^>]*id="checkbox_show_announcements"[^>]*)checked="[^"]*"/gi, '$1'
);
html = html.replace(
  /(<input[^>]*id="checkbox_show_announcements"[^>]*)checked\b/gi, '$1'
);
html = html.replace('</head>',
  '<style>#modal_show_announcements{display:none!important}</style></head>'
);

11

Problem: "Please Wait" Intermediate Page Captured Instead of Marketplace

Symptom

The captcha solved successfully but the renderer captured the "Please Wait" intermediate page instead of the final marketplace.

Root Cause

After the captcha is solved: Captcha → [click] → "Please Wait" (30s) → "Drug Hub - Listings". The solver checked isSolved by verifying the title no longer contained "captcha" — which was true for "Please Wait" — but didn't wait for the final redirect.

Fix

const needsRedirect =
  t.toLowerCase().includes('please wait') ||
  t.toLowerCase().includes('wait') ||
  bodyText.includes('Captcha solved') ||
  bodyText.includes('connect you to the backend') ||
  bodyText.includes('Please wait');

if (needsRedirect) {
  console.log('[CAPTCHA] On intermediate page — waiting up to 120s...');
  try {
    await page.waitForNavigation({ timeout: 120000 });
  } catch (e) {
    console.log('[CAPTCHA] Redirect wait ended');
  }
  await page.waitForTimeout(3000);
}

Applied to all three solver phases.


12

Problem: `.js-modal` Warning Overlay

Symptom

When viewing the captured HTML in a JS-enabled iframe, a "Warning! JavaScript is enabled" overlay appeared.

Root Cause

The .onion site includes a .js-modal div that displays when JavaScript is active — a security warning to users. The viewer renders in a JS-enabled iframe, triggering this.

Fix

html = html.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '');
html = html.replace(
  /<div[^>]*class="[^"]*js-modal[^"]*"[^>]*>[\s\S]*?<\/div>\s*<\/div>\s*<\/div>\s*<\/div>/gi, ''
);
html = html.replace('</head>', '<style>.js-modal{display:none!important}</style></head>');

13

Complete Post-Captcha Processing Flow

Captcha Solved
  ↓
Wait for "Please Wait" → final redirect (up to 120s)
  ↓
Fetch /static/style.css via new page in same Tor-proxied browser
  ↓
Capture page.content() as raw HTML
  ↓
Inline CSS: replace <link> with <style> blocks
  ↓
Strip popup: remove 'checked' from announcements checkbox
  ↓
Strip scripts: remove all <script> tags
  ↓
Strip .js-modal: remove JS-detection overlay
  ↓
Add safety-net CSS: force-hide modals
  ↓
Return clean, styled HTML

14

Final Result

PhaseSuccess RateNotes
Phase 1 (Algo)~20%Background noise causes false positives
Phase 2 (Element Vision)~0%Gemini Flash returns empty responses (likely safety filters)
Phase 3 (Full Vision)~60–70%Primary solver — normalized coordinates work well
Overall pipeline~80%Phase 3 usually solves within 4 attempts
Post-captcha rendering100%CSS inlining + popup strip works every time
Success rate visualization
Phase 1 (Algo)~20%Phase 2 (Element Vision)~0%Phase 3 (Full Vision)~60–70%Overall pipeline~80%Post-captcha rendering100%


Part 3

Adaptive Multi-Captcha System — Black Ops Solver

Status: ✅ First successful bypass achieved on 2026-04-13

Target: blackops3zlgfuq4dg4yrtxoe57u3sxfa34kqzbooqbovutleqhf3zqd.onion

Iterations to success: ~6 deploy cycles

Winning strategy: Gemini OCR (missing chars extraction) + Gemini Vision (odd-one-out) + element.click({ position })

IMPORTANT: Must use element.click({ position }) NOT page.mouse.click(). The <input type="image"> form submission only works when the click event originates from the element itself. Raw mouse events don't trigger the form POST.

15

Captcha Format — Two-Part Challenge

Black Ops captcha with mirror URL asterisks and odd-one-out icon grid

Black Ops

blackops mirror

Round 1 — mirror OCR + Binance icon odd-one-out click challenge.

The Black Ops marketplace uses a fundamentally different captcha than DrugHub. Both parts are submitted as a single `<form method="post">`:

Part 1: Mirror URL Text Challenge (algorithmic)

  • A dark banner image shows "Mirror:" followed by the site's .onion URL
  • Some characters in the URL are replaced by asterisks (*)
  • User types the missing characters only into <input name="code" type="text">
  • Example: If mirror shows http://blackop**3zlg... and actual URL has blackops3zlg..., answer is s3

Part 2: Odd-One-Out Image Click (vision)

  • A grid of 3D-rendered objects/icons/shapes on a dark background
  • Most objects look identical; exactly one is visually different
  • User clicks the different one via <input name="figure" type="image">
  • Form submits code=CHARS&figure.x=X&figure.y=Y

Key HTML structure:

<form method="post">
  <input name="code" type="text" placeholder="Enter missing characters...">
  <div class="img-container">
    <input name="figure" type="image" src="data:image/png;base64,...">
  </div>
</form>

Timing:

  • Initial page load → 12s meta-refresh redirect
  • Captcha page (title: "Captcha - Black Ops")
  • On success → Title changes to onion domain → marketplace content loads

16

Adaptive Captcha Type Detection

Before running any solver, the system detects which captcha type is present:

type CaptchaType = 'broken_circle' | 'blackops_mirror' | 'altcha_pow' | 'simple_checkbox' | 'generic_vision' | 'none';

async function detectCaptchaType(page: Page): Promise<CaptchaType> {
  const html = await page.content();
  if (html.includes('name="code"') && html.includes('name="figure"')) return 'blackops_mirror';
  if (html.includes('name="cap_response"') || html.includes('id="cap_response"')) return 'broken_circle';
  if (html.includes('altcha-widget') || html.includes('altcha')) return 'altcha_pow';
  // ...
}
SignalCaptcha TypeSolver
input[name="code"] + input[name="figure"]Black Ops MirrorsolveBlackOpsCaptcha()
input[name="cap_response"]DrugHub Broken Circle3-phase waterfall
altcha-widgetALTCHA PoWExisting PoW handler
Generic captcha titleUnknownGeneric vision solver

17

Solver: `solveBlackOpsCaptcha()`

Black Ops captcha round 4 with purple orb odd-one-out grid

Black Ops

blackops mirror

Round 4 — string diff recovered truncated Gemini URL; element.click({ position }) submission.

Step 1: OCR Mirror URL + Extract Missing Characters (Gemini)

The mirror URL text is rendered inside an image (not as HTML text). Gemini reads it.

Prompt strategy:

This captcha shows a dark banner with "Mirror:" followed by a URL.
Some characters are replaced by asterisks (*).

The actual URL is: http://blackops3zlgfuq4dg4yrtxoe57u3sxfa34kqzbooqbovutleqhf3zqd.onion

Reply with ONLY two lines:
Line 1: The missing characters concatenated together
Line 2: The full mirror URL with asterisks

Key insight: Providing the actual URL in the prompt lets Gemini do the comparison directly.

Step 2: String Diff Cross-Check (algorithmic)

if (mirrorUrl.includes('*')) {
  let diffChars = '';
  for (let i = 0; i < mirrorUrl.length && i < actualUrl.length; i++) {
    if (mirrorUrl[i] === '*') diffChars += actualUrl[i];
  }
  missingChars = diffChars;  // deterministic — prefer over Gemini's extraction
}

This was the breakthrough — the string diff is deterministic and reliable, even when Gemini truncates the URL.

Step 3: Detect Odd-One-Out Figure (Gemini Vision)

This image shows a grid of 3D objects. All look similar except ONE that is different.
Return ONLY: {"x": NUMBER, "y": NUMBER}

maxOutputTokens: 512 (same lesson as Bug 2).

Step 4: Submit Form

await page.locator('input[name="code"]').fill(missingChars);

const clampedX = Math.max(1, Math.min(figureX, figBox.width - 1));
const clampedY = Math.max(1, Math.min(figureY, figBox.height - 1));

await Promise.all([
  page.waitForNavigation({ timeout: 30000 }).catch(() => {}),
  figureEl.click({ position: { x: clampedX, y: clampedY }, delay: 80 }),
]);

18

Bugs Found & Fixes Applied (Black Ops)

Bug 1: Gemini Truncates Long URLs

Root cause: maxOutputTokens: 256 exhausted by URL tokens. Each character of a random .onion hash is a separate token. Fix: Bumped to maxOutputTokens: 512. Ask for missing chars FIRST so critical data arrives before truncation.

Bug 2: JSON Prompt Caused Worse Truncation

Root cause: {"mirror": "http://...", "missing": "..."} JSON wrapper added overhead. Fix: Abandoned JSON format. Used simple two-line format — missing chars first.

Bug 3: Figure Coordinates Outside Element Bounds

Root cause: Vision returned (831, 741) for an 800×280 element — coordinates were for the full-page screenshot. Fix: Clamp: Math.max(1, Math.min(figureX, Math.round(figBox.width) - 1)).

Bug 4: Form Not Submitting via Mouse Events

Root cause: page.mouse.click() fires on the page, not the element. <input type="image"> requires element-originated click. Fix: element.click({ position: { x, y } }).

Bug 5: Deprecated Gemini Model (404)

Fix: Update model string to current non-deprecated version.


19

Lessons Learned (Black Ops)

  • Ask for the answer FIRST, the evidence SECOND. When Gemini truncates, you get the critical data.
  • Provide context you already know. Giving Gemini the actual URL lets it compare directly.
  • Deterministic string diff > AI extraction. Even partial OCR is useful with a known reference string.
  • `element.click()` ≠ `mouse.click()` for form submission. <input type="image"> requires element-originated click.
  • Same `maxOutputTokens: 512` that solved DrugHub also solved Black Ops. Proven configuration transfers.

20

Success Rate (Black Ops)

ComponentSuccess RateNotes
Type detection100%name="code" + name="figure" is unambiguous
Mirror URL OCR~60%Often truncated, string diff recovers
String diff extraction~90% (when OCR returns *)Deterministic
Overall text answer~70%Combined OCR + diff
Figure odd-one-out vision~50%Gemini spatial reasoning varies
Both correct (single attempt)~35%Both parts must be correct simultaneously
Solved within 4 rounds~80%1 - (1-0.35)^4 ≈ 82%
Success rate visualization
Type detection100%Mirror URL OCR~60%String diff extraction~90% (when OCR returns `*`)Overall text answer~70%Figure odd-one-out vision~50%Both correct (single attem…~35%Solved within 4 rounds~80%


Part 4

AWazon Market — Elementless Captcha Breakthrough

Status: ✅ First successful bypass achieved on 2026-04-14

Target: awazonnr3xffrrdoz24atino2rhw4wksxpvursumzzwjpshn27whkcqd.onion

Iterations to success: ~6 deploy cycles

Winning strategy: Phase 1 (Algorithmic pixel analysis) with direct viewport clicking

Solve time: ~107–115 seconds total

CAUTION: This is the SECOND time this bug appeared. Any new vision call must default to maxOutputTokens: 512 minimum.

Status: ✅ 3/3 solved on Round 1 (100% success rate)

Target: hn2paw7zaahbikbejiv6h22zwtijlam65y2c77xj2ypbilm2xs4bnbid.onion

Captcha type: text_captcha — distorted text OCR (6-char hex strings on noisy background)

Average solve time: ~38–41s total

Status: ✅ 3/3 solved (100% success rate)

Target: omega7eye5ar3rkrhfr5odhzmlft7mrygp76fuqkh4kian73nd3b7hyd.onion

Captcha type: omega_char — sequential single-character OCR in circular image

Average solve time: ~130–150s (includes ~10s queue wait)

Solve round distribution: All 3 solved on Round 3 of 3

21

The Problem: No Captcha Element

AWazon DDOS protection page with broken-circle captcha and timeout bar

AWazon

broken circle

Elementless broken-circle page — viewport-coordinate fallback required (no cap_response element).

AWazon uses the same broken-circle captcha as DrugHub, but the captcha image is rendered without any discoverable HTML element.

DrugHub (worked):

<input name="cap_response" type="image" src="/captcha_image.png">

AWazon:

Captcha page title: "AWazon Market - DDOS Protection". Text says "Click the broken circle (the one with a cut)" — but findCaptchaImageLocator() returns null for all known selectors (#cap_response, input[type="image"], img[src*="captcha"], canvas, generic img).

Pre-captcha flow:

  • Initial page load → "Access Queue" with spinner
  • 12–15s meta-refresh redirect to captcha page
  • Captcha page with ~60 second "Captcha Timeout" progress bar
  • On success → redirects to marketplace login/register page

22

Root Cause: Element-Dependent Solver Architecture

The entire 3-phase waterfall was built around finding a captcha HTML element. When no element existed:

PhaseFailure mode
Phase 1 (algo)Found broken circle at correct viewport coords → threw "No captcha image element found"never clicked
Phase 2 (vision)findCaptchaImageLocator() → null → continuenever sent to Gemini
Phase 3 (vision)findCaptchaImageLocator() → null → threw → never ran

23

Bugs Found & Fixes Applied (AWazon)

Bug 8: Element-Dependent Click Logic Blocked All Phases

Fix: Fall back to direct viewport clicking when no element exists:

if (captchaImg) {
  // Element found — coords are element-relative, convert to viewport-absolute
  targetX = box.x + algoResult.x;
  targetY = box.y + algoResult.y;
} else {
  // No element — algo ran on full-page screenshot, coords ARE viewport coords
  targetX = algoResult.x;
  targetY = algoResult.y;
}

Bug 9: `findCaptchaImageLocator()` Called 5+ Times (~2.5 Minutes Wasted)

Each call tries 7 selectors × 3s timeout = ~21 seconds when element doesn't exist. Called 5+ times = 2+ minutes of pure waste.

Fix: Cache result once, pass as parameter:

const cachedCaptchaEl = await findCaptchaImageLocator(page);
const algoResult = await solveAlgorithmic(page, cachedCaptchaEl);
const solved2 = await solveCroppedVision(page, apiKey, model, 3, cachedCaptchaEl);
const solved3 = await solveFullScreenVision(page, apiKey, model, 4, cachedCaptchaEl);

Impact: Solve time dropped from 6+ minutes → ~107 seconds.

Bug 10: Phase 2 `maxOutputTokens: 64` Truncated JSON (Again!)

Bug 11: Phase 1 Redirect Poll Wasted 45 Seconds

15 iterations × 3s = 45 seconds before Phase 2 could start. Captcha only gives ~60 seconds. Fix: Reduced to 5 iterations (15s).

Bug 12: Renderer Had No Overall Deadline

Fix: Added 150s render deadline with isDeadlineExceeded() checks before each phase.

Bug 13: Crawl Cancellation Did Not Discard Results

Fix: Added post-render status check to discard results if the crawl was stopped during render.


24

Performance Comparison

MetricBefore fixesAfter fixes
findCaptchaImageLocator() calls5–7 per solve1 (cached)
Time on locator search~140 seconds~21 seconds
Phase 1 redirect poll45 seconds15 seconds
Phase 2 Gemini responsesTruncatedValid JSON
Overall solve time6+ min (always timeout)~107–115 seconds
Renderer self-terminationNever150s deadline

25

Lessons Learned (AWazon)

  • Don't assume an element exists. Build viewport-coordinate fallback from day one.
  • Cache expensive lookups. A 21-second function called 5 times = 2 minutes wasted.
  • `maxOutputTokens` bites twice. Default to 512+ for any vision call. No exceptions.
  • Budget time across phases. 60s captcha timeout − 45s polling = impossible solve window.
  • Frontend stop must actually stop. Patch DB + discard in-flight results.
  • The algorithmic solver won. Phase 1 solved AWazon on first click — viewport-coords approach avoids all coordinate mapping complexity.

26

WeTheNorth — Text Captcha Solver

WeTheNorth distorted hex text captcha on orange background

WeTheNorth

text captcha

drawcaptcha4.php entry gate — 6-char hex OCR with honeypot form filtering.

WeTheNorth distorted hex text captcha on pink background

WeTheNorth

text captcha

Alternate colorway — same OCR pipeline, 100% Round 1 solve rate in testing.

Captcha Format

The site uses a two-stage captcha architecture:

#### Stage 1: Entry Gate (drawcaptcha4.php)

  • Large bold captcha characters (~6 hex chars like 714d1b) in center of image
  • Noisy background with .onion URL at top and mirror domain names at bottom
  • Input: placeholder="What's the captcha? - lowercase only"
  • Anti-phishing: 9 forms total — 3 honeypots (name="fuckno", name="phishersucks", class="phishersucks"), only 1 visible real form with a random hex name attribute

#### Stage 2: Login Page (jcaptcha.php)

  • Not solved — requires credentials; entry gate bypass is sufficient

Anti-Phishing Defense

Form ClassInput NameVisibleType
randomHex1randomHexfalseDecoy
randomHex1fucknofalseHoneypot
randomHex2randomHexfalseDecoy
realClassrandomHex`true`Real form
randomHex3randomHexfalseDecoy
phishersucksphishersucksfalseHoneypot

Detection strategy: page.evaluate() with getComputedStyle() + offsetParent to filter visible forms, then exclude known honeypot names.

Key Bugs Fixed

#BugRoot CauseFix
1maxOutputTokens: 64Copy-pasteChanged to 512
2Empty CSS class selector crashform.${empty}Use input[name="..."] directly
3False "still present" after solvingPost-submit matched login page textCheck for title change to "Login"
4jcaptcha.php treated as unsolvedNo entry-gate vs login distinctionSeparate detection per image source

Success Rate (WeTheNorth)

ComponentSuccess Rate
Captcha detection100% (3/3)
Honeypot avoidance100% (3/3)
Gemini OCR (Round 1)100% (3/3)
Overall solve100% (3/3)
Success rate visualization
Captcha detection100% (3/3)Honeypot avoidance100% (3/3)Gemini OCR (Round 1)100% (3/3)Overall solve100% (3/3)

27

Omega Marketplace — Sequential Character Captcha Solver

Omega marketplace sequential character captcha with six input boxes

Omega

omega char

Full DDOS Protection page — JS-required image swap per input box (c1–c6).

Omega captcha single distorted character in circular frame — position 1

Omega

omega char

Per-box circle crop — character 1 OCR at temperature 0.1.

Omega captcha single distorted character in circular frame — position 3

Omega

omega char

Character 3 — heavy distortion; sequential solve retries up to 3 rounds.

Omega captcha single distorted character in circular frame — position 5

Omega

omega char

Character 5 — circular crop sent to Gemini Flash with maxOutputTokens 512.

Multi-Stage Flow

1. Initial page load → meta-refresh redirect (~5s)
2. "Omega Market | Protection Layer" queue page (JS countdown ~10-15s)
3. "DDOS Protection" captcha page (6-char sequential captcha)
4. Submit → if correct → back to "Protection Layer" (success intermediate)
5. Auto-redirect → marketplace

Captcha Format

  • 6 input boxes: name="c1" through name="c6", maxlength="1", pattern="[A-Za-z0-9]"
  • Circular image (~120–170px): photograph with a single distorted white character overlaid
  • Image swapping: Clicking each input box changes the circle image via JS — shows that box's character
  • Submit: <button class="before" type="submit">Submit</button>
  • Timer: ~60s countdown

Critical Architecture Decision: JavaScript Required

Unlike all other .onion captchas solved (which work with JS disabled), Omega's captcha requires JavaScript for the image-swapping behaviour. Each input box triggers a JS handler that swaps the src of the circle image.

Solution: Create a separate browser context with javaScriptEnabled: true, navigate to the URL fresh, wait for the queue countdown, then solve in the JS-enabled context.

Solver Implementation

Type: omega_char
Model: gemini-flash
maxOutputTokens: 512
Temperature: 0.1 (deterministic reads)
Sequential: Focus each input → screenshot circle → OCR → type → repeat × 6

Success detection: Post-submit title changes from "DDOS Protection" to "Omega Market | Protection Layer" (success intermediate), then auto-redirects.

Queue Page Handling

  • Poll every 5s for up to 90s
  • Check for name="c1" + name="c6" inputs to confirm captcha loaded
  • Handle page.content() errors during navigation (page changing while checking)
  • Fall back after timeout

Key Bugs Fixed

#BugRoot CauseFix
1page reassignment errorDeclared as constChanged to let
2targetUrl is not definedWrong variable nameFixed reference
3False "solved" on queue pageQueue page has no c1-c6 inputsWait loop polls for inputs
4Navigation error in wait looppage.content() during redirecttry/catch + waitForLoadState

Success Rate (Omega)

ComponentSuccess RateNotes
Queue detection100% (3/3)"Protection Layer" title detected
Queue → Captcha navigation100% (3/3)Inputs appear within 10–15s
Per-character OCR~80% per charSome chars misread with heavy distortion
Per-round accuracy (6/6 correct)~33% per roundAll 6 must be correct
Overall solve (within 3 rounds)100% (3/3)1 - (1-0.33)^3 ≈ 70% but 3/3 solved
Success rate visualization
Queue detection100% (3/3)Queue → Captcha navigation100% (3/3)Per-character OCR~80% per charPer-round accuracy (6/6 co…~33% per roundOverall solve (within 3 ro…100% (3/3)


Reference

Master Lessons — Applicable to Any New Captcha Type

14 rules validated across 4 sites

These rules have now been validated across 4 sites and 28 bugs. Apply them before touching a new captcha:

#RuleSource
1Default `maxOutputTokens: 512` for any vision call — never 64 or 128Bugs 2, 10
2Strip markdown fences before JSON.parse() on Gemini responsesBug 1
3Always log parse errors — empty catch {} kills weeks of debuggingBug 1
4Screenshot the element, not the page for coordinate accuracyBug 4
5Build viewport fallback — don't assume a captcha element existsBug 8
6Cache `findCaptchaImageLocator()` — never call it more than once per solveBug 9
7Treat navigation as success in post-click polling loopsBug 6
8Use `element.click({ position })` not page.mouse.click() for <input type="image">Bug 4 (BlackOps)
9Provide known context to Gemini — actual URL, known answer prefix, etc.BlackOps Step 1
10Ask for the answer FIRST in multi-part prompts — truncation cuts from the endBug 1 (BlackOps)
11Deterministic verification > AI when you have a reference string to diff againstBlackOps Step 2
12Human-like mouse movement — 45-step bezier, 300–500ms hover, random press delayBug 7
13Add a render deadline — Convex timeouts require explicit self-terminationBug 12
14Save debug screenshots to `/tmp/` — mandatory from day one, not an afterthoughtSection 6