This is a writeup for the pwn challenge Variable-Machine in Defenit CTF 2020.
We are only provided with a binary called main
.
Pwn problem. Get the shell
As the name says, this program has a slot for 256 variables. There are three variable types, INT
, CHAR
, and STRING
. They are all managed by a structure. I named it variable.
00000000 variable struc ; (sizeof=0x10, mappedto_9)
00000000 type dq ? ; offset
00000008 value types ?
00000010 variable ends
The field type
is a const char *
, pointing to the string such as INT
, CHAR
, and STRING
. This field is used to identify the type of the variable in the runtime. The field value
is a union which I named types
.
00000000 types union ; (sizeof=0x8, mappedto_10)
00000000 ; XREF: variable/r
00000000 STRING dq ? ; offset
00000000 INT dq ?
00000000 CHAR db ?
00000000 types ends
Type INT
and CHAR
are just respectively int
and char
, but type STRING
is a pointer to the structure which i named string
.
00000000 string struc ; (sizeof=0x10, mappedto_7)
00000000 str dq ? ; offset
00000008 len dq ?
00000010 string ends
The str
field is a char *
, and the len
contains the length of the string, regardless of the size of the buffer which str
field points to.
This virtual machine allows us to execute up to 0x2000 bytes of code. There are 6 different opcodes available.
01 XX 00 YY Allocate new int YY into slot XX
01 XX 01 YY Allocate new char YY into slot XX
01 XX 02 YY Allocate new string sized YY into slot XX
02 XX Mark varialbe in slot XX for removal
03 XX YY int,char; Update variable in slot XX with YY
string; Write YY bytes on string in slot XX
04 XX Print variable in slot XX
05 01 XX YY Perform XX += YY
05 02 XX YY Perform XX -= YY
05 03 XX YY Perform XX *= YY
05 04 XX YY Perform XX /= YY
06 01 XX YY Concat two chars XX and YY into string in XX
06 02 XX YY Concat two strings XX and YY into string in XX
Most of the dynamic allocations are managed by a singly linked list. So, the program uses its own malloc
wrapper.
void *__fastcall new_node(int count, int size)
{
void *buf; // ST10_8
node *last_node; // ST08_8
buf = malloc(size * count);
memset(buf, 0, size * count);
last_node = search(0LL);
last_node->next = (node *)calloc(1uLL, 0x18uLL);
last_node->next->field = (char *)buf;
last_node->next->status = ACTIVE;
return buf;
}
The search()
functions returns the node *
to the requested pointer, and It returns the last node if its not found. The node
structure looks like this.
00000000 node struc ; (sizeof=0x18, mappedto_6)
00000000 field dq ? ; offset
00000008 status dd ? ; enum NODESTATE
0000000C _ dd ?
00000010 next dq ? ; offset
00000018 node ends
When we delete a variable, it's not deleted on the site. It's status
field is changed into 0
, marking it for deletion. For this, there is a thread routine.
The thread routine continuously examines the whole linked list, and if it finds a node marked for removal, it will free the field
field of the node
structure and change the status field to -1
. It keeps the node itself tho.
The binary has a partial RELRO and PIE.
There is a code leak in the opcode 5, calculation.
if ( !strcmp(variables[opr_2]->type, "INT") && !strcmp(variables[(unsigned __int8)code]->type, "INT") )
{
switch ( opr_1 )
{
case 1:
result.INT = variables[(unsigned __int8)code]->value.INT + variables[opr_2]->value.INT;
break;
case 2:
result.INT = variables[opr_2]->value.INT - variables[(unsigned __int8)code]->value.INT;
break;
case 3:
result.INT = variables[(unsigned __int8)code]->value.INT * variables[opr_2]->value.INT;
break;
case 4:
if ( variables[(unsigned __int8)code]->value.INT )
div_result = (unsigned __int64)variables[opr_2]->value.INT / variables[(unsigned __int8)code]->value.INT;
else
div_result = variables[opr_2]->value.INT;
result.INT = div_result;
break;
}
variables[opr_2]->value = result;
success = 1;
}
There is no default
statement and it will put uninitialized result
in the variables[opr_2]->value
when it meets a bad operation other than 0,1,2,3. The result holds the address which can be used to leak. This is not necessary for solution, but I used this to overwrite GOT because I prefer it over overwriting __free_hook
.
Then the major vulnerability is on the opcode 6, concatenation.
if ( opr_1 == 1 )
{
if ( strcmp(variables[opr_2]->type, "CHAR") || strcmp(variables[opr_3]->type, "CHAR") )
return 0;
opr_2_char = variables[opr_2]->value.CHAR;
opr_3_char = variables[opr_3]->value.CHAR;
snprintf(s, 3uLL, "%c%c", (unsigned int)(char)opr_2_char, (unsigned int)(char)opr_3_char);
variables[opr_2]->type = "STRING";
variables[opr_2]->value.STRING = (string *)new_node(1, 16);
len = strlen(s);
variables[opr_2]->value.STRING->len = len;
str = strdup(s);
variables[opr_2]->value.STRING->str = str;
}
The CHAR
concatenation doesn't use a new_node()
to allocate new buffer. It uses strdup()
to allocate it. This means that the string created by concatenating two chars will not exist on the linked list. So, when we attempt to delete them, it will delete the Last node regardless. Then, we can trigger some UAF.
By creating stings sized in fastbin and small bin, we can get libc and heap leaks. Then, I made 5 fake chunks with size 0x20 in the allocated heap, and altered the freed chunks fd to point it. 5 fake chunks are needed because allocating a new string involves 3 malloc()
and calloc()
calls. This will result in 5 fake fastbins overlapping with a rather bigger chunk. Then, I can just overwrite what I want in those chunks.
Of course, I overwrote the char *
of the str
field of the structure string
with the address of GOT of exit()
, Then I just simply wrote the one-gadget address onto it. rax
was NULL
at the call. Finally, I added a bad opcode to the end in order to trigger exit.
Please refer to the solver.py
for the solution, and the helper.py
for the variable machine code.