[ANCHOR-1248]: Legacy SEP-31 customer ids are attacker-claimable, reopening the KYC IDOR for pre-upgrade customers#1972
Merged
Merged
Conversation
* update sep12 and sep31 services to always pass owner memo * fix owner memo being dropped when a client name is resolved * add new tests for sep12 and sep31 to verify memo preservation
* add v29 sql migration to populate sep31_customer_id_owner table * add v30 java migration to backfill creator_memo for muxed accounts
Contributor
There was a problem hiding this comment.
Pull request overview
Fixes a regression in SEP-31 customer-id ownership enforcement for pre-upgrade customers by backfilling sep31_customer_id_owner from historical sep31_transaction records, including correct handling for muxed-account (M...) callers, and by ensuring account memos are not dropped when a client name resolves.
Changes:
- Backfills
sep31_customer_id_ownerin V29 from earliest historical references insep31_transaction, skipping rows without a recoverable identity. - Adds a V30 Java migration to decode muxed-account sub-ids and repair
creator_memowhere it would otherwise remainNULL. - Ensures ownership checks in SEP-31 and SEP-12 always include the caller memo (including muxed sub-id), plus adds/updates unit tests around memo distinctness.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| platform/src/main/resources/db/migration/V29__sep31_customer_id_owner.sql | Adds a backfill insert to seed ownership rows from earliest sender/receiver references. |
| platform/src/main/java/db/migration/V30__backfill_muxed_sep31_customer_id_owner.java | Repairs muxed-account ownership rows by decoding and filling creator_memo from M-addresses. |
| core/src/main/java/org/stellar/anchor/sep31/Sep31Service.java | Passes owner memo even when client_name resolves to keep sub-user ownership distinct. |
| core/src/main/java/org/stellar/anchor/sep12/Sep12Service.java | Same memo-preserving change for the verifyOrClaim call on new SEP-31 customer creation. |
| core/src/test/kotlin/org/stellar/anchor/sep31/Sep31ServiceTest.kt | Adds coverage ensuring memo remains distinct across sub-users under the same client name. |
| core/src/test/kotlin/org/stellar/anchor/sep12/Sep12ServiceTest.kt | Adds coverage for memo preservation when client name resolves (needs muxed-case extension). |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
* update order by clause in customer id owner selection * ensure null started at values sort last for consistent owner assignment
* add test to verify put customer handles muxed id distinctness per sub-user when client name resolves * add verification for customer id owner store correctly claiming an id with client name and memo
JiahuiWho
approved these changes
Jul 15, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
The ANCHOR-1237 fix (commit 063e435, #3827576) introduced
sep31_customer_id_ownerto stop a caller from naming another customer's SEP-12 id assender_id/receiver_id. It relies on claim-on-first-reference to cover customers that already existed when an operator deploys the fix:V29__sep31_customer_id_owner.sqlcreates the table empty, andverifyOrClaimhands an id with no owner row to whoever references it first. That only protects a pre-existing id if its true owner happens to reference it again after upgrading - and a customer that's simply read, updated, or named as receiver by its own anchor never re-claims its id, since only creating a brand-new SEP-31 customer claims one (Sep12Service.putCustomer'sisNewSep31Customergate). So every customer id that existed before an operator deploys ANCHOR-1237, and isn't re-referenced by its owner first, is claimable by an attacker who knows the id - reopening the exact read/overwrite chain #3827576 fixed, for the entire pre-upgrade customer catalogue.V29 has never shipped in a tagged release (its only commit is on
develop; the latest release, 4.5.0, doesn't contain it), so no deployment has it applied. This implements a SQL backfill added directly to V29, plus a small companion Java migration for the one case plain SQL can't handle.The one part SQL can't do alone:
creatoris stored as a JSON blob in aVARCHARcolumn, and for muxed-account (M...) callers itsmemofield is always null — the sub-account id is embedded only in the M-address's StrKey encoding. A pure-SQL backfill would leave these rows atcreator_memo = NULL, and sinceSep31Service.postTransactioncomputes a muxed caller's memo as their decoded sub-id (never null), the legitimate owner referencing their own legacy id again would fail the ownership check and get locked out.V30decodes it using the sameMuxedAccountclassWebAuthJwtalready relies on.Separately, and independent of the backfill,
ClientFinder.getClientNameresolves by client domain/signing key only, never consulting the account memo. Both ownership call sites nulled the memo whenever a client name resolved, collapsing distinct sub-users authenticated under one registered client's signing key (different memos or muxed ids) to the same ownership identity. Fixed by keeping the memo always, regardless of whether a client name resolves.Changes
V29__sep31_customer_id_owner.sql: adds a backfill insert after theCREATE TABLE, seedingsep31_customer_id_ownerfromsep31_transactionhistory. For each customer id ever named assender_id/receiver_id,DISTINCT ON (customer_id)ordered bystarted_at, idpicks the earliest referencing transaction, and its recordedclient_name(if attributed) or rawcreatoraccount/memo becomes the owner. Rows with no recoverable identity (creatornull and noclient_name) are excluded rather than violating thecreator_account NOT NULLconstraint.V30__backfill_muxed_sep31_customer_id_owner.java(new): aBaseJavaMigrationthat re-derives the same "earliest transaction per customer id" set, filters to muxed-account creators, decodes the sub-id viaorg.stellar.sdk.MuxedAccount, and updatescreator_memoonly where it's stillNULL- so it can never overwrite a distinct owner.Sep31Service.postTransaction:ownerMemois now alwayswebAuthJwt.getOwnerMemo(), not nulled when a client name resolves.Sep12Service.putCustomer: same change to theverifyOrClaimcall in theisNewSep31Customerbranch - memo is always passed through, no longer nulled when a client name resolves.Sep31ServiceTest,Sep12ServiceTest: new tests asserting memo stays distinct per sub-user (one plain-memo, one muxed) even when both resolve to the same client name.Acceptance Criteria
sep31_customer_id_owneralready has the correct owner row before any post-upgrade request arrives.client_nameis skipped by the backfill, not inserted with a null account.Context
HackerOne #3852332
Testing
./gradlew :core:test --tests "org.stellar.anchor.sep12.Sep12ServiceTest"./gradlew :core:test --tests "org.stellar.anchor.sep31.Sep31ServiceTest"sep31_transactionrows covering a base-account owner referenced by two different transactions (confirming the earliest wins, not the latest), a client-name-resolved owner with a non-null memo, a muxed-account owner, and a transaction with no creator recorded (confirming it's skipped). Ran V29's backfill and V30's decode-and-repair against it directly: all three recoverable identities backfilled correctly, the orphan was skipped, and re-running both migrations against the already-migrated state was a no-op.Documentation
N/A
Known limitations
A SEP-12 customer created via
putCustomerbefore this fix shipped but never named on any SEP-31 transaction has no recoverable owner anywhere in the platform database - there's no signal linking it to a caller identity, so it remains claimable by whoever references it first after this migration runs. Closing this would require an operator-facing reclaim workflow (e.g., letting an owner explicitly establish ownership of an id they can otherwise prove is theirs), which is out of scope here.Also deliberately not addressed: if an attacker already claimed a legacy id in the wild before an operator applies this fix, the backfill enshrines that prior claim as legitimate (it can't distinguish a malicious first-reference from a genuine one). Operators who suspect prior exploitation should audit
sep31_transactionhistory for a legacy id's first referencing caller before applying this migration, rather than relying on it as a retroactive remedy.