Wallet Transaction Integrity#
Overview#
The wallet subsystem (li_member_wallet) manages member balances for deposits, withdrawals, and frozen amounts. Two distinct integrity vulnerabilities exist: a race condition in balance checks during withdrawal, and a deduplication key bypass in the duplicate-submission guard. Both affect the POST /buyer/wallet/wallet/withdrawal endpoint.
Race Condition in Balance Updates#
Pattern#
Every balance-reducing operation follows a read-check-update pattern entirely in application code:
- Read the current wallet row (
checkMemberWallet) - Validate
balance >= amountin Java - Subtract and call
updateById()β a plainUPDATE li_member_wallet SET member_wallet = ? WHERE id = ?with no conditional WHERE clause
Why This Is Unsafe#
There is no optimistic locking (@Version) on MemberWallet or its parent BaseEntity , and MemberWalletMapper adds no custom SQL with a guarding WHERE balance >= ? condition . This means two concurrent withdrawal requests can both pass the balance check and both proceed to deduct, overdrafting the wallet.
The applyWithdrawal method re-reads the wallet via getMemberWallet for validation , then calls reduceWithdrawal which does its own read again β two separate SELECT calls before the UPDATE, neither of which is serialized.
The methods have @Transactional, but standard transaction isolation (READ COMMITTED, the MySQL default) does not prevent concurrent transactions from both reading the same pre-deduction balance before either commits.
Affected methods in MemberWalletServiceImpl:
reduceWithdrawalβ deducts balance and moves to frozenreduceβ general balance deductionreduceFrozenβ deducts from frozen amount
Fix Directions#
- Add a MyBatis-Plus
@Versionfield toMemberWalletfor optimistic locking, or - Replace
updateByIdwith a custom mapper method:UPDATE li_member_wallet SET member_wallet = member_wallet - ? WHERE id = ? AND member_wallet >= ? - Alternatively, use a Redis distributed lock keyed on
memberIdaround the read-modify-write cycle.
Deduplication Key Vulnerability#
How @PreventDuplicateSubmissions Works#
The PreventDuplicateSubmissionsInterceptor builds a Redis key from:
- Request URI
- Query/form parameters (serialized as JSON)
- Optionally, the authenticated user ID when
userIsolation=true
It calls cache.incr(key, expire) and rejects the request if the counter exceeds 0 within the TTL window .
The @PreventDuplicateSubmissions annotation defaults: expire = 3 seconds, userIsolation = false .
The Bypass#
The withdrawal controller uses the annotation with all defaults β userIsolation=false β meaning the deduplication key is derived solely from URI + parameters, without the user ID .
Two consequences:
-
Per-user bypass via parameter variation: A member can submit multiple withdrawals within the 3-second window by slightly varying any parameter (e.g.,
price=100.0vsprice=100.00, or differentrealNamevalues). Each produces a different Redis key and passes the guard independently. -
Cross-user collision (global key): Because the key is global (not user-scoped), two different users submitting the exact same
price,realName, andconnectNumbersimultaneously will share one Redis slot β the second user is incorrectly rejected. This is a correctness bug independent of the security concern.
Fix Direction#
Apply @PreventDuplicateSubmissions(userIsolation = true) on the withdrawal endpoint. This appends the authenticated user ID to the key , preventing per-user replay regardless of parameter variations. Additional server-side idempotency (e.g., a unique constraint on (memberId, sn) in MemberWithdrawApply) is needed for full protection since a determined attacker can still vary parameters.
Key Files#
| File | Role |
|---|---|
MemberWalletServiceImpl.java | Balance read/check/update logic; all vulnerable methods |
MemberWallet.java | Entity β memberWallet (balance), memberFrozenWallet; no @Version |
MemberWalletMapper.java | Bare BaseMapper<MemberWallet>; no custom SQL |
MemberWalletBuyerController.java | POST /withdrawal endpoint; uses default @PreventDuplicateSubmissions |
PreventDuplicateSubmissionsInterceptor.java | AOP interceptor; key construction logic |
PreventDuplicateSubmissions.java | Annotation definition; expire and userIsolation defaults |