Welcome to the third story in the “Lessons Learned” series where we discuss real-world vulnerabilities from the perspective of an application security engineer focusing on the underlying root causes and the measures we can take to prevent similar issues in our applications.

In today’s story, we discuss a very interesting bug-bounty write-up showing a 0-click ATO (account takeover) using a clever technique called the Sandwich 🥪 attack, you can find the full write-up here, credit to Lupin & Holmes.

Impact of the vulnerability

As this is a bug bounty write-up the application affected wasn’t disclosed, but the impact was a 0-click ATO which means one user of the application could take over another user’s account without the victim user having to do anything, the attacker only needs to know the username or email of the victim.

What went wrong?

Like the vast majority of web applications, the affected application had a “Reset Password” functionality to help users who forgot their passwords, and like most applications, the functionality worked by following the below sequence:

  1. Take the email of the user who forgot the password.
  2. Verify the email exists.
  3. Generate a random Id that corresponds to the user, and store it in the application database.
  4. Send an email to the user with a link to the reset password endpoint including the random Id in the query parameters. e.g. https://password.application.com/token=ae0010a2-a6ed-11ef-b2c2-d26f418147d3
  5. When the reset password endpoint gets an HTTP request it verifies the random Id exists in the database, and if it does it persists the new password for the corresponding user. Note that the security of this feature depends on the fact that the reset password random Id is long enough and has enough entropy (randomness) and hence can’t be guessed by an attacker trying to reset the password of another user to take over their account.

UUID version 1

Well, turns out this is not entirely true. The security researcher found that the application used UUID version 1 to generate the random Id used for the reset password links, and UUIDv1 relies mainly on the MAC address of the device and the timestamp of generation instead of being random. That is why if you try generating multiple uuids using UUIDv1 on your device part of the uuid will always be the same (depends on the MAC address), and the part that is different is the hexadecimal representation of the timestamp of the uuid generation.

The uuids generated using UUIDv1 have the below structure:

  • First 3 parts are the hexadecimal representation of the timestamp of the uuid generation.
  • Last 2 parts depend on the MAC address and system information, hence will always be the same if the uuid is generated on the same device.


Let’s give that a try in Python’s implementation of UUIDv1

As you can see the first part of the uuid (in red) depends on the time stamp, that is why the last 2 parts are the same in all generated uuids as they were generated within a short period of time and have close timestamps, and the second part of the uuid (in yellow) is the same for all uuids as they were all generated on the same laptop. This isn’t very random, is it?

You can also convert the first part of the UUID to a readable timestamp with a function like the one below:

from uuid import UUID,uuid1
from datetime import datetime, timedelta
def uuid1_to_datetime(uuid1_str):
    # Parse the UUID string to a UUID object
    uuid_obj = UUID(uuid1_str)
    uuid_time = uuid_obj.time
    uuid_seconds = uuid_time / 1e7

    uuid_epoch = datetime(1582, 10, 15)
    unix_epoch = datetime(1970, 1, 1)
    epoch_offset = (unix_epoch - uuid_epoch).total_seconds()

    unix_time = uuid_seconds - epoch_offset
    readable_date = datetime.fromtimestamp(unix_time)

    return readable_date

# Example usage
uuid1_str = str(uuid1()) 
print(uuid1_str)
datetime_value = uuid1_to_datetime(uuid1_str)
print("Timestamp in UUID:", datetime_value)

Which gives the below output

$ python test.py
155da2e8-a75f-11ef-b3ee-d266e835d4e1
Timestamp in UUID: 2024-11-20 11:47:09.396452

$ python test.py
aa604f0e-a763-11ef-9e7f-d266e835d4e1
Timestamp in UUID: 2024-11-20 12:19:57.381403

The Sandwich 🥪 Attack

Now what remains is how the security researcher was able to exploit this un-random uuid issue, which was a very clever attack the worked in the below sequence:

  1. Attacker requests a reset password link for their own account, they will get the link in their email, this is the first slice of bread 🍞.
  2. Very quickly, the attacker also requests a reset password link for the victim’s account, the attacker won’t get this link of course, this is what’s inside the sandwich 🧀.
  3. Also very quickly, the attacker requests another reset password link for their own account, and they will also get this link in their email, this is the second slice of bread 🍞.
  4. Now the attacker has 2 uuids for the 2 links generated for their own account, and there is another uuid for the victim account we don’t know yet, this is the one we need to find to takeover the victim account.
  5. As all of the uuids are generated using UUIDv1 the last part (in yellow in the example above) is always the same so we can get that from either of the 2 links the attacker already has.
  6. What remains is the first part which depends on the timestamp (in red in the example above) which we don’t know, but we know it is a timestamp between the 2 timestamps in the links the attacker has (the 2 slices of bread). Hence, the security researcher created a script that generated a list of all timestamps between the 2 timestamps in the links in the attacker link, and used that to generate uuids and links then used these links to brute force the application until the correct id was found.
  7. Once the correct id was found the attacker could use it to reset the password of the victim account and take over the account.

The Fix

This issue can be fixed by switching to UUID version 4 which relies on random number generation, making it impossible to guess the generated uuids.

Lessons Learned

In this case the issue is more related to the implementation than to the design of the reset password functionality, and there are multiple things we can do that could help avoid this issue and similar issues:

  • Rate limiting: This attack needed brute forcing to work, and this could have been prevented by rate limiting. In this case, as the route is not authenticated you can limit the number of requests per client IP per second or per minute.
  • Always review crypto usage with security: Poor choice of crypto algorithms could lead to different kinds of security issues, so it is always a good idea to review any usage of crypto with your security team. And yes, random number/id generation should be included in crypto operations. This is also a good topic to discuss during the threat modeling of your project.
  • Use SAST and Linters: This kind of implementation issue could be detected automatically using tools SAST and Linters as the vulnerable functions are known. For example, in this case I couldn’t find a SAST rule to detect the usage of UUIDv1, but I took advantage of Semgrep’s Custom rules feature to add a rule to detect the usage of UUIDv1 in Python in the Semgrep Open source Rule Registry. Here is the Pull Request I submitted to add the rule https://github.com/semgrep/semgrep-rules/pull/3517 Here’s an example of findings generated by the new rule I added:
$ semgrep -c insecure-uuid-version.yaml .

┌──── ○○○ ────┐
│ Semgrep CLI │
└─────────────┘

Scanning 180 files (only git-tracked) with 1 Code rule:

  CODE RULES
  Scanning 91 files.

  SUPPLY CHAIN RULES

  No rules to run.


  PROGRESS

  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 100% 0:00:00                                                                                                                        


┌─────────────────┐
│ 3 Code Findings │
└─────────────────┘

    insecure-uuid-version.py
    ❯❱ insecure-uuid-version
          Using UUID version 1 for UUID generation can lead to predictable UUIDs based on system information
          (e.g., MAC address, timestamp). This may lead to security risks such as the sandwich attack. 
          Consider using `uuid.uuid4()` instead for better randomness and security.                                                                

           ▶▶┆ Autofix ▶ uuid.uuid4()
            4┆ uuid = uuid.uuid1()
            ⋮┆----------------------------------------
           ▶▶┆ Autofix ▶ uuid4()
            9┆ uuid = uuid1()
            ⋮┆----------------------------------------
           ▶▶┆ Autofix ▶ uuid4()
           14┆ uuid = uuid1()                

┌──────────────┐
│ Scan Summary │
└──────────────┘
Some files were skipped or only partially analyzed.
  Scan was limited to files tracked by git.

Ran 1 rule on 91 files: 3 findings.

⏫ A new version of Semgrep is available. See <https://semgrep.dev/docs/upgrading>

Side Challenges

💡 Use the above Semgrep custom rule as reference and submit a new Semgrep rule that detects the usage of UUIDv1 in another language such as Java or Javascript. Share with me the Pull Request the in comments if you do.

💡 Create a rule in any Linter you use (e.g. ESLint) that detects the usage of UUIDv1. Share the rule with me in the comments.

Conclusion

The fact that the usage of the uuid1() function instead of uuid4() could lead to an account takeover vulnerability just shows that for many security issues, the devil truly lies in the details. This is why your approach to application security should be multi-layered to cover both the design and the implementation. It is also useful to use automation to convert any lessons learned for past issues to rules and checks in your tools (like the Semgrep rule above). Hope you found this useful, have a great day ahead!

Author Of article : Mohamed AboElKheir Read full article