-
Bug
-
Resolution: Done
-
Undefined
-
None
Before reporting an issue
[x] I have read and understood the above terms for submitting issues, and I understand that my issue may be closed without action if I do not follow them.
Area
oidc
Describe the bug
DPoP proofs are checked for DPoPIsActiveCheck and DPoPReplayCheck conditions in org.keycloak.services.util.DPoPUtil.
The first check takes into account both lifetime (10 seconds) and clockSkew (15 seconds), as documented in Allow a DPoP Proof by considering a clock skew for smooth interoperability
The problem is that second check has not been updated accordingly to take clockSkew into account, so there is a 15 seconds window that causes hashString to be removed from the singleUseCache because of resulting negative time in putIfAbsent
public boolean test(DPoP t) throws DPoPVerificationException {
SingleUseObjectProvider singleUseCache = session.singleUseObjects();
byte[] hash = HashUtils.hash("SHA1", (t.getId() + "\n" + t.getHttpUri()).getBytes());
String hashString = Hex.encodeHexString(hash);
if (!singleUseCache.putIfAbsent(hashString, (int)(t.getIat() + lifetime - Time.currentTime())))
return true;
}
h3. Version
26.4.0
h3. Regression
[ ] The issue is a regression
h3. Expected behavior
A 'replayed' DPoP proof must be checked for not being already used for additional clockSkew time
h3. Actual behavior
Submitting the same DPoP proof multiple times using the appropriate timing can lead to original proof being removed from cache (negative time computed), so attacker can use stolen proof at least one more time to replay.
h3. How to Reproduce?
I'm using oauth2c to simulate OAuth2 flow with pkce and dpop in the same Keycloak dev machine (resulting clock skew is 0 seconds in this test)
export AUTHORIZATION_SERVER=my-keycloak-dev-machine
# Simulate with oauth2c the Auth Code Flow and capture Refresh Token from Keycloak response export REFRESH_TOKEN=`oauth2c "$AUTHORIZATION_SERVER/.well-known/openid-configuration" \ --client-id pkce-dpop-client \ --response-types code \ --response-mode query \ --grant-type authorization_code \ --auth-method none \ --scopes openid,email,offline_access \ --signing-key jwks.json \ --pkce \ --dpop | jq -r .refresh_token`
# Simulate with oauth2c a refresh token request using captured token from previous call, then capture DPoP proof from Keycloak response export DPOP=`script -q -c 'oauth2c "$AUTHORIZATION_SERVER/.well-known/openid-configuration" \ --client-id pkce-dpop-client \ --grant-type refresh_token \ --auth-method none \ --refresh-token $REFRESH_TOKEN \ --signing-key jwks.json \ --dpop' | sed 's/\x1B\[[0-9;]\{1,\}[A-Za-z]//g' | sed -n 's/.*Dpop: \(.*\)/\1/p'`
# Manually simulate refresh token request using the same refresh token and the same DPoP proof captured from previus call watch -n 1 'curl -v -k --request POST "$AUTHORIZATION_SERVER/$TOKEN_METHOD_ENDPOINT" \ --header "Content-Type: application/x-www-form-urlencoded" \ --header "DPoP:$DPOP" \ --data-urlencode "grant_type=refresh_token" \ --data-urlencode "client_id=pkce-dpop-client" \ --data-urlencode "refresh_token=$REFRESH_TOKEN"'
Watch ouput of curl command, first responses are 'DPoP proof has already been used' which is ok, but after 10 seconds the DPoP exists from the cache and attacker can issue a new refresh token
h3. Anything else?
Fix should be quite simple, just add (at least) clockSkew in time computation
if (!singleUseCache.putIfAbsent(hashString, (int)(t.getIat() + lifetime + clockSkew - Time.currentTime()))) { throw new DPoPVerificationException(t, "DPoP proof has already been used"); }
- links to