MailArchiver <= 4.4.0 - Authenticated (Administrator+) Stored Cross-Site Scripting via Settings
Description
The MailArchiver plugin for WordPress is vulnerable to Stored Cross-Site Scripting via admin settings in all versions up to, and including, 4.4.0 due to insufficient input sanitization and output escaping. This makes it possible for authenticated attackers, with administrator-level permissions and above, to inject arbitrary web scripts in pages that will execute whenever a user accesses an injected page. This only affects multi-site installations and installations where unfiltered_html has been disabled.
CVSS Vector Breakdown
CVSS:3.1/AV:N/AC:L/PR:H/UI:R/S:C/C:L/I:L/A:NTechnical Details
What Changed in the Fix
Changes introduced in v4.5.0
Source Code
WordPress.org SVNThis plan outlines the research and exploitation process for **CVE-2026-2721**, a Stored Cross-Site Scripting (XSS) vulnerability in the MailArchiver plugin for WordPress. ### 1. Vulnerability Summary The MailArchiver plugin (versions <= 4.4.0) contains a Stored XSS vulnerability within its adminis…
Show full research plan
This plan outlines the research and exploitation process for CVE-2026-2721, a Stored Cross-Site Scripting (XSS) vulnerability in the MailArchiver plugin for WordPress.
1. Vulnerability Summary
The MailArchiver plugin (versions <= 4.4.0) contains a Stored XSS vulnerability within its administrative settings. The vulnerability arises because the plugin's internal form-rendering engine (found in the Mailarchiver\System\Form class) fails to escape existing option values when generating HTML input and textarea fields. Specifically, values retrieved from the database are concatenated directly into HTML attributes or tags without using functions like esc_attr() or esc_html().
While this typically requires Administrator privileges, it is considered a vulnerability because it allows XSS in environments where unfiltered_html is disabled (e.g., WordPress Multisite or hardened single-site installations), bypassing the intended security restrictions.
2. Attack Vector Analysis
- Endpoint:
wp-admin/admin.php?page=mailarchiver-settings(and potentially archiver configuration sub-pages). - Vulnerable Parameter: Likely the
tofield or any text-based setting field within an "Archiver" configuration. - Authentication Level: Administrator (specifically, a user with
manage_optionscapability). - Preconditions: The site must be a Multisite installation or have
define( 'DISALLOW_UNFILTERED_HTML', true );inwp-config.php.
3. Code Flow
- Entry Point: The Administrator navigates to the settings page registered in
Mailarchiver_Admin::init_perfopsone_admin_menus()with the slugmailarchiver-settings. - Data Retrieval: The plugin retrieves stored settings using
get_option(). - Vulnerable Sink (Output): The retrieved (and potentially malicious) string is passed to methods in
includes/system/class-form.php.Mailarchiver\System\Form::field_input_text($id, $value, ...):$html = '<input ... value="' . $value . '"' . $width . '/>'; // $value is not escapedMailarchiver\System\Form::field_input_textarea($id, $value, ...):$html = '<textarea ...>' . $value . '</textarea>'; // $value is not escaped
- Execution: When the settings page is rendered, the browser interprets the unescaped payload (e.g.,
"><script>alert(1)</script>) as executable code.
4. Nonce Acquisition Strategy
The settings page is part of the WordPress admin dashboard and uses standard WordPress nonces for protection against CSRF. To exploit this as an Administrator:
- Navigate to the MailArchiver settings page:
/wp-admin/admin.php?page=mailarchiver-settings. - The plugin uses the
PerfOpsOneframework for menus. The nonce for saving settings is likely provided in a hidden input field within the settings form. - JS Strategy: Use
browser_evalto extract the nonce:// Example for a standard WordPress settings nonce document.querySelector('input[name="_wpnonce"]')?.value; // Or check for localized data if the plugin uses AJAX for saving window.mailarchiver_admin?.nonce;
5. Exploitation Strategy
The goal is to inject a payload into a setting field (specifically the "to" field mentioned in the changelog) and verify its execution.
Step-by-Step Plan:
- Authentication: Log in to the WordPress admin panel with Administrator credentials.
- Navigate to Target: Open
/wp-admin/admin.php?page=mailarchiver-settings. - Identify Field: Locating the "Archivers" section. According to the
readme.txtandCHANGELOG.md, the vulnerability is in the "to" field of an archiver (likely an IMAP or Email archiver type). - Inject Payload:
- Field Name (Inferred):
toormailarchiver_archiver_to. - Payload:
"><script>alert(document.domain)</script>
- Field Name (Inferred):
- Submit Form: Use
http_requestto send a POST request towp-admin/admin.php?page=mailarchiver-settings(or the relevantadmin-post.phphandler if the plugin uses one).- Content-Type:
application/x-www-form-urlencoded - Body: Include
_wpnonce,action(if applicable), and the payload in the identified field.
- Content-Type:
- Verify Storage: Navigate back to the settings page.
- Trigger Execution: The
alertshould trigger upon page load.
6. Test Data Setup
- Ensure a user with
administratorrole exists. - Crucial: Disable
unfiltered_htmlfor the test.- In
wp-config.php:define( 'DISALLOW_UNFILTERED_HTML', true );.
- In
- The MailArchiver plugin must be active.
- If the "to" field is only visible for specific archiver types (e.g., IMAP), create/activate an IMAP archiver first via
wp-cli:wp m-archive archiver add --type=imap --name=TestArchiver
7. Expected Results
- After submitting the form, the database will store the literal string
"><script>alert(document.domain)</script>in thewp_optionstable (or archiver configuration meta). - Upon reloading the settings page, the HTML source will contain:
<input ... value=""><script>alert(document.domain)</script>" /> - The browser will execute the script and display an alert box.
8. Verification Steps
- Browser Verification: Use
browser_navigateto the settings page and check for the presence of the alert or the injected script in the DOM. - Database Verification: Use
wp-clito check the stored option:wp option get mailarchiver_settings --format=json # Or search specifically for the archiver configuration wp db query "SELECT option_value FROM wp_options WHERE option_name LIKE '%mailarchiver%';"
9. Alternative Approaches
If the "to" field is not immediately accessible on the main settings page:
- Check Archiver Types: Look for the "Pushover" or "Slack" archivers mentioned in the changelog, as they often have recipient/token fields.
- Check the "Viewer": The changelog also mentions SEC005 fixed XSS in the "to" field of the viewer. Navigate to
wp-admin/admin.php?page=mailarchiver-viewerand check how the "To" address of an archived email is rendered. If a sent email with a malicious "To" name is archived, it might trigger XSS in the viewer.- Payload for Viewer: Send an email with
To: "><script>alert(1)</script> <test@example.com>. - Requirement: The plugin must be configured to archive emails to the internal viewer.
- Payload for Viewer: Send an email with
Summary
The MailArchiver plugin for WordPress is vulnerable to Stored Cross-Site Scripting via its administrative settings in versions up to 4.4.0. This occurs because the plugin's internal form-rendering engine fails to escape existing option values when generating HTML for input, password, and textarea fields. Authenticated attackers with administrator-level permissions can inject arbitrary web scripts that execute whenever a user accesses the settings page, even in environments where unfiltered_html is disabled.
Vulnerable Code
// includes/system/class-form.php public function field_input_text( $id, $value = '', $description = null, $full_width = true, $enabled = true ) { if ( $full_width ) { $width = ' style="width:100%;"'; } else { $width = ''; } $html = '<input' . ( $enabled ? '' : ' disabled' ) . ' name="' . $id . '" type="text" id="' . $id . '" value="' . $value . '"' . $width . '/>'; if ( isset( $description ) ) { $html .= '<p class="description">' . $description . '</p>'; } return $html; } --- public function field_input_password( $id, $value = '', $description = null, $full_width = true, $enabled = true ) { if ( $full_width ) { $width = ' style="width:100%;"'; } else { $width = ''; } $html = '<input' . ( $enabled ? '' : ' disabled' ) . ' name="' . $id . '" type="password" id="' . $id . '" value="' . $value . '"' . $width . '/>'; if ( isset( $description ) ) { $html .= '<p class="description">' . $description . '</p>'; } return $html; } --- public function field_input_textarea( $id, $value = '', $description = null, $columns = 80, $lines = 3, $enabled = true ) { $html = '<textarea' . ( $enabled ? '' : ' disabled' ) . ' name="' . $id . '" id="' . $id . '" cols="' . $columns . '" rows="' . $lines . '" class="regular-text code">' . $value . '</textarea>'; if ( isset( $description ) ) { $html .= '<p class="description">' . $description . '</p>'; } return $html; }
Security Fix
@@ -71,6 +71,14 @@ protected $current_view = null; /** + * Password already set "flag". + * + * @since 4.5.0 + * @var string $password_set The "flag". + */ + private $password_set = 'password already set'; + + /** * Initialize the class and set its properties. * * @since 1.0.0 @@ -575,8 +583,15 @@ $this->current_archiver['privacy']['pseudonymization'] = ( array_key_exists( 'mailarchiver_archiver_privacy_name', $_POST ) ? true : false ); $this->current_archiver['privacy']['mailanonymization'] = ( array_key_exists( 'mailarchiver_archiver_privacy_mail', $_POST ) ? true : false ); $this->current_archiver['security']['xss'] = ( array_key_exists( 'mailarchiver_archiver_security_xss', $_POST ) ? true : false ); - $this->current_archiver['privacy']['encryption'] = ( array_key_exists( 'mailarchiver_archiver_privacy_encryption', $_POST ) ? Secret::set( filter_input( INPUT_POST, 'mailarchiver_archiver_privacy_encryption', FILTER_UNSAFE_RAW ) ) : '' ); - $this->current_archiver['processors'] = []; + if ( array_key_exists( 'mailarchiver_archiver_privacy_encryption', $_POST ) ) { + $key = filter_input( INPUT_POST, 'mailarchiver_archiver_privacy_encryption', FILTER_UNSAFE_RAW ); + if ( $key !== $this->password_set) { + $this->current_archiver['privacy']['encryption'] = Secret::set( $key ); + } + } else { + $this->current_archiver['privacy']['encryption'] = ''; + } + $this->current_archiver['processors'] = []; $proc = new ProcessorTypes(); foreach ( array_reverse( $proc->get_all() ) as $processor ) { if ( array_key_exists( 'mailarchiver_archiver_details_' . strtolower( $processor['id'] ), $_POST ) ) { @@ -1103,11 +1118,24 @@ ] ); register_setting( 'mailarchiver_archiver_privacy_section', 'mailarchiver_archiver_privacy_mail' ); - if ( PwdProtect::is_available() ) { - $description = esc_html__( 'Note: this is NOT a strong security feature; it\'s just a simple way to protect privacy in case of data leaks from external services. Think about it as a simple "password protection", with the password stored in plain text in your WordPress database.', 'mailarchiver' ); - $description .= '<br/>' . esc_html__( 'Encryption used:', 'mailarchiver' ) . ' ' . PwdProtect::get_encryption_details(); + + + if ( '' === $this->current_archiver['privacy']['encryption'] ) { + if ( PwdProtect::is_available() ) { + $description = esc_html__( 'Note: this is NOT a strong security feature; it\'s just a simple way to protect privacy in case of data leaks from external services. Think about it as a simple "password protection", with the password stored in plain text in your WordPress database.', 'mailarchiver' ); + $description .= '<br/>' . esc_html__( 'Encryption used:', 'mailarchiver' ) . ' ' . PwdProtect::get_encryption_details(); + } else { + $description = esc_html__( 'Your server does not have OpenSSL installed. Mail body encryption is unavailable.', 'mailarchiver' ); + } + $description = esc_html__( 'Key used to encrypt mail body: once set, you can\'t change it. Let blank to not encrypt it.', 'mailarchiver' ) . '<br/>' . $description; + enabled = PwdProtect::is_available(); + $value = PwdProtect::is_available() ? Secret::get( $this->current_archiver['privacy']['encryption'] ) : ''; + $readonly = false; } else { - $description = esc_html__( 'Your server does not have OpenSSL installed. Mail body encryption is unavailable.', 'mailarchiver' ); + $description = esc_html__( 'The key is already set. You can\'t change it.', 'mailarchiver' ); + enabled = true; + $readonly = true; + $value = $this->password_set; } add_settings_field( 'mailarchiver_archiver_privacy_encryption', @@ -1117,10 +1145,11 @@ 'mailarchiver_archiver_privacy_section', [ 'id' => 'mailarchiver_archiver_privacy_encryption', - 'value' => PwdProtect::is_available() ? Secret::get( $this->current_archiver['privacy']['encryption'] ) : '', - 'description' => esc_html__( 'Key used to encrypt mail body. Let blank to not encrypt it.', 'mailarchiver' ) . '<br/>' . $description, + 'value' => $value, + 'description' => $description, 'full_width' => false, - 'enabled' => PwdProtect::is_available(), + 'enabled' => $enabled, + 'readonly' => $readonly, ] ); register_setting( 'mailarchiver_archiver_privacy_section', 'mailarchiver_archiver_privacy_encryption' ); @@ -113,17 +113,18 @@ * @param string $value The string to put in the text field. * @param string $description Optional. A description to display. * @param boolean $full_width Optional. Is the control full width? - * @param boolean $enabled Optional. Is the control enabled? + * @param boolean $enabled Optional. Is the control enabled? + * @param boolean $readonly Optional. Is the control readonly? * @return string The HTML string ready to print. * @since 1.0.0 */ - public function field_input_password( $id, $value = '', $description = null, $full_width = true, $enabled = true ) { + public function field_input_password( $id, $value = '', $description = null, $full_width = true, $enabled = true, $readonly = false ) { if ( $full_width ) { $width = ' style="width:100%;"'; } else { $width = ''; } - $html = '<input' . ( $enabled ? '' : ' disabled' ) . ' name="' . $id . '" type="password" id="' . $id . '" value="' . $value . '"' . $width . '/>'; + $html = '<input' . ( $enabled ? '' : ' disabled' ) . ' name="' . $id . '" type="password" id="' . $id . '" value="' . $value . '"' . $width . ' ' . ( $readonly ? 'readonly' : '' ) . '/>'; if ( isset( $description ) ) { $html .= '<p class="description">' . $description . '</p>'; } @@ -137,7 +138,7 @@ * @since 1.0.0 */ public function echo_field_input_password( $args ) { - echo $this->field_input_password( $args['id'], $args['value'], $args['description'], $args['full_width'], $args['enabled'] ); + echo $this->field_input_password( $args['id'], $args['value'], $args['description'], $args['full_width'], $args['enabled'], $args['readonly'] ?? false ); }
Exploit Outline
To exploit this vulnerability, an attacker with Administrator-level access must navigate to the plugin's settings page (wp-admin/admin.php?page=mailarchiver-settings). The attacker identifies a vulnerable configuration field, such as the 'To' field in an archiver configuration or the 'encryption key' in the privacy section. By injecting a payload like "><script>alert(document.domain)</script> and saving the settings, the malicious code is stored in the database. Because the Form class fails to escape values when rendering HTML fields, the script will execute automatically in the context of any user (typically other administrators) who visits the settings page.
Check if your site is affected.
Run a free security audit to detect vulnerable plugins, outdated versions, and misconfigurations.