Romhack 2025 CTF Writeup

Disclaimer

The whole CTF source code is available on GitHub together with all the slides of this year conference. If you'd like to solve the CTF yourself don't read further!

All the screenshots have been taken during the writing of this article.

Intro

The CTF was the highlight of Romhack 2025, right at the entrance participants were greeted with the CyberSaiyan stand, flashing in rainbow-colored leds. The stand was there to advertise the CTF: get a badge, capture the flag, and win a ticket for Romhack Camp 2026.

A visitor from the future

As soon as I powered the badge I was greeted with the Romhack logo and the conference agenda.

Badge GIF

The CTF starts with Crillin telling me that a guy (Trunks) landed on Earth all the way from the future, and left him a weird device:

Badge GIF

The device looks a TOPT generator, and by clicking the left button the DEBUG mode activates. Here I see some important info:

At first glance the first three items on the list tell me very little. I assume that the Local time is used for the TOTP computation, but I decide to start by following the link.

CapsCorp Home

Nothing really interesting at first glance, until... CTRL-SHIFT-I - By having a look at the HTML source I immediately notice the two commented anchor tags in the Contact Us section:

<section id="contact" style="padding:4rem 2rem; text-align:center;">
    <h2>Contact us</h2>
    <!-- <a href="/~bulma" title="Contact Bulma">Contact Bulma</a> -->
    <!-- <a href="/~trunks" title="Contact Trunks">Contact Trunks</a> -->
    <p>This feature is currently not available as we are running from Red Ribbon</p>
</section>

Reading Bulma notes I get two important things: Trunks has enough energy for one roundtrip and it is fundamental to stick with the time of departure. Going trough Trunks diary entries I get two new domain names to look into:

  1. redribbon.cybersaiyan.it
  2. trunks.cybersaiyan.it ([...]luckily Cyber Saiyan offered to help me with hosting my own server under their domain.[...])

Attacking RedRibbon

It looks like I have more recoinnesance to do. I start with redribbon.cybersaiyan.it - my job is to help Trunks stop Dr. Gero and the Androids, so I think it is the best target to look at.

A quick nmap reveals that an SSH server is listening on port 22, so I try to SSH into it:

NMap of redribbon.cybersaiyan.it

and of course I am greeted with fantastic "Permission denied (publickey)". So password authentication is not enabled on the host. My next step is to look into the SSH version, maybe it is vulnerable to some known exploit; but also this step is not successful.

I was looking at the wrong target.

Unlocking Time Travel

The only other lead to follow is trunks.cybersaiyan.it; maybe Trunks can help me. Here I follow the same steps as above, and I do my first mistake (more on this later). A quick nmap and ssh command later I am greeted with a date nine years and two hours in the future asking me for a TOTP... it looks like that the number on the badge is indeed a TOTP, I obviously try with the badge:

Trunks SSH server

Access denied. Remember the badge's debug mode? The two clocks are not synchronized... it's time to fix this.

Somehow I need to change the Local Time of the badge, at first glance I don't see any settings menu, so I start with the AP mode.

Now things got a bit messy - I had some difficulties connecting the badge at the laptop with a USB-C cable, as no logs were showed on the console. At the end I managed make it working by using PlatformIO VSCode extension. Once this little hiccup was solved the badge started behaving in a weird way every time I turned on the AP, I don't know what happened but after a couple of resets it started working fine again.

I connect my laptop at badge's AP while it is plugged in to read the logs. I navigate to 192.168.4.1 and I see an interesting "Admin" section:

Badge login

by looking on CyberSaiyan's GitHub page I see that the badge was used for the WHY2025, looking at the code I discover that the password is "saiyan". I notice straight away that a new setting is present: "Time Server", it points to pool.ntp.org - why the badge is not showing the correct time even if it is connected to the internet?

Ideally the badge should synchronize the clock upon connecting to the Internet, but no luck. It is by looking at the debug mode once again that I see something I ignored all along: "Time Sync Function at: 42009988"... "42009988" - It's an address!

So clear, I still don't know why I didn't realize earlier, I need to call a function at 0x42009988. Easy! No?

After spending some time playing Snake, I start looking into the source of the badge web page... and I find Tetris. Amazing, another game to waste time with, and no real progress.

Or so I thought.

At the end of my Tetris game the page proposes me to Send my score to the leaderboard along with a nickname. Finally a user input, I start immediately looking into the JS source code and in the sendScore function I see something that catches my eye. Why is the player name encoded in base64 and why the function checks for the length of it?

Tetris sendScore

I first try writing something in the player name field, and I see on the logs of the badge an interesting string: "Value in the buffer after memcopy" followed by the HEX representation of my player name. Oh this smells like a big fat buffer overflow.

Good news I found something, bad news I am very bad with buffer overflows.

I continue by adding a breakpoint in the code, I trigger the send score button and I fill the value of the variable "e" with a very long string and the badge crashes after dumping the whole stack and the registers in the logs. At least I can see the values in the registers.

My next step is to find the the length of the buffer, easy, now I have to do is to write 0x42009988 in the RA register. So I start adding values in the payload until I find a winner: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\x88\x99\x00\x42" (note how the address is reversed, this is because of the endianness of the ESPF32 CPU).

This time the badge connects to pool.ntp.org and syncs the time before crashing. At the next reboot I see the current date in the Local time field of the Debug Mode.

I see some good progress here, but I still didn't solve my problem, Trunks clock is two hours and nine years in the future, and I have no time, so my next step is to write a simple NTP server that runs locally to return the same date of Trunks machine.

Two hours later I am still at my desk, it's late, and after a long day at work I am tired, time to go to bed.

The next day I get up very early and I go to the gym, and between one exercise and the other I realize: Bulma told Trunks to remember the time of departure, I don't have to provide the time to the badge, Trunks remembers it. Too bad I am sweating on a bench, and my laptop is at home, plus I have 8 hours of work in front of me... I take my phone and google: "Check NTP server online", used the first link and wrote: trunks.cybersaiyan.it. No surprise the website shows me an error message: "Your clock is not accurate" followed by a timestamp nine years and two hours in the future. BINGO. I could have saved two hours of my time if only I would have remembered two important things: NTP uses UDP, and nmap -Pn uses TCP packets, I missed an important hint by not being accurate during recoinnesance.

Back home I exploit the buffer overflow again, this time after writing trunks.cybersaiyan.it in the Time Server configuration of the badge, and just like that I get a working TOTP.

The bad CyberHygiene of Dr. Gero

Upon login on Trunk's server I am greeted with his notes and an interesting folder named "drgero". By reading the notes I get the info that Trunks managed to recover part of a device to stop the Androids, and its content is in the "drgero" folder. I have to dig into it.

At first glance the folder contains only a README file, but by simply using ls -la I see that it is not a normal folder, but a git repo. The first thing I try is to run git status, and just like that I find a deleted file: "sshd.c". I restore it with git restore sshd.c and I open it. It is a C file, with the syntax of an RTOS task, maybe it is running on the badge? Another hidden function?

From the file I get some important info:

But - wait a minute, how do I start this server on the badge? I read the README file again and a word catches my eyes: Broadcast. The badge uses BLE for the Radar feature, maybe I need to broadcast a BLE beacon with DrGero value to start the SSH daemon?

For the following hours I tried anything related to BLE: Ubertooth, installing random apps on my phone to send BLE beacons, catching the data sent by the badge to see if anything other than badge name was sent. NO LUCK. I was tired and I decided to go trough "drgero" folder on Trunks server.

I am sure I have missed something, and .git folder is usually a goldmine of information, so I start enumerating the git tree, and it doesn't help: one commit, one branch, two files. In addition I retrieve the link of the repo on GitHub from the config file. The repo is private, but the profile is visible. In complete desperation for some progress, I focus on the only thing that catches my eye, the profile picture.

Dr Gero Propic

Do you see those weird artifacts? I do, and steganography flashes on my mind. I download the image and rush on Aperisolve. Guess what? NO LUCK!

I was short on ideas and I started to being tired, but I was too close to stop. I went back to the .git folder and started looking into the objects folder, and wait... why are there four folders? I was expecting three folders, one for the tree, one for the README and one for sshd.c. What's the fourth folder for?

By using git cat-file -p I print the content of the fourth object in the objects folder and I get the SSH signature of the commit.

Too many info at this hour of the day. I need to recap:

  1. I know that the SSH Username to use is DrGero,
  2. The key is RSA,
  3. I got a SSH signed commit, on a GitHub hosted repo.

Now I need to find the public key that is used to verify the signature. I start googling about GitHub and how public SSH and GPG keys are exposed for user profiles and I come up with the results to use:

Guess what? The response of the first two links was as empty as my head in that moment. So I decided to go to sleep.

The next morning I wake up, and I decide to bring my laptop at work. I HAVE TO FINISH THIS. I remember from some past CTF that by using RSA Cracker tool it is possible to get the private key starting from the public key. Best case scenario the public key is the only thing left to recover. But how?

On my way to the office I did something I should have done before: asking ChatGPT; I mean, OpenAI crawlers already did the research for me.

I ask about the GitHub user endpoints again, and the API endpoint ssh_signing_keys is part of the answer, but now, it is slightly different: /users/{username}/ssh_signing_keys.

Wait what? Are you telling me that the solution was in front of me all of this time, and I just had to scroll down the documentation page?

Anyway, I run a very satisfying cURL request:

curl -L \
  -H "Accept: application/vnd.github+json" \
  -H "X-GitHub-Api-Version: 2022-11-28" \
  https://api.github.com/users/DrGero-cs/ssh_signing_keys

and I get a beautiful public key. Now I just have to crack it, but before doing it I need to convert it in PEM format:

ssh-keygen -f drgero.pub -e -m pem > drgero.pem

and crack it (I have used RSACracker):

rsacracker --key drgero.pem  --private

and just like that I get Dr Gero's private signing key.

Now, if Dr. Gero has a good cyber hygiene I wouldn't have used the same SSH key for both signing commits and logging-in his server. But luckily for Trunks and me, he doesn't. I save the private key as id_rsa, and the public key as id_rsa.pub in my .ssh folder and I deactivate the Androids.

Red Ribbon defeated