Browse Source

feat(captcha): port hardening from main (PoW, fingerprints, manual proxy)

Brings the captcha-solver improvements from main into feat/doh while keeping
the flat client/ layout (no internal/* refactor pulled in).

- Persistent SavedProfile (UA + Sec-CH-UA + device JSON + browser_fp) captured
  during manual solve and replayed by auto/slider so VK sees a consistent
  fingerprint across runs. Stored under $VK_PROFILE_PATH | UserCacheDir |
  TempDir | CWD.
- callCaptchaNotRobot: per-session adFp, sha256 debug_info, jittered
  connectionRtt/connectionDownlink, cursor "[]" on first check, headers
  switched to Origin api.vk.ru / Referer not_robot_captcha.
- Slider session: per-session adFp + debugInfo, savedProfile injection,
  ApplyBrowserProfileFhttp + same captcha headers on every request,
  getContent fallback with/without captcha_settings, second componentDone
  before getContent (matches real widget lifecycle).
- Manual proxy: strip WebView identity headers (X-Requested-With and friends),
  server-side rewrite of src/href/action attributes (skipping <script>/<style>
  spans), inject helper script at <head> opening, sendBeacon + form fallback
  for token delivery on mobile WebView, /generic_proxy SSRF allowlist +
  scheme check + security-header strip + server-side success_token extract,
  loggingTransport that captures the real browser fingerprint and persists
  it as SavedProfile, best-effort 3s Shutdown, Windows rundll32 launcher,
  PII redaction in logs.
- solvePoW returns an error instead of an empty string.
- Manual captcha timeout bumped 60s -> 3m on context.Background so a human
  has time to solve regardless of the auth-level deadline; non-empty
  token/key from the manual goroutine is treated as success even if the
  server cleanup returned an error.
pull/151/head
samosvalishe 2 months ago
parent
commit
41593ad56f
  1. 113
      client/main.go
  2. 332
      client/manual_captcha.go
  3. 67
      client/profiles.go
  4. 317
      client/slider_captcha.go

113
client/main.go

@ -227,26 +227,15 @@ func applyBrowserProfileFhttp(req *fhttp.Request, profile Profile) {
req.Header.Set("DNT", "1")
}
// generateBrowserFp produces a stable fallback fingerprint when no SavedProfile
// is available. Stable (no time component) so the value matches between
// componentDone and check inside the same auto-solve attempt.
func generateBrowserFp(profile Profile) string {
data := profile.UserAgent + profile.SecChUa + "1920x1080x24" + strconv.FormatInt(time.Now().UnixNano(), 10)
data := profile.UserAgent + profile.SecChUa + "1536x864x24"
h := md5.Sum([]byte(data))
return hex.EncodeToString(h[:])
}
func generateFakeCursor() string {
startX := 600 + rand.Intn(400)
startY := 300 + rand.Intn(200)
startTime := time.Now().UnixMilli() - int64(rand.Intn(2000)+1000)
var points []string
for i := 0; i < 15+rand.Intn(10); i++ {
startX += rand.Intn(15) - 5
startY += rand.Intn(15) + 2
startTime += int64(rand.Intn(40) + 10)
points = append(points, fmt.Sprintf(`{"x":%d,"y":%d,"t":%d}`, startX, startY, startTime))
}
return "[" + strings.Join(points, ",") + "]"
}
// dnsMode is set in main() from the -dns flag and consumed by appDialer().
var dnsMode = DNSModeAuto
@ -392,6 +381,16 @@ func solveVkCaptcha(ctx context.Context, captchaErr *VkCaptchaError, streamID in
return "", fmt.Errorf("no redirect_uri for auto-solve")
}
// Reuse the real-browser fingerprint captured during a prior manual solve.
// VK fingerprints (browser_fp, device, UA) together; keeping them consistent
// across runs helps the auto path stay out of the BOT bucket.
var savedProfile *SavedProfile
if sp, err := LoadProfileFromDisk(); err == nil {
log.Printf("[STREAM %d] [Captcha] Using saved real browser profile", streamID)
savedProfile = sp
profile = sp.Profile
}
bootstrap, err := fetchCaptchaBootstrap(ctx, captchaErr.RedirectURI, client, profile)
if err != nil {
return "", fmt.Errorf("failed to fetch captcha bootstrap: %w", err)
@ -399,7 +398,10 @@ func solveVkCaptcha(ctx context.Context, captchaErr *VkCaptchaError, streamID in
log.Printf("[STREAM %d] [Captcha] PoW input: %s, difficulty: %d", streamID, bootstrap.PowInput, bootstrap.Difficulty)
hash := solvePoW(bootstrap.PowInput, bootstrap.Difficulty)
hash, err := solvePoW(bootstrap.PowInput, bootstrap.Difficulty)
if err != nil {
return "", fmt.Errorf("PoW: %w", err)
}
log.Printf("[STREAM %d] [Captcha] PoW solved: hash=%s", streamID, hash)
var successToken string
@ -412,9 +414,10 @@ func solveVkCaptcha(ctx context.Context, captchaErr *VkCaptchaError, streamID in
client,
profile,
bootstrap.Settings,
savedProfile,
)
} else {
successToken, err = callCaptchaNotRobot(ctx, captchaErr.SessionToken, hash, streamID, client, profile)
successToken, err = callCaptchaNotRobot(ctx, captchaErr.SessionToken, hash, streamID, client, profile, savedProfile)
}
if err != nil {
return "", fmt.Errorf("captchaNotRobot API failed: %w", err)
@ -458,20 +461,20 @@ func fetchCaptchaBootstrap(ctx context.Context, redirectURI string, client tlscl
return parseCaptchaBootstrapHTML(string(body))
}
func solvePoW(powInput string, difficulty int) string {
func solvePoW(powInput string, difficulty int) (string, error) {
target := strings.Repeat("0", difficulty)
for nonce := 1; nonce <= 10000000; nonce++ {
data := powInput + strconv.Itoa(nonce)
hash := sha256.Sum256([]byte(data))
hexHash := hex.EncodeToString(hash[:])
if strings.HasPrefix(hexHash, target) {
return hexHash
return hexHash, nil
}
}
return ""
return "", fmt.Errorf("PoW unsolved (difficulty=%d, tried 10M nonces)", difficulty)
}
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, savedProfile *SavedProfile) (string, error) {
vkReq := func(method string, postData string) (map[string]interface{}, error) {
reqURL := "https://api.vk.ru/method/" + method + "?v=5.131"
parsedURL, err := neturl.Parse(reqURL)
@ -489,13 +492,11 @@ func callCaptchaNotRobot(ctx context.Context, sessionToken, hash string, streamI
applyBrowserProfileFhttp(req, profile)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "*/*")
req.Header.Set("Origin", "https://id.vk.ru")
req.Header.Set("Referer", "https://id.vk.ru/")
req.Header.Set("Sec-Fetch-Site", "same-site")
req.Header.Set("Origin", "https://api.vk.ru")
req.Header.Set("Referer", fmt.Sprintf("https://api.vk.ru/not_robot_captcha?domain=vk.com&session_token=%s&variant=popup&blank=1", sessionToken))
req.Header.Set("Sec-Fetch-Site", "same-origin")
req.Header.Set("Sec-Fetch-Mode", "cors")
req.Header.Set("Sec-Fetch-Dest", "empty")
req.Header.Set("Sec-GPC", "1")
req.Header.Set("Priority", "u=1, i")
httpResp, err := client.Do(req)
if err != nil {
@ -516,7 +517,14 @@ func callCaptchaNotRobot(ctx context.Context, sessionToken, hash string, streamI
return resp, nil
}
baseParams := fmt.Sprintf("session_token=%s&domain=vk.com&adFp=&access_token=", neturl.QueryEscape(sessionToken))
// Per-session adFp: a stable empty value is itself a fingerprint.
adFpBytes := make([]byte, 16)
for i := range adFpBytes {
adFpBytes[i] = byte(rand.Intn(256))
}
adFp := base64.RawURLEncoding.EncodeToString(adFpBytes)[:21]
baseParams := fmt.Sprintf("session_token=%s&domain=vk.com&adFp=%s&access_token=", neturl.QueryEscape(sessionToken), neturl.QueryEscape(adFp))
log.Printf("[STREAM %d] [Captcha] Step 1/4: settings", streamID)
if _, err := vkReq("captchaNotRobot.settings", baseParams); err != nil {
@ -528,6 +536,10 @@ func callCaptchaNotRobot(ctx context.Context, sessionToken, hash string, streamI
log.Printf("[STREAM %d] [Captcha] Step 2/4: componentDone", streamID)
browserFp := generateBrowserFp(profile)
deviceJSON := buildCaptchaDeviceJSON(profile)
if savedProfile != nil {
browserFp = savedProfile.BrowserFp
deviceJSON = savedProfile.DeviceJSON
}
componentDoneData := baseParams + fmt.Sprintf("&browser_fp=%s&device=%s", browserFp, neturl.QueryEscape(deviceJSON))
if _, err := vkReq("captchaNotRobot.componentDone", componentDoneData); err != nil {
@ -537,15 +549,31 @@ func callCaptchaNotRobot(ctx context.Context, sessionToken, hash string, streamI
time.Sleep(200 * time.Millisecond)
log.Printf("[STREAM %d] [Captcha] Step 3/4: check", streamID)
cursorJSON := generateFakeCursor()
// Real browser sends [] for cursor on the first check.
cursorJSON := "[]"
answer := base64.StdEncoding.EncodeToString([]byte("{}"))
// Dynamically generate debug_info to avoid static fingerprint bans
debugInfoBytes := md5.Sum([]byte(profile.UserAgent + strconv.FormatInt(time.Now().UnixNano(), 10)))
// debug_info must vary per-session — a hardcoded hash becomes a stable
// fingerprint VK uses to flag the bot path (status=BOT).
debugInfoBytes := sha256.Sum256([]byte(profile.UserAgent + sessionToken + strconv.FormatInt(time.Now().UnixNano(), 10)))
debugInfo := hex.EncodeToString(debugInfoBytes[:])
connectionRtt := "[50,50,50,50,50,50,50,50,50,50]"
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]"
// Realistic per-session jitter; static arrays were also a fingerprint.
rttSamples := 4 + rand.Intn(4)
rttBase := 40 + rand.Intn(120)
rttVals := make([]string, rttSamples)
for i := range rttVals {
rttVals[i] = strconv.Itoa(rttBase + rand.Intn(40) - 20)
}
connectionRtt := "[" + strings.Join(rttVals, ",") + "]"
dlSamples := 4 + rand.Intn(4)
dlBase := 2.0 + rand.Float64()*8.0
dlVals := make([]string, dlSamples)
for i := range dlVals {
dlVals[i] = strconv.FormatFloat(dlBase+(rand.Float64()-0.5)*0.4, 'f', 2, 64)
}
connectionDownlink := "[" + strings.Join(dlVals, ",") + "]"
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",
@ -955,7 +983,9 @@ func getTokenChain(ctx context.Context, link string, streamID int, creds VKCrede
}
case captchaSolveModeManual:
log.Printf("[STREAM %d] [Captcha] Triggering manual captcha fallback...", streamID)
manualCtx, manualCancel := context.WithTimeout(ctx, 60*time.Second)
// Manual solve waits on a human; keep generous timeout
// independent of any auth-level deadline.
manualCtx, manualCancel := context.WithTimeout(context.Background(), 3*time.Minute)
type manualRes struct {
token string
@ -979,11 +1009,20 @@ func getTokenChain(ctx context.Context, link string, streamID int, creds VKCrede
select {
case res := <-resCh:
successToken = res.token
captchaKey = res.key
solveErr = res.err
// Token can arrive even when err != nil (e.g. server
// Shutdown timeout after the token was already received).
// A non-empty token/key counts as success.
if res.token != "" || res.key != "" {
successToken = res.token
captchaKey = res.key
if res.err != nil {
log.Printf("[STREAM %d] [Captcha] Token received (ignoring cleanup error: %v)", streamID, res.err)
}
} else {
solveErr = res.err
}
case <-manualCtx.Done():
solveErr = fmt.Errorf("manual captcha timed out after 60s")
solveErr = fmt.Errorf("manual captcha timed out after 3m")
}
manualCancel()
}

332
client/manual_captcha.go

@ -14,13 +14,44 @@ import (
"net/http/httputil"
neturl "net/url"
"os/exec"
"regexp"
"runtime"
"sort"
"strings"
"time"
)
const captchaListenPort = "8765"
// redactSensitiveQueryRe matches sensitive token/hash params in form bodies and
// query strings. Replaced with "<redacted:N>" so logs reveal presence and length
// without exposing the JWT itself.
var redactSensitiveQueryRe = regexp.MustCompile(`(?i)\b(session_token|access_token|success_token|hash|debug_info|browser_fp)=([^&\s]*)`)
var redactCookieValueRe = regexp.MustCompile(`(remix[a-z]+|prcl|domain_sid)=([^;\s]+)`)
func redactBodyForLog(s string) string {
return redactSensitiveQueryRe.ReplaceAllStringFunc(s, func(m string) string {
groups := redactSensitiveQueryRe.FindStringSubmatch(m)
if len(groups) < 3 {
return m
}
return groups[1] + "=<redacted:" + fmt.Sprint(len(groups[2])) + ">"
})
}
func redactHeaderForLog(name, value string) string {
switch strings.ToLower(name) {
case "cookie", "set-cookie":
return redactCookieValueRe.ReplaceAllString(value, "$1=<redacted>")
case "referer", "origin", "location":
return redactBodyForLog(value)
case "authorization", "proxy-authorization":
return "<redacted>"
}
return value
}
type browserCommand struct {
name string
args []string
@ -123,7 +154,23 @@ func rewriteProxyRequest(req *http.Request, targetURL *neturl.URL) {
req.Host = targetURL.Host
req.Header.Del("Accept-Encoding")
req.Header.Del("TE") // Disable transfer encoding compression
req.Header.Del("TE")
// Strip WebView identity / fingerprint leak headers. Android WebView
// auto-injects X-Requested-With with the host package name, which would
// reveal the proxy app to VK.
for _, h := range []string{
"X-Requested-With",
"X-Android-Package",
"X-Android-Cert",
"X-Client-Data",
"X-Discord-Locale",
"X-Discord-Timezone",
"Save-Data",
"Purpose",
"Sec-Purpose",
} {
req.Header.Del(h)
}
for _, headerName := range []string{"Origin", "Referer"} {
if rewritten := rewriteProxyHeaderURL(req.Header.Get(headerName), targetURL); rewritten != "" {
req.Header.Set(headerName, rewritten)
@ -162,10 +209,84 @@ func rewriteProxyCookies(header http.Header) {
}
}
var htmlURLAttrDoubleRe = regexp.MustCompile(`(?i)((?:src|href|action)\s*=\s*)"((?:https?:)?//[^"]+)"`)
var htmlURLAttrSingleRe = regexp.MustCompile(`(?i)((?:src|href|action)\s*=\s*)'((?:https?:)?//[^']+)'`)
var (
scriptBlockRe = regexp.MustCompile(`(?is)<script\b[^>]*>.*?</script\s*>`)
styleBlockRe = regexp.MustCompile(`(?is)<style\b[^>]*>.*?</style\s*>`)
)
// rewriteHTMLAttrsServerSide rewrites absolute and protocol-relative URLs in
// src/href/action attributes of raw HTML. URLs matching the upstream origin go
// to localhost; other absolute URLs are routed through /generic_proxy. Skips
// <script> and <style> blocks: their contents are JS/CSS, not HTML attributes.
func rewriteHTMLAttrsServerSide(html string, targetURL *neturl.URL) string {
localOrigin := localCaptchaOrigin()
upstreamOrigin := targetOrigin(targetURL)
rewriteURL := func(rawURL string) string {
absURL := rawURL
if strings.HasPrefix(rawURL, "//") {
absURL = targetURL.Scheme + ":" + rawURL
}
if strings.HasPrefix(absURL, upstreamOrigin) {
return localOrigin + absURL[len(upstreamOrigin):]
}
if strings.HasPrefix(absURL, localOrigin) {
return rawURL
}
return "/generic_proxy?proxy_url=" + neturl.QueryEscape(absURL)
}
rewriteAttrs := func(s string) string {
s = htmlURLAttrDoubleRe.ReplaceAllStringFunc(s, func(match string) string {
groups := htmlURLAttrDoubleRe.FindStringSubmatch(match)
if len(groups) < 3 {
return match
}
return groups[1] + `"` + rewriteURL(groups[2]) + `"`
})
s = htmlURLAttrSingleRe.ReplaceAllStringFunc(s, func(match string) string {
groups := htmlURLAttrSingleRe.FindStringSubmatch(match)
if len(groups) < 3 {
return match
}
return groups[1] + `'` + rewriteURL(groups[2]) + `'`
})
return s
}
type span struct{ a, b int }
var spans []span
for _, m := range scriptBlockRe.FindAllStringIndex(html, -1) {
spans = append(spans, span{m[0], m[1]})
}
for _, m := range styleBlockRe.FindAllStringIndex(html, -1) {
spans = append(spans, span{m[0], m[1]})
}
sort.Slice(spans, func(i, j int) bool { return spans[i].a < spans[j].a })
var b strings.Builder
last := 0
for _, s := range spans {
if s.a < last {
continue
}
b.WriteString(rewriteAttrs(html[last:s.a]))
b.WriteString(html[s.a:s.b])
last = s.b
}
b.WriteString(rewriteAttrs(html[last:]))
return b.String()
}
func rewriteCaptchaHTML(html string, targetURL *neturl.URL) string {
localOrigin := localCaptchaOrigin()
upstreamOrigin := targetOrigin(targetURL)
html = strings.ReplaceAll(html, upstreamOrigin, localOrigin)
html = rewriteHTMLAttrsServerSide(html, targetURL)
script := fmt.Sprintf(`
<script>
@ -205,14 +326,45 @@ func rewriteCaptchaHTML(html string, targetURL *neturl.URL) string {
function handleSuccessToken(token) {
if (!token) return;
console.log('Captcha solved, sending token to proxy...');
var body = 'token=' + encodeURIComponent(token);
// sendBeacon is the most reliable on mobile Safari:
// it's fire-and-forget and works even if the page navigates away.
if (navigator && navigator.sendBeacon) {
var blob = new Blob([body], {type: 'application/x-www-form-urlencoded'});
var sent = navigator.sendBeacon('/local-captcha-result', blob);
if (sent) {
console.log('Token sent via sendBeacon');
showDone();
return;
}
}
fetch('/local-captcha-result', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: 'token=' + encodeURIComponent(token)
}).then(function() {
document.body.innerHTML = '<h2 style="text-align:center;margin-top:20vh">Done! You can close the page.</h2>';
setTimeout(function() { window.close(); }, 300);
}).catch(function() {});
body: body
}).then(function(r) {
console.log('Proxy acknowledged token');
showDone();
}).catch(function(e) {
console.error('Fetch failed, trying form submit...', e);
var form = document.createElement('form');
form.method = 'POST';
form.action = '/local-captcha-result';
var input = document.createElement('input');
input.type = 'hidden';
input.name = 'token';
input.value = token;
form.appendChild(input);
document.body.appendChild(form);
form.submit();
});
}
function showDone() {
try { window.close(); } catch (e) {}
}
var origOpen = XMLHttpRequest.prototype.open;
@ -323,7 +475,11 @@ func rewriteCaptchaHTML(html string, targetURL *neturl.URL) string {
</script>
`, localOrigin, upstreamOrigin)
// Inject as early as possible — at the opening <head> tag — so XHR/fetch
// overrides are active before any inline <script> in <head> runs.
switch {
case strings.Contains(html, "<head>"):
return strings.Replace(html, "<head>", "<head>"+script, 1)
case strings.Contains(html, "</head>"):
return strings.Replace(html, "</head>", script+"</head>", 1)
case strings.Contains(html, "</body>"):
@ -371,7 +527,7 @@ func startCaptchaServer(srv *http.Server, logPrefix string) error {
return fmt.Errorf("captcha listeners failed: %s", strings.Join(listenErrs, "; "))
}
// runCaptchaServerAndWait triggers the browser, and waiting gracefully for the solution token.
// runCaptchaServerAndWait triggers the browser, then waits for a solution token.
func runCaptchaServerAndWait(handler http.Handler, captchaURL string, keyCh <-chan string, logPrefix string) (string, error) {
srv := &http.Server{Handler: handler}
@ -385,20 +541,23 @@ func runCaptchaServerAndWait(handler http.Handler, captchaURL string, keyCh <-ch
fmt.Println("==============================================")
fmt.Println()
log.Printf("[%s] Opening browser...", logPrefix)
openBrowser(captchaURL)
key := <-keyCh
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
return "", err
// Best-effort shutdown: token is already received, return it even if
// Shutdown times out (e.g. ishConn.SetDeadline is no-op on iSH and
// active connections can't be force-closed).
shutCtx, shutCancel := context.WithTimeout(context.Background(), 3*time.Second)
defer shutCancel()
if err := srv.Shutdown(shutCtx); err != nil {
log.Printf("%s: shutdown warning (token already received): %v", logPrefix, err)
}
return key, nil
}
// notifyKey pushes the key string to the given channel without blocking
func notifyKey(keyCh chan<- string, key string) {
if key != "" {
select {
@ -436,7 +595,84 @@ button{font-size:24px;padding:12px 32px;margin-top:12px;cursor:pointer}</style>
_, _ = fmt.Fprint(w, `<!DOCTYPE html><html><body><h2>Done!</h2></body></html>`)
})
return runCaptchaServerAndWait(mux, localCaptchaOrigin(), keyCh, "captcha HTTP server error")
return runCaptchaServerAndWait(mux, localCaptchaOrigin(), keyCh, "captcha-image")
}
// loggingTransport intercepts captchaNotRobot.componentDone / .check requests
// from the WebView, captures the (User-Agent, Sec-CH-UA*, device, browser_fp)
// tuple, and persists it as SavedProfile so subsequent auto-solve attempts
// can replay the same fingerprint.
type loggingTransport struct {
rt http.RoundTripper
debug bool
}
func (t *loggingTransport) RoundTrip(req *http.Request) (*http.Response, error) {
isCaptchaRequest := req.Body != nil && (strings.Contains(req.URL.Path, "captchaNotRobot.check") || strings.Contains(req.URL.Path, "captchaNotRobot.componentDone"))
if isCaptchaRequest {
b, err := io.ReadAll(req.Body)
if err != nil {
log.Printf("[Captcha Proxy] Failed to read request body: %v", err)
b = nil
}
req.Body = io.NopCloser(bytes.NewReader(b))
if t.debug {
log.Printf("[Captcha Proxy] Real browser sent %s data: %s", req.URL.Path, redactBodyForLog(string(b)))
for k, v := range req.Header {
log.Printf("[Captcha Proxy] Header (%s): %s = %s", req.URL.Path, k, redactHeaderForLog(k, strings.Join(v, ", ")))
}
}
parsedBody, perr := neturl.ParseQuery(string(b))
if perr != nil {
log.Printf("[Captcha Proxy] Failed to parse request body: %v", perr)
}
device := parsedBody.Get("device")
browserFp := parsedBody.Get("browser_fp")
if device != "" && browserFp != "" {
sp := SavedProfile{
Profile: Profile{
UserAgent: req.Header.Get("User-Agent"),
SecChUa: req.Header.Get("Sec-Ch-Ua"),
SecChUaMobile: req.Header.Get("Sec-Ch-Ua-Mobile"),
SecChUaPlatform: req.Header.Get("Sec-Ch-Ua-Platform"),
},
DeviceJSON: device,
BrowserFp: browserFp,
}
if err := SaveProfileToDisk(sp); err != nil {
log.Printf("[Captcha Proxy] Failed to save browser profile: %v", err)
} else {
log.Printf("[Captcha Proxy] Successfully intercepted and saved real browser profile!")
}
}
}
return t.rt.RoundTrip(req)
}
// genericProxyAllowedSuffixes are upstream host suffixes the WebView is
// permitted to fetch via /generic_proxy. Anything else is rejected so the
// loopback proxy cannot be abused as an open SSRF gadget.
var genericProxyAllowedSuffixes = []string{
"vk.com", "vk.ru", "vkuser.net", "vk-cdn.net",
"userapi.com", "okcdn.ru",
"mc.yandex.ru",
}
func isAllowedGenericProxyHost(host string) bool {
host = strings.ToLower(host)
if i := strings.Index(host, ":"); i >= 0 {
host = host[:i]
}
for _, suffix := range genericProxyAllowedSuffixes {
if host == suffix || strings.HasSuffix(host, "."+suffix) {
return true
}
}
return false
}
func solveCaptchaViaProxy(redirectURI string) (string, error) {
@ -447,7 +683,7 @@ func solveCaptchaViaProxy(redirectURI string) (string, error) {
return "", fmt.Errorf("invalid redirect URI: %v", err)
}
transport := newCaptchaProxyTransport()
transport := &loggingTransport{rt: newCaptchaProxyTransport(), debug: isDebug}
proxy := &httputil.ReverseProxy{
Transport: transport,
@ -465,7 +701,6 @@ func solveCaptchaViaProxy(redirectURI string) (string, error) {
if res.StatusCode >= 300 && res.StatusCode < 400 {
if loc := res.Header.Get("Location"); loc != "" {
log.Printf("[Captcha Proxy] Redirecting to: %s", loc)
if rewritten, ok := rewriteProxyRedirectLocation(loc, targetURL); ok {
res.Header.Set("Location", rewritten)
} else {
@ -476,7 +711,9 @@ func solveCaptchaViaProxy(redirectURI string) (string, error) {
contentType := res.Header.Get("Content-Type")
contentEncoding := res.Header.Get("Content-Encoding")
log.Printf("[Captcha Proxy] %s %d | Content-Type: %q, Encoding: %q", res.Request.Method, res.StatusCode, contentType, contentEncoding)
if isDebug {
log.Printf("[Captcha Proxy] %s %d | Content-Type: %q, Encoding: %q", res.Request.Method, res.StatusCode, contentType, contentEncoding)
}
shouldInspectBody := strings.Contains(contentType, "text/html") ||
strings.Contains(contentType, "application/xhtml+xml") ||
@ -541,8 +778,15 @@ func solveCaptchaViaProxy(redirectURI string) (string, error) {
mux := http.NewServeMux()
mux.HandleFunc("/local-captcha-result", func(w http.ResponseWriter, r *http.Request) {
notifyKey(keyCh, r.FormValue("token")) // r.FormValue automatically parses the form
token := r.FormValue("token")
if token != "" {
log.Printf("[Captcha] Received success token from browser (%d bytes)", len(token))
notifyKey(keyCh, token)
} else {
log.Printf("[Captcha] Received empty token from browser")
}
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Content-Type", "text/plain")
_, _ = fmt.Fprint(w, "ok")
})
@ -553,6 +797,15 @@ func solveCaptchaViaProxy(redirectURI string) (string, error) {
http.Error(w, "Bad URL", http.StatusBadRequest)
return
}
if targetParsed.Scheme != "http" && targetParsed.Scheme != "https" {
http.Error(w, "Unsupported scheme", http.StatusBadRequest)
return
}
if !strings.EqualFold(targetParsed.Host, targetURL.Host) && !isAllowedGenericProxyHost(targetParsed.Host) {
log.Printf("[Captcha Proxy] /generic_proxy rejected host=%s", targetParsed.Host)
http.Error(w, "Host not allowed", http.StatusForbidden)
return
}
genericReverse := &httputil.ReverseProxy{
Transport: transport,
Rewrite: func(req *httputil.ProxyRequest) {
@ -560,21 +813,53 @@ func solveCaptchaViaProxy(redirectURI string) (string, error) {
req.Out.URL.RawQuery = targetParsed.RawQuery
rewriteProxyRequest(req.Out, targetParsed)
},
ModifyResponse: func(res *http.Response) error {
for _, h := range []string{
"Content-Security-Policy",
"Content-Security-Policy-Report-Only",
"X-Content-Security-Policy",
"X-WebKit-CSP",
"Cross-Origin-Opener-Policy",
"Cross-Origin-Embedder-Policy",
"Cross-Origin-Resource-Policy",
"X-Frame-Options",
"Strict-Transport-Security",
} {
res.Header.Del(h)
}
res.Header.Set("Access-Control-Allow-Origin", "*")
// captchaNotRobot.check goes to api.vk.ru (different host from
// the main proxy upstream vk.com), so it's routed via
// /generic_proxy. Extract success_token here so the server-side
// path works on iOS even if the JS callback never fires.
if strings.Contains(targetAuthURL, "captchaNotRobot.check") {
bodyBytes, readErr := io.ReadAll(res.Body)
if readErr == nil {
_ = res.Body.Close()
res.Body = io.NopCloser(bytes.NewReader(bodyBytes))
res.ContentLength = int64(len(bodyBytes))
res.Header.Set("Content-Length", fmt.Sprint(len(bodyBytes)))
notifyKey(keyCh, extractSuccessToken(bodyBytes))
}
}
return nil
},
}
genericReverse.ServeHTTP(w, r)
})
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
log.Printf("[Captcha Proxy] HTTP %s %s", r.Method, r.URL.String())
log.Printf("[Captcha Proxy] HTTP %s %s", r.Method, r.URL.Path)
if r.URL.Path == "/" && targetURL.Path != "" && targetURL.Path != "/" && r.URL.RawQuery == "" {
log.Printf("[Captcha Proxy] Redirecting ROOT to: %s", localCaptchaURLForTarget(targetURL))
http.Redirect(w, r, localCaptchaURLForTarget(targetURL), http.StatusTemporaryRedirect)
return
}
proxy.ServeHTTP(w, r)
})
return runCaptchaServerAndWait(mux, localCaptchaURLForTarget(targetURL), keyCh, "proxy HTTP server error")
return runCaptchaServerAndWait(mux, localCaptchaURLForTarget(targetURL), keyCh, "captcha-proxy")
}
func openBrowser(url string) {
@ -588,7 +873,12 @@ func openBrowser(url string) {
func browserOpenCommands(goos string, url string) []browserCommand {
switch goos {
case "windows":
return []browserCommand{{name: "cmd", args: []string{"/c", "start", url}}}
// rundll32 url.dll,FileProtocolHandler is more reliable than 'cmd /c start'
// because it bypasses cmd.exe and avoids issues with '&' and other special chars.
return []browserCommand{
{name: "rundll32", args: []string{"url.dll,FileProtocolHandler", url}},
{name: "cmd", args: []string{"/c", "start", "", url}},
}
case "darwin":
return []browserCommand{{name: "open", args: []string{url}}}
case "linux":

67
client/profiles.go

@ -1,7 +1,11 @@
package main
import (
"encoding/json"
"math/rand"
"os"
"path/filepath"
"sync"
)
type Profile struct {
@ -11,7 +15,68 @@ type Profile struct {
SecChUaPlatform string
}
// profiles contain paired User-Agent and Client Hints strings to harden bot detection.
// SavedProfile is the captured browser fingerprint persisted after a manual
// captcha session. Reused for subsequent auto-solve attempts so VK sees a
// consistent (browser_fp, device, UA) triple rather than a freshly-generated one.
type SavedProfile struct {
Profile
DeviceJSON string
BrowserFp string
}
const profileFileName = "vk_profile.json"
var (
profilePathOnce sync.Once
profilePathVal string
)
// profileFilePath returns a writeable absolute path for the cached browser
// profile. Order: $VK_PROFILE_PATH, os.UserCacheDir(), os.TempDir(), CWD.
// CWD is last because on Android it's the read-only APK lib dir.
func profileFilePath() string {
profilePathOnce.Do(func() {
if p := os.Getenv("VK_PROFILE_PATH"); p != "" {
profilePathVal = p
return
}
if dir, err := os.UserCacheDir(); err == nil {
sub := filepath.Join(dir, "vk-turn-proxy")
if mkErr := os.MkdirAll(sub, 0o755); mkErr == nil {
profilePathVal = filepath.Join(sub, profileFileName)
return
}
}
if tmp := os.TempDir(); tmp != "" {
profilePathVal = filepath.Join(tmp, profileFileName)
return
}
profilePathVal = profileFileName
})
return profilePathVal
}
func LoadProfileFromDisk() (*SavedProfile, error) {
data, err := os.ReadFile(profileFilePath())
if err != nil {
return nil, err
}
var sp SavedProfile
if err := json.Unmarshal(data, &sp); err != nil {
return nil, err
}
return &sp, nil
}
func SaveProfileToDisk(sp SavedProfile) error {
data, err := json.MarshalIndent(sp, "", " ")
if err != nil {
return err
}
return os.WriteFile(profileFilePath(), data, 0o644)
}
// profile contains paired User-Agent and Client Hints strings to harden bot detection.
var profile = []Profile{
// Windows Chrome
{

317
client/slider_captcha.go

@ -3,14 +3,17 @@ package main
import (
"bytes"
"context"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"image"
"image/color"
_ "image/jpeg"
_ "image/jpeg" // register JPEG decoder
"io"
"log"
"math/rand"
neturl "net/url"
"regexp"
"sort"
@ -23,7 +26,6 @@ import (
)
const (
captchaDebugInfo = "1d3e9babfd3a74f4588bf90cf5c30d3e8e89a0e2a4544da8de8bbf4d78a32f5c"
sliderCaptchaType = "slider"
defaultSliderAttempts = 4
)
@ -36,6 +38,17 @@ type captchaNotRobotSession struct {
client tlsclient.HttpClient
profile Profile
browserFp string
adFp string
debugInfo string
savedProfile *SavedProfile
}
func generateAdFp() string {
b := make([]byte, 16)
for i := range b {
b[i] = byte(rand.Intn(256))
}
return base64.RawURLEncoding.EncodeToString(b)[:21]
}
type captchaSettingsResponse struct {
@ -68,14 +81,16 @@ type captchaBootstrap struct {
Settings *captchaSettingsResponse
}
func newCaptchaNotRobotSession(
ctx context.Context,
sessionToken string,
hash string,
streamID int,
client tlsclient.HttpClient,
profile Profile,
) *captchaNotRobotSession {
func newCaptchaNotRobotSession(ctx context.Context, sessionToken, hash string, streamID int, client tlsclient.HttpClient, profile Profile, savedProfile *SavedProfile) *captchaNotRobotSession {
browserFp := generateBrowserFp(profile)
if savedProfile != nil {
browserFp = savedProfile.BrowserFp
}
// Per-session debug_info — a hardcoded hash becomes a stable fingerprint
// VK uses to flag the bot path (status=BOT). Mirrors callCaptchaNotRobot.
debugInfoBytes := sha256.Sum256([]byte(profile.UserAgent + sessionToken + strconv.FormatInt(time.Now().UnixNano(), 10)))
return &captchaNotRobotSession{
ctx: ctx,
sessionToken: sessionToken,
@ -83,7 +98,10 @@ func newCaptchaNotRobotSession(
streamID: streamID,
client: client,
profile: profile,
browserFp: generateBrowserFp(profile),
browserFp: browserFp,
adFp: generateAdFp(),
debugInfo: hex.EncodeToString(debugInfoBytes[:]),
savedProfile: savedProfile,
}
}
@ -91,7 +109,7 @@ func (s *captchaNotRobotSession) baseValues() neturl.Values {
values := neturl.Values{}
values.Set("session_token", s.sessionToken)
values.Set("domain", "vk.com")
values.Set("adFp", "")
values.Set("adFp", s.adFp)
values.Set("access_token", "")
return values
}
@ -104,6 +122,15 @@ func (s *captchaNotRobotSession) request(method string, values neturl.Values) (m
return nil, err
}
applyBrowserProfileFhttp(req, s.profile)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "*/*")
req.Header.Set("Origin", "https://api.vk.ru")
req.Header.Set("Referer", fmt.Sprintf("https://api.vk.ru/not_robot_captcha?domain=vk.com&session_token=%s&variant=popup&blank=1", s.sessionToken))
req.Header.Set("Sec-Fetch-Site", "same-origin")
req.Header.Set("Sec-Fetch-Mode", "cors")
req.Header.Set("Sec-Fetch-Dest", "empty")
httpResp, err := s.client.Do(req)
if err != nil {
return nil, err
@ -135,7 +162,12 @@ func (s *captchaNotRobotSession) requestSettings() (*captchaSettingsResponse, er
func (s *captchaNotRobotSession) requestComponentDone() error {
values := s.baseValues()
values.Set("browser_fp", s.browserFp)
values.Set("device", buildCaptchaDeviceJSON(s.profile))
deviceJSON := buildCaptchaDeviceJSON(s.profile)
if s.savedProfile != nil {
deviceJSON = s.savedProfile.DeviceJSON
}
values.Set("device", deviceJSON)
resp, err := s.request("captchaNotRobot.componentDone", values)
if err != nil {
@ -144,8 +176,8 @@ func (s *captchaNotRobotSession) requestComponentDone() error {
respObj, ok := resp["response"].(map[string]interface{})
if ok {
if status, _ := respObj["status"].(string); status != "" && status != "OK" {
return fmt.Errorf("componentDone status: %s", status)
if statusVal, ok := respObj["status"].(string); ok && statusVal != "" && statusVal != "OK" {
return fmt.Errorf("componentDone status: %s", statusVal)
}
}
@ -153,7 +185,7 @@ func (s *captchaNotRobotSession) requestComponentDone() error {
}
func (s *captchaNotRobotSession) requestCheckboxCheck() (*captchaCheckResult, error) {
return s.requestCheck(generateSliderCursor(0, 1), base64.StdEncoding.EncodeToString([]byte("{}")))
return s.requestCheck("[]", base64.StdEncoding.EncodeToString([]byte("{}")))
}
func (s *captchaNotRobotSession) requestSliderContent(sliderSettings string) (*sliderCaptchaContent, error) {
@ -169,28 +201,79 @@ func (s *captchaNotRobotSession) requestSliderContent(sliderSettings string) (*s
return parseSliderCaptchaContentResponse(resp)
}
func (s *captchaNotRobotSession) requestSliderCheck(activeSteps []int, candidateIndex int, candidateCount int) (*captchaCheckResult, error) {
// requestSliderContentWithFallback tries getContent with the provided
// captcha_settings, then without it. VK sometimes reports show_type=checkbox
// in settings but actually serves slider content, so we probe both variants.
func (s *captchaNotRobotSession) requestSliderContentWithFallback(sliderSettings string, streamID int) (*sliderCaptchaContent, error) {
type attempt struct {
settings string
desc string
}
var attempts []attempt
if sliderSettings != "" {
attempts = []attempt{
{settings: sliderSettings, desc: "with captcha_settings"},
{settings: "", desc: "without captcha_settings"},
}
} else {
attempts = []attempt{
{settings: "", desc: "without captcha_settings"},
}
}
var lastErr error
for _, a := range attempts {
log.Printf("[STREAM %d] [Captcha] Requesting slider content (%s)...", streamID, a.desc)
content, err := s.requestSliderContent(a.settings)
if err == nil {
return content, nil
}
log.Printf("[STREAM %d] [Captcha] getContent failed (%s): %v", streamID, a.desc, err)
lastErr = err
}
return nil, lastErr
}
func (s *captchaNotRobotSession) requestSliderCheck(activeSteps []int, _ int, _ int) (*captchaCheckResult, error) {
answer, err := encodeSliderAnswer(activeSteps)
if err != nil {
return nil, err
}
return s.requestCheck(generateSliderCursor(candidateIndex, candidateCount), answer)
return s.requestCheck("[]", answer)
}
func (s *captchaNotRobotSession) requestCheck(cursor string, answer string) (*captchaCheckResult, error) {
values := s.baseValues()
// Per-session jitter on RTT/downlink — static arrays were a fingerprint.
rttSamples := 4 + rand.Intn(4)
rttBase := 40 + rand.Intn(120)
rttVals := make([]string, rttSamples)
for i := range rttVals {
rttVals[i] = strconv.Itoa(rttBase + rand.Intn(40) - 20)
}
connectionRtt := "[" + strings.Join(rttVals, ",") + "]"
dlSamples := 4 + rand.Intn(4)
dlBase := 2.0 + rand.Float64()*8.0
dlVals := make([]string, dlSamples)
for i := range dlVals {
dlVals[i] = strconv.FormatFloat(dlBase+(rand.Float64()-0.5)*0.4, 'f', 2, 64)
}
connectionDownlink := "[" + strings.Join(dlVals, ",") + "]"
values.Set("accelerometer", "[]")
values.Set("gyroscope", "[]")
values.Set("motion", "[]")
values.Set("cursor", cursor)
values.Set("taps", "[]")
values.Set("connectionRtt", "[]")
values.Set("connectionDownlink", "[]")
values.Set("connectionRtt", connectionRtt)
values.Set("connectionDownlink", connectionDownlink)
values.Set("browser_fp", s.browserFp)
values.Set("hash", s.hash)
values.Set("answer", answer)
values.Set("debug_info", captchaDebugInfo)
values.Set("debug_info", s.debugInfo)
resp, err := s.request("captchaNotRobot.check", values)
if err != nil {
@ -214,8 +297,9 @@ func callCaptchaNotRobotWithSliderPOC(
client tlsclient.HttpClient,
profile Profile,
initialSettings *captchaSettingsResponse,
savedProfile *SavedProfile,
) (string, error) {
session := newCaptchaNotRobotSession(ctx, sessionToken, hash, streamID, client, profile)
session := newCaptchaNotRobotSession(ctx, sessionToken, hash, streamID, client, profile, savedProfile)
log.Printf("[STREAM %d] [Captcha] Step 1/4: settings", streamID)
settingsResp, err := session.requestSettings()
@ -265,24 +349,19 @@ func callCaptchaNotRobotWithSliderPOC(
log.Printf("[STREAM %d] [Captcha] Trying experimental slider solver...", streamID)
}
sliderContent, err := session.requestSliderContent(sliderSettings)
// After check returns BOT, a real browser renders the slider widget and calls
// componentDone again to signal "slider component is now loaded". Without this,
// VK refuses getContent with ERROR because it expects the widget lifecycle.
log.Printf("[STREAM %d] [Captcha] Re-registering slider component before getContent...", streamID)
time.Sleep(300 * time.Millisecond)
if err := session.requestComponentDone(); err != nil {
log.Printf("[STREAM %d] [Captcha] Warning: slider componentDone failed: %v", streamID, err)
}
time.Sleep(200 * time.Millisecond)
sliderContent, err := session.requestSliderContentWithFallback(sliderSettings, streamID)
if err != nil {
log.Printf(
"[STREAM %d] [Captcha] Slider getContent failed (status: %v). Trying to solve as a checkbox instead...",
streamID,
err,
)
// Fallback: maybe it's just a checkbox that needs a human-like check
time.Sleep(300 * time.Millisecond)
finalCheck, err2 := session.requestCheckboxCheck()
if err2 == nil && finalCheck.Status == "OK" {
if finalCheck.SuccessToken == "" {
return "", fmt.Errorf("success_token not found in fallback check")
}
log.Printf("[STREAM %d] [Captcha] Fallback checkbox check succeeded!", streamID)
session.requestEndSession()
return finalCheck.SuccessToken, nil
}
log.Printf("[STREAM %d] [Captcha] All slider getContent attempts failed: %v", streamID, err)
return "", fmt.Errorf("check status: %s (slider getContent failed: %w)", initialCheck.Status, err)
}
@ -300,12 +379,7 @@ func callCaptchaNotRobotWithSliderPOC(
)
successToken, err := trySliderCaptchaCandidates(candidates, sliderContent.Attempts, func(candidate sliderCandidate) (*captchaCheckResult, error) {
log.Printf(
"[STREAM %d] [Captcha] Slider guess position=%d score=%d",
streamID,
candidate.Index,
candidate.Score,
)
log.Printf("[STREAM %d] [Captcha] Slider guess position=%d score=%d", streamID, candidate.Index, candidate.Score)
return session.requestSliderCheck(candidate.ActiveSteps, candidate.Index, len(candidates))
})
if err != nil {
@ -317,8 +391,9 @@ func callCaptchaNotRobotWithSliderPOC(
}
func buildCaptchaDeviceJSON(profile Profile) string {
// Fallback device JSON if no saved profile is available.
return fmt.Sprintf(
`{"screenWidth":1920,"screenHeight":1080,"screenAvailWidth":1920,"screenAvailHeight":1040,"innerWidth":1920,"innerHeight":969,"devicePixelRatio":1,"language":"en-US","languages":["en-US"],"webdriver":false,"hardwareConcurrency":8,"deviceMemory":8,"connectionEffectiveType":"4g","notificationsPermission":"default","userAgent":"%s","platform":"Win32"}`,
`{"screenWidth":1536,"screenHeight":864,"screenAvailWidth":1536,"screenAvailHeight":816,"innerWidth":1536,"innerHeight":730,"devicePixelRatio":1.25,"language":"ru-RU","languages":["ru-RU","ru","en-US","en"],"webdriver":false,"hardwareConcurrency":8,"deviceMemory":8,"connectionEffectiveType":"4g","notificationsPermission":"prompt","userAgent":"%s"}`,
profile.UserAgent,
)
}
@ -529,6 +604,15 @@ func parseSliderCaptchaContentResponse(resp map[string]interface{}) (*sliderCapt
status, _ := respObj["status"].(string)
if status != "OK" {
// Log all fields from the response to help diagnose why VK rejected getContent.
var debugFields []string
for k, v := range respObj {
if k != "image" {
debugFields = append(debugFields, fmt.Sprintf("%s=%v", k, v))
}
}
sort.Strings(debugFields)
log.Printf("[Captcha] getContent ERROR response fields: %s", strings.Join(debugFields, " "))
return nil, fmt.Errorf("slider getContent status: %s", status)
}
@ -731,14 +815,75 @@ func rankSliderCandidates(img image.Image, gridSize int, swaps []int) ([]sliderC
}
func scoreSliderCandidate(img image.Image, gridSize int, mapping []int) (int64, error) {
rendered, err := renderSliderCandidate(img, gridSize, mapping)
if err != nil {
return 0, err
bounds := img.Bounds()
var score int64
for row := 0; row < gridSize; row++ {
for col := 0; col < gridSize-1; col++ {
dstLeftIndex := row*gridSize + col
dstRightIndex := row*gridSize + col + 1
srcLeftIndex := mapping[dstLeftIndex]
srcRightIndex := mapping[dstRightIndex]
dstLeftRect := sliderTileRect(bounds, gridSize, dstLeftIndex)
dstRightRect := sliderTileRect(bounds, gridSize, dstRightIndex)
srcLeftRect := sliderTileRect(bounds, gridSize, srcLeftIndex)
srcRightRect := sliderTileRect(bounds, gridSize, srcRightIndex)
height := minInt(dstLeftRect.Dy(), dstRightRect.Dy())
leftSrcXRel := (dstLeftRect.Dx() - 1) * srcLeftRect.Dx() / dstLeftRect.Dx()
sxLeft := srcLeftRect.Min.X + leftSrcXRel
sxRight := srcRightRect.Min.X
for offset := 0; offset < height; offset++ {
syLeft := srcLeftRect.Min.Y + offset*srcLeftRect.Dy()/dstLeftRect.Dy()
syRight := srcRightRect.Min.Y + offset*srcRightRect.Dy()/dstRightRect.Dy()
score += pixelDiff(
img.At(sxLeft, syLeft),
img.At(sxRight, syRight),
)
}
}
}
for row := 0; row < gridSize-1; row++ {
for col := 0; col < gridSize; col++ {
dstTopIndex := row*gridSize + col
dstBottomIndex := (row+1)*gridSize + col
srcTopIndex := mapping[dstTopIndex]
srcBottomIndex := mapping[dstBottomIndex]
dstTopRect := sliderTileRect(bounds, gridSize, dstTopIndex)
dstBottomRect := sliderTileRect(bounds, gridSize, dstBottomIndex)
srcTopRect := sliderTileRect(bounds, gridSize, srcTopIndex)
srcBottomRect := sliderTileRect(bounds, gridSize, srcBottomIndex)
width := minInt(dstTopRect.Dx(), dstBottomRect.Dx())
topSrcYRel := (dstTopRect.Dy() - 1) * srcTopRect.Dy() / dstTopRect.Dy()
syTop := srcTopRect.Min.Y + topSrcYRel
syBottom := srcBottomRect.Min.Y
for offset := 0; offset < width; offset++ {
sxTop := srcTopRect.Min.X + offset*srcTopRect.Dx()/dstTopRect.Dx()
sxBottom := srcBottomRect.Min.X + offset*srcBottomRect.Dx()/dstBottomRect.Dx()
score += pixelDiff(
img.At(sxTop, syTop),
img.At(sxBottom, syBottom),
)
}
}
}
return scoreRenderedSliderImage(rendered, gridSize), nil
return score, nil
}
// renderSliderCandidate produces a fully reassembled image for a given mapping.
// Kept for the test suite even though scoreSliderCandidate now samples pixels
// directly without materialising the rendered image.
func renderSliderCandidate(img image.Image, gridSize int, mapping []int) (*image.RGBA, error) {
if gridSize <= 0 {
return nil, fmt.Errorf("invalid grid size: %d", gridSize)
@ -760,41 +905,6 @@ func renderSliderCandidate(img image.Image, gridSize int, mapping []int) (*image
return rendered, nil
}
func scoreRenderedSliderImage(img image.Image, gridSize int) int64 {
bounds := img.Bounds()
var score int64
for row := 0; row < gridSize; row++ {
for col := 0; col < gridSize-1; col++ {
leftRect := sliderTileRect(bounds, gridSize, row*gridSize+col)
rightRect := sliderTileRect(bounds, gridSize, row*gridSize+col+1)
height := minInt(leftRect.Dy(), rightRect.Dy())
for offset := 0; offset < height; offset++ {
score += pixelDiff(
img.At(leftRect.Max.X-1, leftRect.Min.Y+offset),
img.At(rightRect.Min.X, rightRect.Min.Y+offset),
)
}
}
}
for row := 0; row < gridSize-1; row++ {
for col := 0; col < gridSize; col++ {
topRect := sliderTileRect(bounds, gridSize, row*gridSize+col)
bottomRect := sliderTileRect(bounds, gridSize, (row+1)*gridSize+col)
width := minInt(topRect.Dx(), bottomRect.Dx())
for offset := 0; offset < width; offset++ {
score += pixelDiff(
img.At(topRect.Min.X+offset, topRect.Max.Y-1),
img.At(bottomRect.Min.X+offset, bottomRect.Min.Y),
)
}
}
}
return score
}
func sliderTileRect(bounds image.Rectangle, gridSize int, index int) image.Rectangle {
row := index / gridSize
col := index % gridSize
@ -840,43 +950,6 @@ func absDiff(left uint32, right uint32) int64 {
return int64(right - left)
}
func generateSliderCursor(candidateIndex int, candidateCount int) string {
return buildSliderCursor(candidateIndex, candidateCount, time.Now().Add(-220*time.Millisecond).UnixMilli())
}
func buildSliderCursor(candidateIndex int, candidateCount int, startTime int64) string {
if candidateCount <= 0 {
return "[]"
}
type cursorPoint struct {
X int `json:"x"`
Y int `json:"y"`
T int64 `json:"t"`
}
startX := 140
endX := startX + 620*candidateIndex/candidateCount
startY := 430
points := make([]cursorPoint, 0, 12)
for step := 0; step < 12; step++ {
x := startX + (endX-startX)*step/11
y := startY + ((step % 3) - 1)
points = append(points, cursorPoint{
X: x,
Y: y,
T: startTime + int64(step*18),
})
}
data, err := json.Marshal(points)
if err != nil {
return "[]"
}
return string(data)
}
func trySliderCaptchaCandidates(
candidates []sliderCandidate,
maxAttempts int,

Loading…
Cancel
Save