Good Old Days
- Category: Pwn
- 350 points
- Solved by JCTF Team
Description
Solution
Before the solution I must say it was very cool to see the Windows XP screen and Winamp again, kudos!
Network Traffic Analysis
OK, lets start to solve this challenge.
After browsing around, most of the links/files in the GUI are missing/not working, but the command prompt is working :-)
The dir
command will show us that there are two interesting files in the current directory:
flag.txt
good_old_days.exe
Reading the flag isn't allowed of course (this is a challenge, not a Windows Command Line 101 course :-) )
C:\Documents and Settings\Pwner\Documents>type flag.txt
Error: access denied
Reading the good_old_days.exe
file is allowed, but it looks like this is an ELF file (The first WSL maybe?).
C:\Documents and Settings\Pwner\Documents>type good_old_days.exe
ELF <binary gibberish>
From the the network traffic we can see that the type
command triggers a post request to /cmd with some payload, and the response is the good_old_days.exe
ELF file.
Find The Bug
We can see that this is an ELF file with symbols (Thanks!).
int __cdecl main(int argc, const char **argv, const char **envp)
{
char v4[32]; // [rsp+0h] [rbp-290h] BYREF
char v5[600]; // [rsp+20h] [rbp-270h] BYREF
unsigned __int64 v6; // [rsp+278h] [rbp-18h]
v6 = __readfsqword(0x28u);
httplib::Server::Server((httplib::Server *)v5);
httplib::Server::set_mount_point((httplib::Server *)v5, "/", "./www");
std::function<void ()(httplib::Request const&,httplib::Response &)>::function<main::{lambda(httplib::Request const&,httplib::Response &)#1},void,void>((std::_Function_base *)v4);
httplib::Server::Get((__int64)v5, (__int64)"/hi", (__int64)v4);
std::function<void ()(httplib::Request const&,httplib::Response &)>::~function(v4);
std::function<void ()(httplib::Request const&,httplib::Response &)>::function<main::{lambda(httplib::Request const&,httplib::Response &)#2},void,void>(v4);
httplib::Server::Post(v5, "/cmd", v4);
std::function<void ()(httplib::Request const&,httplib::Response &)>::~function(v4);
httplib::Server::listen((httplib::Server *)v5, "0.0.0.0", 8080, 0);
httplib::Server::~Server((httplib::Server *)v5);
return 0;
}
Analyzing the ELF, we can see that this is an HTTP server with two APIs:
- GET
/hi
that returnsHello World!
- POST
/cmd
that registers the functionexecute_commands
as a callback function to parse, validate and execute the POST request payload.
Let's take a look at the execute_commands
function:
payload_data = (const payload_header *)std::__cxx11::basic_string<char,std::char_traits<char>,std::allocator<char>>::data(a2);
payload_size = std::__cxx11::basic_string<char,std::char_traits<char>,std::allocator<char>>::size(a2);
if ( payload_size <= 0xF )
{
...
}
if ( payload_data->version_number != 0x10000 )
{
...
}
cmd_ptr = (const cmd_header *)payload_data->cmds;
cmds_end = (char *)payload_data + payload_size;
while ( cmd_ptr != (const cmd_header *)cmds_end )
{
if ( (unsigned __int64)(cmds_end - (const char *)cmd_ptr) <= 0xF )
{
...
}
if ( cmd_ptr->cmd_size_qwords <= 1uLL )
{
...
}
if ( cmd_ptr->cmd_size_qwords > 0x80uLL )
{
...
}
if ( cmds_end - (const char *)cmd_ptr < (unsigned __int64)(8 * cmd_ptr->cmd_size_qwords) )
{
...
}
cmd_type = cmd_ptr->cmd_type;
if ( cmd_ptr->cmd_type == CMD_DIR )
{
if ( cmd_ptr->cmd_size_qwords != 2 )
{
...
}
}
else
{
if ( !cmd_type || (unsigned __int64)(cmd_type - 2) > 7 )
{
...
}
if ( cmd_ptr->cmd_size_qwords == 2 )
{
...
}
}
cmd_ptr = (const cmd_header *)((char *)cmd_ptr + 8 * cmd_ptr->cmd_size_qwords);
}
We can understand the payload structure from the binary:
enum CMDS{
CMD_DIR = 1;
CMD_ECHO = 2;
CMD_TYPE = 3;
CMD_ERASE = 4;
CMD_MOVE = 5;
CMD_COPY = 6;
CMD_SET_SRC_NAME = 7;
CMD_PING_IPV4 = 8;
CMD_PING_IPV6 = 9;
};
struct cmd_header{
enum CMDS cmd_type;
uint64_t cmd_size_in_qwords; // including the header size
uint8_t cmd_data[]; // cmd_size_in_qwords * sizeof(uint64_t)
};
struct payload_header{
uint64_t version;
uint64_t session_cookie
struct cmd_header cmds[]; //dynamic size
}
The function checks the following things:
- The payload size is at least 2 qwords.
- The version number is
0x10000
. - For each cmd:
- The
cmd_ptr->cmd_size_qwords
is at least 2 qwords. - The
cmd_ptr->cmd_size_qwords
is not bigger than0x80
qwords. cmd_ptr - cmds_end
(the remaining size) is not less thancmd_ptr->cmd_size_qwords * 8
.- If
cmd_ptr->cmd_type
isCMD_DIR
thencmd_ptr->cmd_size_qwords
is equal 2. cmd_ptr->cmd_type
is a valid value (betweenCMD_DIR(1)
andCMD_PING_IPV6(9)
) andcmd_ptr->cmd_size_qwords
is bigger then2
.
Looks OK, let's continue:
__int64 __fastcall is_admin_token(unsigned __int64 a1)
{
unsigned int v1; // ebx
char *v3; // [rsp+18h] [rbp-48h]
std::__cxx11::basic_string<char,std::char_traits<char>,std::allocator<char> >_0 v4; // [rsp+20h] [rbp-40h] BYREF
unsigned __int64 v5; // [rsp+48h] [rbp-18h]
v5 = __readfsqword(0x28u);
std::__cxx11::to_string((std::__cxx11 *)&v4, a1);
v3 = getenv("ADMIN_TOKEN");
if ( v3 )
v1 = std::operator==<char>(&v4, v3);
else
v1 = std::operator==<char>(&v4, "1");
std::__cxx11::basic_string<char,std::char_traits<char>,std::allocator<char>>::~basic_string(&v4);
return v1;
}
get_session_path[abi:cxx11](&v19, payload_data->session_cookie);
if ( (unsigned __int8)is_admin_token(payload_data->session_cookie) )
is_admin = 1;
The function checks if we are sending the admin token by checking against the environment variable ADMIN_TOKEN
: If we send the admin token, is_admin
will be equal to 1
.
_data_start = (const cmd_header *)payload_data->cmds;
LABEL_28:
while ( _data_start != cmds_end )
{
_cmd_size = 8 * _data_start->cmd_size_qwords - 16;
_memcpy_fwd(_cmd_data, _data_start->cmd_data, _cmd_size);
_cmd_data[_cmd_size] = 0;
switch ( _data_start->cmd_type )
{
case CMD_DIR:
cmd_dir((__int64)&v19, a1);
++_data_start;
break;
case CMD_ECHO:
cmd_echo(_cmd_data, a1);
_data_start = (const cmd_header *)((char *)_data_start + _cmd_size + 16);
break;
case CMD_TYPE:
::cmd_type(&v19, _cmd_data, a1, is_admin);
_data_start = (const cmd_header *)((char *)_data_start + _cmd_size + 16);
break;
case CMD_ERASE:
cmd_erase(&v19, _cmd_data, a1, is_admin);
_data_start = (const cmd_header *)((char *)_data_start + _cmd_size + 16);
break;
case CMD_MOVE:
cmd_move(&v19, _cmd_data, a1, is_admin, &v20);
_data_start = (const cmd_header *)((char *)_data_start + _cmd_size + 16);
break;
case CMD_COPY:
cmd_copy(&v19, _cmd_data, a1, is_admin, &v20);
_data_start = (const cmd_header *)((char *)_data_start + _cmd_size + 16);
break;
case CMD_SET_SRC_NAME:
cmd_set_src_name(&v19, _cmd_data, a1, is_admin, &v20);
_data_start = (const cmd_header *)((char *)_data_start + _cmd_size + 16);
break;
case CMD_PING_IPV4:
cmd_ping(_cmd_data, _cmd_size >> 2, a1);
_data_start = (const cmd_header *)((char *)_data_start + 4 * (_cmd_size >> 2) + 16);
break;
case CMD_PING_IPV6:
cmd_ping_ipv6(_cmd_data, _cmd_size >> 4, a1);
_data_start += (_cmd_size >> 4) + 1;
break;
default:
goto LABEL_28;
}
}
The function calculates the _cmd_size
(in bytes, without the header) and copies the command to a temporary buffer - _cmd_data
.
_cmd_size = 8 * _data_start->cmd_size_qwords - 16;
_memcpy_fwd(_cmd_data, _data_start->cmd_data, _cmd_size);
_cmd_data[_cmd_size] = 0;
After executing the command and incrementing the _data_start
pointer by the command size (that already passed validation) the code continues to the next command.
case CMD_PING_IPV6:
cmd_ping_ipv6(_cmd_data, _cmd_size >> 4, a1);
_data_start += (_cmd_size >> 4) + 1;
break;
Let's take a look at the Ping IPv6 function. The execute_command
function will send this function the number of IPv6 addresses as an argument. An IPv6 address is 16
byte long, so _cmd_size >> 4
(i.e. _cmd_size/16
) is the number of IPv6 address in the command data. After the function cmd_ping_ipv6
finishes, the _data_start
pointer is incremented by _cmd_size >> 4 + sizeof(cmd_header)
to the next command.
The problem is that _cmd_size
can be equal to 8
bytes (1
qword of data or 3
qwords including the header). This value will pass the validation at the beginning of the function and cause the cmd_ping_ipv6
function to receive _cmd_size >> 4
(i.e. 0
) as number of IPv6 address. That alone is Ok, since no full address is in the command payload. However, when the function will incrment the _data_start
pointer, the incrementation will be only by sizeof(cmd_header)
(i.e. _data_start += (8 >> 4) + 1
), which will cause the _data_start
pointer to point to the _data_start->cmd_data
as the next command. This means that this location is still part of the last command that is not validate as a command at the beginning of the function.
Exploit
Let's look at the cmd_type
function:
unsigned __int64 __fastcall cmd_type(__int64 a1, const char *cmd_data, std::__cxx11::basic_string<char,std::char_traits<char>,std::allocator<char> >_0 *a3, char is_admin)
{
std::runtime_error *v4; // rbx
const std::__cxx11::basic_string<char,std::char_traits<char>,std::allocator<char> >_0 *v5; // rax
std::__cxx11::basic_string<char,std::char_traits<char>,std::allocator<char> >_0 v9; // [rsp+20h] [rbp-80h] BYREF
std::__cxx11::basic_string<char,std::char_traits<char>,std::allocator<char> >_0 v10; // [rsp+40h] [rbp-60h] BYREF
char v11[40]; // [rsp+60h] [rbp-40h] BYREF
unsigned __int64 v12; // [rsp+88h] [rbp-18h]
v12 = __readfsqword(0x28u);
validate_filename(cmd_data);
if ( is_admin != 1 && !strcmp(cmd_data, "flag.txt") )
{
v4 = (std::runtime_error *)_cxxabiv1::__cxa_allocate_exception(0x10uLL);
std::runtime_error::runtime_error(v4, "access denied");
_cxxabiv1::__cxa_throw(v4, &`typeinfo for'std::runtime_error, (void (*)(void *))std::runtime_error::~runtime_error);
}
std::operator+<char>(&v9, a1, "/");
std::operator+<char>(&v10, &v9, cmd_data);
readfile(v11, &v10);
std::__cxx11::basic_string<char,std::char_traits<char>,std::allocator<char>>::~basic_string(&v10);
std::__cxx11::basic_string<char,std::char_traits<char>,std::allocator<char>>::~basic_string(&v9);
if ( (unsigned __int8)std::optional<std::__cxx11::basic_string<char,std::char_traits<char>,std::allocator<char>>>::has_value(v11) )
{
v5 = (const std::__cxx11::basic_string<char,std::char_traits<char>,std::allocator<char> >_0 *)std::optional<std::__cxx11::basic_string<char,std::char_traits<char>,std::allocator<char>>>::value(v11);
std::__cxx11::basic_string<char,std::char_traits<char>,std::allocator<char>>::operator+=(a3, v5);
}
else
{
std::__cxx11::basic_string<char,std::char_traits<char>,std::allocator<char>>::operator+=(
a3,
"The system cannot find the file specified.\n");
}
std::optional<std::__cxx11::basic_string<char,std::char_traits<char>,std::allocator<char>>>::~optional(v11);
return v12 - __readfsqword(0x28u);
}
OK, to read flag.txt
we need to make sure that the is_admin
byte will be equal to 1
.
Let's look at the stack:
char _cmd_data[1025]; // [rsp+A0h] [rbp-430h] BYREF
unsigned __int8 is_admin; // [rsp+4A1h] [rbp-2Fh]
So we need to overflow _cmd_data
and override is_admin
using the bug we saw before.
We create the following full payload.
During the validation phase, the execute_command
function will validate the following commands and the validation will pass:
However, during the execution the execute_command
function will execute the following commands:
- The first command is a
dir
command with_data_start->cmd_size_qwords
equal to0x02
. - The second command is a
ping_ipv6
command with_data_start->cmd_size_qwords
equal to0x03
. - Due to the bug, the next command is also a
ping_ipv6
with_data_start->cmd_size_qwords
equal to0x03
. - Due to the bug the next command is a
ping_ipv4
with_data_start->cmd_size_qwords
equal to0x02
. - The next command is a
dir
command with_data_start->cmd_size_qwords
equal to0x83
. - This command will overflow the
_cmd_data
and will write1
tois_admin
. - The
type
command moves the pointer to the next command regardless of the command size, the program assumes that thedir
command size issizeof(cmd_header)
. - The next command is a
type flag.txt
command with_data_start->cmd_size_qwords
equal to0x03
. - The next command is a
type !
command with_data_start->cmd_size_qwords
equal to0x50
. - The next command is a
type !
command with_data_start->cmd_size_qwords
equal to0x41
.
We send the payload and get the response:
Volume in drive C has no label.
Volume Serial Number is 9A7E-22F6.
Directory of C:\Documents and Settings\Pwner\Documents
<DIR> .
<DIR> ..
54 flag.txt
10679376 good_old_days.exe
Volume in drive C has no label.
Volume Serial Number is 9A7E-22F6.
Directory of C:\Documents and Settings\Pwner\Documents
<DIR> .
<DIR> ..
54 flag.txt
10679376 good_old_days.exe
BSidesTLV2021{FCKGW-RHQQ2-YXRKT-8TG6W-2B7Q8-h4ck3rman}The system cannot find the file specified.
The system cannot find the file specified.
And we have a flag: BSidesTLV2021{FCKGW-RHQQ2-YXRKT-8TG6W-2B7Q8-h4ck3rman}