The Forge

Showing Posts Tagged With

Infrastructure

kCaddy: Forging a Malleable Caddy Redirector for Evilginx

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!

C2 Redirectors Using Caddy

C2 Redirectors Using Caddy

This post was written in 2021 and reflects our original manual Caddy setup. Since then, this approach has evolved into kCaddy, a YAML-driven builder that automates everything described here. Check out the updated version: [kCaddy: Forging a Malleable Evilginx Redirector](/blog/kcaddy-redirector-evilginx/).Using Caddy to spin up fast and reliable C2 redirectors. Giving Caddy redirectors some love The consultant's life is a difficult one. New business, new setup and sometimes you gotta do everything in a hurry. We are not a top notch security company with a fully automated infra. We are poor, rookies and always learning from the best. We started by reading several blogposts that can be found on the net, written by people much more experienced than us, realizing that redirectors are almost always based on apache and nginx, which are great solutions! but we wanted to explore other territories… just to name a few:Praetorian's approach to red team infrastructure Testing your red team infrastructure – MDSec Modern red team infrastructure – NetSPI Designing effective covert red team attack infrastructure – Bluescreen of Jeffand many others… despite the posts described above that are seriously top notch level, we decided to proceed taking inspiration from our fellow countryman Marcello aka byt3bl33d3r which came to the rescue!Taking the pain out of C2 infrastructure – byt3bl33d3rAs you can see from his post, Marcello makes available to us mere mortals a quick configuration, which pushed us to explore further. Why Caddy Server? Caddy was born as an opensource webserver specifically created to be easy to use and safe. it is written in Go and runs on almost every platform. The added value of Caddy is the automatic system that supports the ability to generate and renew certificates automatically through let's encrypt with basically no effort at all. Another important factor is the configurative side that is very easy to understand and more minimalist, just what we need! Let's Configure!Do you remember byt3bl33d3r's post listed just above? (Of course, you wrote it 4 lines higher…) let's take a cue from it! First of all let's install Caddy Server with the following commands: (We are installing it on a AWS EC2 instance) sudo yum updateyum install yum-plugin-copr yum copr enable @caddy/caddy yum install caddyOnce installed, let's go under /opt and create a folder named /caddy or whatever you like. And inside create the Caddyfile. At this point let's populate the /caddy with our own Caddyfile and relative folder structure and configurations. To make things clearer, here we have a tree of the structure we are going to implement:The actual Caddyfile The filters folder, which will contain our countermeasures and defensive mechanisms the sites folder, which will contain the domains for our red team operation and their log files the upstreams folder, which will contain the entire upstreams part the www folder, which will contain the sites if we want to farm a categorization for our domains, like hosting a custom index.html or simply clone an existing one because we are terrible individuals.. ├── Caddyfile ├── filters │ ├── allow_ips.caddy │ ├── bad_ips.caddy │ ├── bad_ua.caddy │ └── headers_standard.caddy ├── sites │ ├── cdn.aptortellini.cloud.caddy │ └── logs │ └── cdn.aptortellini.cloud.log ├── upstreams │ ├── cobalt_proxy_upstreams.caddy │ └── reverse_proxy │ └── cobalt.caddy └── www └── cdn.aptortellini.cloud └── index.htmlCaddyfile This is the default configuration file for Caddy: # This are the default ports which instruct caddy to respond where all other configuration are not matched :80, :443 { # Default security headers and custom header to mislead fingerprinting header { import filters/headers_standard.caddy } # Just respond "OK" in the body and put the http status code 200 (change this as you desire) respond "OK" 200 }#Import all upstreams configuration files (only with .caddy extension) import upstreams/*.caddy#Import all sites configuration files (only with .caddy extension) import sites/*.caddyWe decided to keep the Caddyfile as clean as possible, spending some more time structuring and modulating the .caddy files. Filters folder This folder contain all basic configuration for the web server, for example:list of IP to block list of User Agents (UA) to block default implementation of security headersbad_ips.caddy remote_ip mal.ici.ous.ipsStill incomplete but usable list we crafted can be found here: her0ness/av-edr-urls bad_ua.caddy This will block all User-Agent we don't want to visit our domain. header User-Agent curl* header User-Agent *bot*A very well done bad_ua list can be found, for example, here: nginx-ultimate-bad-bot-blocker – bad-user-agents.list headers_standard.caddy # Add a custom fingerprint signature Server "Apache/2.4.50 (Unix) OpenSSL/1.1.1d"X-Robots-Tag "noindex, nofollow, nosnippet, noarchive" X-Content-Type-Options "nosniff"# disable FLoC tracking Permissions-Policy interest-cohort=()# enable HSTS Strict-Transport-Security max-age=31536000;# disable clients from sniffing the media type X-Content-Type-Options nosniff# clickjacking protection X-Frame-Options DENY# keep referrer data off of HTTP connections Referrer-Policy no-referrer-when-downgrade# Do not allow to cache the response Cache-Control no-cacheWe decided to hardly customize the response Server header to mislead any detection based on response headers. Sites folder You may see this folder similar to sites-available and sites-enabled in nginx; where you store the whole host configuration. Example front-end redirector (cdn.aptortellini.cloud.caddy) From our experience (false, we are rookies) this file should contain a single host because we have decided to uniquely identify each individual host, but feel free to add as many as you want, You messy! https://cdn.aptortellini.cloud { # Import the proxy upstream for the cobalt beacon import cobalt_proxy_upstream # Default security headers and custom header to mislead fingerprinting header { import ../filters/headers_standard.caddy } # Put caddy logs to a specified location log { output file sites/logs/cdn.aptortellini.cloud.log format console } # Define the root folder for the content of the website if you want to serve one root * www/cdn.aptortellini.cloud file_server }Upstreams folder The file contains the entire upstream part, the inner part of the reverse proxy has been voluntarily detached because it often requires individual ad-hoc configurations. cobalt_proxy_upstreams Handle directive: Evaluates a group of directives mutually exclusively from other handle blocks at the same level of nesting. The handle directive is kind of similar to the location directive from nginx config: the first matching handle block will be evaluated. Handle blocks can be nested if needed. To make things more comprehensive, here we have the sample of http-get block adopted in the Cobalt Strike malleable profile:# Just a fancy name (cobalt_proxy_upstream) { # This directive instruct caddy to handle only request which begins with /ms/ (http-get block config pre-defined in the malleable profile for testing purposes) handle /ms/* { # This is our list of User Agents we want to block @ua_denylist { import ../filters/bad_ua.caddy } # This is our list of IPs we want to block @ip_denylist { import ../filters/bad_ips.caddy } header { import ../filters/headers_standard.caddy } # Respond 403 to blocked User-Agents route @ua_denylist { redir https://cultofthepartyparrot.com/ } # Respond 403 to blocked IPs / redirect to decoy route @ip_denylist { redir https://cultofthepartyparrot.com/ } # Reverse proxy to our cobalt strike server on port 443 https import reverse_proxy/cobalt.caddy } }Reverse proxy folder The reverse proxy directly instruct the https stream connection to forward the request to the teamserver if the rules above are respected. Cobalt Strike redirector to HTTPS endpoint reverse_proxy https://<cobalt_strike_endpoint> { # This directive put the original X-Forwarded-for header value in the upstream X-Forwarded-For header, you need to use this configuration for example if you are behind cloudfront in order to obtain the correct external ip of the machine you just compromised header_up X-Forwarded-For {http.request.header.X-Forwarded-For} # Standard reverse proxy upstream headers header_up Host {upstream_hostport} header_up X-Forwarded-Host {host} header_up X-Forwarded-Port {port} # Caddy will not check for SSL certificate to be valid if we are defining the <cobalt_strike_endpoint> with an ip address instead of a domain transport http { tls tls_insecure_skip_verify } }WWW This folder is reserved if you want to put a website in here and manually categorize it. Or take a cue from those who do things better than we do: Chameleon – MDSec Starting Caddy Once started, caddy will automatically obtain the SSL certificate. Remember to start Caddy in the same folder where you placed your Caddyfile! sudo caddy startTo reload the configuration, you can just run the following command in the root configuration folder of Caddy: sudo caddy reloadGetting a CS Beacon Everything worked as expected and the beacon is obtained.A final thought This blogpost is just the beginning of a series focused on making infrastructures for offensive security purposes, in the upcoming months we will expand the section with additional components. With this we just wanted to try something we never tried before, and we know there are multiple ways to expand the configuration or make it even better, so, if you are not satisfied with what we just wrote, feel free to offend us: we won't take it personally, promise.