文章总结: 本文深入研究并复现了SiYuan笔记在3.6.0版本之前存在的SQL注入漏洞(CVE-2026-29073)。该漏洞源于系统对SQL接口的管理员权限校验缺失,导致任何已登录用户(即使是读者权限)都能通过/api/search/fullTextSearchBlock接口执行任意SQL语句。文章详细分析了攻击者如何利用此漏洞进行数据泄露、篡改乃至摧毁服务,并提供了环境搭建方法与Python验证脚本。 综合评分: 85 文章分类: 漏洞分析,WEB安全,渗透测试,恶意软件,安全运营
SiYuan SQL漏洞 | CVE-2026-29073复现&研究
原创
404号浪漫 404号浪漫
404号浪漫
2026年3月22日 21:53 北京
点击蓝字,关注我们
0x0 背景介绍
SiYuan(思源笔记)是一款开源的个人知识管理系统,支持细粒度的隐私控制。
在3.6.0之前的版本中,系统的SQL接口存在权限校验漏洞。虽然该接口检查了用户的登录状态(Basic Auth),但未对执行SQL的管理员权限进行二次验证。任何已登录的用户(即使仅拥有最低级别的读者权限)都可以通过该接口向数据库直接发送并执行任意SQL查询,导致系统内存储的笔记数据被泄露、篡改或彻底删除。
漏洞详情
| 漏洞类型 | 影响版本 | 利用复杂度 | CVE编号 | | — | — | — | — | | SQL注入 | <= 3.6.0 | 低 | CVE-2026-29073 |
攻击效果:
- 伪执行任意SQL语句,从而造成数据泄露、篡改等。
0x1 环境搭建(Ubuntu24)
1.1-Ubuntu24+Docker搭建配置
- ### 另存为install.sh运行
#!/bin/bash
# ==========================================# 思源笔记 (SiYuan Note) 一键部署脚本 (纯净版)# 版本: v3.6.0 (含权限校验漏洞版本,仅用于安全研究)
set -e
# --- 配置区域 (可自定义) ---PROJECT_DIR="$HOME/siyuan-note"CONTAINER_NAME="siyuan-note"IMAGE_VERSION="b3log/siyuan:v3.6.0"HOST_PORT="6806"CONTAINER_PORT="6806"TIMEZONE="Asia/Shanghai"
# 默认密码DEFAULT_PASSWORD="MySuperSecretRootPassword2026!"# 运行用户 ID (通常不需要改,除非宿主机用户 ID 不是 1000)RUN_UID=1000RUN_GID=1000
echo "=============================================="echo " 思源笔记 (SiYuan Note) 一键部署脚本"echo " 目标版本: ${IMAGE_VERSION}"echo " (注:此版本存在 CVE-2026-29073 SQL 注入漏洞)"echo "=============================================="
# 阶段 0: 检查依赖echo "[*] 阶段 0/5:检查环境依赖..."if ! command -v docker &> /dev/null; then echo "[x] 未检测到 Docker,请先安装 Docker" exit 1fiif ! command -v docker compose &> /dev/null && ! docker compose version &> /dev/null; then if command -v docker-compose &> /dev/null; then alias docker compose="docker-compose" echo "[*] 检测到旧版 docker-compose,已兼容处理" else echo "[x] 未检测到 Docker Compose,请先安装" exit 1 fifiecho "[+] Docker 环境检查通过"
# 阶段 1: 创建目录echo "[*] 阶段 1/5:创建工作目录..."mkdir -p "${PROJECT_DIR}"/{workspace,backups}cd "${PROJECT_DIR}" || { echo "[x] 进入目录失败"; exit 1; }echo "[+] 工作目录: $(pwd)"
# 阶段 2: 生成配置文件echo "[*] 阶段 2/5:生成配置文件 (.env & docker-compose.yml)..."
# 生成 .envcat > .env <<EOF# 思源笔记访问密码/auth code SIYUAN_PASSWORD=${DEFAULT_PASSWORD}# 运行用户 IDPUID=${RUN_UID}PGID=${RUN_GID}EOF
# 生成 docker-compose.ymlcat > docker-compose.yml <<EOFversion: '3'
services: siyuan: image: ${IMAGE_VERSION} container_name: ${CONTAINER_NAME} restart: unless-stopped
ports: - "${HOST_PORT}:${CONTAINER_PORT}"
volumes: - ./workspace:/siyuan/workspace - ./backups:/siyuan/backups
environment: - TZ=${TIMEZONE} - PUID=\${PUID} - PGID=\${PGID} - SIYUAN_ACCESS_AUTH_CODE=\${SIYUAN_PASSWORD}
# 资源限制 (可选) deploy: resources: limits: memory: 1G cpus: '1.0'EOF
echo "[+] 配置文件生成完毕"
# 阶段 3: 修正权限 (关键步骤)echo "[*] 阶段 3/5:修正目录权限..."# 确保当前用户拥有目录所有权,避免容器内 UID 1000 无法写入chown -R $(id -u):$(id -g) ./workspace ./backupschmod -R 755 ./workspace ./backupsecho "[+] 权限设置完成 (所有者: $(whoami))"
# 阶段 4: 启动服务echo "[*] 阶段 4/5:启动 Docker 容器..."docker compose up -d
echo "[*] 等待服务初始化 (约 15 秒)..."for i in {1..6}; do echo -n "." sleep 2.5doneecho ""
# 阶段 5: 健康检查echo "[*] 阶段 5/5:检查服务状态..."
# 检查容器是否运行if [ "$(docker inspect -f '{{.State.Running}}' ${CONTAINER_NAME} 2>/dev/null)" != "true" ]; then echo "[x] 容器启动失败!请查看日志:" docker logs --tail 20 ${CONTAINER_NAME} exit 1fi
# 检查日志中是否包含成功标志MAX_WAIT=30COUNT=0while [ $COUNT -lt $MAX_WAIT ]; do if docker logs ${CONTAINER_NAME} 2>&1 | grep -q "kernel booted"; then break fi sleep 1 COUNT=$((COUNT+1))done
if [ $COUNT -ge $MAX_WAIT ]; then echo "[!] 警告:未在 ${MAX_WAIT} 秒内检测到 'kernel booted',但容器正在运行。" echo " 可能是首次启动索引重建较慢,请稍后检查日志。"else echo "[+] 服务内核启动成功!"fi
# 获取本地 IPLOCAL_IP=$(hostname -I | awk '{print $1}')
echo "=============================================="echo " 思源笔记部署完成!"echo "=============================================="echo " 访问地址:"echo " 局域网: http://${LOCAL_IP}:${HOST_PORT}"echo " 本地: http://localhost:${HOST_PORT}"echo ""echo " 初始密码/auth code:"echo " ${DEFAULT_PASSWORD}"echo " (提示:请编辑 .env 文件修改密码并重启容器以保障安全)"echo ""echo " 数据位置:"echo " ${PROJECT_DIR}/workspace"echo " ${PROJECT_DIR}/backups"echo ""echo " ⚠️ 安全提醒:"echo " 此版本 (v3.6.0) 存在 CVE-2026-29073 漏洞"echo " 普通用户可通过 /api/search/fullTextSearchBlock 接口执行任意 SQL"echo " 请仅在隔离环境用于安全测试,勿用于生产环境!"echo ""echo " 常用命令:"echo " 查看日志:docker logs -f ${CONTAINER_NAME}"echo " 重启服务:docker compose restart"echo " 停止服务:docker compose down"echo "=============================================="
0x2 漏洞复现
2.1-脚本验证
- ## python验证
https://github.com/Kai-One001/cve-/blob/main/SiYuan-CVE-2026-29073.py
2.2-手动验证 2.1 场景 A:数据泄露 (SELECT)
2.2 场景 B:内容破坏 (INSERT)
2.3 场景 C:服务可用性被摧毁 (DROP TABLE)
- 原理概念
{"method":2,"query":"DROP TABLE assets","page":1,"pageSize":10}
- 后果: assets表被删除,导致图片上传、附件管理等功能彻底失效,系统日志报错刷屏。
{"method":2,"query":"DELETE FROM blocks","page":1,"pageSize":10}
- 后果: 执行后,前端所有笔记内容瞬间消失,且无法通过常规手段恢复(除非有本地备份)。
2.4 场景 D:跨表敏感数据读取(任意表”伪装为块”泄露)
此前 PoC 仅演示了对blocks表的直接读取。结合源码可以发现,/api/search/fullTextSearchBlock在SQL模式下,会把任意 SQL 结果强行映射为Block结构(scanBlockRows 依次 Scan 21 个字段),然后再返回给前端。
这意味着:只要攻击者构造出一条”列数量和类型上可以被Block结构接收”的 SELECT,就可以把任意业务表的数据(例如用户信息表、附件元数据表、权限/分享策略表中的敏感字段)注入到某些块字段中(如 content、memo、alias 等),从而”披着块的皮”返回到接口响应里,实现跨表敏感数据泄露。
HTTP 流量示例(概念)
POST /api/search/fullTextSearchBlock HTTP/1.1Host:<目标主机>:<端口>Content-Type: application/jsonCookie: siyuan=<普通用户Session>
{ "method": 2, "query": "<构造为:SELECT ... FROM 目标敏感表/视图,通过子查询/表达式填充成 Block 结构的列>", "page": 1, "pageSize": 10}
- 不再局限于
blocks内容本身,而是可以读取数据库中任意业务表的敏感信息,包括但不限于用户账号信息、共享/访问控制策略、附件/资源定位信息等 - 由于返回结构仍然是
blocks,这类泄露在日志/审计层面不易与普通搜索流量区分,增加排查难度
2.5 场景 E:解析降级导致的写操作执行(DELETE/UPDATE/INSERT/DROP)
searchBySQL 最终调用的是 sql.SelectBlocksRawStmt,其核心逻辑为:
•优先尝试用 sqlparser.Parse(stmt) 解析 SQL;•若解析失败(err != nil),则直接调用 selectBlocksRawStmt(stmt, limit),内部对 stmt 原样调用底层query(stmt)•对于解析成功但 AST 类型不是 SELECT/UNION 的情况,则直接 return(不执行)。
结合这一逻辑,可以得出结论:
- 若攻击者构造一条在 SQLite 中合法可执行,但当前
sqlparser无法正确解析的 SQL 语句(例如使用特定方言/语法特性),就会触发”解析失败 → 降级直接执行”的路径; - 在该路径下,即使语句是
DELETE/UPDATE/INSERT/DROP等写操作,底层数据库仍会实际执行,只是由于结果集无法映射为Block结构,接口可能返回空数组或不含数据。
HTTP 流量示例(结构模板)
POST /api/search/fullTextSearchBlock HTTP/1.1Host: <目标主机>:<端口>Content-Type: application/jsonCookie: siyuan=<普通用户Session>
{ "method": 2, "query": "<利用 SQLite 支持但当前 sqlparser 难以解析的写操作 SQL,用于在测试环境中对测试表/测试记录做最小破坏验证>", "page": 1, "pageSize": 10}
- 攻击者不再只是”理论上能写”,而是可以在一定条件下实际删改任意表中的记录、结构甚至整表;
- 这类写操作难以从接口返回中直接感知,容易在无声无息中完成数据破坏或后门植入。
2.6 场景 F:持久化篡改与二次受害(影响其他用户)
在确认写操作可行后,攻击者可以针对 blocks 表本身发起更具”传播性”的篡改。例如:
//我测没有成功,可能疏忽哪里了•通过任意 SQL 改写指定文档、指定块的 content / memo / alias 等字段,使其加入恶意内容(诱导链接、伪造提示信息等);•或修改块的元数据(如路径、标签、名称),干扰后续正常使用与检索。
由于 blocks 是前端渲染的核心数据源,上述篡改将具备以下特征:
- •在后续任意访问该文档/块的场景中被呈现;
- 对所有拥有访问权限的用户可见,而不仅限于当前用户。
HTTP 流量示例
POST /api/search/fullTextSearchBlock HTTP/1.1Host: <目标主机>:<端口>Content-Type: application/jsonCookie: siyuan=<普通用户Session>
{ "method": 2, "query": "<针对 blocks 表的 UPDATE/INSERT 等写操作,用于在测试环境中对某个测试文档块内容进行特征化改写>", "page": 1, "pageSize": 10}
可能的建议:
•在测试环境创建一篇"测试文档",记录其某个块的 id;•使用 SQL 模式对该块的内容或元数据做"打标签式"改写(例如增加明显的测试标记字符串);•使用: •同一账号; •另一个普通账号; •如有,公开访问码访问; 分别打开该文档,确认所有访问路径下都呈现了改写后的内容。
- 攻击者可以对现有文档进行持久化篡改,影响范围覆盖所有合法访问者,形成典型的”二次受害”;
- 若篡改内容包含钓鱼链接、伪造指引等,将进一步放大社会工程学攻击成功率;
- 对于依赖笔记内容进行自动化处理/同步的场景,还可能引入下游系统的风险。
2.7 场景 G:复杂查询导致的性能退化与拒绝服务
- 如果你只想增加危害,可以尝试DOS:
•利用 SQL 模式构造高复杂度查询(大范围全表扫描、多表 JOIN、大量模糊匹配等),在未做资源/超时限制的情况下,可能显著拉高 CPU/IO/内存占用;•即使不删库,只通过不断触发该接口即可对服务形成"慢性拒绝服务"攻击;•可通过记录接口响应时间、服务器日志中的 SQL 耗时等指标来给出客观证据。
2.3-复现流量特征 (PCAP)
- 获取认证后进行SQL查询
- INSERT操作
- 查询成功,不过没在WEB找到(另外响应体是正常的,我这看的是TCP流量,应该是HTTP)
#
0x3 漏洞原理分析
3.1- [路由守卫] 被遗忘的关卡:当搜索遇上 SQL
我这边追踪起点是Web框架的路由注册文件。在SiYuan的架构中,API接口的安全性很大程度上依赖于中间件链Middleware Chain的挂载。
首先,定位到 kernel/api/router.go。在这里,可以看到了两个看似功能相似,但不同的接口定义:
- 受保护的 SQL 查询接口 (基准线):
// kernel/api/router.go Line 177ginServer.Handle("POST", "/api/query/sql", model.CheckAuth, model.CheckAdminRole, model.CheckReadonly, SQL)
- 要访问此接口,请求必须依次通过三道关卡:
•CheckAuth: 确认用户已登录。•CheckAdminRole: 关键! 强制要求用户必须是管理员。•CheckReadonly: 确保当前不是只读模式(防止写操作)。•只有同时满足这三个条件,SQL 函数才会被执行。
- 失守的搜索接口 (漏洞点):
// kernel/api/router.go Line 188ginServer.Handle("POST", "/api/search/fullTextSearchBlock", model.CheckAuth, fullTextSearchBlock)
- 这里出现了很经典的”防御缺口”。可能觉得这只是一个”搜索”功能,因此只挂载了
CheckAuth。 - 不过,往往都有是开发的疏忽导致意想不到的结果,忽略了这个搜索功能,只要用户登录(哪怕是Reader角色),就能直入调用
fullTextSearchBlock函数。 - 这是第一道防线的崩塌:权限校验的粒度与内部功能的危险程度严重不匹配。
3.2- [逻辑黑洞] 危险的”方法”:信任边界的彻底瓦解
进fullTextSearchBlock函数 (kernel/api/search.go) 后,我们继续追踪数据流向。代码解析了前端传来的参数,其中关注到这method字段。
// kernel/api/search.go Line 389-411func fullTextSearchBlock(c *gin.Context) { // ... 参数解析 ... page, pageSize, query, paths, boxes, types, method, orderBy, groupBy := parseSearchBlockArgs(arg)
// 核心调用:将用户控制的 method 和 query 直接传入模型层 blocks, matchedBlockCount, matchedRootCount, pageCount, docMode := model.FullTextSearchBlock(query, boxes, paths, types, method, orderBy, groupBy, page, pageSize) // ...
}
小知识课堂:
注意函数名前面的model.前缀。在Go语言中,这表示调用的是名为model 的包(Package)中的函数,被调用的函数名是 FullTextSearchBlock核心逻辑就是 model 包里(PS:IDE可以直接:鼠标按住 Ctrl (或 Cmd) 点击 model.FullTextSearchBlock跳转)
接着,我们深入到kernel/model/search.go中的FullTextSearchBlock实现。在这里,逻辑发生了分叉:
// kernel/model/search.go Line 1205-1206switch method {case 0, 1: // 正常的全文检索逻辑,安全 blocks = searchByKeyword(...) case 2: // 当 method=2 时,直接进入 SQL 执行模式 blocks, matchedBlockCount, matchedRootCount = searchBySQL(query, beforeLen, page, pageSize)}
- 这里的
case 2是整个漏洞的逻辑核心。 - 为了实现某种高级自定义搜索功能,允许传入原始SQL。但是,在这个分支内部,没有任何关于”当前用户是否有权执行SQL”的二次检查。
- 盲目地信任了上游路由传来的请求,认为既然能进到这个函数,就是安全的。
3.3 [执行深渊] 裸奔的查询器:从字符串到数据库指令
最后,我们来到了漏洞爆发的终点——searchBySQL函数 (kernel/model/search.go) 及其调用的底层驱动。
// kernel/model/search.go Line 1460-1462func searchBySQL(stmt string, beforeLen, page, pageSize int) (ret []*Block, ...) { stmt = strings.TrimSpace(stmt) // 直接将用户输入的 stmt 传递给底层执行器 blocks := sql.SelectBlocksRawStmt(stmt, page, pageSize) // ...}
- 继续追踪到
kernel/sql/block_query.go:
109110111112113114115116117118119120121122123124// kernel/sql/block_query.go Line 566-569func SelectBlocksRawStmt(stmt string, page, limit int) (ret []*Block) { parsedStmt, err := sqlparser.Parse(stmt) if err != nil { // 解析失败?没关系,降级处理,直接执行原始语句! return selectBlocksRawStmt(stmt, limit) } // ... (即使解析成功,后续逻辑也未做严格的只读限制)}
// kernel/sql/block_query.go Line 713-714func selectBlocksRawStmt(stmt string, limit int) (ret []*Block) { // 【最终爆发点】调用 Go 标准库 db.Query,执行任意 SQL rows, err := query(stmt) // ...}
- 在
kernel/sql/database.go中,query函数仅仅是Godatabase/sql包的一层薄封装:
// kernel/sql/database.go Line 1327-1337func query(query string, args ...interface{}) (*sql.Rows, error) { // ... return db.Query(query, args...) // 这里是 SQLite 执行的绝对入口}
至此,链路完全打通
- 输入无过滤:用户的
query参数未经过任何白名单校验。 - 解析可绕过:即使有
sqlparser,代码逻辑在解析失败时选择了”降级执行”,这让攻击者可以通过构造特殊语法绕过解析检查。 - 执行无限制:最终调用的
db.Query是通用的SQL执行器,它不区分SELECT还是DROP。
在 SQLite中,db.Query甚至可以执行 DELETE和UPDATE(尽管通常推荐用 Exec,但在某些驱动实现或特定上下文中,副作用依然会发生,或者攻击者利用db.Exec的类似调用路径)。
完整攻击链路总结:
A[攻击者: 携带Token 角色] -->|1. POST /api/search... method=2| B(路由层: router.go)B -->|2. 缺失 CheckAdminRole | C{逻辑层: search.go}C -->|3. switch case 2 | D[函数: searchBySQL]D -->|4. 透传原始 SQL | E[驱动层: block_query.go]E -->|5. 降级/直接执行 | F((SQLite 数据库))F -->|6. 执行 DELETE/DROP | G[ 数据毁灭/泄露]
0x4 修复建议
1、升级最新版本:将组件升级最新版本
https://github.com/siyuan-note/siyuan
2、临时防护措施:
-
反向代理拦截:在Nginx/Apache层直接禁止访问该特定接口
-
网络隔离:确保思源笔记实例仅监听127.0.0.1,严禁暴露在公网或非受信局域网。
/**发芽了发芽了**/
免责声明:本文仅用于安全研究目的,未经授权不得用于非法渗透测试活动。
免责声明:
本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。
任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。
本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我。
本文转载自:404号浪漫 404号浪漫 404号浪漫《SiYuan SQL漏洞 | CVE-2026-29073复现&研究》
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。









评论