Browse Source

feat: implement dynamic browser fingerprint capture and reuse

Added SavedProfile struct and persistence logic in vk_profile.json.
Implemented automatic interception of real browser telemetry (browser_fp, device JSON, headers) during manual captcha solves.
Integrated saved profile reuse in automated solve flows to bypass BOT status.
Restored dynamic MD5-based fingerprint generation as a fallback mechanism.
pull/181/head
Moroka8 2 months ago
parent
commit
b9642c6134
  1. 93
      client/main.go
  2. 60
      client/manual_captcha.go
  3. 35
      client/profiles.go
  4. 145
      client/slider_captcha.go

93
client/main.go

@ -228,11 +228,14 @@ func applyBrowserProfileFhttp(req *fhttp.Request, profile Profile) {
} }
func generateBrowserFp(profile Profile) string { func generateBrowserFp(profile Profile) string {
data := profile.UserAgent + profile.SecChUa + "1920x1080x24" + strconv.FormatInt(time.Now().UnixNano(), 10) // Fallback logic for generating a fingerprint if no saved profile is available.
// This uses a simple MD5 hash of UA and a fixed resolution.
data := profile.UserAgent + profile.SecChUa + "1536x864x24"
h := md5.Sum([]byte(data)) h := md5.Sum([]byte(data))
return hex.EncodeToString(h[:]) return hex.EncodeToString(h[:])
} }
/*
func generateFakeCursor() string { func generateFakeCursor() string {
startX := 600 + rand.Intn(400) startX := 600 + rand.Intn(400)
startY := 300 + rand.Intn(200) startY := 300 + rand.Intn(200)
@ -247,6 +250,48 @@ func generateFakeCursor() string {
return "[" + strings.Join(points, ",") + "]" return "[" + strings.Join(points, ",") + "]"
} }
// generateCheckboxCursor simulates a mouse moving from a random starting position
// towards the VK captcha checkbox area, decelerating as it approaches the target.
// This looks more like a real click than either a stationary cursor or pure random jitter.
func generateCheckboxCursor() string {
type point struct {
X int `json:"x"`
Y int `json:"y"`
T int64 `json:"t"`
}
// Target is roughly where VK renders the checkbox
targetX := 290 + rand.Intn(20) - 10
targetY := 437 + rand.Intn(10) - 5
// Starting position: somewhere to the upper-right of the checkbox
startX := targetX + 200 + rand.Intn(300)
startY := targetY - 80 - rand.Intn(120)
steps := 14 + rand.Intn(6)
startTime := time.Now().Add(-time.Duration(400+rand.Intn(600)) * time.Millisecond).UnixMilli()
points := make([]point, 0, steps)
for i := 0; i < steps; i++ {
// Ease-out: fast at start, slow near target
t := float64(i) / float64(steps-1)
ease := 1 - (1-t)*(1-t)
x := startX + int(float64(targetX-startX)*ease) + rand.Intn(5) - 2
y := startY + int(float64(targetY-startY)*ease) + rand.Intn(5) - 2
dt := int64(15 + rand.Intn(25) + int(20*t)) // slower near target
startTime += dt
points = append(points, point{X: x, Y: y, T: startTime})
}
data, err := json.Marshal(points)
if err != nil {
return "[]"
}
return string(data)
}
*/
func getCustomNetDialer() net.Dialer { func getCustomNetDialer() net.Dialer {
return net.Dialer{ return net.Dialer{
Timeout: 20 * time.Second, Timeout: 20 * time.Second,
@ -399,6 +444,14 @@ func solveVkCaptcha(ctx context.Context, captchaErr *VkCaptchaError, streamID in
return "", fmt.Errorf("no redirect_uri for auto-solve") return "", fmt.Errorf("no redirect_uri for auto-solve")
} }
// Try to load saved profile from disk
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 // Use saved headers/UA
}
bootstrap, err := fetchCaptchaBootstrap(ctx, captchaErr.RedirectURI, client, profile) bootstrap, err := fetchCaptchaBootstrap(ctx, captchaErr.RedirectURI, client, profile)
if err != nil { if err != nil {
return "", fmt.Errorf("failed to fetch captcha bootstrap: %w", err) return "", fmt.Errorf("failed to fetch captcha bootstrap: %w", err)
@ -419,9 +472,10 @@ func solveVkCaptcha(ctx context.Context, captchaErr *VkCaptchaError, streamID in
client, client,
profile, profile,
bootstrap.Settings, bootstrap.Settings,
savedProfile, // Pass savedProfile if available
) )
} else { } 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 { if err != nil {
return "", fmt.Errorf("captchaNotRobot API failed: %w", err) return "", fmt.Errorf("captchaNotRobot API failed: %w", err)
@ -478,7 +532,7 @@ func solvePoW(powInput string, difficulty int) string {
return "" return ""
} }
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) { vkReq := func(method string, postData string) (map[string]interface{}, error) {
reqURL := "https://api.vk.ru/method/" + method + "?v=5.131" reqURL := "https://api.vk.ru/method/" + method + "?v=5.131"
parsedURL, err := neturl.Parse(reqURL) parsedURL, err := neturl.Parse(reqURL)
@ -496,13 +550,11 @@ func callCaptchaNotRobot(ctx context.Context, sessionToken, hash string, streamI
applyBrowserProfileFhttp(req, profile) applyBrowserProfileFhttp(req, profile)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "*/*") req.Header.Set("Accept", "*/*")
req.Header.Set("Origin", "https://id.vk.ru") req.Header.Set("Origin", "https://api.vk.ru")
req.Header.Set("Referer", "https://id.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-site") req.Header.Set("Sec-Fetch-Site", "same-origin")
req.Header.Set("Sec-Fetch-Mode", "cors") req.Header.Set("Sec-Fetch-Mode", "cors")
req.Header.Set("Sec-Fetch-Dest", "empty") 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) httpResp, err := client.Do(req)
if err != nil { if err != nil {
@ -523,7 +575,13 @@ func callCaptchaNotRobot(ctx context.Context, sessionToken, hash string, streamI
return resp, nil return resp, nil
} }
baseParams := fmt.Sprintf("session_token=%s&domain=vk.com&adFp=&access_token=", neturl.QueryEscape(sessionToken)) 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) 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 {
@ -535,6 +593,10 @@ func callCaptchaNotRobot(ctx context.Context, sessionToken, hash string, streamI
log.Printf("[STREAM %d] [Captcha] Step 2/4: componentDone", streamID) log.Printf("[STREAM %d] [Captcha] Step 2/4: componentDone", streamID)
browserFp := generateBrowserFp(profile) browserFp := generateBrowserFp(profile)
deviceJSON := buildCaptchaDeviceJSON(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)) componentDoneData := baseParams + fmt.Sprintf("&browser_fp=%s&device=%s", browserFp, neturl.QueryEscape(deviceJSON))
if _, err := vkReq("captchaNotRobot.componentDone", componentDoneData); err != nil { if _, err := vkReq("captchaNotRobot.componentDone", componentDoneData); err != nil {
@ -544,15 +606,16 @@ func callCaptchaNotRobot(ctx context.Context, sessionToken, hash string, streamI
time.Sleep(200 * time.Millisecond) time.Sleep(200 * time.Millisecond)
log.Printf("[STREAM %d] [Captcha] Step 3/4: check", streamID) log.Printf("[STREAM %d] [Captcha] Step 3/4: check", streamID)
cursorJSON := generateFakeCursor() // The real browser sends an empty array for cursor on the first check.
cursorJSON := "[]"
answer := base64.StdEncoding.EncodeToString([]byte("{}")) answer := base64.StdEncoding.EncodeToString([]byte("{}"))
// Dynamically generate debug_info to avoid static fingerprint bans // The real browser sends a static SHA-256 hash for debug_info.
debugInfoBytes := md5.Sum([]byte(profile.UserAgent + strconv.FormatInt(time.Now().UnixNano(), 10))) // We use the exact one captured from the real browser's session.
debugInfo := hex.EncodeToString(debugInfoBytes[:]) debugInfo := "f3ef768dab7a20f574c6461f34e4257894d2a3c30a53d8727a3edaf7ab70847d"
connectionRtt := "[50,50,50,50,50,50,50,50,50,50]" connectionRtt := "[250,250,250,250,250]"
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]" connectionDownlink := "[1.45,1.45,1.45,1.45,1.45]"
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",

60
client/manual_captcha.go

@ -495,7 +495,7 @@ func runCaptchaServerAndWait(handler http.Handler, captchaURL string, keyCh <-ch
fmt.Println("==============================================") fmt.Println("==============================================")
fmt.Println() fmt.Println()
log.Printf("[%s] Opening browser: %s", logPrefix, captchaURL) log.Printf("[%s] Opening browser...", logPrefix)
openBrowser(captchaURL) openBrowser(captchaURL)
key := <-keyCh key := <-keyCh
@ -554,6 +554,52 @@ button{font-size:24px;padding:12px 32px;margin-top:12px;cursor:pointer}</style>
return runCaptchaServerAndWait(mux, localCaptchaOrigin(), keyCh, "captcha HTTP server error") return runCaptchaServerAndWait(mux, localCaptchaOrigin(), keyCh, "captcha HTTP server error")
} }
type loggingTransport struct {
rt http.RoundTripper
}
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, _ := io.ReadAll(req.Body)
req.Body = io.NopCloser(bytes.NewReader(b))
if isDebug {
log.Printf("[Captcha Proxy] Real browser sent %s data: %s", req.URL.Path, string(b))
for k, v := range req.Header {
log.Printf("[Captcha Proxy] Header (%s): %s = %s", req.URL.Path, k, strings.Join(v, ", "))
}
}
if strings.Contains(req.URL.Path, "captchaNotRobot.componentDone") || strings.Contains(req.URL.Path, "captchaNotRobot.check") {
parsedBody, _ := neturl.ParseQuery(string(b))
device := parsedBody.Get("device")
browserFp := parsedBody.Get("browser_fp")
// We only save it if device is present. componentDone usually has it.
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)
}
func solveCaptchaViaProxy(redirectURI string, dialer *dnsdialer.Dialer) (string, error) { func solveCaptchaViaProxy(redirectURI string, dialer *dnsdialer.Dialer) (string, error) {
keyCh := make(chan string, 1) keyCh := make(chan string, 1)
@ -562,7 +608,7 @@ func solveCaptchaViaProxy(redirectURI string, dialer *dnsdialer.Dialer) (string,
return "", fmt.Errorf("invalid redirect URI: %v", err) return "", fmt.Errorf("invalid redirect URI: %v", err)
} }
transport := newCaptchaProxyTransport(dialer) transport := &loggingTransport{rt: newCaptchaProxyTransport(dialer)}
proxy := &httputil.ReverseProxy{ proxy := &httputil.ReverseProxy{
Transport: transport, Transport: transport,
@ -580,7 +626,7 @@ func solveCaptchaViaProxy(redirectURI string, dialer *dnsdialer.Dialer) (string,
if res.StatusCode >= 300 && res.StatusCode < 400 { if res.StatusCode >= 300 && res.StatusCode < 400 {
if loc := res.Header.Get("Location"); loc != "" { if loc := res.Header.Get("Location"); loc != "" {
log.Printf("[Captcha Proxy] Redirecting to: %s", loc) // Don't log the full redirect URL to keep console clean
if rewritten, ok := rewriteProxyRedirectLocation(loc, targetURL); ok { if rewritten, ok := rewriteProxyRedirectLocation(loc, targetURL); ok {
res.Header.Set("Location", rewritten) res.Header.Set("Location", rewritten)
} else { } else {
@ -591,7 +637,9 @@ func solveCaptchaViaProxy(redirectURI string, dialer *dnsdialer.Dialer) (string,
contentType := res.Header.Get("Content-Type") contentType := res.Header.Get("Content-Type")
contentEncoding := res.Header.Get("Content-Encoding") 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") || shouldInspectBody := strings.Contains(contentType, "text/html") ||
strings.Contains(contentType, "application/xhtml+xml") || strings.Contains(contentType, "application/xhtml+xml") ||
@ -723,9 +771,9 @@ func solveCaptchaViaProxy(redirectURI string, dialer *dnsdialer.Dialer) (string,
}) })
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 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 == "" { if r.URL.Path == "/" && targetURL.Path != "" && targetURL.Path != "/" && r.URL.RawQuery == "" {
log.Printf("[Captcha Proxy] Redirecting ROOT to: %s", localCaptchaURLForTarget(targetURL)) // Don't log the full redirect URL to keep console clean
http.Redirect(w, r, localCaptchaURLForTarget(targetURL), http.StatusTemporaryRedirect) http.Redirect(w, r, localCaptchaURLForTarget(targetURL), http.StatusTemporaryRedirect)
return return
} }

35
client/profiles.go

@ -1,7 +1,9 @@
package main package main
import ( import (
"encoding/json"
"math/rand" "math/rand"
"os"
) )
type Profile struct { type Profile struct {
@ -11,8 +13,37 @@ type Profile struct {
SecChUaPlatform string SecChUaPlatform string
} }
type SavedProfile struct {
Profile
DeviceJSON string
BrowserFp string
}
const profileFile = "vk_profile.json"
func LoadProfileFromDisk() (*SavedProfile, error) {
data, err := os.ReadFile(profileFile)
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(profileFile, data, 0644)
}
// profiles contain paired User-Agent and Client Hints strings to harden bot detection. // profiles contain paired User-Agent and Client Hints strings to harden bot detection.
var profile = []Profile{ // Used only as a fallback if no saved profile exists (which we shouldn't really use for check anymore).
var profileList = []Profile{
// Windows Chrome // Windows Chrome
{ {
UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36", UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36",
@ -78,5 +109,5 @@ var profile = []Profile{
// getRandomProfile returns a paired User-Agent and Client Hints profile. // getRandomProfile returns a paired User-Agent and Client Hints profile.
func getRandomProfile() Profile { func getRandomProfile() Profile {
return profile[rand.Intn(len(profile))] return profileList[rand.Intn(len(profileList))]
} }

145
client/slider_captcha.go

@ -11,6 +11,7 @@ import (
_ "image/jpeg" _ "image/jpeg"
"io" "io"
"log" "log"
"math/rand"
neturl "net/url" neturl "net/url"
"regexp" "regexp"
"sort" "sort"
@ -21,9 +22,7 @@ import (
fhttp "github.com/bogdanfinn/fhttp" fhttp "github.com/bogdanfinn/fhttp"
tlsclient "github.com/bogdanfinn/tls-client" tlsclient "github.com/bogdanfinn/tls-client"
) )
const ( const (
captchaDebugInfo = "1d3e9babfd3a74f4588bf90cf5c30d3e8e89a0e2a4544da8de8bbf4d78a32f5c"
sliderCaptchaType = "slider" sliderCaptchaType = "slider"
defaultSliderAttempts = 4 defaultSliderAttempts = 4
) )
@ -36,6 +35,17 @@ type captchaNotRobotSession struct {
client tlsclient.HttpClient client tlsclient.HttpClient
profile Profile profile Profile
browserFp string browserFp string
adFp string
savedProfile *SavedProfile
}
func generateAdFp() string {
b := make([]byte, 16)
// simple random bytes (or any pseudo-random logic that matches the 21-char base64 footprint)
for i := range b {
b[i] = byte(rand.Intn(256))
}
return base64.RawURLEncoding.EncodeToString(b)[:21]
} }
type captchaSettingsResponse struct { type captchaSettingsResponse struct {
@ -68,14 +78,12 @@ type captchaBootstrap struct {
Settings *captchaSettingsResponse Settings *captchaSettingsResponse
} }
func newCaptchaNotRobotSession( func newCaptchaNotRobotSession(ctx context.Context, sessionToken, hash string, streamID int, client tlsclient.HttpClient, profile Profile, savedProfile *SavedProfile) *captchaNotRobotSession {
ctx context.Context, browserFp := generateBrowserFp(profile)
sessionToken string, if savedProfile != nil {
hash string, browserFp = savedProfile.BrowserFp
streamID int, }
client tlsclient.HttpClient,
profile Profile,
) *captchaNotRobotSession {
return &captchaNotRobotSession{ return &captchaNotRobotSession{
ctx: ctx, ctx: ctx,
sessionToken: sessionToken, sessionToken: sessionToken,
@ -83,7 +91,9 @@ func newCaptchaNotRobotSession(
streamID: streamID, streamID: streamID,
client: client, client: client,
profile: profile, profile: profile,
browserFp: generateBrowserFp(profile), browserFp: browserFp,
adFp: generateAdFp(),
savedProfile: savedProfile,
} }
} }
@ -91,7 +101,7 @@ func (s *captchaNotRobotSession) baseValues() neturl.Values {
values := neturl.Values{} values := neturl.Values{}
values.Set("session_token", s.sessionToken) values.Set("session_token", s.sessionToken)
values.Set("domain", "vk.com") values.Set("domain", "vk.com")
values.Set("adFp", "") values.Set("adFp", s.adFp)
values.Set("access_token", "") values.Set("access_token", "")
return values return values
} }
@ -104,6 +114,16 @@ func (s *captchaNotRobotSession) request(method string, values neturl.Values) (m
return nil, err return nil, err
} }
// Match the headers that the real VK captcha JS sends, same as callCaptchaNotRobot.
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) httpResp, err := s.client.Do(req)
if err != nil { if err != nil {
return nil, err return nil, err
@ -135,7 +155,12 @@ func (s *captchaNotRobotSession) requestSettings() (*captchaSettingsResponse, er
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)
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) resp, err := s.request("captchaNotRobot.componentDone", values)
if err != nil { if err != nil {
@ -153,7 +178,7 @@ func (s *captchaNotRobotSession) requestComponentDone() error {
} }
func (s *captchaNotRobotSession) requestCheckboxCheck() (*captchaCheckResult, 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) { func (s *captchaNotRobotSession) requestSliderContent(sliderSettings string) (*sliderCaptchaContent, error) {
@ -169,28 +194,68 @@ func (s *captchaNotRobotSession) requestSliderContent(sliderSettings string) (*s
return parseSliderCaptchaContentResponse(resp) return parseSliderCaptchaContentResponse(resp)
} }
// requestSliderContentWithFallback tries to get slider content using multiple strategies:
// first with the provided captcha_settings, then without it (and vice versa).
// VK sometimes reports show_type=checkbox in settings but actually serves slider content,
// so we need to 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 {
// We have no slider settings; just one attempt without captcha_settings
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, candidateIndex int, candidateCount int) (*captchaCheckResult, error) { func (s *captchaNotRobotSession) requestSliderCheck(activeSteps []int, candidateIndex int, candidateCount int) (*captchaCheckResult, error) {
answer, err := encodeSliderAnswer(activeSteps) answer, err := encodeSliderAnswer(activeSteps)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return s.requestCheck(generateSliderCursor(candidateIndex, candidateCount), answer) return s.requestCheck("[]", answer)
} }
func (s *captchaNotRobotSession) requestCheck(cursor string, answer string) (*captchaCheckResult, error) { func (s *captchaNotRobotSession) requestCheck(cursor string, answer string) (*captchaCheckResult, error) {
values := s.baseValues() values := s.baseValues()
// The real browser sends a static SHA-256 hash for debug_info.
// We use the exact one captured from the real browser's session.
debugInfo := "f3ef768dab7a20f574c6461f34e4257894d2a3c30a53d8727a3edaf7ab70847d"
values.Set("accelerometer", "[]") values.Set("accelerometer", "[]")
values.Set("gyroscope", "[]") values.Set("gyroscope", "[]")
values.Set("motion", "[]") values.Set("motion", "[]")
values.Set("cursor", cursor) values.Set("cursor", cursor)
values.Set("taps", "[]") values.Set("taps", "[]")
values.Set("connectionRtt", "[]") values.Set("connectionRtt", "[250,250,250,250,250]")
values.Set("connectionDownlink", "[]") values.Set("connectionDownlink", "[1.45,1.45,1.45,1.45,1.45]")
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) values.Set("debug_info", debugInfo)
resp, err := s.request("captchaNotRobot.check", values) resp, err := s.request("captchaNotRobot.check", values)
if err != nil { if err != nil {
@ -214,8 +279,9 @@ func callCaptchaNotRobotWithSliderPOC(
client tlsclient.HttpClient, client tlsclient.HttpClient,
profile Profile, profile Profile,
initialSettings *captchaSettingsResponse, initialSettings *captchaSettingsResponse,
savedProfile *SavedProfile,
) (string, error) { ) (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) log.Printf("[STREAM %d] [Captcha] Step 1/4: settings", streamID)
settingsResp, err := session.requestSettings() settingsResp, err := session.requestSettings()
@ -265,23 +331,24 @@ func callCaptchaNotRobotWithSliderPOC(
log.Printf("[STREAM %d] [Captcha] Trying experimental slider solver...", streamID) 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 {
// Non-fatal: log and continue — getContent may still succeed.
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 { if err != nil {
log.Printf( log.Printf(
"[STREAM %d] [Captcha] Slider getContent failed (status: %v). Trying to solve as a checkbox instead...", "[STREAM %d] [Captcha] All slider getContent attempts failed: %v",
streamID, streamID,
err, err,
) )
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
}
return "", fmt.Errorf("check status: %s (slider getContent failed: %w)", initialCheck.Status, err) return "", fmt.Errorf("check status: %s (slider getContent failed: %w)", initialCheck.Status, err)
} }
@ -316,8 +383,10 @@ func callCaptchaNotRobotWithSliderPOC(
} }
func buildCaptchaDeviceJSON(profile Profile) string { func buildCaptchaDeviceJSON(profile Profile) string {
// Fallback device JSON if no saved profile is available.
// We include the User-Agent from the current profile to maintain some consistency.
return fmt.Sprintf( 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, profile.UserAgent,
) )
} }
@ -528,6 +597,15 @@ func parseSliderCaptchaContentResponse(resp map[string]interface{}) (*sliderCapt
status, _ := respObj["status"].(string) status, _ := respObj["status"].(string)
if status != "OK" { 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" { // skip base64 image blob
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) return nil, fmt.Errorf("slider getContent status: %s", status)
} }
@ -862,6 +940,7 @@ func absDiff(left uint32, right uint32) int64 {
return int64(right - left) return int64(right - left)
} }
/*
func generateSliderCursor(candidateIndex int, candidateCount int) string { func generateSliderCursor(candidateIndex int, candidateCount int) string {
return buildSliderCursor(candidateIndex, candidateCount, time.Now().Add(-220*time.Millisecond).UnixMilli()) return buildSliderCursor(candidateIndex, candidateCount, time.Now().Add(-220*time.Millisecond).UnixMilli())
} }
@ -898,6 +977,8 @@ func buildSliderCursor(candidateIndex int, candidateCount int, startTime int64)
} }
return string(data) return string(data)
} }
*/
func trySliderCaptchaCandidates( func trySliderCaptchaCandidates(
candidates []sliderCandidate, candidates []sliderCandidate,

Loading…
Cancel
Save