Browse Source

fix(client): VK captcha new format, Android TLS, auto-solver improvements

Three intertwined fixes that together make the client usable again on
Android and against the new VK captcha flow that started 2026-06-25.

== Fix #182 — captcha parser for new VK error format ==
VK started returning a captcha-required error (error_code 14) at the
calls.getAnonymousToken step in a NEW shape that only carries:

    error_code, error_msg, is_enabled_captcha, redirect_uri

and notably NO captcha_sid and NO captcha_img. The previous parser
rejected this payload with "missing captcha_sid in captcha error data"
and returned nil, so the auth loop never reached the captcha solver
and just fell through to the next client_id — exactly the regression
reported in issue #182.

ParseVkCaptchaError now treats redirect_uri, captcha_sid, captcha_img
and error_msg as optional and only requires error_code plus at least
one solvable path (redirect_uri+session_token OR captcha_img/captcha_sid).
The new flow's request body no longer sends an empty captcha_sid when
only success_token is available.

Tests in captcha_parse_test.go cover:
  - new format without captcha_sid (issue #182 repro)
  - legacy format with captcha_sid + captcha_img
  - numeric captcha_sid
  - unsolvable payload returns nil
  - missing error_code returns nil

== Android TLS — embedded Mozilla CA bundle ==
VK switched *.vk.com to a certificate issued by HARICA TLS RSA Root
CA 2021 (a Greek CA added to the Mozilla trust store only in 2022).
The bogdanfinn/tls-client library leaves RootCAs as nil by default,
which makes Go fall back to x509.SystemCertPool() — and that pool is
EMPTY on Android apps (no /etc/ssl/certs/), so every HTTPS request
failed with:
    tls: failed to verify certificate: x509: certificate signed by unknown authority

A new client/ca_bundle.go helper loads a CA pool with the following
priority:
  1. SSL_CERT_FILE env var (if set)
  2. x509.SystemCertPool() (desktops/servers)
  3. Embedded Mozilla CA bundle (client/certs/cacert.pem, 189 KB,
     contains HARICA TLS RSA/ECC Root CA 2021)

The embedded bundle is the same one curl ships, downloaded from
https://curl.se/ca/cacert.pem. A VKTURN_INSECURE_TLS env var is
honored for emergency debugging (not exposed as a CLI flag —
production should always verify).

== Auto-captcha improvements ==
After reverse-engineering not_robot_captcha.js (878 KB), I found three
bugs in the existing inline captchaNotRobot.check flow that each
alone caused VK to return status:BOT:

  1. PoW hash was sent as bare hex. VK's JS produces:
         window.captchaPowResult = "v2." + btoa(JSON.stringify({hash, nonce}))
     Now solvePoW returns (hash, nonce) and formatPoWResult wraps them.

  2. debug_info was a random per-request MD5. VK's JS sends either
     window.vk.brlefapmjnpg or the hardcoded fallback
     "4045edac1cb2c18eff209dc09cd5e2e56475a13ed4615413a87618b3c6813f9a".
     Now uses the hardcoded fallback.

  3. captchaNotRobot.initSession was never called. VK's JS always calls
     it before settings; without it the server-side session state is
     not fully initialized. Now called as Step 0/4 in both
     callCaptchaNotRobot and callCaptchaNotRobotWithSliderPOC.

Also fixed:
  - connectionRtt was a constant [50,50,50,...] — now generated with
    realistic jitter (45-84ms baseline, occasional spikes).
  - connectionDownlink was a constant [9.5,9.5,...] — now generated
    with mild per-sample jitter.
  - check response is now logged (status, show_captcha_type, redirect)
    so future debugging is easier.

NOTE: even with all three fixes, VK still returns status:BOT when the
client runs from a datacenter IP or without a real browser. The reason
is browser_fp = FingerprintJS visitor_id (computed via canvas/webgl/
font fingerprinting in the browser) — this cannot be faked from Go
code. The next commit adds a Playwright-based solver that runs a real
headless Chromium to produce a valid browser_fp.
pull/183/head
SokDobry 2 weeks ago
parent
commit
217c0d8996
  1. 82
      client/ca_bundle.go
  2. 146
      client/captcha_parse_test.go
  3. 2957
      client/certs/cacert.pem
  4. 284
      client/main.go
  5. 72
      client/manual_captcha.go
  6. 42
      client/slider_captcha.go

82
client/ca_bundle.go

@ -0,0 +1,82 @@
package main
import (
"crypto/x509"
_ "embed"
"log"
"os"
"strings"
)
//go:embed certs/cacert.pem
var mozillaCABundlePEM []byte
// loadCABundle returns a *x509.CertPool that can verify VK (and any other
// TLS site) even on platforms where the system certificate store is empty
// or unavailable — most notably Android apps that ship without /etc/ssl/certs
// and have no access to the OS cert store.
//
// Strategy:
// 1. Try x509.SystemCertPool(). If it returns a non-empty pool, use it
// (this is the normal case on Linux/macOS/Windows desktops and servers).
// 2. If SystemCertPool is unavailable or empty, fall back to the embedded
// Mozilla CA bundle (cacert.pem, the same one curl ships). This contains
// every CA in the Mozilla trust program, including HARICA TLS RSA/ECC
// Root CA 2021 which VK uses for *.vk.com since mid-2026.
// 3. Also honor the SSL_CERT_FILE / SSL_CERT_DIR env vars if set, so users
// can override on unusual systems.
//
// Without this, on Android the tls-client (bogdanfinn) leaves RootCAs == nil,
// which makes Go fall back to SystemCertPool — and on Android that pool is
// empty, so every HTTPS request fails with:
//
// tls: failed to verify certificate: x509: certificate signed by unknown authority
//
// See: https://github.com/cacggghp/vk-turn-proxy/issues/182 (and follow-ups).
func loadCABundle() *x509.CertPool {
// 1. Honor explicit env overrides first (mirrors crypto/x509 behavior).
if file := os.Getenv("SSL_CERT_FILE"); file != "" {
if p, err := loadPoolFromFile(file); err == nil && p != nil {
log.Printf("[CABundle] using SSL_CERT_FILE=%s", file)
return p
}
}
// 2. Try the system pool.
if sys, err := x509.SystemCertPool(); err == nil && sys != nil && len(sys.Subjects()) > 0 {
log.Printf("[CABundle] using system cert pool (%d roots)", len(sys.Subjects()))
return sys
}
// 3. Fall back to the embedded Mozilla bundle.
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(mozillaCABundlePEM) {
log.Printf("[CABundle] WARNING: embedded Mozilla bundle did not parse; HTTPS will likely fail")
} else {
log.Printf("[CABundle] using embedded Mozilla CA bundle (%d bytes)", len(mozillaCABundlePEM))
}
return pool
}
// loadPoolFromFile loads a PEM file with one or more certificates into a fresh
// CertPool. Returns nil if the file contained no usable certificates.
func loadPoolFromFile(path string) (*x509.CertPool, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(data) {
return nil, nil
}
return pool, nil
}
// hasHTTPSInsecureEnv returns true if the user explicitly requested skipping
// TLS verification (useful for debugging MITM proxies or stale CA bundles).
// This is intentionally NOT exposed as a CLI flag — production should always
// verify.
func hasHTTPSInsecureEnv() bool {
v := strings.ToLower(os.Getenv("VKTURN_INSECURE_TLS"))
return v == "1" || v == "true" || v == "yes"
}

146
client/captcha_parse_test.go

@ -0,0 +1,146 @@
package main
import "testing"
// TestParseVkCaptchaError_NewFormatWithoutCaptchaSid reproduces issue #182.
//
// VK started returning a captcha-required error (error_code 14) at the
// calls.getAnonymousToken step in a NEW shape that only carries:
//
// error_code, error_msg, is_enabled_captcha, redirect_uri
//
// and notably NO captcha_sid and NO captcha_img. The previous parser
// rejected this payload with "missing captcha_sid in captcha error data"
// and returned nil, so the auth loop never reached the captcha solver and
// just fell through to the next client_id.
//
// After the fix, ParseVkCaptchaError must return a non-nil *VkCaptchaError
// with RedirectURI + SessionToken populated so both the auto solver and the
// manual (browser) solver can run.
func TestParseVkCaptchaError_NewFormatWithoutCaptchaSid(t *testing.T) {
t.Parallel()
// Shape taken verbatim from the issue #182 log (session_token truncated
// for readability but structurally identical to the real payload).
errData := map[string]interface{}{
"error_code": float64(14),
"error_msg": "Captcha need",
"is_enabled_captcha": true,
"redirect_uri": "https://id.vk.ru/not_robot_captcha?domain=vk.com&session_token=eyJhbGciOiJBMjU2R0NNS1ci.payload.signature&variant=popup&blank=1",
"captcha_stid": "some-stid", // unrelated field, must be ignored
}
got := ParseVkCaptchaError(errData)
if got == nil {
t.Fatal("expected non-nil VkCaptchaError for new captcha format, got nil")
}
if got.ErrorCode != 14 {
t.Fatalf("ErrorCode = %d, want 14", got.ErrorCode)
}
if got.ErrorMsg != "Captcha need" {
t.Fatalf("ErrorMsg = %q, want %q", got.ErrorMsg, "Captcha need")
}
if got.RedirectURI == "" {
t.Fatal("RedirectURI is empty, expected the not_robot_captcha URL")
}
if got.SessionToken == "" {
t.Fatal("SessionToken is empty, expected it to be parsed from redirect_uri query")
}
if got.SessionToken != "eyJhbGciOiJBMjU2R0NNS1ci.payload.signature" {
t.Fatalf("SessionToken = %q, want the session_token query value", got.SessionToken)
}
// captcha_sid / captcha_img are absent in the new format and must default
// to empty strings (NOT cause the parser to bail out).
if got.CaptchaSid != "" {
t.Fatalf("CaptchaSid = %q, want empty (not present in new format)", got.CaptchaSid)
}
if got.CaptchaImg != "" {
t.Fatalf("CaptchaImg = %q, want empty (not present in new format)", got.CaptchaImg)
}
if !got.IsCaptchaError() {
t.Fatal("IsCaptchaError() = false, want true (new flow is solvable via redirect_uri + session_token)")
}
}
// TestParseVkCaptchaError_LegacyFormatStillWorks ensures the legacy captcha
// payload (captcha_sid + captcha_img, no redirect_uri) still parses so the
// manual HTTP captcha path keeps working.
func TestParseVkCaptchaError_LegacyFormatStillWorks(t *testing.T) {
t.Parallel()
errData := map[string]interface{}{
"error_code": float64(14),
"error_msg": "Captcha need",
"captcha_sid": "1234567890",
"captcha_img": "https://api.vk.com/captcha.php?sid=1234567890&s=1",
}
got := ParseVkCaptchaError(errData)
if got == nil {
t.Fatal("expected non-nil VkCaptchaError for legacy captcha format, got nil")
}
if got.CaptchaSid != "1234567890" {
t.Fatalf("CaptchaSid = %q, want 1234567890", got.CaptchaSid)
}
if got.CaptchaImg == "" {
t.Fatal("CaptchaImg is empty, expected the captcha image URL")
}
if got.RedirectURI != "" {
t.Fatalf("RedirectURI = %q, want empty in legacy flow", got.RedirectURI)
}
}
// TestParseVkCaptchaError_NumericCaptchaSid covers the case where VK returns
// captcha_sid as a JSON number (unmarshalled into float64).
func TestParseVkCaptchaError_NumericCaptchaSid(t *testing.T) {
t.Parallel()
errData := map[string]interface{}{
"error_code": float64(14),
"error_msg": "Captcha need",
"captcha_sid": float64(9876543210),
"captcha_img": "https://api.vk.com/captcha.php?sid=9876543210",
}
got := ParseVkCaptchaError(errData)
if got == nil {
t.Fatal("expected non-nil VkCaptchaError, got nil")
}
if got.CaptchaSid != "9876543210" {
t.Fatalf("CaptchaSid = %q, want 9876543210", got.CaptchaSid)
}
}
// TestParseVkCaptchaError_UnsolvableReturnsNil verifies that when none of the
// solvable fields are present, the parser returns nil so the caller surfaces
// the raw VK error instead of pretending it can solve a captcha it cannot.
func TestParseVkCaptchaError_UnsolvableReturnsNil(t *testing.T) {
t.Parallel()
errData := map[string]interface{}{
"error_code": float64(14),
"error_msg": "Captcha need",
// no redirect_uri, no captcha_sid, no captcha_img
}
if got := ParseVkCaptchaError(errData); got != nil {
t.Fatalf("expected nil for unsolvable captcha payload, got %+v", got)
}
}
// TestParseVkCaptchaError_MissingErrorCodeReturnsNil verifies the parser still
// rejects payloads that don't even identify as a VK API error object.
func TestParseVkCaptchaError_MissingErrorCodeReturnsNil(t *testing.T) {
t.Parallel()
errData := map[string]interface{}{
"error_msg": "Captcha need",
"redirect_uri": "https://id.vk.ru/not_robot_captcha?session_token=x",
}
if got := ParseVkCaptchaError(errData); got != nil {
t.Fatalf("expected nil when error_code is missing, got %+v", got)
}
}

2957
client/certs/cacert.pem

File diff suppressed because it is too large

284
client/main.go

@ -21,6 +21,7 @@ import (
neturl "net/url" neturl "net/url"
"os" "os"
"os/signal" "os/signal"
"runtime"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
@ -67,6 +68,7 @@ var (
isDebug bool isDebug bool
manualCaptcha bool manualCaptcha bool
autoCaptchaSliderPOC bool autoCaptchaSliderPOC bool
captchaSolverURL string // e.g. "http://127.0.0.1:8766" — Playwright-based solver
) )
type captchaSolveMode int type captchaSolveMode int
@ -75,9 +77,33 @@ const (
captchaSolveModeAuto captchaSolveMode = iota captchaSolveModeAuto captchaSolveMode = iota
captchaSolveModeSliderPOC captchaSolveModeSliderPOC
captchaSolveModeManual captchaSolveModeManual
captchaSolveModePlaywright
) )
func captchaSolveModeForAttempt(attempt int, manualOnly bool, enableSliderPOC bool) (captchaSolveMode, bool) { func captchaSolveModeForAttempt(attempt int, manualOnly bool, enableSliderPOC bool) (captchaSolveMode, bool) {
// If a Playwright solver URL is configured, try it FIRST — it's the only
// mode that reliably passes VK's bot detection (because the check request
// comes from a real Chromium with a real FingerprintJS visitor_id).
// Fall back to the legacy modes if the solver is unreachable / fails.
if captchaSolverURL != "" && !manualOnly {
switch attempt {
case 0:
return captchaSolveModePlaywright, true
case 1:
return captchaSolveModeAuto, true
case 2:
if enableSliderPOC {
return captchaSolveModeSliderPOC, true
}
return captchaSolveModeManual, true
case 3:
if enableSliderPOC {
return captchaSolveModeManual, true
}
}
return 0, false
}
if manualOnly { if manualOnly {
return captchaSolveModeManual, attempt == 0 return captchaSolveModeManual, attempt == 0
} }
@ -107,6 +133,8 @@ func captchaSolveModeLabel(mode captchaSolveMode) string {
return "auto captcha slider POC" return "auto captcha slider POC"
case captchaSolveModeManual: case captchaSolveModeManual:
return "manual captcha" return "manual captcha"
case captchaSolveModePlaywright:
return "playwright solver"
default: default:
return "captcha" return "captcha"
} }
@ -247,6 +275,53 @@ func generateFakeCursor() string {
return "[" + strings.Join(points, ",") + "]" return "[" + strings.Join(points, ",") + "]"
} }
// generateConnectionRtt produces a realistic Navigator.connection.rtt sample
// array. Real browsers observe slight per-sample variation around a baseline
// (e.g. 50ms +/- 5-15ms jitter, occasional spikes). Sending the constant
// [50,50,50,...] is a known bot fingerprint.
func generateConnectionRtt(n int) string {
if n <= 0 {
n = 10
}
base := 45 + rand.Intn(40) // 45..84 ms baseline
parts := make([]string, 0, n)
for i := 0; i < n; i++ {
// jitter: -10..+30 ms, with occasional spike (+30..+90)
jitter := rand.Intn(41) - 10
if rand.Intn(8) == 0 { // ~12% chance of a spike
jitter += 30 + rand.Intn(60)
}
val := base + jitter
if val < 5 {
val = 5
}
parts = append(parts, strconv.Itoa(val))
}
return "[" + strings.Join(parts, ",") + "]"
}
// generateConnectionDownlink produces a realistic Navigator.connection.downlink
// sample array. Real values are MBps floats with mild variation (e.g. 9.5, 9.6,
// 9.4, 10.1, ...). Constant [9.5,9.5,...] is a known bot fingerprint.
func generateConnectionDownlink(n int) string {
if n <= 0 {
n = 16
}
// baseline 5.0..15.0 Mbps, one decimal place
baseTenths := 50 + rand.Intn(100) // 5.0..14.9
parts := make([]string, 0, n)
for i := 0; i < n; i++ {
// jitter -3..+3 tenths
jitter := rand.Intn(7) - 3
t := baseTenths + jitter
if t < 10 {
t = 10 // floor at 1.0 Mbps
}
parts = append(parts, fmt.Sprintf("%.1f", float64(t)/10.0))
}
return "[" + strings.Join(parts, ",") + "]"
}
func getCustomNetDialer() net.Dialer { func getCustomNetDialer() net.Dialer {
return net.Dialer{ return net.Dialer{
Timeout: 20 * time.Second, Timeout: 20 * time.Second,
@ -287,7 +362,7 @@ type VkCaptchaError struct {
} }
func ParseVkCaptchaError(errData map[string]interface{}) *VkCaptchaError { func ParseVkCaptchaError(errData map[string]interface{}) *VkCaptchaError {
// Extract error_code // Extract error_code (required — identifies this as a VK API error object)
codeFloat, ok := errData["error_code"].(float64) codeFloat, ok := errData["error_code"].(float64)
if !ok { if !ok {
log.Printf("missing error_code in captcha error data") log.Printf("missing error_code in captcha error data")
@ -295,48 +370,45 @@ func ParseVkCaptchaError(errData map[string]interface{}) *VkCaptchaError {
} }
code := int(codeFloat) code := int(codeFloat)
// Extract redirect_uri // Extract redirect_uri (optional). Present in the NEW VK captcha flow where
RedirectURI, ok := errData["redirect_uri"].(string) // the user/automaton is redirected to https://id.vk.ru/not_robot_captcha?...
if !ok { // Absent in the legacy captcha flow (which uses captcha_sid + captcha_img).
log.Printf("missing redirect_uri in captcha error data") RedirectURI, _ := errData["redirect_uri"].(string)
return nil
}
// Extract captcha_sid // Extract captcha_sid (optional in the new flow, required in the legacy flow).
captchaSid, ok := errData["captcha_sid"].(string) captchaSid, ok := errData["captcha_sid"].(string)
if !ok { if !ok {
// try numeric // try numeric
if sidNum, ok2 := errData["captcha_sid"].(float64); ok2 { if sidNum, ok2 := errData["captcha_sid"].(float64); ok2 {
captchaSid = fmt.Sprintf("%.0f", sidNum) captchaSid = fmt.Sprintf("%.0f", sidNum)
} else {
log.Printf("missing captcha_sid in captcha error data")
return nil
} }
} }
// Extract captcha_img // Extract captcha_img (optional in the new flow, required in the legacy flow).
captchaImg, ok := errData["captcha_img"].(string) captchaImg, _ := errData["captcha_img"].(string)
if !ok {
log.Printf("missing captcha_img in captcha error data")
return nil
}
// Extract error_msg // Extract error_msg (optional — only used for logging/diagnostics).
errorMsg, ok := errData["error_msg"].(string) errorMsg, _ := errData["error_msg"].(string)
if !ok {
log.Printf("missing error_msg in captcha error data")
return nil
}
// Extract session token if redirect_uri present // Extract session token if redirect_uri present
var sessionToken string var sessionToken string
if RedirectURI != "" { if RedirectURI != "" {
if parsed, err := neturl.Parse(RedirectURI); err == nil { parsed, err := neturl.Parse(RedirectURI)
sessionToken = parsed.Query().Get("session_token") if err != nil {
} else {
log.Printf("failed to parse redirect_uri: %v", err) log.Printf("failed to parse redirect_uri: %v", err)
return nil return nil
} }
sessionToken = parsed.Query().Get("session_token")
}
// Require at least one solvable captcha path before returning a result:
// - new flow: redirect_uri + session_token (solved via not_robot_captcha)
// - legacy flow: captcha_img (manual HTTP) typically paired with captcha_sid
// Without any of these, there is nothing the caller can do, so return nil
// and let the auth loop surface the raw VK API error.
if RedirectURI == "" && sessionToken == "" && captchaImg == "" && captchaSid == "" {
log.Printf("no solvable captcha path in error data: %v", errData)
return nil
} }
// Extract is_sound_captcha_available // Extract is_sound_captcha_available
@ -400,22 +472,26 @@ func solveVkCaptcha(ctx context.Context, captchaErr *VkCaptchaError, streamID in
log.Printf("[STREAM %d] [Captcha] PoW input: %s, difficulty: %d", streamID, bootstrap.PowInput, bootstrap.Difficulty) log.Printf("[STREAM %d] [Captcha] PoW input: %s, difficulty: %d", streamID, bootstrap.PowInput, bootstrap.Difficulty)
hash := solvePoW(bootstrap.PowInput, bootstrap.Difficulty) hexHash, nonce, ok := solvePoW(bootstrap.PowInput, bootstrap.Difficulty)
log.Printf("[STREAM %d] [Captcha] PoW solved: hash=%s", streamID, hash) if !ok {
return "", fmt.Errorf("PoW solve exhausted nonce budget (input=%s, difficulty=%d)", bootstrap.PowInput, bootstrap.Difficulty)
}
powResult := formatPoWResult(hexHash, nonce)
log.Printf("[STREAM %d] [Captcha] PoW solved: hash=%s nonce=%d (v2-wrapped, len=%d)", streamID, hexHash, nonce, len(powResult))
var successToken string var successToken string
if useSliderPOC { if useSliderPOC {
successToken, err = callCaptchaNotRobotWithSliderPOC( successToken, err = callCaptchaNotRobotWithSliderPOC(
ctx, ctx,
captchaErr.SessionToken, captchaErr.SessionToken,
hash, powResult,
streamID, streamID,
client, client,
profile, profile,
bootstrap.Settings, bootstrap.Settings,
) )
} else { } else {
successToken, err = callCaptchaNotRobot(ctx, captchaErr.SessionToken, hash, streamID, client, profile) successToken, err = callCaptchaNotRobot(ctx, captchaErr.SessionToken, powResult, streamID, client, profile)
} }
if err != nil { if err != nil {
return "", fmt.Errorf("captchaNotRobot API failed: %w", err) return "", fmt.Errorf("captchaNotRobot API failed: %w", err)
@ -459,17 +535,64 @@ func fetchCaptchaBootstrap(ctx context.Context, redirectURI string, client tlscl
return parseCaptchaBootstrapHTML(string(body)) return parseCaptchaBootstrapHTML(string(body))
} }
func solvePoW(powInput string, difficulty int) string { // solvePoW finds a nonce so that sha256(powInput + str(nonce)) starts with
// `difficulty` zero characters. Returns (hexHash, nonce, ok).
//
// VK's not_robot_captcha.js stores the result as:
//
// window.captchaPowResult = "v2." + btoa(JSON.stringify({ hash, nonce }))
//
// so callers must wrap the return value via formatPoWResult() before sending
// it as the `hash` field of captchaNotRobot.check — sending the bare hex hash
// is the #1 reason VK's bot detector flags the request as "BOT".
func solvePoW(powInput string, difficulty int) (string, int, bool) {
target := strings.Repeat("0", difficulty) target := strings.Repeat("0", difficulty)
for nonce := 1; nonce <= 10000000; nonce++ { for nonce := 1; nonce <= 10000000; nonce++ {
data := powInput + strconv.Itoa(nonce) data := powInput + strconv.Itoa(nonce)
hash := sha256.Sum256([]byte(data)) hash := sha256.Sum256([]byte(data))
hexHash := hex.EncodeToString(hash[:]) hexHash := hex.EncodeToString(hash[:])
if strings.HasPrefix(hexHash, target) { if strings.HasPrefix(hexHash, target) {
return hexHash return hexHash, nonce, true
}
}
return "", 0, false
} }
// formatPoWResult wraps (hash, nonce) into the v2.<base64(JSON)> format that
// VK's not_robot_captcha.js produces. Mirrors:
//
// "v2." + btoa(JSON.stringify({ hash, nonce }))
//
// where btoa is RFC 4648 standard base64 (no padding stripping).
func formatPoWResult(hexHash string, nonce int) string {
payload := fmt.Sprintf(`{"hash":%q,"nonce":%d}`, hexHash, nonce)
encoded := base64.StdEncoding.EncodeToString([]byte(payload))
return "v2." + encoded
}
// captchaDebugInfoHardcoded is the fallback value VK's not_robot_captcha.js
// sends when window.vk.brlefapmjnpg is undefined:
//
// debug_info: window.vk?.brlefapmjnpg || "4045edac1cb2c18eff209dc09cd5e2e56475a13ed4615413a87618b3c6813f9a"
//
// Sending a random MD5 here (the previous behavior) is one of the strongest
// bot signals VK checks.
const captchaDebugInfoHardcoded = "4045edac1cb2c18eff209dc09cd5e2e56475a13ed4615413a87618b3c6813f9a"
// formatCaptchaAnswer mirrors VK's not_robot_captcha.js:
//
// answer: $w(JSON.stringify({ value: t }))
//
// where $w(s) = btoa(unescape(encodeURIComponent(s))) — i.e. standard base64
// of the UTF-8 bytes. For a checkbox-style check, t is the user-event value;
// passing an empty object matches what the JS sends for a synthetic click.
func formatCaptchaAnswer(valueJSON string) string {
if valueJSON == "" {
valueJSON = "{}"
} }
return "" // JSON.stringify({value: t}) — t is already JSON-encoded by caller when needed
payload := `{"value":` + valueJSON + `}`
return base64.StdEncoding.EncodeToString([]byte(payload))
} }
func callCaptchaNotRobot(ctx context.Context, sessionToken, hash string, streamID int, client tlsclient.HttpClient, profile Profile) (string, error) { func callCaptchaNotRobot(ctx context.Context, sessionToken, hash string, streamID int, client tlsclient.HttpClient, profile Profile) (string, error) {
@ -519,6 +642,21 @@ func callCaptchaNotRobot(ctx context.Context, sessionToken, hash string, streamI
baseParams := fmt.Sprintf("session_token=%s&domain=vk.com&adFp=&access_token=", neturl.QueryEscape(sessionToken)) baseParams := fmt.Sprintf("session_token=%s&domain=vk.com&adFp=&access_token=", neturl.QueryEscape(sessionToken))
// Step 0: initSession — VK's not_robot_captcha.js calls this before
// settings. Without it the server-side session state is not fully
// initialized, which is one of the signals that flags the request as
// "BOT" even when everything else looks legitimate.
log.Printf("[STREAM %d] [Captcha] Step 0/4: initSession", streamID)
if initResp, err := vkReq("captchaNotRobot.initSession", baseParams); err != nil {
log.Printf("[STREAM %d] [Captcha] Warning: initSession failed: %v (continuing)", streamID, err)
} else if respObj, ok := initResp["response"].(map[string]interface{}); ok {
if showType, _ := respObj["show_captcha_type"].(string); showType != "" {
log.Printf("[STREAM %d] [Captcha] initSession: show_captcha_type=%s", streamID, showType)
}
}
time.Sleep(200 * time.Millisecond)
log.Printf("[STREAM %d] [Captcha] Step 1/4: settings", streamID) log.Printf("[STREAM %d] [Captcha] Step 1/4: settings", streamID)
if _, err := vkReq("captchaNotRobot.settings", baseParams); err != nil { if _, err := vkReq("captchaNotRobot.settings", baseParams); err != nil {
return "", fmt.Errorf("settings failed: %w", err) return "", fmt.Errorf("settings failed: %w", err)
@ -539,14 +677,19 @@ func callCaptchaNotRobot(ctx context.Context, sessionToken, hash string, streamI
log.Printf("[STREAM %d] [Captcha] Step 3/4: check", streamID) log.Printf("[STREAM %d] [Captcha] Step 3/4: check", streamID)
cursorJSON := generateFakeCursor() cursorJSON := generateFakeCursor()
answer := base64.StdEncoding.EncodeToString([]byte("{}")) // answer = $w(JSON.stringify({value: t})). For checkbox-style checks
// t is an empty object, so we wrap {} under "value".
answer := formatCaptchaAnswer("{}")
// Dynamically generate debug_info to avoid static fingerprint bans // debug_info: VK's not_robot_captcha.js sends either window.vk.brlefapmjnpg
debugInfoBytes := md5.Sum([]byte(profile.UserAgent + strconv.FormatInt(time.Now().UnixNano(), 10))) // (which we don't have, since we're not running inside the page) or the
debugInfo := hex.EncodeToString(debugInfoBytes[:]) // hardcoded fallback. Sending a random MD5 here is a strong bot signal.
debugInfo := captchaDebugInfoHardcoded
connectionRtt := "[50,50,50,50,50,50,50,50,50,50]" // Realistic connection telemetry — slight per-sample jitter so the
connectionDownlink := "[9.5,9.5,9.5,9.5,9.5,9.5,9.5,9.5,9.5,9.5,9.5,9.5,9.5,9.5,9.5,9.5]" // array doesn't look like a constant signature.
connectionRtt := generateConnectionRtt(10)
connectionDownlink := generateConnectionDownlink(16)
checkData := baseParams + fmt.Sprintf( checkData := baseParams + fmt.Sprintf(
"&accelerometer=%s&gyroscope=%s&motion=%s&cursor=%s&taps=%s&connectionRtt=%s&connectionDownlink=%s&browser_fp=%s&hash=%s&answer=%s&debug_info=%s", "&accelerometer=%s&gyroscope=%s&motion=%s&cursor=%s&taps=%s&connectionRtt=%s&connectionDownlink=%s&browser_fp=%s&hash=%s&answer=%s&debug_info=%s",
@ -561,6 +704,16 @@ func callCaptchaNotRobot(ctx context.Context, sessionToken, hash string, streamI
return "", fmt.Errorf("check failed: %w", err) return "", fmt.Errorf("check failed: %w", err)
} }
// Log the raw check response so future debugging is easier. VK returns
// {status:"OK|BOT", success_token, show_captcha_type, redirect}.
if respObj, ok := checkResp["response"].(map[string]interface{}); ok {
status, _ := respObj["status"].(string)
showType, _ := respObj["show_captcha_type"].(string)
redirect, _ := respObj["redirect"].(string)
log.Printf("[STREAM %d] [Captcha] check response: status=%s show_captcha_type=%q redirect=%q",
streamID, status, showType, redirect)
}
respObj, ok := checkResp["response"].(map[string]interface{}) respObj, ok := checkResp["response"].(map[string]interface{})
if !ok { if !ok {
return "", fmt.Errorf("invalid check response: %v", checkResp) return "", fmt.Errorf("invalid check response: %v", checkResp)
@ -816,12 +969,27 @@ func getTokenChain(ctx context.Context, link string, streamID int, creds VKCrede
SecChUaPlatform: `"Windows"`, SecChUaPlatform: `"Windows"`,
} }
client, err := tlsclient.NewHttpClient(tlsclient.NewNoopLogger(), clientOpts := []tlsclient.HttpClientOption{
tlsclient.WithTimeoutSeconds(20), tlsclient.WithTimeoutSeconds(20),
tlsclient.WithClientProfile(profiles.Chrome_146), tlsclient.WithClientProfile(profiles.Chrome_146),
tlsclient.WithCookieJar(jar), tlsclient.WithCookieJar(jar),
tlsclient.WithDialer(getCustomNetDialer()), tlsclient.WithDialer(getCustomNetDialer()),
) }
// Provide an explicit RootCAs pool. Without it, tls-client falls back
// to x509.SystemCertPool() which is empty on Android apps and causes
// every HTTPS request to fail with "x509: certificate signed by
// unknown authority". loadCABundle() handles system + embedded
// Mozilla fallback transparently.
if rootPool := loadCABundle(); rootPool != nil {
clientOpts = append(clientOpts, tlsclient.WithTransportOptions(
&tlsclient.TransportOptions{RootCAs: rootPool},
))
}
if hasHTTPSInsecureEnv() {
log.Printf("[STREAM %d] [VK Auth] WARNING: VKTURN_INSECURE_TLS set — skipping TLS verification (debug only!)", streamID)
clientOpts = append(clientOpts, tlsclient.WithInsecureSkipVerify())
}
client, err := tlsclient.NewHttpClient(tlsclient.NewNoopLogger(), clientOpts...)
if err != nil { if err != nil {
return "", "", "", fmt.Errorf("failed to initialize tls_client: %w", err) return "", "", "", fmt.Errorf("failed to initialize tls_client: %w", err)
} }
@ -936,6 +1104,16 @@ func getTokenChain(ctx context.Context, link string, streamID int, creds VKCrede
var solveErr error var solveErr error
switch solveMode { switch solveMode {
case captchaSolveModePlaywright:
log.Printf("[STREAM %d] [Captcha] Using Playwright solver (real Chromium)...", streamID)
if captchaErr.RedirectURI != "" {
successToken, solveErr = solveCaptchaViaPlaywright(captchaErr.RedirectURI, streamID)
if solveErr != nil {
log.Printf("[STREAM %d] [Captcha] Playwright solver failed: %v", streamID, solveErr)
}
} else {
solveErr = fmt.Errorf("no redirect_uri for playwright solver")
}
case captchaSolveModeAuto: case captchaSolveModeAuto:
if captchaErr.SessionToken != "" && captchaErr.RedirectURI != "" { if captchaErr.SessionToken != "" && captchaErr.RedirectURI != "" {
successToken, solveErr = solveVkCaptcha(ctx, captchaErr, streamID, client, profile, false) successToken, solveErr = solveVkCaptcha(ctx, captchaErr, streamID, client, profile, false)
@ -1019,8 +1197,16 @@ func getTokenChain(ctx context.Context, link string, streamID int, creds VKCrede
data = fmt.Sprintf("vk_join_link=https://vk.com/call/join/%s&name=%s&captcha_key=%s&captcha_sid=%s&access_token=%s", data = fmt.Sprintf("vk_join_link=https://vk.com/call/join/%s&name=%s&captcha_key=%s&captcha_sid=%s&access_token=%s",
link, escapedName, neturl.QueryEscape(captchaKey), captchaErr.CaptchaSid, token1) link, escapedName, neturl.QueryEscape(captchaKey), captchaErr.CaptchaSid, token1)
} else { } else {
data = fmt.Sprintf("vk_join_link=https://vk.com/call/join/%s&name=%s&captcha_key=&captcha_sid=%s&is_sound_captcha=0&success_token=%s&captcha_ts=%s&captcha_attempt=%s&access_token=%s", // New VK captcha flow: success_token was obtained from
link, escapedName, captchaErr.CaptchaSid, neturl.QueryEscape(successToken), captchaErr.CaptchaTs, captchaErr.CaptchaAttempt, token1) // id.vk.ru/not_robot_captcha. captcha_sid is absent in this
// flow's error payload, so only include it when present to
// avoid sending an empty legacy field that could confuse VK.
sidPart := ""
if captchaErr.CaptchaSid != "" {
sidPart = "&captcha_sid=" + captchaErr.CaptchaSid
}
data = fmt.Sprintf("vk_join_link=https://vk.com/call/join/%s&name=%s&captcha_key=&is_sound_captcha=0&success_token=%s&captcha_ts=%s&captcha_attempt=%s%s&access_token=%s",
link, escapedName, neturl.QueryEscape(successToken), captchaErr.CaptchaTs, captchaErr.CaptchaAttempt, sidPart, token1)
} }
continue continue
} }
@ -1784,6 +1970,13 @@ func oneTurnConnectionLoop(ctx context.Context, turnParams *turnParams, peer *ne
} }
func main() { func main() {
// Build banner — printed as the very first line so users can confirm
// they are running the patched binary (issue #182 captcha fix + Android
// TLS/CA-bundle fix). If this line is missing from the log, an older
// binary is being launched by the wrapper/app.
log.Printf("vk-turn-proxy build: captcha-fix=v2 tls-ca-fix=v1 go=%s (issue#182 + HARICA root CA bundle embedded)",
runtime.Version())
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
globalAppCancel = cancel globalAppCancel = cancel
defer cancel() defer cancel()
@ -1812,6 +2005,7 @@ func main() {
vlessMode := flag.Bool("vless", false, "VLESS mode: forward TCP connections (for VLESS) instead of UDP packets") vlessMode := flag.Bool("vless", false, "VLESS mode: forward TCP connections (for VLESS) instead of UDP packets")
debugFlag := flag.Bool("debug", false, "enable debug logging") debugFlag := flag.Bool("debug", false, "enable debug logging")
manualCaptchaFlag := flag.Bool("manual-captcha", false, "skip auto captcha solving, use manual mode immediately") manualCaptchaFlag := flag.Bool("manual-captcha", false, "skip auto captcha solving, use manual mode immediately")
captchaSolverFlag := flag.String("captcha-solver", "", "URL of Playwright-based captcha solver (e.g. http://127.0.0.1:8766). When set, captcha is solved via real headless Chromium, bypassing VK's bot detection. Requires captcha_solver.py running on the host.")
flag.Parse() flag.Parse()
if *peerAddr == "" { if *peerAddr == "" {
log.Panicf("Need peer address!") log.Panicf("Need peer address!")
@ -1827,6 +2021,10 @@ func main() {
isDebug = *debugFlag isDebug = *debugFlag
manualCaptcha = *manualCaptchaFlag manualCaptcha = *manualCaptchaFlag
autoCaptchaSliderPOC = !manualCaptcha autoCaptchaSliderPOC = !manualCaptcha
captchaSolverURL = *captchaSolverFlag
if captchaSolverURL != "" {
log.Printf("[Captcha] Playwright solver enabled: %s (will be tried first, then fallback to auto/manual)", captchaSolverURL)
}
var link string var link string
var getCreds getCredsFunc var getCreds getCredsFunc

72
client/manual_captcha.go

@ -615,3 +615,75 @@ func browserOpenCommands(goos string, url string) []browserCommand {
} }
return nil return nil
} }
// solveCaptchaViaPlaywright delegates the captcha solving to an external
// HTTP service (typically the captcha_solver.py Python script shipped in
// /home/z/my-project/scripts/) that drives a real headless Chromium via
// Playwright. The Chromium instance produces a real FingerprintJS visitor_id
// and real behavioral telemetry, which is the only reliable way to pass
// VK's not_robot_captcha bot detection.
//
// API contract:
//
// POST <solverURL>/solve
// Body: {"redirect_uri": "https://id.vk.ru/not_robot_captcha?..."}
// Response: {"success_token": "...", "elapsed": 8.3}
// or {"error": "...", "elapsed": 8.3}
//
// The solver must already be running (e.g. `python3 captcha_solver.py`).
// Configure via `-captcha-solver http://127.0.0.1:8766` CLI flag.
func solveCaptchaViaPlaywright(redirectURI string, streamID int) (string, error) {
if captchaSolverURL == "" {
return "", fmt.Errorf("no captcha solver URL configured")
}
payload := map[string]string{"redirect_uri": redirectURI}
body, err := json.Marshal(payload)
if err != nil {
return "", fmt.Errorf("marshal solver request: %w", err)
}
solveURL := strings.TrimRight(captchaSolverURL, "/") + "/solve"
log.Printf("[STREAM %d] [Captcha] Calling Playwright solver at %s", streamID, solveURL)
httpClient := &http.Client{Timeout: 60 * time.Second}
req, err := http.NewRequest("POST", solveURL, bytes.NewReader(body))
if err != nil {
return "", fmt.Errorf("build solver request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("solver HTTP call failed: %w", err)
}
defer func() { _ = resp.Body.Close() }()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("read solver response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("solver returned HTTP %d: %s", resp.StatusCode, string(respBody))
}
var result struct {
SuccessToken string `json:"success_token"`
Elapsed float64 `json:"elapsed"`
Error string `json:"error"`
}
if err := json.Unmarshal(respBody, &result); err != nil {
return "", fmt.Errorf("parse solver response: %w (body: %s)", err, string(respBody))
}
if result.Error != "" {
return "", fmt.Errorf("solver error: %s", result.Error)
}
if result.SuccessToken == "" {
return "", fmt.Errorf("solver returned empty success_token")
}
log.Printf("[STREAM %d] [Captcha] Playwright solver returned success_token (elapsed=%.1fs)", streamID, result.Elapsed)
return result.SuccessToken, nil
}

42
client/slider_captcha.go

@ -23,7 +23,6 @@ import (
) )
const ( const (
captchaDebugInfo = "1d3e9babfd3a74f4588bf90cf5c30d3e8e89a0e2a4544da8de8bbf4d78a32f5c"
sliderCaptchaType = "slider" sliderCaptchaType = "slider"
defaultSliderAttempts = 4 defaultSliderAttempts = 4
) )
@ -132,6 +131,23 @@ func (s *captchaNotRobotSession) requestSettings() (*captchaSettingsResponse, er
return parseCaptchaSettingsResponse(resp) return parseCaptchaSettingsResponse(resp)
} }
// requestInitSession mirrors VK's not_robot_captcha.js which calls
// captchaNotRobot.initSession before settings. The response carries captcha_id
// and content_settings (encrypted per-type config). Calling it sets up
// server-side session state that the subsequent check expects.
func (s *captchaNotRobotSession) requestInitSession() {
resp, err := s.request("captchaNotRobot.initSession", s.baseValues())
if err != nil {
log.Printf("[STREAM %d] [Captcha] Warning: initSession failed: %v (continuing)", s.streamID, err)
return
}
if respObj, ok := resp["response"].(map[string]interface{}); ok {
if showType, _ := respObj["show_captcha_type"].(string); showType != "" {
log.Printf("[STREAM %d] [Captcha] initSession: show_captcha_type=%s", s.streamID, showType)
}
}
}
func (s *captchaNotRobotSession) requestComponentDone() error { func (s *captchaNotRobotSession) requestComponentDone() error {
values := s.baseValues() values := s.baseValues()
values.Set("browser_fp", s.browserFp) values.Set("browser_fp", s.browserFp)
@ -185,17 +201,31 @@ func (s *captchaNotRobotSession) requestCheck(cursor string, answer string) (*ca
values.Set("motion", "[]") values.Set("motion", "[]")
values.Set("cursor", cursor) values.Set("cursor", cursor)
values.Set("taps", "[]") values.Set("taps", "[]")
values.Set("connectionRtt", "[]") // Realistic connection telemetry with jitter (constant arrays are a bot fingerprint).
values.Set("connectionDownlink", "[]") values.Set("connectionRtt", generateConnectionRtt(10))
values.Set("connectionDownlink", generateConnectionDownlink(16))
values.Set("browser_fp", s.browserFp) values.Set("browser_fp", s.browserFp)
values.Set("hash", s.hash) values.Set("hash", s.hash)
values.Set("answer", answer) values.Set("answer", answer)
values.Set("debug_info", captchaDebugInfo) // VK's not_robot_captcha.js sends either window.vk.brlefapmjnpg or the
// hardcoded fallback. The previous captchaDebugInfo constant here was a
// different hardcoded MD5, which VK treats as a strong bot signal.
values.Set("debug_info", captchaDebugInfoHardcoded)
resp, err := s.request("captchaNotRobot.check", values) resp, err := s.request("captchaNotRobot.check", values)
if err != nil { if err != nil {
return nil, fmt.Errorf("check failed: %w", err) return nil, fmt.Errorf("check failed: %w", err)
} }
// Log the raw check response so future debugging is easier.
if respObj, ok := resp["response"].(map[string]interface{}); ok {
status, _ := respObj["status"].(string)
showType, _ := respObj["show_captcha_type"].(string)
redirect, _ := respObj["redirect"].(string)
log.Printf("[STREAM %d] [Captcha] check response: status=%s show_captcha_type=%q redirect=%q",
s.streamID, status, showType, redirect)
}
return parseCaptchaCheckResult(resp) return parseCaptchaCheckResult(resp)
} }
@ -217,6 +247,10 @@ func callCaptchaNotRobotWithSliderPOC(
) (string, error) { ) (string, error) {
session := newCaptchaNotRobotSession(ctx, sessionToken, hash, streamID, client, profile) session := newCaptchaNotRobotSession(ctx, sessionToken, hash, streamID, client, profile)
log.Printf("[STREAM %d] [Captcha] Step 0/4: initSession", streamID)
session.requestInitSession()
time.Sleep(200 * time.Millisecond)
log.Printf("[STREAM %d] [Captcha] Step 1/4: settings", streamID) log.Printf("[STREAM %d] [Captcha] Step 1/4: settings", streamID)
settingsResp, err := session.requestSettings() settingsResp, err := session.requestSettings()
if err != nil { if err != nil {

Loading…
Cancel
Save