⍉ BLAZING FAST...

# recommended listening - sheena ringo - yami ni furu ame
aka: i cant sleep

Introduction

Welcome to the most BLAZINGLY FAST (read: low effort) pwn writeups EVER made. I will be covering the four interesting pwns from the most recent iteration of sctf, which I was a setter for. I think the challenges are all good and worth covering. Not a lot of depth because I am tired, anyways let's get into it. Nyoom.

nlb by ndgsghdj

This program implements a custom packet format for managing books, users, and requests. The bug is in parse_rpc_packet.

    5     alloc_size = (uint16_t)(wire->field_count * wire->field_width);
    6     copy_size = (size_t)wire->field_count * (size_t)wire->field_width;
    7     if (copy_size > sizeof(wire->data)) {
    8        copy_size = sizeof(wire->data);
    9     }
   10    
   11     scratch = alloca(alloc_size);
   12     memcpy(scratch, wire->data, copy_size);

alloc_size is truncated down to a uint16_t, but there is no such truncation on copy_size which is declared as a size_t (64 bits), meaning there is a trivial buffer overflow. Very interestingly, there is a mitigation in this challenge that prevents us from doing straightforward ROP, as the binary is compiled with no ret instrs. Instead, after each function execution, the binary jmps to a stub that checks if the return address is 'valid' (preceded by a call instruction).

This greatly reduces our search space for ROP, and instead, we can look at the stack variables we'll end up overwriting.

    7   static void parse_rpc_packet(const struct rpc_wire_packet *wire, struct rpc_packet *pkt) {
    6      uint16_t alloc_size;
    5     size_t copy_size;
    4      char *scratch;

We overwrite the char* ptr scratch, as well as alloc_size and copy_size. Looking at the rest of the code, we have direct control over pkt->first and scratch, so our primitive is that we can arbitrarily copy from one known buffer to another. The binary is compiled with no PIE and stores heap pointers in bss, so we can corrupt those heap pointers.

   11     if (copy_size > FIELD_SIZE) {
   12        memcpy(pkt->first, scratch, FIELD_SIZE);
   13        memcpy(pkt->second, scratch + FIELD_SIZE, FIELD_SIZE);
   14     } else {
   15        memcpy(pkt->first, scratch, copy_size);
   16     }
   17     pkt->first[FIELD_SIZE - 1] = '\0';
   18     pkt->second[FIELD_SIZE - 1] = '\0';

First we can get our heap leaks. There are functions that print out specific allocated books / users after they have been created by referencing their pointers in bss.

    8   static void rpc_show_book(const struct rpc_packet *pkt) {
    7   │   if (pkt->index >= MAX_BOOKS || books[pkt->index] == NULL) {
    6   │     write_all(s_no);
    5   │     return;
    4   │  }
    3   │ 
    2   │  books[pkt->index]->vt->describe(books[pkt->index]);
    1   }

The goal is to now forge a fake user struct that will leak heap pointers. Note the write_all functions below, and how the vtable is referenced from the passed-in user pointer. We need to forge all of these on the heap - note that vtable addresses are in bss so we know those values, and heap pointers will be stored in bss so we also know where they are.

    6   static void user_describe(void *obj) {
    5   │  struct user *u = obj;
    4   │ 
    3   │  write_all(u->username);
    2   │   write_all(s_newline);
    1   }

Let's look at the format of the user struct to know what to forge, as well as the other structs we can create.

   23   struct book {
   22   │   const struct object_vtable *vt;
   21   │   uint64_t isbn;
   20   │   uint32_t volume;
   19   │   uint64_t borrowed_by;
   18   │   char *title;
   17   │   char *author;
   16   };
   15   
   14   struct user {
   13   │   const struct object_vtable *vt;
   12   │   uint64_t uid;
   11   │   char *username;
   10   │   char *password;
    9   };
    8   
    7   struct user_book_request {
    6   │   const struct object_vtable *vt;
    5   │   uint32_t refcount;
    4   │   uint32_t kind;
    3   │   uint64_t user_uid;
    2   │   uint64_t isbn;
    1   };

We need to forge a qword for the vtable, 8 arbitrary bytes, and then another qword for the username pointer. We can do this by forging a book pointer where the isbn value is the vtable pointer and the borrowed_by value is the username pointer, then corrupt the LSB of a stored user pointer to point to the middle of this book struct.

We do this by allocating a user and a book on the heap such that the pointers only differ in their LSBs, and then harvest a byte from the binary to corrupt the user pointer. Calling describe on this forged pointer gets us our heap leak. (Note that this also works to leak libc, as we can forge a pointer to the GOT.)

With a heap leak, we can get arbitrary 0x10-sized writes by writing stuff on the heap and then using our arbitrary copy primitive to reference our written bytes on the heap. We do this by creating request structs, which are the only structs that have two consecutive qwords.

Now, we can get execution through the vtables. The issue I faced is that the vtable pointer is at the very head of each object, meaning if we forge a vtable correctly, the program would end up executing object->vtable_entry(object). And, ewll, object->vtable_entry would just be object itself. Thus, if we try to execute system('/bin/sh'), the closest we can get is executing system(system).

To fix this problem, we either mine libc for useful gadgets that would take this into account, or use the nuclear option of FSOP. With vtable override, we have a valid arbitrary call primitive.

    1   static int read_full(void *buf, size_t n) {
    2      char *p = buf;
    3      size_t off = 0;
    4   
    5      while (off < n) {
    6        ssize_t got = read(STDIN_FILENO, p + off, n - off);
    7        if (got == 0) {
    8          return 0;
    9       }
   10       if (got < 0) {
   11          _exit(1);
   12       }
   13       off += (size_t)got;
   14     }
   15    
   16     return 1;
   17   }
   18   
   19   static void write_n(const char *s, size_t n) {
   20   │   write(STDOUT_FILENO, s, n);
   21   }
   22   
   23   static void write_all(const char *s) {
   24   │   size_t n = 0;
   25   │
   26   │   while (s[n] != '\0') {
   27   │     n++;
   28   │  }
   29   │ 
   30   │  write_n(s, n);
   31   }

FSOP is greatly complicated by the fact that the IO for this binary is raw read/write syscalls that don't deal with the stdin / stdout file structs. But using our arbitrary call primitive, we can first arbwrite stdin's _IO_buf_base_ and _IO_buf_end_, and then call a stdio function ourselves. Notably, these functions do fit our constraint of the first argument being the function pointer itself: we can choose getchar(), which doesn't take an argument, and puts(), which will happily try to print its own pointer.

In summary:

  1. Buffer overflow on alloca() to overwrite stack variables.
  2. Overwrite stack variables to obtain arbitrary copy primitive.
  3. Whack heap pointers in bss, forging fake structs to leak heap and libc.
  4. Using heap leak + libc, obtain arbwrite.
  5. Use arbwrite to FSOP, hijacking vtable pointers to call stdin / stdout functions ourselves.

This challenge took me 6 hours and I found it extremely fun, although I think it is much harder than its medium difficulty suggests.

innards by me

Simple heap notes using strdup(). The primitive for this challenge is overflow in strdup(), specifically cases 1 and 4.

  27   │   case '1':
  26   │    printf("[?] idx (src) > ");
  25   │    src = get_int();
  24   │    printf("[?] idx (dst) > ");
  23   │    dst = get_int();
  22   │    ptrs[dst] = strdup(ptrs[src]);
  21   │    break;
  20   │   case '2':
  19   │    printf("[?] idx to free > ");
  18   │    src = get_int();
  17   │    free(ptrs[src]);
  16   │    ptrs[src] = NULL;
  15   │    break;
  14   │   case '3':
  13   │    printf("[?] idx to alloc > ");
  12   │    src = get_int();
  11   │    ptrs[src] = malloc(0x100);
  10   │    read(0, ptrs[src], 0x100);
   9   │    break;
   8   │   case '4':
   7   │    printf("[?] idx to edit > ");
   6   │    src = get_int();
   5   │    read(0, ptrs[src], 0x100);
   4   │    break;

strdup() allocates strlen(*ptr) + 1, while read() is fixed at 0x100-size write. If we write 0x10 non-null bytes into our chunk and then strdup() it, it will allocate an 0x10 sized chunk which we can overflow 0x100 bytes into. This is our primitive, a simple heap overflow.

The challenge gets its difficulty from the fact that we can only leak once. We use this one leak to get a heap leak, and then perform a House-of-Water-esque attack by placing unsorted bin pointers in the tcache-perthread-struct. Specifically, we use our heap overflow to tcache-poison onto perthread, fake an unsorted bin chunk + its corresponding next headers, and then free that unsorted bin chunk, placing a pointer to main_arena in the perthread struct. Then, we can use our heap overflow to partially overwrite that main_arena pointer to point to stdout, after which we perform standard FSOP tips and tricks (clobbering the first fiew fields, flags and _IO_write_base_) to gain a libc leak.

This challenge is quite standard and not worth discussing further. Note that this is not exactly House of Water, as House of Water uses chunk allocations to forge a chunk header, while we can just use our heap leak and writes to forge that chunk header directly.

lotus by personjs

This challenge involves placing malicious inputs into a competitive programming script. The script is tiny and written in C++.

   6   main() {
   5   │   int n, m, q, a[maxn] = {0}, op, x, y, p[maxn] = {0};
   4   │   vector<int> decks[maxn];
   3   │   cin >> n >> m;
   2   │   assert(n <= maxn);
   1   │   assert(m <= maxn);
  17      for (int i=0; i<m; i++) {
   1   │     cin >> a[i];
   2   │  }
   3   │  cin >> q;
   4   │  while (q--) {
   5   │     cin >> op;
   6   │     if (op == 1) {
   7   │       cin >> x >> y;
   8   │       p[x] += y * a[decks[x].size()];
   9   │       decks[x].pb(y);
  10   │    } else if (op == 2) {
  11   │       cin >> x;
  12   │       p[x] -= decks[x].back() * a[decks[x].size() - 1];
  13   │       decks[x].pop_back();
  14   │    } else {
  15   │       cin >> x >> y;
  16   │       cout << (p[x] >= p[y]) << '\n';
  17   │    }
  18   │  }
  19   }

This is stack pwn. There are no bounds checks on x and y. To obtain leaks, we use the third option, which allows us to binary search for a given address's value by repeatedly using it as an oracle. To overwrite the return address, we first leak the stack and libc using the 3 option, and then forge a valid vector on the stack.

The program operates by storing vectors on the stack. A C++ vector has three fields: start, end, and capacity. Ordinarily, these are heap pointers, three consecutive ones in memory. We can use the 1 option to place values into the other arrays allocated on the stack, and then reference them as if they were valid vectors in the stored array.

We just forge a vector whose start and end point to the return address, and capacity points somewhere higher. Then, we reference that vector and slowly overwrite our return address with our ROP chain.

ascent by water

This challenge is perhaps the easiest possible v8 challenge ever. No sandbox, and the primitive is simple: one bitflip at any heap address of your choosing.

We can create an Uint32Array and then bitflip its length to make that array very big, henceforht called oob_array. From here it is kind of trivially easy. To get our addrof() primitive, we place an array leak_array after our oob_array in memory, and then place objects into that leak_array. Afterwards, we simply read from our Uint32Array which returns the pointer to that object as a Uint32.

For arbread, we place another array arb_array after oob_array, and then use the oob_array to mangle arb_array's backing buffer to point to wherever we want. This also doubles as arbwrite, as we can both read and write to this backing buffer.

www2exec is a little interesting on v8. Given that there's no sandbox, there are rwx pages we can whack, specifically, the ones allocated for wasm modules. We can create a wasm module, chain arbreads to leak where the compiled wasm module's shellcode lies, overwrite the shellcode, and then just execute the wasm module.

Conclusion

Sorry for no solve scripts or debugging output I'm super tired, but I figured these were worth writing up in some capacity. I think they're good and I learned lots of fun stuff.

Reward for reaching the end of this post