Stepp2p Double Withdraw
Exploit Transactions
0xe94752783519da14315d47cde34da55496c39546813ef4624c94825e2d69c6a8Victim Addresses
0x99855380e5f48db0a6babeae312b80885a816dceBSCLoss Breakdown
Similar Incidents
RSunTokenLocker Double Withdraw on BNB
43%Blacklist-Zero Double Payout
37%Revamp Reward Double Count
35%Mosca double-withdrawal exploit via helper on BNB
33%KEKESANTA Pair-to-Router Double-Credit Liquidity Drain
32%ZS Pair Burn Drain
32%Root Cause Analysis
Stepp2p Double Withdraw
1. Incident Overview TL;DR
Stepp2p was drained in BSC transaction 0xe94752783519da14315d47cde34da55496c39546813ef4624c94825e2d69c6a8. The attacker flash-borrowed 50,000 USDT, created sale 4796, canceled it to recover the deposited escrow, then immediately called modifySaleOrder(4796, 43782405928285700000000, false) to withdraw the same escrow a second time. After repaying the flash loan plus the 25 USDT fee, the attacker helper contract retained 43757405928285700000000 USDT profit.
The root cause is a stale-state bug in Stepp2p sale lifecycle handling. cancelSaleOrder deactivates the sale but does not clear remaining or totalAmount, and modifySaleOrder does not require sale.active before executing the withdrawal branch. That combination lets the seller double-withdraw canceled escrow.
2. Key Background
Stepp2p escrows seller USDT inside a Sale struct containing seller, totalAmount, remaining, receivedFee, sellFee, and active. A new sale is created by transferring USDT into the contract and recording the amount under a fresh saleId.
At the exploit fork point, the contract state matched the later exploit path: Stepp2p held 43782405928285700000000 USDT, sellFee() was 0, and lastSaleId() was 4795. The attacker therefore needed only temporary capital to create one sale and then exercise the stale canceled state. Pancake V3 flash liquidity provided that temporary funding permissionlessly.
3. Vulnerability Analysis & Root Cause Summary
The bug is an application-level state machine failure in escrow accounting. A canceled sale should be terminal: once the seller is refunded, the canceled sale must not preserve any withdrawable balance. Stepp2p violates that invariant because cancelSaleOrder transfers out sale.remaining and flips sale.active to false, but leaves both sale.remaining and sale.totalAmount intact. The verified source also shows modifySaleOrder checks seller identity and amount bounds, but not sale.active, before executing the subtraction branch. That allows the same seller to call modifySaleOrder(..., false) on a canceled sale and transfer the stale escrow balance out again. The on-chain trace confirms two equal USDT transfers from Stepp2p to the attacker helper: one during cancellation and one during modification.
4. Detailed Root Cause Analysis
The key victim-side logic is the sale lifecycle. The verified Stepp2p source exposes the relevant functions and guards:
struct Sale {
address seller;
uint256 totalAmount;
uint256 remaining;
uint256 receivedFee;
uint256 sellFee;
bool active;
}
function modifySaleOrder(uint256 _saleId, uint256 _modifyAmount, bool _isPositive) external nonReentrant {
// seller and amount checks
// no require(sale.active, ...)
}
function cancelSaleOrder(uint256 _saleId) external nonReentrant {
Sale storage sale = sales[_saleId];
require(sale.remaining > 0 && sale.active, "Invalid sale");
uint256 refundAmount = sale.remaining;
sale.active = false;
// refund transfer, but no zeroing of remaining / totalAmount
}
That logic leaves a stale positive balance after cancellation. The seed trace shows the exact exploit sequence on-chain:
Stepp2p::createSaleOrder(43782405928285700000000)
Stepp2p::cancelSaleOrder(4796)
BEP20USDT::transfer(Stepp2p -> attacker helper, 43782405928285700000000)
Stepp2p::modifySaleOrder(4796, 43782405928285700000000, false)
BEP20USDT::transfer(Stepp2p -> attacker helper, 43782405928285700000000)
The trace storage diffs are consistent with the stale-state hypothesis. cancelSaleOrder changes only the active slot from 1 to 0; it does not clear the slots that held remaining and totalAmount. The subsequent modifySaleOrder call then zeroes those slots while performing a second USDT transfer out of the contract. This is the concrete breakpoint: cancellation finalizes custody but not accounting, and modification trusts stale accounting.
5. Adversary Flow Analysis
The exploit was initiated by EOA 0xd7235d08a48cbd3f63b9faa16130f2fdb50b2341, which called attacker helper contract 0x399eff46b7d458575ebbbb572098e62e38f3c993 in block 54653987. The helper flash-borrowed 50000000000000000000000 USDT from Pancake V3 pool 0x4f31fa980a675570939b737ebdde0471a4be40eb.
Using the borrowed USDT, the helper approved Stepp2p and created sale 4796 for 43782405928285700000000 USDT. The same helper then canceled sale 4796, receiving back the full deposited amount. Because Stepp2p left the canceled sale's accounting untouched, the helper immediately called modifySaleOrder(4796, 43782405928285700000000, false) and withdrew the same amount again. Finally, the helper repaid 50025000000000000000000 USDT to the flash pool and retained the remainder as profit.
6. Impact & Losses
Stepp2p lost exactly 43782405928285700000000 USDT from escrowed seller funds in the seed transaction. The balance diff shows the victim contract balance moved from 43782405928285700000000 to 0, the flash pool gained the 25000000000000000000 USDT fee, and the attacker helper finished with 43757405928285700000000 USDT.
{
"victim_usdt_delta": "-43782405928285700000000",
"attacker_helper_usdt_delta": "43757405928285700000000",
"flash_pool_fee_delta": "25000000000000000000"
}
7. References
- Seed transaction:
0xe94752783519da14315d47cde34da55496c39546813ef4624c94825e2d69c6a8 - Victim contract:
0x99855380e5f48db0a6babeae312b80885a816dce - Flash pool:
0x4f31fa980a675570939b737ebdde0471a4be40eb - Attacker helper:
0x399eff46b7d458575ebbbb572098e62e38f3c993 - Evidence used: seed transaction metadata, verbose execution trace, balance diff, and verified Stepp2p source on BscScan