6 minutes
DDC 2023 Qualification - Psssssyducks
Recon and Introduction
At the given url (http://psyducks.hkn
), we are met by the image of a psyduck:
Devtools
If we inspect the HTML in devtools, we can see that the image comes from /content?psyduck=brick
:
Going over to the storage tab in the devtools we can see that we have a cookie set for this site:
We can decode it from base64 in cyberchef and see what it contains:
It seems that the cookie contains a list of psyduck variants, if we change the brick
from the /content?psyduck=
GET request to any of the others, we get a different image.
We may be able to use this to get a psyduck image not in the list by changing the entries in the cookie and get request.
Scanning for directories
We need some more information on how the site works, so we run gobuster
to see if we can find some other directories and entrypoints on the server:
We find the /browse
path, if we visit it in the browser we can see that it links to a backup.zip
:
We then download and unzip this archive:
Analysis of the app.py file
We now have a copy of the app.py
file the server is running:
#!/usr/bin/env python3
from flask import Flask,request,Response,render_template,abort,make_response
import json,random,os,base64
### Global variables
app = Flask(__name__)
allowedPsyducks = ['normal','king','tea','cold','strong','shy','gentleman','sick','brick','sus','swole','scream','party','cool','panic','fast','happy','irl','oddish','onix','rain','sad','serene','smol','super','triple']
forbiddenPsyducks = ['princess']
allPsyducks = allowedPsyducks + forbiddenPsyducks
### Internal functions
def forbidden_psy(requested_psyduck:str,ip:str):
if requested_psyduck.lower() in forbiddenPsyducks:
# Localhost is allowed to view the magnificent psyducks
if ip == "127.0.0.1":
return
# Outsiders are not!
else:
return abort(401)
else:
return
def check_favorite_requested(requested_psyduck:str,favourite_psyducks:list):
return requested_psyduck in favourite_psyducks
def check_favorite_forbidden(favourite_psyducks:list):
for favourite_psyduck in favourite_psyducks:
if favourite_psyduck in forbiddenPsyducks:
return False
return True
### Flask functions
@app.route('/supersecretbackup/backup.zip')
def backup():
return Response(backup_zip, mimetype='application/zip')
@app.route('/browse')
def dir_listing():
# Show directory contents
return render_template('browse.html')
@app.route('/content')
def retPsyduck():
user_request = request.args.get('psyduck')
user_ip = request.environ['REMOTE_ADDR']
user_cookie = json.loads(base64.b64decode(request.cookies.get('MyFavoritePsyducks')).decode())['psy']
# Ensure the requested psyducks is a favorite!
if not check_favorite_requested(user_request,user_cookie):
return Response('You can only request your favorite psyducks!')
# Ensure user doesn't covet any forbidden psyducks!
if not check_favorite_forbidden(user_cookie):
return Response('You are not allowed to like that psyduck!')
# Check the requested psyduck is not forbidden!
forbidden_psy(user_request,user_ip)
if user_request.casefold() in allPsyducks:
if not check_favorite_requested(user_request.casefold(), user_cookie):
return Response('You can only request your truly most favourite of psyducks!')
with open('psyducks/'+user_request.casefold()+'.png','rb') as f:
return Response(f.read(), mimetype='image/png')
else:
return Response('No Psyduck requested!')
@app.route("/")
def index():
n = random.randint(0, len(allowedPsyducks)-1)
res = make_response()
cookie = base64.b64encode(json.dumps({'psy':allowedPsyducks}).encode())
res.set_cookie('MyFavoritePsyducks', cookie)
res.set_data(indexTemplate.replace('ZZZ',allowedPsyducks[n]))
return res
### Main
with open('./templates/index.html') as f:
indexTemplate = f.read()
with open('./supersecretbackup/backup.zip','rb') as f:
backup_zip = f.read()
if(__name__ == '__main__'):
app.run(host='0.0.0.0')
We are mostly interested in the /content
route so we can strip out most of the file contents for our local version.
Here are a simplified version we can use for testing (with added comments):
#!/usr/bin/env python3
from flask import Flask,request,Response,render_template,abort,make_response
import json,random,os,base64
### Global variables
allowedPsyducks = ['normal','king','tea','cold','strong','shy','gentleman','sick','brick','sus','swole','scream','party','cool','panic','fast','happy','irl','oddish','onix','rain','sad','serene','smol','super','triple']
forbiddenPsyducks = ['princess'] ## (WRITEUP) Name of psyduck they dont want us to see
allPsyducks = allowedPsyducks + forbiddenPsyducks
### Internal functions
def forbidden_psy(requested_psyduck:str,ip:str):
## (WRITEUP) Checks if our requested psyduck is the forbidden psyduck,
## (WRITEUP) if it is we need to be localhost.
## (WRITEUP) Notice the use of .lower()
if requested_psyduck.lower() in forbiddenPsyducks:
# Localhost is allowed to view the magnificent psyducks
if ip == "127.0.0.1":
return
# Outsiders are not!
else:
return abort(401)
else:
return
def check_favorite_requested(requested_psyduck:str,favourite_psyducks:list):
## (WRITEUP) Checks if the requested psyduck is in the list from the cookie
return requested_psyduck in favourite_psyducks
def check_favorite_forbidden(favourite_psyducks:list):
## (WRITEUP) Loops over all psyducks in the cookie list
## (WRITEUP) and checks if they are the forbidden psyduck
for favourite_psyduck in favourite_psyducks:
if favourite_psyduck in forbiddenPsyducks:
return False
return True
### Flask functions
@app.route('/content')
def retPsyduck():
user_request = request.args.get('psyduck')
user_ip = request.environ['REMOTE_ADDR']
user_cookie = json.loads(base64.b64decode(request.cookies.get('MyFavoritePsyducks')).decode())['psy']
## (WRITEUP) 1st check: check if requested psyduck is in the list from the cookie
# Ensure the requested psyducks is a favorite!
if not check_favorite_requested(user_request,user_cookie):
return Response('You can only request your favorite psyducks!')
## (WRITEUP) 2nd check: check if the list from the cookie contains the forbidden psyduck
# Ensure user doesn't covet any forbidden psyducks!
if not check_favorite_forbidden(user_cookie):
return Response('You are not allowed to like that psyduck!')
## (WRITEUP) 3rd check: check if the user requested the forbidden psyduck
## (WRITEUP) and if the users ip is 127.0.0.1
# Check the requested psyduck is not forbidden!
forbidden_psy(user_request,user_ip)
## (WRITEUP) 4th and 5th check: make sure that the requested psyduck exist
## (WRITEUP) and if the requested psyduck is in the list from the cookie.
## (WRITEUP) Notice the use of .casefold() instead of .lower()
if user_request.casefold() in allPsyducks:
if not check_favorite_requested(user_request.casefold(), user_cookie):
return Response('You can only request your truly most favourite of psyducks!')
## (WRITEUP) Finally return the image of the requested psyduck after passing all the checks.
with open('psyducks/'+user_request.casefold()+'.png','rb') as f:
return Response(f.read(), mimetype='image/png')
else:
return Response('No Psyduck requested!')
### Main
if(__name__ == '__main__'):
app.run(host='0.0.0.0', 8080)
The goal seems to successfully request the princess
psyduck by bypassing all the checks.
Bypassing the 1st and 2nd check
The 1st check is easy to get by, we just need to write the psyduck we request to the list in the cookie.
The 2nd check is where it becomes difficult, we can’t just use princess
as the get request and cookie.
We can use Princess
(with capital P), since "Princess" != "princess"
.
It will still return the correct image since .casefold()
will make the capital P to lowercase.
Bypassing the 3rd check
We cant bypass the third check with this though, since it uses .lower()
before it compares.
To bypass this we will use a unicode trick that uses the difference between .lower()
and .casefold()
, for this we will use the german sharp S (ß
, 0xC39F
in UTF-8), since .casefold()
converts it but .lower()
doesn’t:
In [1]: 'ß'.lower()
Out[1]: 'ß'
In [2]: 'ß'.casefold()
Out[2]: 'ss'
so if we use ß
instead of the double s, we can bypass the 3rd check and still get the correct filename.
Bypassing the 4th and 5th check
The 4th check is really easy to bypass, since it casefolds itself out to the correct string.
The 5th check is where it becomes hard, now we need our cookie to contain princess
, but if we do this we will fail the 2nd check.
So it seems that we need to cookie to contain and not contain princess
. How is this possible?
Well it actually only seems this way because we up untill this point held on to the fact that out favorite psyducks were in a list.
But the code actually never checks if it decodes it to a list, if we change it to a string instead we can actually bypass both the 2nd and 5th check at the same time.
Since the 5th just asks if princess
is in the cookie, but the 2nd check loops over elements (which is single chars for a string), we can set the cookie to:
{"psy": "princessprinceß"}
All checks that ask if something is in the cookie will now check if the substring exists in the string from the cookie.
Solved and Flag
We encode our own cookie and set it in our browser:
and set the get parameter to then get the flag: