Apache, Nginx, and Caddy redirector config generator for Cobalt Strike Malleable C2 profiles.
Malleable Redirector parses a Cobalt Strike Malleable C2 profile and emits a hardened redirector configuration for the web server of your choice (--format apache|nginx|caddy). It proxies only profile-matching beacon traffic to your team server. Everything else, including scanners, bots, and blue-team probes, is redirected to a convincing decoy.
It understands the profile DSL well enough to enforce exact header matching on beacon comms endpoints, not just User-Agent (UA) filtering.
Intended for authorised red team engagements only.
What's new: Malleable Redirector used to generate Apache
.htaccessfiles only. It now targets Apache, Nginx, and Caddy from the same profile via--format, adds a unified--domainflag that sets the actual site hostname (not just report text), and introduces--phantomblogger-decoy— a Caddy-only mode that serves a real static site for unmatched traffic instead of redirecting to an external decoy.
-
Three output formats. Apache (
.htaccess), Nginx, and Caddy from the same profile, same policy engine, same routing logic. Pick your web server with--format. -
Precise header matching on beacon routes. Extracts every
client {}header from the profile (Accept,Accept-Language,Content-Type, etc.) and enforces them as match conditions on the exact URIs where the beacon sends them. A scanner with the right UA still gets turned away if it doesn't send the right headers. -
URI-specific routing policy. Generated rules do not treat every path the same. Profile-derived beacon endpoints are locked to the profile's user agents and
client {}headers; operator-added extra paths can accept extra--allow-uavalues without weakening beacon routes; and explicitly lax paths, such as health checks, can be proxied without required UA or header checks when you ask for that behavior. -
Correct rule ordering. Profile proxy rules run before the probe path filter, so
.phpor.aspxURIs in your profile are proxied rather than blocked by the probe filter that comes after. -
UA scope separation. Operator-supplied
--allow-uavalues (e.g. a PowerShell stager UA) only apply to extra operator URIs, not to beacon comms endpoints. Your C2 traffic gate is not widened by your staging setup. -
Multi-URI support.
set uri "/path1 /path2";is correctly split into separate routes. -
Staging awareness. If your profile doesn't set
host_stage "false", the tool warns loudly before generation. Automatic Cobalt Strike staging rules are not generated, by design. -
PhantomBlogger catch-all (Caddy only). Instead of redirecting unmatched-but-legitimate traffic to an external decoy URL,
--phantomblogger-decoyserves a real static site directly from the redirector, so a curious visitor lands on convincing content rather than bouncing to another domain.
| Track | Source | UA matching | Header matching |
|---|---|---|---|
| Track 1 — profile | http-get / http-post URIs parsed from the profile |
Beacon UAs from profile only | All client {} headers (strict policy) |
| Track 2 — extra | --extra-uri operator flags for manually added staging or payload routes |
Beacon UAs + operator --allow-ua (strict policy) |
None |
| Track 3 — lax | --lax-uri operator flags |
None | None |
Different traffic on your redirector carries different levels of certainty: you know exactly what your beacon will send, but you cannot always predict the shape of a staging or operator-added request. The three tracks translate that operational reality into distinct rule sets.
| Policy | Bad-UA blocklist | Direct-IP guard | Method guard | UA check | Profile header check |
|---|---|---|---|---|---|
strict (default) |
✓ | ✓ | ✓ | ✓ | ✓ |
lax |
✓ | — | — | — | — |
none |
— | — | — | — | — |
strict is the intended operational mode. lax and none exist for testing or environments where access is already controlled at the network layer.
Rules are emitted in this fixed order so each gate is evaluated before the next:
0 Forbid plain HTTP (--forbid-http)
1 Global bad-UA blocklist (strict + lax)
2a Direct-IP guard (strict)
2b Method guard GET|POST (strict)
3 Proxy routes (all tracks, BEFORE probe filter)
4 Probe path filter (strict, after proxy routes)
5 Catch-all → decoy
The probe path filter (section 4) intentionally comes after proxy routes (section 3), so a .php or .aspx URI in your malleable profile is proxied before the filter ever sees it.
A sample Malleable C2 profile, chches_APT10.profile, is included for testing.
- Python 3.7+ — pure stdlib, no third-party dependencies
- One of:
- Apache 2.4 with modules:
mod_rewrite,mod_proxy,mod_proxy_http,mod_ssl,mod_headers - Nginx with the
mapmodule (built in on virtually all distro packages) - Caddy v2
- Apache 2.4 with modules:
git clone https://github.com/InTheCyber/malleable-redirector
cd malleable-redirectorRun the tool against your profile, pointing it at your team server and a decoy, and choose your web server with --format:
python3 profile_to_htaccess.py current.profile \
--backend https://teamserver.internal:443 \
--decoy https://www.legit-looking-site.com/ \
--policy strict \
--forbid-http \
--format apache \
-o .htaccesspython3 profile_to_htaccess.py current.profile \
--backend https://teamserver.internal:443 \
--decoy https://www.legit-looking-site.com/ \
--domain redirector.contoso-infra.com \
--format nginx \
-o redirector.confpython3 profile_to_htaccess.py current.profile \
--backend https://teamserver.internal:443 \
--decoy https://www.legit-looking-site.com/ \
--domain redirector.contoso-infra.com \
--format caddy \
-o CaddyfileFor a more complete operator setup with extra operator URIs and an allow-listed stager UA:
python3 profile_to_htaccess.py ops.profile \
--backend https://10.10.10.5:443 \
--decoy https://www.microsoft.com/ \
--forbid-http \
--policy strict \
--format caddy \
--extra-uri /cdn/update.cab \
--extra-uri /telemetry/v2/push \
--lax-uri /health \
--allow-ua "PowerShell/5.1 (Windows NT 10.0; Win64; x64)" \
--domain redirector.contoso-infra.com \
-o CaddyfileThe tool prints a full operator report and a ready-to-use setup snippet to stderr (an Apache VirtualHost, or reload/validate commands for Nginx/Caddy), leaving stdout free for config output when -o is not used.
By default, unmatched-but-legitimate traffic (a real visitor who isn't your beacon) is redirected to --decoy, an external site. If you'd rather this traffic land on a real page served by the redirector itself, pass --phantomblogger-decoy:
python3 profile_to_htaccess.py ops.profile \
--backend https://10.10.10.5:443 \
--decoy https://www.microsoft.com/ \
--domain redirector.contoso-infra.com \
--phantomblogger-decoy \
-o Caddyfile--decoy still receives redirects from the security filters (bad-UA, bad-path, direct-IP, bad-method) — only the final catch-all is replaced with a static file server block. --phantomblogger-decoy forces --format caddy and expects the static site content at /opt/Blog-Template/dist on the redirector; adjust the path in the generated Caddyfile if yours differs.
Enable the required Apache modules:
sudo a2enmod rewrite proxy proxy_http ssl headersHarden the server identity in /etc/apache2/conf-available/security.conf:
ServerTokens Prod
ServerSignature Off
Drop the generated .htaccess into your DocumentRoot and configure the VirtualHost (the tool prints a tailored snippet to stderr, but the general structure is):
<VirtualHost *:80>
ServerName redirector.example.com
<Location />
Require all denied
</Location>
</VirtualHost>
<VirtualHost *:443>
ServerName redirector.example.com
SSLEngine on
SSLCertificateFile /etc/letsencrypt/live/redirector.example.com/fullchain.pem
SSLCertificateKeyFile /etc/letsencrypt/live/redirector.example.com/privkey.pem
DocumentRoot /var/www/html
<Directory /var/www/html>
Options -Indexes +FollowSymLinks
AllowOverride All
Require all granted
</Directory>
# Required if team server uses a self-signed certificate
SSLProxyEngine On
SSLProxyVerify none
SSLProxyCheckPeerName off
SSLProxyCheckPeerCN off
SSLProxyCheckPeerExpire off
Header always unset X-Powered-By
Header always unset Server
</VirtualHost>Enable and reload:
sudo a2ensite redirector.conf
sudo apachectl configtest && sudo systemctl reload apache2The generated file has two sections: map blocks that must live in the http {} context, and a server {} block. Split them accordingly, e.g.:
# map blocks → /etc/nginx/conf.d/malleable-maps.conf
# server block → /etc/nginx/sites-available/redirector.conf
sudo ln -s /etc/nginx/sites-available/redirector.conf /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginxDrop the generated file in as your Caddyfile (or a site block in /etc/caddy/conf.d/) and reload:
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddyusage: profile_to_htaccess.py profile [options]
| Flag | Default | Description |
|---|---|---|
profile |
— | Path to the .profile file |
--format |
apache |
apache / nginx / caddy |
--backend URL |
https://teamserver.internal:443 |
Team server URL. No trailing slash. |
--decoy URL |
https://www.example.com/ |
Non-matching traffic redirects here (302). |
-o / --output PATH |
stdout | Write the generated config to file. |
--forbid-http |
off | Reject plain HTTP (403 on Apache; commented-out block to adapt yourself on Nginx/Caddy). |
--policy |
strict |
strict / lax / none |
--extra-uri PATH |
— | Repeatable. Track 2 URI — required UA check, no profile header matching. |
--lax-uri PATH |
— | Repeatable. Track 3 URI — no required UA or header checks. |
--allow-ua STRING |
— | Repeatable. Exact UA for Track 2 only. Does not affect beacon comms routes. |
--block-ua STRING |
— | Repeatable. Extra substring added to the bad-UA blocklist. |
--domain NAME |
profile Host header |
Site hostname — Caddyfile site block, Nginx server_name, Apache ServerName in the report. (--server-name is a backward-compat alias.) |
--phantomblogger-decoy |
off | Caddy only. Serve a local static site as the catch-all instead of redirecting to --decoy. Forces --format caddy. See PhantomBlogger catch-all. |
--document-root PATH |
/var/www/redirector |
Apache only. For the setup report. |
--site-name NAME |
redirector |
Apache only. For the setup report. |
-
Beacon staging rules are not generated. If your profile has
host_stageenabled (or unset), the tool warns before generating. Addset host_stage "false";to suppress. Automatic staging from a redirector adds operational complexity with limited benefit. -
PhantomBlogger's static site path is hardcoded to
/opt/Blog-Template/dist. Edit the generated Caddyfile if your content lives elsewhere.
Thanks to the team behind cs2modrewrite for their inspiration.