This web application looked very innocent at first glance. It displayed a simple calculator, where users could enter two operands and an operator (addition, subtraction, multiplication, division). While there were some ways to get strange results, there was no obvious way to proceed.

We assumed that this site was probably written in Python, since the way the result could be inf or nan was very similar to the way Python spelled those values.

Simple Calculator

The custom 404 page on /projects

What looked suspicious, however, was the request for this “other projects” link in the footer.

> GET /projects HTTP/2
> Host:
> user-agent: curl/7.68.0
> accept: */*

< HTTP/2 200
< date: Sat, 02 Dec 2023 18:07:18 GMT
< content-type: text/html; charset=utf-8
< content-length: 181
< strict-transport-security: max-age=15724800; includeSubDomains

<!DOCTYPE html>
        <title>Custom 404 Page</title>
        <h1>404 - Page Not Found</h1>
        <p>Oops! The page you're looking for at /projects doesn't exist.</p>

Interestingly, this page returned a status code of 200, but the content was a 404 page. This led us to believe that there was something wrong with this custom 404 page.

We noticed that we could inject arbitrary HTML into this page just by using it as the URL path:;%3C/script%3E

HTML injection

Jinja2 template injection

Let’s put together what we know so far:

  • The application is likely written in Python
  • The application’s custom 404 page is vulnerable to code injection
  • Flask is a popular Python web framework
  • Flask uses Jinja2 as its template engine

Flask allows you to return files with a mix of static and dynamic content:

<!doctype html>
<section class="content">
  The message is: {{ message }}

Assuming that the application is written to just naively add the user input (the URL path) to the response (a Jinja2-templated HTML file), we were able to inject Jinja2 template code:

{{ 1 + 1 }}

Jinja2 injection with simple math

With this, we have just discovered a way to execute arbitrary Python code on the server:

{{ range(1337)[-1] }}

Jinja2 injection with Python code

Reading the flag

Assuming the flag is stored in /flag.txt, we tried to read it with the following code:

{{ open('/flag.txt').read() }}

However, this gave us an internal server error - possibly because some built-in functions were disabled. But as we learned from the Avatar challenge already, there are many alternatives to get to the built-in functions. The following trick worked for us:

{{'/flag.txt').read() }}

Jinja2 injection to read the flag

The flag is gctf{404_fl4g_w4s_f0und} 🥳