[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
- 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 - #262 (comment 255332604) and WIP PoC https://dev.gitlab.org/gitlab/gitlab-pages/-/merge_requests/21
- Proposal 2 - #262 (comment 341774099)
Proposal 2
Going ahead with proposal 2
- Generate a JWT signing key based on the
auth-secret
on Pages startup - 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)
- Redirect to
projects.gitlab.io?state=state
and validate the state's JWT signature - Extract the domain from the JWT claims and continue OAuth flow.
- When we receive
mydomain.gitlab.io?stat=state&code=code
we verify the state's JWT signature again. - 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
- Generate a JWT signing key based on the
auth-secret
on Pages startup. - On redirection from GitLab to https://gitlab.io/auth?code=plaintext&state=random, encrypt and sign the code
- Encrypt code
encryptedCode=AES-GCM(code, domain (as salt), hkdfKey(auth-secret)
- Use the JWT as new code
code=JWT(encryptedCode, nonce, signingKey)
- Redirect to
mydomain.com/auth?code=JWT(encryptedCode, nonce, signingKey)&state=random
and strip thetoken
from the query string to mitigate gitlab#285244 (closed) - When we receive
mydomain.gitlab.io?state=random&code=code
we verify the code's JWT signature. - Get
encryptedCode
andnonce
from the JWT claims and decrypt the code - 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 inattackersdomain.com?code=mygroupcode&state=irrelevant
- After receiving the
code
attacker can complete the Auth process formygroup.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
Signed Encrypted code" fix
The "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