BriVault

First Flight #52
Beginner FriendlySolidity
100 EXP
View results
Submission Details
Impact: high
Likelihood: high
Invalid

Flash‑loan / last‑minute deposit griefing (share dilution / sandwiching)

Root + Impact

Description

  • The vault allows deposits up until owner finalization and computes winners’ payouts from live totals (or from totals taken at finalization without a protected deposit deadline or snapshot). This permits an attacker to perform very large last‑minute deposits (including via flash loans) to manipulate the per‑team share ratios used for payout calculation, causing honest winners to be diluted or enabling the attacker to extract value.

// Root cause in the codebase with @> marks to highlight the relevant section
pragma solidity ^0.8.0;
interface IERC20 { function transferFrom(address, address, uint256) external returns (bool); }
contract SnapshotlessVaultDescription {
IERC20 public asset;
mapping(uint256 => uint256) public teamAssets;
mapping(address => uint256) public userTeam;
mapping(address => uint256) public userShares;
bool public winnerSet;
uint256 public winnerTeam;
// Deposits allowed at any time (no depositDeadline/snapshot)
function deposit(uint256 amount, uint256 teamId) external {
@> asset.transferFrom(msg.sender, address(this), amount); // deposit can occur anytime
@> teamAssets[teamId] += amount; // team totals immediately updated
@> userShares[msg.sender] += amount;
@> userTeam[msg.sender] = teamId;
}
// Owner finalizes winner and payouts computed using current totals (no protected snapshot)
function setWinner(uint256 teamId) external /*onlyOwner*/ {
@> winnerTeam = teamId;
@> winnerSet = true;
}
// Payout uses teamAssets[winnerTeam] / totalAssets() at withdrawal time
function payoutShare(address user) public view returns (uint256) {
// uses live totals (or totals captured without proper cutoff)
// this is the problematic computation that can be manipulated by last-minute deposits
uint256 total = teamAssets[0] + teamAssets[1]; // simplified
return userShares[user] * teamAssets[winnerTeam] / total;
}
}

Risk

Likelihood:

  • The issue occurs whenever deposits remain allowed up to the moment the owner finalizes the winner, because an attacker can deposit immediately before finalization.

  • It occurs whenever the contract computes payouts from totals that are not snapshotted before a deposit cutoff, allowing recent deposits to change payout denominators.

Impact:

  • Impact 1: Honest winners receive a smaller share of the prize pool because the attacker’s last‑minute deposits change the pool ratios — resulting in loss of expected winnings.

  • Impact 2: Attackers can profit with minimal capital (flash‑loan) or cause significant griefing to participants, undermining fairness and user trust.

Proof of Concept

Explanation:
Deposits are accepted with immediate effect right up to finalization, and the payout formula uses team/total amounts that are influenced by those deposits. Without a deposit cutoff or snapshot, an attacker can change teamAssets or total just before setWinner or withdrawal, manipulating payoutShare().

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
/// Very concise PoC demonstrating last-minute deposit manipulation
contract SimpleToken {
mapping(address => uint256) public balanceOf;
function mint(address to, uint256 amt) external { balanceOf[to] += amt; }
function transferFrom(address from, address to, uint256 amt) external returns (bool) {
require(balanceOf[from] >= amt);
balanceOf[from] -= amt;
balanceOf[to] += amt;
return true;
}
function transfer(address to, uint256 amt) external returns (bool) {
require(balanceOf[msg.sender] >= amt);
balanceOf[msg.sender] -= amt;
balanceOf[to] += amt;
return true;
}
}
contract SnapshotlessVault {
SimpleToken public token;
mapping(uint256 => uint256) public teamAssets;
mapping(address => uint256) public shares;
mapping(address => uint256) public userTeam;
uint256 public winnerTeam;
bool public winnerSet;
constructor(SimpleToken _t) { token = _t; }
function deposit(uint256 amt, uint256 teamId) external {
require(token.transferFrom(msg.sender, address(this), amt));
shares[msg.sender] += amt;
userTeam[msg.sender] = teamId;
teamAssets[teamId] += amt;
}
// public here for PoC to simulate collusion/timing
function setWinner(uint256 teamId) external {
winnerTeam = teamId;
winnerSet = true;
}
function withdrawWinnings() external {
require(winnerSet && userTeam[msg.sender] == winnerTeam, "no winnings");
uint256 total = teamAssets[0] + teamAssets[1];
uint256 payout = shares[msg.sender] * teamAssets[winnerTeam] / total;
require(token.transfer(msg.sender, payout));
}
}
contract Attacker {
SnapshotlessVault public vault;
SimpleToken public token;
constructor(SnapshotlessVault _v, SimpleToken _t) { vault = _v; token = _t; }
// attacker mints large amount and deposits immediately before finalization
function flashDepositAndExploit(uint256 amt, uint256 teamId) external {
token.mint(address(this), amt);
token.transferFrom(address(this), address(vault), amt);
vault.deposit(amt, teamId);
// assume attacker or colluding finalizer calls setWinner(...) now
// later attacker calls withdrawWinnings() to capture manipulated share
}
}

Recommended Mitigation

Set a deposit deadline so no new deposits can be made immediately before finalization. Take an immutable snapshot of team and total assets at the time the winner is finalized, and use these snapshots for payout calculations. This prevents last-minute or flash-loan deposits from manipulating payout ratios and ensures fair distribution.

- // deposits allowed until finalization; payouts use live totals
- function deposit(uint256 amount, uint256 teamId) external { ... }
- function setWinner(uint256 teamId) external { winnerTeam = teamId; winnerSet = true; }
- function payoutShare(address user) public view returns (uint256) { /* uses live totals */ }
+ uint256 public depositDeadline;
+ bool public snapshotTaken;
+ mapping(uint256 => uint256) public teamAssetsSnapshot;
+ uint256 public totalAssetsSnapshot;
+
+ modifier beforeDepositDeadline() { require(block.timestamp < depositDeadline, "deposits closed"); _; }
+ function deposit(uint256 amount, uint256 teamId) external beforeDepositDeadline { ... }
+ function setWinner(uint256 teamId) external { require(!snapshotTaken); teamAssetsSnapshot[...] = ...; totalAssetsSnapshot = ...; snapshotTaken = true; winnerTeam = teamId; }
+ function payoutShare(address user) public view returns (uint256) { require(snapshotTaken); return userShares[user] * teamAssetsSnapshot[winnerTeam] / totalAssetsSnapshot; }
Updates

Appeal created

bube Lead Judge 19 days ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

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

Give us feedback!