ECSC 2026, Day 2: Jeopardy
13 October 2026A violent way to start the morning
"Taste it," Níels said, and handed me the box full of salted licorice. I could tell just from glancing at the packaging that this was going to be disgusting. I took a bite and, what a violent way to start the morning. It was pure disgust. The four of us were sitting in the hotel lobby, waiting for the rest of the team to wake up before we left, laughing at my misery, which soon got worse: I accidentally rubbed my right eye and got licorice salt in it. It burned so badly it was like getting pepper sprayed. What a violent way to start the morning, I thought. Hjalti scolded Níels and told him, "Don't nerf the team on day 1, we're now 1/10th down."
It wasn't long before we made our way to the venue, excitement rising. The hall was huge, and the red walls reminded me a little of the big hall in Harpa at home in Iceland. There were 42 flags hanging by the balconies and 400+ competitors taking their seats and setting up. We found our table and got set up.
The jeopardy
I did not find any flags today, but I got close to solving a few of the challenges. Still, it was very fun and I learned a lot about HAML, a templating language for Ruby, how to write udev rules, and how to exploit Unicode normalization, to name a few.
The challenges were quite intricate. We had 12 hours to solve them, and new challenges were introduced regularly, so if you got stuck you could move on to something different, which I really liked.
My favorite challenge: Njinxed
My favorite problem of the day was njinxed. I didn't get the flag, but web is quickly becoming my favorite category, and this is one of the reasons why.
The setup was a small Flask app sitting behind nginx, with SSI (Server Side Includes) turned on for the /portal/ path. The search box reflected your query straight into the page, so the first step was just <script>alert(1)</script> to confirm it was unescaped, and from there <!--#echo var="DATE_LOCAL" --> to confirm SSI itself was live. The real target was an /internal/docs/<name> route, guarded by a header check and blocked from the outside by nginx's internal; directive, serving documents out of a vault keyed by a secret, one of which was named ops/console.
An SSI <!--#include virtual="..." --> is a subrequest nginx makes to itself, so it sails past the internal; restriction entirely. We needed to get the slash through Flask's own filter somehow. ops/console on its own got rejected, because of a rule which forbids having a slash in the name, so we needed something that looked safe when Flask checked it but turned into a real slash after the check ran.
Double-encoding the slash as %252F did it — Flask only decodes once, so its filter sees the literal text %2F, not a slash, and lets it through. But when Flask redirects internally via X-Accel-Redirect, nginx decodes the URL a second time on its own pass, turning %2F into a /.
I thought it was a very well made challenge, because it involved that extra step of sending a request to nginx using nginx.
Results
We came in 20th out of 42 countries with 2038 points, so we are very happy with the results overall. See the scoreboard here.
Tomorrow the organizers are experimenting with new formats, Potluck and CTF-Unplugged. I'm very excited to try them out.
Photo reference: Bochum Wirtschaftsentwicklung / Donna und der Blitz GmbH