You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Labels: Backend, security, payments, reconciliation, Hard
Overview: The invoice memo is the key that binds an incoming Stellar payment to an invoice, and it is generated from a timestamp plus sixteen bits of Math.random(). generateMemoId() returns String(Date.now() * 65536 + Math.floor(Math.random() * 65536)). Anyone who knows roughly when an invoice was created has only 65,536 candidate memos to search, and the reconciliation watcher accepts any purely numeric memo as an invoice identifier. Combined with the missing amount validation in Validate payment amount and asset in the Horizon reconciliation path before marking invoices paid #439, guessing a memo is enough to mark someone else's invoice paid.
Details:
The entropy is 16 bits per millisecond. An attacker who knows the creation time to within a second has roughly 65 million candidates; to within a millisecond, 65,536. Invoice creation time is not secret — the public payer page exposes createdAt through findPublicInvoice, so for any invoice whose link has been shared, the timestamp half of the memo is known exactly and only the 16-bit random half remains.
Math.random() is not cryptographically secure and is not intended for values that gate access to anything. crypto.randomBytes is already imported and used in AuthService for nonces, so the secure primitive is available and in use elsewhere in the codebase.
resolveMemoId() in the Horizon watcher returns any memo matching /^\d+$/ verbatim as an invoice ID, before the configured MEMO_PREFIX is considered. Since generated memos are purely numeric, the prefixed form is effectively unused for matching, and any numeric memo is a candidate lookup key.
That numeric-passthrough is a collision surface beyond deliberate attack. Numeric memo IDs are the standard convention for exchange deposits across the Stellar ecosystem, so an unrelated payment arriving at the merchant account carrying a numeric memo is looked up as an invoice ID and settles a matching invoice if one exists.
findByMemo returns any non-cancelled invoice with that memo regardless of merchant, and markAsPaid then settles it without checking amount, asset, or payer — so a successful guess costs the attacker one dust payment and the merchant a falsely settled invoice, a webhook, a notification, and an activity-feed entry.
The memo is also enumerable in bulk: because the timestamp component is monotonic, an attacker who has seen two invoices from a merchant can bound the range for everything issued between them.
The comment on the function reasons only about staying inside Number.MAX_SAFE_INTEGER. It treats the memo as an identifier that needs to be unique, not as a value that needs to be unguessable — which is the actual requirement given how the watcher uses it.
Scope:
Generate memos from a cryptographically secure source with enough entropy that guessing is not viable, while staying within the Stellar memo constraints for the memo type in use.
Decide deliberately between a numeric memo ID and a text memo with the configured prefix, and make the watcher's matching rule agree with what the generator produces instead of accepting both shapes.
Stop treating an arbitrary numeric memo as an invoice identifier, so unrelated ecosystem traffic carrying numeric memos cannot resolve to an invoice.
Keep memo lookup constrained to the merchant the payment was actually sent to, so a match is scoped rather than global.
Preserve compatibility for invoices already issued with existing memos, and document the transition.
Math.random().generateMemoId()returnsString(Date.now() * 65536 + Math.floor(Math.random() * 65536)). Anyone who knows roughly when an invoice was created has only 65,536 candidate memos to search, and the reconciliation watcher accepts any purely numeric memo as an invoice identifier. Combined with the missing amount validation in Validate payment amount and asset in the Horizon reconciliation path before marking invoices paid #439, guessing a memo is enough to mark someone else's invoice paid.createdAtthroughfindPublicInvoice, so for any invoice whose link has been shared, the timestamp half of the memo is known exactly and only the 16-bit random half remains.Math.random()is not cryptographically secure and is not intended for values that gate access to anything.crypto.randomBytesis already imported and used inAuthServicefor nonces, so the secure primitive is available and in use elsewhere in the codebase.resolveMemoId()in the Horizon watcher returns any memo matching/^\d+$/verbatim as an invoice ID, before the configuredMEMO_PREFIXis considered. Since generated memos are purely numeric, the prefixed form is effectively unused for matching, and any numeric memo is a candidate lookup key.findByMemoreturns any non-cancelled invoice with that memo regardless of merchant, andmarkAsPaidthen settles it without checking amount, asset, or payer — so a successful guess costs the attacker one dust payment and the merchant a falsely settled invoice, a webhook, a notification, and an activity-feed entry.Number.MAX_SAFE_INTEGER. It treats the memo as an identifier that needs to be unique, not as a value that needs to be unguessable — which is the actual requirement given how the watcher uses it.backend/src/invoices/invoices.service.tsbackend/src/stellar/horizon-watcher.service.tsbackend/src/stellar/stellar.service.tsbackend/prisma/schema.prismabackend/src/config/stellar.config.ts