Reference Token security in IdentityServer
A recent threat modeling session got me interested in "how hard would it be to brute force discover a valid reference token in an app using OAuth2 with IdentityServer". Unsurprisingly the answer is 'very', and the attack is not worth attempting. Regardless, as my first attempt at an 'actual attack', I decided to try and document my process of exploring this and the defenses that could be mounted.
IdentityServer
IdentityServer was and probably still is the de facto OAuth2/OIDC provider for .NET shops when self-hosting and customizing an auth service is needed. It integrates well with e.g. ASP.NET Identity and is easy to get up and running.
The last open version was IdentityServer4 and in 2020 the product was branded Duende IdentityServer with a new license. Due to the pricing model, I believe many organizations are still holding on to IS4, although being stuck in .NET 6 should be pretty scary for such a critical component.
I also must mention the Skoruba frontends for both IS4 and Duende versions, which give you a fully working IS service including a GUI, admin tools and API. Brilliant tools deserving some attention.
Reference tokens
Reference tokens (RT) are an alternative access token mechanism for JWTs for passing user authorization information around. Not to be confused with Refresh tokens, which can be used to fetch new access tokens.
In a nutshell, the tradeoffs between JWTs and RTs are:
- RTs can easily be revoked upon user logout
- RTs are opaque; the information cannot be accessed without performing an authenticated introspection call
- RTs are more compact than JWTs, reducing data transfer from client to server but adding the introspection overhead from server to IS
The format of the token is defined as:
The handle is 32 bytes of cryptographically strong random data encoded as a hex string with a suffix to indicate the encoding ("-1")
Here's an example RT as it's passed in the Authorization header: Bearer 08F8EAD21FE3926BEC8FCC6A82A08EF42BD12B0A17EF8F4F9BEE2C9F1F41790C-1
Brute forcing the token?
The server receiving the RT performs the introspection and, if it succeeds, allows the action to continue as per the contents of the introspection result. As with all session tokens, having one give you keys to the kingdom, regardless of the original owner. I was especially interested in seeing what it would take to gain a valid Reference Token by a repeated attack.
For a successful attack, these conditions should be true:
- There exists an endpoint that gives a different response (or timing) for an invalid and valid token
- for this you of course need a valid token to begin with. Easiest is to log in with your own account, perform and capture a HTTP call with the token and observe the response. Then modify the token, resend and see the differences
- if you can't get a valid token, you might still chance upon an endpoint that behaves differently when giving it a valid-looking token, giving one in the wrong format or giving none at all, but this might be really difficult unless you have detailed knowledge of the system
- You hit a token that is currently valid for another user
- access tokens are by default (in Skoruba) valid for 1 hour, and as RTs they're invalidated upon user logout, so the time window is rather small
- The token you found is valid for the system you're attempting to break into
- RTs are valid for specific scopes, so if you have one IdentityServer handling access for multiple APIs, lucking out on a currently open token is not enough - it must match the scopes of the system you're attempting to pop
- you might observe differences in the responses for a non-existing token and one that exists but does not belong to the current system. This is where observing the response timings is important, as you could attempt to use such a token for another service
So, from the get-go, actually pulling off this kind of attack is not very likely to succeed. I could have a go at the math here but prefer not to embarrass myself just yet. On the other hand, if the service has hundreds of thousands of active RTs lying around and there's no defenses in place, just leave a repeater chugging away - sooner or later it will find a match.
An attempt was made
After you have set up the environment so that you can call a service with valid and invalid tokens, you can use the excellent Burp Suite to automate an attack.
First, using e.g. Burp Proxy, isolate the HTTP call and send it to Repeater. Here's what a working API call looks like in my demo environment:
GET /api/Users/GetCurrentUserInfo?api-version=1 HTTP/2
Host: localhost:44333
Authorization: Bearer 8DC24D7A152688A86D8A3C3CFBC1C6A5256AA3A59A5B5B65882A7A5FD5E22887-1
And the response
HTTP/2 200 OK
Content-Type: application/json; charset=utf-8
Date: Sun, 02 Feb 2025 07:41:50 GMT
Server: Kestrel
Api-Supported-Versions: 1
{"name":"admin","isId":"b18d57f5-a728-4c77-9212-a26447a9eaf4","organizationId":1,"organizationName":"Testorg","userType":"NewUser"}
Once you have the working call, modify it and observe the differences in response. Here's the response after changing a single letter in the Bearer token:
HTTP/2 401 Unauthorized
Content-Type: application/problem+json; charset=utf-8
Date: Sun, 02 Feb 2025 07:43:03 GMT
Server: Kestrel
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.2","title":"Unauthorized","status":401,"traceId":"00-49c11205ce6dd6bb2794241009c82e73-65e86f020b192ff3-00"}
To initialize a brute force attack, send the request to Burp Intruder and set up a sniper attack on the bearer token:
Authorization: Bearer §8DC24D7A152688A86D8A3C3CFBC1C6A5256AA3A59A5B5B65882A7A5FD5E22887§-1
.
I used the "Brute forcer" but others, such as the bit flipper, might be good for this as well. Set the min and max lengths to 64 so we match the format and set the character set to ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789
to match the casing - tokens are case sensitive, as you can observe in the Repeater.
Once all that is set up - let fly! The Burp Intruder will generate valid looking values for a reference token, starting with AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA-1
, then BAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA-1
etc. Observe from the responses (here it's 200 or 401) or timings for any signs of unexpected access. You might also want to take a nap - this will take a while!
Since I'm currently on the free edition of Burp, my attacks are throttled from the start, so this attack will not get anywhere fast. Even bombarding the endpoint with hundreds of messages per second would take a very long time, during which you would probably be flagged by all manners of tools by the number of failed messages you're sending.
Here's what Skoruba Duende Admin logs:
Invalid reference token. TokenValidationLog { ClientId: null, ClientName: null, ValidateLifetime: True, AccessTokenType: "Reference", ExpectedScope: null, TokenHandle: "PAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA-1", JwtId: null, Claims: null }
The attack is very high noise, low probability and compute intensive. Your effort would most definitely be better placed elsewhere. If you know your target has no automated defenses nor good visibility for this kind of flooding, this could certainly work. Even in that case, other attacks might still yield easier results.
Adding hurdles
So even if this kind of attack is not likely to succeed, what could be done to defend if you found yourself targeted and you were missing the automated infra that would block this traffic for you? Well, naturally you should first fix the infra around this, but if you'd be so inclined, there may be something to be done on the IdentityServer level.
Rate limiting
One interesting point here is that the traffic you're trying to restrict is coming from a trusted server as token introspection calls. The API cannot act before it has introspected the token (unless the token was introspected earlier and cached), so practically for every new permutation of the token, a corresponding introspection call must be made. But since they're coming from the application, not the attacker, you cannot naively rate-limit based on IP or standard headers, as that would halt the valid traffic as well.
You could of course forward some information about the caller in additional headers in the introspection call and then rate limit by those. Caller IP is the thing that comes to mind, but since these kinds of attacks often rotate IPs or use multiple devices, the efficacy is questionable.
Delaying the responses
As established, we can't easily block requests outright, since the attacking messages are mixed with valid traffic. Delaying the responses to invalid requests can be done without disturbing the normal data flow, increasing the effort and decreasing the probability of mounting a successful attack.
A trivial way to achieve this would be to add a global middleware that evaluates the call, and if it fails, adds a flat or variable delay before responding. Like this:
app.Use(async (context, next) => {
await next();
if (context.Response.StatusCode >= 400){
await Task.Delay(2000);
}
});
UseAuthentication(app); //note the middleware order
But as per the spec, the introspection endpoint returns 200 even if the token is invalid, and to get the actual result, you'd need to parse the response like this for example:
app.Use(async (context, next) =>
{
if (context.Request.Method != HttpMethods.Post || context.Request.Path != "/connect/introspect")
{
await next();
}
else
{
var originalBodyStream = context.Response.Body;
await using var memoryStream = new MemoryStream();
context.Response.Body = memoryStream;
await next();
memoryStream.Seek(0, SeekOrigin.Begin);
var responseBodyText = await new StreamReader(memoryStream).ReadToEndAsync();
if (responseBodyText.Equals("{\"active\":false}"))
{
await Task.Delay(2000);
}
memoryStream.Seek(0, SeekOrigin.Begin);
context.Response.Body = originalBodyStream;
await context.Response.Body.WriteAsync(memoryStream.ToArray());
}
});
UseAuthentication(app);
NOTE: delaying responses like this could lead you to destroy your thread pool very quickly, so handling this outside IS4 in e.g. a reverse proxy would still be better.
You could also use IIntrospectionRequestValidator
to perform your own introspection validation, where you could also handle the rate limiting mentioned above, but since there's no easy way to append your own functionality after the default check has been done, you'd need to implement the whole thing yourself.
I'd also like to see the introspection return 401 when the token is invalid, as that would make it easier for automatic tools to detect these errors without having to dig in to logs. Though possible, going against the specification is not recommended and if implemented, should be documented especially well.
Conclusions
As was pretty much clear from the start, brute forcing your way into a valid reference token is not an actual threat, as long as you have visibility into ongoing bursts of traffic and the infrastructure to lock it down. The reference token length and the limited token validity are reasons enough for attackers to spend their time more productively.
If you wanted, there's a couple of things on the IdentityServer side you could do to mitigate, but that time, too, could be better used to e.g. improve your infra.
This was my first foray into planning, examining and executing an attack (outside Portswigger Academy) and I had a lot of fun. Seeing as maintaining one IS instance is partially my responsibility, I feel like I learned a lot in a way that would not have happened 'naturally'. Maybe someday I'll find something actually usable, but until then, I'll be happy to just keep fantasizing about these in threat modeling workshops.