Skip to main content
  1. My Blog Posts and Stories/

STF 2022 Web: PyRunner

··1000 words·5 mins

Recently, I participated with 3 other players in the Stack The Flags 2022 CTF. This is a writeup for the Web challenge Hyper Proto Secure Note.

CHALLENGE NAME
PyRunner
Can you help us test our internal Python job runner prototype?

Exploring the challenge #

The challenge provided the source code for the web application The full source code will be available for download here

Home page
Home page

When we first enter the challenge website, we are greeted with a screen where we can select a template, fill in some arguments and run the script on the website.

The template of the website shown is as follows

import uvicorn
from fastapi import FastAPI, Request, Response, Form, File
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from fastapi.responses import JSONResponse

app = FastAPI()
app.mount("/static", StaticFiles(directory="static"), name="static")
templates = Jinja2Templates(directory="templates")

print("Webserver: <title>")

@app.get("/")
def index(request: Request):
    return templates.TemplateResponse("index.html", {"request": request})

if __name__ == "__main__":
    print("Hello!")
    #uvicorn.run("app:app", host="<host>", port=<port>, reload=True) #TODO: add support for files that don't exit

As we can see, the <title> is within a print statement and the <host> and <port> are within comments. This seems a little suspicious if the website directly replaces the place holders with the values we provide.

Before we try it break it, let us see what will happen during its normal execution

Normal execution
Normal execution

If we fill up the arguments with test, we can see that the output shows the expected output of the script.

Now that we see what is the normal execution, let us see what we can do to break it.

What if we close the quotes and execute the code that we want?

To test this hypothesis, I decided to use the command below to list the possible pages.

"); import os; os.system('ls')

Attempt 1
Attempt 1

However, instead of listing the directory, some of the variables seems to be missing.

There seems to be some type of sanitization going on. In order to see what is going on, I decided to inspect the source code.

Inspecting the source code #

FROM python:3.10.8-alpine3.16

RUN adduser -D -u 1000 -g 1000 -s /bin/sh www

RUN apk add --update --no-cache gcc musl-dev

WORKDIR /app
COPY app /app

COPY flag /root/flag

COPY config/readflag.c /
RUN gcc -o /readflag /readflag.c && chmod 4755 /readflag && rm /readflag.c

RUN pip install -r requirements.txt

CMD ["python3", "app.py"]

We first took a look at the dockerfile, which contained how the file was built in docker. We can see that the readflag.c was compiled and place in the root directory. This seems to be the target for us to execute.

Next, let us look for the sanitization function.

...

disallowed = ["import", ";", "\n", "eval", "exec", "os"]

...

def textfilter(text):
    for i in disallowed:
        if i in text:
            text = text.replace(i, "")
    return text

...

@app.post('/run-template')
async def run_template(request: Request):
    ...
        for argument in template['arguments']:
            contents = contents.replace(f'<{argument}>', textfilter(data['arguments'][argument]))
        filename = f'{random.randint(100000, 999999)}.py'

        ...

        output = subprocess.run(['python', f'scripts/{filename}'], capture_output=True, text=True)

        ... # returns STDOUT and STDERR

It seems that the textfilter function is used to filter out the disallowed keywords. This means that we have to craft a payload using only words which are not in the disallowed keywords.

Banned KeywordsImplications
importWe cannot import any modules directly
;We cannot use semicolons to delimit commands
\nWe are unable to add new lines
evalWe cannot use the eval function
execWe cannot use the exec function
osWe cannot use the os module

This means that we must run the readflag binary within 1 line using a string as a start. Lucky for me, I have come across such a method before.

Before we delve into exploitation, let us first discuss 2 attributes in python, MRO and subclass

MRO #

MRO stands for Method Resolution Order, which is the order in which python looks for methods in a class. To find out more about how python resolves class methods you can take a look at the blog post from Educative.io.

Within the scope of this CTF, there is an mro() method of every class which will return us the list of classes which is in the MRO of the class.

As the object class is the father of all classes in python, it will always appear at the end of the MRO list.

To access it, we will have to do something like "".__class__.mro() to read the MRO list of the particular class.

Subclass #

If A is a subclass of B, we say that A inherits from B. This means that A will have all the methods and attributes of B.

In python, you can find all the subclasses of a class using the __subclasses__() method.

Within the context of this CTF, we will be using this to find the subclass of the Object class to see all classes available for us to access.

Finding the right class to use #

By making use of the above 2 concepts, we can traverse the MRO tree of all objects within scope and find out which can help us execute the readflag binary that we have found earlier.

".__class__.mro()[-1].__subclasses__())#

By using the payload above, we can list out all the subclasses of the Object class. As all objects are subclasses of the Object class, we can use this to find all the classes that we can use.

Output of payload
Attempt 2

This gives us a giant list of classes to try.

One interesting class that stood out was <class 'subprocess.Popen'>. From the documentation, we can see that subprocess.Popen execute a child program in a new process. By calling this method, we can execute the readflag binary that we have found earlier to retrieve the flag.

Putting all of it together #

After painstakingly going through the list of classes to find the index of subprocess.Popen (it is at index 237), we can finally craft the payload to retrieve the flag using the readflag binary.

".__class__.mro()[-1].__subclasses__()[237](['/readflag'], shell=True))#

Final Output
Final Exploit

After executing it, we can see that the flag is successfully retrieved.

Flag: STF22{4ut0m4t3d_c0mm4nd_1nj3ct10n}

References #