Calculated from recorded token losses using historical USD prices at the incident time.
0xf3f84ce038442ae4c4dcb6a8ca8bacd7f28c9bdeEthereum0x4585fe77225b41b697c938b018e2ac67ac5a20c0Ethereum0x2260fac5e5542a773aa44fbcfedf7c193bc2c599EthereumAn unprivileged adversary exploited SilicaPools on Ethereum mainnet (chainid 1) in block 22146340 using a custom Silica index contract and a helper contract. Within a single block, the adversary:
decimals value during the Silica pool lifecycle.The core root cause is that SilicaPools trusts an external ISilicaIndex implementation whose decimals field can be changed by an adversary during the pool lifecycle. PoolMaths uses decimals together with shares and balance to compute collateral requirements and payouts, but SilicaPools does not enforce that decimals is immutable or consistent with the units of the index state. A malicious index changes decimals mid‑lifecycle, causing mis‑scaled accounting and enabling undercollateralized payouts to the adversary’s positions.
The adversary cluster achieves at least 2.0 ETH net profit (after gas) in the reference asset ETH, with a conservative upper bound of 0.1 ETH on total gas costs for the three attacker-crafted transactions.
0xf3F84cE038442aE4c4dCB6A8Ca8baCd7F28c9bDe) is an ERC‑1155‑based protocol. Each pool is parameterized by:
0xb8a15efb31211b335e3e2b662cfef4ab0ae8cb5513ec67fa260c95485bad5114ISilicaIndex address.ISilicaIndex interface exposes shares(), balance(), and decimals(). SilicaPools assumes:
decimals correctly describes the scale of the index’s quantities relative to the payout token.shares, balance, and decimals remains consistent across the pool lifecycle.PoolMaths uses the index’s shares(), balance(), and decimals() along with pool parameters to compute required collateral and the balanceChangePerShare value that determines payouts to long and short ERC‑1155 holders.0x9188738a7cA1E4B2af840a77e8726cC6Dcbe7Bdb is unverified and adversary‑controlled. The decompiled code shows:
shares, decimals, and balance.decimals from 31 to 1.decimals value is changed inside the primary exploit transaction.0x80BF7Db69556D9521c03461978B8fC731DBBD4e4 is an unverified dispatcher callable by EOA 0xfde0d1575ed8e06fbf36256bcdfa1f359281455a. It orchestrates:
0xBBBBBbbBBb9cC5e90e3b3Af64bdAF62C37EEFFCb.0x229b8325bb9ac04602898b7e8989998710235d5f.SilicaPools reliance on index.decimals() (verified source for SilicaPools at 0xf3F8…9bDe)
The pool end logic records the ending index state and uses index.decimals() when computing balanceChangePerShare in endPool:
// Collected SilicaPools source, endPool() snippet
function endPool(PoolParams calldata poolParams) public {
bytes32 poolHash = hashPool(poolParams);
PoolState storage sState = sPoolState[poolHash];
ISilicaIndex index = ISilicaIndex(poolParams.index);
// ...
uint256 indexBalanceAtEnd = index.balance();
sState.balanceChangePerShare = uint128(
PoolMaths.balanceChangePerShare(
indexBalanceAtEnd,
sState.indexInitialBalance,
sState.indexShares,
index.decimals(),
poolParams.floor,
poolParams.cap
)
);
// ...
}
Caption: Verified SilicaPools code shows that endPool recomputes balanceChangePerShare using a live call to index.decimals(), rather than pinning the value used at pool start or collateralization.
Custom index with mutable decimals (decompiled index at 0x9188…7Bdb)
// Decompiled index contract (heimdall-rs), key fields
contract DecompiledContract {
address public owner;
uint256 public shares;
uint256 store_d;
uint256 public decimals;
uint256 public balance;
/// @custom:selector 0xa6f9dae1
function changeOwner(address arg0) public payable {
require(arg0 == (address(arg0)));
owner = (address(arg0) * 0x01) | (uint96(owner));
}
/// @custom:selector 0x2ee79ded
function change() public payable {
decimals = 0x01;
}
}
Caption: The adversary-controlled index exposes a public decimals storage variable and a change() function that sets decimals to 1, allowing the adversary to modify the units used by SilicaPools at any point in the pool lifecycle.
Index storage diff showing decimals change during exploit
// State diff for index 0x9188…7Bdb during primary yoink() tx 0x9b9a6d…f0814
{
"storage_diff": {
"0x0000000000000000000000000000000000000000000000000000000000000002": {
"from": "0x000000000000000000000000000000000000000000000000000000000000001f",
"to": "0x0000000000000000000000000000000000000000000000000000000000000001"
}
}
}
Caption: Collected state diff shows storage slot 2 (the decimals field) changing from 31 to 1 during the primary exploit transaction, confirming on‑chain manipulation of index decimals.
SilicaPools trusts an external ISilicaIndex for shares, balance, and decimals at multiple lifecycle steps but does not constrain how the index can change decimals or validate that these values remain consistent with the payout token units. A malicious index can change decimals mid‑lifecycle in a way that breaks PoolMaths’ assumptions and causes undercollateralized or mis‑priced payouts.
collateralizedMint), WBTC collateral is transferred into SilicaPools based on PoolMaths.collateral(...), which depends on the index’s shares and decimals.startPool), SilicaPools records snapshot values:
index.shares().index.balance() as indexInitialBalance.decimals (via PoolMaths behavior during collateralization).endPool), SilicaPools again calls the index:
index.balance() at end.index.decimals() live.balanceChangePerShare via PoolMaths.balanceChangePerShare(...), which uses the current decimals.decimals is a stable configuration parameter describing how index units map to the payout token.shares, balance, and decimals stays consistent from collateralization through pool end.decimals is not pinned at pool start.decimals remains unchanged.0x9188…7Bdb is unverified and controlled by the adversary.decimals variable with an auto‑generated getter.change() function that sets decimals = 1.shares and balance can be set to arbitrary values consistent with the adversary’s strategy.0x9188…7Bdb during the primary yoink() transaction 0x9b9a6d…f0814 shows:
decimals field) changed from 0x1f (31) to 0x01 (1).STATICCALL to index.decimals() before or around pool end computation.decimals = 31.PoolMaths.balanceChangePerShare is computed using decimals = 1.balanceChangePerShare (and therefore long/short payouts) to be computed on a different scale from the one implied by the locked collateral, allowing the adversary’s positions to receive a WBTC‑denominated payout that is too large relative to the WBTC originally deposited.0xf3F8…9bDe):
ISilicaIndex for shares, balance, and decimals without enforcing immutability or consistency of decimals.index.decimals() at pool end for balanceChangePerShare instead of using a pinned value.PoolMaths to compute both collateral and payouts using the same logical units, which are broken by the mutable index.0x9188…7Bdb):
ISilicaIndex.decimals field via change().decimals changing mid‑transaction.0x2260…c599).0xC02a…6Cc2).0xBBBB…FFCb).22146340.0xac6c9ec1b77f3084ac5345813bfa48e4d9cdd67d9309305ce6dfaff69a7cbd11.0x9b9a6dd05526a8a4b40e5e1a74a25df6ecccae6ee7bf045911ad89a1dd3f0814.0xb8a15efb31211b335e3e2b662cfef4ab0ae8cb5513ec67fa260c95485bad5114.Evidence for σ_B:
0xf3F8…9bDe).0x9188…7Bdb).Tx 1 (funding, attacker-crafted)
0xac6c9ec1b77f3084ac5345813bfa48e4d9cdd67d9309305ce6dfaff69a7cbd110x229b8325bb9ac04602898b7e8989998710235d5f.0x80BF7Db69556D9521c03461978B8fC731DBBD4e4.// Funding tx trace summary (debug_traceTransaction callTracer)
{
"result": {
"from": "0x229b8325bb9ac04602898b7e8989998710235d5f",
"to": "0x80bf7db69556d9521c03461978b8fc731dbbd4e4",
"value": "0x29dfee0c2a906076d"
}
}
Caption: Trace for tx 0xac6c9e…bd11 shows a direct ETH transfer from the profit EOA to the helper contract with value 0x29dfee0c2a906076d ≈ 48.2782721821223 ETH.
Tx 2 (primary yoink, attacker-crafted)
0x9b9a6dd05526a8a4b40e5e1a74a25df6ecccae6ee7bf045911ad89a1dd3f08140xfde0d1575ed8e06fbf36256bcdfa1f359281455a.0x80BF7Db69556D9521c03461978B8fC731DBBD4e4.Key on‑chain behaviors:
0xBBBB…FFCb sends 1e9 units of WBTC to the helper.index.shares(), index.balance(), and index.decimals(), and later endPool computes balanceChangePerShare.0x9188…7Bdb changes its decimals from 31 to 1 via change(), as seen in the storage diff.// Excerpt from call trace of primary yoink() tx 0x9b9a6d…f0814
{
"calls": [
{
"from": "0x80bf7db69556d9521c03461978b8fc731dbbd4e4",
"to": "0x2260fac5e5542a773aa44fbcfedf7c193bc2c599",
"input": "0x095ea7b3…", // WBTC approve SilicaPools
"type": "CALL"
},
{
"from": "0xbbbbbbbbbb9cc5e90e3b3af64bdaf62c37eeffcb",
"to": "0x2260fac5e5542a773aa44fbcfedf7c193bc2c599",
"input": "0xa9059cbb…", // Flashloan provider transfers WBTC to helper
"type": "CALL"
},
{
"from": "0xf3f84ce038442ae4c4dcb6a8ca8bacd7f28c9bde",
"to": "0x9188738a7ca1e4b2af840a77e8726cc6dcbe7bdb"
Caption: Primary yoink() call trace shows WBTC approvals and transfers, a flashloan from 0xBBBB…FFCb, and a STATICCALL to the malicious index’s decimals() function as part of the SilicaPools pool computation.
Tx 3 (second yoink, attacker-crafted, same block)
0xb8a15efb31211b335e3e2b662cfef4ab0ae8cb5513ec67fa260c95485bad51140xfde0d1575ed8e06fbf36256bcdfa1f359281455a.0x80BF7Db69556D9521c03461978B8fC731DBBD4e4.// Excerpt from call trace of second yoink() tx 0xb8a15e…d5114
{
"calls": [
{
"from": "0x4585fe77225b41b697c938b018e2ac67ac5a20c0",
"to": "0x2260fac5e5542a773aa44fbcfedf7c193bc2c599",
"input": "0xa9059cbb…", // WBTC transfer to helper
"type": "CALL"
},
{
"from": "0x80bf7db69556d9521c03461978b8fc731dbbd4e4",
"to": "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2",
"input"
0x229b8325bb9ac04602898b7e8989998710235d5f
0x80BF7Db69556D9521c03461978B8fC731DBBD4e4
0xfde0d1575ed8e06fbf36256bcdfa1f359281455a
0x9188738a7cA1E4B2af840a77e8726cC6Dcbe7Bdb
ISilicaIndex.decimals, enabling the mis‑scaling exploit.These entities form the adversary cluster used in the ACT profit calculation.
Adversary initial funding
0xac6c9e…bd11.0x229b…d5f5 transfers exactly 48.278272182122317677 ETH to the helper contract 0x80BF…d4e4.Adversary tx execution (primary yoink)
0x9b9a6d…f0814.0xfde0…455a invokes the helper contract.1e9‑unit WBTC flashloan from 0xBBBB…FFCb.decimals.balanceChangePerShare due to the decimals change from 31 to 1.50.834053044409908846 ETH to profit EOA 0x229b…d5f5.0.050884937982393301 ETH on the helper contract.Adversary repeated execution (second yoink)
0xb8a15e…d5114.0xfde0…455a again calls the helper contract.0x229b8325bb9ac04602898b7e8989998710235d5f.The analysis considers the adversary cluster:
0x229b…d5f5 (profit receiver and funder).0x80BF…d4e4.0xfde0…455a (tx origin for yoink()).Using balance_diff.json and balance_diff_extended.json for the primary yoink() transaction 0x9b9a6d…f0814:
// Seed balance diff for 0x9b9a6d…f0814 (native ETH)
{
"native_balance_deltas": [
{
"address": "0x80bf7db69556d9521c03461978b8fc731dbbd4e4",
"delta_wei": "50884937982393301"
},
{
"address": "0xfde0d1575ed8e06fbf36256bcdfa1f359281455a",
"delta_wei": "-476756366117580"
},
{
"address": "0x229b8325bb9ac04602898b7e8989998710235d5f",
"delta_wei": "50834053044409908846"
}
]
}
Caption: Native balance diffs for the primary yoink() tx show ETH gains for the helper contract and profit EOA, and a small gas payment loss for the sender EOA.
ETH‑denominated changes in the primary yoink() tx:
0x229b…d5f5: +50.834053044409908846 ETH.0x80BF…d4e4: +0.050884937982393301 ETH.0xfde0…455a: -0.00047675636611758 ETH (gas).In the funding transaction 0xac6c9e…bd11:
0x229b…d5f5 sends exactly 48.278272182122317677 ETH to the helper contract 0x80BF…d4e4.Aggregate over the adversary cluster for the funding and primary yoink() transactions:
50.834053044409908846 (profit EOA gain)+ 0.050884937982393301 (helper gain)− 48.278272182122317677 (funding outflow)− 0.00047675636611758 (yoink() gas)2.606188 ETH gross profit.Gas costs for the other attacker‑crafted transactions (funding and second yoink()) are also included in the conservative bound:
1,000,000.30 gwei.< 0.1 ETH.Subtracting the conservative upper bound of 0.1 ETH gas from the ≈ 2.606188 ETH gross gain yields:
2.5 ETH.The analysis conservatively asserts:
>= 2.0 ETH for the adversary cluster after all fees.This satisfies the ACT profit predicate: the adversary can deterministically achieve strictly positive ETH‑denominated profit from state σ_B by executing the transaction sequence b.
2.0 ETH net of gas, based on:
< 0.1 ETH upper bound on total gas fees for all three attacker‑crafted transactions.2.0 ETH net of gas, establishing a clear economic loss to SilicaPools and its users.Key artifacts underlying this analysis:
0x9b9a6d…f0814 metadata and callTracer trace.SilicaPools.sol at 0xf3F84cE038442aE4c4dCB6A8Ca8baCd7F28c9bDe.0x9188738a7cA1E4B2af840a77e8726cC6Dcbe7Bdb, showing mutable decimals behavior.callTracer output for 0x9b9a6d…f0814, covering flashloan, SilicaPools, index, WBTC/WETH pool, and WETH9 interactions.callTracer outputs for:
0xac6c9e…bd11.0xb8a15e…d5114.Caption: Second yoink() trace shows additional WBTC movements and WETH interactions via the helper contract, mirroring the primary exploit pattern and generating extra ETH profit.