picoCTF 2021 - Kit Engine

A lesson in floating-point form

You will need an account for picoCTF to play this. The accounts are free, and there are hundreds of challenges for all categories - highly recommend it!

Analysis

We are given d8, source.tar.gz and server.py. Let's look at server.py first:

#!/usr/bin/env python3 

# With credit/inspiration to the v8 problem in downUnder CTF 2020

import os
import subprocess
import sys
import tempfile

def p(a):
    print(a, flush=True)

MAX_SIZE = 20000
input_size = int(input("Provide size. Must be < 5k:"))
if input_size >= MAX_SIZE:
    p(f"Received size of {input_size}, which is too big")
    sys.exit(-1)
p(f"Provide script please!!")
script_contents = sys.stdin.read(input_size)
p(script_contents)
# Don't buffer
with tempfile.NamedTemporaryFile(buffering=0) as f:
    f.write(script_contents.encode("utf-8"))
    p("File written. Running. Timeout is 20s")
    res = subprocess.run(["./d8", f.name], timeout=20, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
    p("Run Complete")
    p(f"Stdout {res.stdout}")
    p(f"Stderr {res.stderr}")

It's very simple - you input the size of the file, and then you input the file itself. The file contents get written to a javascript file, then run under ./d8 with the output returned. Let's check the source code.

The patch is as follows:

This just just generally quite strange. The only particularly relevant part is the new AssembleEngine() function:

This is a pretty strange function to have, but the process is simple. FIrst there are a couple of checks, and if they are not passed, they fail:

  • Check if the number of arguments is 1

  • Assign 4096 bytes of memory with RWX permissions

Then, if the first argument is an array, we cast it to one and store it in arr. We then loop through arr, and for every index i, we store the result in the local variable element. If it's a number, it gets written to func at a set offset. Essentially, it copies the entirety of arr to func! With some added checks to make sure the types are correct.

There is then a memory dump of func, just to simplify things.

And then finally execution is continued from func, like a classic shellcoding challenge!

Exploitation

This isn't really much of a V8-specific challenge - the data we are input is run as shellcode, and the output is returned to us.

HOWEVER

val->Value() actually returns a floating-point value (a double), not an integer. Maybe you could get this from the source code, but you could also get it from the mmap() line:

You can see it's all double values. This means we have to inject shellcode, but in their floating-point form rather than as integers.

If you've read the oob-v8 writeup, you know there are common functions for converting the integers you want to be written to memory to the floating-point form that would write them (and if you haven't, check it out).

So now we just need to get valid shellcode, convert it into 64-bit integers and find the float equivalent. Once we make the array, we simply call AssembleEngine() on it and it executes it for us. Easy peasy!

We can't actually interact with the process, only get stdout and stderr, so we'll have to go to a direct read of flag.txt. We can use pwntools to generate the shellcode for this:

We want to convert shellcode to bytes, then to 64-bit integers so we can transform them to floats. Additionally, the 64-bit integers have to have the bytes in reverse order for endiannes! We'll let python do all of that for us:

We can dump this (after minor cleanup) into exploit.js and convert the entire list to floats before calling AssembleEngine(). Make sure you put the n after every 64-bit value, to signify to the javascript that it's a BigInt type!

And finally we can deliver it with a python script using pwntools, and parse the input to get the important bit:

And we get the flag:

Last updated

Was this helpful?