905 words
5 minutes
ExpressionEngine Preview Flaw: How Anyone Could Read Private Drafts and Hidden Content

HackerOne Report: #3531272
Vendor: ExpressionEngine
Severity: High (7.5 CVSS)
Status: Fixed (released Feb 26, 2026)
Disclosure: Coordinated


Summary#

The live_preview action in the Channel module is completely unauthenticated and CSRF-exempt. It contains a severe Mass Assignment vulnerability combined with missing authorization checks.

Because the LivePreview service blindly does $entry->set($_POST), an attacker can overwrite critical model properties including status, entry_date, expiration_date, and author_id. This completely defeats ExpressionEngine’s core content visibility controls (status filters, future publishing, expiration dates).

An unauthenticated attacker can read 100% of Channel content — including drafts, embargoed press releases, and entries marked “Closed”.

In Simple Terms: How to Explain This Bug to Anyone#

Here are 4 easy ways to explain what went wrong:

  1. The “Preview” button was open to the whole internet.
    It was supposed to be a tool only for logged-in editors to see their own work before publishing. Instead, anyone on the internet could use the same button.

  2. You could change the “secret settings” of any article.
    By sending special hidden values in a web request, an attacker could tell the system:

    • “Pretend this secret draft is actually published and public” (status=open)
    • “Pretend this was written years ago” (old entry_date)
    • “This article never expires” (expiration_date=0)
    • “This was written by the site admin” (author_id=1)
  3. The system blindly trusted whatever you told it.
    Instead of checking the real database record (which said “this is closed/draft”), it let the attacker’s fake values override everything when rendering the page.

  4. Normal content rules were completely bypassed.
    Templates that normally only show “Open” and “published in the past” articles would happily display the attacker’s fake version of a secret entry.

In short: one unauthenticated request could make the website show you content that should have been completely hidden from the public.

How the Vulnerability Worked (Simplified Technical View)#

The preview feature had three main problems:

  1. No login check at all
    The live_preview function in the Channel module accepted requests from anyone. There were no checks like “is this user logged in?” or “does this user have permission to see this entry?”

  2. The system trusted attacker input completely
    It took every field sent in the web request and directly overwrote the real article data (a classic mass assignment issue). This meant an attacker could change the article’s “status”, “publish date”, “expiry date”, and even who the author was.

  3. The page renderer was tricked into using the fake data
    Special logic in the template engine would inject the attacker’s fake version of the article into the output if the URL looked like it was requesting that specific entry.

How an Attacker Could Exploit It (Step by Step)#

1. Find the preview “door”#

ExpressionEngine uses numbered “actions”. The live preview action is usually something like ?ACT=4.

2. Trick the domain check#

The code tried to verify the request came from the same site using the Origin header. This was easily bypassed by adding a from parameter with the site address encoded in base64.

3. Point to a real-looking page + the secret entry ID#

The attacker had to provide a return parameter (base64 encoded) that pointed to a real template on the site and ended with the ID of the hidden entry they wanted to steal (e.g. news/entry/3).

4. Send the “magic” values that override all protections#

Terminal window
# Basic example (real attack used proper base64 values)
curl -X POST "http://target.com/index.php?ACT=4&return=...&from=..." \
-d "entry_id=3" \
-d "status=open" \
-d "entry_date=1233868018" \
-d "expiration_date=0" \
-d "author_id=1"

By setting:

  • status=open → makes a “Closed” or “Draft” entry appear public
  • Old entry_date → bypasses “don’t show future posts” rules
  • expiration_date=0 → prevents the entry from being considered expired
  • author_id=1 → makes it look like it was written by an admin

The server would then happily render the full private entry as if it were a normal public page.

Real-World Impact#

This bug meant that every piece of content managed through ExpressionEngine Channels could potentially be read by anyone on the internet, including:

  • Draft articles still being written
  • Press releases scheduled for next month
  • Internal or “Closed” pages meant only for staff or specific members
  • Any content the site owner thought was safely hidden behind status or date settings

These protections (status, publish date, expiration) are fundamental to how CMSes control visibility. When they can be overridden by an unauthenticated visitor, the entire access control model collapses.

This is why the report was rated High severity.

Timeline#

  • January 30, 2026 — Report submitted to ExpressionEngine via HackerOne (#3531272)
  • February 9, 2026 — Acknowledged: “We have a patch under review”
  • February 12, 2026 — Follow-up inquiry
  • February 24, 2026 — Status changed to Triaged
  • February 25, 2026 — Severity set to High (7.5); fix PR provided for review
  • February 26, 2026 — Reporter reviewed the fix; patch released the same day

The Fix#

ExpressionEngine released the fix in the next version (released Feb 26, 2026).

Key changes in the PR:

  • Proper authentication and permission checks before allowing live preview
  • Stricter validation of the data being applied to the entry model
  • Prevention of unauthorized mass assignment on sensitive fields

PR: https://github.com/ExpressionEngine/ExpressionEngine/pull/5166

Recommendations#

  • Update ExpressionEngine to the latest patched version immediately.
  • Audit any custom templates or modules that rely on status, entry_date, or expiration_date for access control (these should never be the sole control).
  • Consider rate-limiting or additional hardening on the ACT endpoint if you expose it publicly.

Conclusion#

This was a textbook example of missing trust boundaries + unsafe mass assignment. The combination of an unauthenticated preview endpoint that blindly trusts user input to a rich domain model is extremely dangerous in a CMS that positions status and scheduling as core security features.

Huge thanks to the ExpressionEngine team (especially @prancer) for the fast, transparent, and professional handling of the report. Coordinated disclosure worked exactly as it should.


Originally reported on HackerOne as report #3531272. This write-up is published with the vendor’s awareness after the patch was released on hacck3y.me.

Report Link: https://hackerone.com/reports/3531272

ExpressionEngine Preview Flaw: How Anyone Could Read Private Drafts and Hidden Content
https://blogs.hacck3y.me/posts/expressionengine-live-preview-mass-assignment/
Author
hacck3y
Published at
2026-02-28
License
CC BY-NC-SA 4.0