Browse Source

fix: port VK captcha v2 solver

Co-Authored-By: Nikita <[email protected]>
pull/162/head
Moroka8 1 month ago
parent
commit
21cf9fa91b
  1. 570
      client/captcha_v2.go
  2. 606
      client/captcha_v2_slider.go
  3. 19
      client/main.go

570
client/captcha_v2.go

@ -0,0 +1,570 @@
package main
import (
"bytes"
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"log"
mathrand "math/rand"
"regexp"
"strconv"
"strings"
"sync"
"time"
fhttp "github.com/bogdanfinn/fhttp"
tlsclient "github.com/bogdanfinn/tls-client"
)
const (
captchaV2APIVersion = "5.131"
captchaV2ScriptVersion = "1.1.1324"
captchaV2DeviceInfo = `{"screenWidth":1920,"screenHeight":1080,"screenAvailWidth":1920,"screenAvailHeight":1080,"innerWidth":1920,"innerHeight":951,"devicePixelRatio":1,"language":"en-US","languages":["en-US","en"],"webdriver":false,"hardwareConcurrency":8,"notificationsPermission":"denied"}`
)
var (
reCaptchaV2PowInput = regexp.MustCompile(`const\s+powInput\s*=\s*"([^"]+)"`)
reCaptchaV2Difficulty = regexp.MustCompile(`const\s+difficulty\s*=\s*(\d+)`)
reCaptchaV2WindowInit = regexp.MustCompile(`(?s)window\.init\s*=\s*(\{.*?})\s*;`)
reCaptchaV2ScriptSrc = regexp.MustCompile(`src="(https://[^"]+not_robot_captcha[^"]+)"`)
reCaptchaV2DebugInfo = regexp.MustCompile(`debug_info:(?:[^"]*\|\|)?"([a-fA-F0-9]{64})"`)
reCaptchaV2Version = regexp.MustCompile(`vkid/([0-9.]*)/not_robot_captcha\.js`)
errCaptchaV2RateLimit = errors.New("captcha session rate limit reached")
errCaptchaV2Bot = errors.New("captcha bot challenge")
captchaV2MaxAttempts = 2
captchaV2DebugCache sync.Map // scriptURL -> string
captchaV2HeaderOrder = []string{
"host",
"content-length",
"sec-ch-ua-platform",
"accept-language",
"sec-ch-ua",
"content-type",
"sec-ch-ua-mobile",
"user-agent",
"accept",
"origin",
"sec-fetch-site",
"sec-fetch-mode",
"sec-fetch-dest",
"referer",
"accept-encoding",
"priority",
}
captchaV2PHeaderOrder = []string{":method", ":path", ":authority", ":scheme"}
)
type captchaV2Init struct {
Data captchaV2InitData `json:"data"`
}
type captchaV2InitData struct {
ShowCaptchaType string `json:"show_captcha_type"`
CaptchaSettings []captchaV2InitSetting `json:"captcha_settings"`
}
type captchaV2InitSetting struct {
Type string `json:"type"`
Settings string `json:"settings"`
}
type captchaV2Page struct {
PowInput string
PowDifficulty int
ScriptURL string
Init *captchaV2Init
}
type captchaV2Check struct {
Status string
SuccessToken string
ShowType string
}
type captchaV2ShowTypeError struct {
ShowType string
}
func (e *captchaV2ShowTypeError) Error() string {
return "captcha show type mismatch: " + e.ShowType
}
type captchaV2Session struct {
ctx context.Context
client tlsclient.HttpClient
profile Profile
savedProfile *SavedProfile
}
func solveVkCaptchaV2(
ctx context.Context,
captchaErr *VkCaptchaError,
streamID int,
client tlsclient.HttpClient,
profile Profile,
savedProfile *SavedProfile,
) (string, error) {
if captchaErr == nil || captchaErr.SessionToken == "" {
return "", fmt.Errorf("no session_token in redirect_uri")
}
log.Printf("[STREAM %d] [Captcha] Solving VK Smart Captcha automatically (v2)...", streamID)
s := &captchaV2Session{ctx: ctx, client: client, profile: profile, savedProfile: savedProfile}
for attempt := 1; attempt <= captchaV2MaxAttempts; attempt++ {
token, solveErr := s.solveOnce(captchaErr)
if solveErr == nil {
return token, nil
}
log.Printf("[STREAM %d] [Captcha] v2 captcha solve attempt %d failed: %v", streamID, attempt, solveErr)
if errors.Is(solveErr, errCaptchaV2RateLimit) {
return "", solveErr
}
backoffSteps := attempt
if backoffSteps > 10 {
backoffSteps = 10
}
timer := time.NewTimer(time.Duration(backoffSteps) * 500 * time.Millisecond)
select {
case <-ctx.Done():
timer.Stop()
return "", ctx.Err()
case <-timer.C:
}
}
return "", fmt.Errorf("v2 captcha attempts exhausted")
}
func (s *captchaV2Session) solveOnce(captchaErr *VkCaptchaError) (string, error) {
html, err := s.fetchCaptchaHTML(captchaErr.RedirectURI)
if err != nil {
return "", err
}
page, err := parseCaptchaV2Page(html)
if err != nil {
return "", err
}
if page.PowInput == "" {
return "", errors.New("failed to find PoW settings")
}
sliderSettings := ""
if page.Init != nil {
for _, setting := range page.Init.Data.CaptchaSettings {
if setting.Type == "slider" {
sliderSettings = setting.Settings
}
}
}
if page.Init != nil && page.Init.Data.ShowCaptchaType == "slider" && sliderSettings == "" {
return "", errors.New("failed to find slider captcha settings")
}
log.Printf("v2 captcha solving pow difficulty=%d", page.PowDifficulty)
hash := solveCaptchaPoWV2(s.ctx, page.PowInput, page.PowDifficulty)
if hash == "" {
return "", errors.New("captcha pow failed")
}
log.Printf("v2 captcha pow solved")
base := captchaV2BaseValues(captchaErr.SessionToken)
if _, err := s.captchaRequest("captchaNotRobot.settings", base); err != nil {
return "", fmt.Errorf("captcha settings failed: %w", err)
}
browserFP, err := captchaV2BrowserFP()
if err != nil {
return "", err
}
if s.savedProfile != nil && strings.TrimSpace(s.savedProfile.BrowserFp) != "" {
browserFP = s.savedProfile.BrowserFp
}
if m := reCaptchaV2Version.FindStringSubmatch(page.ScriptURL); len(m) > 1 {
if m[1] != captchaV2ScriptVersion {
log.Printf("v2 captcha script version drift: known=%s latest=%s", captchaV2ScriptVersion, m[1])
}
}
debugInfo, err := s.fetchDebugInfo(page.ScriptURL)
if err != nil {
return "", fmt.Errorf("failed to fetch debug info: %w (script_version=%s)", err, captchaV2ScriptVersion)
}
showType := ""
if page.Init != nil {
showType = page.Init.Data.ShowCaptchaType
}
var token string
for {
log.Printf("v2 captcha solving show_type=%s", showType)
switch showType {
case "slider":
token, err = s.solveSliderCaptcha(captchaErr.SessionToken, browserFP, hash, sliderSettings, debugInfo)
case "checkbox", "":
token, err = s.solveCheckboxCaptcha(captchaErr.SessionToken, browserFP, hash, debugInfo)
default:
return "", fmt.Errorf("unsupported captcha type: %s", showType)
}
if err == nil {
break
}
if errors.Is(err, errCaptchaV2Bot) && !strings.EqualFold(showType, "slider") && sliderSettings != "" {
log.Printf("v2 captcha checkbox returned BOT, trying slider challenge from page settings")
showType = "slider"
continue
}
var stErr *captchaV2ShowTypeError
if !errors.As(err, &stErr) || stErr.ShowType == "" {
return "", err
}
showType = stErr.ShowType
}
_, _ = s.captchaRequest("captchaNotRobot.endSession", base)
return token, nil
}
func captchaV2BaseValues(sessionToken string) [][2]string {
return [][2]string{
{"session_token", sessionToken},
{"domain", "vk.com"},
{"adFp", ""},
{"access_token", ""},
}
}
func captchaV2BrowserFP() (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", fmt.Errorf("browser fp generate: %w", err)
}
return hex.EncodeToString(b), nil
}
func (s *captchaV2Session) fetchCaptchaHTML(redirectURI string) (string, error) {
body, err := s.doRaw(fhttp.MethodGet, redirectURI, nil, map[string]string{
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Sec-Fetch-Dest": "document",
"Sec-Fetch-Mode": "navigate",
"Sec-Fetch-Site": "cross-site",
})
if err != nil {
return "", err
}
return string(body), nil
}
func (s *captchaV2Session) fetchDebugInfo(scriptURL string) (string, error) {
if cached, ok := captchaV2DebugCache.Load(scriptURL); ok {
return cached.(string), nil
}
body, err := s.doRaw(fhttp.MethodGet, scriptURL, nil, map[string]string{
"Accept": "text/javascript,*/*",
"Referer": "https://id.vk.com/",
})
if err != nil {
return "", err
}
m := reCaptchaV2DebugInfo.FindSubmatch(body)
if len(m) < 2 {
return "", errors.New("debug_info match not found")
}
v := string(m[1])
captchaV2DebugCache.Store(scriptURL, v)
log.Printf("v2 captcha debug_info fetched url=%s", scriptURL)
return v, nil
}
func parseCaptchaV2Page(html string) (*captchaV2Page, error) {
page := &captchaV2Page{}
match := reCaptchaV2WindowInit.FindStringSubmatch(html)
if len(match) < 2 {
return nil, errors.New("captcha init json not found")
}
var init captchaV2Init
if err := json.Unmarshal([]byte(match[1]), &init); err != nil {
return nil, fmt.Errorf("captcha init json parse: %w", err)
}
page.Init = &init
match = reCaptchaV2ScriptSrc.FindStringSubmatch(html)
if len(match) < 2 {
return nil, errors.New("captcha script url not found")
}
page.ScriptURL = match[1]
if m := reCaptchaV2PowInput.FindStringSubmatch(html); len(m) >= 2 {
page.PowInput = m[1]
}
if page.PowInput == "" {
return page, nil
}
match = reCaptchaV2Difficulty.FindStringSubmatch(html)
if len(match) < 2 {
return nil, errors.New("captcha difficulty const not found")
}
difficulty, err := strconv.Atoi(match[1])
if err != nil || difficulty <= 0 {
return nil, fmt.Errorf("invalid captcha difficulty %q", match[1])
}
page.PowDifficulty = difficulty
return page, nil
}
func (s *captchaV2Session) captchaRequest(method string, form [][2]string) (map[string]any, error) {
endpoint := "https://api.vk.ru/method/" + method + "?v=" + captchaV2APIVersion
body, err := s.doRaw(fhttp.MethodPost, endpoint, form, map[string]string{
"Origin": "https://id.vk.com",
"Referer": "https://id.vk.com/",
"Priority": "u=1, i",
})
if err != nil {
return nil, err
}
var out map[string]any
if err := json.Unmarshal(body, &out); err != nil {
return nil, fmt.Errorf("captcha api decode: %w", err)
}
return out, nil
}
func (s *captchaV2Session) performCaptchaCheck(
sessionToken string,
browserFP string,
hash string,
answerJSON string,
cursor string,
debugInfo string,
) (*captchaV2Check, error) {
values := [][2]string{
{"session_token", sessionToken},
{"domain", "vk.com"},
{"adFp", ""},
{"accelerometer", "[]"},
{"gyroscope", "[]"},
{"motion", "[]"},
{"cursor", cursor},
{"taps", "[]"},
{"connectionRtt", "[]"},
{"connectionDownlink", "[]"},
{"browser_fp", browserFP},
{"hash", hash},
{"answer", base64.StdEncoding.EncodeToString([]byte(answerJSON))},
{"debug_info", debugInfo},
{"access_token", ""},
}
resp, err := s.captchaRequest("captchaNotRobot.check", values)
if err != nil {
return nil, fmt.Errorf("captcha check failed: %w", err)
}
check, err := parseCaptchaV2Check(resp)
if err != nil {
return nil, err
}
if check.ShowType != "" {
log.Printf("v2 captcha check status=%s show_type=%s", check.Status, check.ShowType)
} else {
log.Printf("v2 captcha check status=%s", check.Status)
}
return check, nil
}
func parseCaptchaV2Check(raw map[string]any) (*captchaV2Check, error) {
resp, ok := raw["response"].(map[string]any)
if !ok {
return nil, fmt.Errorf("invalid captcha check response: %v", raw)
}
out := &captchaV2Check{
Status: captchaV2StringifyAny(resp["status"]),
SuccessToken: captchaV2StringifyAny(resp["success_token"]),
ShowType: captchaV2StringifyAny(resp["show_captcha_type"]),
}
if out.Status == "" {
return nil, fmt.Errorf("captcha check status missing: %v", raw)
}
return out, nil
}
func (s *captchaV2Session) solveCheckboxCaptcha(
sessionToken string,
browserFP string,
hash string,
debugInfo string,
) (string, error) {
deviceJSON := captchaV2DeviceInfo
if s.savedProfile != nil && strings.TrimSpace(s.savedProfile.DeviceJSON) != "" {
deviceJSON = s.savedProfile.DeviceJSON
}
if _, err := s.captchaRequest("captchaNotRobot.componentDone", [][2]string{
{"session_token", sessionToken},
{"domain", "vk.com"},
{"adFp", ""},
{"browser_fp", browserFP},
{"device", deviceJSON},
{"access_token", ""},
}); err != nil {
return "", fmt.Errorf("captcha componentDone failed: %w", err)
}
select {
case <-s.ctx.Done():
return "", s.ctx.Err()
case <-time.After(time.Duration(400+mathrand.Intn(250)) * time.Millisecond):
}
check, err := s.performCaptchaCheck(sessionToken, browserFP, hash, "{}", "[]", debugInfo)
if err != nil {
return "", err
}
if check.ShowType != "" && !strings.EqualFold(check.ShowType, "checkbox") {
return "", &captchaV2ShowTypeError{ShowType: check.ShowType}
}
if strings.EqualFold(check.Status, "error_limit") {
return "", errCaptchaV2RateLimit
}
if strings.EqualFold(check.Status, "bot") {
return "", fmt.Errorf("%w: checkbox captcha rejected: status=%s", errCaptchaV2Bot, check.Status)
}
if !strings.EqualFold(check.Status, "ok") {
return "", fmt.Errorf("checkbox captcha rejected: status=%s", check.Status)
}
if check.SuccessToken == "" {
return "", errors.New("captcha success token not found")
}
return check.SuccessToken, nil
}
func solveCaptchaPoWV2(ctx context.Context, input string, difficulty int) string {
if input == "" || difficulty <= 0 {
return ""
}
target := strings.Repeat("0", difficulty)
for nonce := 1; nonce <= 10_000_000; nonce++ {
if nonce%4096 == 0 {
select {
case <-ctx.Done():
return ""
default:
}
}
sum := sha256.Sum256([]byte(input + strconv.Itoa(nonce)))
hashHex := hex.EncodeToString(sum[:])
if strings.HasPrefix(hashHex, target) {
return hashHex
}
}
return ""
}
func (s *captchaV2Session) doRaw(
method string,
endpoint string,
form [][2]string,
extraHeaders map[string]string,
) ([]byte, error) {
var body []byte
if form != nil {
body = []byte(captchaV2EncodeForm(form))
}
req, err := fhttp.NewRequestWithContext(s.ctx, method, endpoint, bytes.NewReader(body))
if err != nil {
return nil, err
}
applyBrowserProfileFhttp(req, s.profile)
req.Header.Set("Accept", "*/*")
req.Header.Set("Sec-Fetch-Site", "same-site")
req.Header.Set("Sec-Fetch-Mode", "cors")
req.Header.Set("Sec-Fetch-Dest", "empty")
req.Header.Set("Origin", "https://vk.com")
req.Header.Set("Referer", "https://vk.com/")
if form != nil {
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
}
for k, v := range extraHeaders {
req.Header.Set(k, v)
}
req.Header[fhttp.HeaderOrderKey] = captchaV2HeaderOrder
req.Header[fhttp.PHeaderOrderKey] = captchaV2PHeaderOrder
resp, err := s.client.Do(req)
if err != nil {
return nil, err
}
defer func() {
if closeErr := resp.Body.Close(); closeErr != nil {
log.Printf("v2 captcha close body: %s", closeErr)
}
}()
return io.ReadAll(resp.Body)
}
func captchaV2EncodeForm(values [][2]string) string {
if len(values) == 0 {
return ""
}
var sb strings.Builder
for i, kv := range values {
if i > 0 {
sb.WriteByte('&')
}
sb.WriteString(captchaV2QueryEscape(kv[0]))
sb.WriteByte('=')
sb.WriteString(captchaV2QueryEscape(kv[1]))
}
return sb.String()
}
func captchaV2QueryEscape(s string) string {
const upper = "0123456789ABCDEF"
hexDigits := func(b byte) [3]byte {
return [3]byte{'%', upper[b>>4], upper[b&0xF]}
}
out := make([]byte, 0, len(s))
for i := 0; i < len(s); i++ {
c := s[i]
switch {
case c == ' ':
out = append(out, '+')
case ('a' <= c && c <= 'z') || ('A' <= c && c <= 'Z') || ('0' <= c && c <= '9') || c == '-' || c == '_' || c == '.' || c == '~':
out = append(out, c)
default:
h := hexDigits(c)
out = append(out, h[:]...)
}
}
return string(out)
}
func captchaV2StringifyAny(value any) string {
switch v := value.(type) {
case nil:
return ""
case string:
return v
case float64:
return strconv.FormatFloat(v, 'f', -1, 64)
case bool:
return strconv.FormatBool(v)
default:
data, err := json.Marshal(v)
if err != nil {
return fmt.Sprintf("%v", v)
}
return string(data)
}
}

606
client/captcha_v2_slider.go

@ -0,0 +1,606 @@
package main
import (
"bytes"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"image"
"image/color"
_ "image/jpeg"
"log"
"math"
mathrand "math/rand"
"runtime"
"sort"
"strconv"
"strings"
"sync"
)
type sliderPuzzleV2 struct {
Image image.Image
Size int
Swaps []int
Attempts int
}
type sliderGuessV2 struct {
Index int
Swaps []int
Score int64
ScoreRGB int64
ScoreLuma int64
ScoreText float64
ConsensusRank int
}
func (s *captchaV2Session) solveSliderCaptcha(
sessionToken string,
browserFP string,
hash string,
settings string,
debugInfo string,
) (string, error) {
values := [][2]string{
{"session_token", sessionToken},
{"domain", "vk.com"},
{"adFp", ""},
{"access_token", ""},
{"captcha_settings", settings},
}
resp, err := s.captchaRequest("captchaNotRobot.getContent", values)
if err != nil {
return "", fmt.Errorf("slider getContent failed: %w", err)
}
puzzle, err := parseSliderPuzzleV2(resp)
if err != nil {
return "", err
}
log.Printf("v2 slider puzzle decoded: grid=%d attempts=%d swaps=%d", puzzle.Size, puzzle.Attempts, len(puzzle.Swaps))
guesses, err := rankSliderGuessesV2(puzzle.Image, puzzle.Size, puzzle.Swaps)
if err != nil {
return "", err
}
limit := puzzle.Attempts
if limit > len(guesses) {
limit = len(guesses)
}
if limit <= 0 {
return "", errors.New("slider has no attempts available")
}
log.Printf("v2 slider guesses ranked: total=%d limit=%d", len(guesses), limit)
deviceJSON := captchaV2DeviceInfo
if s.savedProfile != nil && strings.TrimSpace(s.savedProfile.DeviceJSON) != "" {
deviceJSON = s.savedProfile.DeviceJSON
}
if _, err := s.captchaRequest("captchaNotRobot.componentDone", [][2]string{
{"session_token", sessionToken},
{"domain", "vk.com"},
{"adFp", ""},
{"access_token", ""},
{"browser_fp", browserFP},
{"device", deviceJSON},
}); err != nil {
return "", fmt.Errorf("captcha componentDone failed: %w", err)
}
for i := 0; i < limit; i++ {
log.Printf("v2 slider attempt %d/%d (guess #%d)", i+1, limit, guesses[i].Index)
answerData, err := json.Marshal(struct {
Value []int `json:"value"`
}{Value: guesses[i].Swaps})
if err != nil {
return "", err
}
check, err := s.performCaptchaCheck(
sessionToken,
browserFP,
hash,
string(answerData),
buildSliderCursorV2(guesses[i].Index, len(guesses)),
debugInfo,
)
if err != nil {
return "", err
}
if strings.EqualFold(check.Status, "ok") {
if check.SuccessToken == "" {
return "", errors.New("captcha success token not found")
}
log.Printf("v2 slider accepted on attempt %d", i+1)
return check.SuccessToken, nil
}
if strings.EqualFold(check.Status, "error_limit") {
return "", errCaptchaV2RateLimit
}
}
return "", errors.New("slider guesses exhausted")
}
func parseSliderPuzzleV2(raw map[string]any) (*sliderPuzzleV2, error) {
resp, ok := raw["response"].(map[string]any)
if !ok {
return nil, fmt.Errorf("invalid slider content response: %v", raw)
}
status := captchaV2StringifyAny(resp["status"])
if !strings.EqualFold(status, "ok") {
return nil, fmt.Errorf("slider getContent status: %s", status)
}
rawImage := captchaV2StringifyAny(resp["image"])
if rawImage == "" {
return nil, errors.New("slider image missing")
}
rawSteps, ok := resp["steps"].([]any)
if !ok {
return nil, errors.New("slider steps missing")
}
steps := make([]int, 0, len(rawSteps))
for _, item := range rawSteps {
switch v := item.(type) {
case float64:
steps = append(steps, int(v))
case int:
steps = append(steps, v)
case string:
n, err := strconv.Atoi(strings.TrimSpace(v))
if err != nil {
return nil, fmt.Errorf("invalid numeric value: %v", item)
}
steps = append(steps, n)
default:
return nil, fmt.Errorf("invalid numeric value: %v", item)
}
}
size, swaps, attempts, err := splitSliderStepsV2(steps)
if err != nil {
return nil, err
}
data, err := base64.StdEncoding.DecodeString(rawImage)
if err != nil {
return nil, fmt.Errorf("decode slider image: %w", err)
}
img, _, err := image.Decode(bytes.NewReader(data))
if err != nil {
return nil, fmt.Errorf("decode slider image: %w", err)
}
return &sliderPuzzleV2{Image: img, Size: size, Swaps: swaps, Attempts: attempts}, nil
}
func splitSliderStepsV2(steps []int) (int, []int, int, error) {
if len(steps) < 3 {
return 0, nil, 0, errors.New("slider steps payload too short")
}
size := steps[0]
if size <= 0 {
return 0, nil, 0, fmt.Errorf("invalid slider size: %d", size)
}
tail := append([]int(nil), steps[1:]...)
attempts := 4
if len(tail)%2 != 0 {
attempts = tail[len(tail)-1]
tail = tail[:len(tail)-1]
log.Printf("v2 slider payload had odd-length tail; fallback attempts=%d", attempts)
}
if attempts <= 0 {
attempts = 4
}
if len(tail) == 0 || len(tail)%2 != 0 {
return 0, nil, 0, errors.New("invalid slider swap payload")
}
return size, tail, attempts, nil
}
func rankSliderGuessesV2(img image.Image, gridSize int, swaps []int) ([]sliderGuessV2, error) {
candidateCount := len(swaps) / 2
if candidateCount == 0 {
return nil, errors.New("slider has no candidates")
}
guesses := make([]sliderGuessV2, candidateCount)
for idx := 1; idx <= candidateCount; idx++ {
active := activeSwapsForIndexV2(swaps, idx)
mapping, err := applySliderSwapsV2(gridSize, active)
if err != nil {
return nil, err
}
guesses[idx-1] = sliderGuessV2{Index: idx, Swaps: active}
guesses[idx-1].ScoreLuma = seamScoreLumaV2(img, gridSize, mapping)
}
lumaOrder := append([]sliderGuessV2(nil), guesses...)
sort.SliceStable(lumaOrder, func(i, j int) bool {
if lumaOrder[i].ScoreLuma == lumaOrder[j].ScoreLuma {
return lumaOrder[i].Index < lumaOrder[j].Index
}
return lumaOrder[i].ScoreLuma < lumaOrder[j].ScoreLuma
})
lumaRank := make(map[int]int, candidateCount)
for rank, g := range lumaOrder {
lumaRank[g.Index] = rank
}
stage2Count := candidateCount
if stage2Count > 12 {
stage2Count = 12
}
stage2Set := make(map[int]struct{}, stage2Count)
for i := 0; i < stage2Count; i++ {
stage2Set[lumaOrder[i].Index] = struct{}{}
}
type stage2Result struct {
index int
rgb int64
text float64
err error
}
jobs := make([]int, 0, stage2Count)
for idx := range stage2Set {
jobs = append(jobs, idx)
}
jobCh := make(chan int, len(jobs))
resCh := make(chan stage2Result, len(jobs))
workers := runtime.NumCPU()
if workers < 1 {
workers = 1
}
if workers > len(jobs) {
workers = len(jobs)
}
var wg sync.WaitGroup
for w := 0; w < workers; w++ {
wg.Add(1)
go func() {
defer wg.Done()
for index := range jobCh {
mapping, err := applySliderSwapsV2(gridSize, guesses[index-1].Swaps)
if err != nil {
resCh <- stage2Result{index: index, err: err}
continue
}
rgb, text := seamScoreRGBTextV2(img, gridSize, mapping)
resCh <- stage2Result{index: index, rgb: rgb, text: text}
}
}()
}
for _, idx := range jobs {
jobCh <- idx
}
close(jobCh)
wg.Wait()
close(resCh)
for r := range resCh {
if r.err != nil {
return nil, r.err
}
g := &guesses[r.index-1]
g.ScoreRGB = r.rgb
g.ScoreText = r.text
}
stage2 := make([]sliderGuessV2, 0, stage2Count)
for _, g := range guesses {
if _, ok := stage2Set[g.Index]; ok {
stage2 = append(stage2, g)
}
}
rgbOrder := append([]sliderGuessV2(nil), stage2...)
sort.SliceStable(rgbOrder, func(i, j int) bool {
if rgbOrder[i].ScoreRGB == rgbOrder[j].ScoreRGB {
return rgbOrder[i].Index < rgbOrder[j].Index
}
return rgbOrder[i].ScoreRGB < rgbOrder[j].ScoreRGB
})
rgbRank := make(map[int]int, len(rgbOrder))
for rank, g := range rgbOrder {
rgbRank[g.Index] = rank
}
textOrder := append([]sliderGuessV2(nil), stage2...)
sort.SliceStable(textOrder, func(i, j int) bool {
if textOrder[i].ScoreText == textOrder[j].ScoreText {
return textOrder[i].Index < textOrder[j].Index
}
return textOrder[i].ScoreText < textOrder[j].ScoreText
})
textRank := make(map[int]int, len(textOrder))
for rank, g := range textOrder {
textRank[g.Index] = rank
}
for i := range guesses {
g := &guesses[i]
g.ConsensusRank = lumaRank[g.Index]
if _, ok := stage2Set[g.Index]; ok {
g.ConsensusRank += rgbRank[g.Index] + textRank[g.Index]
} else {
g.ConsensusRank += candidateCount
}
g.Score = int64(g.ConsensusRank)
}
sort.SliceStable(guesses, func(i, j int) bool {
if guesses[i].ConsensusRank == guesses[j].ConsensusRank {
if guesses[i].ScoreLuma == guesses[j].ScoreLuma {
return guesses[i].Index < guesses[j].Index
}
return guesses[i].ScoreLuma < guesses[j].ScoreLuma
}
return guesses[i].ConsensusRank < guesses[j].ConsensusRank
})
return guesses, nil
}
func activeSwapsForIndexV2(swaps []int, index int) []int {
if index <= 0 {
return []int{}
}
end := index * 2
if end > len(swaps) {
end = len(swaps)
}
return append([]int(nil), swaps[:end]...)
}
func applySliderSwapsV2(gridSize int, swaps []int) ([]int, error) {
tileCount := gridSize * gridSize
if tileCount <= 0 {
return nil, fmt.Errorf("invalid slider tile count: %d", tileCount)
}
if len(swaps)%2 != 0 {
return nil, fmt.Errorf("invalid slider swaps length: %d", len(swaps))
}
mapping := make([]int, tileCount)
for i := range mapping {
mapping[i] = i
}
for i := 0; i < len(swaps); i += 2 {
left := swaps[i]
right := swaps[i+1]
if left < 0 || right < 0 || left >= tileCount || right >= tileCount {
return nil, fmt.Errorf("slider step out of range: %d,%d", left, right)
}
mapping[left], mapping[right] = mapping[right], mapping[left]
}
return mapping, nil
}
func seamScoreLumaV2(img image.Image, gridSize int, mapping []int) int64 {
bounds := img.Bounds()
var score int64
for row := 0; row < gridSize; row++ {
for col := 0; col < gridSize-1; col++ {
leftIdx := row*gridSize + col
rightIdx := leftIdx + 1
leftDst := sliderTileRect(bounds, gridSize, leftIdx)
rightDst := sliderTileRect(bounds, gridSize, rightIdx)
leftSrc := sliderTileRect(bounds, gridSize, mapping[leftIdx])
rightSrc := sliderTileRect(bounds, gridSize, mapping[rightIdx])
h := leftDst.Dy()
if rightDst.Dy() < h {
h = rightDst.Dy()
}
for y := 0; y < h; y++ {
yy := leftDst.Min.Y + y
a := sampleLumaMappedV2(img, leftDst, leftSrc, leftDst.Max.X-1, yy)
b := sampleLumaMappedV2(img, rightDst, rightSrc, rightDst.Min.X, yy)
score += int64(absIntV2(int(a) - int(b)))
}
}
}
for row := 0; row < gridSize-1; row++ {
for col := 0; col < gridSize; col++ {
topIdx := row*gridSize + col
bottomIdx := (row+1)*gridSize + col
topDst := sliderTileRect(bounds, gridSize, topIdx)
bottomDst := sliderTileRect(bounds, gridSize, bottomIdx)
topSrc := sliderTileRect(bounds, gridSize, mapping[topIdx])
bottomSrc := sliderTileRect(bounds, gridSize, mapping[bottomIdx])
w := topDst.Dx()
if bottomDst.Dx() < w {
w = bottomDst.Dx()
}
for x := 0; x < w; x++ {
xx := topDst.Min.X + x
a := sampleLumaMappedV2(img, topDst, topSrc, xx, topDst.Max.Y-1)
b := sampleLumaMappedV2(img, bottomDst, bottomSrc, xx, bottomDst.Min.Y)
score += int64(absIntV2(int(a) - int(b)))
}
}
}
return score
}
func seamScoreRGBTextV2(img image.Image, gridSize int, mapping []int) (int64, float64) {
bounds := img.Bounds()
height := float64(bounds.Dy())
textCenters := []float64{
float64(bounds.Min.Y) + 0.2*height,
float64(bounds.Min.Y) + 0.5*height,
float64(bounds.Min.Y) + 0.8*height,
}
sigma := height * 0.14
if sigma < 1.0 {
sigma = 1.0
}
weight := func(y int) float64 {
yf := float64(y)
best := absFloatV2(yf - textCenters[0])
for i := 1; i < len(textCenters); i++ {
d := absFloatV2(yf - textCenters[i])
if d < best {
best = d
}
}
return 1 + 3*math.Exp(-(best*best)/(2*sigma*sigma))
}
var rgbScore int64
var textScore float64
for row := 0; row < gridSize; row++ {
for col := 0; col < gridSize-1; col++ {
leftIdx := row*gridSize + col
rightIdx := leftIdx + 1
leftDst := sliderTileRect(bounds, gridSize, leftIdx)
rightDst := sliderTileRect(bounds, gridSize, rightIdx)
leftSrc := sliderTileRect(bounds, gridSize, mapping[leftIdx])
rightSrc := sliderTileRect(bounds, gridSize, mapping[rightIdx])
h := leftDst.Dy()
if rightDst.Dy() < h {
h = rightDst.Dy()
}
for y := 0; y < h; y++ {
yy := leftDst.Min.Y + y
l := sampleColorMappedV2(img, leftDst, leftSrc, leftDst.Max.X-1, yy)
r := sampleColorMappedV2(img, rightDst, rightSrc, rightDst.Min.X, yy)
rgbScore += pixelDiff(l, r)
_, _, lb, _ := l.RGBA()
_, _, rb, _ := r.RGBA()
textScore += weight(yy) * float64(absIntV2(int(lb>>8)-int(rb>>8)))
}
}
}
for row := 0; row < gridSize-1; row++ {
for col := 0; col < gridSize; col++ {
topIdx := row*gridSize + col
bottomIdx := (row+1)*gridSize + col
topDst := sliderTileRect(bounds, gridSize, topIdx)
bottomDst := sliderTileRect(bounds, gridSize, bottomIdx)
topSrc := sliderTileRect(bounds, gridSize, mapping[topIdx])
bottomSrc := sliderTileRect(bounds, gridSize, mapping[bottomIdx])
w := topDst.Dx()
if bottomDst.Dx() < w {
w = bottomDst.Dx()
}
for x := 0; x < w; x++ {
xx := topDst.Min.X + x
t := sampleColorMappedV2(img, topDst, topSrc, xx, topDst.Max.Y-1)
b := sampleColorMappedV2(img, bottomDst, bottomSrc, xx, bottomDst.Min.Y)
rgbScore += pixelDiff(t, b)
_, _, tb, _ := t.RGBA()
_, _, bb, _ := b.RGBA()
textScore += 0.65 * float64(absIntV2(int(tb>>8)-int(bb>>8)))
}
}
}
return rgbScore, textScore
}
func sampleColorMappedV2(img image.Image, dstRect image.Rectangle, srcRect image.Rectangle, dstX int, dstY int) color.Color {
dx := dstRect.Dx()
if dx < 1 {
dx = 1
}
dy := dstRect.Dy()
if dy < 1 {
dy = 1
}
sx := srcRect.Min.X + (dstX-dstRect.Min.X)*srcRect.Dx()/dx
sy := srcRect.Min.Y + (dstY-dstRect.Min.Y)*srcRect.Dy()/dy
return img.At(sx, sy)
}
func sampleLumaMappedV2(img image.Image, dstRect image.Rectangle, srcRect image.Rectangle, dstX int, dstY int) uint8 {
c := sampleColorMappedV2(img, dstRect, srcRect, dstX, dstY)
r, g, b, _ := c.RGBA()
y := (299*(r>>8) + 587*(g>>8) + 114*(b>>8)) / 1000
return uint8(y)
}
func absFloatV2(v float64) float64 {
if v < 0 {
return -v
}
return v
}
func absIntV2(v int) int {
if v < 0 {
return -v
}
return v
}
func buildSliderCursorV2(candidateIndex int, candidateCount int) string {
if candidateCount <= 0 {
return "[]"
}
if candidateIndex < 1 {
candidateIndex = 1
}
if candidateIndex > candidateCount {
candidateIndex = candidateCount
}
type cursorPoint struct {
X int `json:"x"`
Y int `json:"y"`
}
startX := 570 + mathrand.Intn(40)
startY := 875 + mathrand.Intn(30)
denom := candidateCount - 1
if denom < 1 {
denom = 1
}
baseTargetX := 734 + (937-734)*(candidateIndex-1)/denom
targetX := baseTargetX + mathrand.Intn(10) - 5
targetY := 655 + mathrand.Intn(14)
points := make([]cursorPoint, 0, 28)
for i := 0; i < 1+mathrand.Intn(3); i++ {
points = append(points, cursorPoint{
X: startX + mathrand.Intn(5) - 2,
Y: startY + mathrand.Intn(5) - 2,
})
}
transitSteps := 2 + mathrand.Intn(3)
arcOffX := mathrand.Intn(60) - 30
arcOffY := -(mathrand.Intn(30) + 10)
for i := 1; i <= transitSteps; i++ {
t := float64(i) / float64(transitSteps+1)
cx := float64(startX+targetX)/2 + float64(arcOffX)
cy := float64(startY+targetY)/2 + float64(arcOffY)
bx := (1-t)*(1-t)*float64(startX) + 2*t*(1-t)*cx + t*t*float64(targetX)
by := (1-t)*(1-t)*float64(startY) + 2*t*(1-t)*cy + t*t*float64(targetY)
jitter := int((1-t)*8) + 2
points = append(points, cursorPoint{
X: int(math.Round(bx)) + mathrand.Intn(jitter*2+1) - jitter,
Y: int(math.Round(by)) + mathrand.Intn(jitter*2+1) - jitter,
})
}
approachSteps := 4 + mathrand.Intn(4)
prev := points[len(points)-1]
for i := 1; i <= approachSteps; i++ {
t := float64(i) / float64(approachSteps)
ax := prev.X + int(math.Round(t*float64(targetX-prev.X))) + mathrand.Intn(5) - 2
ay := prev.Y + int(math.Round(t*float64(targetY-prev.Y))) + mathrand.Intn(5) - 2
points = append(points, cursorPoint{X: ax, Y: ay})
}
settleCount := 3 + mathrand.Intn(5)
for i := 0; i < settleCount; i++ {
points = append(points, cursorPoint{
X: targetX + mathrand.Intn(7) - 3,
Y: targetY + mathrand.Intn(7) - 3,
})
}
data, err := json.Marshal(points)
if err != nil {
return "[]"
}
return string(data)
}

19
client/main.go

@ -12,6 +12,7 @@ import (
"encoding/binary"
"encoding/hex"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
@ -68,6 +69,7 @@ var (
isDebug bool
manualCaptcha bool
autoCaptchaSliderPOC bool
captchaSolverVersion string
)
type captchaSolveMode int
@ -548,6 +550,18 @@ func solveVkCaptcha(ctx context.Context, captchaErr *VkCaptchaError, streamID in
profile = sp.Profile // Use saved headers/UA
}
if !useSliderPOC && !strings.EqualFold(captchaSolverVersion, "v1") {
successToken, v2Err := solveVkCaptchaV2(ctx, captchaErr, streamID, client, profile, savedProfile)
if v2Err == nil {
log.Printf("[STREAM %d] [Captcha] v2 solver succeeded", streamID)
return successToken, nil
}
if errors.Is(v2Err, errCaptchaV2RateLimit) {
return "", v2Err
}
log.Printf("[STREAM %d] [Captcha] v2 solver failed, falling back to legacy solver: %v", streamID, v2Err)
}
bootstrap, err := fetchCaptchaBootstrap(ctx, captchaErr.RedirectURI, client, profile)
if err != nil {
return "", fmt.Errorf("failed to fetch captcha bootstrap: %w", err)
@ -2042,6 +2056,7 @@ func main() {
vlessBond := flag.Bool("vless-bond", false, "bond one VLESS TCP connection across all active smux sessions")
debugFlag := flag.Bool("debug", false, "enable debug logging")
manualCaptchaFlag := flag.Bool("manual-captcha", false, "skip auto captcha solving, use manual mode immediately")
captchaSolverFlag := flag.String("captcha-solver", "v2", "auto captcha solver implementation: v1|v2")
flag.Parse()
if *peerAddr == "" {
log.Panicf("Need peer address!")
@ -2056,6 +2071,10 @@ func main() {
isDebug = *debugFlag
manualCaptcha = *manualCaptchaFlag
captchaSolverVersion = strings.ToLower(strings.TrimSpace(*captchaSolverFlag))
if captchaSolverVersion != "v1" && captchaSolverVersion != "v2" {
captchaSolverVersion = "v2"
}
autoCaptchaSliderPOC = !manualCaptcha
var link string

Loading…
Cancel
Save