I got tired of Evilginx's session list, so I built GhostLoot

Run an AiTM campaign with Evilginx for a day and you know the drill. You SSH into the box, type sessions, and a wall of rows scrolls by. Bots. Scanners. The same target who clicked five times. Landings that never went anywhere. Buried in there are the two or three sessions that actually matter, the ones with a live token you can replay, and you find them by squinting at a table over a laggy SSH pipe while new rows shove the good ones off the screen.
That was my Tuesday on a recent job. Wednesday I had GhostLoot.
Quick disclaimer before anyone gets ideas: this is for authorized red team work. GhostLoot doesn't phish anyone. It's a read-only viewer for loot Evilginx already captured on an engagement you're allowed to run. Repo's at the bottom.
The bit that was missing
Evilginx is a proxy. It's very good at driving the attack and it was never meant to be a case-management tool, so I'm not complaining about it. But on a real op you spend way more time reading loot than launching it, and that side is rough.
The sessions list has no idea who's a real victim and who's a bot that touched the lure once. It doesn't collapse duplicates, so one person who logged in three times shows up as three targets. And the thing you care about most, whether a captured session is actually reusable, is hidden inside the cookie dump. So you end up copying JSON out of a tmux pane that keeps wrapping and slicing it in half. I did that one too many times.
Rule one: don't touch the live database
First decision, and the one I'd defend hardest: GhostLoot never talks to Evilginx and never opens its live file. It works on a copy.
Everything Evilginx captures lives in one file, ~/.evilginx/data.db. Fun fact that trips people up: it's a buntdb store, not the BoltDB most folks assume. Sessions are plain JSON documents keyed like sessions:14. buntdb rewrites the file when you open it, so the last thing you want is a second process poking the real one mid-campaign. GhostLoot copies the file to a temp path, opens the copy, reads it, deletes the copy.
Parsing is refreshingly dumb:
db.View(func(tx *buntdb.Tx) error {
return tx.Ascend("", func(key, value string) bool {
var s Session
if err := json.Unmarshal([]byte(value), &s); err == nil {
byID[s.Id] = s
}
return true
})
})
Walk every key, try to decode it as a session, keep what parses. Counters and other junk keys just fail the unmarshal and drop out. No offsets, no schema archaeology.
Copying and reparsing on every request would be dumb in the bad way, so it's cached against the file's mtime. Evilginx only writes when something happens, a new visitor or a captured token. No change, no reparse. The panel polls every few seconds and costs basically nothing while the campaign is quiet. Warm request on my box was around 9ms against 35ms for a cold reparse, and that gap widens with every session you capture.
Cutting the signal out of the noise
Two things do the heavy lifting.
Dedup by victim. GhostLoot groups sessions by username, across phishlets, one row per human. Their phishlets, their attempt count, and the best capture of the bunch. Twenty raw rows become the four people who were actually in them. It keeps the duplicate count in an "attempts" column so you don't lose that signal, you just stop drowning in it.
Then, is the session worth anything. A row only matters if it carries a real, replayable token. Entra / Azure AD accounts mean ESTSAUTH and ESTSAUTHPERSISTENT. Personal Microsoft accounts mean MSPAuth or RPSSecAuth. Evilginx will cheerfully log routing and context cookies that look like loot and are worthless, so GhostLoot throws out the junk values (Disabled, estsfd, and friends) and only lights up a session as valid when a strong token shows up with a real value. That one green pill is the only thing I scan for now.
The trap that cost me an afternoon
This is the part worth reading even if you never run the tool.
I kept feeding my own personal Microsoft account into an o365 phishlet to smoke-test the pipeline, and every capture came back MSPAuth: Disabled, no usable session. First reaction, obviously, was "my tool is broken." It wasn't. It was telling me the truth. Microsoft never issued a session.
Microsoft runs two identity stacks and it's easy to smear them together:
Entra / Azure AD is the work-and-school side. Sign-in goes through login.microsoftonline.com, the login closes at /login.srf, and the reusable session is ESTSAUTH / ESTSAUTHPERSISTENT.
MSA is the consumer side, personal accounts, including a Gmail address you registered as a Microsoft account. Sign-in goes through login.live.com, the endpoint is /ppsecure/post.srf, and the session lives in RPSSecAuth / MSPAuth / __Host-MSAAUTH.
An o365 phishlet has its hosts, sub_filters and auth_tokens wired for the org stack. Throw a personal account at it and Microsoft quietly reroutes you into the consumer stack, different hosts, different cookies. The phishlet grabs the flow artifacts but not the cookie that actually authenticates, the login never closes cleanly through the proxy, and you get Disabled. Coverage problem, not a limit of the technique.
The takeaway that belongs in a report: personal versus corporate isn't what stops AiTM. Password plus "normal" MFA doesn't stop it either, SMS, OTP, push, an emailed code, you capture the cookie after the second factor is already done. The thing that actually kills it is phishing-resistant MFA. FIDO2, passkeys, Windows Hello for Business. Those are bound to the real origin, so the browser flat out refuses to hand the credential to your look-alike domain and there's nothing to capture. If a tenant enforces that across the board, your run dies at the door, and that's a strength you write up, not a gap.
Everything else I bolted on
Once the loot side felt right I kept adding the stuff I actually reach for mid-op.
Bulk export of every valid victim's cookies as a zip, one JSON per person, drop-in for Cookie-Editor or StorageAce. A masked CSV for the report with no secrets in it. A domain monitor that pings your phishing hosts and pings you back when one dies, which usually means a takedown or a killed domain. It checks your host, not wherever it 302s to, so a redirect to the real login doesn't get read as "up." Telegram alerts when a valid victim lands or a domain drops, with a minimal mode that leaves the username and IP out of the message, which you want if that alert is leaving your perimeter. A per-lure view so you can see which pretext is converting, and a small capture timeline.
All of it configured from the panel, all of it one self-contained Go binary with the frontend embedded. Deploying is a single scp.
OPSEC I didn't hand-wave
A loot dashboard is a pile of stolen session cookies with a web UI stapled on. Treat it like one.
It binds to 127.0.0.1 and expects an SSH tunnel. Try to expose it on a public interface without auth and it refuses to boot instead of serving creds to the internet. The write endpoints check request origin so a random tab in your browser can't reprogram your Telegram token behind your back. And the one feature that can actually burn your infra, Safe Browsing lookups, is off by default and yells at you when you flip it on, because that API ships your live phishing URLs straight to Google tied to your key. Same reason you never feed your own URLs to urlscan or VirusTotal. That's how infra ends up cooked.
Grab it
GhostLoot is MIT: github.com/marcocarolasec/ghostloot
It reads a data.db you already have, from an engagement you're already cleared to run. No Evilginx bundled, no phishlets, and it won't help you hit anyone you don't have permission to hit.
If you run Evilginx and that session list has ever made you sigh, take it for a spin and open an issue. I wrote it for my own ops, so the roadmap is whatever annoys me next.





