Skip to content

Commit 929266e

Browse files
authoredFeb 6, 2026··
feat(tartarus): implement support (#23)
1 parent 00afbaf commit 929266e

File tree

4 files changed

+457
-118
lines changed

4 files changed

+457
-118
lines changed
 

‎go.mod‎

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
11
module github.com/lizthgrey/tor-fetcher
22

3-
go 1.21.0
3+
go 1.25.0
44

55
require (
6-
github.com/cretz/bine v0.2.0
7-
github.com/ipsn/go-libtor v1.0.380
8-
golang.org/x/crypto v0.25.0
6+
github.com/refraction-networking/utls v1.8.2
7+
golang.org/x/crypto v0.47.0
8+
golang.org/x/net v0.49.0
99
)
1010

1111
require (
12-
golang.org/x/net v0.21.0 // indirect
13-
golang.org/x/sys v0.22.0 // indirect
12+
github.com/andybalholm/brotli v1.0.6 // indirect
13+
github.com/klauspost/compress v1.17.4 // indirect
14+
golang.org/x/sys v0.40.0 // indirect
15+
golang.org/x/text v0.33.0 // indirect
1416
)

‎go.sum‎

Lines changed: 14 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,14 @@
1-
github.com/cretz/bine v0.1.0/go.mod h1:6PF6fWAvYtwjRGkAuDEJeWNOv3a2hUouSP/yRYXmvHw=
2-
github.com/cretz/bine v0.2.0 h1:8GiDRGlTgz+o8H9DSnsl+5MeBK4HsExxgl6WgzOCuZo=
3-
github.com/cretz/bine v0.2.0/go.mod h1:WU4o9QR9wWp8AVKtTM1XD5vUHkEqnf2vVSo6dBqbetI=
4-
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
5-
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
6-
github.com/ipsn/go-libtor v1.0.380 h1:hCmALDBe3bPpgwMunonMLArrG41MxzpE91Bk8KQYnYM=
7-
github.com/ipsn/go-libtor v1.0.380/go.mod h1:6rIeHU7irp8ZH8E/JqaEOKlD6s4vSSUh4ngHelhlSMw=
8-
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
9-
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
10-
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
11-
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
12-
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
13-
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
14-
golang.org/x/crypto v0.0.0-20190404164418-38d8ce5564a5/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE=
15-
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
16-
golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30=
17-
golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M=
18-
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
19-
golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
20-
golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4=
21-
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
22-
golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
23-
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
24-
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
25-
golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
26-
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
27-
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
28-
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
29-
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
30-
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
31-
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
32-
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
33-
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
1+
github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI=
2+
github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
3+
github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4=
4+
github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
5+
github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo=
6+
github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
7+
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
8+
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
9+
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
10+
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
11+
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
12+
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
13+
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
14+
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=

‎main.go‎

Lines changed: 277 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -4,41 +4,40 @@ import (
44
"bufio"
55
"compress/gzip"
66
"context"
7-
"crypto/tls"
7+
"crypto/sha256"
8+
"encoding/binary"
89
"flag"
910
"fmt"
1011
"io"
11-
"io/ioutil"
1212
"log"
13+
"net"
1314
"net/http"
1415
"net/http/cookiejar"
1516
"net/url"
1617
"os"
1718
"strconv"
1819
"strings"
19-
"time"
20+
"sync"
2021

22+
utls "github.com/refraction-networking/utls"
2123
"golang.org/x/crypto/argon2"
22-
23-
"github.com/cretz/bine/tor"
24-
"github.com/ipsn/go-libtor"
24+
"golang.org/x/net/http2"
25+
"golang.org/x/net/proxy"
2526
)
2627

2728
var parallelism = flag.Int("p", 1, "Parallelism")
2829
var length = flag.Int("l", 32, "Length")
2930
var target = flag.String("target", "", "The URL to retrieve (required)")
30-
var ua = flag.String("ua", "Mozilla/5.0 (Windows NT 10.0; rv:102.0) Gecko/20100101 Firefox/102.0", "Tor user agent by default")
31+
var ua = flag.String("ua", "Mozilla/5.0 (Windows NT 10.0; rv:140.0) Gecko/20100101 Firefox/140.0", "Tor user agent by default")
32+
var socksAddr = flag.String("proxy", "socks5://127.0.0.1:9050", "SOCKS5 proxy address for Tor")
3133

3234
func main() {
3335
flag.Parse()
3436
if *target == "" {
3537
flag.Usage()
3638
os.Exit(1)
3739
}
38-
http.DefaultTransport.(*http.Transport).TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
39-
4040
tc := NewTorClient()
41-
defer tc.Close()
4241
resp, err := tc.Fetch(*target, "")
4342
if err != nil {
4443
log.Fatal(err)
@@ -57,7 +56,7 @@ func main() {
5756
default:
5857
reader = resp.Body
5958
}
60-
body, err := ioutil.ReadAll(reader)
59+
body, err := io.ReadAll(reader)
6160
if err != nil {
6261
log.Fatal(err)
6362
}
@@ -75,6 +74,9 @@ type ArgonParams struct {
7574
}
7675

7776
func (p ArgonParams) Check(n int) bool {
77+
if p.difficulty == 0 {
78+
return true
79+
}
7880
password := fmt.Sprintf("%s%d", p.prefix, n)
7981
hash := argon2.IDKey([]byte(password), []byte(p.salt), p.iterations, p.memory, p.parallelism, p.keyLength)
8082
for i, v := range hash[:(p.difficulty+1)/2] {
@@ -91,105 +93,309 @@ func (p ArgonParams) Check(n int) bool {
9193
return false
9294
}
9395

94-
type TorClient struct {
95-
c http.Client
96-
torCtx *tor.Tor
96+
type TartarusParams struct {
97+
salt string
98+
difficulty uint
9799
}
98100

99-
func (tc *TorClient) Get(target, referer string) (*http.Response, error) {
100-
req, err := http.NewRequest("GET", target, nil)
101-
if err != nil {
102-
return nil, err
101+
func (p TartarusParams) Check(n int) bool {
102+
input := p.salt + strconv.Itoa(n)
103+
hash := sha256.Sum256([]byte(input))
104+
val := binary.BigEndian.Uint32(hash[:4])
105+
return val < (1 << (32 - p.difficulty))
106+
}
107+
108+
// extractAttr extracts the value of an HTML attribute from a string.
109+
// e.g. extractAttr(`<html data-foo="bar">`, "data-foo") returns "bar".
110+
func extractAttr(s, attr string) string {
111+
key := attr + `="`
112+
idx := strings.Index(s, key)
113+
if idx == -1 {
114+
return ""
103115
}
116+
start := idx + len(key)
117+
end := strings.Index(s[start:], `"`)
118+
if end == -1 {
119+
return ""
120+
}
121+
return s[start : start+end]
122+
}
123+
124+
type TorClient struct {
125+
c http.Client
126+
}
127+
128+
func setHeaders(req *http.Request, referer string) {
104129
if referer != "" {
105130
req.Header.Set("Referer", referer)
106131
}
107-
108132
req.Header.Set("User-Agent", *ua)
109133
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8")
110134
req.Header.Set("Accept-Encoding", "gzip, deflate, br")
111135
req.Header.Set("Accept-Language", "en-US,en;q=0.5")
112136
req.Header.Set("Upgrade-Insecure-Requests", "1")
137+
}
138+
139+
func (tc *TorClient) Get(target, referer string) (*http.Response, error) {
140+
req, err := http.NewRequest("GET", target, nil)
141+
if err != nil {
142+
return nil, err
143+
}
144+
setHeaders(req, referer)
113145
return tc.c.Do(req)
114146
}
115147

116-
func (tc *TorClient) PostForm(target string, data url.Values) (*http.Response, error) {
148+
func (tc *TorClient) PostForm(target, referer string, data url.Values) (*http.Response, error) {
117149
req, err := http.NewRequest("POST", target, strings.NewReader(data.Encode()))
118150
if err != nil {
119151
return nil, err
120152
}
121-
req.Header.Set("Referer", target)
122-
123-
req.Header.Set("User-Agent", *ua)
153+
setHeaders(req, referer)
124154
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
125-
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8")
126-
req.Header.Set("Accept-Encoding", "gzip, deflate, br")
127-
req.Header.Set("Accept-Language", "en-US,en;q=0.5")
128-
req.Header.Set("Upgrade-Insecure-Requests", "1")
129155
return tc.c.Do(req)
130156
}
131157

132-
func (tc *TorClient) Close() {
133-
tc.torCtx.Close()
158+
// utlsTransport is an http.RoundTripper that dials TLS with utls
159+
// (for browser-like fingerprints) and dispatches to HTTP/2 or HTTP/1.1
160+
// based on the ALPN-negotiated protocol.
161+
type utlsTransport struct {
162+
dialTLS func(ctx context.Context, network, addr string) (net.Conn, error)
163+
164+
mu sync.Mutex
165+
h2Conns map[string]*http2.ClientConn
134166
}
135167

136-
func NewTorClient() *TorClient {
137-
ctx := context.Background()
138-
torCtx, err := tor.Start(
139-
ctx,
140-
&tor.StartConf{ProcessCreator: libtor.Creator},
141-
)
142-
if err != nil {
143-
log.Fatalf("Failed to create Tor Context = %v\n", err)
168+
func (t *utlsTransport) RoundTrip(req *http.Request) (*http.Response, error) {
169+
addr := req.URL.Hostname()
170+
port := req.URL.Port()
171+
if port == "" {
172+
port = "443"
144173
}
145-
var dialer *tor.Dialer
146-
for retry := 0; retry < 3; retry++ {
147-
timeoutCtx, done := context.WithTimeout(ctx, 15*time.Second)
148-
dialer, err = torCtx.Dialer(timeoutCtx, nil)
149-
done()
174+
hostPort := net.JoinHostPort(addr, port)
175+
176+
// Try reusing a cached HTTP/2 connection.
177+
t.mu.Lock()
178+
cc := t.h2Conns[hostPort]
179+
t.mu.Unlock()
180+
if cc != nil {
181+
//log.Printf("transport: reusing h2 conn for %s %s", req.Method, req.URL)
182+
resp, err := cc.RoundTrip(req)
150183
if err == nil {
151-
break
184+
return resp, nil
185+
}
186+
//log.Printf("transport: cached h2 conn failed: %v, dialing new", err)
187+
t.mu.Lock()
188+
delete(t.h2Conns, hostPort)
189+
t.mu.Unlock()
190+
} else {
191+
//log.Printf("transport: no cached conn for %s %s, dialing new", req.Method, req.URL)
192+
}
193+
194+
conn, err := t.dialTLS(req.Context(), "tcp", hostPort)
195+
if err != nil {
196+
return nil, err
197+
}
198+
199+
// Check ALPN negotiated protocol.
200+
alpn := ""
201+
if uconn, ok := conn.(*utls.UConn); ok {
202+
alpn = uconn.ConnectionState().NegotiatedProtocol
203+
}
204+
205+
if alpn == "h2" {
206+
cc, err := (&http2.Transport{}).NewClientConn(conn)
207+
if err != nil {
208+
conn.Close()
209+
return nil, err
210+
}
211+
t.mu.Lock()
212+
if t.h2Conns == nil {
213+
t.h2Conns = make(map[string]*http2.ClientConn)
152214
}
215+
t.h2Conns[hostPort] = cc
216+
t.mu.Unlock()
217+
return cc.RoundTrip(req)
218+
}
219+
220+
// HTTP/1.1 fallback.
221+
if err := req.Write(conn); err != nil {
222+
conn.Close()
223+
return nil, err
224+
}
225+
resp, err := http.ReadResponse(bufio.NewReader(conn), req)
226+
if err != nil {
227+
conn.Close()
228+
return nil, err
229+
}
230+
return resp, nil
231+
}
232+
233+
func NewTorClient() *TorClient {
234+
proxyURL, err := url.Parse(*socksAddr)
235+
if err != nil {
236+
log.Fatalf("Failed to parse proxy URL %q: %v\n", *socksAddr, err)
153237
}
238+
socksDialer, err := proxy.FromURL(proxyURL, proxy.Direct)
154239
if err != nil {
155-
log.Fatalf("Failed to create dialer for Tor Context - %v\n", err)
240+
log.Fatalf("Failed to create SOCKS dialer: %v\n", err)
156241
}
242+
243+
dialTLS := func(ctx context.Context, network, addr string) (net.Conn, error) {
244+
host, _, err := net.SplitHostPort(addr)
245+
if err != nil {
246+
host = addr
247+
}
248+
// TCP dial through the SOCKS5 proxy.
249+
rawConn, err := socksDialer.(proxy.ContextDialer).DialContext(ctx, network, addr)
250+
if err != nil {
251+
return nil, err
252+
}
253+
// TLS handshake with Firefox fingerprint.
254+
cfg := &utls.Config{ServerName: host}
255+
uConn := utls.UClient(rawConn, cfg, utls.HelloFirefox_Auto)
256+
if err := uConn.HandshakeContext(ctx); err != nil {
257+
rawConn.Close()
258+
return nil, err
259+
}
260+
return uConn, nil
261+
}
262+
157263
jar, _ := cookiejar.New(nil)
158264
httpClient := http.Client{
159-
Transport: &http.Transport{
160-
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
161-
Proxy: http.ProxyFromEnvironment,
162-
DialContext: dialer.DialContext,
265+
Transport: &utlsTransport{dialTLS: dialTLS},
266+
Jar: jar,
267+
CheckRedirect: func(req *http.Request, via []*http.Request) error {
268+
// Don't follow redirects automatically; Fetch() handles them.
269+
return http.ErrUseLastResponse
163270
},
164-
Jar: jar,
165-
}
166-
return &TorClient{
167-
c: httpClient,
168-
torCtx: torCtx,
169271
}
272+
return &TorClient{c: httpClient}
170273
}
171274

172275
func (tc *TorClient) Fetch(target, referer string) (*http.Response, error) {
173-
resp, err := tc.Get(target, referer)
276+
currentURL := target
277+
currentReferer := referer
278+
279+
for range 10 { // max redirect/challenge hops
280+
resp, err := tc.Get(currentURL, currentReferer)
281+
if err != nil {
282+
return nil, err
283+
}
284+
285+
// Follow redirects manually (we disabled auto-follow).
286+
if loc := resp.Header.Get("Location"); loc != "" &&
287+
(resp.StatusCode >= 300 && resp.StatusCode < 400) {
288+
resp.Body.Close()
289+
resolved, err := resp.Request.URL.Parse(loc)
290+
if err != nil {
291+
return nil, fmt.Errorf("bad redirect Location %q: %w", loc, err)
292+
}
293+
//log.Printf("Following redirect: %s -> %s", currentURL, resolved)
294+
currentReferer = currentURL
295+
currentURL = resolved.String()
296+
continue
297+
}
298+
299+
// Not a challenge — return directly.
300+
if resp.StatusCode != http.StatusForbidden && resp.StatusCode != http.StatusNonAuthoritativeInfo {
301+
return resp, nil
302+
}
303+
304+
// Read the challenge body.
305+
bodyBytes, err := io.ReadAll(resp.Body)
306+
resp.Body.Close()
307+
if err != nil {
308+
return nil, err
309+
}
310+
body := string(bodyBytes)
311+
requestURL := resp.Request.URL
312+
313+
if strings.Contains(body, "data-ttrs-challenge") {
314+
challengeResp, err := tc.solveTartarus(requestURL, body)
315+
if err != nil {
316+
return nil, err
317+
}
318+
// solveTartarus returns the re-GET response; loop to
319+
// handle further redirects or challenges on the new domain.
320+
if loc := challengeResp.Header.Get("Location"); loc != "" &&
321+
(challengeResp.StatusCode >= 300 && challengeResp.StatusCode < 400) {
322+
challengeResp.Body.Close()
323+
resolved, err := requestURL.Parse(loc)
324+
if err != nil {
325+
return nil, fmt.Errorf("bad redirect Location %q: %w", loc, err)
326+
}
327+
//log.Printf("Following redirect after challenge: %s -> %s", requestURL, resolved)
328+
currentReferer = requestURL.String()
329+
currentURL = resolved.String()
330+
continue
331+
}
332+
return challengeResp, nil
333+
}
334+
return tc.solveBasedFlare(requestURL, body)
335+
}
336+
return nil, fmt.Errorf("too many redirects/challenges")
337+
}
338+
339+
func (tc *TorClient) solveTartarus(requestURL *url.URL, body string) (*http.Response, error) {
340+
salt := extractAttr(body, "data-ttrs-challenge")
341+
diffStr := extractAttr(body, "data-ttrs-difficulty")
342+
difficulty, err := strconv.Atoi(diffStr)
174343
if err != nil {
175-
return nil, err
344+
return nil, fmt.Errorf("parsing tartarus difficulty: %w", err)
176345
}
177346

178-
// Check whether we were allowed direct access.
179-
if resp.StatusCode != http.StatusForbidden {
180-
// If so (eg due to passing captcha earlier), then return
181-
// the http.Response for caller to do what it will.
182-
return resp, nil
347+
p := TartarusParams{salt: salt, difficulty: uint(difficulty)}
348+
349+
// Brute-force SHA256 PoW from nonce=0.
350+
var nonce int
351+
for n := 0; ; n++ {
352+
if p.Check(n) {
353+
nonce = n
354+
break
355+
}
183356
}
184357

185-
// Otherwise, do the captcha dance.
186-
defer resp.Body.Close()
358+
// POST the solution to /.ttrs/challenge as an XHR.
359+
challengeURL := fmt.Sprintf("%s://%s/.ttrs/challenge", requestURL.Scheme, requestURL.Host)
360+
values := url.Values{}
361+
values.Set("salt", salt)
362+
values.Set("nonce", strconv.Itoa(nonce))
363+
//log.Printf("Tartarus: salt=%s difficulty=%d nonce=%d", salt, difficulty, nonce)
364+
req, err := http.NewRequest("POST", challengeURL, strings.NewReader(values.Encode()))
365+
if err != nil {
366+
return nil, fmt.Errorf("building tartarus POST: %w", err)
367+
}
368+
req.Header.Set("Referer", requestURL.String())
369+
req.Header.Set("User-Agent", *ua)
370+
req.Header.Set("Accept", "application/json")
371+
req.Header.Set("Accept-Language", "en-US,en;q=0.5")
372+
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
373+
postResp, err := tc.c.Do(req)
374+
if err != nil {
375+
return nil, fmt.Errorf("posting tartarus solution: %w", err)
376+
}
377+
postBody, _ := io.ReadAll(postResp.Body)
378+
postResp.Body.Close()
379+
//log.Printf("Tartarus POST: status=%d body=%s", postResp.StatusCode, postBody)
380+
//log.Printf("Tartarus POST: Set-Cookie=%v", postResp.Header["Set-Cookie"])
381+
if postResp.StatusCode != http.StatusOK {
382+
return nil, fmt.Errorf("tartarus challenge POST returned %d", postResp.StatusCode)
383+
}
187384

385+
// Check what cookies the jar has for this URL.
386+
if tc.c.Jar != nil {
387+
cookies := tc.c.Jar.Cookies(requestURL)
388+
//log.Printf("Tartarus: cookies for %s: %v", requestURL, cookies)
389+
}
390+
391+
// Re-GET the original target (cookie jar preserves ttrs_clearance).
392+
return tc.Get(requestURL.String(), requestURL.String())
393+
}
394+
395+
func (tc *TorClient) solveBasedFlare(requestURL *url.URL, body string) (*http.Response, error) {
188396
var p ArgonParams
189397
var pow string
190-
scanner := bufio.NewScanner(resp.Body)
191-
for scanner.Scan() {
192-
l := scanner.Text()
398+
for _, l := range strings.Split(body, "\n") {
193399
if !strings.HasPrefix(l, "\t<body data") {
194400
continue
195401
}
@@ -202,43 +408,36 @@ func (tc *TorClient) Fetch(target, referer string) (*http.Response, error) {
202408
value := split[1][1 : len(split[1])-1]
203409
switch key {
204410
case "data-pow":
205-
// data-pow="234a8b1a036dd6aee9c2745b31ffb1b8#2b8e80f38873205a65c14f9055b6ad0567b7690d8cd0fc73ac55882f32457045#fa725558ce6c1a9343265dd2abaddde7acfdd8af56c6e7269b3fddc4b6c29884"
206411
pow = value
207412
params := strings.Split(pow, "#")
208413
p.salt = params[0]
209414
p.prefix = params[1]
210415
case "data-time":
211-
// data-time="1"
212416
iters, err := strconv.Atoi(value)
213417
if err != nil {
214-
log.Fatal(err)
418+
return nil, fmt.Errorf("parsing basedflare time: %w", err)
215419
}
216420
p.iterations = uint32(iters)
217421
case "data-diff":
218-
// data-diff="24"
219422
bits, err := strconv.Atoi(value)
220423
if err != nil {
221-
log.Fatal(err)
424+
return nil, fmt.Errorf("parsing basedflare diff: %w", err)
222425
}
223426
p.difficulty = bits / 8
224427
case "data-kb":
225-
// data-kb="512"
226428
mem, err := strconv.Atoi(value)
227429
if err != nil {
228-
log.Fatal(err)
430+
return nil, fmt.Errorf("parsing basedflare kb: %w", err)
229431
}
230432
p.memory = uint32(mem)
231433
default:
232-
log.Fatalf("Unexpected key: %s", key)
434+
return nil, fmt.Errorf("unexpected basedflare key: %s", key)
233435
}
234436
}
235437
p.parallelism = uint8(*parallelism)
236438
p.keyLength = uint32(*length)
237439
break
238440
}
239-
if err := scanner.Err(); err != nil {
240-
return nil, err
241-
}
242441

243442
// Run the POW, single-threaded in case another circuit is running.
244443
var result int
@@ -249,10 +448,9 @@ func (tc *TorClient) Fetch(target, referer string) (*http.Response, error) {
249448
}
250449
}
251450

252-
// Post the result back to the checker. This will yield a redirect to
253-
// our true target.
451+
// Post the result back to the checker.
254452
values := url.Values{}
255453
values.Set("pow_response", fmt.Sprintf("%s#%d", pow, result))
256454
values.Set("submit", "submit")
257-
return tc.PostForm(resp.Request.URL.String(), values)
455+
return tc.PostForm(requestURL.String(), requestURL.String(), values)
258456
}

‎main_test.go‎

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
package main
2+
3+
import (
4+
"fmt"
5+
"io"
6+
"net/http"
7+
"net/http/cookiejar"
8+
"net/http/httptest"
9+
"net/url"
10+
"strconv"
11+
"testing"
12+
)
13+
14+
func TestTartarusCheck(t *testing.T) {
15+
tests := []struct {
16+
name string
17+
salt string
18+
difficulty uint
19+
nonce int
20+
want bool
21+
}{
22+
{"difficulty 1, nonce 0 fails", "testsalt", 1, 0, false},
23+
{"difficulty 1, nonce 1 passes", "testsalt", 1, 1, true},
24+
{"difficulty 8, nonce 0 fails", "testsalt", 8, 0, false},
25+
{"difficulty 8, nonce 13 passes", "testsalt", 8, 13, true},
26+
{"real urlscan vector, fails nonce 0", "a92a106fa4e8c2398ebcabecefebf28c_69853ed8", 16, 0, false},
27+
{"real urlscan vector, passes known nonce", "a92a106fa4e8c2398ebcabecefebf28c_69853ed8", 16, 3026359506902472, true},
28+
}
29+
for _, tt := range tests {
30+
t.Run(tt.name, func(t *testing.T) {
31+
p := TartarusParams{salt: tt.salt, difficulty: tt.difficulty}
32+
if got := p.Check(tt.nonce); got != tt.want {
33+
t.Errorf("TartarusParams{%q, %d}.Check(%d) = %v, want %v",
34+
tt.salt, tt.difficulty, tt.nonce, got, tt.want)
35+
}
36+
})
37+
}
38+
}
39+
40+
func TestExtractAttr(t *testing.T) {
41+
tests := []struct {
42+
name string
43+
html string
44+
attr string
45+
want string
46+
}{
47+
{"finds attribute", `<html data-ttrs-challenge="abc123" data-ttrs-difficulty="16">`, "data-ttrs-challenge", "abc123"},
48+
{"finds second attribute", `<html data-ttrs-challenge="abc123" data-ttrs-difficulty="16">`, "data-ttrs-difficulty", "16"},
49+
{"missing attribute", `<html data-foo="bar">`, "data-ttrs-challenge", ""},
50+
{"empty value", `<html data-ttrs-challenge="">`, "data-ttrs-challenge", ""},
51+
}
52+
for _, tt := range tests {
53+
t.Run(tt.name, func(t *testing.T) {
54+
if got := extractAttr(tt.html, tt.attr); got != tt.want {
55+
t.Errorf("extractAttr(%q, %q) = %q, want %q",
56+
tt.html, tt.attr, got, tt.want)
57+
}
58+
})
59+
}
60+
}
61+
62+
func TestSolveTartarusFlow(t *testing.T) {
63+
// Reproduce the real urlscan flow from
64+
// https://urlscan.io/api/v1/result/019c307d-9f9d-72ac-a600-a6319d5708d7/
65+
const (
66+
wantSalt = "a92a106fa4e8c2398ebcabecefebf28c_69853ed8"
67+
wantDiff = "16"
68+
)
69+
70+
challengeHTML := fmt.Sprintf(
71+
`<html data-ttrs-challenge="%s" data-ttrs-difficulty="%s"></html>`,
72+
wantSalt, wantDiff)
73+
74+
var gotPost url.Values
75+
var gotAccept, gotReferer, gotContentType string
76+
77+
ts := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
78+
switch {
79+
case r.Method == "GET" && r.URL.Path == "/":
80+
// First GET returns 203 with challenge page.
81+
w.WriteHeader(http.StatusNonAuthoritativeInfo)
82+
fmt.Fprint(w, challengeHTML)
83+
case r.Method == "POST" && r.URL.Path == "/.ttrs/challenge":
84+
// Capture the POST for assertions.
85+
body, _ := io.ReadAll(r.Body)
86+
gotPost, _ = url.ParseQuery(string(body))
87+
gotAccept = r.Header.Get("Accept")
88+
gotReferer = r.Header.Get("Referer")
89+
gotContentType = r.Header.Get("Content-Type")
90+
// Set a cookie like the real server does.
91+
http.SetCookie(w, &http.Cookie{Name: "ttrs_clearance", Value: "test"})
92+
w.Header().Set("Content-Type", "application/json")
93+
fmt.Fprint(w, `{"success":true}`)
94+
case r.Method == "GET" && r.URL.Path == "/" && r.Header.Get("Cookie") != "":
95+
// Re-GET after challenge solved.
96+
w.WriteHeader(http.StatusOK)
97+
fmt.Fprint(w, "<html>real page</html>")
98+
default:
99+
w.WriteHeader(http.StatusBadRequest)
100+
}
101+
}))
102+
defer ts.Close()
103+
104+
// Use the test server's client (trusts its TLS cert) with a cookie jar.
105+
jar, _ := cookiejar.New(nil)
106+
testClient := ts.Client()
107+
testClient.Jar = jar
108+
tc := &TorClient{c: *testClient}
109+
resp, err := tc.Fetch(ts.URL+"/", "")
110+
if err != nil {
111+
t.Fatalf("Fetch: %v", err)
112+
}
113+
resp.Body.Close()
114+
115+
// Verify POST fields match the real urlscan capture.
116+
if got := gotPost.Get("salt"); got != wantSalt {
117+
t.Errorf("POST salt = %q, want %q", got, wantSalt)
118+
}
119+
if gotNonce := gotPost.Get("nonce"); gotNonce == "" {
120+
t.Error("POST nonce is empty")
121+
} else {
122+
n, err := strconv.Atoi(gotNonce)
123+
if err != nil {
124+
t.Errorf("POST nonce %q is not an integer: %v", gotNonce, err)
125+
} else {
126+
p := TartarusParams{salt: wantSalt, difficulty: 16}
127+
if !p.Check(n) {
128+
t.Errorf("POST nonce %d does not satisfy difficulty 16", n)
129+
}
130+
}
131+
}
132+
if gotAccept != "application/json" {
133+
t.Errorf("POST Accept = %q, want %q", gotAccept, "application/json")
134+
}
135+
if gotContentType != "application/x-www-form-urlencoded" {
136+
t.Errorf("POST Content-Type = %q, want %q", gotContentType, "application/x-www-form-urlencoded")
137+
}
138+
if gotReferer == "" {
139+
t.Error("POST Referer is empty, want original page URL")
140+
}
141+
}
142+
143+
func TestArgonCheck(t *testing.T) {
144+
// Use minimal parameters so the test runs quickly.
145+
p := ArgonParams{
146+
memory: 64,
147+
iterations: 1,
148+
parallelism: 1,
149+
keyLength: 32,
150+
difficulty: 0,
151+
prefix: "test",
152+
salt: "salt",
153+
}
154+
// difficulty=0 means 0 leading hex nibbles required, so any hash passes.
155+
if !p.Check(0) {
156+
t.Error("ArgonParams with difficulty=0 should accept any nonce")
157+
}
158+
}

0 commit comments

Comments
 (0)
Please sign in to comment.