Sneaker Dev Logo
Back to Blog

CoinMarketCap captcha: Part 2, creating a solver

August 12, 2022

obfio

In this post, we will be continuing what we did in part 1, focusing on making a captcha solver for the CoinMarketCap mobile captcha. This will be split into a few parts:
CoinMarketCap captcha: Part 2, creating a solverThis article may have been republished from another source and might not have been originally written for this site.

⚠️ Some information, tools, or techniques discussed may have changed or evolved since the publishing of this article.

Originally published at https://antibot.blog/posts/1741831465176

Intro

In this post, we will be continuing what we did in part 1, focusing on making a captcha solver for the CoinMarketCap mobile captcha. This will be split into a few parts:

Setting up the enviornment

First it's important to say which HTTP library we will be using, I prefer to use github.com/bogdanfinn/fhttp and github.com/bogdanfinn/tls-client to get valid TLS signatures.

Next is the general file structure, I prefer to make a folder named coinmarketcap, making 3 files called client.go, http.go, and structs.go. This is the file structure I use for every project, I find it easier to keep a consistent file structure between projects.

First, this is client.go:

1package coinmarketcap
2
3import (
4	tlsclient "github.com/bogdanfinn/tls-client"
5	"github.com/bogdanfinn/tls-client/profiles"
6)
7
8type Client struct {
9	HTTPClient tlsclient.HttpClient
10	Proxy      string
11}
12
13func MakeClient(proxy string) *Client {
14	opts := []tlsclient.HttpClientOption{
15		tlsclient.WithTimeoutSeconds(10),
16		tlsclient.WithProxyUrl(proxy),
17		tlsclient.WithInsecureSkipVerify(),
18		tlsclient.WithClientProfile(profiles.Okhttp4Android12),
19	}
20	h, err := tlsclient.NewHttpClient(nil, opts...)
21	if err != nil {
22		panic(err)
23	}
24	c := &Client{
25		HTTPClient: h,
26		Proxy:      proxy,
27	}
28	return c
29}

The other 2 files will stay empty for now, we will also not be using a proxy in this project but my setup ofc is default supporting proxies. Now we're all setup, let's move on.

Getting example images

Referring back to part 1, the request that gives us our image is https://api.commonservice.io/gateway-api/v1/public/antibot/getCaptcha, let's setup this request in http.go. For now, we will use the default Device-Info header:

1// file: ./coinmarketcap/http.go
2package coinmarketcap
3
4import (
5	"encoding/json"
6	"errors"
7	http "github.com/bogdanfinn/fhttp"
8	"io"
9	"strings"
10)
11
12func (c *Client) GetCaptcha() (*Captcha, error) {
13
14	req, err := http.NewRequest("POST", "https://api.commonservice.io/gateway-api/v1/public/antibot/getCaptcha", strings.NewReader("bizId=CMC_register&sv=20220812&snv=1.4.7&lang=en&securityCheckResponseValidateId=CMC_register&clientType=android"))
15	if err != nil {
16		return nil, err
17	}
18	req.Header.Set("Fvideo-Id", "xxx")
19	req.Header.Set("User-Agent", "Binance/1.0.0 Mozilla/5.0 (Linux; Android 12; SM-A528B Build/V417IR; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/91.0.4472.114 Safari/537.36 Captcha/1.0.0 (Android 1.0.0) SecVersion/1.4.7")
20	req.Header.Set("Content-Type", "text/plain; charset=UTF-8")
21	req.Header.Set("Captcha-Sdk-Version", "1.0.0")
22	req.Header.Set("Device-Info", "eyJzY3JlZW5fcmVzb2x1dGlvbiI6IjYwMCwxMDY3IiwiYXZhaWxhYmxlX3NjcmVlbl9yZXNvbHV0aW9uIjoiNjAwLDEwNjciLCJzeXN0ZW1fdmVyc2lvbiI6InVua25vd24iLCJicmFuZF9tb2RlbCI6InVua25vd24iLCJ0aW1lem9uZSI6IkFtZXJpY2EvTmV3X1lvcmsiLCJ0aW1lem9uZU9mZnNldCI6MjQwLCJ1c2VyX2FnZW50IjoiQmluYW5jZS8xLjAuMCBNb3ppbGxhLzUuMCAoTGludXg7IEFuZHJvaWQgMTI7IFNNLUE1MjhCIEJ1aWxkL1Y0MTdJUjsgd3YpIEFwcGxlV2ViS2l0LzUzNy4zNiAoS0hUTUwsIGxpa2UgR2Vja28pIFZlcnNpb24vNC4wIENocm9tZS85MS4wLjQ0NzIuMTE0IFNhZmFyaS81MzcuMzYgQ2FwdGNoYS8xLjAuMCAoQW5kcm9pZCAxLjAuMCkgU2VjVmVyc2lvbi8xLjQuNyIsImxpc3RfcGx1Z2luIjoiIiwicGxhdGZvcm0iOiJMaW51eCBpNjg2Iiwid2ViZ2xfdmVuZG9yIjoidW5rbm93biIsIndlYmdsX3JlbmRlcmVyIjoidW5rbm93biJ9")
23	req.Header.Set("Bnc-Uuid", "xxx")
24	req.Header.Set("Clienttype", "android")
25	req.Header.Set("X-Captcha-Se", "true")
26	req.Header.Set("Accept", "*/*")
27	req.Header.Set("Origin", "https://staticrecap.cgicgi.io")
28	req.Header.Set("X-Requested-With", "com.coinmarketcap.android")
29	req.Header.Set("Sec-Fetch-Site", "cross-site")
30	req.Header.Set("Sec-Fetch-Mode", "cors")
31	req.Header.Set("Sec-Fetch-Dest", "empty")
32	req.Header.Set("Referer", "https://static://staticrecap.cgicgi.io/")
33	req.Header.Set("Accept-Language", "en,en-US;q=0.9")
34	resp, err := c.HTTPClient.Do(req)
35	if err != nil {
36		return nil, err
37	}
38	defer resp.Body.Close()
39	b, err := io.ReadAll(resp.Body)
40	if err != nil {
41		return nil, err
42	}
43	if resp.StatusCode != 200 {
44		return nil, errors.New(resp.Status)
45	}
46	f := &Captcha{}
47	err = json.Unmarshal(b, f)
48	if err != nil {
49		return nil, err
50	}
51	return f, nil
52}
53
54func (c *Client) GetImage(path string) ([]byte, error) {
55	req, err := http.NewRequest("GET", "https://staticrecap.cgicgi.io"+path, nil)
56	if err != nil {
57		return nil, err
58	}
59	req.Header.Set("User-Agent", "Binance/1.0.0 Mozilla/5.0 (Linux; Android 12; SM-A528B Build/V417IR; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/91.0.4472.114 Safari/537.36 Captcha/1.0.0 (Android 1.0.0) SecVersion/1.4.7")
60	req.Header.Set("Accept", "*/*")
61	req.Header.Set("Origin", "https://staticrecap.cgicgi.io")
62	resp, err := http.DefaultClient.Do(req)
63	if err != nil {
64		return nil, err
65	}
66	defer resp.Body.Close()
67	b, err := io.ReadAll(resp.Body)
68	if err != nil {
69		return nil, err
70	}
71	return b, nil
72}
73// file: ./coinmarketcap/structs.go
74package coinmarketcap
75
76type Captcha struct {
77	Code string `json:"code"`
78	Data struct {
79		Sig         string `json:"sig"`
80		Salt        string `json:"salt"`
81		Path2       string `json:"path2"`
82		Ek          string `json:"ek"`
83		CaptchaType string `json:"captchaType"`
84		Tag         string `json:"tag"`
85		Fb          string `json:"fb"`
86		I18N        string `json:"i18n"`
87	} `json:"data"`
88	Success bool `json:"success"`
89}

Now we add these 2 methods to main.go and save our image to a folder named ./examples.go:

1package main
2
3import (
4	"fmt"
5	"github.com/obfio/cmc-solve-image/coinmarketcap"
6	"os"
7	"strings"
8)
9
10func main() {
11	client := coinmarketcap.MakeClient("")
12	captcha, err := client.GetCaptcha()
13	if err != nil {
14		panic(err)
15	}
16	fmt.Printf("%+v\n", captcha)
17	imageBytes, err := client.GetImage(captcha.Data.Path2)
18	if err != nil {
19		panic(err)
20	}
21	os.WriteFile(fmt.Sprintf("./examples/%s", strings.Split(captcha.Data.Path2, "/")[7]), imageBytes, 0666)
22}

Great, now we can get images in real time to get quite a few samples. Now let's get 1 sample and work on making a solver for it.

Solving without OCR

My first idea for this was that the captcha is split into 2 pieces, one with the puzzle piece, one with the actual image we need to solve. This is very helpful, the puzzle piece is always the same size and the left side is always going to be transparent everywhere but the puzzle piece. This means we can get the y position of where the puzzle piece needs to be.

The first thing I'm going to do is make a new package named solve with a file named solve.go, this will have an exported function called SolveImage that takes in a type image.Image and outputs an int. This int will be how far we need to move the puzzle piece to solve the image. This value will be used in the final payload:

1// file: ./solve/solve.go
2func SolveImage(img image.Image) int {
3	
4	return 0
5}

Now I'm going to make a function that takes in that image.Image and splits it into 2 images, as well as getting the middle x of the puzzle piece, we will use this value later to identify the y of the puzzle piece. We will be saving the puzzle piece image and the solving image as well to piece.png and target.png:

1// file: ./solve/solve.go
2func SolveImage(img image.Image) int {
3	// Split the image into puzzle piece image and target image, and save them
4	puzzlePieceImage, targetImage, puzzlePieceMiddle := splitImages(img)
5	saveImage(puzzlePieceImage, "./examples/piece.png")
6	saveImage(targetImage, "./examples/target.png")
7	fmt.Println(puzzlePieceMiddle)
8	return 0
9}
10
11func splitImages(img image.Image) (image.Image, image.Image, int) {
12	bounds := img.Bounds()
13
14	var splitX int
15	for x := bounds.Min.X; x < bounds.Max.X; x++ {
16		r, g, b, _ := img.At(x, 0).RGBA()
17		if r != 0 && g != 0 && b != 0 {
18			splitX = x
19			break
20		}
21	}
22	puzzleRect := image.Rect(bounds.Min.X, bounds.Min.Y, splitX, bounds.Max.Y)
23	targetRect := image.Rect(splitX, bounds.Min.Y, bounds.Max.X, bounds.Max.Y)
24
25	puzzleImg := image.NewRGBA(puzzleRect)
26	draw.Draw(puzzleImg, puzzleRect, img, bounds.Min, draw.Src)
27
28	targetImg := image.NewRGBA(targetRect)
29	draw.Draw(targetImg, targetRect, img, image.Point{X: splitX, Y: 0}, draw.Src)
30
31	return puzzleImg, targetImg, splitX / 2
32}
33
34func saveImage(img image.Image, path string) error {
35	file, err := os.Create(path)
36	if err != nil {
37		return err
38	}
39	defer file.Close()
40
41	return png.Encode(file, img)
42}

The way this works is by finding the starting point of the non-transparent part of the image on the x axis, then it splits the image into 2 images at that point. It then returns that x position /2 to get the middle of the puzzle piece image.

Next, we need to get the y of the puzzle piece in the puzzle piece image. Realistically, we only have to get any part of the puzzle piece but the deeper into the puzzle piece that we get, the better. To do this, we will find which row in the image has the most color difference from 0, aka transparent:

1// file: ./solve/solve.go
2func SolveImage(img image.Image) int {
3	// Split the image into puzzle piece image and target image, and save them
4	puzzlePieceImage, targetImage, puzzlePieceMiddle := splitImages(img)
5	saveImage(puzzlePieceImage, "./examples/piece.png")
6	saveImage(targetImage, "./examples/target.png")
7	fmt.Println(puzzlePieceMiddle)
8
9	// Find the dominant row in the puzzle piece image
10	dominantRow := findDominantRow(puzzlePieceImage, puzzlePieceMiddle)
11	fmt.Println(dominantRow)
12	return 0
13}
14
15func findDominantRow(img image.Image, middle int) int {
16	bounds := img.Bounds()
17	rowPixelCount := make(map[int]int)
18	maxPixels := 0
19	dominantRow := 0
20	for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
21		r, g, b, _ := img.At(middle, y).RGBA()
22		if r != 0 && g != 0 && b != 0 {
23			rowPixelCount[y]++
24			if rowPixelCount[y] > maxPixels {
25				maxPixels = rowPixelCount[y]
26				dominantRow = y
27			}
28		}
29	}
30	return dominantRow
31}

To confirm our output, we can write code to put a red pixel on the x,y position: As you can see, the pixel it's getting it very close to the top of the image, however, it is on the image which is good. Instead of adding complexity, let's just add 20 to dominantRow:

1func SolveImage(img image.Image) int {
2	// Split the image into puzzle piece image and target image, and save them
3	puzzlePieceImage, targetImage, puzzlePieceMiddle := splitImages(img)
4	saveImage(puzzlePieceImage, "./examples/piece.png")
5	saveImage(targetImage, "./examples/target.png")
6	fmt.Println(puzzlePieceMiddle)
7
8	// Find the dominant row in the puzzle piece image
9	dominantRow := findDominantRow(puzzlePieceImage, puzzlePieceMiddle)
10	fmt.Println(dominantRow)
11
12	// add 20 to dominantRow
13	dominantRow += 20
14	
15	// make red pixel at `x,y` of puzzlePieceImage
16	bounds := puzzlePieceImage.Bounds()
17	rgba := image.NewRGBA(bounds)
18	draw.Draw(rgba, bounds, img, bounds.Min, draw.Src)
19	rgba.Set(puzzlePieceMiddle, dominantRow, color.RGBA{R: 255, G: 0, B: 0, A: 255})
20	saveImage(rgba, "./examples/piece.png")
21	return 0
22}

Now we see this: This is much better, having the x,y deeper into the image should increase the solve rate.

The final thing we need to do is determine where on the image the piece needs to go. To do this, let's look at the image and see what we can do. The first thing I notice is that the space the piece needs to go is shaded, as a gray color. My first thought when I saw this is that I should look on that y axis and scan which pixel on the x axis is darkest, let's try that, again we're going to place a red pixel at the x,y it found:

1// file: ./solve/solve.go
2func SolveImage(img image.Image) int {
3	// Split the image into puzzle piece image and target image, and save them
4	puzzlePieceImage, targetImage, puzzlePieceMiddle := splitImages(img)
5	saveImage(puzzlePieceImage, "./examples/piece.png")
6	saveImage(targetImage, "./examples/target.png")
7	fmt.Println(puzzlePieceMiddle)
8
9	// Find the dominant row in the puzzle piece image
10	dominantRow := findDominantRow(puzzlePieceImage, puzzlePieceMiddle)
11	fmt.Println(dominantRow)
12
13	// add 20 to dominantRow
14	dominantRow += 20
15
16	// make red pixel at `x,y` of puzzlePieceImage
17	bounds := puzzlePieceImage.Bounds()
18	rgba := image.NewRGBA(bounds)
19	draw.Draw(rgba, bounds, img, bounds.Min, draw.Src)
20	rgba.Set(puzzlePieceMiddle, dominantRow, color.RGBA{R: 255, G: 0, B: 0, A: 255})
21	saveImage(rgba, "./examples/piece.png")
22
23	// get the place on the image where the shading is most defined
24	shadedX := findShadedArea(targetImage, dominantRow)
25	bounds = targetImage.Bounds()
26	rgba = image.NewRGBA(bounds)
27	draw.Draw(rgba, bounds, img, bounds.Min, draw.Src)
28	rgba.Set(shadedX, dominantRow, color.RGBA{R: 255, G: 0, B: 0, A: 255})
29	saveImage(rgba, "./examples/target.png")
30	return shadedX
31}
32
33func findShadedArea(img image.Image, row int) int {
34	bounds := img.Bounds()
35	xStart := -1
36	minIntensity := math.MaxFloat64
37	for x := bounds.Min.X + 60; x < bounds.Max.X; x++ {
38		c := img.At(x, row)
39		r, g, b, _ := c.RGBA()
40		r8 := float64(r >> 8)
41		g8 := float64(g >> 8)
42		b8 := float64(b >> 8)
43		intensity := r8 + g8 + b8
44		if intensity < minIntensity {
45			if intensity+60 < minIntensity {
46				xStart = x
47			}
48			minIntensity = intensity
49		}
50	}
51	return xStart
52}

After running this code, I see this: You can see that it is finding a pixel on the target. This is great! Now we can move onto generating the payload.

Payload Generation

In order to generate our payload, let's create a struct for it and give that struct a FillPayload method that will fill the payload with everything we need. First I will show you all the code I got, then I will explain what I think needs explaining:

1// file: ./main.go
2package main
3
4import (
5	"bytes"
6	"fmt"
7	"github.com/obfio/cmc-solve-image/coinmarketcap"
8	"github.com/obfio/cmc-solve-image/solve"
9	"image/png"
10	"os"
11	"strings"
12)
13
14func main() {
15	client := coinmarketcap.MakeClient("")
16	captcha, err := client.GetCaptcha()
17	if err != nil {
18		panic(err)
19	}
20	fmt.Printf("%+v\n", captcha)
21	imageBytes, err := client.GetImage(captcha.Data.Path2)
22	if err != nil {
23		panic(err)
24	}
25	os.WriteFile(fmt.Sprintf("./examples/%s", strings.Split(captcha.Data.Path2, "/")[7]), imageBytes, 0666)
26	// convert bytes to png
27	img, err := png.Decode(bytes.NewReader(imageBytes))
28	if err != nil {
29		panic(err)
30	}
31	solvedX := solve.SolveImage(img)
32	payload := &coinmarketcap.Payload{}
33	payload.FillPayload(solvedX)
34	fmt.Printf("%+v\n", payload)
35}
36
37// file: ./coinmarketcap/payload.go
38package coinmarketcap
39
40import (
41	"fmt"
42	"math"
43	"math/rand"
44	"time"
45)
46
47const (
48	Platform          = "Linux i686"
49	InnerHeight       = 355
50	OuterHeight       = "355"
51	PuzzlePieceWidth  = 44
52	PuzzlePieceHeight = 44
53	ImageWidth        = "310"
54)
55
56func (p *Payload) FillPayload(solvedX int) {
57	// ENVIRONMENT
58	p.Ev.Wd = genOddNum()
59	p.Ev.Im = genEvenNum()
60	p.Ev.Prde = fmt.Sprintf("%v,%v,%v,%v", genOddNum(), genOddNum(), genOddNum(), genOddNum())
61	p.Ev.Brla = genOddNum()
62	p.Ev.Pl = Platform
63	p.Ev.Wiinhe = InnerHeight
64	p.Ev.Wiouhe = OuterHeight
65
66	MACT, finalDist := generateMACT(solvedX, 290)
67	p.Be.El = MACT
68	p.Be.Ec.Ts = 1
69	p.Be.Ec.Tm = len(MACT) - 2
70	p.Be.Ec.Te = 1
71
72	// PUZZLE PIECE
73	p.Be.Th.El = []string{}
74	p.Be.Th.Si.W = PuzzlePieceWidth
75	p.Be.Th.Si.H = PuzzlePieceHeight
76
77	// FINAL X WHERE THE SLIDER LANDS, MINUS THE OFFSET
78	p.Dist = finalDist
79
80	// IMAGE WIDTH, HARD CODED
81	p.ImageWidth = ImageWidth
82}
83
84func generateMACT(xEnd, yDom int) ([]string, int) {
85	xStart := rand.Intn(40) + 20
86	xStartCopy := xStart + 1
87	xEndMinusOffset := xEnd - 60
88	yStart := yDom + 1
89	eventsNeeded := int(math.Ceil(float64(xEnd) / 6.0))
90	timestampStart := time.Now().UnixMilli()
91	totalTimeUsed := 0
92	output := []string{}
93	for i := 0; i < eventsNeeded; i++ {
94		yStart = newY(yStart)
95		xStart = newX(xStart)
96		timeTaken := getTimeTaken(i)
97
98		totalTimeUsed += timeTaken
99
100		output = append(output, fmt.Sprintf("|tm|%v,%v|%v|1", xStart, yStart, timeTaken))
101
102		if xStart >= xEnd {
103			break
104		}
105	}
106	timeTaken := getTimeTaken(999)
107	output = append(output, fmt.Sprintf("|te||%v|1", timeTaken))
108	totalTimeUsed += timeTaken
109	time.Sleep(time.Duration(totalTimeUsed*2) * time.Millisecond)
110	finalOutput := []string{fmt.Sprintf("|ts|%v,%v|%v|1", xStartCopy, yDom, timestampStart)}
111	for _, o := range output {
112		finalOutput = append(finalOutput, o)
113	}
114	return finalOutput, xEndMinusOffset
115}
116
117func getTimeTaken(i int) int {
118	if i == 0 {
119		return rand.Intn(20) + 80
120	}
121	if i == 999 {
122		return rand.Intn(50) + 200
123	}
124	return rand.Intn(7) + 13
125}
126
127func newX(xStart int) int {
128	amtToAdd := rand.Intn(6) + 3
129	return xStart + amtToAdd
130}
131
132func newY(yStart int) int {
133	// get if change y
134	changeY := randBool()
135	// get if change up or down
136	var changeYUp bool
137	if changeY {
138		changeYUp = randBool()
139	}
140	// get how much to change
141	var changeYAmount int
142	if changeY {
143		changeYAmount = rand.Intn(3)
144		if changeYUp {
145			yStart += changeYAmount
146		} else {
147			yStart -= changeYAmount
148		}
149	}
150	return yStart
151}
152
153func randBool() bool {
154	return rand.Intn(2) == 1
155}
156
157func genOddNum() int {
158	return (rand.Intn(50) * 2) + 1
159}
160
161func genEvenNum() int {
162	return rand.Intn(50) * 2
163}
164
165// file: ./coinmarketcap/structs.go
166type Payload struct {
167	Ev struct {
168		Wd     int    `json:"wd"`
169		Im     int    `json:"im"`
170		De     string `json:"de"`
171		Prde   string `json:"prde"`
172		Brla   int    `json:"brla"`
173		Pl     string `json:"pl"`
174		Wiinhe int    `json:"wiinhe"`
175		Wiouhe string `json:"wiouhe"`
176	} `json:"ev"`
177	Be struct {
178		Ec struct {
179			Ts int `json:"ts"`
180			Tm int `json:"tm"`
181			Te int `json:"te"`
182		} `json:"ec"`
183		El []string `json:"el"`
184		Th struct {
185			El []string `json:"el"`
186			Si struct {
187				W int `json:"w"`
188				H int `json:"h"`
189			} `json:"si"`
190		} `json:"th"`
191	} `json:"be"`
192	Dist       int    `json:"dist"`
193	ImageWidth string `json:"imageWidth"`
194}

The first thing I think that needs explaining is generateMACT. This is basically just randomness. There should be 1 touch_start event, x touch_move event, and 1 touch_end event to simulate what a user does with the touch the slider, move it to the desired location, then let go. First we need to start somewhere on the slider, that is why xStart has a minimum of 20 and a maximum of 60, 20 is the point on the image that the slider starts and 60 is where the slider ends. Next we give the xEnd an offset of 60, this is because the x position we got earlier is including the 60 pixels of blank space, that should not be included in the x position of the touch event. To decide how many events to use, we will use xEnd / 6 to get this value, this means that every 6 pixels will result in a touch_move event. Everything else is just randomness, we get a random time between events, a random movement, rather that's moving x or y, and rather to move y up or down for that event. Last, we just add out touch_end and touch_start events, as well as waiting x ms, this is because if the final MS timestamp we have is > the current time, it will ofc detect us as a bot.

That was a lot ot take in at once, however, I hope I explained it well enough. The only other thing I think needs explaining is how PuzzlePieceWidth/Height and ImageWidth are hard coded, I took a few samples and found out that these values are always the same, therefore, we can hardcode them! Now we can move on to encoding the payload!

Encoding the payload

When it comes to encoding the payload, I actually wrote this code a long time ago and wasn't 100% confident that the long functions we saw that ended up doing nothing to the payload in part 1 were actually doing nothing, therefore, I coded all that into it the payload encoding.

In general, this code does not need explaining, it's really just taking the JavaScript code and making it GoLang. The only part that I will explain is my use of this trippleShift function. When reverse engineering, you can sometimes find differences between languages, this time being that golang has no >>> operator, therefore, I made my own function that acts the same.

Now this is the code:

1// file: ./coinmarketcap/encoding.go
2package cmc
3
4import (
5	"encoding/json"
6	"strings"
7)
8
9func (p *Payload) Encode(key string) string {
10	key = reverseString(key)
11	key = key + encodeXORKey(key)
12	payloadBytes, _ := json.Marshal(p)
13	UTF8PayloadBytes := UTF8(payloadBytes)
14	XORDBytes := []byte{}
15	for i := 0; i < len(UTF8PayloadBytes); i++ {
16		XORDBytes = append(XORDBytes, []byte(fromCharCode(int(UTF8PayloadBytes[i]) ^ charCodeAt(key, i%len(key))))[0])
17	}
18	return btoa(XORDBytes)
19}
20
21// =======================================
22// ==        payload enc stuff          ==
23// =======================================
24func UTF8(payload []byte) []byte {
25	manipulatedBytes := K(payload)
26	out := []byte{}
27	for i := 0; i < len(manipulatedBytes); i++ {
28		char := manipulatedBytes[i]
29		out = append(out, L(char)...)
30	}
31	return out
32}
33
34func L(c byte) []byte {
35	char := int(c)
36	test := 1
37	if 0&(trippleShift(char, 16)&65535) == 0 {
38		test = 0
39	}
40	if (char & 65408) == test {
41		return []byte(fromCharCode(char))
42	}
43	out := []byte{}
44	if (char&63488) == 0 && (trippleShift(char, 16)&65535) == 0 {
45		out = []byte(fromCharCode(char>>6&31 | 192))
46	} else if (char&0) == 0 && (trippleShift(char, 16)&65535) == 0 {
47		J(char)
48		out = []byte(fromCharCode(char>>12&15 | 224))
49		out = append(out, I(char, 6))
50	} else if (char&0) == 0 && (trippleShift(char, 16)&65504) == 0 {
51		out = []byte(fromCharCode(char>>18&7 | 240))
52		out = append(out, I(char, 12))
53		out = append(out, I(char, 6))
54	}
55	out = append(out, []byte(fromCharCode(char&63|128))...)
56	return out
57}
58
59func I(char, num int) byte {
60	return byte(char>>num&63 | 128)
61}
62
63func J(char int) {
64	if char >= 55296 && char <= 57343 {
65		panic("not a scalar value")
66	}
67}
68
69func K(payload []byte) []byte {
70	out := []byte{}
71	bytesAdded := 0
72	payloadLen := len(payload)
73	byteValue := 0
74	nextByte := 0
75	for bytesAdded < payloadLen {
76		byteValue = int(payload[bytesAdded])
77		bytesAdded++
78		if byteValue >= 55296 && byteValue <= 56319 && bytesAdded < payloadLen {
79			nextByte = int(payload[bytesAdded])
80			bytesAdded++
81			bytesAdded++
82			if (nextByte & 64512) == 56320 {
83				out = append(out, byte(((byteValue&1023)<<10)+(nextByte&1023)+65536))
84			} else {
85				out = append(out, byte(byteValue))
86				bytesAdded--
87			}
88		} else {
89			out = append(out, byte(byteValue))
90		}
91	}
92	return out
93}
94
95// =======================================
96// ==          XOR key stuff            ==
97// =======================================
98
99func encodeXORKey(key string) string {
100	output := []string{}
101	chars := strings.Split("abcdhijkxy", "")
102	for i := 0; i < 4; i++ {
103		charPos := 0
104		keyPos := 1
105		if i == 3 {
106			keyPos = 1 + len(key)%4
107		}
108		for x := 0; x < keyPos; x++ {
109			charCodePos := i + x
110			if x < len(key) {
111				charPos = charPos + charCodeAt(key, charCodePos)
112			}
113		}
114		charPos = charPos * 31
115		output = append(output, chars[charPos%len(chars)])
116	}
117	return strings.Join(output, "")
118}
119
120func reverseString(str string) string {
121	runes := []rune(str)
122	for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 {
123		runes[i], runes[j] = runes[j], runes[i]
124	}
125	return string(runes)
126}
127
128// file: ./coinmarketcap/encodingUtils.go
129package cmc
130
131import (
132	"crypto/sha512"
133	"encoding/base64"
134	"encoding/hex"
135)
136
137func btoa(str []byte) string {
138	return base64.StdEncoding.EncodeToString(str)
139}
140
141func charCodeAt(a string, i int) int {
142	return int(a[i])
143}
144
145// FromCharCode https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/FromCharCode
146func fromCharCode(c int) string {
147	return string(rune(c))
148}
149
150// >>> operator in golang
151func trippleShift(num, t int) int {
152	overflow := int32(num)
153	return int(uint32(overflow) >> t)
154}
155
156func shaPassword(password string) string {
157	sha512Hasher := sha512.New()
158	sha512Hasher.Write([]byte(password))
159	return hex.EncodeToString(sha512Hasher.Sum(nil))
160}

You can see that, simply to make the process of porting code easier, I made helper functions like fromCharCode, charCodeAt and btoa. I would highly recommend doing this in your own projects, it makes porting code much much easier in my opinion.

There's one more thing that we need to discuss here that I actually missed in part 1. There is a value in validateCaptcha that I never went over called s. We can pretend this is a challenge for the readers. I challenge you to, without looking at the code on the GitHub, to reverse engineer this s value and maybe even port the code yourself. For this reason, I won't show the code for it in this blog, it will be in the GitHub repository though!

Hooking it all together

First, we need to add code to main.go to get the encoded payload and the s value:

1// file: ./main.go
2func main() {
3	client := coinmarketcap.MakeClient("")
4	captcha, err := client.GetCaptcha()
5	if err != nil {
6		panic(err)
7	}
8	fmt.Printf("%+v\n", captcha)
9	if captcha.Data.CaptchaType != "SLIDE" {
10		panic("captcha type is " + captcha.Data.CaptchaType)
11	}
12	imageBytes, err := client.GetImage(captcha.Data.Path2)
13	if err != nil {
14		panic(err)
15	}
16	os.WriteFile(fmt.Sprintf("./examples/%s", strings.Split(captcha.Data.Path2, "/")[7]), imageBytes, 0666)
17	
18	// convert bytes to png
19	img, err := png.Decode(bytes.NewReader(imageBytes))
20	if err != nil {
21		panic(err)
22	}
23	
24	// solve image
25	solvedX := solve.SolveImage(img)
26	
27	// make the payload
28	payload := &coinmarketcap.Payload{}
29	payload.FillPayload(solvedX)
30	fmt.Printf("%+v\n", payload)
31	
32	// get encoded payload
33	encodedPayload := payload.Encode(captcha.Data.Ek)
34	
35	// get S value
36	sValue := payload.GenSValue(encodedPayload, captcha.Data.Sig, captcha.Data.Salt)
37}

Next, we need to add a method to ./coinmarketcap/http.go to do validateCaptcha:

1// file: ./coinmarketcap/http.go
2func (c *Client) SolveCaptcha(sig, encodedPayload string, sValue int) (*SolveResponse, error) {
3	//bizId=CMC_register&sv=20220812&snv=1.4.7&lang=en&securityCheckResponseValidateId=CMC_register&clientType=android&sig=pUBDZytKUt6D1SSxq7H8JQEtYSiU4ywACNPeOnJT1DwXKt1c&data
4	t := url.Values{}
5	t.Set("data", encodedPayload)
6	t.Set("bizId", "CMC_register")
7	t.Set("sv", "20220812")
8	t.Set("snv", "1.4.7")
9	t.Set("lang", "en")
10	t.Set("securityCheckResponseValidateId", "CMC_register")
11	t.Set("clientType", "android")
12	t.Set("sig", sig)
13	t.Set("s", fmt.Sprintf("%v", sValue))
14	body := fmt.Sprintf(`bizId=CMC_register&sv=20220812&snv=1.4.7&lang=en&securityCheckResponseValidateId=CMC_register&clientType=android&sig=%s&data=%s&s=%v`, sig, strings.Split(t.Encode(), "=")[1], sValue)
15	req, err := http.NewRequest("POST", "https://api.commonservice.io/gateway-api/v1/public/antibot/validateCaptcha", strings.NewReader(body))
16	if err != nil {
17		return nil, err
18	}
19	req.Header.Set("Fvideo-Id", "xxx")
20	req.Header.Set("User-Agent", "Binance/1.0.0 Mozilla/5.0 (Linux; Android 12; SM-A528B Build/V417IR; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/91.0.4472.114 Safari/537.36 Captcha/1.0.0 (Android 1.0.0) SecVersion/1.4.7")
21	req.Header.Set("Content-Type", "text/plain; charset=UTF-8")
22	req.Header.Set("Captcha-Sdk-Version", "1.0.0")
23	req.Header.Set("Device-Info", "eyJzY3JlZW5fcmVzb2x1dGlvbiI6IjYwMCwxMDY3IiwiYXZhaWxhYmxlX3NjcmVlbl9yZXNvbHV0aW9uIjoiNjAwLDEwNjciLCJzeXN0ZW1fdmVyc2lvbiI6InVua25vd24iLCJicmFuZF9tb2RlbCI6InVua25vd24iLCJ0aW1lem9uZSI6IkFtZXJpY2EvTmV3X1lvcmsiLCJ0aW1lem9uZU9mZnNldCI6MjQwLCJ1c2VyX2FnZW50IjoiQmluYW5jZS8xLjAuMCBNb3ppbGxhLzUuMCAoTGludXg7IEFuZHJvaWQgMTI7IFNNLUE1MjhCIEJ1aWxkL1Y0MTdJUjsgd3YpIEFwcGxlV2ViS2l0LzUzNy4zNiAoS0hUTUwsIGxpa2UgR2Vja28pIFZlcnNpb24vNC4wIENocm9tZS85MS4wLjQ0NzIuMTE0IFNhZmFyaS81MzcuMzYgQ2FwdGNoYS8xLjAuMCAoQW5kcm9pZCAxLjAuMCkgU2VjVmVyc2lvbi8xLjQuNyIsImxpc3RfcGx1Z2luIjoiIiwicGxhdGZvcm0iOiJMaW51eCBpNjg2Iiwid2ViZ2xfdmVuZG9yIjoidW5rbm93biIsIndlYmdsX3JlbmRlcmVyIjoidW5rbm93biJ9")
24	req.Header.Set("Bnc-Uuid", "xxx")
25	req.Header.Set("Clienttype", "android")
26	req.Header.Set("X-Captcha-Se", "true")
27	req.Header.Set("Accept", "*/*")
28	req.Header.Set("Origin", "https://staticrecap.cgicgi.io")
29	req.Header.Set("X-Requested-With", "com.coinmarketcap.android")
30	req.Header.Set("Sec-Fetch-Site", "cross-site")
31	req.Header.Set("Sec-Fetch-Mode", "cors")
32	req.Header.Set("Sec-Fetch-Dest", "empty")
33	req.Header.Set("Referer", "https://static://staticrecap.cgicgi.io/")
34	req.Header.Set("Accept-Language", "en,en-US;q=0.9")
35	resp, err := c.HTTPClient.Do(req)
36	if err != nil {
37		return nil, err
38	}
39	defer resp.Body.Close()
40	if resp.StatusCode != 200 {
41		return nil, errors.New("invalid status code")
42	}
43	b, err := io.ReadAll(resp.Body)
44	if err != nil {
45		return nil, err
46	}
47	f := &SolveResponse{}
48	err = json.Unmarshal(b, &f)
49	if err != nil {
50		return nil, err
51	}
52	if !f.Success || f.Code != "000000" || f.Data.Token == "" {
53		return nil, errors.New(string(b))
54	}
55	return f, nil
56}
57
58
59// file: ./coinmarketcap/structs.go
60type SolveResponse struct {
61	Code string `json:"code"`
62	Data struct {
63		Result int    `json:"result"`
64		Tag    string `json:"tag"`
65		I18N   string `json:"i18n"`
66		Token  string `json:"token"`
67	} `json:"data"`
68	Success bool `json:"success"`
69}

Now, to get the output captcha, let's just hook this up to main.go:

1// file: ./main.go
2func main() {
3	client := coinmarketcap.MakeClient("")
4	captcha, err := client.GetCaptcha()
5	if err != nil {
6		panic(err)
7	}
8	fmt.Printf("%+v\n", captcha)
9	if captcha.Data.CaptchaType != "SLIDE" {
10		panic("captcha type is " + captcha.Data.CaptchaType)
11	}
12	imageBytes, err := client.GetImage(captcha.Data.Path2)
13	if err != nil {
14		panic(err)
15	}
16	os.WriteFile(fmt.Sprintf("./examples/%s", strings.Split(captcha.Data.Path2, "/")[7]), imageBytes, 0666)
17
18	// convert bytes to png
19	img, err := png.Decode(bytes.NewReader(imageBytes))
20	if err != nil {
21		panic(err)
22	}
23
24	// solve image
25	solvedX := solve.SolveImage(img)
26
27	// make the payload
28	payload := &coinmarketcap.Payload{}
29	payload.FillPayload(solvedX)
30	fmt.Printf("%+v\n", payload)
31
32	// get encoded payload
33	encodedPayload := payload.Encode(captcha.Data.Ek)
34
35	// get S value
36	sValue := payload.GenSValue(encodedPayload, captcha.Data.Sig, captcha.Data.Salt)
37	solvedCaptcha, err := client.SolveCaptcha(captcha.Data.Sig, encodedPayload, sValue)
38	if err != nil {
39		panic(err)
40	}
41	fmt.Printf("%+v\n", solvedCaptcha)
42}

When I ran this, I got this response:

1{
2  "code": "000000",
3  "data": {
4    "result": 3,
5    "tag": "cmc_new",
6    "i18n": "{\"cap_timeout\":\"Timeout\",\"cap_try_again\":\"Please try again\",\"cap_select_all_match_images\":\"Please select all images with\",\"cap_verify_fail\":\"Please try again\",\"cap_success\":\"Success\",\"cap_too_many_attempts\":\"Too many attempts\",\"cap_verify\":\"Verify\",\"cap_verify_success\":\"Success\",\"networkerror\":\"change to a new captcha image\",\"cap_loading\":\"Loading\",\"cap_security_verification\":\"Security Verification\",\"describe\":\"select all image with {{tag}}\",\"cap_complete_puzzle\":\"Slide to complete the puzzle\",\"cap_network_error\":\"Network error\",\"cap_next\":\"Next\",\"cap_system_error\":\"System error\"}",
7    "token": ""
8  },
9  "success": true
10}

At first, I was confused, then I realized something. In golang, url.Values doesn't keep the order that you add keys. To test if this was really the problem, I changed the way we get the body to this code:

1// file: ./coinmarketcap/http.go
2t := url.Values{}
3t.Set("data", encodedPayload)
4body := fmt.Sprintf(`bizId=CMC_register&sv=20220812&snv=1.4.7&lang=en&securityCheckResponseValidateId=CMC_register&clientType=android&sig=%s&data=%s&s=%v`, sig, strings.Split(t.Encode(), "=")[1], sValue)

When running the code now, I get this output:

1&{Code:000000 Data:{Result:0 Tag:cmc_new I18N:{"cap_timeout":"Timeout","cap_try_again":"Please try again","cap_select_all_match_images":"Please select all images with","cap_verify_fail":"Please try again","cap_success":"Success","cap_too_many_attempts":"Too many attempts","cap_verify":"Verify","cap_verify_success":"Success","networkerror":"change to a new captcha image","cap_loading":"Loading","cap_security_verification":"Security Verification","describe":"select all image with {{tag}}","cap_complete_puzzle":"Slide to complete the puzzle","cap_network_error":"Network error","cap_next":"Next","cap_system_error":"System error"} Token:captcha#zzzzzzzzzzzzzzzzzzzzz} Success:true}

Great, we got a captcha! I will say though, it was surprising that they validate the order of the keys in the payload. It was the first thing I tried to fix this problem, however, I was not expecting it at all. Fair play to them. Now we get to write a little http function to try to register an account.

Signup

First, let's get a disposable email from mail.gw, this will be used to validate that the signup is succeeding by seeing if we get a signup code sent to the email.

Next, we can use Burp Suite's repeater function to see if our captcha works. Right click the signUp request and select Send to Repeater, then at the top you should see the Repeater tab, click that. Now we can generate a new captcha and replace the Cmc-Captcha-Token header with our generated captcha and the email with our disposable email. I'll record a little demo video of this to show you all! You can view that here.

As you can see, it all works as intended! I had a really great time making doing this project and and even better time writing the 2 blog posts on it and I hope you, the reader, enjoyed it as well! Thank you for reading and feel free to contact me if you have any questions about anything I've done here.

References

GitHub Repository: github.com/obfio/coinmarketcap-captcha-solver HTTP Client: github.com/bogdanfinn/fhttp TLS Profiles: github.com/bogdanfinn/tls-client Disposable Email: mail.gw


obfio