Back in September 2025 a friend reached out with a lead on a bug bounty target. We didn’t manage to exploit the lead, but the site’s password-reset flow caught my attention. After spending a good amount of time poking at it, I discovered an issue that would allow an attacker to craft password reset tokens that can change the password of a victim’s account under certain conditions.

0. Here’s the TL;DR

The password reset token is a base64 encoded json object with user properties and an HMAC to ensure the integrity of the token. The HMAC is computed over the concatenated user properties of the token. This lets an attacker create a new account with carefully chosen user properties that concatenate to the same string as the victim’s account and therefore generates a matching HMAC. Besides the HMAC value, the token also contains an expiration unix timestamp in seconds which the attacker can obtain. Finally the token also contains a “SessionEpoch” value in nanoseconds that the attacker needs to bruteforce. The attacker can leak a very close estimate of this value, which makes the bruteforcing very feasible.

1. The password reset flow and the HMAC value

Resetting a password

Let’s first quickly go over how the password reset flow works. When a user wants to reset their password they enter their email and receive a password reset link with a token. The link looks something like this:

https://app.example.com/resetpassword/eyJBY2NvdW50IjoiYXNkZmZvbyIsIkVtYWlsIjoiYXNkZkBhc2RmLmZvbyIsIkV4cGlyZXMiOjE3NjEyMjM1NjYsIkhtYWMiOiJCaHNJUm05UHBfS2RVZGI0VHd2LUVybFJEQk1pRTEwTFpFcVp0MWhxQ3hNPSIsIlNlc3Npb25FcG9jaCI6MTc2MTEzNzE2MjQxMTgxMzk5MSwiRmlyc3ROYW1lIjoiSGFyb2xkIiwiTGFzdE5hbWUiOiJGb29iYXIifQ==

The token base64 decodes to this object:

{
    "Account": "asdffoo",
    "Email": "asdf@asdf.foo",
    "Expires": 1761223566,
    "Hmac": "BhsIRm9Pp_KdUdb4Twv-ErlRDBMiE10LZEqZt1hqCxM=",
    "SessionEpoch": 1761137162411814000,
    "FirstName": "Harold",
    "LastName": "Foobar"
}

Visiting the reset link lets us choose a new password. If we attempt to modify this token by changing the email for example, the link will return a 403 Forbidden response.

After choosing a new password, the page will make a POST request to the same endpoint, setting the new password for the account.

Tampering with the token values

An HMAC-function is a function that takes a secret key and some data and spits out a message authentication code (MAC) which is a kind of hash that ensures authenticity and integrity of the data. In our case the Hmac value in the reset-token ensures that the token values are authentic and can’t be tampered with. Or can they?…

A property of an HMAC-function is that it always produces the same output for the same input. I found that the input to this HMAC-function is, at least partially, constructed from values from this json object, and that these values are simply concatenated together. I realized this by moving around the values in the json and observing that the token was still valid (valid token returns status codes 200 or 409, invalid token returns 403). For example the following token returns 200 OK but notice the FirstName and LastName have been changed:

{
    "Account": "asdffoo",
    "Email": "asdf@asdf.foo",
    "Expires": 1761223566,
    "Hmac": "BhsIRm9Pp_KdUdb4Twv-ErlRDBMiE10LZEqZt1hqCxM=",
    "SessionEpoch": 1761137162411814000,
    "FirstName": "HaroldFooba",
    "LastName": "r"
}

So now we know that the FirstName and LastName must be concatenated in the input to the HMAC.

After some experimenting, I found out that in the input to the HMAC-function, the following values must be concatenated directly.

...|Account|Email|FirstName|LastName|...

The SessionEpoch value is not part of the HMAC input. The Expires value is part of the HMAC input, but I don’t know where it appears.

2. Using HMAC collisions to forge password reset tokens

This gave me an idea. Can we create a user with values such that we can generate a password reset token with a valid Hmac field that is valid for another user?

To achieve this goal we have to overcome these challenges:

  • Figure out how to create an account so that the concatenated values of Account, Email, FirstName and LastName matches a victim’s account.
  • We need a way to control or predict the Expires value
  • Figure out the value of SessionEpoch

Concatenation collisions

Our first challenge is to choose account values so that when they’re concatenated they match a victim account’s concatenated values. The first thing we learn is that Account values are unique so we cannot choose the same Account value as the victim. That’s fine and doesn’t stop us.

If we can register our own email on the same domain as the victim we can create a collision like this:

Property victim attacker
Account victimaccount victimac
Email john@victim.com countjohn@victim.com
FirstName john john
LastName doe doe

If we cannot register emails on victim.com, then maybe we can buy the domain victim.co:

Property victim attacker
Account victimaccount victimac
Email john@victim.com countjohn@victim.co
FirstName john mjohn
LastName doe doe

Finding an available domain that is a prefix is in many cases not possible. We’re much more likely to find a domain that extends the victim’s email domain. A few examples:

Domain Extending domains
victim.com victim.company, victim.community, victim.computer
victim.org victim.organic
victim.me victim.meme, victim.media, victim.memorial, victim.menu
victim.ai victim.airforce

victim.computer is available so with that domain we can create an account like this:

Property victim attacker
Account victimaccount victimac
Email john@victim.com countjohn@victim.computer
FirstName john john
LastName doe doe

But that does not give us the collision we need. We would need a way to change the victim’s first name to be “puterjohn” for the collision to happen. It turns out that in some cases, an attacker can in fact change the name of a victim’s account.

This is possible when the attacker is an admin of an organization of which the victim is also a member. Since users can be members of multiple organizations, any user that is already a member of the attacker’s organization or any user that accepts an invitation from the attacker can have their name changed by the attacker.

Property victim attacker
Account victimaccount victimac
Email john@victim.com countjohn@victim.computer
FirstName puterjohn john
LastName doe doe

An attacker, with admin rights in one of the victim’s organizations, can change the victim’s name so the collision happens. A successful compromise of the victim account would give the attacker access to the victim’s other organizations.

Getting matching “Expires” values for attacker/victim tokens

To compromise the john@victim.com account (with the first name changed to “puterjohn”) we request a password reset to our email “countjohn@victim.computer”. We receive a link with a token that decodes to this:

{
    "Account": "victimac",
    "Email": "countjohn@victim.computer",
    "Expires": 1770383125,
    "Hmac": "FsPutMfy3ntfBmQB4ozqiSqv2ZVVDSCaau9_z0SdlNk=",
    "SessionEpoch": 1763122926978402369,
    "FirstName": "john",
    "LastName": "doe"
}

We fix the property values to match a reset token for the victim’s account:

{
    "Account": "victimaccount",
    "Email": "john@victim.com",
    "Expires": 1770383125,
    "Hmac": "FsPutMfy3ntfBmQB4ozqiSqv2ZVVDSCaau9_z0SdlNk=",
    "SessionEpoch": 1763122926978402369,
    "FirstName": "puterjohn",
    "LastName": "doe"
}

Now the token properties matches the victim’s account and the Hmac value is still valid for the token. But the tokens still fail to reset the victim’s account! After some more testing I realized that the account has some state saved on the server side about the password reset, and a token with the correct user properties and a valid Hmac is not enough in itself.

When trying to change the password the server not only checks if Hmac is valid, but also checks if the user is in a pending password reset state and if Expires and SessionEpoch values matches the server side state values…

The Expires value is just a Unix timestamp in seconds that indicate when the reset token expires. Since it’s in seconds we can just request a password reset token for both our attacker account and the victim account at the same time. Burp proxy has excellent tooling for sending 2 requests simultaneously. This way we can put the victim account in a pending password change state, and know the value of Expires since we will receive a token with this value on our countjohn@victim.computer email.

Brute forcing the “SessionEpoch”

The token still fails to change the victim’s password. Even with the victim account in a pending password reset state and a token with the correct Expires timestamp, a valid Hmac and all the user properties matching the victim’s account. It seems like we have to figure out the value of SessionEpoch as well.

The SessionEpoch value is a unix timestamp in nano seconds. The timestamp appears to be the last time the user signed out or their session expired.

Since this value is not part of the HMAC-function input, we can still generate a token with a valid Hmac and Expires values, and just change the SessionEpoch to try and guess the correct value. But there’s a billion (!) nanoseconds per second so this is not feasible unless we can get a good idea about when the victim last signed out.

Lucky for us we can use our admin account to send a PUT request to

https://app.example.com/api/user/[VICTIM_USER_ID]/change/admin

which will set the SessionEpoch of that account to the current timestamp.

If we use a “helper” account that we have access to, we can set the SessionEpoch for both the victim account and our helper account with Burp’s “single packet” feature. This way the 2 PUT requests will hit the server at almost the exact same time, and we know that the SessionEpoch values must be very close to each other. Since we can just request a password reset for the helper account and read the SessionEpoch from the reset token, we have a pretty good idea of what the SessionEpoch value is for the victim account and we can now easily brute force the value.

With Burp’s TurboIntruder tool I easily got more than 1000 requests per second, and this can probably be improved a lot more by playing around more with the settings.

Burp TurboIntruder RPS
Sending 1164 requests per second with TurboIntruder

In the POC I created and sent with the report the victim and helper accounts’ SessionEpoch values were about 2 million nanoseconds apart.

SessionEpoch Value
SessionEpochvictim 1761304073586371112
SessionEpochhelper 1761304073588332952
Difference 1961840

Assuming 1000 requests per second we can guess 3.6 million values per hour:

1000 requests second   ×   60 seconds minute   ×   60 minutes hour   =   3,600,000 requests hour


3. Performing the attack

When I reported this I also submitted a video of me performing the attack against an account I had created myself. I created a victim account and bought the domain intigriti.media for the attacker account:

Property victim attacker
Account lil_endian_victim lil_endian_vi
Email lil_endian@intigriti.me ctimlil_endian@intigriti.media
FirstName Foo Foo
LastName Bar Bar

I then

  1. Used an admin account to change the first name of the victim account to “diaFoo”.
  2. Sent the PUT request to set the SessionEpoch for the victim account and a helper account.
  3. Requested a password reset for the helper account and extracted the SessionEpoch value from the reset token.
  4. Request a password reset for both the victim and attacker account using Burp’s single packet request. The 2 accounts now have pending password resets with the same Expires values.
  5. I adjust the values in the attacker’s reset token to match the victim’s account values instead.
  6. I use the approximate SessionEpoch value from the helper account as a starting point, and brute force the correct value for the victim’s token.
  7. I manage to find the correct token and use it to successfully change the victim account’s password.

4. Program response

Both the Intigriti triagers and the program were a pleasure to work with on this report. The program had the issue resolved just 4 days after the report was triaged. The report got accepted as High, and awarded a bounty of $2250 + $500 bonus.

The company has read a draft of this writeup and I promised to mention:

  • No exploitation occurred prior to the report I submitted via Intigriti.
  • The issue has been fully remediated prior to the publication of this writeup.