Root Cause
The FundsModule contract on Base accepted payout amounts directly from caller-supplied contract addresses without confirming that the corresponding value had first been transferred into the module. Any address could be passed as a "payout contract", and the module would faithfully pay out from its own pooled ETH and USDC based on whatever amount that contract reported.
The vulnerable function — identified by selector 0x9d2edfac — performed no authentication of the supplied payout-contract address and no balance-delta check before releasing funds.
// Conceptual approximation of the vulnerable logic (not verbatim)
function processPayout(address payoutContract) external {
uint256 amount = IPayoutContract(payoutContract).getPayoutAmount();
// ❌ No check that payoutContract transferred `amount` into this module
_pool.transfer(msg.sender, amount);
}
This is a classic "check-effects-interactions" / input-validation failure: the contract trusted an externally-supplied value from an untrusted source and acted on it before validating economic invariants.
Attack Steps
| Step | Description |
|---|---|
| 1 | Attacker deploys a malicious contract that returns an arbitrarily large getPayoutAmount() value |
| 2 | Attacker calls FundsModule.0x9d2edfac(maliciousContract) |
| 3 | FundsModule queries maliciousContract.getPayoutAmount(), which returns a large number |
| 4 | FundsModule transfers that amount from its pooled ETH/USDC reserves to the attacker |
| 5 | Steps 1-4 repeated until ~$41,000 in value is drained |
The exploit transaction was flagged by TenArmor and independently analyzed by SlowMist, both placing net losses at approximately $41,000.
Impact
- Chain: Base (Ethereum L2)
- Protocol: Slice Protocol — an onchain commerce / dispute-resolution protocol
- Loss: ~$41,000 USD (ETH + USDC from pooled FundsModule reserves)
- Attacker address (unverified secondary source):
0xc34572ebb26b9a4c54854aae8d2347108e8c892f - FundsModule / Slice Core:
0xD8A10bD25e0E5dAD717372fA0C66d3a59a425e4D(Base Mainnet)
Lessons for Auditors
- Validate economic invariants before state changes. Any function that pays out from a shared pool must verify that the pool balance increased by at least the payout amount before releasing funds — either via a balance delta check or by requiring the caller to transfer tokens in the same transaction.
- Never trust caller-supplied contract addresses for pricing or amounts. Accepting an arbitrary
IPayoutContractinterface and calling into it for a monetary amount is equivalent to letting the caller set their own withdrawal amount. - Restrict permissioned roles. If only approved payout contracts should be callable, maintain an allowlist and validate membership before interacting.
- Fuzz-test financial invariants. A property like
poolBalance_after >= poolBalance_before - authorizedPayoutshould hold for all inputs and is straightforwardly expressible as a Foundry invariant test.