Classified Listing – AI-Powered Classified ads & Business Directory Plugin <= 5.3.8 - Unauthenticated Stored Cross-Site Scripting
Description
The Classified Listing – AI-Powered Classified ads & Business Directory Plugin plugin for WordPress is vulnerable to Stored Cross-Site Scripting in versions up to, and including, 5.3.8 due to insufficient input sanitization and output escaping. This makes it possible for unauthenticated attackers to inject arbitrary web scripts in pages that will execute whenever a user accesses an injected page.
CVSS Vector Breakdown
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:L/I:L/A:NTechnical Details
<=5.3.8What Changed in the Fix
Changes introduced in v5.3.9
Source Code
WordPress.org SVN### 1. Vulnerability Summary The **Classified Listing** plugin (versions <= 5.3.8) contains an unauthenticated stored cross-site scripting (XSS) vulnerability. The vulnerability exists within the listing submission and update logic handled via AJAX. Specifically, the `FormBuilderAjax::update_listing…
Show full research plan
1. Vulnerability Summary
The Classified Listing plugin (versions <= 5.3.8) contains an unauthenticated stored cross-site scripting (XSS) vulnerability. The vulnerability exists within the listing submission and update logic handled via AJAX. Specifically, the FormBuilderAjax::update_listing() function processes user-supplied data from the formData parameter using parse_str() but fails to adequately sanitize this data before storing it in the database (either as post content or post metadata). Because this action is available to unauthenticated users (when "Post as Guest" is enabled), an attacker can inject malicious scripts into a listing. These scripts execute in the context of any user (including administrators) who views the affected listing on the frontend or backend.
2. Attack Vector Analysis
- AJAX Action:
rtcl_update_listing(available viawp_ajax_nopriv_rtcl_update_listing) - Vulnerable Parameter:
formData - Authentication Required: None (unauthenticated), provided the "Post for unregistered user" setting is enabled.
- Preconditions:
- The plugin setting
post_for_unregistermust be enabled (General Settings -> Moderation). - A valid Form ID (
form_id) must be known (typically1by default).
- The plugin setting
3. Code Flow
- Entry Point:
app/Controllers/Ajax/FormBuilderAjax.phpregisters the AJAX actions:add_action( 'wp_ajax_nopriv_rtcl_update_listing', [ $this, 'update_listing' ] ); - Nonce Verification: The function checks for a nonce using
rtcl()->nonceId(which isrtcl_nonce) andrtcl()->nonceText(which isrtcl_ajax_nonce):if ( ! wp_verify_nonce( isset( $_REQUEST[ rtcl()->nonceId ] ) ? $_REQUEST[ rtcl()->nonceId ] : null, rtcl()->nonceText ) ) { ... } - Data Parsing: The
formDatastring (containing the listing details) is parsed into an array:if ( ! empty( $_POST['formData'] ) ) { parse_str( $_POST['formData'], $formData ); } - Registration/User Logic: If unauthenticated, it optionally registers a user based on the
emailfield in$formData. - Storage (Sink): The parsed
$formDatais used to create or update a listing. While truncated in the source, the plugin useswp_insert_post()andupdate_post_meta()to save fields likertcl_title,rtcl_content, and custom field metadata. Becauseparse_stris used on raw input and subsequent validation inFBHelper::formDataValidation(inferred) focuses on structure rather than script neutralization, malicious HTML tags are stored in the database. - Execution: When the listing is rendered via a template (e.g., single listing page), the unsanitized metadata or content is printed to the page.
4. Nonce Acquisition Strategy
The rtcl_update_listing action requires a valid WordPress nonce. This nonce is localized for the frontend listing submission form.
- Identify Shortcode: The listing form is rendered using the
[rtcl_post_form]shortcode. - Setup Page: Create a public page containing this shortcode to force the plugin to enqueue its scripts and localizations.
- Navigate: Use the
browser_navigatetool to visit this page. - Extract Nonce: The nonce is stored in the global JavaScript object
rtcl_common. Usebrowser_evalto retrieve it.- JS Variable:
rtcl_common.nonce - Nonce ID (Parameter name):
rtcl_nonce
- JS Variable:
5. Exploitation Strategy
Step 1: Enable Unauthenticated Posting
Configure the plugin to allow guests to post listings without an account.
Step 2: Create a Form Page
Create a page with the [rtcl_post_form] shortcode to expose the necessary AJAX nonce.
Step 3: Extract the Nonce
Navigate to the newly created page and extract rtcl_common.nonce.
Step 4: Execute the Exploit
Send a POST request to admin-ajax.php to create a new listing with an XSS payload.
- Endpoint:
/wp-admin/admin-ajax.php - Method:
POST - Content-Type:
application/x-www-form-urlencoded - Parameters:
action:rtcl_update_listingrtcl_nonce:[EXTRACTED_NONCE]formId:1(or the ID of the active listing form)formData: A URL-encoded string containing:rtcl_title:XSS Test <img src=x onerror=alert(document.domain)>rtcl_content:Malicious Listing Contentemail:attacker@example.com(required for guest posting)_rtcl_phone:<script>alert('Stored_XSS_Phone')</script>(often unescaped in listing details)
6. Test Data Setup
- Enable Guest Posting:
# Update settings to enable post_for_unregister and set new listings to publish immediately wp option patch update rtcl_general_settings moderation post_for_unregister 1 wp option patch update rtcl_general_settings moderation new_listing_status publish - Identify/Create Form: Ensure a form exists.
wp post list --post_type=rtcl_cf_group - Create Form Page:
wp post create --post_type=page --post_title="Submit Listing" --post_status=publish --post_content='[rtcl_post_form]'
7. Expected Results
- The AJAX response should return
{"success": true, ...}and alistingId. - A new post of type
rtcl_listingwill be created. - When visiting the listing URL (e.g.,
/?post_type=rtcl_listing&p=[ID]), the browser will execute the injected JavaScript (alert box).
8. Verification Steps
- Check Post Creation:
wp post list --post_type=rtcl_listing --fields=ID,post_title,post_status - Inspect Metadata for Payload:
# Replace [ID] with the ID from the AJAX response wp post meta list [ID] - Check for Sanitize Failures: Verify if the payload in
post_titleor_rtcl_phoneremains exactly as sent (e.g., includes<script>oronerror).
9. Alternative Approaches
- Custom Fields Injection: If
rtcl_titleis sanitized by WordPress core, target custom fields. The plugin allows arbitrary custom fields informData. Inject into a key likertcl_custom_field_[ID]. - Location/Category Injection: The
Import.phplogic suggests that categories/locations might be created on the fly. Try injecting XSS intortcl_tax_categoryorrtcl_tax_locationvia theformData. - Bypass Nonce (If applicable): If the
rtcl_noncecheck fails or the action string is incorrect (e.g.,-1), the exploit may work without a specific nonce, although the source suggests a specific check is present.
Summary
The Classified Listing plugin for WordPress is vulnerable to unauthenticated Stored Cross-Site Scripting (XSS) via the `rtcl_update_listing` AJAX action. This occurs because the plugin uses `parse_str()` on raw user-supplied data in the `formData` parameter and fails to adequately sanitize or escape this data before storing it in the database as listing content or metadata. When guest posting is enabled, an attacker can inject malicious scripts that execute in the context of any user, including administrators, who views the compromised listing.
Vulnerable Code
// app/Controllers/Ajax/FormBuilderAjax.php:748 if ( ! empty( $_POST['formData'] ) ) { parse_str( $_POST['formData'], $formData ); } else { $formData = []; } --- // app/Controllers/Ajax/FormBuilderAjax.php:757 $errors = FBHelper::formDataValidation( $formData, $form, $listing ); if ( ! empty( $errors ) ) { wp_send_json_error( apply_filters( 'rtcl_error_validation_update_listing', [ 'errors' => $errors ], $formData, $sections ) ); return; }
Security Fix
@@ -18,8 +18,12 @@ function edit_field_delete() { $data = null; $error = true; - if ( Functions::verify_nonce() ) { - $post_id = !empty( $_REQUEST['id'] ) ? $_REQUEST['id'] : 0; + if ( !Functions::verify_nonce() ) { + $msg = esc_html__( "Session expired", "classified-listing" ); + } elseif ( !current_user_can( 'manage_rtcl_options' ) ) { + $msg = esc_html__( "You do not have permission to delete custom fields.", "classified-listing" ); + } else { + $post_id = !empty( $_REQUEST['id'] ) ? absint( $_REQUEST['id'] ) : 0; if ( $post_id && ( $post = get_post( $post_id ) ) && $post->post_type === rtcl()->post_type_cf ) { $p = wp_delete_post( $post_id, true ); if ( $p ) { @@ -32,8 +36,6 @@ $data = $_REQUEST; $msg = esc_html__( "Field was not selected", "classified-listing" ); } - } else { - $msg = esc_html__( "Session expired", "classified-listing" ); } wp_send_json( [ 'data' => $data, @@ -43,6 +45,14 @@ } function edit_field_choose() { + if ( !Functions::verify_nonce() ) { + esc_html_e( "Session expired", "classified-listing" ); + die(); + } + if ( !current_user_can( 'manage_rtcl_options' ) ) { + esc_html_e( "You do not have permission to view custom fields.", "classified-listing" ); + die(); + } $html = null; $fields = Options::get_custom_field_list(); $html .= "<p>" . esc_html__( "You can choose from the available fields:", "classified-listing" ) . "</p>"; @@ -58,8 +68,12 @@ $data = null; $error = true; $type = !empty( $_REQUEST['type'] ) && array_key_exists( $_REQUEST['type'], Options::get_custom_field_list() ) ? esc_attr( $_REQUEST['type'] ) : 'text'; - if ( Functions::verify_nonce() ) { - $parent_id = !empty( $_REQUEST['id'] ) ? $_REQUEST['id'] : 0; + if ( !Functions::verify_nonce() ) { + $msg = esc_html__( "Session expired", "classified-listing" ); + } elseif ( !current_user_can( 'manage_rtcl_options' ) ) { + $msg = esc_html__( "You do not have permission to insert custom fields.", "classified-listing" ); + } else { + $parent_id = !empty( $_REQUEST['id'] ) ? absint( $_REQUEST['id'] ) : 0; if ( $type && $parent_id ) { $field_id = wp_insert_post( [ 'post_status' => 'draft', @@ -76,8 +90,6 @@ $data = $_REQUEST; $msg = esc_html__( "Select a field type", "classified-listing" ); } - } else { - $msg = esc_html__( "Session expired", "classified-listing" ); } wp_send_json( [ 'data' => $data,
Exploit Outline
The exploit targets the `rtcl_update_listing` AJAX action which is available to unauthenticated users when the 'Post for unregistered user' setting is enabled. 1. **Nonce Acquisition**: Navigate to any frontend page containing the `[rtcl_post_form]` shortcode. Extract the required AJAX nonce from the JavaScript global variable `rtcl_common.nonce`. 2. **Identify Target Parameters**: The vulnerability resides in the `formData` parameter, which is a URL-encoded string containing listing fields. 3. **Payload Construction**: Create a payload within `formData` for fields such as `rtcl_title`, `rtcl_content`, or custom metadata fields (e.g., `_rtcl_phone`). A typical payload would be: `rtcl_title=Test<img src=x onerror=alert(document.domain)>&email=attacker@example.com`. 4. **Execution**: Send an unauthenticated POST request to `/wp-admin/admin-ajax.php` with the following parameters: - `action`: `rtcl_update_listing` - `rtcl_nonce`: [EXTRACTED_NONCE] - `formId`: 1 - `formData`: [URL_ENCODED_XSS_PAYLOAD] 5. **Verification**: Once the listing is created or updated, navigate to the listing's public URL. The injected script will execute in the browser of any user viewing the page.
Check if your site is affected.
Run a free security audit to detect vulnerable plugins, outdated versions, and misconfigurations.