-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathworker_data.go
More file actions
250 lines (225 loc) · 8.13 KB
/
Copy pathworker_data.go
File metadata and controls
250 lines (225 loc) · 8.13 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
package main
import (
"bytes"
"context"
crand "crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"mime/multipart"
"net/http"
"os"
"regexp"
"strconv"
"strings"
)
var (
// gppChRegex captures the `_gpp_ch` challenge object out of the profile
// page's Next.js flight payload. Interior quotes are backslash-escaped there
// (the object lives inside a JS string literal), so the captured group is
// unescaped before it parses as JSON. The object is flat, so a brace-free
// body ([^{}]*) matches its full extent.
gppChRegex = regexp.MustCompile(`\\"_gpp_ch\\":(\{[^{}]*\})`)
// flightUnescaper turns the escaped flight-payload substring back into JSON.
flightUnescaper = strings.NewReplacer(`\"`, `"`, `\\`, `\`, `\/`, `/`)
// Challenge page regexes (double-quoted JS object fields in _gs_sets), used
// by the legacy guns_clearance interstitial.
challengeNonceRegex = regexp.MustCompile(`[{,]_n:"([^"]+)"`)
challengeO09Regex = regexp.MustCompile(`[{,]o09:"([^"]+)"`)
challenge2xaRegex = regexp.MustCompile(`[{,]_2xa:"([^"]+)"`)
challengeOrgTsRegex = regexp.MustCompile(`[{,]_org_ts:"([^"]+)"`)
challengeDRegex = regexp.MustCompile(`,d:"([^"]+)"`)
challengeSRegex = regexp.MustCompile(`[{,]__s:"([^"]+)"`)
)
// proxySessionPlaceholder, when present in the proxy URL, is replaced with a
// fresh random token each time a session is created. Sticky-session proxy
// providers key the session (and thus the exit IP) off the credentials — e.g.
// IPRoyal's `password_session-<id>_lifetime-30s` — so substituting a new token
// hands out a new IP, which is what botting distinct views requires.
const proxySessionPlaceholder = "{session}"
// resolveProxySession replaces every proxySessionPlaceholder in rawURL with one
// freshly generated session token, leaving URLs without the placeholder
// unchanged.
func resolveProxySession(rawURL string) string {
if !strings.Contains(rawURL, proxySessionPlaceholder) {
return rawURL
}
return strings.ReplaceAll(rawURL, proxySessionPlaceholder, randomSessionID())
}
// randomSessionID returns a 16-hex-character token for use as a proxy session
// id.
func randomSessionID() string {
b := make([]byte, 8)
if _, err := crand.Read(b); err != nil {
panic(fmt.Sprintf("generate proxy session id: %v", err))
}
return hex.EncodeToString(b)
}
// WorkerData is the guns.lol proof-of-work challenge scraped from a profile
// page's Next.js flight payload (the `_gpp_ch` object). The obfuscated single-
// letter page keys are expanded here; each field comment names its on-page key.
type WorkerData struct {
Version int // v — payload schema version
ID string // e — challenge id (first path segment of WorkerURL)
WorkerURL string // u — same-origin path of the PoW worker module
Timestamp int64 // t — challenge issue time (unix seconds)
Nonce string // n — per-challenge nonce
Seal string // s — opaque server seal, replayed in the solution
Challenge string // c — 64-char hex challenge input to the solver
Difficulty int // d — required proof difficulty
CData string // cd — Turnstile data-cdata
Action string // a — Turnstile data-action
}
// gppChallengeData mirrors the on-page `_gpp_ch` JSON with its raw short keys.
type gppChallengeData struct {
V int `json:"v"`
E string `json:"e"`
U string `json:"u"`
T int64 `json:"t"`
N string `json:"n"`
S string `json:"s"`
C string `json:"c"`
D int `json:"d"`
Cd string `json:"cd"`
A string `json:"a"`
}
// parseGppChallenge extracts and decodes the `_gpp_ch` challenge object from a
// profile page body.
func parseGppChallenge(body string) (*WorkerData, error) {
m := gppChRegex.FindStringSubmatch(body)
if m == nil {
return nil, errors.New("failed to locate _gpp_ch challenge in page")
}
var ch gppChallengeData
if err := json.Unmarshal([]byte(flightUnescaper.Replace(m[1])), &ch); err != nil {
return nil, fmt.Errorf("decode _gpp_ch: %w", err)
}
return &WorkerData{
Version: ch.V,
ID: ch.E,
WorkerURL: ch.U,
Timestamp: ch.T,
Nonce: ch.N,
Seal: ch.S,
Challenge: ch.C,
Difficulty: ch.D,
CData: ch.Cd,
Action: ch.A,
}, nil
}
// FetchWorkerData fetches the profile page and extracts its `_gpp_ch` view-PoW
// challenge, transparently handling the guns_clearance 401 interstitial and the
// 307 clearance-cookie redirect.
func (s *session) FetchWorkerData(ctx context.Context, username string) (*WorkerData, error) {
req, err := http.NewRequestWithContext(ctx, "GET", "https://guns.lol/"+username, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", userAgent)
req.AddCookie(&http.Cookie{Name: "GUNS_LOCALE", Value: "en"})
req.AddCookie(&http.Cookie{Name: "GUNS_PATH_LOCALE", Value: "en"})
s.addClearanceCookies(req)
resp, err := s.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
s.captureCfClearance(resp)
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
body := string(bodyBytes)
if resp.StatusCode == http.StatusUnauthorized {
if s.gunsClearance != "" {
s.gunsClearance = ""
if s.persist {
os.Remove(clearanceFile())
}
}
s.warnf("Got 401 — solving guns_clearance interstitial")
if err = s.solveChallenge(ctx, body); err != nil {
return nil, fmt.Errorf("challenge: %w", err)
}
return s.FetchWorkerData(ctx, username)
}
if resp.StatusCode == http.StatusTemporaryRedirect {
for _, cookie := range resp.Cookies() {
if cookie.Name == "guns_clearance" {
s.gunsClearance = cookie.Value
s.saveGunsClearance()
return s.FetchWorkerData(ctx, username)
}
}
if s.gunsClearance != "" {
location := resp.Header.Get("Location")
return s.FetchWorkerData(ctx, location[1:]) // remove leading slash
}
return nil, errors.New("307 redirect without clearance cookie")
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("status code: %d %s", resp.StatusCode, resp.Status)
}
return parseGppChallenge(body)
}
// solveChallenge handles the guns_clearance 401 interstitial (solve a PoW →
// POST /_challenge/verify → guns_clearance cookie). This is a separate,
// stable subsystem from the rotating view PoW: it uses the `_gs_sets` challenge
// format and the embedded clearance binary.
func (s *session) solveChallenge(ctx context.Context, body string) error {
nonce := challengeNonceRegex.FindStringSubmatch(body)
o09 := challengeO09Regex.FindStringSubmatch(body)
twoXa := challenge2xaRegex.FindStringSubmatch(body)
orgTs := challengeOrgTsRegex.FindStringSubmatch(body)
d := challengeDRegex.FindStringSubmatch(body)
sMatch := challengeSRegex.FindStringSubmatch(body)
if nonce == nil || o09 == nil || twoXa == nil || orgTs == nil || d == nil || sMatch == nil {
return errors.New("failed to parse challenge data from page")
}
difficulty, err := strconv.Atoi(d[1])
if err != nil {
return fmt.Errorf("invalid difficulty %q: %w", d[1], err)
}
s.infof("clearance params: difficulty %d nonce %s", difficulty, nonce[1])
res, err := SolveWithWasm(ctx, clearancePowModule(), o09[1], difficulty, orgTs[1], nonce[1], twoXa[1])
if err != nil {
return fmt.Errorf("wasm solve: %w", err)
}
s.infof("clearance solved: _oo %s", truncateMiddle(res.Oo, 24))
var buf bytes.Buffer
w := multipart.NewWriter(&buf)
w.WriteField("_o", res.Oo)
w.WriteField("_s", sMatch[1])
w.WriteField("_u", nonce[1])
w.WriteField("_i", twoXa[1])
w.WriteField("_x", o09[1])
w.WriteField("_t", orgTs[1])
w.Close()
req, err := http.NewRequestWithContext(ctx, "POST", "https://guns.lol/_challenge/verify", &buf)
if err != nil {
return err
}
req.Header.Set("Content-Type", w.FormDataContentType())
req.Header.Set("User-Agent", userAgent)
s.addClearanceCookies(req)
vresp, err := s.client.Do(req)
if err != nil {
return err
}
defer vresp.Body.Close()
s.captureCfClearance(vresp)
io.ReadAll(vresp.Body) // drain
if vresp.StatusCode != http.StatusOK {
return fmt.Errorf("verify returned %d", vresp.StatusCode)
}
for _, cookie := range vresp.Cookies() {
if cookie.Name == "guns_clearance" {
s.gunsClearance = cookie.Value
s.saveGunsClearance()
return nil
}
}
return errors.New("no clearance cookie in verify response")
}