Introduction
generateUUID(inputString) takes an arbitrary string and turns it into a UUID-formatted identifier, used to populate a distinct_id value sent in outgoing API request headers. The problem was in how that string got hashed:
const md5Hash = crypto.createHash('md5')
md5Hash.update(inputString)
const hash = md5Hash.digest('hex')
MD5 is not just "old" — it is cryptographically broken. Practical collision attacks against MD5 have been demonstrated for well over a decade, meaning two different inputs can be crafted (or, less deliberately, can coincidentally occur) to produce the identical 128-bit digest. Since generateUUID() reformats that digest into a UUID-like string, an MD5 collision here means two different inputString values can yield the exact same generated identifier. For code whose entire job is to give each input a distinct, request-identifying value, that's the one property it can't afford to lose.
Affected Versions
| Affected | not applicable (first-party code) |
| Fixed in | not applicable (first-party code) |
| Ecosystem | npm (Node.js) |
| CVE / GHSA | not assigned |
| CWE | CWE-328 (Use of a Broken or Risky Cryptographic Algorithm) |
There's no package version to pin here — this is application code. The relevant marker is the commit that replaced the md5 digest with sha256 inside generateUUID().
The Vulnerability Explained
The vulnerable pattern is compact:
export function generateUUID(inputString) {
const md5Hash = crypto.createHash('md5')
md5Hash.update(inputString)
const hash = md5Hash.digest('hex')
// Format the hash into UUID-like string
const uuid = ...
inputString is hashed with MD5 and the resulting hex digest is sliced into UUID segments. Two issues stack up here:
- Collision resistance is broken. MD5 collisions can be engineered with modest compute. If any part of the system lets an attacker influence or choose
inputString(for example, a value derived from user-supplied data such as an email address, per how this identifier is populated), a motivated attacker could search for a second input that hashes to the same digest as a target user's, causing the generateddistinct_idto collide. - No salt, regardless of algorithm. Even after upgrading the digest algorithm, hashing predictable, low-entropy strings like email addresses without a salt or HMAC key means the output is still amenable to precomputed lookup tables — an attacker who suspects a specific email is registered can hash their guess and compare it against captured identifiers to confirm it.
Attack scenario: imagine a downstream service uses the distinct_id header produced by generateUUID() to route support tickets or attribute telemetry to a user. An attacker who can influence the input to generateUUID() — even indirectly, through a value they control that eventually gets hashed — could attempt to find a colliding input under MD5 that maps to another user's distinct_id. Requests tagged with that collided identifier would then be misattributed, undermining any downstream logic (rate limiting, auditing, support routing) that trusts the identifier's uniqueness.
The Fix
The fix is a one-line algorithm swap inside generateUUID():
const sha256Hash = crypto.createHash('sha256')
sha256Hash.update(inputString)
const hash = sha256Hash.digest('hex')
MD5's createHash('md5') call is replaced with createHash('sha256'). The rest of the function — updating the hash with inputString and formatting the digest into a UUID-like string — is unchanged. SHA-256 has no known practical collision attacks, so two different inputString values are astronomically unlikely to produce the same digest, closing the collision path that made MD5 unsuitable here.
This fix specifically targets the "broken algorithm" half of CWE-328. It does not, on its own, add a salt or HMAC key — if inputString is ever derived from something guessable like an email address, that residual exposure (dictionary/rainbow-table matching against low-entropy input) remains a separate hardening step worth considering.
Key Takeaways
generateUUID(inputString)is exactly the kind of "just build an identifier" helper that quietly inherits whatever hash function was copy-pasted into it — check what algorithm sits behind any custom UUID/ID generator in your codebase.- MD5's collision weakness matters even for "just a header value" like
distinct_id: identifier collisions can misattribute requests, not just break cryptographic signatures. - Swapping
crypto.createHash('md5')forcrypto.createHash('sha256')is a minimal, drop-in fix when the digest length and format don't need to change beyond re-slicing for the UUID template. - If the input to a hash-based identifier generator is ever a low-entropy, guessable value (like an email address), upgrading the algorithm alone doesn't add salting — that's a separate control to evaluate.
How Orbis AppSec Detected This
- Source: the
inputStringparameter passed intogenerateUUID() - Sink:
crypto.createHash('md5')used to derive the identifier's digest - Missing control: use of a collision-resistant hash algorithm (and, more broadly, absence of salting for any low-entropy input)
- CWE: CWE-328 (Use of a Broken or Risky Cryptographic Algorithm)
- Fix:
generateUUID()now hashesinputStringwithcrypto.createHash('sha256')instead of MD5
Orbis AppSec automatically detected this vulnerability and opened a pull request with the fix. Try Orbis AppSec on your repositories to find and fix issues like this automatically.
Conclusion
MD5 has no place generating identifiers that need to stay unique, and generateUUID() is a clear example of why: a broken hash function undermines the one guarantee the function exists to provide. The fix — swapping in SHA-256 — is small, but it closes a real collision risk on a code path that feeds directly into request-identifying headers. Anywhere else in your codebase you find createHash('md5') doing similar identifier work deserves the same look.