[Gitlab Pages Auth Bypass] Able to steal a user's Authentication Code For Gitlab Pages

HackerOne report #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

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 2

Going ahead with proposal 2

  1. Generate a JWT signing key based on the auth-secret on Pages startup
  2. When we initiate the authentication flow, generate a JWT token with claims, set it as the state and save to the encrypted cookie
  state := jwt(domain, randomNewState, jwtSigningKey)
  1. Redirect to projects.gitlab.io?state=state and validate the state's JWT signature
  2. Extract the domain from the JWT claims and continue OAuth flow.
  3. When we receive mydomain.gitlab.io?stat=state&code=code we verify the state's JWT signature again.
  4. 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.
  2. On redirection from GitLab to https://gitlab.io/auth?code=plaintext&state=random, encrypt and sign the code
  3. Encrypt code encryptedCode=AES-GCM(code, domain (as salt), hkdfKey(auth-secret)
  4. Use the JWT as new code code=JWT(encryptedCode, nonce, signingKey)
  5. Redirect to mydomain.com/auth?code=JWT(encryptedCode, nonce, signingKey)&state=random and strip the token from the query string to mitigate gitlab#285244 (closed)
  6. When we receive mydomain.gitlab.io?state=random&code=code we verify the code's JWT signature.
  7. Get encryptedCode and nonce from the JWT claims and decrypt the code
  8. Exchange for access token and serve content if successful

Sequence diagrams from the comment below

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

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

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)

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:

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: 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.

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:

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

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 😉

Edited by Jaime Martinez