kCaddy: Forging a Malleable Caddy Redirector for Evilginx
kCaddy is a Docker project I built to give red team operators surgical control over HTTP traffic using the Caddy web server. One YAML file defines your entire infrastructure: response headers, IP and User-Agent filtering, path-based routing, reverse proxy profiles, and custom error handling. This post covers the story behind it, how to run it, and a detailed walkthrough of putting kCaddy in front of Evilginx for a hardened phishing deployment. For the full YAML schema reference and every configuration option, check the kCaddy Documentation. History In 2021, together with Devid Lana (her0ness), we published a setup of our Caddy webserver structure with the goal of helping red teamers deploy infrastructure faster. The idea was simple: a replicable Caddy configuration for both C2 and phishing, instead of relying on nginx, Apache, or Traefik, which tend to be more complex by design. We chose Caddy because it was easy to set up, reliable, fast, and handled TLS without friction. I am not saying it is the best web server in the world, but after using it across dozens of deployments, I can say it earns your trust quickly. The Growing Edge: Caddy in Red Team Infrastructure After that article, I started noticing more people adopting Caddy for red team work. More recently, Kuba Gretzky (the creator of Evilginx) has been talking about using Caddy in front of Evilginx as well.Then while studying the MalDev Academy Phishing Course, I noticed that mrd0x used a Caddy structure that turned out to be derived from our original setup. I reached out, and from there I had the opportunity to collaborate with him on a new module demonstrating how to put Caddy in front of Evilginx, something that is painful to set up manually but fundamental for operational security. Some references that pushed the red team community toward Caddy:Taking the Pain Out of C2 Infrastructure by byt3bl33d3r Red Team Infrastructure Wiki by Jeff Dimmock Automating C2 Infrastructure with Terraform, Nebula, Caddy, and Cobalt Strike by Malicious Group Caddy for Phishing by TrustedSec Working with Evilginx on Premises by dan1toWhy I Built kCaddy I used Caddy for every web service in the infrastructure I built during my time as Red Team Manager. When I recently jumped back into red team operations, I found myself reaching for Caddy every single time: device code phishing, MitM with Evilginx Pro, C2 HTTP profiles. The impulse was almost subconscious, partly because I know Caddy well, but also because every deployment shared the same requirements:Granular path control to choose what to accept or reject IP and User-Agent filtering with whitelisting during testing and blacklisting in production A reverse proxy layer that hides the original web server Fake infrastructure fingerprints: custom server headers, overridden error pages (even reverse-proxied ones) with default Apache or Nginx HTMLYou will not see automatic TLS certificate provisioning in this setup, because I believe Caddy should always sit behind a CDN like Cloudflare or CloudFront. Caddy can be fingerprinted via JA4 signatures when exposed directly, though its built-in ACME support is a powerful feature when you do need it. Every time I deployed, I was manually copying the structure we made years ago and applying small customizations with cut, copy, and paste. I initially built a custom Docker image where I just replaced files inside the sites-enabled folder, but I decided to push the idea beyond the edge. So I forged kCaddy: a highly malleable Caddy Docker image governed by a single YAML configuration file. You design your entire HTTP profile with reusable helpers (headers, filters, matchers, reverse proxy profiles) that the builder compiles into a valid Caddyfile. And for anything kCaddy does not cover natively, every object accepts a custom_config attribute where you can drop raw Caddyfile directives directly, so the tool never becomes a limitation.A quick disclaimer: kCaddy is not the easiest tool on earth, and you are probably wondering why you should use a YAML config when you could write a Caddyfile directly. You are right, and I get it. But once you get your hands dirty with kCaddy, the full power shows itself. My plan is to publish as many working examples as possible, so people can start from a proven base and iterate on it.Running kCaddy I will not go through every configuration option here; the kCaddy Documentation covers the full YAML schema in detail. This section gets you from clone to running. Before you run kCaddy, install Docker and Task. git clone https://github.com/knifesec/kcaddy.git cd kcaddyCopy the example configuration files: cp config/kcaddy.yaml.example config/kcaddy.yml cp docker/docker-compose.yml.example docker/docker-compose.ymlThen build and run: cd task/ task build task copyconfig task upThis brings up a single Docker service called kcaddy with ports 80 and 443 exposed to the host. All runtime state (configuration, generated Caddyfiles, TLS storage, logs, assets, static content) lives in one named Docker volume called kcaddy_runtime, mounted at /etc/caddy inside the container. The environment variable KCADDY_ROOT=/etc/caddy drives all path resolution. And to confirm that kCaddy is operating correctly try to open http://localhost and you should see this:Remember to enable the sites you want to use via enable: true otherwise only the Caddyfile will be loaded. The docker-compose.yml includes an extra_hosts entry that maps evilginx-site to host-gateway. This means the container resolves that hostname to the Docker host's IP address, so Evilginx running on the same machine is reachable from inside the kCaddy container without hardcoding IPs. For this blog post, I am running Evilginx community edition on the same machine, listening on port 8443 (see Configuring Evilginx for details). The same approach works for Evilginx Pro on a cloud instance; the only difference is that you would replace host-gateway with the actual IP of the Evilginx host, or use Docker networking if both services run on the same server. You can customize error pages, whitelists, and blacklists for both IPs and User-Agents by editing the files inside the data/assets/ directory. The task/Taskfile.yml is designed to let you run the project without any customization, from both Windows and Linux. The safest way to push a new configuration while the redirector is live is the reload command. It validates kcaddy.yaml first, rebuilds only if the config hash changed, formats the generated .caddy files, and sends a hot reload to Caddy, all without restarting the container. If validation or the Caddy reload fails, the previous configuration keeps running. task reloadThis is an example of a failed task reload:Use Case: kCaddy in Front of Evilginx This is where kCaddy shows its value. I will walk through an advanced HTTP profile for managing and hiding Evilginx (both community and Pro editions) using kCaddy. This post is the first in a series of use case scenarios I will publish on kCaddy. The Basic Reverse Proxy Let me start with the simple approach, the way most people would reverse proxy Evilginx using kCaddy: helpers: reverse_proxy: evilginx_upstream: uri: https://evilginx-site:8443 transport: mode: http tls_insecure_skip_verify: true tls_server_name: "{host}" header_up: set: - "Host: {host}" strip: - "X-*"sites: - sitename: evilginx-site enable: true domains: - "academy.evilginx-example.site" http: http: true http_port: 80 https: true https_port: 443 tls: internal encode: "zstd gzip" handle: - mode: none reverse_proxy_template: evilginx_upstreamThis accepts both HTTP and HTTPS and forwards all traffic to the Evilginx endpoint. It works, but it gives you no control over who accesses what, no header hardening, no error interception. This blog post is built different. TLS and Host Header Handling The most important piece for reverse proxying to Evilginx (community or Pro) is the transport configuration: transport: mode: http tls_insecure_skip_verify: true tls_server_name: "{host}" header_up: set: - "Host: {host}" strip: - "X-*"The tls_server_name: "{host}" directive overrides the TLS Server Name during the handshake, using the hostname from the incoming request. Both Evilginx and Evilginx Pro check this name before accepting the connection. The "Host: {host}" header sends the matching host header to the upstream. This is fundamental for the community edition; the Pro version handles routing through TLS name alone, but setting both is good practice regardless. Even if your phishlet uses 30 different subdomains, this single site configuration handles them all dynamically without any additional entries. The strip attribute removes all upstream headers starting with X-. This prevents Caddy's automatically added reverse proxy headers from reaching Evilginx. Some phishlets break when they receive unexpected X-Forwarded-* or X-Cache headers, because the proxied website interprets them and changes its behavior. You can add custom headers alongside the strip rule. For example, "X-Real-IP: {remote_host}" passes the client IP to Evilginx for its anti-bot system. Just remember that stripping has priority: if you strip X-*, then X-Real-IP will not be passed either. In that case, strip individual headers like X-Forwarded-For, X-Forwarded-Proto, and Via instead of using the wildcard. Response Header Hardening One of the most effective ways to make your phishing infrastructure look like a legitimate web application is to override response headers. kCaddy handles this through header profiles defined in helpers: helpers: headers: nginx_default: set: - "Server: nginx/1.18.0 (Ubuntu)" - "X-Frame-Options: SAMEORIGIN" - "X-Content-Type-Options: nosniff" - "X-XSS-Protection: 1; mode=block" - "Referrer-Policy: strict-origin-when-cross-origin" - "Connection: keep-alive" - "Accept-Ranges: bytes" strip: - "X-Forwarded*" - "X-Real-IP" - "X-Client-IP" - "Via*" - "ETag*" defer: trueThis profile replaces Caddy's default server header with one that mimics nginx, strips all revealing X-* headers, and adds standard security headers that any legitimate web app would have. The defer: true keyword ensures headers are applied late in the handler chain, after the response has been processed. kCaddy ships with multiple built-in profiles: nginx_default, apache_default, extreme (strips all response headers), and hardened (strict security headers including HSTS and Permissions-Policy). You reference them by name wherever headers need to be applied: sites: - sitename: evilginx-site headers_template: nginx_defaultThis ensures that no matter what Evilginx or the proxied website returns, the final HTTP response always carries the headers you defined. Response headers can also be used to match JA4H fingerprints of common web servers, making the infrastructure look even more legitimate to automated scanners. You can also strip all response headers using "*" in the strip attribute with the extreme profile. This is not recommended for production, but it can be useful during testing to isolate what the upstream actually sends before you decide which headers to keep. Matchers, Filters, and Handle Directives Before diving into the granular lure path logic, let me introduce the three building blocks that make it possible. Filters let you block or allow traffic based on IP addresses or User-Agent strings: helpers: filters: ip_blacklist: type: blacklist kind: ip list: ["192.168.1.5", "10.0.0.0/24"] ua_blacklist: type: blacklist kind: ua list: ["curl", "python-requests"]Filters are activated by referencing them in filters_templates on the site or Caddyfile block. They are evaluated in the order listed, so if you need a whitelist checked before a blacklist, list it first.Currently, when you hit kCaddy using a banned IP or User-Agent, the connection is simply aborted.If you are setting up kCaddy behind a CDN or another reverse proxy, keep in mind that you have to change or add the source of the incoming IP: ```yaml helpers: filters: ip_whitelist: type: whitelist kind: ip source: [remote_ip, client_ip] # OR semantics: allow if either matches list list: ["192.168.1.0/24", "2001:db8::/32"] ```Matchers are named conditions that can match paths, query strings, headers, cookies, status codes, and more. They are defined in helpers and imported via matchers_templates: helpers: matchers: evilginx_lure_access_qs: - path: /access* - expression: "{query.utm_source}.matches('^(k9m2wx|b4v7rt|j1n5pz)$')" evilginx_lure_access_path: - path: /access*Handle directives are how kCaddy routes requests. There are three modes:The default handle {} in Caddy. Matches requests and processes them in order. handle: - mode: none matcher: "@evilginx_lure_access_qs" rewrites: - matcher: "*" rewrite_to: "{path}?" reverse_proxy_template: evilginx_upstreamThe handle_path {} in Caddy. Strips the matched path prefix before processing. handle: - mode: path matcher: "/api" reverse_proxy_template: api_reverse_proxyThe handle_errors {} in Caddy. Intercepts application errors for custom responses. handle: - mode: errors headers_template: nginx_default rewrites: - matcher: "*" rewrite_to: /404-nginx.htmlThe handle_response directive in both Caddy and kCaddy is an attribute of reverse_proxy, not a handle directive. It intercepts responses from the upstream before they reach the client. Matcher imports need to match their context. Path-based matchers belong in site or handle blocks. Status-code matchers (like all_4xx_errors) belong inside reverse proxy objects only. Importing a status matcher at the site level will cause a Caddy error. This is a Caddy limitation. Evilginx Campaign Setup Let me analyze what a common Evilginx campaign looks like and where kCaddy can add control:You configure your domain in the phishlet (in this example: evilginx-example.site) The lure has a subdomain and a path you can modify (in this example: /access) After hitting the lure, every HTTP request carrying Evilginx cookies gets proxied to the target site The lure URL is accessible to anyone who has the correct linkThis default behavior is not terrible, but we can do much better. If you configured the DNS with a wildcard record, you need to intercept every possible subdomain and decide what to do when the request does not match. To track and block unwanted bot interactions, you can add a variable query string parameter to the lure, then strip it before forwarding to Evilginx. The idea: add custom GET parameters (e.g. utm_source) to the lure URL. This serves double duty as a per-target tracking token and an access gate. Analysis bots tend to modify or strip GET parameters, and if a blue teamer figures out that the lure path is /access but does not know the correct utm_source value, they cannot trigger the real phishing page. But there is a problem: Evilginx PRO does not accept query parameters that are not defined in the lure configuration. It throws an error and refuses to process the request. So kCaddy intercepts the request first, validates the query string, strips it via a rewrite, and only then proxies the clean request to Evilginx.Granular Lure Path Control Here are the matchers that implement the query string gate: helpers: matchers: evilginx_lure_access_qs: - path: /access* - expression: "{query.utm_source}.matches('^(k9m2wx|b4v7rt|j1n5pz|q8s3hc|f2l9gm|x5y1kd|v7b4nj|m0w3rs|p6t8fq|h4z2lv|c9d1nx|g5s7bt|r3j6kw|xxxxx|yyyyy)$')" evilginx_lure_access_path: - path: /access*The first matcher (evilginx_lure_access_qs) matches requests to /access that carry a utm_source parameter with one of the predefined token values. Both conditions (path AND query string) must be true for the match. The second matcher (evilginx_lure_access_path) matches any request to /access regardless of parameters. The handle section uses these matchers in order: handle: - mode: none matcher: "@evilginx_lure_access_qs" rewrites: - matcher: "*" rewrite_to: "{path}?" reverse_proxy_template: evilginx_upstream - mode: none matcher: "@evilginx_lure_access_path" error: status: 404The first handle intercepts the lure path with valid query parameters. The rewrite "{path}?" strips the query string entirely, then the clean request is forwarded to Evilginx. The second handle catches all other requests to /access (without valid parameters, or with tampered ones) and returns a 404. Cookie-Based Session Detection After a victim successfully accesses the lure with the correct utm_source value, Evilginx sets a session cookie. All subsequent requests from that victim (loading resources, submitting credentials, navigating the phished site) carry this cookie. We can use this to restrict access even further: only requests carrying the Evilginx session cookie should reach the backend. helpers: matchers: evilginx_auth_cookie: - header: Cookie: "*2a8f-9bbd=*"This matcher checks if the request carries an Evilginx session cookie. The header clause accepts glob patterns, so "*2a8f-9bbd=*" matches any Cookie header containing that session identifier. With this matcher, the complete handle section becomes: handle: - mode: none matcher: "@evilginx_lure_access_qs" rewrites: - matcher: "*" rewrite_to: "{path}?" reverse_proxy_template: evilginx_upstream - mode: none matcher: "@evilginx_lure_access_path" error: status: 404 - mode: none matcher: "@evilginx_auth_cookie" reverse_proxy_template: evilginx_upstream - mode: none matcher: "*" error: status: 404The logic is now: if the request hits the lure path with valid parameters, strip the query string and proxy to Evilginx. If it hits the lure path without valid parameters, block it. If it carries the Evilginx session cookie (an authenticated victim navigating the phished site), let it through. Everything else gets a 404. A blue teamer, a bot, a defender, or an anti-spam scanner that does not carry the correct cookie will never reach the Evilginx backend.The Evilginx session cookie name changes every time you restart the process. You need to test the lure path after each restart to discover the new cookie name. Here is an example request and response:GET /access?utm_source=yyyyy HTTP/2 Host: academy.evilginx-example.site User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36 Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8Response: Set-Cookie: 2a8f-9bbd=89b77233fd472c461151a0a762deccb1ffd3d075f049ff8967879df52786aebb; Path=/;Use the cookie name from the Set-Cookie header in your evilginx_auth_cookie matcher.The important part here is that Evilginx does not require any special configuration for this. kCaddy handles the entire access control layer independently. Error Interception and Custom Pages Even with precise handle directives, error responses can leak information. If Evilginx returns a 404 or 502, the default error page might reveal the Caddy server identity or the upstream framework. kCaddy addresses this at two levels. First, the reverse proxy's handle_response intercepts errors from the upstream: helpers: reverse_proxy: evilginx_upstream: handle_response: matcher: "@all_errors" error: status: 404The all_errors matcher catches all 4xx and 5xx status codes from Evilginx and converts them into a Caddy error with status 404. This error is then caught by the site's error handler: handle: - mode: errors headers_template: nginx_default rewrites: - matcher: "*" rewrite_to: /404-nginx.html root: matcher: "*" path: /etc/caddy/www/static-defaults file_server: trueThis serves a static nginx-branded 404 page from the static-defaults folder, with the same hardened response headers applied. The result: every error, whether from Caddy itself, from kCaddy's access control, or from the upstream, looks identical. A scanner sees the same nginx 404 page no matter what it tries. Note that headers_template is repeated inside the error handler. This is a Caddy behavior: the headers from the site scope are not automatically inherited by error handlers, so you need to import them explicitly. Status-code matchers like all_4xx_errors and all_errors must be imported inside the reverse proxy object, not at the site level. This is why the reverse proxy definition includes its own matchers_templates: helpers: reverse_proxy: evilginx_upstream: matchers_templates: - all_4xx_errors - all_errorsYou can also import all available matchers with a single glob: matchers_templates: "*". Be careful though: if any matcher uses a clause type that is not compatible with the reverse proxy context (like a path matcher), Caddy will throw an error. In that case, use a more targeted glob like "*errors*".If kCaddy does not cover a specific Caddyfile directive you need, every major object supports custom_config where you can write raw Caddy syntax directly (example below).reverse_proxy: evilginx_upstream: custom_config: | transport http { tls_insecure_skip_verify tls_server_name {host} }This way kCaddy never limits what you can configure. Reverse Proxy as a Reusable Object One design choice worth highlighting: reverse proxy profiles live in helpers.reverse_proxy as reusable snippets referenced by name via reverse_proxy_template. In the configuration above, three different handles reference evilginx_upstream. Without a reusable snippet, you would define the same reverse proxy (with transport, headers, and error handling) three times. With reverse_proxy_template, each handle is a single line: reverse_proxy_template: evilginx_upstreamThis keeps the handle section focused on routing logic, with the transport and header details defined once in a central place. The Full Configuration Here is the complete kcaddy.yml for the Evilginx scenario described in this post.# kCaddy Configuration for Evilginx Scenario force_rebuild: truehelpers: headers: apache_default: set: - "Server: Apache/2.4.41 (Unix)" - "X-Powered-By: PHP/7.4.33" - "X-Frame-Options: SAMEORIGIN" - "X-Content-Type-Options: nosniff" - "X-XSS-Protection: 1; mode=block" - "Referrer-Policy: strict-origin-when-cross-origin" - "Pragma: no-cache" - "Cache-Control: no-store, no-cache, must-revalidate, max-age=0" - "Expires: 0" - "Connection: keep-alive" - "Accept-Ranges: bytes" strip: - X-Runtime - X-Version - X-AspNet-Version - Via - X-Cache - X-Cache-Hits - ETag defer: true nginx_default: set: - "Server: nginx/1.18.0 (Ubuntu)" - "X-Frame-Options: SAMEORIGIN" - "X-Content-Type-Options: nosniff" - "X-XSS-Protection: 1; mode=block" - "Referrer-Policy: strict-origin-when-cross-origin" - "Connection: keep-alive" - "Accept-Ranges: bytes" strip: - "X-*" - "Via*" - "ETag*" defer: true extreme: set: - "Server: nginx/1.18.0 (Ubuntu)" - "X-Frame-Options: SAMEORIGIN" - "X-Content-Type-Options: nosniff" - "X-XSS-Protection: 1; mode=block" - "Referrer-Policy: strict-origin-when-cross-origin" - "Connection: keep-alive" - "Accept-Ranges: bytes" strip: - "*" defer: true hardened: set: - "Server: Apache/2.4.41 (Unix)" - "X-Frame-Options: DENY" - "X-Content-Type-Options: nosniff" - "X-XSS-Protection: 1; mode=block" - "Referrer-Policy: no-referrer" - "Permissions-Policy: geolocation=(), microphone=(), camera=()" - "Strict-Transport-Security: max-age=31536000; includeSubDomains; preload" - "Pragma: no-cache" - "Cache-Control: no-store, no-cache, must-revalidate, max-age=0" - "Expires: 0" strip: - X-Powered-By - X-Runtime - X-Version - X-AspNet-Version - Via - X-Cache - X-Cache-Hits - ETag defer: true filters: ip_whitelist: type: whitelist kind: ip list: ["192.168.1.0/24", "2001:db8::/32"] ip_blacklist: type: blacklist kind: ip list: ["192.168.1.5", "10.0.0.0/24"] ua_blacklist: type: blacklist kind: ua list: ["curl", "python-requests"] matchers: all_4xx_errors: - status: "4xx" all_errors: - status: "4xx 5xx" evilginx_lure_access_qs: - path: /access* - expression: "{query.utm_source}.matches('^(k9m2wx|b4v7rt|j1n5pz|q8s3hc|f2l9gm|x5y1kd|v7b4nj|m0w3rs|p6t8fq|h4z2lv|c9d1nx|g5s7bt|r3j6kw|xxxxx|yyyyy)$')" evilginx_lure_access_path: - path: /access* evilginx_auth_cookie: - header: Cookie: "*2a8f-9bbd=*" reverse_proxy: evilginx_upstream: uri: https://evilginx-site:8443 matchers_templates: - all_4xx_errors - all_errors transport: mode: http tls_insecure_skip_verify: true tls_server_name: "{host}" header_up: set: - "Host: {host}" - "X-Real-IP: {remote_host}" strip: - "X-Forwarded-*" - "Via*" handle_response: matcher: "@all_errors" error: status: 404Caddyfile: import_folders: - sites-enabled http: http: true https: true http_port: 80 https_port: 443 respond: body: "OK" code: 200 log: output: /etc/caddy/logs/access.log encode: "zstd gzip" tls: internal headers_template: nginx_default filters_templates: ["ip_blacklist", "ua_blacklist"] auto_https: "disable_redirects"sites: - sitename: evilginx-site enable: true domains: - "*.evilginx-example.site" - "evilginx-example.site" group: evilginx-group log: auto: true http: http: true http_port: 80 https: true https_port: 443 tls: internal encode: "zstd gzip" headers_template: nginx_default filters_templates: ["ip_blacklist", "ua_blacklist"] matchers_templates: - "evilginx*" handle: - mode: none matcher: "@evilginx_lure_access_qs" rewrites: - matcher: "*" rewrite_to: "{path}?" reverse_proxy_template: evilginx_upstream - mode: none matcher: "@evilginx_lure_access_path" error: status: 404 - mode: none matcher: "@evilginx_auth_cookie" reverse_proxy_template: evilginx_upstream - mode: none matcher: "*" error: status: 404 - mode: errors headers_template: nginx_default rewrites: - matcher: "*" rewrite_to: /404-nginx.html root: matcher: "*" path: /etc/caddy/www/static-defaults file_server: trueThis configuration is complex, but it gives you granular control over every aspect of the HTTP space. I truly believe that much of the success of a phishing campaign with a tool like Evilginx is not about Evilginx itself, but about its surrounding infrastructure.Request Flow Summary With the full configuration in place, here is how every request is handled:Any request with a blocked IP or User-Agent will be aborted https://random.evilginx-example.site/ : no cookie, no lure path → 404 (custom nginx error page) https://academy.evilginx-example.site/ : no cookie → 404 (custom nginx error page) https://academy.evilginx-example.site/access : lure path, no valid parameters → 404 https://academy.evilginx-example.site/access?random=1234 : lure path, wrong parameters → 404 https://academy.evilginx-example.site/access?utm_source=yyyyy : valid lure → query stripped → proxied to Evilginx → victim lands on phishing page, session cookie is set https://academy.evilginx-example.site/<any_evilginx_path> : victim has session cookie → proxied to Evilginx → normal phishing flow continues https://academy.evilginx-example.site/<any_path> : scanner without cookie → 404Every blocked request returns the same nginx-branded 404 page with hardened response headers. A blue teamer, a bot, or a scanner sees the exact same response regardless of what path or method they try. Only a victim who followed the intended flow (correct lure URL with valid token, then continued with the session cookie) reaches Evilginx. Use Case: kCaddy in Front of Okta, Google, and Microsoft 365 Phishlets Let me sharpen the baseline into something closer to what you will face in the real world. In this section I will only use Evilginx Pro against Okta, Google, and Microsoft 365. The phishlets I use are the ones shipped with Evilginx Pro: they are not public, so I cannot share the phishlet files themselves, but the domains they expose are not a secret, which is why I can include them in the matchers below.Keep in mind that you need to update the cookie value for every phishlet. The Evilginx Pro session cookie changes every time you restart the tool or toggle a phishlet on or off.Microsoft 365 The Microsoft 365 phishlet is probably the most common in phishing engagements. It exposes several subdomains: the login flow (login.*, www.*, the apex domain), the SSO and M365 entry points (sso.*, m365.*), and a handful of CDN hosts (cdn-1.*, cdn-2.*, cdn-3.*, cdn-4.*), plus a secondary events.* subdomain. The baseline structure is almost identical to the Evilginx example above, but I had to tweak the matchers to cover every host and guarantee the phishlet works end to end. The big change here (and in all the other examples in this section) is that there are multiple domains to handle, and some of them are requested without the Evilginx cookie, so protecting them is trickier but possible. First, let's look at the new matchers for the Microsoft 365 phishlet: evilginx_o365_lure_access_qs: - host: login.evilginx-o365.site www.evilginx-o365.site evilginx-o365.site - path: /access* - expression: "{query.utm_source}.matches('^(k9m2wx|b4v7rt|j1n5pz|q8s3hc|f2l9gm|x5y1kd|v7b4nj|m0w3rs|p6t8fq|h4z2lv|c9d1nx|g5s7bt|r3j6kw|xxxxx|yyyyy)$')" evilginx_o365_lure_access_path: - host: login.evilginx-o365.site www.evilginx-o365.site evilginx-o365.site - path: /access* evilginx_o365_auth_cookie: - host: login.evilginx-o365.site www.evilginx-o365.site evilginx-o365.site - header: Cookie: "*sailormansteamstrife=*" evilginx_o365_cdn_content: - host: cdn-1.evilginx-o365.site cdn-2.evilginx-o365.site cdn-3.evilginx-o365.site cdn-4.evilginx-o365.site sso.evilginx-o365.site m365.evilginx-o365.site events.evilginx-o365.siteThe setup is almost identical to the basic Evilginx example, with one important addition: the evilginx_o365_cdn_content matcher intercepts requests made to CDN endpoints and other secondary hosts. These are mostly served without the Evilginx cookie, and they live outside the /access lure path, so we need a dedicated matcher to let them through.Avoid using `/login` as the lure path: Microsoft uses it too and it will cause conflicts. Pick anything else (`/access`, `/portal`, whatever fits your pretext), but keep in mind that `/login` specifically is a known limitation in this setup.Then we configure the site pretty much like the basic Evilginx one, referencing the new matchers in the handles: - sitename: evilginx-o365 enable: true domains: - "*.evilginx-o365.site" - "evilginx-o365.site" group: evilginx-o365-group log: auto: true http: http: true http_port: 80 https: true https_port: 443 tls: internal encode: "zstd gzip" headers_template: nginx_default filters_templates: ["ip_blacklist", "ua_blacklist"] matchers_templates: - "evilginx*" handle: - mode: none matcher: "@evilginx_o365_lure_access_qs" rewrites: - matcher: "*" rewrite_to: "{path}?" reverse_proxy_template: evilginx_upstream - mode: none matcher: "@evilginx_o365_lure_access_path" error: status: 404 - mode: none matcher: "@evilginx_o365_auth_cookie" reverse_proxy_template: evilginx_upstream - mode: none matcher: "@evilginx_o365_cdn_content" reverse_proxy_template: evilginx_upstream - mode: none matcher: "*" error: status: 404 - mode: errors headers_template: nginx_default rewrites: - matcher: "*" rewrite_to: /404-nginx.html root: matcher: "*" path: /etc/caddy/www/static-defaults file_server: trueGoogle The Google phishlet (also shipped with Evilginx Pro and not publicly distributed) is a little trickier because Google has a lot of security mechanisms to bypass, but the kCaddy configuration stays very similar. The phishlet exposes the authentication entry point on accounts.* and the apex domain, plus a long list of secondary hosts used for APIs, static assets, webmail, and other Google services (apis.*, gstatic.*, ssl.*, content.*, signaler-pa.*, mail.*, ogs.*, contacts.*, clients6.*, hangouts.*, notifications.*, myaccount.*, youtube.*, play.*). These domains are not a secret, so I can include them in the matchers, but the phishlet itself remains private. Here are the new matchers: evilginx_google_lure_access_qs: - host: accounts.evilginx-google.site evilginx-google.site - path: /access* - expression: "{query.utm_source}.matches('^(k9m2wx|b4v7rt|j1n5pz|q8s3hc|f2l9gm|x5y1kd|v7b4nj|m0w3rs|p6t8fq|h4z2lv|c9d1nx|g5s7bt|r3j6kw|xxxxx|yyyyy)$')" evilginx_google_lure_access_path: - host: accounts.evilginx-google.site evilginx-google.site - path: /access* evilginx_google_auth_cookie: - host: accounts.evilginx-google.site evilginx-google.site - header: Cookie: "*sixtymidwaydiadem=*" evilginx_google_cdn_content: - host: apis.evilginx-google.site gstatic.evilginx-google.site ssl.evilginx-google.site content.evilginx-google.site signaler-pa.evilginx-google.site mail.evilginx-google.site ogs.evilginx-google.site contacts.evilginx-google.site clients6.evilginx-google.site hangouts.evilginx-google.site notifications.evilginx-google.site myaccount.evilginx-google.site youtube.evilginx-google.site play.evilginx-google.siteThe concept stays the same: I introduced evilginx_google_cdn_content to proxy secondary content without requiring the Evilginx cookie. Then we reference the new matchers in the handles: - sitename: evilginx-google enable: true domains: - "*.evilginx-google.site" - "evilginx-google.site" group: evilginx-google-group log: auto: true http: http: true http_port: 80 https: true https_port: 443 tls: internal encode: "zstd gzip" headers_template: nginx_default filters_templates: ["ip_blacklist", "ua_blacklist"] matchers_templates: - "evilginx*" handle: - mode: none matcher: "@evilginx_google_lure_access_qs" rewrites: - matcher: "*" rewrite_to: "{path}?" reverse_proxy_template: evilginx_upstream - mode: none matcher: "@evilginx_google_lure_access_path" error: status: 404 - mode: none matcher: "@evilginx_google_auth_cookie" reverse_proxy_template: evilginx_upstream - mode: none matcher: "@evilginx_google_cdn_content" reverse_proxy_template: evilginx_upstream - mode: none matcher: "*" error: status: 404 - mode: errors headers_template: nginx_default rewrites: - matcher: "*" rewrite_to: /404-nginx.html root: matcher: "*" path: /etc/caddy/www/static-defaults file_server: trueOkta Okta is another tricky one: the Okta phishlet (shipped with Evilginx Pro only) requires you to generate it from a template before use: phishlets create breakdev/okta test1 cdnsubdomain=ok14static subdomain=trial-12345The cdnsubdomain value is currently okstatic, but it may change in the future, so keep an eye on it. The subdomain is the one tied to your Okta trial or the customer environment you are targeting. The phishlet therefore exposes two hosts you need to cover: the tenant subdomain (trial-* or the customer tenant) on the apex, and the CDN subdomain (okstatic.*).Update the matchers to reflect the subdomain you configured in the phishlet.I'm not a fan of this phishlet's design because appending an -okta suffix exposes the customer's subdomain in the FQDN. I reached out to Kuba privately about this, and I hope he updates the phishlet with the small tweak I suggested. My proposal was to configure three variables in the template instead of two: the customer subdomain used by the proxy, the phishing domain, and the CDN. Feel free to try this approach yourself, or reach out to me directly in the Evilginx Pro Discord channel.Here are the matchers created for this setup: evilginx_okta_lure_access_qs: - host: trial-2293322-okta.evilginx-okta.site evilginx-okta.site - path: /okta-login* - expression: "{query.utm_source}.matches('^(k9m2wx|b4v7rt|j1n5pz|q8s3hc|f2l9gm|x5y1kd|v7b4nj|m0w3rs|p6t8fq|h4z2lv|c9d1nx|g5s7bt|r3j6kw|xxxxx|yyyyy)$')" evilginx_okta_lure_access_path_and_cookie: - host: trial-2293322-okta.evilginx-okta.site evilginx-okta.site - path: /okta-login* - header: Cookie: "*sinkglenplover=*" evilginx_okta_lure_access_path: - host: trial-2293322-okta.evilginx-okta.site evilginx-okta.site - path: /okta-login* evilginx_okta_auth_cookie: - host: trial-2293322-okta.evilginx-okta.site evilginx-okta.site - header: Cookie: "*sinkglenplover=*" evilginx_okta_cdn_content: - host: okstatic.evilginx-okta.siteAnd here is the site with its handles. Two additions stand out compared to O365 and Google. First, the /.well-known/* handle, which is crucial: a specific Okta request hits this path without the session cookie and would otherwise be blocked by the catchall. Second, the evilginx_okta_lure_access_path_and_cookie matcher: I observed a request on the lure path that was not part of the initial authorized flow, carrying the session cookie but no query string. That combination (lure path + cookie, no QS) needs its own handle to pass through to Evilginx. - sitename: evilginx-okta enable: true domains: - "*.evilginx-okta.site" - "evilginx-okta.site" group: evilginx-okta-group log: auto: true http: http: true http_port: 80 https: true https_port: 443 tls: internal encode: "zstd gzip" headers_template: nginx_default filters_templates: ["ip_blacklist", "ua_blacklist"] matchers_templates: - "evilginx*" handle: - mode: none matcher: "@evilginx_okta_lure_access_path_and_cookie" reverse_proxy_template: evilginx_upstream - mode: none matcher: "@evilginx_okta_lure_access_qs" rewrites: - matcher: "*" rewrite_to: "{path}?" reverse_proxy_template: evilginx_upstream - mode: none matcher: "@evilginx_okta_lure_access_path" error: status: 404 - mode: none matcher: "@evilginx_okta_auth_cookie" reverse_proxy_template: evilginx_upstream - mode: none matcher: "@evilginx_okta_cdn_content" reverse_proxy_template: evilginx_upstream - mode: none matcher: "/.well-known/*" reverse_proxy_template: evilginx_upstream - mode: none matcher: "*" error: status: 404 - mode: errors headers_template: nginx_default rewrites: - matcher: "*" rewrite_to: /404-nginx.html root: matcher: "*" path: /etc/caddy/www/static-defaults file_server: trueConfiguring Evilginx to Work with kCaddy A few notes on configuring Evilginx to accept traffic from kCaddy correctly. Community Edition Run Evilginx with the -developer flag to enable self-signed certificates, and -debug for detailed logging during testing: sudo ./build/evilginx -p ./phishlets -t ./redirectors -developer -debugI always suggest running with -debug during initial testing. You will find it very helpful in case of problems.To make Evilginx listen on port 8443 (matching the kCaddy evilginx_upstream URI), configure the config.json file that Evilginx creates in its working directory. Here is the configuration I am using for this setup: { "blacklist": { "mode": "off" }, "general": { "autocert": true, "bind_ipv4": "", "dns_port": 53, "domain": "evilginx-example.site", "external_ipv4": "", "https_port": 8443, "ipv4": "", "unauth_url": "" }, "lures": [ { "hostname": "", "id": "", "info": "", "og_desc": "", "og_image": "", "og_title": "", "og_url": "", "path": "/access", "paused": 0, "phishlet": "example", "redirect_url": "", "redirector": "", "ua_filter": "" } ], "phishlets": { "example": { "hostname": "evilginx-example.site", "unauth_url": "", "enabled": true, "visible": true } }, "server": { "bind_ipv4": "", "dns_port": 53, "domain": "evilginx-example.site", "external_ipv4": "1.2.3.4", "http_port": 8080, "https_port": 8443 } }The key field is https_port: 8443 in the server section, which matches uri: https://evilginx-site:8443 in the kCaddy reverse proxy configuration. The lure path is set to /access to match the kCaddy matchers. Pro Edition The setup for Evilginx Pro follows the same principle. The Pro version typically runs as a systemd service, so you need to update the service unit file to include the equivalent flags for self-signed certificates and the custom HTTPS port. The kCaddy configuration itself remains identical; the only behavioral difference is that the Pro version routes requests based on TLS Server Name alone, while the community edition also relies on the Host header (which kCaddy sends in both cases). The configuration shared in this blog post is designed to work with both versions; I have tested and deployed infrastructure using both tools. Path: /etc/systemd/system/evilginx.serviceExample configuration [Unit] Description=Evilginx Pro Server Daemon Requires=network.target After=network.target[Service] User=evilginx Group=evilginx PIDFile=/var/run/evilginx.pid ExecStartPre=/bin/rm -f /var/run/evilginx.pid ExecStart=/home/evilginx/evilginx-pro/evilginx -server -P 127.0.0.1:44500 --developer --debug Restart=on-failure WorkingDirectory=[Install] WantedBy=multi-user.targetDisabling Blacklist For a quick win with the community edition, disable the blacklist entirely: blacklist offIf you need Evilginx's anti-bot system active (for example, when running behind a CDN), you need to pass the client's real IP address through a header. The kCaddy reverse proxy configuration already includes "X-Real-IP: {remote_host}" in header_up.set. Just make sure you are not stripping it with a wildcard rule.These headers are supported from version 3.3 of the community edition: [Evilginx Changelog](https://github.com/kgretzky/evilginx2/blob/master/CHANGELOG)X-Forwarded-For X-Real-IP X-Client-IP Connecting-IP True-Client-IP Client-IPI worked in the past on a web application capable of acting as an anti-bot plugin perfectly integrated with Caddy. It was incredibly powerful. I hope to resume the project and share it too.Disabling Anti-Bot Introduced in the Evilginx Pro version there is an additional security feature which tries to detect if the client is a bot, via ja4 and user agent signatures, with kcaddy on top of Evilginx this will make the detection prone to false positivie, for example blocking caddy ja4. It's not necessary to disable this feature, i will suggest you to try with the feature on and if everything works fine you should leave this feature on, and same goaes for the balcklist. But in case you need to disable the feature: config botguard ja4 false config botguard browser falseConclusions and Next Steps This project started from a single scenario: I needed to run an Evilginx attack chained with device code phishing and GoPhish for email delivery, and I was relying heavily on Caddy for all the HTTP traffic. So I decided to make a draft of kCaddy, and then I thought it could be sharpened into something useful for other operators too. I developed kCaddy for the use cases I was working on, and I hope the community will start using it and criticizing it as much as possible. I want to see whether the project has real value, and I will add features and changes based on what people actually need. I am going to share more scenarios on how to use kCaddy in follow-up articles. I started with Evilginx because I think it is one of the most common use cases right now, and there is a lot of confusion around configuring it with a reverse proxy, both for the free and Pro versions. Next up: C2 infrastructure, GoPhish obfuscation, and general infrastructure obfuscation. Let me know which ones interest you most and I will prioritize accordingly. Acknowledgments I want to thank DNV Cyber for giving me the time and environment to research and build this. Thanks also to Kuba Gretzky, mrd0x, her0ness, and Paolo Stagno for reviewing an early draft and pushing back where it needed it. Do not hesitate to reach out!