CoinMarketCap captcha: Part 1, Reverse Engineering
August 12, 2022
• obfio
In this post, we will be targetting and reverse engineering the CoinMarketCap(CMC) mobile app, specifically going after their custom captcha implimentation. To keep this blog consistent throughout...
This 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/1741712677486
Intro
In this post, we will be targetting and reverse engineering the CoinMarketCap(CMC) mobile app, specifically going after their custom captcha implimentation. To keep this blog consistent throughout the updates this app will have, lets all make sure we're on the same page. First, I'm going to be using version 4.65.2 provided by uptodown.com. As for the HTTP intercecptor I'm using, I will be using Burp Suite Community Edition. For the Android emulator I'm using, I'll be using MuMu player. Finally, for SSL unpinning, I'm using a free script provided on codeshare.frida.re.
Setting up
First, we will setup burp to use our local IP, starting in 192. Do this by going into proxy -> proxy settings, under "proxy listeners" click add, then under "specific address" click the dropdown, then click your local IP starting in 192. Set your port to whatever you want but I use 8899 typically. After you do that your proxy listeners should look like this:
Now I'll setup my emulator to use the local proxy, this is something I won't explain in depth as it depends on android version and it's a simple google search away. Generally speaking, you go into settings -> wifi -> edit -> advanced options -> proxy type manual. From here you input your local IP and the port you chose, for me that would be 192.168.1.40 with port 8899.
Now we will use ADB to connect to our emulator, ADB can be installed from developer.android.com and is fairly simple to use, the "difficult" part would be setting up frida. Frida can be tricky if you're new to mobile reverse engineering because you have to install the correct build for both your arch and your android version. I would highly recommend, if you need help, to watch this video by jimmisimon on youtube, he goes over the process greatly.
From here, I will assume you have, I'll assume you have your adb shell setup with frida-server running on your emulator. If you cannot figure this all out, feel free to reach out via the contacts page. Now we just need to launch frida with the SSL unpinning script, that command would be frida -U --codeshare masbog/frida-android-unpinning-ssl -f com.coinmarketcap.android. After running that command, the app should open on your emulator and you should see HTTP requests in burp!
Looking at requests
Now that we're all setup and ready to go, we can start investigating the requests. What we're after is the captcha request. Where are captchas typically present? That would be Login and SignUp endpoints. My use-case for this is the SignUp endpoint, I'll be investigating that. Let's register an account and look at the requests after.
I'll be starting at the signup request and looking backwards from there. At signup, we see it requests to /auth/v4/user/signUp to regsiter an account. There is nothing in the payload that signals a captcha is used, looking at the headers though, we see Cmc-Captcha-Token: captcha#blahblah, this must be it. We can also see a header named Security-Id (foreshadowing). Working backwards, I see that there is actually 2 requests to /signUp, the first one contains no captcha header and returns this body:
1{
2 "data": {
3 "securityId": "xxxxxxxxx",
4 "bizCode": "CMC_register",
5 "type": "binance"
6 },
7 "status": {
8 "timestamp": "xxxxxxxxxxxxx",
9 "error_code": "1017",
10 "error_message": "Need Captcha Verification again",
11 "elapsed": "0",
12 "credit_count": 0,
13 "verifiedUserType": 0
14 },
15 "header": {
16 "code": 1017,
17 "msg": "Need Captcha Verification again"
18 }
19}So what seems to be happening is that the first request to signUp gives a token (securityId) that's then used in the header Security-Id in the second request. The way they set this up makes narrowing these requests down fairly simple. It can be assumed anything between those 2 requests is related to the captcha. Looking at the request URLs, we see stuff like captcha and antibot mentioned a lot, this confirms the assumption.
We see 2 scripts getting loaded, one is is .html (new_captcha_app_content.html), the other is a .js (captcha.min.js). Looking at the html, there seems to be nothing important there, we can assume this quickly by noticing that the js file is obfuscated, this is a clear sign that all the important code will be in the js file, not in the html file.
Next we see a request to /getCaptcha and /validateCaptcha. These requests both include a Device-Info header which is a base64 encoded string containing information about our phone. Payload can be seen below:
1{
2 "screen_resolution": "600,1067",
3 "available_screen_resolution": "600,1067",
4 "system_version": "unknown",
5 "brand_model": "unknown",
6 "timezone": "America/New_York",
7 "timezoneOffset": 240,
8 "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",
9 "list_plugin": "",
10 "platform": "Linux i686",
11 "webgl_vendor": "unknown",
12 "webgl_renderer": "unknown"
13}We can see that this is very basic information, honestly, all of this data can be easily scraped off of phone manufacturer websites as seen in my PX blog posts. Anyways, let's focus on getCaptcha right now. First we have the request payload, we notice most of it seems to be static values, except sv which appears like it could be dynamic off first glance, we won't be sure until we deobfuscate the js file that's making the request. Next we look at the response payload, this contains a sig, short for signature, then a salt, an image path, something called "ek" which is likely short for "encryption key" but we can't know for sure right now, then followed by the captcha type, which is a SLIDE in this case. We can go ahead and confirm that the image path seen is our captcha image by prefixing it with https://staticrecap.cgicgi.io and opening it in a browser, from here we see that it is infact our captcha image.
From here, we have done about all we can do with just the requests. In /validateCaptcha we see that the main payload is base64, I can tell this right away because it ends with %3D which is URL encoding for =, any time a large string ends with and equals sign, it's almost always going to be base64. In this case, it is, however, it's encrypted and for now we're unsure how, therefore, we cannot decrypt it right now to see what the payload is.
At this point in the blog is where the difficulty will be ramping up, we're moving onto deobfuscation next and I will not be in-depth explaining everything. For good deobfuscation resources, I'd look at steak enthusiasts blog, when I was first getting into deobfuscation, his resources helped me a lot!
Deobfuscation
The packages we will be using for deobfuscation are @babel/parser, @babel/traverse, @babel/generator, @babel/types, and vm. First, I will want to beautify the code. I can do that by running this code that runs a hex to ascii transformer, then save the output:
1const fs = require('fs');
2const parser = require('@babel/parser');
3const traverse = require('@babel/traverse').default;
4const generate = require('@babel/generator').default;
5const t = require('@babel/types');
6const vm = require('vm');
7const {
8 readFileSync,
9 writeFileSync,
10} = require("fs");
11var output = ""
12
13let beautify_opts = {
14 comments: true,
15 minified: false,
16 concise: false,
17}
18const script = readFileSync('./original/a.js', 'utf-8');
19
20const AST = parser.parse(script, {})
21
22const hexToAsciiVisitor = {
23 NumericLiteral(path) {
24 delete path.node.extra.raw;
25 },
26 StringLiteral(path) {
27 delete path.node.extra.raw;
28 }
29}
30
31traverse(AST, hexToAsciiVisitor)
32
33writeFileSync("output.txt", output, 'utf-8')
34
35const final_code = generate(AST, beautify_opts).code;
36
37fs.writeFileSync('./output/a.js', final_code);Looking at the beautified code, the first thing I notice is an array of strings. This is extremely common in obfuscator.io obfuscation and is very likely to be what we're looking at here. I can also tell this because of this code:
1 (function (b, e) {
2 var f = function (g) {
3 while (--g) {
4 b["push"](b["shift"]());
5 }
6 };
7 f(++e);
8 })(a, 190);
9 var b = function (c, d) {
10 c = c - 0;
11 var e = a[c];
12 //...
13 };This first function that shifts and pushes stuff, this is something I see very often in obfuscator.io files. This b function is also something I see often. It's safe to say we're working against obfuscator.io here. My first thought is to save us all some time and run it through webcrack, however, this unfortunately does not work here. Likely because the file seems to be on an older version of obfuscator.io unfortunately.
Thankfully, since it's on an older version, it should be fairly easy to deobfuscate. It's always easier said than done though. Let's start by making 3 visitors that pull the array of strings, the shift/push function, and the decoding function b into a vm instance, this is so we can decode the strings in the file.
1var code = ``
2var decryptFuncCtx = vm.createContext();
3var decryptFuncName = ``
4//...
5const getArrayVisitor = {
6 ArrayExpression(path) {
7 code += `${generate(path.parentPath.parentPath.node).code}\n`
8 path.remove()
9 path.stop()
10 }
11}
12
13const getShiftPushVisitor = {
14 ExpressionStatement(path) {
15 let c = generate(path.node).code;
16 if(c.length > 1000) return;
17 code += `${generate(path.node).code}\n`
18 path.remove()
19 path.stop()
20 }
21}
22
23const getDecodeVisitor = {
24 VariableDeclaration(path) {
25 let {node} = path;
26 if(!node.declarations || node.declarations.length !== 1 || !node.declarations[0].init || node.declarations[0].init.type !== 'FunctionExpression') return;
27 code += `${generate(node).code}\n`
28 decryptFuncName = node.declarations[0].id.name;
29 path.remove()
30 path.stop()
31 }
32}
33
34traverse(AST, hexToAsciiVisitor)
35traverse(AST, getArrayVisitor)
36traverse(AST, getShiftPushVisitor)
37traverse(AST, getDecodeVisitor)
38vm.runInContext(code, decryptFuncCtx);
39
40writeFileSync("output.txt", output, 'utf-8')
41writeFileSync("code.js", code, 'utf-8')
42
43const final_code = generate(AST, beautify_opts).code;
44
45fs.writeFileSync('./output/a.js', final_code);Now we can write our visitor that decodes the strings:
1const decodeStringsVisitor = {
2 CallExpression(path) {
3 let {node} = path;
4 if(!node.callee || node.callee.name !== decryptFuncName) return;
5 if(!node.arguments || node.arguments.length !== 2 || node.arguments[0].type !== 'StringLiteral' || node.arguments[1].type !== 'StringLiteral') return;
6 let c = generate(node).code;
7 const value = vm.runInContext(c, decryptFuncCtx)
8 path.replaceWith(t.valueToNode(value))
9 }
10}Now looking at the file, we see that we already have strings decoded. Now we have to deal with this:
1 var c = {
2 "pBQxn": function (f, g) {
3 return f(g);
4 },
5 "XCnkY": "2|4|1|0|3",
6 "XjgOM": function (f, g) {
7 return f + g;
8 },
9 "cRhLP": "Cannot find module '",
10 "vWrdB": function (f, g, h) {
11 return f(g, h);
12 },
13 "vNJaB": function (f, g) {
14 return f == g;
15 },
16 "IwWCf": "function",
17 "uzQXv": function (f, g) {
18 return f == g;
19 },
20 "PJRGw": function (f, g) {
21 return f < g;
22 }
23 };Honestly, this part is the hardest part. Let's go over what we have to do here:
1const replaceObjectExpressionVisitor = {
2 VariableDeclarator(path) {
3 let {node} = path;
4 if(!node.init || node.init.type !== 'ObjectExpression') return;
5 let {properties} = node.init;
6 if(properties.length === 0) return;
7 // validation
8 let valid = true;
9 let invalidSpot = 0;
10 for(var i in properties) {
11 let prop = properties[i]
12 if(!prop || !prop.key || prop.key.type !== 'StringLiteral') {
13 valid = false;
14 invalidSpot = 1;
15 break;
16 }
17 if(prop.value.type == "StringLiteral") continue;
18 if(prop.value.type == "FunctionExpression") {
19 if(prop.value.body.type !== "BlockStatement") {
20 valid = false;
21 invalidSpot = 3;
22 break;
23 }
24 if(prop.value.body.body.length !== 1) {
25 valid = false;
26 invalidSpot = 4;
27 break;
28 }
29 if(prop.value.body.body[0].type !== "ReturnStatement") {
30 valid = false;
31 invalidSpot = 5;
32 break;
33 }
34 }
35 }
36 if(!valid) {
37 output += `INVALID ${invalidSpot}: ${generate(node).code}\n`
38 return;
39 }
40 // log all the objects to output.txt
41 output += `VALID: ${generate(node).code}\n`
42 // replace
43 let props = []
44 for(var i in properties) {
45 let prop = properties[i]
46 props.push({
47 key: prop.key.value,
48 value: prop.value
49 })
50 }
51 // get references to the object
52 let bindings = path.scope.getBinding(node.id.name)
53 if(!bindings) {
54 output += `INVALID 6: ${generate(node).code}\n`
55 return;
56 }
57 for(var i in bindings.referencePaths) {
58 let ref = bindings.referencePaths[i]
59 ref.scope.crawl()
60 let path = ref.parentPath
61 if(path.node.type != "MemberExpression" || path.node.property.type != "StringLiteral") continue;
62 let key = path.node.property.value
63 let value = getProperty(props, key)
64 if(!value) continue;
65 if(value.type == "StringLiteral") {
66 path.replaceWith(value)
67 continue;
68 } else {
69 // function expression here
70 // lets handle function (f, g) {return f + g;} first
71 path = path.parentPath
72 if(!value.body || !value.body.body) continue;
73 if(value.body.body[0].argument.type == "BinaryExpression") {
74 let toReplace = t.binaryExpression(value.body.body[0].argument.operator, path.node.arguments[0], path.node.arguments[1])
75 path.replaceWith(toReplace)
76 continue;
77 }
78 // now handle function (f, g) {return f(g);}
79 let toReplace = t.callExpression(value, path.node.arguments)
80 path.replaceWith(toReplace)
81 continue;
82 }
83 }
84 path.remove()
85 }
86}
87
88function getProperty(props, key) {
89 for(var i in props) {
90 let prop = props[i]
91 if(prop.key === key) return prop.value
92 }
93 return null
94}There's very likley a better way to do this, however, I work by the principle of if it works, it works. After running this visitor, we can see that the code is almost completely deobfuscated. What we can see though is stuff like this:
1var w = "2|4|1|0|3"["split"]("|");
2var x = 0;
3while (!![]) {
4 switch (w[x++]) {
5 case "0":
6 var y = new Error("Cannot find module '" + p"'");
7 continue;
8 case "1":
9 if (l) return function (p, q, u) {
10 return function (f, g, h) {
11 return f(g, h);
12 }(p, q, u);
13 }(l, p, !0);
14 continue;
15 case "2":
16 var z = typeof require == "function" && require;
17 continue;
18 case "3":
19 throw y["code"] = "MODULE_NOT_FOUND", y;
20 continue;
21 case "4":
22 if (!q && z) return function (p, q, u) {
23 return function (f, g, h) {
24 return f(g, h);
25 }(p, q, u);
26 }(z, p, !0);
27 continue;
28 }
29 break;
30}This is called control flow obfuscation, though this is a very simple version of it. I already have code written for this, I'd imagine I got this off of someone else's code on github but I have no clue who, sorry for not giving credit here. If you made this code, contact me and I'll give credit!!
1const controlFlowFlattening = {
2 SwitchStatement(path){
3 if(t.isMemberExpression(path.node.discriminant) &&
4 t.isIdentifier(path.node.discriminant.object) &&
5 t.isUpdateExpression(path.node.discriminant.property) &&
6 path.node.discriminant.property.operator === "++" &&
7 path.node.discriminant.property.prefix === false){
8 let nodesInsideCasesInOrder = [];
9 let mainBlockStatement = path.parentPath.parentPath.parentPath;
10 let whileStatementKey = path.parentPath.parentPath.key;
11 let arrayDeclaration = mainBlockStatement.node.body[0].declarations[0];
12 let casesOrderArray = eval(generate(arrayDeclaration.init).code);
13 let casesInTheirOrderInSwitch = new Map();
14 for(let i = 0; i < path.node.cases.length; i++){
15 casesInTheirOrderInSwitch.set(path.node.cases[i].test.value, i);
16 }
17 for(let i = 0; i < casesOrderArray.length; i++){
18 let currentCase = path.node.cases[casesInTheirOrderInSwitch.get(casesOrderArray[i])];
19 for(let j = 0; j < currentCase.consequent.length; j++){
20 if(!t.isContinueStatement(currentCase.consequent[j]))
21 nodesInsideCasesInOrder.push(currentCase.consequent[j]);
22 }
23 }
24 mainBlockStatement.get('body')[0].remove();
25 path.parentPath.parentPath.replaceWithMultiple(nodesInsideCasesInOrder);
26 }
27 }
28}Now after running this visitor, we can see that the code is looking extremely readable! Now we can start reverse engineering and make a payload decoder!
Reverse Engineering
The first thing I'd like to do is figure out what sv is so we can understand that. Searching sv and matching by full word, we can see this:
1var D = "20220812";
2//...
3aP["sv"] = D;Okay, so it's static. I assume sv stands for __ version, maybe service version since the endpoint requesting to is commonservice.io? It honestly doesn't matter what it means though, aslong as we know how it works, that's what really matters.
Alright, now lets look into how the payload is encrypted, this is the fun part! To find where the payload building is located, I searched validateCaptcha to find:
1this["validateCaptchaApi"] = aA["apiHost"] + aF + "/validateCaptcha";Next, I searched for uses of this validateCaptchaApi, which brought me here:
1aw["prototype"]["doValidate"] = function (aA, aB, aC) {
2 (function (g, h, i, j) {
3 return g(h, i, j);
4 })(z, this["validateCaptchaApi"], aA, {
5 "onSuccess": aB,
6 "onError": aC,
7 "headers": function (g) {
8 return g();
9 }(an)
10 });
11};Next, I looked for the uses of doValidate, which brings me here:
1ay["prototype"]["onCommit"] = function () {
2 //...
3 var aJ = function (g, h, i) {
4 return g(h, i);
5 }(A, JSON["stringify"](aI), aB["captchaInfo"]["ek"]);
6 var aK = ac(aB["config"]["bizId"] + aB["captchaInfo"]["sig"] + aJ + (aB["captchaInfo"]["salt"] || ""));
7 var aL = aB["getApiCommonData"]();
8 aL["sig"] = aB["captchaInfo"]["sig"];
9 aL["data"] = aJ;
10 aL["s"] = aK;
11 aB["doValidate"](aL, aC, aD);
12};Here we see how data is being build, with this A function being called with the ek, aka the encryption key. Let's follow this A function to see what it is:
1var w = function (g, h) {
2 return g(h);
3}(c, 3);
4//...
5var A = w[4];Well, this makes it more annoying. I'm not a web developer at all so I don't really know how this webpack(?) stuff works. I can see that there is 3 functions defined as the input to the webpack function, they're defined as 1, 2, 3. My thought process here is that, since arrays start at 0, perhaps this 3 function is w[4]. Overall, it looks like this is it. I see a lot of math being done and a custom base64 function, in my eyes, this seems like this is it.
My first thought when seeing this, since I'm unsure of the entry point here, would be to follow their custom base64 function to see where it's being used, since we know the payload itself is base64 encoded. Doing that brings us to the end of this function:
1function D(G, H) {
2 var J = 0;
3 if (!G) {
4 return "";
5 }
6 var L = function (G, H) {
7 return function (g, h) {
8 return g(h);
9 }(G, H);
10 }(z, G);
11 var M = "";
12 for (var K = 0; K < L["length"]; K++) {
13 M += function (G, H) {
14 return G(H);
15 }(x, L["charCodeAt"](K) ^ H["charCodeAt"](K % H["length"]));
16 }
17 return function (G, H) {
18 return function (g, h) {
19 return g(h);
20 }(G, H);
21 }(y, M);
22}This function has 2 inputs, of which the first input goes into here:
1var L = function (G, H) {
2 return function (g, h) {
3 return g(h);
4 }(G, H);
5}(z, G);To understand this better, I'll clean this up, since it just seems to be proxy-calling functions for no real reason, I'll also do this to the end of the function, as we can see the same thing there and in the middle of the for loop:
1function D(G, H) {
2 var J = 0;
3 if (!G) {
4 return "";
5 }
6 var L = z(G)
7 var M = "";
8 for (var K = 0; K < L["length"]; K++) {
9 M += x(L["charCodeAt"](K) ^ H["charCodeAt"](K % H["length"]))
10 }
11 return y(M);
12}This is looking much better now, at this point, lets look into the z function:
1function z(G) {
2 function I(Q, R) {
3 return function (G, H) {
4 return function (g, h) {
5 return g(h);
6 }(G, H);
7 }(x, Q >> R & 63 | 128);
8 }
9 function J(Q) {
10 if (Q >= 55296 && Q <= 57343) {
11 throw Error("not a scalar value");
12 }
13 }
14 //..
15 var M = function (G, H) {
16 return function (g, h) {
17 return g(h);
18 }(G, H);
19 }(K, G);
20 var N = -1;
21 var O = "";
22 while (++N < M["length"]) {
23 var P = M[N];
24 O += function (G, H) {
25 return function (g, h) {
26 return g(h);
27 }(G, H);
28 }(L, P);
29 }
30 return O;
31}At first glance, this function seems very large, however you should notice the same proxy-calling seen in the previous function. After cleaning that up, we're left with this:
1function z(G) {
2 function I(Q, R) {
3 return x(Q >> R & 63 | 128);
4 }
5 function J(Q) {
6 if (Q >= 55296 && Q <= 57343) {
7 throw Error("not a scalar value");
8 }
9 }
10 function K(Q) {
11 var S = 0;
12 var V = [];
13 var T = 0;
14 var W = Q["length"];
15 var U;
16 var X;
17 while (T < W) {
18 U = Q["charCodeAt"](T++);
19 if (U >= 55296 && U <= 56319 && T < W) {
20 X = Q["charCodeAt"](T++);
21 T = T + 1;
22 if ((X & 64512) == 56320) {
23 V["push"](((U & 1023) << 10) + (X & 1023) + 65536);
24 } else {
25 V["push"](U);
26 T--;
27 }
28 } else {
29 V["push"](U);
30 }
31 }
32 return V;
33 }
34 function L(Q) {
35 var S = 0;
36 if ((Q & 65408) == 0 & (Q >>> 16 & 65535) == 0) {
37 return x(Q)
38 }
39 var T = "";
40 if ((Q & 63488) == 0 && (Q >>> 16 & 65535) == 0) {
41 T = x(Q >> 6 & 31 | 192)
42 } else if ((Q & 0) == 0 && (Q >>> 16 & 65535) == 0) {
43 J(Q);
44 T = x(Q >> 12 & 15 | 224);
45 T += x(Q >> 6 & 63 | 128);
46 T += I(Q, 6);
47 } else if ((Q & 0) == 0 && (Q >>> 16 & 65504) == 0) {
48 T = x(Q >> 18 & 7 | 240);
49 T += I(Q, 12);
50 T += I(Q, 6);
51 }
52 T += x(Q & 63 | 128);
53 return T;
54 }
55 var M = K(G);
56 var N = -1;
57 var O = "";
58 while (++N < M["length"]) {
59 var P = M[N];
60 O += L(P);
61 }
62 return O;
63}From this, we've figured out that the first argument to that D function is a string, which aligns with what we figured it would be. We can look at the D function and also see that the second input is also a string, likely our encryption key. This D function is almost 100% confirmed to be our encryption function. First, we need to figure out what it's doing to the payload before it's sent to the xor in the D function.
My first thought is to copy this code, put it into a browser, and see what outputs from there. Let's do that now. Trying to run z(JSON.stringify({"hello": "hi", "hi":123, "last one": 123.1})) will return an error of x is not defined. Looking at x, it's var x = p["fromCharCode"];. We can assume p is String since it's using x as a function call. Defining var x = String.fromCharCode then rerunning our code, we see the output '{"hello":"hi","hi":123,"last one":123.1}'... Wait, that's the exact same as our input? So I guess in this case, the function does nothing? We can come back to this later on but for now I'll consider myself blessed.
Now we can go back the D function, knowing what we know now, L is just our stringified payload. We also know x is String.fromChatCode so let's replace that to make D more readable. We also know y is likely just a base64 function, so let's replace that with btoa. Next I'll rename the two inputs to payloadString and key:
1function D(payloadString, key) {
2 if (!payloadString) {
3 return "";
4 }
5 var output = "";
6 for (var i = 0; i < payloadString["length"]; i++) {
7 output += String.fromCharCode(payloadString["charCodeAt"](i) ^ key["charCodeAt"](i % key["length"]))
8 }
9 return btoa(output);
10}Now there's one more thing we have to do, we have to look at where D is used. This was an oversight of mine that I didn't notice until I went to make the decoder and saw the output was not what I expected. So where is D used? It's used in this function:
1function A(G, H) {
2 H = H || "cdababcddcba";
3 function J(M, N, O) {
4 var Q = 0;
5 if (M === "") return "";
6 var Z = ["a", "b", "c", "d", "h", "i", "j", "k", "x", "y"]["join"]("");
7 var a1 = u(M["length"] / N);
8 var a0 = [];
9 for (var R = 0; R < N; R++) {
10 var T = 0;
11 var U = 0;
12 var Y = R * a1;
13 var X = R == N - 1 ? a1 + M["length"] % N : a1;
14 for (var V = 0; V < X; V++) {
15 var W = Y + V;
16 if (W < M["length"]) {
17 U = U + M["charCodeAt"](W);
18 }
19 }
20 U = U * (O || 31);
21 a0["push"](Z["charAt"](U % Z["length"]));
22 }
23 return a0["join"]("");
24 }
25 var K = H["split"]("")["reverse"]()["join"]("");
26 var L = K + J(K, 4);
27 return D(G, L);
28}We know that the first input must be the payload, the second must be the key. This function does nothing to the payload bvut does modify the key, that must be why my decoder was wrong. Now we have all the things we need in order to make our decoder!
Payload Decoder
First, I'm going to define what we need, the encoded payload and the key:
1var encodedPayload = `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`
2var encodingKey = `kyxg`Next, we need the encoded payload's raw bytes, to do this, I wrote this code:
1var encodedPayloadBytes = []
2var encodedPayloadAtob = atob(encodedPayload)
3for(var i = 0; i < encodedPayloadAtob.length; i++) {
4 encodedPayloadBytes.push(encodedPayloadAtob.charCodeAt(i))
5}After that, we need to modify the key, first we need to reverse the key, for this I made a helper function. Then we need to encode the key and conjoin the two:
1encodingKey = reverseString(encodingKey)
2encodingKey = encodingKey + encodeXORKey(encodingKey)
3console.log(encodingKey)
4
5function encodeXORKey(key){
6 var output = [];
7 for(var i = 0; i < 4; i++) {
8 var charPos = 0;
9 var keyPos = 1;
10 if(i === 3) {
11 keyPos = 1 + key.length % 4
12 }
13 for(var x = 0; x < keyPos; x++) {
14 var charCodePos = i + x;
15 if (x < key.length) {
16 charPos = charPos + key.charCodeAt(charCodePos)
17 }
18 }
19 charPos = charPos * 31;
20 output.push("abcdhijkxy".charAt(charPos % 10))
21 }
22 return output.join("")
23}
24
25function reverseString(str) {
26 return str.split("").reverse().join("")
27}Last but not least, we need to decode the payload. For this, I just XOR'd the encoded bytes by the key:
1var output = []
2for(var i = 0; i < encodedPayloadBytes.length; i++) {
3 output.push(encodedPayloadBytes[i] ^ encodingKey.charCodeAt(i % encodingKey.length))
4}
5
6
7// convert output to string
8var outputString = String.fromCharCode(...output)
9console.log(outputString)Now we have our raw payload:
1{
2 "ev": {
3 "wd": 75,
4 "im": 80,
5 "de": "",
6 "prde": "3,57,37,83",
7 "brla": 67,
8 "pl": "Linux i686",
9 "wiinhe": 356,
10 "wiouhe": "356"
11 },
12 "be": {
13 "ec": {
14 "mm": 39,
15 "ts": 1,
16 "tm": 30,
17 "te": 1
18 },
19 "el": [
20 "|mm|107,1|1741652159888|1",
21 "|mm|107,2|5|1",
22 "|mm|108,4|22|1",
23 "|mm|109,5|12|1",
24 "|mm|110,7|19|1",
25 //...
26 "im|mm|131,58|17|1",
27 "im|mm|134,71|19|1",
28 "im|mm|134,84|13|1",
29 "im|mm|134,98|31|1",
30 "im|mm|131,112|15|1",
31 //...
32 "shim|mm|75,220|17|1",
33 "shim|mm|73,226|18|1",
34 "shim|mm|69,237|32|1",
35 "|mm|66,246|34|1",
36 "|mm|64,257|34|1",
37 "sl|mm|62,264|32|1",
38 "slth|mm|57,269|35|1",
39 "slth|mm|54,274|33|1",
40 "slth|mm|50,278|33|1",
41 "|mm|46,280|34|1",
42 "|mm|44,284|32|1",
43 "|mm|42,284|35|1",
44 //...
45 "|tm|91,284|16|1",
46 "|tm|95,284|17|1",
47 "|tm|99,284|16|1",
48 "|tm|103,284|19|1",
49 "|tm|105,284|16|1",
50 "|tm|108,284|19|1",
51 "|tm|110,284|13|1",
52 //...
53 "|te||156|1"
54 ],
55 "th": {
56 "el": [
57 "me|41,6",
58 "mm|41,6",
59 "mm|38,11",
60 "mm|34,15",
61 "mm|30,17",
62 "mm|28,21",
63 "mm|26,21"
64 ],
65 "si": {
66 "w": 44,
67 "h": 44
68 }
69 }
70 },
71 "dist": 82,
72 "imageWidth": "310"
73}Let's go ahead and document this payload a little bit, to figure out what's going on.
Documenting the payload
First, let's start with:
1"ev": {
2 "wd": 75,
3 "im": 80,
4 "de": "",
5 "prde": "3,57,37,83",
6 "brla": 67,
7 "pl": "Linux i686",
8 "wiinhe": 356,
9 "wiouhe": "356"
10 }To find the function responsible for this, I looked for wd in the code and found the place where this payload is built. There's a lot of proxy-calling going on here so I'm going to clean that up before showing the code:
1function J() {
2 function aB() {
3 return parseInt(Math["random"]() * 50) * 2;
4 }
5 function aC() {
6 return parseInt(Math["random"]() * 50) * 2 + 1;
7 }
8 function aD() {
9 var aG = [];
10 var aH = "plugins|mimeTypes|webdriver|languages"["split"]("|");
11 for (var aI = 0; aI < aH["length"]; aI++) {
12 var aJ = m["getOwnPropertyDescriptor"] && m["getOwnPropertyDescriptor"](i, aH[aI]) ? aB() : aC();
13 aG["push"](aJ);
14 }
15 return aG["join"](",");
16 }
17 var aE = aD();
18 function aF() {
19 var aG = "";
20 if (typeof captcha_native != "undefined") {
21 try {
22 aG = captcha_native["geDe"]();
23 } catch (aH) {}
24 } else {}
25 return aG;
26 }
27 return {
28 "wd": B() ? aB() : aC(),
29 "im": I() ? aB() : aC(),
30 "de": aF(),
31 "prde": aE,
32 "brla": i["browserLanguage"] ? aB() : aC(),
33 "pl": i["platform"],
34 "wiinhe": g["innerHeight"],
35 "wiouhe": g["outerHeight"] + ""
36 };
37}So first is wd, we see this calls B, which is y["ivw"] where y is w[2]. I'll look for ivw in the code to find where this function is defined. We see this:
1E["ivw"] = function () {
2 return "$cdc_asdjflasutopfhvcZLmcfl_" in window || !!document["webdriver"] || false;
3};This seems to be a webdriver check. This should theoretically always return false, therefore, only aC should ever be called. What is aC? It's a function that returns a random number from 0-50, then times it by 2, which will make sure it's always an even number, then it will add 1 to make sure it's always a negative number. So what is this really? It's a random negative number generator. Which means the other function named aB is an even number generator. Knowing this, we can look at the payload and know that anywhere that uses those functions, we already know the output. That marks off im and brla. We see im is even and brla is odd, that's all we need to know.
Now let's move onto de, well in the payload, this variable is blank. It looks like the function responsible for it is calling back to captcha_native.geDe(), this function does not exist, infact geDe does not expect at all. I'm unsure what this is really meant to do but I'm going to ignore it for now.
Now prde, this function is responsible for generating it:
1function aD() {
2 var aG = [];
3 var aH = "plugins|mimeTypes|webdriver|languages"["split"]("|");
4 for (var aI = 0; aI < aH["length"]; aI++) {
5 var aJ = m["getOwnPropertyDescriptor"] && m["getOwnPropertyDescriptor"](i, aH[aI]) ? aB() : aC();
6 aG["push"](aJ);
7 }
8 return aG["join"](",");
9}We see that this function is calling aB and aC, looking at the payload, it looks like if we supply 4 negative numbers, it's going to pass.
Now pl, this is just navigator.platform, the last two are window.innerHeight and window.outerheight which are the same, except the second one is turned into a string by doing x + "".
I do not want to fully breakdown me documenting this entire payload, I challenge you the reader to go through and do it yourself. In the repository for this blog post, you will see the documentation though. I hope you enjoyed part 1, in part 2, we will be porting all this code to golang and even making our own solver for the slider, with a mouse activity generator (it won't be passing on big anti-bots).
References
GitHub Repo for this post: https://github.com/obfio/coinmarketcap-captcha-part1 APK Version From UpToDown.com: https://coinmarketcap.en.uptodown.com/android/download/1052325597 SSL Unpinning script: https://codeshare.frida.re/@masbog/frida-android-unpinning-ssl/ Burp Suite Community Edition: https://portswigger.net/burp/communitydownload Android emulator: https://www.mumuplayer.com ADB: https://developer.android.com/studio/releases/platform-tools Frida: https://github.com/frida/frida/releases/tag/16.6.6