# How to write a CTF writeup that shows what you learned

Published: 2026-11-12 · By HackerGPT Team
Canonical: https://hackergpt.org/blog/how-to-write-a-ctf-writeup/

> A CTF writeup template that records how you reasoned, not just the flag — with a complete example writeup of the Missing Encoding challenge on CyberGym, dead end included.

**TL;DR:** A good CTF writeup records how you got to the flag, not just the flag: what the challenge asked, what you observed first, what you tried that failed, the exact requests that worked, why they worked, and how a developer would fix it. Write it from notes taken while you solve, quote real commands and output, and end with the one thing you would do differently next time. That last part is what shows you learned something.

This is for CTF players and students who already solve a few challenges and want their notes to be worth reading, by someone stuck on the same task or by you in six months. You get a template, a note-taking habit that makes the template easy to fill, and a complete example writeup of a CyberGym challenge with the dead end left in. Everything here runs on [CyberGym](https://hackergpt.org/features/cybergym/), a deliberately vulnerable practice target we host for this purpose: practise and write up only targets you own or have written permission to test.

## What is a CTF writeup, and who is it for?

A CTF writeup is a short document that explains how a challenge was solved, written after the solve. [CTFtime](https://ctftime.org/writeups) indexes them per event and per task, and after most events you will find several for the same challenge, written by different teams.

A writeup has two readers, and both want the reasoning rather than the answer:

- **Someone who is stuck.** They want the step they missed and why it was the right step, so the next challenge goes better. A pasted flag teaches them nothing.
- **You, later.** Six months on, "used %23" means nothing. "The browser cut the request at `#` because a fragment never reaches the server" is a rule you can reuse on a real application.

There is a third reader for people building a career: whoever looks at your GitHub or blog when you apply for something. A set of clear writeups shows how you work, which is why [how to learn hacking on a real target](https://hackergpt.org/blog/how-to-learn-hacking/) suggests keeping them from the start.

## What should a CTF writeup include?

A writeup that shows learning has eight parts, and most of them are one or two sentences. The order follows the solve, so it doubles as a checklist while you work.

![Eight parts of a CTF writeup in solve order: challenge, goal, first look, dead ends, solve, root cause, fix, lesson](https://hackergpt.org/blog/how-to-write-a-ctf-writeup/writeup-anatomy.webp) *The parts in green are the ones a flag-only writeup leaves out, and the ones that show what you learned.*

1. **Challenge** — name, category, difficulty, the target and its version, the date you solved it. Versions matter: a challenge can behave differently on another instance.
2. **Goal in your own words** — restate the task. If you cannot, you have not understood it yet.
3. **First look** — what you read before you attacked anything, and what pointed you at the solution.
4. **Dead ends** — what you tried that did not work and what that ruled out. This is the part most writeups delete, and the part that makes yours useful.
5. **The solve** — the exact commands or requests, with the output trimmed to what proves the point. Fenced code, not screenshots, so it can be searched and copied.
6. **Root cause** — why it worked, in one paragraph, with a link to the spec, the documentation or the source code that explains it.
7. **Fix** — how a developer would close it. Thinking about the fix is what turns a CTF trick into something you can use when you [write up a finding for a real pentest](https://hackergpt.org/blog/pentest-report-example/).
8. **Lesson** — the rule you take away, and what you would check first next time.

## How do I take notes during a CTF so the writeup is easy?

Keep a running log while you solve, one line per action: the time, the command, what came back, and what you thought it meant. The writeup is then an edit of that log rather than an act of memory.

```text
14:02  GET /rest/memories -> 10 photos, one caption full of '#' -> suspect
14:05  curl raw path -> 200, but text/html 75 KB?? that's the app shell, not a jpg
14:07  curl -v -> request line stops at '-' before the first '#'. fragment!
14:09  %23 instead of '#' -> 200 image/jpeg 19 KB. solved
```

Three habits make the log worth having:

- **Save raw output**, not your memory of it. Redirect long responses to a file (`> step3.json`) and quote from it later.
- **Write down the wrong guesses** at the moment you make them. They are the dead ends section, and you will not remember them afterwards.
- **Note the surprise.** Every solve has a moment where the target did something you did not expect. That moment is usually the lesson.

## CTF writeup example: Missing Encoding on CyberGym

What follows is a complete writeup in the shape above, of a difficulty-1 challenge on CyberGym. It is a spoiler for that one challenge. Every command was run on 12 November 2026 against CyberGym, which reports its version at `/rest/admin/application-version` as `19.1.1`.

### Challenge

**Missing Encoding** — category Improper Input Validation, difficulty 1. Target: CyberGym (OWASP Juice Shop 19.1.1). Task text, from the challenge list:

```bash
curl -s "https://cybergym.hackergpt.app/api/Challenges/?name=Missing%20Encoding" \
  | jq -r '.data[0].description'
```

```text
Retrieve the photo of Bjoern's cat in "melee combat-mode".
```

### Goal in my own words

There is a photo on the site that does not load. Get the server to return the actual image file.

### First look

The photos live on the Photo Wall, and the app loads them from an API. I asked for only the caption and file path of each entry, because that response carries more than the challenge needs:

```bash
curl -s https://cybergym.hackergpt.app/rest/memories \
  | jq -r '.data[] | "\(.caption) | \(.imagePath)"' | head -3
```

```text
😼 #zatschi #whoneedsfourlegs | assets/public/images/uploads/ᓚᘏᗢ-#zatschi-#whoneedsfourlegs-1572600969477.jpg
Magn(et)ificent! | assets/public/images/uploads/magn(et)ificent!-1571814229653.jpg
My rare collectors item! [̲̅$̲̅(̲̅ ͡° ͜ʖ ͡°̲̅)̲̅$̲̅] | assets/public/images/uploads/my-rare-collectors-item!-[̲̅$̲̅(̲̅-͡°-͜ʖ-͡°̲̅)̲̅$̲̅]-1572603645543.jpg
```

The cat is the first entry. Its file name contains two `#` characters, and `#` has a special meaning in a URL. That was my hypothesis before I sent anything.

### Dead end: the 200 that was not the image

I requested the path exactly as the API returned it:

```bash
curl -s -o /dev/null -w "%{http_code} %{content_type} %{size_download}\n" \
  "https://cybergym.hackergpt.app/assets/public/images/uploads/ᓚᘏᗢ-#zatschi-#whoneedsfourlegs-1572600969477.jpg"
```

```text
200 text/html; charset=UTF-8 75294
```

A `200` looks like success. It is not: a JPEG does not come back as 75 KB of `text/html`. This is the single-page app's HTML shell, which the server sends for paths it does not have a file for. Lesson inside the lesson: check the content type and size, not just the status code. Asking curl to show the request it actually sent explained it:

```bash
curl -sv -o /dev/null \
  "https://cybergym.hackergpt.app/assets/public/images/uploads/ᓚᘏᗢ-#zatschi-#whoneedsfourlegs-1572600969477.jpg" \
  2>&1 | grep '^> GET'
```

```text
> GET /assets/public/images/uploads/%e1%93%9a%e1%98%8f%e1%97%a2- HTTP/1.1
```

Everything from the first `#` on never left my machine. The server was asked for a file called `ᓚᘏᗢ-`, which does not exist.

![The unencoded # ends the path, so the server gets a truncated name and returns HTML; %23 sends the full name and returns the JPEG](https://hackergpt.org/blog/how-to-write-a-ctf-writeup/fragment-split.webp) *Same file name, two requests. The fragment is kept by the client; only the part before it is sent.*

### Solve

Percent-encode each `#` as `%23` so it is part of the path instead of the start of a fragment:

```bash
curl -s -o cat.jpg -w "%{http_code} %{content_type} %{size_download}\n" \
  "https://cybergym.hackergpt.app/assets/public/images/uploads/ᓚᘏᗢ-%23zatschi-%23whoneedsfourlegs-1572600969477.jpg"
file cat.jpg
```

```text
200 image/jpeg 19028
cat.jpg: JPEG image data, JFIF standard 1.01, resolution (DPI), density 96x96, segment length 16, progressive, precision 8, 341x341, components 3
```

A 341×341 JPEG: the photo. CyberGym is one shared instance, so the challenge list already showed this challenge as solved by someone else, and that tick proved nothing about my request. What does: Juice Shop marks it solved when the requested URL, lower-cased, ends in `%e1%93%9a%e1%98%8f%e1%97%a2-%23zatschi-%23whoneedsfourlegs-1572600969477.jpg` ([`routes/verify.ts`](https://github.com/juice-shop/juice-shop/blob/master/routes/verify.ts)), which is exactly the path sent above.

### Root cause

In a URI, `#` starts the fragment, and the fragment "is separated from the rest of the URI prior to a dereference" and handled only by the client ([RFC 3986, section 3.5](https://www.rfc-editor.org/rfc/rfc3986#section-3.5)). A character with a reserved meaning has to be percent-encoded when it is meant as data ([RFC 3986, section 2.1](https://www.rfc-editor.org/rfc/rfc3986#section-2.1)). The Photo Wall puts the stored `imagePath` into the page without encoding it ([`photo-wall.component.ts`](https://github.com/juice-shop/juice-shop/blob/master/frontend/src/app/photo-wall/photo-wall.component.ts)), so a browser makes the same truncated request curl did.

### Fix

Encode user-controlled file names when building a URL, for example with [`encodeURIComponent`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURIComponent) on the file-name segment, which turns `#` into `%23`. Better still, do not keep the uploader's file name at all: store uploads under a generated name and keep the original only as display text.

### Lesson

A status code is a claim, not proof; the content type and size are the evidence. And when a request "works" but returns the wrong thing, look at what was actually sent: `curl -v` shows the request line, and the browser's Network tab does the same.

## What makes a writeup show what you learned?

The difference is whether a reader could solve the *next* challenge after reading it. The same solve, written two ways:

| Flag-only writeup | Writeup that shows learning |
| --- | --- |
| "Encode the # and you get the cat." | Says why `#` broke the request, with the spec |
| One working command | The failed request, the misleading `200`, and what gave it away |
| No version or date | Target version and date, so the reader knows if it still applies |
| Ends at the flag | Ends with the fix and a rule for next time |

When you finish a draft, read it as the stuck person would and ask one question: does it say why each step was the right step? If a step only says what you did, add the sentence that says why.

## Should I publish my CTF writeups, and where?

Publish them once you are allowed to: for a live event, read its rules first, and if they say nothing about writeups, wait until it has ended. Always-on practice targets such as CyberGym are fine to write up at any time.

Where to put them depends on who you want to read them. A GitHub repository with one Markdown file per challenge is easy to keep and to link; a personal blog is better for longer pieces; and CTFtime's [writeups section](https://ctftime.org/writeups) lets players find yours by event and task. Before you publish, remove anything that is not yours to share: tokens, session cookies, other users' data in a response, and the flags of challenges that are still running.

## Can an AI help me write a CTF writeup?

It can give you the raw material, but the reasoning section has to be yours, because that is the part that shows you learned something. HackerGPT's [CTF Mode](https://hackergpt.org/features/ctf-mode/) explains every step as it works a challenge, keeps what it learned about the target in the Memory tab, and saves the inputs that made the app misbehave in the Payloads section, which together read much like the running log above. [How to use the CTF Mode workspace](https://hackergpt.org/blog/ctf-mode-workspace-guide/) shows a run end to end, including the moment the agent looked up published writeups of the same challenge, which you will want to steer it away from if you are practising.

A practical split: let the agent's run be your notes, then write the root cause, fix and lesson yourself, and check each claim against the request that proves it. If you cannot explain a step without the transcript open, solve that step again by hand.

## Key takeaways

- Write the writeup from a log you keep while solving: time, command, result, what you thought it meant.
- Keep the dead ends; they are the most useful part for a reader who is stuck.
- Quote real commands and trimmed real output in fenced code, with the target version and date.
- Explain the root cause with a link to the spec or source, then say how a developer would fix it.
- End with one rule you will apply next time; if you cannot name one, the writeup is not finished.

## FAQ

### What is a CTF writeup?

A CTF writeup is a short document explaining how a capture-the-flag challenge was solved, written after the solve. A useful one covers the challenge, what the solver observed, what failed, the exact steps that worked, why they worked and how the flaw would be fixed. Writeups are commonly published on GitHub, personal blogs and CTFtime after an event ends.

### How long should a CTF writeup be?

As long as the reasoning needs and no longer. An easy challenge can be written up in a few hundred words; a hard one may need several sections. Trim command output to the lines that prove the point, and cut anything a reader would skim.

### Should a CTF writeup include the flag?

For a challenge that is still running, no: keep the whole writeup private until the event ends, unless its rules say otherwise. For an always-on practice target the flag is usually beside the point: the steps and the reason they work are what a reader came for.

### Is it OK to read other people's writeups while practising?

After you have tried, yes. Reading a writeup before you have formed your own hypothesis turns practice into copying. A good rule is to read one only when you are stuck, take the single step you missed, and then continue on your own.

### Where can I practise CTF challenges to write up?

On targets built for it. CyberGym is a hosted, deliberately vulnerable web app with a challenge list you can work through and write up at any time. [CTF for beginners](https://hackergpt.org/blog/ctf-for-beginners-web-challenges/) walks through a first web challenge there and the reading-first loop that goes with it.

## Sources

- [CTFtime — Writeups](https://ctftime.org/writeups)
- [RFC 3986, Uniform Resource Identifier: Generic Syntax — section 3.5, Fragment](https://www.rfc-editor.org/rfc/rfc3986#section-3.5)
- [RFC 3986 — section 2.1, Percent-Encoding](https://www.rfc-editor.org/rfc/rfc3986#section-2.1)
- [OWASP Juice Shop — `routes/verify.ts`](https://github.com/juice-shop/juice-shop/blob/master/routes/verify.ts)
- [OWASP Juice Shop — `photo-wall.component.ts`](https://github.com/juice-shop/juice-shop/blob/master/frontend/src/app/photo-wall/photo-wall.component.ts)
- [MDN — `encodeURIComponent()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURIComponent)

Practise the loop on [CyberGym](https://cybergym.hackergpt.app), or point the same method at your own site with [HackerGPT](https://hackergpt.app).
