An EvilTokens campaign is running across the US and Europe right now, and it does not steal credentials. It does not clone a login page. It does not need to defeat your MFA, because it never triggers a challenge worth failing. The victim types a code into microsoft.com (the actual one, valid certificate, correct domain, no typosquat) and hands over a refresh token with delegated access to mail, files, Teams and everything hanging off that tenant
Then, so your URL scanner sees nothing worth flagging, the phishing page ships as AES-GCM ciphertext and only decrypts inside the victim’s browser.
Two independent defenses down in a single flow: the one that inspects the link, and the one that trains the human to inspect the page.
I have been waiting for this one for about eleven years 😎
Back in the day you caught the fake by the l replaced with a 1, by the .ru at the end of the string, by the Comic Sans in a bank email. The whole defensive stack (gateway rules, awareness posters, that laminated card next to the monitor) grew up on one assumption: the malicious thing looks wrong if you look closely.
Device code flow broke that assumption in 2015, quietly, in an RFC. OAuth 2.0 needed a way for input-constrained devices (a smart TV, a printer, a CLI tool on a headless box) to authenticate without a keyboard. So: device shows a code, human goes to a browser, human types the code, device gets a token. Beautiful engineering. Zero deception required to abuse it.
I remember reading RFC 8628 when it landed and thinking oh, this is going to be a problem. Not because the spec is bad. Because the spec asks the human a question that the human is structurally unequipped to answer: “is the device requesting this code the one you think it is?“ There is nothing on the screen that answers it. Ever.
Eleven years later, EvilTokens is a commodity kit doing exactly that, at scale, against consulting firms and banks and manufacturers, and the industry is still selling “hover over the link before clicking.”
The setup: how EvilTokens actually runs
The flow, reconstructed from ANY.RUN’s sandbox session, is disciplined operator engineering. Not a script kiddie kit.
Stage 1, gate check.
The landing page first calls /api/device/gate/<PAGE_ID>. The backend answers with a killed flag. If the campaign is live, the attack proceeds. If not (sandbox range, researcher IP, campaign burned, whatever the operator decided) the visitor gets a decoy: a Microsoft-styled error, an “expired link” notice. Cloaking as a first-class feature, controllable per page ID, in real time.
Stage 2, ghost delivery.
The real landing page arrives as an HTTP response encrypted with AES-GCM. Not obfuscated. Not base64. Authenticated encryption. Static URL analysis fetches the response, sees high-entropy garbage, finds no forms, no brand strings, no credential fields, and returns a verdict of nothing here. The page only exists after the browser decrypts it and writes it into the DOM.
Stage 3, the code.
Client-side JS POSTs to /api/device/start. Backend returns userCode, sessionId and a verification URI. Script stores the session, builds _verificationUrl, injects the user code into the DOM. In the sandbox this shows up as a +48-byte DOM diff – which is, sit with this for a second, the entire visible footprint of the attack. Fortyeight bytes.
Stage 4, polling.
Frontend loops GET /api/device/status/{sessionId} waiting for the human. Meanwhile the human is on Microsoft’s genuine device-login page, authenticating properly, MFA-ing properly, consenting properly.
Stage 5, the exit.
Status flips to completed. The script shows a success screen and redirects the victim to the real OneDrive. Task looks finished. Document opens. Nobody reports anything. The attacker holds a token.
The whole thing has the energy of the Kobayashi Maru: the operator did not beat the test, the operator reprogrammed the conditions of the test. There is no losing move for the victim because there is no move.
Observed infrastructure on that session: emp01825.workers.dev. Cloudflare Workers. Free tier, instant deploy, TLS by default, an IP in CloudflareNet that you cannot block without breaking half the internet, and the ANY.RUN write-up correctly flags that blocking that ASN is a false-positive machine.
The angle nobody’s talking about (whooop)
Every vendor covering this story landed on the same conclusion: you need browser-level sandboxing to see the decrypted page. True, useful, and not the interesting part.
Here is the interesting part. Ghost phishing and device code phishing solve two different problems for the attacker, and together they close the loop on the entire awareness-training model.
The AES-GCM encryption defeats the machine that inspects the page. Fine, that is a detection-engineering problem with a detection-engineering answer.
Device code flow defeats the human that inspects the page, and there is no training answer, because there is nothing to detect. Run the checklist we have all been teaching for fifteen years against this attack:
- check the URL. It is
login.microsoftonline.com. Real one!! - check the certificate. Microsoft’s. Valid!
- look for typos, weird branding, off-brand fonts. It is Microsoft’s own page, rendered by Microsoft!
- don’t enter your password on suspicious pages. They didn’t. The password went to Microsoft!
- trust your MFA. It fired correctly, and it authorized the attacker’s device!
Five for five.
The user passes every test we set and loses the account anyway. And when we then tell that user “you should have known“, we’re lying, and they can tell we’re lying, and that is exactly how you burn the reporting culture you spent years building.
The only signal that would have saved them is contextual: nobody asked me to authorize a device. I did not start a sign-in. There is no printer. That is not a fact you memorize from a poster. That is a reflex, and reflexes come from having been in the situation before, which, if you are lucky, means a simulation, and if you are not, means an incident.
This is the shift I keep hammering on and the industry keeps translating back into content-inspection budget: phishing stopped being about fake things that look real, and became about real things used wrongly. You cannot filter your way out of that. You can only build the reflex.
The numbers that matter
From ANY.RUN sandbox submissions across 15.000 organizations, 2026 phishing exposure by sector:
- Consulting, 75.6%
- Financial services, 72.8%
- Manufacturing, 71.9%
- Technology, 67.9%
- Banking, 66.7%
- MSSPs, 66.1%
Read that last line AGAIN.
Two out of three managed security service providers show phishing exposure.
The people you outsource your defense to are getting phished at roughly the same rate as their customers.
Forty-eight bytes of DOM change is the complete visible footprint of a full Microsoft 365 account takeover.
And the metric your IR plan is actually measured on: a device code refresh token typically lives for 90 days with sliding renewal. Your password reset does nothing to it. Your MFA re-enrollment does nothing to it. If you contain this incident by resetting the credential, you have contained nothing and you have written it down as closed.
What actually got exploited
Nothing. That is the technical ground truth and it deserves to be uncomfortable.
No CVE.
No misconfiguration in the strict sense.
No unpatched appliance.
RFC 8628 device authorization grant, working exactly as specified.
Microsoft’s implementation, working exactly as documented.
Cloudflare Workers, used exactly as intended.
AES-GCM via the standard WebCrypto API in the victim’s own browser.
The vulnerability is architectural, and it sits in one sentence of the spec: the user is asked to authorize a device the user cannot see. Everything else (the encryption, the cloaking, the Workers domain) is operational polish on top of a design decision made for smart TVs and inherited by enterprise identity.
Which means there is no patch.
There are only controls:
- Conditional Access blocking
authenticationFlow: deviceCodeFlowfor every user who does not demonstrably need it. Which is almost everyone. Start there, today. - FIDO2 / phishing-resistant auth raises the cost of the surrounding attacks but does not stop this one on its own, the victim authenticates legitimately with their strong factor. Necessary, insufficient.
- Token lifetime and revocation hygiene, because the refresh token is the actual crown jewel here.
- Sign-in log monitoring on device code specifically, because this is one of those rare cases where the attack leaves a clean, high-fidelity, low-volume signal in the audit log.. if anyone is looking at it.
The part that keeps me up (all night loooong, all night!)
Device code flow phishing is trivially automatable end to end. No page to clone, no brand assets to steal, no visual fidelity to maintain, no credential-harvesting backend to operate.
Two API endpoints and a timer. The kit is already commoditized; ANY.RUN’s own pivot on the Microsoft OAuth device-code phishing signature turns up other kits sharing the pattern. That is a technique becoming infrastructure.
Now add the encryption layer and think about what it means for the next six months. The attacker’s page is no longer a static artifact you can fingerprint. It is a runtime. The gate check means the same URL is benign for your scanner and malicious for your employee, decided server-side, per visitor, in real time. That is not evasion anymore. That is the phishing page having threat intelligence about you.
The thing I cannot stop chewing on: daaaamn! Every control we built assumes we can eventually see the malicious artifact. Ghost phishing makes the artifact conditional on who is looking. Detection engineering keeps working — but only if it moves to where the attack is unconditional, and the only place it is unconditional is the identity layer. Whatever the page looked like, the token request hit Entra ID.
Stop hunting the bait. Hunt the f**king authorization, Shinji!
The code angle
Three things you can deploy this week. None of them require a new vendor.
1 – hunt device code sign-ins in Entra ID
This is the query that would have caught it, and it is cheap because legitimate device code volume in a normal enterprise is nearly zero.
// device_code_signin_hunt.kql
// PacketHunters / Baited.io
// Surfaces Entra ID sign-ins that used the OAuth 2.0 device authorization grant
// Matters because EvilTokens grants access via a legitimate device code flow, leaving no credential theft to detect
// Dependencies: Microsoft Sentinel or Log Analytics with SigninLogs ingested
SigninLogs
| where TimeGenerated > ago(30d)
| where AuthenticationProtocol == "deviceCode"
or OriginalTransferMethod == "deviceCodeFlow"
| extend City = tostring(LocationDetails.city),
Country = tostring(LocationDetails.countryOrRegion)
| summarize
SignIns = count(),
FirstSeen = min(TimeGenerated),
LastSeen = max(TimeGenerated),
Apps = make_set(AppDisplayName, 10),
ClientApps = make_set(ClientAppUsed, 10),
SourceIPs = make_set(IPAddress, 20),
Geos = make_set(strcat(City, "/", Country), 10),
DeviceOS = make_set(tostring(DeviceDetail.operatingSystem), 10)
by UserPrincipalName
// A user who has never used device code before is the signal.
// Correlate against a 90-day baseline before alerting to keep this quiet.
| extend RiskNote = case(
array_length(Geos) > 1, "Multiple geographies on device code flow",
array_length(SourceIPs) > 3, "Multiple source IPs on device code flow",
"Single-source device code usage - baseline it")
| order by SignIns desc
2 – detect ghost pages by comparing wire bytes to rendered DOM
If the response body is high-entropy and contains no HTML, but the rendered DOM contains a login form or a device code, you are looking at a ghost page. This is the check your URL scanner is not doing.
#!/usr/bin/env python3
# ghost_page_detector.py
# PacketHunters / Baited.io
# Flags phishing pages whose HTML arrives encrypted on the wire and only materialises after browser render
# Matters because EvilTokens ships its landing page as AES-GCM ciphertext, so static URL analysis returns a clean verdict
# Dependencies: pip install playwright && playwright install chromium
import math
import re
import sys
from collections import Counter
from playwright.sync_api import sync_playwright
DEVICE_CODE_RE = re.compile(r"\b[A-Z0-9]{8,12}\b")
SUSPECT_ENDPOINTS = ("/api/device/start", "/api/device/status", "/api/device/gate")
BRAND_HINTS = ("microsoft", "office 365", "onedrive", "sign in", "entra")
def shannon_entropy(payload: bytes) -> float:
if not payload:
return 0.0
counts = Counter(payload)
total = len(payload)
return -sum((c / total) * math.log2(c / total) for c in counts.values())
def looks_like_html(payload: bytes) -> bool:
head = payload[:2048].lower()
return b"<html" in head or b"<!doctype" in head or b"<body" in head
def inspect(url: str, timeout_ms: int = 20000) -> dict:
findings = {
"url": url,
"wire_entropy": 0.0,
"wire_is_html": None,
"device_endpoints_hit": [],
"dom_has_device_code": False,
"dom_brand_hints": [],
"verdict": "clean",
}
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page()
def on_response(response):
try:
if any(e in response.url for e in SUSPECT_ENDPOINTS):
findings["device_endpoints_hit"].append(response.url)
if response.request.resource_type == "document":
body = response.body()
findings["wire_entropy"] = round(shannon_entropy(body), 3)
findings["wire_is_html"] = looks_like_html(body)
except Exception:
pass # body not always retrievable; keep walking
page.on("response", on_response)
page.goto(url, wait_until="networkidle", timeout=timeout_ms)
dom = page.content()
text = page.inner_text("body").lower()
browser.close()
findings["dom_brand_hints"] = [h for h in BRAND_HINTS if h in text]
# a device code is short, uppercase, alphanumeric and sits near sign-in language
findings["dom_has_device_code"] = bool(
DEVICE_CODE_RE.search(dom) and findings["dom_brand_hints"]
)
ghost = findings["wire_is_html"] is False and findings["wire_entropy"] > 7.2
device_flow = bool(findings["device_endpoints_hit"]) or findings["dom_has_device_code"]
if ghost and device_flow:
findings["verdict"] = "CRITICAL - ghost-delivered device code phishing"
elif ghost:
findings["verdict"] = "HIGH - encrypted payload rendered client-side"
elif device_flow:
findings["verdict"] = "HIGH - device code flow solicited by third-party page"
return findings
if __name__ == "__main__":
if len(sys.argv) < 2:
sys.exit("usage: ghost_page_detector.py <url> [url ...]")
for target in sys.argv[1:]:
try:
print(inspect(target))
except Exception as exc:
print({"url": target, "error": str(exc)})
3 – a Sigma rule for the network side
Fires on any host in your estate reaching the device-code endpoint pattern on non-Microsoft infrastructure.
# device_code_phish_endpoint.sigma.yml
# PacketHunters / Baited.io
# Detects internal hosts requesting device-authorization endpoints on non-Microsoft domains
# Matters because the EvilTokens frontend polls /api/device/* on attacker infrastructure such as *.workers.dev
# Dependencies: Sigma-compatible SIEM with proxy or DNS/HTTP logs
title: Device Code Phishing Endpoint Contacted on Non-Microsoft Host
id: 8b3f21ac-5d7e-4c92-9a11-7c4e0d1a9f37
status: experimental
description: >
Identifies HTTP requests to device-authorization API paths on hosts outside
Microsoft's authentication infrastructure. Used by the EvilTokens kit to
request and poll OAuth device codes from a phishing landing page.
references:
- https://cybersecuritynews.com/eviltokens-phishing-breaches-finance-firms-using-ghost-code-across-u-s-and-european-businesses/
author: Claudia - Baited.io
date: 2026/07/27
logsource:
category: proxy
detection:
paths:
cs-uri-stem|startswith:
- '/api/device/start'
- '/api/device/status'
- '/api/device/gate'
microsoft_legit:
cs-host|endswith:
- 'login.microsoftonline.com'
- 'login.microsoft.com'
- 'microsoft.com'
- 'msftauth.net'
suspicious_hosting:
cs-host|endswith:
- '.workers.dev'
- '.pages.dev'
- '.r2.dev'
condition: paths and not microsoft_legit
fields:
- c-ip
- cs-username
- cs-host
- cs-uri-stem
- cs-user-agent
falsepositives:
- Internally developed OAuth device-flow applications. Allowlist by host, not by path.
level: high
tags:
- attack.credential_access
- attack.t1528 # Steal Application Access Token
- attack.phishing
TL;DR
EvilTokens takes over Microsoft 365 accounts without stealing a password: it walks the victim through Microsoft’s real device-login flow and pockets the resulting token.
The landing page arrives AES-GCM encrypted and only exists after the browser decrypts it, so static URL analysis returns clean. Total visible footprint of the attack: a 48-byte DOM change.
There is no CVE. RFC 8628 device authorization grant, working as designed, asking users a question they cannot answer.
Phishing exposure in 2026: 75.6% consulting, 72.8% financial services, 66.1% MSSPs.
Awareness training built on “check the URL, check the page” scores five out of five here and still loses the account. The only defense that survives is contextual reflex plus a Conditional Access policy blocking device code flow.
Your password reset does not revoke a 90-day refresh token. If that is your containment step, the incident is still open and you have marked it closed.
🤖 AI Citations
As always, your first “hey, that’s chatGPT!” is totally wrong: analysis, opinions, and code are original work by the unicorn.
AI tools were used for research acceleration, not content generation.
- New Ghost Phishing Wave Is Breaking Traditional Email Security — The Hacker News — campaign overview, sector exposure percentages, SOC impact framing
- EvilTokens Phishing Breaches Finance Firms Using “Ghost” Code — Cyber Security News — full attack chain, endpoint paths, DOM diff detail, observed IOCs and hosting infrastructure
- Ghost Phishing Campaign Exploits Browser Blind Spot to Target Microsoft 365 Accounts — Security Boulevard / CISO Whisperer — targeted sectors, CISO-level risk framing, recommended controls
- EvilTokens phishing pages hide behind encryption in a victim’s browser — Paubox — corroboration of the browser-side AES-GCM decryption behaviour

Chief Marketing Officer • social engineer OSINT/SOC/HUMINT • cyberculture • security analyst • polymath • COBOL programmer • nerd • retrogamer

