neven@dimitri-dev:~$
cd ../hackultet-2026

$cat uvod-u-web-sigurnost.mdWebHackultet 2026

Uvod u Web Sigurnost (Introduction to Web Security)

A search portal blocks any query or result containing the flag outright, but never blocks a mere substring of it, turning the filter itself into a way to read the flag out one piece at a time.

  • #boolean-oracle
  • #data-leak-protection-bypass

$man uvod-u-web-sigurnost

What this one teaches:

  • Building a boolean oracle out of a filter that only checks for the full secret, not pieces of it.
  • Growing a known fragment outward, left and right, instead of searching character by character from a fixed start.

Task description

Can you get past our data leak protection and find out the flag?

The original was in Croatian; this is my translation.

The protections

The target is a small PHP search portal that searches the contents of a few text files and shows snippets of what matched. Searching short, broad terms like a reveals there are at least three files, named 01_ncert.txt, 02_lorem_ipsum.txt, and 99_flag.txt.

Two filters guard the flag:

  1. Input filter: a query containing the literal text CTF2026 is rejected outright, with ZABRANJENI STRING (“forbidden string”) in place of any results.
  2. Output filter: if a search result’s content contains CTF2026, the snippet is replaced with NEMATE OVLASTI (“you don’t have permission”) instead of being shown.

Both filters check for the complete prefix CTF2026, which never appears alone in a query or a result unless the flag itself does.

The gap in the filter

Neither filter does anything about searching for a shorter piece of the flag’s content, a substring that doesn’t itself contain CTF2026. Searching for part of the flag’s digits still matches inside 99_flag.txt, and the response still says so, through the exact same “you don’t have permission” wording the output filter would otherwise give for any match containing the forbidden prefix. That response, present or absent, is a boolean oracle: for any string, it tells us whether that string appears somewhere in the flag file.

Growing the known fragment

Starting from single digits, each tried one at a time, the oracle tells us which of 0 through 9 appear anywhere in the flag file at all. From there, two-digit, then three-digit substrings narrow things down further, each confirmed hit a small window into the file’s actual content.

Several of these confirmed substrings overlap once there are enough of them. For instance, two independently discovered hits, 201566840 and 18642, both extend (one to the left, one to the right) into a shared middle section, 220, and stitching them together at that overlap gives a single longer confirmed substring, 18642201566840. Repeating that, extending every known fragment one digit at a time in both directions and merging overlaps, eventually produces a string that can’t be extended any further in either direction: 186422015668400.

That’s the flag’s content. Submitting it alongside a sanity check, confirming that the file matches 186422015668400 but not 1864220156684001, verifies there’s nothing more to find in either direction.

Automating the growth

def in_flag(q):
    resp = fetch(URL + "?q=" + quote(q))
    return "NEMATE OVLASTI" in resp

def extend(s):
    changed = True
    while changed:
        changed = False
        for d in "0123456789":
            if in_flag(s + d):
                s += d; changed = True; break
            if in_flag(d + s):
                s = d + s; changed = True; break
    return s

Seeded with any confirmed short hit (we used 1566), extend() walks outward in both directions until no digit extends it further, which lands directly on the full flag content: CTF2026[186422015668400].

$cat flag.txt revealCTF2026[186422015668400]

Written by Neven Marić. This writeup was written with the help of Claude Code (Anthropic).