[Gitlab Pages Auth Bypass] Able to steal a user's Authentication Code For Gitlab Pages
**[HackerOne report #718460](https://hackerone.com/reports/718460)** by `ngalog` on 2019-10-21, assigned to @jeremymatos: ### Summary I bypassed the regex for the gitlab pages authentication in gitlab.com ### Steps to reproduce - Login to gitlab and then visit https://projects.gitlab.io/auth?domain=https://ronchangitlab.io&state=xdgnwM0hmRQ7g5xoevNV6g== - Then attacker can get the access authorization code in ronchangitlab.io, then they can use it to exchange for victim's gitlab pages cookies ## Impact Gitlab Pages authentication bypass ## Issue on dev https://dev.gitlab.org/gitlab/gitlabhq/issues/2938 https://gitlab.com/gitlab-org/security/gitlab/-/issues/102 --- ## Solution ### Proposals - Proposal 1 - https://gitlab.com/gitlab-org/gitlab-pages/-/issues/262#note_255332604 and WIP PoC https://dev.gitlab.org/gitlab/gitlab-pages/-/merge_requests/21 - Proposal 2 - https://gitlab.com/gitlab-org/gitlab-pages/-/issues/262#note_341774099 ### Proposal 2 ~~Going ahead with proposal 2~~ 1. Generate a JWT signing key based on the `auth-secret` on Pages startup 1. When we initiate the authentication flow, generate a JWT token with claims, set it as the state and save to the encrypted cookie ```go state := jwt(domain, randomNewState, jwtSigningKey) ``` 3. Redirect to `projects.gitlab.io?state=state` and validate the state's JWT signature 4. Extract the domain from the JWT claims and continue OAuth flow. 5. When we receive `mydomain.gitlab.io?stat=state&code=code` we verify the state's JWT signature again. 6. Continue as usual --- ## Implemented solution It was later discovered that the problem lies in someone being able to steal an OAuth `code` as part of the authentication flow. To mitigate this, the process is to encrypt the code and add it to a JWT which is then signed. The flow is 1. Generate a JWT signing key based on the `auth-secret` on Pages startup. 1. On redirection from GitLab to https://gitlab.io/auth?code=plaintext&state=random, encrypt and sign the code 1. Encrypt code `encryptedCode=AES-GCM(code, domain (as salt), hkdfKey(auth-secret)` 1. Use the JWT as new code `code=JWT(encryptedCode, nonce, signingKey)` 1. Redirect to `mydomain.com/auth?code=JWT(encryptedCode, nonce, signingKey)&state=random` and strip the `token` from the query string to mitigate https://gitlab.com/gitlab-org/gitlab/-/issues/285244 1. When we receive `mydomain.gitlab.io?state=random&code=code` we verify the code's JWT signature. 1. Get `encryptedCode` and `nonce` from the JWT claims and decrypt the code 1. Exchange for access token and serve content if successful --- <details> <summary>Sequence diagrams from the comment below</summary> ### Summary * The goal of the attack is to ready the content of site `mygroup.gitlab.io` which is private * To achieve this attacker sends link `project.gitlab.io?domain=Attackersdomain.com` to user, which result in `attackersdomain.com?code=mygroupcode&state=irrelevant` * After receiving the `code` attacker can complete the Auth process for `mygroup.gitlab.io` Note: it's not possible for attacker to get `token`, only to read the content of private web-site. ### How the current Auth workflow works ```mermaid sequenceDiagram participant U as User participant D as mygroup.gitlab.io participant P as projects.gitlab.io participant G as gitlab.com U->>D: get index.html D->>U: not authorzied, redirect to projects.gitlab.io/auth?domain=mygroup.gitlab.io&state=123 U->>P: projects.gitlab.io/auth?domain=mygroup.gitlab.io&state=123 rect rgb(0, 255, 0) Note over P,G: OAuth workflow P->>U: redirect to gitlab.com/oauth?redirect_url=projects.gitlab.io&state=123 U->>G: gitlab.com/oauth?redirect_url=projects.gitlab.io&state=123 G->>U: redirect to projects.gitlab.io?state=123&code=mycode U->>P: projects.gitlab.io?state=123&code=mycode end P->>U: redirect to mygroup.gitlab.io?state=123&code=mycode U->>D: mygroup.gitlab.io?state=123&code=mycode rect rgb(0, 255, 0) Note over D,G: Exchange code for token and verify that user has permissions for the mygroup.gitlab.io D->>G: get token by code=mycode G->>D: token = blablabla D->>G: get project by id with token = blablabla G->>D: success end D->>U: success ``` ### Requests highlined in the green are not so relevant to the described attack, so let's get rid of them ```mermaid sequenceDiagram participant U as User participant D as mygroup.gitlab.io participant P as projects.gitlab.io participant G as gitlab.com U->>D: get index.html D->>U: not authorzied, redirect to projects.gitlab.io/auth?domain=mygroup.gitlab.io&state=123 U->>P: projects.gitlab.io/auth?domain=mygroup.gitlab.io&state=123 Note over P,G: OAuth workflow P->>U: redirect to mygroup.gitlab.io?state=123&code=mycode U->>D: mygroup.gitlab.io?state=123&code=mycode Note over D,G: Exchange code for token and verify that user has permissions for the mygroup.gitlab.io D->>U: success ``` ### Some notes about sessions(stored in encrypted and signed cookies with 10 minutes expiration time) ```mermaid sequenceDiagram participant U as User participant D as mygroup.gitlab.io participant P as projects.gitlab.io participant G as gitlab.com U->>D: get index.html Note left of D: generate random state and save it in the session cookie D->>U: not authorzied, redirect to projects.gitlab.io/auth?domain=mygroup.gitlab.io&state=123 U->>P: projects.gitlab.io/auth?domain=mygroup.gitlab.io&state=123 Note over P,G: domain=mygroup.gitlab.io is being saved to the session cookie Note over P,G: OAuth workflow Note over P,G: verify that "GitLab Pages conrolls the domain" from session cookie P->>U: redirect to mygroup.gitlab.io?state=123&code=mycode U->>D: mygroup.gitlab.io?state=123&code=mycode Note left of D: Verify that state from the query parameter is the same as in session cookie Note over D,G: Exchange code for token and verify that user has permissions for the mygroup.gitlab.io D->>U: success ``` ### A vulnerable step You can see `verify that "GitLab Pages conrolls the domain" from session cookie` step above. This is how it's implemented: ```golang func (a *Auth) domainAllowed(name string, domains source.Source) bool { // This is incorrect but it's not important because of second check isConfigured := (name == a.pagesDomain) || strings.HasSuffix("."+name, a.pagesDomain) if isConfigured { return true } // This check is super easy to bypass by just adding the `attackersdomain.com` to any pages project passing validation // Note that validation does not mean that the correct CNAME record is set domain, err := domains.GetDomain(name) // domain exists and there is no error return (domain != nil && err == nil) } ``` ### The attack as it's can be performed currently (note this diagram is a little different from [my previous comment](https://gitlab.com/gitlab-org/security/gitlab-pages/-/merge_requests/1#note_359604339): I placed attacker requesting user domain last to bring related steps closer, it's still possible to perform the attack this way, and it's actually even more convenient) Vulnerability executed is highlighted in green. ```mermaid sequenceDiagram participant A as Attacker participant U as User participant D as mygroup.gitlab.io participant P as projects.gitlab.io participant G as gitlab.com participant AD as ATTACKERSDOMAIN.com A->U: can you please visit projects.gitlab.io/auth?domain=ATTACKERSDOMAIN.COM&state=IRRELEVANT U->>P: projects.gitlab.io/auth?domain=ATTACKERSDOMAIN.COM&state=IRRELEVANT Note over P,G: domain=ATTACKERSDOMAIN.COM is being saved to the session cookie Note over P,G: OAuth workflow rect rgb(0, 255, 0) Note over P,G: verify that "GitLab Pages controls the domain" from session cookie Note over P,G: This check is bypassed by adding ATTACKERSDOMAIN.COM to a random pages project P->>U: redirect to ATTACKERSDOMAIN.COM?state=IRRELEVANT&code=mycode U->>AD: ?state=IRRELEVANT&code=mycode end AD->>A: code=mycode Note right of A: Need to get a valid state in cookie: A->>D: get index.html D->>A: unauthorized, redirect to projects.gitlab.io/auth?domain=mygroup.gitlab.io&state=USERSTATE Note right of A: skip the auth, just go the last step manually: A->>D: /auth?state=USERSTATE&code=mycode Note over D,G: Exchange code for token and verify that user has permissions for the mygroup.gitlab.io D->>A: success ``` ### The JWT fix We tried to fix this in https://gitlab.com/gitlab-org/security/gitlab-pages/-/merge_requests/1 It works by completely removing the `domain` parameter and including it in the `state=JWT({random=random, domain=domain...})`. So the attacker can't send a link to domain. But, it can be exploited in a little more complicated way: ```mermaid sequenceDiagram participant A as Attacker participant Pages as Pages Server participant U as User participant D as mygroup.gitlab.io participant P as projects.gitlab.io participant G as gitlab.com participant AD as ATTACKERSDOMAIN.com Note right of A: Need to get a valid state for ATTACKERSDOMAIN.COM Note right of A: Add ATTACKERSDOMAIN.COM as the domain for the private pages project Note right of A: But not setup DNS for it A->Pages: ATTACKERSDOMAIN.com/index Pages->A: not authorized, redirect to projects.gitlab.io/auth?state=JWT({random=random, domain=ATTACKERSDOMAIN.com}) Note right of A: Let's call JWT({random=random, domain=ATTACKERSDOMAIN.com}) ATTACKERSTATE A->U: can you please visit projects.gitlab.io/auth?state=ATTACKERSTATE U->>P: projects.gitlab.io/auth?state=ATTACKERSTATE Note over P,G: OAuth workflow rect rgb(0, 255, 0) Note over P,G: use the domain from state, wich is ATTACKERSDOMAIN.COM P->>U: redirect to ATTACKERSDOMAIN.COM?state=ATTACKERSTATE&code=mycode U->>AD: ?state=ATTACKERSTATE&code=mycode end AD->>A: code=mycode Note right of A: Need to get a valid state in cookie: A->>D: get index.html D->>A: unauthorized, redirect to projects.gitlab.io/auth?state=USERSTATE(JWT({random=random, domain=mygroup.gitlab.io})) Note right of A: skip the auth, just go the last step manually: A->>D: /auth?state=USERSTATE&code=mycode Note over D,G: Exchange code for token and verify that user has permissions for the mygroup.gitlab.io D->>A: success ``` ### The "~~Signed~~ Encrypted code" fix My current idea is to sign the code instead of state when we redirect back to user domain ```mermaid sequenceDiagram participant A as Attacker participant U as User participant D as mygroup.gitlab.io participant P as projects.gitlab.io participant G as gitlab.com participant AD as ATTACKERSDOMAIN.com A->U: can you please visit projects.gitlab.io/auth?domain=ATTACKERSDOMAIN.COM&state=IRRELEVANT U->>P: projects.gitlab.io/auth?domain=ATTACKERSDOMAIN.COM&state=IRRELEVANT Note over P,G: domain=ATTACKERSDOMAIN.COM is being saved to the session cookie Note over P,G: OAuth workflow P->>U: redirect to ATTACKERSDOMAIN.COM?state=IRRELEVANT&securecode=ENCRYPTED({domain=ATTACKERSDOMAIN.COM, code=mycode}) U->>AD: ?state=IRRELEVANT&securecode=ENCRYPTED({domain=ATTACKERSDOMAIN.COM, code=mycode}) AD->>A: securecode=ENCRYPTED({domain=ATTACKERSDOMAIN.COM, code=mycode}) Note right of A: Need to get a valid state in cookie: A->>D: get index.html D->>A: unauthorized, redirect to projects.gitlab.io/auth?domain=mygroup.gitlab.io&state=USERSTATE Note right of A: skip the auth, just go the last step manually: A->>D: /auth?state=USERSTATE&securecode=ENCRYPTED({domain=ATTACKERSDOMAIN.COM, code=mycode}) Note right of D: decrypt code, and get domain=ATTACKERSDOMAIN.COM, and code=mycode Note right of D: check if ATTACKERSDOMAIN.COM==mygroup.gitlab.io Note right of D: it's not equal, so user was tricked into clicking this, D->>A: failure ``` **I can't say if there is a way to bypass this check. I would really appreciate if everyone who read that far tries to break it** :wink: </details>
issue