One VPS, Many Operators: Building a Multi-Tenant Phishing Platform for Red Team Engagements
What is LPhish?
LPhish ("Let's Phish") is an open-source phishing operations platform that fuses two of the most widely used open-source phishing tools — GoPhish (campaign orchestration, email sending, result tracking) and evilginx3 (adversary-in-the-middle credential capture) — into one unified web control plane.
Instead of juggling two separate admin UIs, two databases, and a pile of shell scripts to glue them together, an operator gets a single console that manages engagements, phishlets, lures, campaigns, DNS, TLS certificates, and a unified live event feed.
The problem it solves
GoPhish and evilginx3 are powerful individually, but running them together for a real engagement is painful:
- Two separate admin interfaces — GoPhish's web UI for campaigns, evilginx3's terminal console for phishlets and lures.
- Two separate data stores — GoPhish tracks email opens/clicks in its SQLite DB, while evilginx3 tracks AitM sessions in its own state. Reconciling "who clicked, who submitted" across both is manual.
- No shared identity — the
rid(recipient ID) that GoPhish embeds in campaign URLs is opaque to evilginx3, so click events and credential submissions can't be tied back to a specific target automatically. - Operational overhead — DNS records, TLS certificates, phishlet hostnames, and lure URLs are all managed by hand.
LPhish's core bet: make evilginx3 write directly into GoPhish's database, and put a single authenticated control plane in front of both backends' APIs.
Architecture at a glance
┌──────────────────────────────────────────────┐
Operator (browser) │ LPhish control plane │
── :8080 ─────────────►│ HTTP + JSON API + Web UI (bin/lphish) │
│ │
│ /api/engagements/{id}/gophish/* ────────┐ │
│ /api/engagements/{id}/evilginx/* ──────┐ │ │
└──────────────────────────────────────────┼─┼─┘
│ │
┌─────────────────────────────┐ ┌──────────▼─▼──────────┐
Target (email/browser) │ │ GoPhish admin API │
── :443 (lure URL) ──► evilginx3 (AitM proxy) │ │ :3333 (bridge) │
── :25 / SMTP ───────► GoPhish mailer (SMTP sink) │ │ SQLite (SHARED DB) │
│ -api :8081 │ │ gophish.db │
└─────────────┬───────────────┘ └───────────▲───────────┘
└──── evilginx3 writes ─────────┘
(opened / clicked / submitted data)
| Component | Role | Ports |
|---|---|---|
bin/lphish |
Control plane: auth, engagements, operators, API bridges, unified feed, web UI | 127.0.0.1:8080 |
bin/gophish (fork) |
GoPhish admin API + mailer; template execution; result/status store | 127.0.0.1:3333 admin API, :8083 TLS phish server |
bin/evilginx3 (fork) |
AitM proxy; phishlet engine; REST API bridge; writes events to the shared GoPhish DB | :8443 HTTPS, 127.0.0.1:8081 -api |
| Apache2 front-end (optional) | TLS termination on public :443; proxies / → evilginx3, /phish → control plane |
0.0.0.0:443 |
The shared database — the key architectural decision
Both forks were modified so that the evilginx3 event path calls GoPhish's database layer
(database.HandleEmailOpened, database.HandleClickedLink, database.HandleSubmittedData)
against the same SQLite file GoPhish uses (gophish.db, selected with evilginx3 -g).
GoPhish status values therefore advance exactly like a native GoPhish campaign:
Email/SMS Sent → Email Opened → Clicked Link → Submitted Data
A status is only ever promoted — a late email-open after a click is recorded as an event but
does not downgrade Clicked Link back to Email Opened. This single decision is what makes
the unified live feed and the campaign result funnel possible.
The client_id blob — how the two tools share identity
The campaign URL stored on the GoPhish campaign is the plain lure URL (e.g.
https://login.login.evil.test/zGkzbfAO). GoPhish's mailer automatically appends tracking
parameters through evilginx.CreatePhishUrl, which wraps rid, fname, lname, and email
into one encrypted client_id query parameter:
?client_id=<8-char RC4 key><base64url(checksum-byte + RC4(params))>
evilginx3's extractParams decrypts that blob on the lure path and treats the decoded rid as
the click/recipient id. A plaintext ?rid=... query string is not accepted (the log shows
lure parameter checksum doesn't match), which keeps the tracking mechanism tamper-resistant.
Email-open tracking uses the same blob format with parameters o=track&rid=<rid>; evilginx3
answers with a tracking PNG and logs email opened.
What the console can do
The control plane is a single-page app (vanilla JS + CSS, no framework) served by the Go binary. Its tabs cover the full engagement lifecycle:
Engagements & operators
- Engagements bundle a GoPhish admin URL + API key and an evilginx3 API URL + token, plus one root domain. Each engagement can run as an isolated cell (its own gophish + evilginx processes) for tenant isolation.
- Operators with admin/operator roles, PBKDF2-HMAC-SHA256 password hashing (210,000 iterations), 24-hour session expiry, login throttling, and RBAC.
Phishlets (evilginx3 management)
- Import phishlet YAML, edit source with a per-save version history (snapshots under
<phishlets dir>/.lphish-history/), set serving hostnames, enable/disable and hide/show. - Parameterized templates and their child instances are handled explicitly: templates can't run directly, children inherit the parent's YAML read-only.
- A root-domain dropdown (fed from the engagement's confirmed DNS zones) lets you clone a plain phishlet into a child instance serving a chosen domain.
Lures
- Create lures with a path (blank = random) and optional custom hostname, with per-lure bot checks: Turnstile challenge mode (inherit/on/off), per-lure site+secret keys, a challenge-template override, and uploadable lure assets.
- A lure-health classifier reports what each lure actually serves (turnstile / BitB / login), so a broken lure is caught before it goes out.
Campaigns (GoPhish resources in the console)
- Full CRUD over the GoPhish admin API bridge for Groups (with CSV import), Templates
(plaintext/HTML, tracking pixel,
.emlimport, attachments), and Sending Profiles (password or OAuth2/XOAUTH2 for Google Workspace / Microsoft 365). - Campaign launch with scheduling (launch date + "send emails by" spread), a campaign wizard, and a rich results page: per-email stat cards, event timelines, browser/UA/IP tables, ASN/ORG classification, and a client-side HTML report download.
Browser-in-the-Browser (BitB) pages
- Generate victim-facing landing pages that render a fake browser window (Chrome / Safari /
Edge / Windows chrome) with a spoofed address bar, framing the lure URL. The evilginx3 fork
strips
X-Frame-Options/CSPon proxied responses so the AitM page renders inside the frame.
Static harvest pages
- Brand-preset (Microsoft / Google / Okta / Amazon / generic) "dumb HTML" sign-in forms served
by the control plane itself at
/phish/{engagement_id}/{page_id}— no AitM proxying. On submit, credentials + victim IP + User-Agent are stored and the victim is redirected to a success URL. Frameable by design (noX-Frame-Options), so they can be wrapped in BitB.
Device-code phishing (RFC 8628)
- A Microsoft OAuth2 device-code module: the console runs an RFC 8628 flow (public client,
tenant
common, scopehttps://outlook.office365.com/SMTP.Send offline_access) and, once the mailbox owner approves in a browser, fills in the SMTP profile's OAuth client ID/tenant, refresh token, and From address. Also used for a Microsoft Graph connector for Outlook Groups.
DNS, certs, and domain intelligence
- DNS tab: per-engagement providers (Cloudflare / GoDaddy / DigitalOcean / manual) with
credentials sealed at rest (AES-256-GCM,
enc1:prefix), zone/record CRUD, and A-record sync to the exact hostnames evilginx3 is serving. - Certs tab: per-phishlet TLS status (autocert / BYO / developer / none) with live SNI probing, BYO PEM upload/reload/delete, and ACME renew.
- Domains tab: generate lookalike candidates (typos / homoglyphs / TLD variants / word-combos) from a brand seed and batch-check registration over RDAP before buying a lure domain.
Unified live feed
- Merges GoPhish result events and evilginx3 AitM session events, sorted newest-first, with victim notifications (per-operator settings, SSE live tail, unread badge, toasts, and browser/webhook/email channels with HMAC-signed webhooks).
Deployment
Three deployment modes are supported:
- One command (sandbox/dev) —
./runtime/start_all.shbuilds and starts the whole stack (GoPhish, evilginx3, control plane, optional Apache2 front-end, and an opt-in dev SMTP sink on:2525for end-to-end testing without a real relay). - Persistent host (systemd) —
./runtime/install_systemd.shinstalls units that survive logout, auto-restart on crash, and start on boot. The default install runs isolated per-engagement cells (each engagement gets its own gophish + evilginx);--sharedis the legacy single-backend layout. - Manual — per-component start commands, with prebuilt
linux/amd64binaries inbin/.
An optional Apache2 front-end terminates TLS on public :443, proxies / to evilginx3
(127.0.0.1:8443), /phish to the control plane's harvest pages, and serves the BitB assets —
with X-Forwarded-For set to the real client IP so the blacklist and session logging work.
Security posture
The control plane is hardened in ways worth calling out:
- PBKDF2-HMAC-SHA256 (210k iterations, per-operator salt), constant-time verification, transparent upgrade of legacy SHA-256 hashes.
- Session security: 24h expiry,
Securecookie behind a TLS proxy, server-side expiry. - Login throttling: 10 failed attempts per (IP, username) pair → 5-minute lockout (HTTP 429).
- RBAC: operator creation/deletion is admin-only; the last admin can't be deleted.
- XSS: all dynamic values (including evilginx session usernames and backend error messages) are escaped before DOM insertion.
- SSRF guard: bridge URLs must be absolute
http(s)and must not target link-local addresses (e.g. cloud metadata169.254.169.254). - Secrets at rest: DNS provider credentials are AES-256-GCM sealed; operator hashes and device-code tokens live in a mode-0600 state file.
The README is also candid about the trade-offs: OAuth secrets/refresh tokens are stored in
plaintext in the shared gophish.db (like upstream GoPhish's password field), and the GoPhish
bridge uses InsecureSkipVerify because the admin API serves a self-signed cert on loopback.
Responsible use
LPhish is a research / authorized-testing platform. The NOTICE.md is explicit: the forks
retain their original licenses (GoPhish MIT, evilginx3 BSD-style), and the modifications are
provided for authorized security assessments and awareness testing only. The README's
BitB section even includes a detection note — modern browsers expose OS-level window chrome
that fake chrome can't reproduce, so the technique is best used where the assessment calls
for it.
Bottom line
LPhish is a serious attempt to solve a real operational problem in phishing simulation: the GoPhish/evilginx3 split. By making evilginx3 write into GoPhish's database and wrapping both APIs in one hardened control plane, it collapses the two-tool workflow into a single console with a unified funnel from email sent to credentials submitted. For red teams and authorized phishing assessments that already use both tools, it's worth a look.
This post was generated from a review of the LPhish repository at /home/ubuntu/LPhish
(HEAD 27ea906, September 2026).