These are the writeups for most of the Reverse and Web challenges of the 2021 edition of the Cybersecurity Challenge Portugal.


Doge - 100 pts

In this challenge we were given a simple website that only contained a number input field (and a beautiful doge image). Once we submit a valid number it redirects us to a blank page that reflects our input.

My first instinct was to test for XSS or SSTI but as I examined the response headers I saw that most of the XSS protection headers were set.

On to SSTI.

At this point I needed a bit more information on the infrastructure that the website is running on.

So I crashed it.

If we send a broken CSRF token it shows us a Ruby on Rails error page.

When I sent <% 7*7 %> the input didn’t reflect itself. Bingo! After testing some more payloads with the system function I eventually ended up using <% p `cat flag.txt` %> . Unfortunately it was too late and ended up solving it 2 minutes after the CTF ended. Sad.

If you want to learn more about Ruby on Rails SSTI please refer to this post.

Censos - 200 pts

We are given an unfinished webapp that Zé wants us to test. It uses the flask_cors package to configure CORS and it has a rather interesting line commented.

if(request.headers["Sec-Fetch-Site"] == "same-site" or request.headers["Sec-Fetch-Site"] == "none"):

The organizers also gave us a bot that was logged in to the site with an account that contained sensitive information and would connect to any user controlled domain. At this point it became pretty obvious that this was a CSRF challenge. The challenge author also hints us to check what browser the bot is running on. So I sent him to a request bin and checked the user-agent header. It was running Firefox 89.

If we take a deeper look at the code, we find that there is an /api endpoint that let’s us request any information about the user using the params GET parameter and that the website checks the session cookie to know if we are logged in. The CORS configuration is the following:

cors = CORS(app, supports_credentials=True, send_wildcard= False)

After reading flask cors’ documentation we see that the supports_credentials=True allows us to make authenticated requests and This allows cookies and credentials to be submitted across domains.

This is the part of the writeup where knowing what browser the bot is running is crucial. Chrome uses SameSite cookies by default whereas Firefox doesn’t. I learned this while solving this challenge from Angstrom Ctf with ice.

Exploit time! We only need a webpage that creates a XMLHttpRequest and sets the withCredentials flag so that Firefox uses the session cookie that was already set. After visiting the /api/?params=codigo_secreto endpoint it should send the response to a user controlled request bin.

If you want to know more about CSRF you can check out OWASP’s page and hack tricks’ page.

Diário - 300 pts

This time we are given a web page that lets us log in or register a user and after logging in lets us create, delete or search for notes. We are also given a bot with the same browser as the Censos challenge and the description said the flag only contained lowercase letters and curly braces O.o . So far it looks like a pretty common XSS scenario but after reviewing the code (and some braindead testing :P) I noticed the following PostgreSQL query in the note search endpoint:

query = '%%%s%%' % str(query)   

It instantly clicked inside my head. It is a XSLeaks challenge (shook). I also noticed it creates an iframe for each post that is found by the query

After some testing I ran into a problem. we can search for a single character but we don’t know in what position it is in the string or how many times it’s repeated. I’m not that good at Scrabble so I thought I needed to know how the flag started or ended. As the common prefix for all the flags was CSCPT I wasn’t sure if I could use that because the flag only contained lowercase letters. So I started from the end. If I started to search backwards I could always find the letter before the one I already knew. Time to write an exploit.

All that is required to exploit this challenge is a webpage with a script that iterates over all the lowercase letters and curly braces and creates an iframe setting its src to the search endpoint. After the created iframe has finished loading we can use the contentWindow.length attribute to check whether it loaded an iframe containing the flag or not and send the char that triggered that to a request bin.

To know more about XSLeaks I 1001% recommend the XSLeaks Wiki.


You can find the code for all the exploits here.


Truz Truz, Quem é? - 100 pts

We were given a binary that checks for the flag. It calls sleep(1) if a character is incorrect. By sending an empty string we can calculate the length of the flag, based on how many seconds the server takes to answer. After that we can bruteforce one character at a time. To speed up the process I ended up guessing parts of the flag CSCPT{timing_attacks_ftw}.

Smali - 150 pts

We were given part of the source of an Android ransomware (in Smali), some logs and an encrypted file.

After not being able to use a decompiler to translate the Smali file to Java, I took advantage of some skills I learned in last year’s Hack The Vote Ctf and proceeded to read the Smali file.

The first thing I searched for was for the Log function. It was called after a function named generateCipher and logged the following 06-02 20:49:11.493 2802 2802 V Cryptor : Key generated

While digging inside the encryptFile function I started analysing the initAESCipher function and noticed the file was encrypted with AES CBC with PKCS5Padding with an hardcoded IV: "QQqun 571012706 ".

So far so good. The only thing remaining was to discover where the AES key came from. I searched for SecretKeySpec and right after the call to generateCipher and to Log the SecretKeySpec constructor was being called with the result of generateCipher .

The generateCipher function uses the java.util.Random as a pseudo-random number generator to select 16 random characters (that would be the AES key) from an alphabet containing all the letters, decimal numbers and some symbols. The constructor of Random was being called with a seed that was the current time in milliseconds.

To recover the key I translated the generateCipher (replacing the call to currentTimeMillis with the milliseconds since epoch from the UTC timestamp in the log) and ran it, passing the result to a python script that received the base64 encoded ciphertext and the static IV, decrypted the ciphertext with AES CBC and returned the flag.

Secure Token - 200 pts

We were given a web service and its python source. It features a OTP auth system system.

After reading some of the code I concluded that each user seed is generated from the admin seed, xored with the admin username, xored with the user username.

That seed is passed into the gera_token function, that also receives the user username and the current tick (this is just a timestamp that changes every minute) as arguments and it returns a valid login token.

Looking at gera_token it only calls gera_token_aux with the same arguments and converts its output from bytes to a long.

gera_token_aux is a bit more complex. It xors the current seed with the padded username and then it calls the function f and that becomes the new seed value, doing that tick+1 times.

Inside the function f is where things get interesting. It takes a list of integers and has 2 temporary internal states: res and res2 .

  • The first loop takes each integer from the input and changes the order of its bits. This order is determined by the list S. res is built appending each bit (in the order determined by S) of each integer.
  • The second loop takes all the i-th bit of each 8 bit sub-sequence that could be formed from res appending them and building res2
  • The last step simply exchanges 4 bits with the following 4 bits

In my approach I prefered to think about res and res2 as 8 bit integer arrays. Taking this into consideration it’s much easier to reverse the algorithm.

  • If we apply the last step in the algorithm to any number 2 times we get the original number back, so all we need to do to undo this step is apply the same step again obtaining reversed_res2.
  • Then we build an array containing arrays of 8 bits from the reversed_res2 we just obtained.
  • Then we can build reversed_res by iterating sequentially through reversed_res2 and putting each bit in the i-th position of each element in the reversed_res array.
  • Then we build the original input by changing the position of each bit of each element of reversed_res following the order of S' = [7-v for v in S].

Now we can recover the original user seed xoring the result of the inverse function of f with the padded username tick+1 times.

To recover the admin seed it is only needed to xor the user seed with the padded admin username with the padded user username, and by calling gera_token with the admin username, the admin seed and the current tick we can generate a valid admin password. GG

Treasure Island - 300 pts

We were given a Unity game that consisted in a player weilding an axe in an island with a treasure chest.

Using dnSpy I opened the Assembly-CSharp.dll file that contains all the game logic for Unity based games and looked at the Update function (that is called every frame) and found that there was a call to 2 functions named f1 and f3. f3 would only be called if f1 returned true .

f3 receives a string s as a parameter and calls f6 passing it an array of bytes (aka a ciphertext) and s+"13337" .

f6 receives a ciphertext and a key (the byte array in f3 and s+"133337" ) and decrypts the ciphertext using AES CBC with an hardcoded IV.

f1 receives a string and checks the following conditions:

s[8] == s[9] + 1
s[9] == s[1] - 2
s[0] == s[1] - 2
s[1] == s[2] + 1
s[7] == s[2] + 1
s[3] == s[7]
s[4] == s[7] - 1
s[5] == s[7]
s[6] == s[5] - 1

Back to the Update function, I checked where the string s was being created/changed and to my surprise each byte could only be one of 3 options: "A" , "B" or "C" . So I took my favourite SAT solver (z3) out of my pocket, inserted all the restrictions and bams! I had a valid key.

Next thing I did was to patch the Update function to call f3 with the correct key each frame. Et voila. The chest opened and the flag appeared in the sky.


You can find the code for all the exploits here.

Post Mortem

Overall it was a really fun CTF. I would like to congratulate all the participants and thank all the organizers and challenge creators.

See you in Prague, Space Cowboy