commit 244667457ae2065ceae51fc86b20fdb7a423f064 Author: y a t s <140337963+y-a-t-s@users.noreply.github.com> Date: Sun Jan 25 14:41:59 2026 -0500 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5932476 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +.* +!.gitignore + +*.json +*.log + +build/ +data-dir-*/ +tmp/ + +GPATH +GRTAGS +GTAGS diff --git a/README.md b/README.md new file mode 100644 index 0000000..e252871 --- /dev/null +++ b/README.md @@ -0,0 +1,14 @@ +# Cerberus + +A fast Tartarus solver lib. + +For technical details, you can try asking Josh (not me) or reverse engineer the *wonderful* solver solutions offered on Tartarus' no-js page. +Good luck. + +
+ +Donations are always appreciated but never expected nor required: + +XMR: `8BjCARiV2uB2gZTbbiMUetfRxcAYZgVM5fXxjEbpmb2nAu8ND1grazZ1EhMGdRqVerAtvEJeiy7SzA3SLXpg2CtRDtCAFfn` + +[Other crypto (anonpay)](https://trocador.app/anonpay/?ticker_to=xmr&network_to=Mainnet&address=8BjCARiV2uB2gZTbbiMUetfRxcAYZgVM5fXxjEbpmb2nAu8ND1grazZ1EhMGdRqVerAtvEJeiy7SzA3SLXpg2CtRDtCAFfn&donation=True&description=SockChat+Donation&bgcolor=) ([Tor version](http://trocadorfyhlu27aefre5u7zri66gudtzdyelymftvr4yjwcxhfaqsid.onion/anonpay/?ticker_to=xmr&network_to=Mainnet&address=8BjCARiV2uB2gZTbbiMUetfRxcAYZgVM5fXxjEbpmb2nAu8ND1grazZ1EhMGdRqVerAtvEJeiy7SzA3SLXpg2CtRDtCAFfn&donation=True&description=SockChat+Donation&bgcolor=)) diff --git a/UNLICENSE b/UNLICENSE new file mode 100644 index 0000000..68a49da --- /dev/null +++ b/UNLICENSE @@ -0,0 +1,24 @@ +This is free and unencumbered software released into the public domain. + +Anyone is free to copy, modify, publish, use, compile, sell, or +distribute this software, either in source code form or as a compiled +binary, for any purpose, commercial or non-commercial, and by any +means. + +In jurisdictions that recognize copyright laws, the author or authors +of this software dedicate any and all copyright interest in the +software to the public domain. We make this dedication for the benefit +of the public at large and to the detriment of our heirs and +successors. We intend this dedication to be an overt act of +relinquishment in perpetuity of all present and future rights to this +software under copyright law. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR +OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, +ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. + +For more information, please refer to diff --git a/cerberus.go b/cerberus.go new file mode 100644 index 0000000..988fa04 --- /dev/null +++ b/cerberus.go @@ -0,0 +1,141 @@ +package cerberus + +import ( + "context" + "fmt" + "math/rand" + "net/url" + "runtime" + + "github.com/minio/sha256-simd" +) + +type Challenge struct { + Salt string // Challenge salt from server. + Diff uint32 // Difficulty level. + Steps uint32 // Time limit for answer in minutes. + host *url.URL +} + +type Solution struct { + Salt string + Nonce uint32 + Redirect string + + Hash []byte + host *url.URL +} + +// Brute force nonces until a valid solution is found. +func genHashes(ctx context.Context, c Challenge) <-chan Solution { + var ( + out = make(chan Solution, 1) + sha = sha256.New() + nonce = rand.Uint32() + redirect = "/" // TODO: figure out non-homepage redirects. + ) + + go func() { + defer close(out) + + for { + select { + case <-ctx.Done(): + return + default: + } + + sha.Write(fmt.Append(nil, c.Salt, nonce)) + + sol := Solution{ + Salt: c.Salt, + Nonce: nonce, + Redirect: redirect, + Hash: sha.Sum(nil), + } + // Ensure we don't hang if out channel is full on ctx close. + select { + case <-ctx.Done(): + return + case out <- sol: + } + + // Reset hasher input for next iteration. + sha.Reset() + nonce++ + } + }() + + return out +} + +// Given difficulty is measured in number of leading 0 bits. +func checkZeros(diff uint32, hash []byte) bool { + var ( + rem = diff % 8 // Remainder after dividing diff (given in bits) to bytes. + nbytes = (diff - rem) / 8 // Amount of 0x0 bytes we can divide difficulty bits into. + + mask uint8 // Mask to check remaining bits. + ) + + lh := uint32(len(hash)) + // Check bounds for the loops found below. + if lh < nbytes || (rem > 0 && lh < nbytes+1) { + return false + } + + // First, we count the number of leading 0x0 bytes. + for i := range nbytes { + if b := hash[i]; b != 0x0 { + return false + } + } + // If we don't have any more bits to check, return. + if rem == 0 { + return true + } + + // Create bitmask by setting a bit to 1 for each remaining bit to check. + // The mask is built from right-to-left and then shifted to the LHS of the octet. + for range rem { + mask <<= 1 + mask += 1 + } + // Shift 1s we just added to the LHS of the octet. + mask <<= 8 - rem + + return hash[nbytes]&mask == 0x0 +} + +// Solve Challenge c. Returns Solution that can be submitted. +func Solve(ctx context.Context, c Challenge) (Solution, error) { + sol := make(chan Solution, 1) + + go func() { + // A reasonable hardware-based job limiter. + // Use 1 worker per thread at most. + threads := runtime.NumCPU() + for i := 0; i < threads; i++ { + go func() { + hf := genHashes(ctx, c) + // Loop until answer has been found. + // Should break when hash worker terminates. + for h := range hf { + if checkZeros(c.Diff, h.Hash) { + h.Salt = c.Salt + // Set host url to submit this to. + h.host = c.host + sol <- h + } + } + }() + } + }() + + select { + case <-ctx.Done(): + return Solution{}, ctx.Err() + case s := <-sol: + return s, nil + } +} diff --git a/cerberus_test.go b/cerberus_test.go new file mode 100644 index 0000000..92c1de8 --- /dev/null +++ b/cerberus_test.go @@ -0,0 +1,100 @@ +package cerberus + +import ( + "context" + "errors" + "fmt" + "log" + "net" + "net/http" + "strings" + "testing" + + "golang.org/x/net/proxy" +) + +const _TEST_HOST = "kiwifarms.jp" +const _TEST_ONION = "kiwifarmsaaf4t2h7gc3dfc5ojhmqruw2nit3uejrpiagrxeuxiyxcyd.onion" + +type errBadZeroCheck struct { + Diff uint32 + Hash []byte +} + +func (e *errBadZeroCheck) Error() string { + return fmt.Sprintf("Zero check failed. Diff: %d, Hash: %+v\n", e.Diff, e.Hash) +} + +func solveTest(ctx context.Context, hc http.Client, host string) error { + connType := "clearnet" + if strings.HasSuffix(host, ".onion") { + connType = "tor" + } + + log.Printf("Fetching new %s challenge...", connType) + c, err := NewChallenge(hc, host) + if err != nil { + return err + } + log.Printf("Challenge: %s, Difficulty: %d, Steps: %d\n", c.Salt, c.Diff, c.Steps) + + s, err := Solve(ctx, c) + if err != nil { + return err + } + log.Printf("Solution hash: %x, nonce: %d\n", s.Hash, s.Nonce) + + a, err := Submit(hc, s) + if err != nil { + return err + } + log.Printf("Response: %s\n\n", a) + + return nil +} + +func newProxyTransport() *http.Transport { + pcd := proxy.FromEnvironment().(proxy.ContextDialer) + + tr := http.DefaultTransport.(*http.Transport).Clone() + tr.DialContext = pcd.DialContext + + return tr +} + +func TestSubmit(t *testing.T) { + ctx := t.Context() + + hc := http.Client{} + + err := solveTest(ctx, hc, _TEST_HOST) + if err != nil { + t.Error(err) + } + + hc.Transport = newProxyTransport() + + var dnsErr *net.DNSError + err = solveTest(ctx, hc, _TEST_ONION) + if err != nil { + if errors.As(err, &dnsErr) { + log.Println("Unable to resolve .onion domain. Make sure ALL_PROXY is set and tor is running.") + log.Println("Skipping...") + } else { + t.Error(err) + } + } +} + +func TestCheckZeros(t *testing.T) { + d, h := uint32(17), []byte{0, 0, 64, 128, 42} + if !checkZeros(d, h) { + t.Error(errBadZeroCheck{d, h}) + } + + // This should fail (i.e. be false). + d, h = uint32(3), []byte{33, 130, 222, 88} + if checkZeros(d, h) { + t.Error(errBadZeroCheck{d, h}) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..3fe0925 --- /dev/null +++ b/go.mod @@ -0,0 +1,13 @@ +module github.com/y-a-t-s/cerberus + +go 1.25.5 + +require ( + github.com/minio/sha256-simd v1.0.1 + golang.org/x/net v0.49.0 +) + +require ( + github.com/klauspost/cpuid/v2 v2.2.3 // indirect + golang.org/x/sys v0.40.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..705bb85 --- /dev/null +++ b/go.sum @@ -0,0 +1,9 @@ +github.com/klauspost/cpuid/v2 v2.2.3 h1:sxCkb+qR91z4vsqw4vGGZlDgPz3G7gjaLyK3V8y70BU= +github.com/klauspost/cpuid/v2 v2.2.3/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY= +github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM= +github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= diff --git a/http.go b/http.go new file mode 100644 index 0000000..b779bbc --- /dev/null +++ b/http.go @@ -0,0 +1,129 @@ +package cerberus + +import ( + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + + "golang.org/x/net/html" + "golang.org/x/net/html/atom" +) + +var ( + ErrNoRedirect = errors.New("No redirect to challenge page.") + ErrParseFailed = errors.New("Failed to parse challenge from HTML data tags.") +) + +func NewChallenge(hc http.Client, host string) (Challenge, error) { + u, err := parseHost(host) + if err != nil { + return Challenge{}, err + } + + // Update host url in case we get redirected across domains. + hc.CheckRedirect = func(req *http.Request, via []*http.Request) error { + rh := req.URL.Host + if rh != u.Host && strings.HasPrefix(rh, "kiwifarms") { + u.Host = rh + } + + return nil + } + + resp, err := hc.Get(u.String()) + if err != nil { + return Challenge{}, err + } + defer resp.Body.Close() + + // Check for 203 status + if resp.StatusCode != 203 { + return Challenge{}, ErrNoRedirect + } + + // Kept separate from the return because of the defer. + c, err := parseTags(resp.Body) + if err != nil { + return Challenge{}, err + } + c.host = u + + return c, nil +} + +func Submit(hc http.Client, s Solution) (string, error) { + resp, err := postSolution(hc, s) + if err != nil { + return "", err + } + defer resp.Body.Close() + + return resp.Header.Get("Set-Cookie"), nil +} + +func postSolution(hc http.Client, s Solution) (*http.Response, error) { + // Ensure the POST url parses properly before passing the string. + u, err := url.Parse(fmt.Sprintf("%s://%s/.ttrs/challenge", s.host.Scheme, s.host.Hostname())) + if err != nil { + return nil, err + } + + return hc.PostForm(u.String(), url.Values{ + "salt": []string{s.Salt}, + "redirect": []string{s.Redirect}, + "nonce": []string{fmt.Sprint(s.Nonce)}, + }) +} + +func parseHost(addr string) (*url.URL, error) { + // Guess https as protocol if one wasn't provided and hope it parses. + if !strings.Contains(addr, "://") { + addr = "https://" + addr + } + + u, err := url.Parse(addr) + if err != nil { + return nil, err + } + + return u, nil +} + +func parseTags(r io.Reader) (Challenge, error) { + c := Challenge{} + + z := html.NewTokenizer(r) + for i := z.Next(); i != html.ErrorToken; i = z.Next() { + tk := z.Token() + if tk.DataAtom == atom.Html { + for _, a := range tk.Attr { + switch a.Key { + case "data-ttrs-challenge": + c.Salt = a.Val + case "data-ttrs-difficulty": + diff, err := strconv.Atoi(a.Val) + if err != nil { + return c, ErrParseFailed + } + c.Diff = uint32(diff) + case "data-ttrs-steps": + steps, err := strconv.Atoi(a.Val) + if err != nil { + return c, ErrParseFailed + } + c.Steps = uint32(steps) + } + } + } + } + + if c.Salt == "" { + return c, ErrParseFailed + } + + return c, nil +}